diff --git a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts index d280134299b..b08741aba64 100644 --- a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts +++ b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts @@ -21,6 +21,7 @@ import { nativeRuntimePromptDigest, type NativeRuntimeContextSnapshot, } from "../../contracts/runtime-context.js"; +import { createCodexTaskEnvelope } from "../../contracts/codex.js"; import { localIntegrityBoundaryGolden } from "../../../test-support/local-integrity-boundary-golden.js"; import { OpenCodeServerDriver, @@ -1455,23 +1456,25 @@ describe("OpenCodeServerDriver", () => { ).resolves.toMatchObject({ ok: true }); }); - it("returns a repairable tool error for wrong criteria without committing a bad semantic result", async () => { + it.each(["wrong", "missing", "duplicate"])("returns a repairable tool error for %s criteria without committing a bad semantic result", async (mode) => { await chmod(fixture, 0o755); const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-criteria-")); const workspace = await mkdtemp(join(tmpdir(), "paperclip-opencode-workspace-")); roots.push(root, workspace); - 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" } }); + 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" }, taskEnvelope: createCodexTaskEnvelope({ objective: "Apply the accepted decision", contractRevision: "approval-v2", criteria: [{ id: "human_response", requirement: "Apply the approved response" }] }) }); const session = await driver.openSession({ runId: "criteria-repair", normalizedSessionId: "criteria-repair", workingDirectory: workspace }); - await session.startTurn({ message: { role: "user", text: "repair-criteria" } }); + await session.startTurn({ message: { role: "user", text: `repair-criteria-${mode}` } }); const events = []; for await (const event of session.events()) events.push(event); const results = events.filter((event) => event.eventType === "run.result.proposed"); expect(results).toHaveLength(1); - expect(results[0].payload).toMatchObject({ completionClaim: { criteria: [{ criterionId: "objective" }] } }); + expect(events.some((event) => event.eventType === "item.completed" && + (event.payload as { item?: { is_error?: boolean } }).item?.is_error === true)).toBe(true); + expect(results[0].payload).toMatchObject({ completionClaim: { criteria: [{ criterionId: "human_response" }] } }); const files = await readdir(root, { recursive: true }); const evidence = files.find((name) => name.endsWith("fake-criteria-repair.json")); expect(evidence).toBeDefined(); - expect(JSON.parse(await readFile(join(root, evidence!), "utf8"))).toMatchObject({ result: { isError: true, content: [{ text: expect.stringContaining('"objective"') }] } }); + expect(JSON.parse(await readFile(join(root, evidence!), "utf8"))).toMatchObject({ result: { isError: true, content: [{ text: expect.stringContaining('"human_response"') }] } }); await session.close({ reason: "test" }); }); diff --git a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts index f3ab32f37d4..f9a1f29cb3d 100644 --- a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts +++ b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts @@ -887,60 +887,71 @@ class OpenCodeHarnessSession implements HarnessSession { { turnId, itemId: call.callId }, ); if (tool === PRP_COMPLETION_TOOL_NAME || tool === PRP_BLOCK_TOOL_NAME) { - const validation = validatePrpStructuredRunResult(call.arguments); - if (!validation.ok) throw new Error("Invalid semantic result"); - if ( - (tool === PRP_BLOCK_TOOL_NAME && - validation.result.reportedWorkDisposition !== "blocked") || - (tool === PRP_COMPLETION_TOOL_NAME && - validation.result.reportedWorkDisposition === "blocked") - ) - throw new Error( - "Semantic result disposition does not match the terminal tool", - ); - if ( - validation.result.completionClaim.contractRevision !== - this.#taskEnvelope.completionContract.revision - ) { - throw new Error( - "Semantic result completion contract revision does not match", - ); - } - const expectedIds = this.#taskEnvelope.completionContract.criteria.map((criterion) => criterion.id); - const receivedIds = validation.result.completionClaim.criteria.map((criterion) => criterion.criterionId); - if (receivedIds.length !== expectedIds.length || new Set(receivedIds).size !== receivedIds.length || receivedIds.some((id) => !expectedIds.includes(id))) { - // Reject at the tool boundary so the provider can repair its claim. - // Emitting a bad result here kills runnerd's strict outer validation. - throw new Error(`Semantic result criteria must contain exactly these criterionIds, once each: ${JSON.stringify(expectedIds)}. Keep contractRevision ${JSON.stringify(this.#taskEnvelope.completionContract.revision)}.`); - } - const fingerprint = canonicalJson(validation.result); - if (this.#resultFingerprint && this.#resultFingerprint !== fingerprint) - throw new Error("A different semantic result was already committed"); - if (!this.#resultFingerprint) { - this.#result = structuredClone(validation.result); - this.#resultFingerprint = fingerprint; - this.#resultCallId = call.callId; - this.#resultTurnId = turnId; - this.#semanticResultTextBoundary = this.#completedTextParts.length; - this.#emit("run.result.proposed", validation.result, { - turnId, - itemId: call.callId, - }); - } - this.#emit( - "item.completed", - { - kind: "dynamicToolCall", - item: { - type: "tool_result", - id: call.callId, - tool_use_id: call.callId, - result: "Semantic completion accepted.", + try { + const validation = validatePrpStructuredRunResult(call.arguments); + if (!validation.ok) throw new Error("Invalid semantic result"); + if ( + (tool === PRP_BLOCK_TOOL_NAME && + validation.result.reportedWorkDisposition !== "blocked") || + (tool === PRP_COMPLETION_TOOL_NAME && + validation.result.reportedWorkDisposition === "blocked") + ) + throw new Error( + "Semantic result disposition does not match the terminal tool", + ); + if ( + validation.result.completionClaim.contractRevision !== + this.#taskEnvelope.completionContract.revision + ) { + throw new Error( + "Semantic result completion contract revision does not match", + ); + } + const expectedIds = this.#taskEnvelope.completionContract.criteria.map((criterion) => criterion.id); + const receivedIds = validation.result.completionClaim.criteria.map((criterion) => criterion.criterionId); + if (receivedIds.length !== expectedIds.length || new Set(receivedIds).size !== receivedIds.length || receivedIds.some((id) => !expectedIds.includes(id))) { + // Reject at the tool boundary so the provider can repair its claim. + // Emitting a bad result here kills runnerd's strict outer validation. + throw new Error(`Semantic result criteria must contain exactly these criterionIds, once each: ${JSON.stringify(expectedIds)}. Keep contractRevision ${JSON.stringify(this.#taskEnvelope.completionContract.revision)}.`); + } + const fingerprint = canonicalJson(validation.result); + if (this.#resultFingerprint && this.#resultFingerprint !== fingerprint) + throw new Error("A different semantic result was already committed"); + if (!this.#resultFingerprint) { + this.#result = structuredClone(validation.result); + this.#resultFingerprint = fingerprint; + this.#resultCallId = call.callId; + this.#resultTurnId = turnId; + this.#semanticResultTextBoundary = this.#completedTextParts.length; + this.#emit("run.result.proposed", validation.result, { + turnId, + itemId: call.callId, + }); + } + this.#emit( + "item.completed", + { + kind: "dynamicToolCall", + item: { + type: "tool_result", + id: call.callId, + tool_use_id: call.callId, + result: "Semantic completion accepted.", + }, }, - }, - { turnId, itemId: call.callId }, - ); - return { accepted: true }; + { turnId, itemId: call.callId }, + ); + return { accepted: true }; + } catch (error) { + // A rejected semantic call still completes its tool activity item. + // Otherwise a later question can appear to have an in-flight tool. + this.#emit("item.completed", { + kind: "dynamicToolCall", + item: { type: "tool_result", id: call.callId, tool_use_id: call.callId, + is_error: true, error: error instanceof Error ? error.message : String(error) }, + }, { turnId, itemId: call.callId }); + throw error; + } } if (!this.#dynamicToolHandler) throw new Error("Unsupported Paperclip operation"); diff --git a/packages/paperclip-runner/test/fixtures/fake-opencode-server.mjs b/packages/paperclip-runner/test/fixtures/fake-opencode-server.mjs index 1767c08d919..3a7db00308c 100644 --- a/packages/paperclip-runner/test/fixtures/fake-opencode-server.mjs +++ b/packages/paperclip-runner/test/fixtures/fake-opencode-server.mjs @@ -206,7 +206,9 @@ async function callTerminalTool(promptBody) { }; if (String(prompt.message ?? "").includes("repair-criteria")) { const bad = structuredClone(result); - bad.completionClaim.criteria = [{ criterionId: "invented", status: "satisfied", evidenceRefs: [] }]; + bad.completionClaim.criteria = String(prompt.message).includes("repair-criteria-missing") ? [] + : String(prompt.message).includes("repair-criteria-duplicate") ? [...result.completionClaim.criteria, ...result.completionClaim.criteria] + : [{ criterionId: "invented", status: "satisfied", evidenceRefs: [] }]; const rejected = await mcpRequest("tools/call", { name: "paperclip_finish", arguments: bad }); await writeFile(join(process.env.XDG_DATA_HOME, "fake-criteria-repair.json"), JSON.stringify(rejected)); }