diff --git a/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.ts b/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.ts index 460cc9f5344..ecd9a95ae9b 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.ts @@ -1316,18 +1316,32 @@ class CodexHarnessSession implements HarnessSession { if (!isSemanticTool(tool)) { const admitted = this.#dynamicTools.some((candidate) => candidate.name === tool); if (admitted && this.#dynamicToolHandler !== undefined) { + this.#emit("item.started", { + kind: "dynamicToolCall", + item: { type: "tool_use", id: callId, name: tool, input: request.params.arguments }, + }, { turnId, itemId: callId }); try { - return dynamicToolResponse(await this.#dynamicToolHandler({ + const result = await this.#dynamicToolHandler({ tool, callId, threadId, turnId, arguments: request.params.arguments, - })); + }); + this.#emit("item.completed", { + kind: "dynamicToolCall", + item: { type: "tool_result", id: callId, tool_use_id: callId, result }, + }, { turnId, itemId: callId }); + return dynamicToolResponse(result); } catch (error) { + const message = boundedText(error instanceof Error ? error.message : error); + this.#emit("item.completed", { + kind: "dynamicToolCall", + item: { type: "tool_result", id: callId, tool_use_id: callId, error: message, is_error: true }, + }, { turnId, itemId: callId }); return { success: false, - contentItems: [{ type: "inputText", text: boundedText(error instanceof Error ? error.message : error) }], + contentItems: [{ type: "inputText", text: message }], }; } } diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index b3c58068059..a1509c6fdc0 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -16171,6 +16171,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) leaseOwner: runOptions.nativeLeaseOwner, backend: options.nativeSessionBackendFactory?.(nativeExecution), useRunnerd: agent.adapterType === "paperclip_runner", + onLog, onSpawn: async (meta) => { await persistRunProcessMetadata(run.id, meta); }, diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 71bb89bdcd7..01398dce658 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -247,6 +247,7 @@ export async function executePaperclipNativeSession(input: { /** Test seam at the provider boundary; production always uses the package Codex backend. */ backend?: NativeSessionBackend; useRunnerd?: boolean; + onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; }): Promise { const leaseOwner = input.leaseOwner ?? `${input.runnerInstanceId}:${randomUUID()}`; const leaseNow = new Date(); @@ -293,6 +294,10 @@ export async function executePaperclipNativeSession(input: { completionContractSha256: input.execution.completionContract.sha256, sourceInstanceId: input.runnerInstanceId, controlPlaneSourceInstanceId: controlPlaneInstanceId, + }, { + onCommittedEvent: input.onLog + ? async (event) => input.onLog!("stdout", `${JSON.stringify({ type: "paperclip.prp.event", event })}\n`) + : undefined, }); let native: Awaited>; try { diff --git a/server/src/services/native-runtime/paperclip-control-plane-port.ts b/server/src/services/native-runtime/paperclip-control-plane-port.ts index 1407faf7903..8b545d79007 100644 --- a/server/src/services/native-runtime/paperclip-control-plane-port.ts +++ b/server/src/services/native-runtime/paperclip-control-plane-port.ts @@ -66,10 +66,14 @@ export class PaperclipControlPlanePort implements ControlPlanePort { readonly #db: Db; readonly #binding: PaperclipControlPlaneBinding; #sessionId: string | null = null; + readonly #onCommittedEvent?: (event: PrpEvent) => Promise; - constructor(db: Db, binding: PaperclipControlPlaneBinding) { + constructor(db: Db, binding: PaperclipControlPlaneBinding, options: { + onCommittedEvent?: (event: PrpEvent) => Promise; + } = {}) { this.#db = db; this.#binding = structuredClone(binding); + this.#onCommittedEvent = options.onCommittedEvent; } async openRun(input: OpenControlPlaneRunInput): Promise { @@ -190,6 +194,7 @@ export class PaperclipControlPlanePort implements ControlPlanePort { canonicalPayload: event as unknown as Record, }, }); + if (persisted.disposition === "committed") await this.#onCommittedEvent?.(event); return { cursor: persisted.row.seq, highestContiguousSourceSeq: persisted.highestContiguousSourceSeq, diff --git a/ui/src/adapters/paperclip-runner/index.test.ts b/ui/src/adapters/paperclip-runner/index.test.ts new file mode 100644 index 00000000000..abca26648b8 --- /dev/null +++ b/ui/src/adapters/paperclip-runner/index.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { paperclipRunnerUIAdapter } from "./index"; + +describe("paperclip runner transcript projection", () => { + it("renders committed PRP semantic tool items with the existing chat parts", () => { + const started = paperclipRunnerUIAdapter.parseStdoutLine(JSON.stringify({ + type: "paperclip.prp.event", + event: { + eventType: "item.started", + payload: { item: { type: "tool_use", id: "call-1", name: "get_task_context", input: {} } }, + }, + }), "2026-08-21T12:00:00.000Z"); + const completed = paperclipRunnerUIAdapter.parseStdoutLine(JSON.stringify({ + type: "paperclip.prp.event", + event: { + eventType: "item.completed", + payload: { item: { type: "tool_result", id: "call-1", tool_use_id: "call-1", result: { ok: true } } }, + }, + }), "2026-08-21T12:00:01.000Z"); + expect(started).toEqual([expect.objectContaining({ kind: "tool_call", name: "get_task_context", toolUseId: "call-1" })]); + expect(completed).toEqual([expect.objectContaining({ kind: "tool_result", toolUseId: "call-1", isError: false })]); + }); +}); diff --git a/ui/src/adapters/paperclip-runner/index.ts b/ui/src/adapters/paperclip-runner/index.ts index 6311bc91b22..1338caeed48 100644 --- a/ui/src/adapters/paperclip-runner/index.ts +++ b/ui/src/adapters/paperclip-runner/index.ts @@ -2,10 +2,40 @@ import type { UIAdapterModule } from "../types"; import { parseCodexStdoutLine, buildPaperclipRunnerConfig } from "@paperclipai/adapter-codex-local/ui"; import { CodexLocalConfigFields } from "../codex-local/config-fields"; +function parsePaperclipRunnerStdoutLine(line: string, ts: string) { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return parseCodexStdoutLine(line, ts); + } + if (typeof parsed !== "object" || parsed === null || (parsed as { type?: unknown }).type !== "paperclip.prp.event") { + return parseCodexStdoutLine(line, ts); + } + const event = (parsed as { event?: unknown }).event; + if (typeof event !== "object" || event === null) return []; + const candidate = event as { eventType?: unknown; payload?: unknown }; + const eventType = typeof candidate.eventType === "string" ? candidate.eventType : ""; + const payload = typeof candidate.payload === "object" && candidate.payload !== null + ? candidate.payload as Record + : {}; + if ((eventType === "item.started" || eventType === "item.completed") && payload.item) { + return parseCodexStdoutLine(JSON.stringify({ type: eventType, item: payload.item }), ts); + } + if (eventType === "turn.started") return parseCodexStdoutLine(JSON.stringify({ type: "turn.started" }), ts); + if (eventType === "turn.completed") { + return parseCodexStdoutLine(JSON.stringify({ type: "turn.completed", result: payload.result ?? "" }), ts); + } + if (eventType === "harness.diagnostic") { + return [{ kind: "system" as const, ts, text: String(payload.message ?? payload.code ?? "Runner diagnostic") }]; + } + return []; +} + export const paperclipRunnerUIAdapter: UIAdapterModule = { type: "paperclip_runner", label: "Paperclip Runner", - parseStdoutLine: parseCodexStdoutLine, + parseStdoutLine: parsePaperclipRunnerStdoutLine, ConfigFields: CodexLocalConfigFields, buildAdapterConfig: buildPaperclipRunnerConfig, };