fix(web): treat duplicate rectification tool calls as idempotent
Aborting the turn on a second identical public tool-call failed staging after evidence and compare had already succeeded. Skip the duplicate receipt instead and let maxSteps bound real loops. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -409,20 +409,32 @@ test("distinct evidence calls in one natural turn are not mistaken for a repeate
|
||||
]);
|
||||
});
|
||||
|
||||
test("a repeated identical tool call is detected and aborts the turn", async () => {
|
||||
const { options, billing } = runOptions({
|
||||
test("a repeated identical tool call is treated as idempotent and does not abort", async () => {
|
||||
const { options, emitted, billing } = runOptions({
|
||||
buildAgent: async () => fakeAgentStream([
|
||||
chunk("start"),
|
||||
chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }),
|
||||
chunk("tool-result", { toolName: "skill" }),
|
||||
...Array.from({ length: 2 }, () => chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } })),
|
||||
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
|
||||
chunk("tool-result", { toolName: "rectification-read-case" }),
|
||||
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
|
||||
chunk("finish"),
|
||||
]) as never,
|
||||
});
|
||||
const result = await runV9AgentTurn(options);
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.errorCode, "repeated_tool_call");
|
||||
assert.equal(billing.released, 1);
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.errorCode, null);
|
||||
assert.match(result.answerText, /已经记下|请继续说下一件/);
|
||||
assert.equal(billing.completed, 1);
|
||||
assert.equal(billing.released, 0);
|
||||
assert.equal(emitted.some((event) => event.type === "attempt.reset"), false);
|
||||
assert.equal(emitted.some((event) => event.type === "run.failed"), false);
|
||||
assert.equal(
|
||||
emitted.filter((event) => event.type === "tool.activity"
|
||||
&& (event as { tool?: string; status?: string }).tool === "rectification-read-case"
|
||||
&& (event as { tool?: string; status?: string }).status === "started").length,
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test("a failed opening does not let the next turn skip the server Skill load gate", async () => {
|
||||
|
||||
@@ -1050,7 +1050,11 @@ test("does not retry set-focus with identical arguments", async () => {
|
||||
...receiptHandlers,
|
||||
get_agentic_rectification_case_dossier: () => dossierFixture(),
|
||||
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
|
||||
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "failed", idempotent: false }),
|
||||
finalize_agentic_rectification_turn: (_fn, args) => ({
|
||||
turn_id: TURN_ID,
|
||||
status: args.p_status,
|
||||
idempotent: false,
|
||||
}),
|
||||
});
|
||||
const { options, emitted, billing } = runOptions({
|
||||
accounting: accounting.client,
|
||||
@@ -1071,10 +1075,66 @@ test("does not retry set-focus with identical arguments", async () => {
|
||||
|
||||
const result = await runV9AgentTurn(options);
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.errorCode, "repeated_tool_call");
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.errorCode, null);
|
||||
assert.equal(result.answerText, "主问题:请确认这段经历发生在哪个月?");
|
||||
assert.equal(emitted.some((event) => event.type === "attempt.reset"), false);
|
||||
assert.deepEqual(billing, { reserved: 1, completed: 0, released: 1 });
|
||||
assert.equal(emitted.some((event) => event.type === "run.failed"), false);
|
||||
assert.deepEqual(billing, { reserved: 1, completed: 1, released: 0 });
|
||||
});
|
||||
|
||||
test("duplicate compare after diagnostics still completes with server narration", async () => {
|
||||
const executedMethods = [
|
||||
"ashtakavarga",
|
||||
"d1-rashi",
|
||||
"d10-dashamsa",
|
||||
"shadbala",
|
||||
"functional-benefic-malefic",
|
||||
"arudha-pada",
|
||||
];
|
||||
const { options, emitted, billing } = runOptions({
|
||||
buildAgent: async () => fakeAgentStream([
|
||||
chunk("start"),
|
||||
chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }),
|
||||
chunk("tool-result", { toolName: "skill" }),
|
||||
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
|
||||
chunk("tool-result", { toolName: "rectification-read-case" }),
|
||||
chunk("tool-call", { toolName: "rectification-record-evidence-batch", args: { caseId: CASE_ID } }),
|
||||
chunk("tool-result", { toolName: "rectification-record-evidence-batch" }),
|
||||
chunk("tool-call", { toolName: "rectification-propose-evidence", args: { caseId: CASE_ID } }),
|
||||
chunk("tool-result", { toolName: "rectification-propose-evidence" }),
|
||||
chunk("tool-call", { toolName: "rectification-confirm-evidence", args: { caseId: CASE_ID } }),
|
||||
chunk("tool-result", { toolName: "rectification-confirm-evidence" }),
|
||||
chunk("tool-call", { toolName: "rectification-read-diagnostics", args: { caseId: CASE_ID } }),
|
||||
chunk("tool-result", {
|
||||
toolName: "rectification-read-diagnostics",
|
||||
result: { executed_methods: executedMethods },
|
||||
}),
|
||||
chunk("tool-call", { toolName: "rectification-compare-candidates", args: { caseId: CASE_ID } }),
|
||||
chunk("tool-result", {
|
||||
toolName: "rectification-compare-candidates",
|
||||
result: { executed_methods: executedMethods },
|
||||
}),
|
||||
chunk("tool-call", { toolName: "rectification-compare-candidates", args: { caseId: CASE_ID } }),
|
||||
chunk("finish"),
|
||||
]) as never,
|
||||
});
|
||||
|
||||
const result = await runV9AgentTurn(options);
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.errorCode, null);
|
||||
assert.match(result.answerText, /已经记下|请继续说下一件/);
|
||||
assert.equal(emitted.some((event) => event.type === "attempt.reset"), false);
|
||||
assert.equal(emitted.some((event) => event.type === "run.failed"), false);
|
||||
assert.equal(emitted.some((event) => event.type === "run.completed"), true);
|
||||
assert.equal(
|
||||
emitted.filter((event) => event.type === "tool.activity"
|
||||
&& (event as { tool?: string; status?: string }).tool === "rectification-compare-candidates"
|
||||
&& (event as { tool?: string; status?: string }).status === "started").length,
|
||||
1,
|
||||
);
|
||||
assert.deepEqual(billing, { reserved: 1, completed: 1, released: 0 });
|
||||
});
|
||||
|
||||
test("an unclaimed V10 attempt never starts the model", async () => {
|
||||
|
||||
Reference in New Issue
Block a user