fix(opencode): close rejected completion tool activity

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-09-19 08:07:07 -05:00
1 parent 97eac6d237
commit 917513edda
3 files changed
+75 -59

No files matched your search

@@ -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" });
});
@@ -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");
@@ -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));
}