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[] {