mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 12:37:55 +01:00
fix(runner): retain closed OpenCode finish diagnostics
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
f5692c57da
commit
e72803a2b6
3 files changed
+117
-1
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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<string, unknown> | undefined;
|
||||
let rejections = 0;
|
||||
const emit = (value: Record<string, unknown>) => {
|
||||
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<string, unknown>;
|
||||
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);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user