mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 12:37:55 +01:00
fix(opencode): retain closed finish input diagnostics
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
22ce1a8c32
commit
17c89f48f6
5 files changed
+133
-1
No files matched your search
@@ -2057,6 +2057,13 @@ pending and continue to block a new run; a longer wait does not certify an outco
|
||||
The one-hour client deadline does not renew with the Runner lease. If the client
|
||||
deadline expires while Runner still owns a call, its effect remains unknown
|
||||
until authoritative reconciliation. Expiry permits neither replay nor attachment.
|
||||
The OpenCode bridge emits a closed finish-input diagnostic through its existing
|
||||
optional diagnostic sink. It records the effective offered schema's SHA-256 and,
|
||||
for at most eight rejected inputs per bridge, the argument root and continuation
|
||||
presence/type enums. It records no argument values, field names, schema text or
|
||||
validator messages. A failed sink does not change admission or cancellation.
|
||||
These facts distinguish a schema/input-shape rejection from a pending MCP call;
|
||||
they do not identify the submitted value in older attempts without that evidence.
|
||||
An adopted transport can receive authenticated queued semantic calls before
|
||||
the driver publishes its recovered session and tool handler. It holds those
|
||||
calls until the handler is installed, then rechecks the original authority and
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { startOpenCodeMcpBridge, type OpenCodeMcpBridge } from "./mcp-bridge.js";
|
||||
|
||||
const bridges: OpenCodeMcpBridge[] = [];
|
||||
afterEach(async () => { await Promise.all(bridges.splice(0).map(bridge => bridge.close())); });
|
||||
|
||||
const report = {
|
||||
reportedWorkDisposition: "done", summary: "Completed the synthetic request.",
|
||||
completionClaim: { contractRevision: "1", objectiveSatisfied: true,
|
||||
criteria: [{ criterionId: "objective", status: "satisfied", evidenceRefs: [] }], remainingWork: [] },
|
||||
evidence: [], verification: [],
|
||||
};
|
||||
|
||||
async function rpc(bridge: OpenCodeMcpBridge, id: number, method: string, params?: unknown) {
|
||||
return (await fetch(bridge.url, { method: "POST", headers: {
|
||||
Authorization: `Bearer ${bridge.secret}`, "Content-Type": "application/json",
|
||||
}, body: JSON.stringify({ jsonrpc: "2.0", id, method, params }) })).json();
|
||||
}
|
||||
|
||||
it("binds closed rejection shapes to the actual offered finish schema without recording values", async () => {
|
||||
const messages: string[] = [], handler = vi.fn(async () => ({ accepted: true }));
|
||||
const bridge = await startOpenCodeMcpBridge({ handler, onDiagnostic: message => messages.push(message) });
|
||||
bridges.push(bridge);
|
||||
const listed = await rpc(bridge, 1, "tools/list");
|
||||
const schema = listed.result.tools.find((tool: { name: string }) => tool.name === "paperclip_finish").inputSchema;
|
||||
const digest = createHash("sha256").update(JSON.stringify(schema)).digest("hex");
|
||||
for (const [index, input] of [report, { ...report, continuation: null }].entries()) {
|
||||
const result = await rpc(bridge, index + 2, "tools/call", { name: "paperclip_finish", arguments: input });
|
||||
expect(result.result.isError).not.toBe(true);
|
||||
}
|
||||
for (const [index, continuation] of ["PRIVATE_INPUT_VALUE", [], 42, false].entries()) {
|
||||
const rejected = await rpc(bridge, index + 4, "tools/call", {
|
||||
name: "paperclip_finish", arguments: { ...report, continuation },
|
||||
});
|
||||
expect(rejected.result.isError).toBe(true);
|
||||
}
|
||||
expect(handler).toHaveBeenCalledTimes(2);
|
||||
expect(messages.map(message => JSON.parse(message))).toEqual([
|
||||
{ schema: "paperclip.opencode.finish-input-diagnostic/v1", stage: "offered", schemaSha256: digest },
|
||||
...["string", "array", "number", "boolean"].map(continuationShape => ({
|
||||
schema: "paperclip.opencode.finish-input-diagnostic/v1", stage: "input_rejected",
|
||||
schemaSha256: digest, rootShape: "object", continuationShape,
|
||||
})),
|
||||
]);
|
||||
expect(JSON.stringify(messages)).not.toMatch(/PRIVATE_INPUT_VALUE|Completed|criteria|continuation.*must|instancePath/);
|
||||
});
|
||||
|
||||
it("reports the effective supplied schema and bounds diagnostics without admitting rejected inputs", async () => {
|
||||
const messages: string[] = [], handler = vi.fn();
|
||||
const inputSchema = { type: "object", required: ["continuation"], properties: { continuation: { type: "object" } } };
|
||||
const bridge = await startOpenCodeMcpBridge({
|
||||
tools: [{ name: "paperclip_finish", inputSchema }], handler,
|
||||
onDiagnostic: message => messages.push(message),
|
||||
});
|
||||
bridges.push(bridge);
|
||||
for (let index = 0; index < 12; index++) {
|
||||
const rejected = await rpc(bridge, index, "tools/call", {
|
||||
name: "paperclip_finish", arguments: index === 0 ? [] : { continuation: null },
|
||||
});
|
||||
expect(rejected.result.isError).toBe(true);
|
||||
}
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
expect(messages).toHaveLength(9);
|
||||
expect(JSON.parse(messages[0]!)).toMatchObject({ schemaSha256: createHash("sha256").update(JSON.stringify(inputSchema)).digest("hex") });
|
||||
expect(JSON.parse(messages[1]!)).toMatchObject({ rootShape: "array", continuationShape: "omitted" });
|
||||
expect(JSON.parse(messages[2]!)).toMatchObject({ rootShape: "object", continuationShape: "null" });
|
||||
expect(messages.every(message => Buffer.byteLength(message) < 320)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps diagnostics optional and nonfatal while preserving cancellation", async () => {
|
||||
const aborted = vi.fn();
|
||||
let started!: () => void;
|
||||
const begun = new Promise<void>(resolve => { started = resolve; });
|
||||
const bridge = await startOpenCodeMcpBridge({ timeoutMs: null,
|
||||
onDiagnostic: () => { throw new Error("unavailable diagnostic sink"); },
|
||||
handler: ({ signal }) => new Promise((_resolve, reject) => {
|
||||
signal.addEventListener("abort", () => { aborted(); reject(new Error("cancelled")); });
|
||||
started();
|
||||
}),
|
||||
});
|
||||
bridges.push(bridge);
|
||||
const pending = rpc(bridge, 1, "tools/call", { name: "paperclip_finish", arguments: report });
|
||||
await begun;
|
||||
await fetch(bridge.url, { method: "POST", headers: { Authorization: `Bearer ${bridge.secret}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/cancelled", params: { requestId: 1 } }) });
|
||||
expect((await pending).result.isError).toBe(true);
|
||||
expect(aborted).toHaveBeenCalledOnce();
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
||||
import { Ajv2020, type ValidateFunction } from "ajv/dist/2020.js";
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface OpenCodeMcpBridgeOptions {
|
||||
privateToolTimeoutMs?: number;
|
||||
maxBodyBytes?: number;
|
||||
secret?: string;
|
||||
onDiagnostic?: (message: string) => void;
|
||||
}
|
||||
|
||||
export interface OpenCodeMcpBridge {
|
||||
@@ -66,6 +67,22 @@ export async function startOpenCodeMcpBridge(
|
||||
const privateTools = normalizeTools(options.privateTools ?? [], false);
|
||||
const admittedTools = [...tools, ...privateTools.filter((candidate) => !tools.some((tool) => tool.name === candidate.name))];
|
||||
const validators = compileValidators(admittedTools);
|
||||
const finishSchema = admittedTools.find(tool => tool.name === PRP_COMPLETION_TOOL_NAME)?.inputSchema;
|
||||
const finishSchemaSha256 = finishSchema && options.onDiagnostic
|
||||
? createHash("sha256").update(JSON.stringify(finishSchema)).digest("hex") : undefined;
|
||||
let rejectionDiagnostics = 0;
|
||||
const diagnoseFinishInput = (stage: "offered" | "input_rejected", input?: unknown) => {
|
||||
if (!finishSchemaSha256 || !options.onDiagnostic) return;
|
||||
if (stage === "input_rejected" && rejectionDiagnostics++ >= 8) return;
|
||||
const diagnostic = {
|
||||
schema: "paperclip.opencode.finish-input-diagnostic/v1", stage, schemaSha256: finishSchemaSha256,
|
||||
...(stage === "input_rejected" ? {
|
||||
rootShape: inputShape(input),
|
||||
continuationShape: inputShape(isRecord(input) ? input.continuation : undefined),
|
||||
} : {}),
|
||||
};
|
||||
try { options.onDiagnostic(JSON.stringify(diagnostic)); } catch { /* Optional diagnostics never change admission. */ }
|
||||
};
|
||||
const calls = new Map<string, { fingerprint: string; promise: Promise<unknown> }>();
|
||||
const controllers = new Map<string, AbortController>();
|
||||
const server = createServer((request, response) => {
|
||||
@@ -77,6 +94,7 @@ export async function startOpenCodeMcpBridge(
|
||||
calls,
|
||||
controllers,
|
||||
handler: options.handler,
|
||||
diagnoseFinishInput,
|
||||
timeoutMs: options.timeoutMs === null ? null : options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
privateToolTimeoutMs: options.privateToolTimeoutMs ?? options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
privateToolNames: new Set(privateTools.map((tool) => tool.name)),
|
||||
@@ -95,6 +113,7 @@ export async function startOpenCodeMcpBridge(
|
||||
await closeServer(server);
|
||||
throw new Error("OpenCode MCP bridge failed to bind a loopback port");
|
||||
}
|
||||
diagnoseFinishInput("offered");
|
||||
return {
|
||||
url: `http://127.0.0.1:${address.port}/mcp`,
|
||||
secret,
|
||||
@@ -105,6 +124,17 @@ export async function startOpenCodeMcpBridge(
|
||||
};
|
||||
}
|
||||
|
||||
function inputShape(input: unknown): "omitted" | "null" | "array" | "object" | "string" | "number" | "boolean" | "other" {
|
||||
if (input === undefined) return "omitted";
|
||||
if (input === null) return "null";
|
||||
if (Array.isArray(input)) return "array";
|
||||
if (typeof input === "object") return "object";
|
||||
if (typeof input === "string") return "string";
|
||||
if (typeof input === "number") return "number";
|
||||
if (typeof input === "boolean") return "boolean";
|
||||
return "other";
|
||||
}
|
||||
|
||||
function normalizeTools(
|
||||
input: readonly Readonly<Record<string, unknown>>[],
|
||||
includeTerminalTools = true,
|
||||
@@ -151,6 +181,7 @@ async function handleRequest(
|
||||
privateToolTimeoutMs: number;
|
||||
privateToolNames: ReadonlySet<string>;
|
||||
maxBodyBytes: number;
|
||||
diagnoseFinishInput: (stage: "offered" | "input_rejected", input?: unknown) => void;
|
||||
},
|
||||
): Promise<void> {
|
||||
setSecurityHeaders(response);
|
||||
@@ -236,6 +267,7 @@ async function handleRequest(
|
||||
const args = params.arguments ?? {};
|
||||
const validator = context.validators.get(tool);
|
||||
if (validator === undefined || !validator(args)) {
|
||||
if (tool === PRP_COMPLETION_TOOL_NAME) context.diagnoseFinishInput("input_rejected", params.arguments);
|
||||
const detail = validator?.errors?.map((error) => `${error.instancePath || "/"} ${error.message ?? "is invalid"}`).join("; ");
|
||||
writeRpc(response, id, {
|
||||
isError: true,
|
||||
|
||||
@@ -2340,6 +2340,7 @@ describe("OpenCodeServerDriver", () => {
|
||||
const continuation = { kind, summary: "Wait for the saved task path.", idempotencyKey: "task-wait:saved" };
|
||||
const reads: string[] = [];
|
||||
const accepted: unknown[] = [];
|
||||
const diagnostics: string[] = [];
|
||||
const driver = new OpenCodeServerDriver({
|
||||
model: "openrouter/deepseek/deepseek-v4-flash-0731", runtimeDirectory: root, command: fixture,
|
||||
environment: { PATH: process.env.PATH, OPENROUTER_API_KEY: "fixture-key" },
|
||||
@@ -2349,11 +2350,13 @@ describe("OpenCodeServerDriver", () => {
|
||||
completionContinuation: eligible ? continuation : null };
|
||||
},
|
||||
completionFeedback: async result => { accepted.push(result); return "Accepted monitor wait."; },
|
||||
onDiagnostic: message => diagnostics.push(message),
|
||||
});
|
||||
const session = await driver.openSession({ runId: "monitor-default", normalizedSessionId: "monitor-default", workingDirectory: workspace });
|
||||
try {
|
||||
await session.startTurn({ message: { role: "user", text: "monitor-default" } });
|
||||
const events = await collectTurnEvents(session.events());
|
||||
expect(diagnostics).toContainEqual(expect.stringContaining('"schema":"paperclip.opencode.finish-input-diagnostic/v1","stage":"offered"'));
|
||||
expect(reads).toEqual(["get_task_context"]);
|
||||
if (eligible) {
|
||||
expect(accepted).toMatchObject([{ reportedWorkDisposition: "yielded", continuation }]);
|
||||
|
||||
@@ -2426,6 +2426,7 @@ async function startRuntime(input: {
|
||||
const bridge = await startOpenCodeMcpBridge({
|
||||
tools: input.options.dynamicTools,
|
||||
handler: input.dispatch,
|
||||
onDiagnostic: input.options.onDiagnostic,
|
||||
...(input.options.durableToolRequests ? { timeoutMs: null } : {}),
|
||||
}).catch(async (error) => {
|
||||
await removeIsolatedHome(isolatedHome).catch(() => undefined);
|
||||
|
||||
Reference in new issue
Block a user