Merge scoped Pi prerequisite review fixes

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* commit '95a30b7ff':
  fix(ui): coalesce bound duplicate Pi failure notices
  test(runner): align held Pi promotion assertions with profile 12
This commit is contained in:
DottaandPaperclip committed 2026-10-05 13:00:15 -05:00
commit ef1558e027
2 files changed
+117 -2

No files matched your search

@@ -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<string, unknown> = {}, eventOverrides: Record<string, unknown> = {}) => ({
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",
+29 -2
View File
@@ -13,6 +13,7 @@ interface PaperclipRunnerParserState {
itemChannels: Map<string, "progress" | "final" | "summary" | "detail" | "unknown">;
structuredFinalItemIds: Set<string>;
runtimeRequests: Map<string, Extract<TranscriptEntry, { kind: "runtime_request" }>>;
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[] {