mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-19 01:46:15 +02:00
Keep native permissions answerable after steering
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
342287678f
commit
3728bb45ff
5 files changed
+133
-9
No files matched your search
@@ -45,6 +45,24 @@ prices are estimates. Unpriced usage must stay unpriced in the ledger and UI.
|
||||
|
||||
## Evidence so far
|
||||
|
||||
- At `342287678`, normal public profile-14 admission passes on ARM Mac in
|
||||
23.8 seconds and Intel Mac in 53.5 seconds. Fresh installed steering returns
|
||||
HTTP 200 and retains one exact correlation-bound acknowledgement. The full
|
||||
journey fails at Deny: steering splits the transcript display and the UI
|
||||
incorrectly marks the older, still-live permission cancelled. A real widget
|
||||
regression reproduces the missing button. The correction derives request
|
||||
state from the whole run, carries pending cards to the live tail, and keeps
|
||||
closed cards at their original position. All 281 focused UI/projection and
|
||||
performance checks, UI typecheck and token gates pass. The paid failure and
|
||||
cleanup grade remain unchanged; all seven journalled owned processes later
|
||||
retire. A new installed journey must verify actual denial and consumption.
|
||||
- Full workspace build and typecheck pass at `342287678`; core native tests
|
||||
pass 333 cases. The broader native integration run fails one Codex interrupt
|
||||
recovery case with `provider startup ownership remains unadmitted`; isolated
|
||||
diagnosis reproduces it. Do not count that broader run as passing. Current
|
||||
CI passes its other checks and Greptile is 5/5, but Linux Canary retains
|
||||
`session_handshake_timeout`. The exact-source local image build cannot reach
|
||||
the Docker daemon and produces no usable image. Linux and Daytona remain held.
|
||||
- At `36e712104`, installed steering returns HTTP 200 and the native command
|
||||
journal records accepted delivery. The public API retains the correct
|
||||
correlation-bound acknowledgement at source sequence 65 and also a
|
||||
|
||||
@@ -1934,6 +1934,35 @@ describe("TaskChatThread runtime transcript selection", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a pending native permission answerable after same-turn steering", async () => {
|
||||
const runId = "native-pending-steered";
|
||||
nativeTranscriptState.transcriptByRun.set(runId, [
|
||||
{ kind: "runtime_request", ts: "2026-08-25T18:00:01.000Z", requestId: "permission-1",
|
||||
requestKind: "permission_approval", turnId: "provider-turn-1", requestType: "permission", status: "pending",
|
||||
prompt: "Pi write", choices: [{ key: "decline", label: "Deny" }], fields: [] },
|
||||
{ kind: "assistant", ts: "2026-08-25T18:00:03.000Z", text: "Steering acknowledged.", channel: "progress" },
|
||||
]);
|
||||
const resolve = vi.spyOn(heartbeatsApi, "resolveRuntimeRequest").mockResolvedValue({} as never);
|
||||
render(<TaskChatThread comments={[{
|
||||
id: "pending-steering-comment", companyId: "company-1", issueId: "issue-1", authorType: "user",
|
||||
authorAgentId: null, authorUserId: "user-1", body: "Change course.", presentation: null, metadata: null,
|
||||
runId: null, consumedByRunId: runId, steeredIntoRunId: runId,
|
||||
conversationAnchorAt: new Date("2026-08-25T18:00:02.000Z"),
|
||||
createdAt: new Date("2026-08-25T18:00:02.000Z"), updatedAt: new Date("2026-08-25T18:00:02.000Z"),
|
||||
}]} onAdd={async () => {}} issueStatus="in_progress" activeRun={{
|
||||
id: runId, runtimeMode: "native", status: "running", invocationSource: "issue", triggerDetail: null,
|
||||
startedAt: "2026-08-25T18:00:00.000Z", finishedAt: null, createdAt: "2026-08-25T18:00:00.000Z",
|
||||
agentId: "agent-1", agentName: "Runner", adapterType: "paperclip_runner",
|
||||
}} />);
|
||||
const deny = Array.from(container.querySelectorAll<HTMLButtonElement>("button")).filter(button => button.textContent === "Deny");
|
||||
expect(deny).toHaveLength(1);
|
||||
expect(deny[0].disabled).toBe(false);
|
||||
expect(container.textContent).not.toContain("Cancelled");
|
||||
await act(async () => deny[0].click());
|
||||
expect(resolve).toHaveBeenCalledWith({ runId, requestId: "permission-1", turnId: "provider-turn-1",
|
||||
requestKind: "permission_approval", resolution: { action: "decline" } });
|
||||
});
|
||||
|
||||
it("labels the live tail as a continuation after the steering bubble", () => {
|
||||
nativeTranscriptState.transcriptByRun.set("native-live-steered", [
|
||||
{
|
||||
|
||||
@@ -51,6 +51,8 @@ import {
|
||||
settledRunChildren,
|
||||
splitTranscriptAtAnchors,
|
||||
transcriptToTaskChatItems,
|
||||
runtimeRequestSegmentContext,
|
||||
reconcileSegmentRuntimeRequests,
|
||||
type SettledTurnMergeMeta,
|
||||
} from "@/components/task-chat/transcript-adapter";
|
||||
import { TaskChatDescriptionBubble } from "@/components/task-chat/TaskChatDescriptionBubble";
|
||||
@@ -1833,12 +1835,15 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
startSlotMs,
|
||||
timelineAnchors,
|
||||
);
|
||||
const runtimeRequests = runtimeRequestSegmentContext(entries, {
|
||||
runId: source.id, agentName: meta?.agentName, running: false,
|
||||
});
|
||||
const projectedSegments = segments.map((segment) => {
|
||||
const parsedTranscript = transcriptToTaskChatItems(segment.entries, {
|
||||
const parsedTranscript = reconcileSegmentRuntimeRequests(transcriptToTaskChatItems(segment.entries, {
|
||||
runId: source.id,
|
||||
agentName: meta?.agentName,
|
||||
running: false,
|
||||
});
|
||||
}), segment.entries, runtimeRequests);
|
||||
const parsed =
|
||||
source.id === planDocumentSourceRunId && planTurnItem
|
||||
? embedPlanDocumentAtWriteBoundary(parsedTranscript, planTurnItem)
|
||||
@@ -1979,13 +1984,16 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
startSlotMs,
|
||||
timelineAnchors,
|
||||
);
|
||||
const runtimeRequests = runtimeRequestSegmentContext(entries, {
|
||||
runId: liveRun.id, agentName: liveRun.agentName, running: true,
|
||||
});
|
||||
for (const [segmentIndex, segment] of segments.slice(0, -1).entries()) {
|
||||
if (segment.entries.length === 0) continue;
|
||||
const parsedTranscript = transcriptToTaskChatItems(segment.entries, {
|
||||
const parsedTranscript = reconcileSegmentRuntimeRequests(transcriptToTaskChatItems(segment.entries, {
|
||||
runId: liveRun.id,
|
||||
agentName: liveRun.agentName,
|
||||
running: false,
|
||||
});
|
||||
}), segment.entries, runtimeRequests);
|
||||
const parsed =
|
||||
liveRun.id === planDocumentSourceRunId && planTurnItem
|
||||
? embedPlanDocumentAtWriteBoundary(parsedTranscript, planTurnItem)
|
||||
@@ -2212,7 +2220,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
// A fresh array is produced on every render. Track every presentation field
|
||||
// that can change without changing the entry count or text length so channel,
|
||||
// lifecycle, result status, and usage-only updates invalidate the projection.
|
||||
const tailEntryContentKey = tailEntries.reduce((key, entry) => {
|
||||
const tailEntryContentKey = tailAllEntries.reduce((key, entry) => {
|
||||
const textIdentity = "text" in entry ? entry.text : "";
|
||||
const contentIdentity = "content" in entry ? entry.content : "";
|
||||
const channelIdentity = "channel" in entry ? (entry.channel ?? "") : "";
|
||||
@@ -2224,8 +2232,9 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
entry.kind === "result"
|
||||
? `${entry.subtype}:${entry.inputTokens}:${entry.outputTokens}:${entry.cachedTokens}:${entry.costUsd}`
|
||||
: "";
|
||||
return `${key}|${entry.kind}:${channelIdentity}:${lifecycleIdentity}:${statusIdentity}:${usageIdentity}:${textIdentity}:${contentIdentity}`;
|
||||
}, String(tailEntries.length));
|
||||
const requestIdentity = entry.kind === "runtime_request" ? JSON.stringify(entry) : "";
|
||||
return `${key}|${entry.kind}:${channelIdentity}:${lifecycleIdentity}:${statusIdentity}:${usageIdentity}:${textIdentity}:${contentIdentity}:${requestIdentity}`;
|
||||
}, String(tailAllEntries.length));
|
||||
const tailContentKey = `${tailSegmentStartMs ?? "run-start"}:${tailEntryContentKey}`;
|
||||
const blockerContentKey = blockerLinks
|
||||
? `${blockerLinks.directBlocker.id}:${blockerLinks.ultimateBlocker?.id ?? ""}`
|
||||
@@ -2302,11 +2311,14 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
const tailItems = useMemo(
|
||||
() => {
|
||||
if (!tailRunId) return [];
|
||||
const parsed = transcriptToTaskChatItems(tailEntries, {
|
||||
const runtimeRequests = runtimeRequestSegmentContext(tailAllEntries, {
|
||||
runId: tailRunId, agentName: tailAgentName, running: tailStreaming,
|
||||
});
|
||||
const parsed = reconcileSegmentRuntimeRequests(transcriptToTaskChatItems(tailEntries, {
|
||||
runId: tailRunId,
|
||||
agentName: tailAgentName,
|
||||
running: tailStreaming,
|
||||
});
|
||||
}), tailEntries, runtimeRequests, tailStreaming);
|
||||
return tailPlanItem
|
||||
? embedPlanDocumentAtWriteBoundary(parsed, tailPlanItem)
|
||||
: parsed;
|
||||
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
splitTranscriptAtAnchors,
|
||||
toolDisplayName,
|
||||
transcriptToTaskChatItems,
|
||||
runtimeRequestSegmentContext,
|
||||
reconcileSegmentRuntimeRequests,
|
||||
type SettledTurnMergeMeta,
|
||||
type ThreadBackboneEntry,
|
||||
} from "./transcript-adapter";
|
||||
@@ -37,6 +39,25 @@ import { nativeRunEventsToTranscript } from "../transcript/native-run-events";
|
||||
|
||||
const TS = "2026-07-31T12:00:00.000Z";
|
||||
|
||||
describe("runtime requests across steering sections", () => {
|
||||
it.each(["pending", "resolved", "cancelled"] as const)("keeps one %s permission under whole-run authority", status => {
|
||||
const request: TranscriptEntry = { kind: "runtime_request", ts: TS, requestId: "permission-1",
|
||||
requestKind: "permission_approval", turnId: "provider-turn", requestType: "permission", status: "pending",
|
||||
prompt: "Pi write", choices: [{ key: "decline", label: "Deny" }], fields: [] };
|
||||
const after: TranscriptEntry[] = [{ kind: "assistant", ts: "2026-07-31T12:00:01.000Z", text: "Continued.", channel: "progress" }];
|
||||
if (status === "resolved") after.push({ ...request, ts: "2026-07-31T12:00:02.000Z", status, resolvedAction: "decline" });
|
||||
const entries = [request, ...after];
|
||||
const options = { runId: "run-steering", running: status !== "cancelled" };
|
||||
const context = runtimeRequestSegmentContext(entries, options);
|
||||
const history = reconcileSegmentRuntimeRequests(transcriptToTaskChatItems([request], { ...options, running: false }), [request], context);
|
||||
const tail = reconcileSegmentRuntimeRequests(transcriptToTaskChatItems(after, options), after, context, options.running);
|
||||
const cards = [...history, ...tail].filter(item => item.kind === "protocol" && item.surface === "runtime_request");
|
||||
expect(cards).toEqual([expect.objectContaining({ requestId: "permission-1", turnId: "provider-turn", status })]);
|
||||
expect(history.filter(item => item.kind === "protocol")).toHaveLength(status === "pending" ? 0 : 1);
|
||||
expect(tail.filter(item => item.kind === "protocol")).toHaveLength(status === "pending" ? 1 : 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("accepted native response-wake answers", () => {
|
||||
const runId = "native-response-wake";
|
||||
const summary =
|
||||
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
TaskChatRunResultItem,
|
||||
TaskChatMessageItem,
|
||||
TaskChatPlanDocumentItem,
|
||||
TaskChatRuntimeRequestItem,
|
||||
} from "./task-chat-model";
|
||||
import {
|
||||
humanizeToolName,
|
||||
@@ -570,6 +571,49 @@ interface TranscriptAdapterOptions {
|
||||
running: boolean;
|
||||
}
|
||||
|
||||
/** Request authority follows the whole run, even when steering splits its display. */
|
||||
export function runtimeRequestSegmentContext(
|
||||
entries: readonly TranscriptEntry[],
|
||||
options: TranscriptAdapterOptions,
|
||||
) {
|
||||
const requests = entries.filter(entry => entry.kind === "runtime_request");
|
||||
const origins = new Map<string, TranscriptEntry>();
|
||||
for (const entry of requests) {
|
||||
if (!origins.has(entry.requestId)) origins.set(entry.requestId, entry);
|
||||
}
|
||||
const context = new Map<string, { item: TaskChatRuntimeRequestItem; origin: TranscriptEntry }>();
|
||||
for (const item of transcriptToTaskChatItems(requests, options)) {
|
||||
if (item.kind === "protocol" && item.surface === "runtime_request") {
|
||||
context.set(item.id, { item, origin: origins.get(item.requestId)! });
|
||||
}
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
/** Carry live requests to the tail; retain closed cards at their original position. */
|
||||
export function reconcileSegmentRuntimeRequests(
|
||||
items: readonly TaskChatItem[],
|
||||
entries: readonly TranscriptEntry[],
|
||||
context: ReturnType<typeof runtimeRequestSegmentContext>,
|
||||
carryPending = false,
|
||||
): TaskChatItem[] {
|
||||
const owned = new Set(entries);
|
||||
const retained = new Set<string>();
|
||||
const result = items.flatMap<TaskChatItem>(item => {
|
||||
if (item.kind !== "protocol" || item.surface !== "runtime_request") return [item];
|
||||
const state = context.get(item.id);
|
||||
if (!state || (state.item.status === "pending" ? !carryPending : !owned.has(state.origin))) return [];
|
||||
retained.add(item.id);
|
||||
return [state.item];
|
||||
});
|
||||
if (carryPending) {
|
||||
for (const { item } of context.values()) {
|
||||
if (item.status === "pending" && !retained.has(item.id)) result.push(item);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce a transcript into ordered items, coalescing consecutive thinking and
|
||||
* assistant deltas and threading tool_result/diff onto their tool_call. Mirrors
|
||||
|
||||
Reference in new issue
Block a user