diff --git a/doc/run-log-events.md b/doc/run-log-events.md index b449def19f6..5672c6a693c 100644 --- a/doc/run-log-events.md +++ b/doc/run-log-events.md @@ -92,6 +92,14 @@ They also become native Observability spans only with an operator OTLP endpoint. ## Native PRP Run-Log Events +Completed OpenCode builtin `bash` parts preserve the numeric process exit from +`state.metadata.exit` in `tool.execution.completed.payload.exitCode`. The tool +status stays `completed` even when the command exits with a nonzero code. This +status describes completion of the tool call, not success of the command or the +native run. Metadata adds an exit only when it is an integer. Conflicting supplied +legacy exits stay unknown. Existing numeric `state.exit` values remain supported; +metadata on other tools cannot add process-exit proof. + `auth.credentials.updated` is a runner-produced PRP event for a change to a supported credential store, including account identity changes and later token refreshes on the new account. The bundled transport awaits a host commit callback diff --git a/packages/paperclip-runner/src/provider-events.test.ts b/packages/paperclip-runner/src/provider-events.test.ts index a35c21179fd..ea9469238d2 100644 --- a/packages/paperclip-runner/src/provider-events.test.ts +++ b/packages/paperclip-runner/src/provider-events.test.ts @@ -484,6 +484,53 @@ describe("provider-neutral events", () => { ).toEqual([]); }); + it.each([0, 37, -1])("preserves OpenCode bash metadata exit %i without changing tool completion", (exit) => { + const event = canonicalProviderEventsFromOpenCodePart({ + id: "bash-part", type: "tool", tool: "bash", callID: "bash-call", + state: { status: "completed", input: { command: "node verify.mjs" }, + output: "Work validation finished", metadata: { exit, truncated: false }, time: { start: 1, end: 2 } }, + })[0]!; + expect(event).toMatchObject({ eventType: "tool.execution.completed", itemId: "bash-part", + payload: { executionId: "bash-part", name: "bash", status: "completed", exitCode: exit } }); + expect(validatePrpEvent(envelope(event)).ok).toBe(true); + }); + + it.each([undefined, null, "37", 1.5, Number.NaN, Number.POSITIVE_INFINITY, Number.MAX_SAFE_INTEGER + 1])("does not infer a bash exit from invalid metadata %s", (exit) => { + const event = canonicalProviderEventsFromOpenCodePart({ id: "bash-invalid", type: "tool", tool: "bash", + state: { status: "completed", output: "exitCode=37", metadata: { exit } } })[0]!; + expect(event.payload.exitCode).toBeNull(); + }); + + it.each([ + { type: "tool", tool: "read", state: { status: "completed" } }, + { type: "tool", tool: "mcp_bash", state: { status: "completed" } }, + { type: "tool", tool: "unknown", callID: "functions.bash:0", state: { status: "completed" } }, + { type: "bash", state: { status: "completed" } }, + { type: "tool", tool: "bash", state: { status: "running" } }, + { type: "tool", tool: "bash", state: { status: "error", error: "Tool execution aborted" } }, + ])("rejects metadata exits outside a completed builtin bash part %#", (part) => { + const event = canonicalProviderEventsFromOpenCodePart({ id: "not-bash-complete", ...part, + state: { ...part.state, metadata: { exit: 37 } } })[0]!; + expect(event.payload.exitCode).toBeNull(); + }); + + it.each([0, 37, "37", 1.5])("rejects conflicting legacy and metadata exits %s", (exit) => { + const event = canonicalProviderEventsFromOpenCodePart({ id: "bash-conflict", type: "tool", tool: "bash", + state: { status: "completed", exit, metadata: { exit: 1 } } })[0]!; + expect(event.payload.exitCode).toBeNull(); + }); + + it.each([ + { tool: "bash", exit: 37, metadata: undefined }, + { tool: "bash", exit: 37, metadata: { exit: 37 } }, + { tool: "bash", exit: 37, metadata: { exit: "37" } }, + { tool: "read", exit: 37, metadata: { exit: 1 } }, + ])("preserves an existing legacy numeric exit %#", ({ tool, exit, metadata }) => { + const event = canonicalProviderEventsFromOpenCodePart({ id: "legacy-exit", type: "tool", tool, + state: { status: "completed", exit, metadata } })[0]!; + expect(event.payload.exitCode).toBe(exit); + }); + it("maps OpenCode pending dynamic tools to a valid builtin execution start", () => { const event = canonicalProviderEventsFromOpenCodePart({ id: "prt_02c35c313001X42cqyvQrpXOFv", diff --git a/packages/paperclip-runner/src/provider-events.ts b/packages/paperclip-runner/src/provider-events.ts index a20ac855af1..dac4af58b80 100644 --- a/packages/paperclip-runner/src/provider-events.ts +++ b/packages/paperclip-runner/src/provider-events.ts @@ -1002,6 +1002,19 @@ export function canonicalProviderEventsFromCodex( return []; } +function openCodeToolExitCode( + part: Record, + state: Record, +): number | null { + const legacy = integer(state.exit); + if (part.type !== "tool" || part.tool !== "bash" || state.status !== "completed") { + return legacy; + } + const exit = integer(record(state.metadata).exit); + if (exit === null) return legacy; + return state.exit != null && legacy !== exit ? null : exit; +} + /** Maps documented OpenCode structured message parts. Unrecognized parts are deliberately unsupported. */ export function canonicalProviderEventsFromOpenCodePart( partValue: unknown, @@ -1051,7 +1064,7 @@ export function canonicalProviderEventsFromOpenCodePart( readOnly: /read|search|grep|find|list|glob/i.test(tool), status, durationMs: integer(state.time), - exitCode: integer(state.exit), + exitCode: openCodeToolExitCode(part, state), progress: status === "running" ? text(state.title).slice(0, 4000) || null : null, ...boundedOutput(