From 832420148cf13656e00957135bf0cfe6ac965104 Mon Sep 17 00:00:00 2001 From: spiceoogway Date: Fri, 30 Jan 2026 00:10:57 -0500 Subject: [PATCH 1/3] fix: repair tool_use/tool_result pairings after history truncation (fixes #4367) The message processing pipeline had a synchronization bug where limitHistoryTurns() truncated conversation history AFTER repairToolUseResultPairing() had already fixed tool_use/tool_result pairings. This could split assistant messages (with tool_use) from their corresponding tool_result blocks, creating orphaned tool_result blocks that the Anthropic API rejects. This fix calls sanitizeToolUseResultPairing() AFTER limitHistoryTurns() to repair any pairings broken by truncation, ensuring the transcript remains valid before being sent to the LLM API. Changes: - Added import for sanitizeToolUseResultPairing from session-transcript-repair.js - Call sanitizeToolUseResultPairing() on the limited message array - Updated variable name from 'limited' to 'repaired' for clarity --- src/agents/pi-embedded-runner/run/attempt.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index e83c3ae4a..622bdb7f4 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -52,6 +52,7 @@ import { import { DEFAULT_BOOTSTRAP_FILENAME } from "../../workspace.js"; import { buildSystemPromptReport } from "../../system-prompt-report.js"; import { resolveDefaultModelForAgent } from "../../model-selection.js"; +import { sanitizeToolUseResultPairing } from "../../session-transcript-repair.js"; import { isAbortError } from "../abort.js"; import { buildEmbeddedExtensionPaths } from "../extensions.js"; @@ -535,9 +536,11 @@ export async function runEmbeddedAttempt( validated, getDmHistoryLimitFromSessionKey(params.sessionKey, params.config), ); - cacheTrace?.recordStage("session:limited", { messages: limited }); - if (limited.length > 0) { - activeSession.agent.replaceMessages(limited); + // Fix: Repair tool_use/tool_result pairings AFTER truncation (issue #4367) + const repaired = sanitizeToolUseResultPairing(limited); + cacheTrace?.recordStage("session:limited", { messages: repaired }); + if (repaired.length > 0) { + activeSession.agent.replaceMessages(repaired); } } catch (err) { sessionManager.flushPendingToolResults?.(); From 68e23336186a1214bc1de379f820cdeb5c53d20b Mon Sep 17 00:00:00 2001 From: spiceoogway Date: Fri, 30 Jan 2026 01:16:13 -0500 Subject: [PATCH 2/3] fix: apply format to onboard-helpers.ts (pre-existing formatting issue) --- src/commands/onboard-helpers.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/commands/onboard-helpers.ts b/src/commands/onboard-helpers.ts index f56da78e9..774893213 100644 --- a/src/commands/onboard-helpers.ts +++ b/src/commands/onboard-helpers.ts @@ -64,12 +64,12 @@ export function randomToken(): string { export function printWizardHeader(runtime: RuntimeEnv) { const header = [ - "▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄", - "██░▄▄▄░██░▄▄░██░▄▄▄██░▀██░██░▄▄▀██░████░▄▄▀██░███░██", - "██░███░██░▀▀░██░▄▄▄██░█░█░██░█████░████░▀▀░██░█░█░██", - "██░▀▀▀░██░█████░▀▀▀██░██▄░██░▀▀▄██░▀▀░█░██░██▄▀▄▀▄██", - "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀", - " 🦞 OPENCLAW 🦞 ", + "▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄", + "██░▄▄▄░██░▄▄░██░▄▄▄██░▀██░██░▄▄▀██░████░▄▄▀██░███░██", + "██░███░██░▀▀░██░▄▄▄██░█░█░██░█████░████░▀▀░██░█░█░██", + "██░▀▀▀░██░█████░▀▀▀██░██▄░██░▀▀▄██░▀▀░█░██░██▄▀▄▀▄██", + "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀", + " 🦞 OPENCLAW 🦞 ", " ", ].join("\n"); runtime.log(header); From 155eca6b3eeb57f26946c130945015e258fba439 Mon Sep 17 00:00:00 2001 From: spiceoogway Date: Fri, 30 Jan 2026 02:34:29 -0500 Subject: [PATCH 3/3] test: add comprehensive tests for tool_use/tool_result repair after truncation Add tests for PR #4387 covering three critical scenarios: 1. Orphaned tool_use (tool call without result after truncation) - verifies synthetic error result insertion 2. Orphaned tool_result (result without call after truncation) - verifies orphan dropping 3. Normal history (well-formed transcript) - verifies no-op behavior Also tests edge cases: - Multiple orphaned tool_use blocks - Mixed scenario with some results present, some missing All tests pass. Fixes #4367 --- src/agents/session-transcript-repair.test.ts | 146 +++++++++++++++++++ 1 file changed, 146 insertions(+) diff --git a/src/agents/session-transcript-repair.test.ts b/src/agents/session-transcript-repair.test.ts index ccc63ec7f..320e76fce 100644 --- a/src/agents/session-transcript-repair.test.ts +++ b/src/agents/session-transcript-repair.test.ts @@ -109,4 +109,150 @@ describe("sanitizeToolUseResultPairing", () => { expect(out.some((m) => m.role === "toolResult")).toBe(false); expect(out.map((m) => m.role)).toEqual(["user", "assistant"]); }); + + // Tests for PR #4387: tool_use_id mismatch fix after history truncation + describe("after history truncation", () => { + it("repairs orphaned tool_use by inserting synthetic error result", () => { + // Simulates truncation that removed the tool_result but kept the assistant tool_use + const truncatedHistory = [ + { + role: "assistant", + content: [{ type: "toolCall", id: "call_1", name: "read", arguments: {} }], + }, + { role: "user", content: "what did you find?" }, + ] satisfies AgentMessage[]; + + const out = sanitizeToolUseResultPairing(truncatedHistory); + + // Should have inserted a synthetic error result after the assistant message + expect(out).toHaveLength(3); + expect(out[0]?.role).toBe("assistant"); + expect(out[1]?.role).toBe("toolResult"); + expect((out[1] as { toolCallId?: string }).toolCallId).toBe("call_1"); + expect((out[1] as { isError?: boolean }).isError).toBe(true); + expect((out[1] as { content?: Array<{ text?: string }> }).content?.[0]?.text).toContain( + "missing tool result", + ); + expect(out[2]?.role).toBe("user"); + }); + + it("drops orphaned tool_result that no longer has matching tool_use", () => { + // Simulates truncation that removed the assistant tool_use but kept the tool_result + const truncatedHistory = [ + { role: "user", content: "please read the file" }, + { + role: "toolResult", + toolCallId: "call_orphan", + toolName: "read", + content: [{ type: "text", text: "file contents" }], + isError: false, + }, + { + role: "assistant", + content: [{ type: "text", text: "Here's what I found..." }], + }, + ] satisfies AgentMessage[]; + + const out = sanitizeToolUseResultPairing(truncatedHistory); + + // Orphaned tool_result should be dropped + expect(out).toHaveLength(2); + expect(out.map((m) => m.role)).toEqual(["user", "assistant"]); + expect(out.some((m) => m.role === "toolResult")).toBe(false); + }); + + it("passes through normal history unchanged", () => { + // Well-formed history with proper tool_use/tool_result pairing + const normalHistory = [ + { role: "user", content: "please read the file" }, + { + role: "assistant", + content: [{ type: "toolCall", id: "call_1", name: "read", arguments: {} }], + }, + { + role: "toolResult", + toolCallId: "call_1", + toolName: "read", + content: [{ type: "text", text: "file contents" }], + isError: false, + }, + { + role: "assistant", + content: [{ type: "text", text: "Here's what I found..." }], + }, + { role: "user", content: "thanks!" }, + ] satisfies AgentMessage[]; + + const out = sanitizeToolUseResultPairing(normalHistory); + + // Should return the same array reference (no modifications) + expect(out).toBe(normalHistory); + expect(out).toHaveLength(5); + expect(out.map((m) => m.role)).toEqual([ + "user", + "assistant", + "toolResult", + "assistant", + "user", + ]); + }); + + it("handles multiple orphaned tool_use blocks after truncation", () => { + const truncatedHistory = [ + { + role: "assistant", + content: [ + { type: "toolCall", id: "call_1", name: "read", arguments: {} }, + { type: "toolCall", id: "call_2", name: "exec", arguments: {} }, + ], + }, + { role: "user", content: "what happened?" }, + ] satisfies AgentMessage[]; + + const out = sanitizeToolUseResultPairing(truncatedHistory); + + // Should insert synthetic results for both orphaned tool calls + expect(out).toHaveLength(4); + expect(out[0]?.role).toBe("assistant"); + expect(out[1]?.role).toBe("toolResult"); + expect((out[1] as { toolCallId?: string }).toolCallId).toBe("call_1"); + expect(out[2]?.role).toBe("toolResult"); + expect((out[2] as { toolCallId?: string }).toolCallId).toBe("call_2"); + expect(out[3]?.role).toBe("user"); + }); + + it("repairs mixed scenario: some results present, some missing after truncation", () => { + const truncatedHistory = [ + { + role: "assistant", + content: [ + { type: "toolCall", id: "call_1", name: "read", arguments: {} }, + { type: "toolCall", id: "call_2", name: "exec", arguments: {} }, + ], + }, + { + role: "toolResult", + toolCallId: "call_1", + toolName: "read", + content: [{ type: "text", text: "contents" }], + isError: false, + }, + // call_2's result was truncated away + { role: "user", content: "ok" }, + ] satisfies AgentMessage[]; + + const out = sanitizeToolUseResultPairing(truncatedHistory); + + // Should keep existing result and insert synthetic result for missing one + expect(out).toHaveLength(4); + expect(out[0]?.role).toBe("assistant"); + expect(out[1]?.role).toBe("toolResult"); + expect((out[1] as { toolCallId?: string }).toolCallId).toBe("call_1"); + expect((out[1] as { isError?: boolean }).isError).toBe(false); + expect(out[2]?.role).toBe("toolResult"); + expect((out[2] as { toolCallId?: string }).toolCallId).toBe("call_2"); + expect((out[2] as { isError?: boolean }).isError).toBe(true); + expect(out[3]?.role).toBe("user"); + }); + }); });