mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 02:26:12 +02:00
fix(opencode): close rejected completion tool activity
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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));
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user