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,
|
shouldForwardOpenCodeProxyItem,
|
||||||
} from "./opencode-proxy-events.js";
|
} from "./opencode-proxy-events.js";
|
||||||
import { enqueueOpenCodeProxyInput, openCodeProxyCompletionFeedback } from "./opencode-proxy-input.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 { createOpenCodeProxyOutput } from "./opencode-proxy-output.js";
|
||||||
import {
|
import {
|
||||||
assertOpenCodeProxyCollaborationMode,
|
assertOpenCodeProxyCollaborationMode,
|
||||||
@@ -113,6 +114,7 @@ async function open(
|
|||||||
JSON.parse(readFileSync(runtimeContextPath, "utf8")),
|
JSON.parse(readFileSync(runtimeContextPath, "utf8")),
|
||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
|
const finishDiagnostics = createOpenCodeFinishDiagnosticForwarder(send);
|
||||||
driver = new OpenCodeServerDriver({
|
driver = new OpenCodeServerDriver({
|
||||||
model,
|
model,
|
||||||
permissionMode: parseOpenCodeProxyPermissionMode(
|
permissionMode: parseOpenCodeProxyPermissionMode(
|
||||||
@@ -146,7 +148,10 @@ async function open(
|
|||||||
tool: call.tool,
|
tool: call.tool,
|
||||||
arguments: call.arguments,
|
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
|
// runnerd gives this proxy its own process group. Keep `opencode serve` in
|
||||||
// that same group so runnerd's TERM/KILL fallback cannot orphan it.
|
// that same group so runnerd's TERM/KILL fallback cannot orphan it.
|
||||||
isolateProcessGroup: false,
|
isolateProcessGroup: false,
|
||||||
@@ -175,6 +180,7 @@ async function open(
|
|||||||
workingDirectory: cwd,
|
workingDirectory: cwd,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
finishDiagnostics.bind(session.ids().driverSessionId);
|
||||||
eventPump = pumpEvents(session);
|
eventPump = pumpEvents(session);
|
||||||
void eventPump.catch((error) => failProxy(error));
|
void eventPump.catch((error) => failProxy(error));
|
||||||
return threadResponse(
|
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