mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-08 00:54:38 +02:00
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:
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",
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
Reference in new issue
Block a user