From e72803a2b6ce514cf3cc3ece90db08c2ee22f69f Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 6 Nov 2026 00:05:22 -0600 Subject: [PATCH] fix(runner): retain closed OpenCode finish diagnostics Co-Authored-By: Paperclip --- .../src/cli/opencode-app-server-proxy.ts | 8 ++- .../opencode-proxy-finish-diagnostics.test.ts | 68 +++++++++++++++++++ .../cli/opencode-proxy-finish-diagnostics.ts | 42 ++++++++++++ 3 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 packages/paperclip-runner/src/cli/opencode-proxy-finish-diagnostics.test.ts create mode 100644 packages/paperclip-runner/src/cli/opencode-proxy-finish-diagnostics.ts diff --git a/packages/paperclip-runner/src/cli/opencode-app-server-proxy.ts b/packages/paperclip-runner/src/cli/opencode-app-server-proxy.ts index dc21cb13fcd..00dc9c0cb9c 100644 --- a/packages/paperclip-runner/src/cli/opencode-app-server-proxy.ts +++ b/packages/paperclip-runner/src/cli/opencode-app-server-proxy.ts @@ -20,6 +20,7 @@ import { shouldForwardOpenCodeProxyItem, } from "./opencode-proxy-events.js"; import { enqueueOpenCodeProxyInput, openCodeProxyCompletionFeedback } from "./opencode-proxy-input.js"; +import { createOpenCodeFinishDiagnosticForwarder } from "./opencode-proxy-finish-diagnostics.js"; import { createOpenCodeProxyOutput } from "./opencode-proxy-output.js"; import { assertOpenCodeProxyCollaborationMode, @@ -113,6 +114,7 @@ async function open( JSON.parse(readFileSync(runtimeContextPath, "utf8")), ) : null; + const finishDiagnostics = createOpenCodeFinishDiagnosticForwarder(send); driver = new OpenCodeServerDriver({ model, permissionMode: parseOpenCodeProxyPermissionMode( @@ -146,7 +148,10 @@ async function open( tool: call.tool, arguments: call.arguments, }), - onDiagnostic: (message) => process.stderr.write(`[opencode] ${message}\n`), + onDiagnostic: (message) => { + process.stderr.write(`[opencode] ${message}\n`); + finishDiagnostics.diagnostic(message); + }, // runnerd gives this proxy its own process group. Keep `opencode serve` in // that same group so runnerd's TERM/KILL fallback cannot orphan it. isolateProcessGroup: false, @@ -175,6 +180,7 @@ async function open( workingDirectory: cwd, }); } + finishDiagnostics.bind(session.ids().driverSessionId); eventPump = pumpEvents(session); void eventPump.catch((error) => failProxy(error)); return threadResponse( diff --git a/packages/paperclip-runner/src/cli/opencode-proxy-finish-diagnostics.test.ts b/packages/paperclip-runner/src/cli/opencode-proxy-finish-diagnostics.test.ts new file mode 100644 index 00000000000..7614cc98edf --- /dev/null +++ b/packages/paperclip-runner/src/cli/opencode-proxy-finish-diagnostics.test.ts @@ -0,0 +1,68 @@ +import { expect, it } from "vitest"; +import { stripTypeScriptTypes } from "node:module"; +import { readFile } from "node:fs/promises"; +import { createOpenCodeFinishDiagnosticForwarder } from "./opencode-proxy-finish-diagnostics.js"; +import { createOpenCodeProxyOutput } from "./opencode-proxy-output.js"; +const offered = { schema: "paperclip.opencode.finish-input-diagnostic/v1", stage: "offered", schemaSha256: "a".repeat(64) }; +const rejected = { ...offered, stage: "input_rejected", callIdSha256: "b".repeat(64), validationCategory: "continuation", rootShape: "object", continuationShape: "string" }; +it("retains closed diagnostics through normal warning output before an empty-response failure", async () => { + const frames: unknown[] = [], output = createOpenCodeProxyOutput(frame => frames.push(frame)); + const forward = createOpenCodeFinishDiagnosticForwarder(output.send); + forward.diagnostic(JSON.stringify(offered)); + expect(frames).toEqual([]); + expect(forward.bind("actual-session")).toBe(true); + forward.diagnostic(JSON.stringify(rejected)); + output.send({ method: "turn/completed", params: { threadId: "actual-session", turn: { status: "failed", error: { code: "opencode_empty_response" } } } }); + expect(frames).toEqual([ + { method: "warning", params: { threadId: "actual-session", message: JSON.stringify(offered) } }, + { method: "warning", params: { threadId: "actual-session", message: JSON.stringify(rejected) } }, + expect.objectContaining({ method: "turn/completed" }), + ]); + const source = await readFile(new URL("./opencode-app-server-proxy.ts", import.meta.url), "utf8"); + expect(source).toContain("finishDiagnostics.diagnostic(message)"); + expect(source).toContain("finishDiagnostics.bind(session.ids().driverSessionId)"); + expect(source).toContain('process.stderr.write(`[opencode] ${message}\\n`)'); +}); +it("rejects unbound rejections, foreign binding/schema, unknown fields and arbitrary text", () => { + const frames: unknown[] = [], forward = createOpenCodeFinishDiagnosticForwarder(frame => frames.push(frame)); + forward.diagnostic(JSON.stringify(rejected)); + forward.diagnostic("arbitrary private text"); + forward.diagnostic(JSON.stringify({ ...offered, threadId: "foreign" })); + expect(frames).toEqual([]); + forward.diagnostic(JSON.stringify(offered)); + expect(forward.bind("actual-session")).toBe(true); + expect(forward.bind("foreign-session")).toBe(false); + for (const invalid of [{ ...rejected, schemaSha256: "c".repeat(64) }, { ...rejected, input: "private" }, { ...rejected, rootShape: {} }, { ...rejected, rootShape: ["object"] }, { ...rejected, continuationShape: "invalid" }]) forward.diagnostic(JSON.stringify(invalid)); + expect(frames).toHaveLength(1); + for (let i = 0; i < 12; i++) forward.diagnostic(JSON.stringify(rejected)); + forward.diagnostic(JSON.stringify(offered)); + expect(frames).toHaveLength(9); + expect(JSON.stringify(frames)).not.toMatch(/private|foreign/); +}); + +it("forwards from the actual proxy open callback instead of losing normal semantic failure diagnostics to stderr", async () => { + const source=await readFile(new URL("./opencode-app-server-proxy.ts",import.meta.url),"utf8"); + const body=stripTypeScriptTypes(source.slice(source.indexOf("async function open("),source.indexOf("function threadResponse("))); + const frames:unknown[]=[],stderr:string[]=[],output=createOpenCodeProxyOutput(frame=>frames.push(frame)); + let diagnostic:(message:string)=>void=()=>{}; + class Driver { + constructor(options:{onDiagnostic:(message:string)=>void}) { diagnostic=options.onDiagnostic; } + async openSession() { diagnostic(JSON.stringify(offered));return {ids:()=>({driverSessionId:"actual-session",providerSessionId:"actual-session"})}; } + } + const invoke=new Function("OpenCodeServerDriver","createOpenCodeFinishDiagnosticForwarder","send","process",` + let session=null,driver,eventPump,cwd,activeModel; + const activeRunId="run",launchBinding={},resolve=x=>x,text=(x,f="")=>typeof x==="string"?x:f,record=x=>x; + const runtimeDirectory=()=>"/synthetic",withoutAmbientOpenCodeCommand=()=>({}),parseOpenCodeProxyPermissionMode=()=>"allow",openCodeProxyTaskEnvelope=()=>({}); + const openCodeProxyCompletionFeedback=()=>()=>{},requestController=()=>{},pumpEvents=async()=>{},failProxy=()=>{},threadResponse=id=>({thread:{id}}); + ${body} + return open({model:"provider/model",cwd:"/synthetic"},false); + `); + await invoke(Driver,createOpenCodeFinishDiagnosticForwarder,output.send,{env:{},cwd:()=>"/synthetic",stderr:{write:(message:string)=>stderr.push(message)}}); + diagnostic(JSON.stringify(rejected)); + diagnostic("unrelated private diagnostic"); + output.send({method:"turn/completed",params:{threadId:"actual-session",turn:{status:"failed",error:{code:"opencode_empty_response"}}}}); + expect(frames).toHaveLength(3); + expect(frames.slice(0,2)).toEqual([offered,rejected].map(value=>({method:"warning",params:{threadId:"actual-session",message:JSON.stringify(value)}}))); + expect(stderr).toHaveLength(3); + expect(JSON.stringify(frames)).not.toContain("unrelated private diagnostic"); +}); diff --git a/packages/paperclip-runner/src/cli/opencode-proxy-finish-diagnostics.ts b/packages/paperclip-runner/src/cli/opencode-proxy-finish-diagnostics.ts new file mode 100644 index 00000000000..69210d1f226 --- /dev/null +++ b/packages/paperclip-runner/src/cli/opencode-proxy-finish-diagnostics.ts @@ -0,0 +1,42 @@ +type Warning = { method: "warning"; params: { threadId: string; message: string } }; +export function createOpenCodeFinishDiagnosticForwarder(send: (frame: Warning) => void) { + let threadId: string | undefined; + let offered: Record | undefined; + let rejections = 0; + const emit = (value: Record) => { + if (!threadId) return; + try { send({ method: "warning", params: { threadId, message: JSON.stringify(value) } }); } catch {} + }; + return { + bind(id: string) { + if (!id || id.length > 512 || threadId) return false; + threadId = id; + if (offered) emit(offered); + return true; + }, + diagnostic(message: string) { + if (message.length > 2048) return; + let value: Record; + try { value = JSON.parse(message); } catch { return; } + if (!value || typeof value !== "object" || Array.isArray(value) + || value.schema !== "paperclip.opencode.finish-input-diagnostic/v1" + || typeof value.schemaSha256 !== "string" || !/^[a-f0-9]{64}$/.test(value.schemaSha256)) return; + const keys = Object.keys(value).sort().join(","); + if (value.stage === "offered") { + if (offered || keys !== "schema,schemaSha256,stage") return; + offered = value; + emit(value); + } else if (value.stage === "input_rejected") { + if (!threadId || !offered || rejections >= 8 || value.schemaSha256 !== offered.schemaSha256 + || keys !== "callIdSha256,continuationShape,rootShape,schema,schemaSha256,stage,validationCategory" + || typeof value.callIdSha256 !== "string" || !/^[a-f0-9]{64}$/.test(value.callIdSha256) + || typeof value.validationCategory !== "string" || typeof value.rootShape !== "string" || typeof value.continuationShape !== "string" + || !["root", "continuation", "other"].includes(String(value.validationCategory)) + || !["object", "array", "string", "number", "boolean", "null", "omitted"].includes(String(value.rootShape)) + || !["object", "array", "string", "number", "boolean", "null", "omitted"].includes(String(value.continuationShape))) return; + rejections += 1; + emit(value); + } + }, + }; +}