This commit is contained in:
Parafee41 2026-01-30 18:26:11 +08:00 committed by GitHub
commit 1b9da76f17
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 232 additions and 2 deletions

View File

@ -1,6 +1,6 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { AgentMessage } from "@mariozechner/pi-agent-core";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { sanitizeToolUseResultPairing } from "./session-transcript-repair.js"; import { isIncompleteToolCall, sanitizeToolUseResultPairing } from "./session-transcript-repair.js";
describe("sanitizeToolUseResultPairing", () => { describe("sanitizeToolUseResultPairing", () => {
it("moves tool results directly after tool calls and inserts missing results", () => { it("moves tool results directly after tool calls and inserts missing results", () => {
@ -109,4 +109,201 @@ describe("sanitizeToolUseResultPairing", () => {
expect(out.some((m) => m.role === "toolResult")).toBe(false); expect(out.some((m) => m.role === "toolResult")).toBe(false);
expect(out.map((m) => m.role)).toEqual(["user", "assistant"]); expect(out.map((m) => m.role)).toEqual(["user", "assistant"]);
}); });
it("skips incomplete tool calls (partialJson, no arguments) and drops their synthetic results", () => {
// Simulates a terminated streaming response: the assistant message has a
// tool call with partialJson but no arguments, followed by a synthetic
// error toolResult inserted by session persistence. The repair must skip
// the incomplete tool call entirely so no synthetic result is emitted, and
// the orphaned synthetic result is dropped.
const input = [
{
role: "assistant",
content: [
{ type: "text", text: "Let me write that file:" },
{
type: "toolCall",
id: "toolu_terminated",
name: "write",
partialJson: '{"path": "/tmp/test.md", "content": "# Hello',
},
],
},
{
role: "toolResult",
toolCallId: "toolu_terminated",
toolName: "write",
content: [
{
type: "text",
text: "[moltbot] missing tool result in session history; inserted synthetic error result for transcript repair.",
},
],
isError: true,
},
{ role: "user", content: "what happened?" },
] satisfies AgentMessage[];
const out = sanitizeToolUseResultPairing(input);
// The incomplete tool call should be treated as having zero tool calls,
// so no toolResult should appear in the output.
expect(out.some((m) => m.role === "toolResult")).toBe(false);
expect(out.map((m) => m.role)).toEqual(["assistant", "user"]);
});
it("keeps complete tool calls even when partialJson is present", () => {
// A tool call that completed successfully may still carry partialJson
// from the streaming buffer. It must be treated normally.
const input = [
{
role: "assistant",
content: [
{
type: "toolCall",
id: "call_ok",
name: "read",
arguments: { path: "/tmp/test.md" },
partialJson: '{"path": "/tmp/test.md"}',
},
],
},
{
role: "toolResult",
toolCallId: "call_ok",
toolName: "read",
content: [{ type: "text", text: "file contents" }],
isError: false,
},
] satisfies AgentMessage[];
const out = sanitizeToolUseResultPairing(input);
expect(out).toHaveLength(2);
expect(out[0]?.role).toBe("assistant");
expect(out[1]?.role).toBe("toolResult");
});
it("handles mixed complete and incomplete tool calls in one assistant message", () => {
// The assistant issued two parallel tool calls but was terminated during
// the second. The first call completed and has a real result; the second
// is incomplete. Only the first should be paired.
const input = [
{
role: "assistant",
content: [
{ type: "toolCall", id: "call_done", name: "read", arguments: { path: "/a" } },
{
type: "toolCall",
id: "call_partial",
name: "write",
partialJson: '{"path": "/b", "content": "half...',
},
],
},
{
role: "toolResult",
toolCallId: "call_done",
toolName: "read",
content: [{ type: "text", text: "ok" }],
isError: false,
},
{
role: "toolResult",
toolCallId: "call_partial",
toolName: "write",
content: [{ type: "text", text: "[moltbot] synthetic" }],
isError: true,
},
{ role: "user", content: "continue" },
] satisfies AgentMessage[];
const out = sanitizeToolUseResultPairing(input);
const results = out.filter((m) => m.role === "toolResult");
// Only the completed tool call should have a result
expect(results).toHaveLength(1);
expect((results[0] as { toolCallId?: string }).toolCallId).toBe("call_done");
expect(out.map((m) => m.role)).toEqual(["assistant", "toolResult", "user"]);
});
it("handles incomplete tool call with empty arguments object", () => {
// Some providers set arguments to {} when the stream was interrupted
// before any argument parsing completed.
const input = [
{
role: "assistant",
content: [
{
type: "toolCall",
id: "call_empty_args",
name: "exec",
arguments: {},
partialJson: '{"command": "ls',
},
],
},
{ role: "user", content: "?" },
] satisfies AgentMessage[];
const out = sanitizeToolUseResultPairing(input);
expect(out.some((m) => m.role === "toolResult")).toBe(false);
expect(out.map((m) => m.role)).toEqual(["assistant", "user"]);
});
});
describe("isIncompleteToolCall", () => {
it("returns true for partialJson with no arguments", () => {
expect(
isIncompleteToolCall({
type: "toolCall",
id: "x",
name: "write",
partialJson: '{"path": "/tmp',
}),
).toBe(true);
});
it("returns true for partialJson with empty arguments", () => {
expect(
isIncompleteToolCall({
type: "toolCall",
id: "x",
name: "exec",
arguments: {},
partialJson: '{"command": "ls',
}),
).toBe(true);
});
it("returns false for complete tool call with partialJson", () => {
expect(
isIncompleteToolCall({
type: "toolCall",
id: "x",
name: "read",
arguments: { path: "/tmp/test" },
partialJson: '{"path": "/tmp/test"}',
}),
).toBe(false);
});
it("returns false for tool call without partialJson", () => {
expect(
isIncompleteToolCall({
type: "toolCall",
id: "x",
name: "read",
arguments: { path: "/tmp/test" },
}),
).toBe(false);
});
it("returns false when partialJson is empty string", () => {
expect(
isIncompleteToolCall({
type: "toolCall",
id: "x",
name: "read",
partialJson: "",
}),
).toBe(false);
});
}); });

View File

@ -5,6 +5,35 @@ type ToolCallLike = {
name?: string; name?: string;
}; };
/**
* Check whether a tool call block is incomplete (terminated mid-stream).
*
* When a streaming response is interrupted (`stopReason: "error"`,
* `errorMessage: "terminated"`), the assistant message may contain tool call
* blocks with only `partialJson` and no valid `arguments`. These were never
* executed, so inserting a synthetic `toolResult` for them causes Anthropic's
* API to reject the request with "unexpected tool_use_id".
*
* We detect incompleteness by checking:
* 1. `partialJson` is present (streaming was in progress), AND
* 2. `arguments` is missing, empty, or not a non-empty object.
*
* If `arguments` is fully populated the call completed successfully and should
* be treated normally, even if `partialJson` happens to be set.
*/
function isIncompleteToolCall(block: Record<string, unknown>): boolean {
if (typeof block.partialJson !== "string" || !block.partialJson) return false;
const args = block.arguments;
// No arguments at all → incomplete
if (args === undefined || args === null) return true;
// Empty object {} → incomplete (arguments were not parsed)
if (typeof args === "object" && !Array.isArray(args) && Object.keys(args as object).length === 0)
return true;
return false;
}
function extractToolCallsFromAssistant( function extractToolCallsFromAssistant(
msg: Extract<AgentMessage, { role: "assistant" }>, msg: Extract<AgentMessage, { role: "assistant" }>,
): ToolCallLike[] { ): ToolCallLike[] {
@ -18,6 +47,10 @@ function extractToolCallsFromAssistant(
if (typeof rec.id !== "string" || !rec.id) continue; if (typeof rec.id !== "string" || !rec.id) continue;
if (rec.type === "toolCall" || rec.type === "toolUse" || rec.type === "functionCall") { if (rec.type === "toolCall" || rec.type === "toolUse" || rec.type === "functionCall") {
// Skip incomplete tool calls from terminated/errored streaming responses.
// These were never executed, so they must not receive a synthetic toolResult.
if (isIncompleteToolCall(rec as Record<string, unknown>)) continue;
toolCalls.push({ toolCalls.push({
id: rec.id, id: rec.id,
name: typeof rec.name === "string" ? rec.name : undefined, name: typeof rec.name === "string" ? rec.name : undefined,
@ -54,7 +87,7 @@ function makeMissingToolResult(params: {
} as Extract<AgentMessage, { role: "toolResult" }>; } as Extract<AgentMessage, { role: "toolResult" }>;
} }
export { makeMissingToolResult }; export { isIncompleteToolCall, makeMissingToolResult };
export function sanitizeToolUseResultPairing(messages: AgentMessage[]): AgentMessage[] { export function sanitizeToolUseResultPairing(messages: AgentMessage[]): AgentMessage[] {
return repairToolUseResultPairing(messages).messages; return repairToolUseResultPairing(messages).messages;