Keep native permissions answerable after steering

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-02 16:24:47 -05:00
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
+29
View File
@@ -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", [
{
+21 -9
View File
@@ -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