From 5c9ae3f8563052e17dd186fa4f2cececf972ce1f Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 1 Oct 2026 22:22:31 -0500 Subject: [PATCH 1/2] test(runner): align held Pi promotion assertions with profile 12 Co-Authored-By: Paperclip --- packages/paperclip-runner/src/live/live-session.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/paperclip-runner/src/live/live-session.test.ts b/packages/paperclip-runner/src/live/live-session.test.ts index bfb415d3fa..cbb9d7b220 100644 --- a/packages/paperclip-runner/src/live/live-session.test.ts +++ b/packages/paperclip-runner/src/live/live-session.test.ts @@ -43,8 +43,8 @@ it("admits Pi live sessions without candidate opt-in while preserving the exact const session = await service.create({ provider: "acpx", acpxAgent: "pi", requestedModel: "openrouter/deepseek/deepseek-v4-flash-0731" }); try { expect(session.snapshot().config.acpxProfile).toMatchObject({ - agent: "pi", agentProfileVersion: 11, - commandDigest: "sha256:5e276f48c8a87b3e6165369faac62d3925282c84b98934575b1b7b97ad50b309", + agent: "pi", agentProfileVersion: 12, + commandDigest: "sha256:47306e6d2a9b59e8f9189f725ebb7a0a7f91826044d1739e1a35ab31f228ba1f", }); await expect(service.create({ provider: "acpx", acpxAgent: "pi", requestedModel: "another-model" })) .rejects.toThrow("requires exact model"); From 95a30b7ffe865fa9b8878d0652e0ed5d370f1c80 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 5 Oct 2026 12:58:32 -0500 Subject: [PATCH 2/2] fix(ui): coalesce bound duplicate Pi failure notices Preserve the original run log while showing one consecutive failure row for the same run, turn, session, and notice payload. Different bindings, messages, and intervening activity remain distinct. Co-Authored-By: Paperclip --- .../adapters/paperclip-runner/index.test.ts | 88 +++++++++++++++++++ ui/src/adapters/paperclip-runner/index.ts | 31 ++++++- 2 files changed, 117 insertions(+), 2 deletions(-) diff --git a/ui/src/adapters/paperclip-runner/index.test.ts b/ui/src/adapters/paperclip-runner/index.test.ts index 0f447cb4fe..df5fdfd9f5 100644 --- a/ui/src/adapters/paperclip-runner/index.test.ts +++ b/ui/src/adapters/paperclip-runner/index.test.ts @@ -1,7 +1,95 @@ import { describe, expect, it } from "vitest"; import { paperclipRunnerUIAdapter } from "./index"; +import { createPiProfileExtensionAdapter, PI_NOTICE_METHOD } from "../../../../packages/paperclip-runner/src/drivers/acpx/pi-extension-adapter"; +import { transcriptToTaskChatItems } from "../../components/task-chat/transcript-adapter"; + +const piFailure = (overrides: Record = {}, eventOverrides: Record = {}) => ({ + type: "paperclip.prp.event", + event: { + runId: "run-1", turnId: "turn-1", normalizedSessionId: "session-1", + eventType: "provider.notice.recorded", + payload: { + schema: "paperclip.provider.notice.v1", noticeId: "notice-1", category: "pi.runtime_failure", + severity: "error", summary: "Pi process exited with code 4", scope: "session", + recoverable: false, userActionable: false, + details: [{ name: "source.method", value: PI_NOTICE_METHOD }, + { name: "source.nativeEvent", value: "runtime_failure" }, { name: "source.sessionId", value: "session-1" }, + { name: "reason", value: "native_process_exited" }], + ...overrides, + }, + ...eventOverrides, + }, +}); describe("paperclip runner transcript projection", () => { + it("keeps both pinned Pi failure facts while rendering one consecutive diagnostic", async () => { + const adapter = createPiProfileExtensionAdapter({ workspacePath: "/fixture", sessionId: "session-1", turnId: "turn-1" }); + const notice = { sessionId: "session-1", category: "runtime_failure", severity: "error", + summary: "Pi process exited with code 4", details: { reason: "native_process_exited" } }; + const raw = [...await adapter.notification(PI_NOTICE_METHOD, notice), + ...await adapter.notification(PI_NOTICE_METHOD, structuredClone(notice))]; + expect(raw).toHaveLength(2); + expect(raw[0]!.payload.noticeId).not.toBe(raw[1]!.payload.noticeId); + const before = structuredClone(raw); + const parse = paperclipRunnerUIAdapter.createStdoutParser!().parseLine; + const entries = raw.flatMap(event => parse(JSON.stringify({ type: "paperclip.prp.event", event: { + runId: "run-1", normalizedSessionId: "session-1", turnId: "turn-1", ...event, + } }), "2026-10-03T07:00:00.000Z")); + expect(entries).toHaveLength(1); + expect(transcriptToTaskChatItems(entries, { runId: "run-1", agentName: "Pi", running: false })) + .toEqual([expect.objectContaining({ kind: "protocol", family: "provider_notice", + details: expect.arrayContaining([{ label: "Severity", value: "error", mono: false }]) })]); + expect(raw).toEqual(before); + }); + + it("preserves different failure details, severity, authority and run/turn/session scope", () => { + const changed = [ + piFailure({ summary: "Provider request failed" }), + piFailure({ severity: "warning" }), + piFailure({ recoverable: true }), + piFailure({ userActionable: true }), + piFailure({ details: piFailure().event.payload.details.map(detail => detail.name === "reason" + ? { ...detail, value: "provider_http_429" } : detail) }), + piFailure({ details: piFailure().event.payload.details.map(detail => detail.name === "source.sessionId" + ? { ...detail, value: "session-2" } : detail) }), + piFailure({}, { runId: "run-2" }), + piFailure({}, { turnId: "turn-2" }), + piFailure({}, { normalizedSessionId: "session-2" }), + piFailure({}, { runId: undefined }), + piFailure({}, { turnId: undefined }), + ]; + for (const event of changed) { + const parse = paperclipRunnerUIAdapter.createStdoutParser!().parseLine; + expect(parse(JSON.stringify(piFailure()), "first")).toHaveLength(1); + expect(parse(JSON.stringify(event), "second")).toHaveLength(1); + } + }); + + it("starts a new diagnostic after retry activity, another event, or parser reset", () => { + const parser = paperclipRunnerUIAdapter.createStdoutParser!(); + const parse = (event: unknown) => parser.parseLine(JSON.stringify(event), "2026-10-03T07:00:00.000Z"); + expect(parse(piFailure())).toHaveLength(1); + expect(parse(piFailure({ noticeId: "notice-2" }))).toEqual([]); + for (const intervening of [piFailure({ category: "pi.auto_retry_start" }), + piFailure({}, { eventType: "item.delta", payload: { kind: "agentMessage", text: "Retrying" } }), + { type: "other" }]) { + parse(intervening); + expect(parse(piFailure())).toHaveLength(1); + } + parser.reset(); + expect(parse(piFailure())).toHaveLength(1); + }); + + it("never coalesces other providers or unscoped/unsupported notices", () => { + for (const event of [piFailure({ category: "other.runtime_failure" }), + piFailure({ details: [] }), piFailure({}, { normalizedSessionId: undefined }), + piFailure({}, { runId: undefined }), piFailure({}, { turnId: undefined })]) { + const parse = paperclipRunnerUIAdapter.createStdoutParser!().parseLine; + expect(parse(JSON.stringify(event), "first")).toHaveLength(1); + expect(parse(JSON.stringify(event), "second")).toHaveLength(1); + } + }); + it("renders committed PRP semantic tool items with the existing chat parts", () => { const started = paperclipRunnerUIAdapter.parseStdoutLine(JSON.stringify({ type: "paperclip.prp.event", diff --git a/ui/src/adapters/paperclip-runner/index.ts b/ui/src/adapters/paperclip-runner/index.ts index 6d2ac167b7..13be18c4d9 100644 --- a/ui/src/adapters/paperclip-runner/index.ts +++ b/ui/src/adapters/paperclip-runner/index.ts @@ -13,6 +13,7 @@ interface PaperclipRunnerParserState { itemChannels: Map; structuredFinalItemIds: Set; runtimeRequests: Map>; + previousPiRuntimeFailure: string | null; } function itemChannel(payload: JsonRecord): "progress" | "final" | "summary" | "detail" | "unknown" { @@ -664,6 +665,22 @@ function semanticToolEntries(eventType: string, payload: JsonRecord, ts: string) }]; } +function piRuntimeFailureKey(event: JsonRecord, payload: JsonRecord): string | null { + // The frozen Pi projection emits both process-exit and prompt-rejection + // facts. Coalesce their identical consecutive display rows, retaining the + // original PRP log and requiring the complete run/turn/session binding. + if (event.eventType !== "provider.notice.recorded" + || payload.schema !== "paperclip.provider.notice.v1" + || payload.category !== "pi.runtime_failure" + || !text(event.runId) || !text(event.turnId) || !text(event.normalizedSessionId)) return null; + const details = Array.isArray(payload.details) ? payload.details.map(record) : []; + if (!details.some(detail => detail.name === "source.method" && detail.value === "paperclip/pi_notice") + || !details.some(detail => detail.name === "source.nativeEvent" && detail.value === "runtime_failure") + || !details.some(detail => detail.name === "source.sessionId" && text(detail.value))) return null; + const { noticeId: _noticeId, ...notice } = payload; + return JSON.stringify([event.runId, event.turnId, event.normalizedSessionId, notice]); +} + function parsePrpEvent( event: JsonRecord, ts: string, @@ -671,6 +688,9 @@ function parsePrpEvent( ): TranscriptEntry[] { const eventType = text(event.eventType); const payload = record(event.payload); + const failure = piRuntimeFailureKey(event, payload); + if (failure !== null && failure === state.previousPiRuntimeFailure) return []; + state.previousPiRuntimeFailure = failure; const family = eventType.startsWith("plan.") ? "plan" : eventType.startsWith("tool.execution.") ? "tool_execution" : eventType.startsWith("research.") ? "research" @@ -768,6 +788,7 @@ function createParserState(): PaperclipRunnerParserState { itemChannels: new Map(), structuredFinalItemIds: new Set(), runtimeRequests: new Map(), + previousPiRuntimeFailure: null, }; } @@ -776,12 +797,18 @@ function parsePaperclipRunnerLine(line: string, ts: string, state: PaperclipRunn try { parsed = JSON.parse(line); } catch { + state.previousPiRuntimeFailure = null; return parseCodexStdoutLine(line, ts); } const envelope = record(parsed); - if (envelope.type !== "paperclip.prp.event") return parseCodexStdoutLine(line, ts); + if (envelope.type !== "paperclip.prp.event") { + state.previousPiRuntimeFailure = null; + return parseCodexStdoutLine(line, ts); + } const event = record(envelope.event); - return Object.keys(event).length > 0 ? parsePrpEvent(event, ts, state) : []; + if (Object.keys(event).length > 0) return parsePrpEvent(event, ts, state); + state.previousPiRuntimeFailure = null; + return []; } export function parsePaperclipRunnerStdoutLine(line: string, ts: string): TranscriptEntry[] {