From 3728bb45ffed461975501177f560f9afd7f6873e Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 2 Oct 2026 16:24:47 -0500 Subject: [PATCH] Keep native permissions answerable after steering Co-Authored-By: Paperclip --- .../2026-10-02-pi-production-readiness.md | 18 ++++++++ ui/src/components/TaskChatThread.test.tsx | 29 ++++++++++++ ui/src/components/TaskChatThread.tsx | 30 +++++++++---- .../task-chat/transcript-adapter.test.ts | 21 +++++++++ .../task-chat/transcript-adapter.ts | 44 +++++++++++++++++++ 5 files changed, 133 insertions(+), 9 deletions(-) diff --git a/doc/plans/2026-10-02-pi-production-readiness.md b/doc/plans/2026-10-02-pi-production-readiness.md index d53aefaaf24..f2aed4ae5fe 100644 --- a/doc/plans/2026-10-02-pi-production-readiness.md +++ b/doc/plans/2026-10-02-pi-production-readiness.md @@ -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 diff --git a/ui/src/components/TaskChatThread.test.tsx b/ui/src/components/TaskChatThread.test.tsx index fa4a17ed06c..9fb2fbae118 100644 --- a/ui/src/components/TaskChatThread.test.tsx +++ b/ui/src/components/TaskChatThread.test.tsx @@ -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( {}} 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("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", [ { diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index 51a027fd5ab..14841620f2f 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -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; diff --git a/ui/src/components/task-chat/transcript-adapter.test.ts b/ui/src/components/task-chat/transcript-adapter.test.ts index 455055c04a7..d123b2ee2e4 100644 --- a/ui/src/components/task-chat/transcript-adapter.test.ts +++ b/ui/src/components/task-chat/transcript-adapter.test.ts @@ -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 = diff --git a/ui/src/components/task-chat/transcript-adapter.ts b/ui/src/components/task-chat/transcript-adapter.ts index 999cf904cbf..607462f57da 100644 --- a/ui/src/components/task-chat/transcript-adapter.ts +++ b/ui/src/components/task-chat/transcript-adapter.ts @@ -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(); + for (const entry of requests) { + if (!origins.has(entry.requestId)) origins.set(entry.requestId, entry); + } + const context = new Map(); + 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, + carryPending = false, +): TaskChatItem[] { + const owned = new Set(entries); + const retained = new Set(); + const result = items.flatMap(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