diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 411afc81b7dd..10b5988a8b89 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -427,9 +427,124 @@ function mapProviderOptions( }) } +function ensureToolIntegrity(msgs: ModelMessage[]): ModelMessage[] { + const toolResultIds = new Set() + + for (const msg of msgs) { + if (msg.role !== "tool" || !Array.isArray(msg.content)) continue + for (const part of msg.content) { + if (part.type === "tool-result") { + toolResultIds.add(part.toolCallId) + } + } + } + + const result: ModelMessage[] = [] + const pendingOrphans: Array<{ toolCallId: string; toolName: string }> = [] + + for (let i = 0; i < msgs.length; i++) { + const msg = msgs[i] + + if (pendingOrphans.length > 0 && msg.role === "tool" && Array.isArray(msg.content)) { + const augmentedContent = [ + ...msg.content, + ...pendingOrphans.map((call) => ({ + type: "tool-result" as const, + toolCallId: call.toolCallId, + toolName: call.toolName, + output: { + type: "error-text" as const, + value: "[Tool result lost during session recovery]", + }, + })), + ] + result.push({ ...msg, content: augmentedContent } as ModelMessage) + for (const call of pendingOrphans) { + toolResultIds.add(call.toolCallId) + } + pendingOrphans.length = 0 + continue + } + + if (pendingOrphans.length > 0 && (msg.role === "user" || msg.role === "assistant")) { + const syntheticToolMsg: ModelMessage = { + role: "tool", + content: pendingOrphans.map((call) => ({ + type: "tool-result" as const, + toolCallId: call.toolCallId, + toolName: call.toolName, + output: { + type: "error-text" as const, + value: "[Tool result lost during session recovery]", + }, + })), + } + result.push(syntheticToolMsg as ModelMessage) + for (const call of pendingOrphans) { + toolResultIds.add(call.toolCallId) + } + pendingOrphans.length = 0 + } + + result.push(msg) + + if (msg.role !== "assistant" || !Array.isArray(msg.content)) continue + + const orphanedCalls = msg.content.filter( + (part): part is Extract => + part.type === "tool-call" && !toolResultIds.has(part.toolCallId), + ) + + if (orphanedCalls.length === 0) continue + + for (const call of orphanedCalls) { + pendingOrphans.push({ toolCallId: call.toolCallId, toolName: call.toolName }) + } + } + + // Orphans are flushed before user or assistant messages (block 469-487). + // Trailing orphans only remain when the last message is an assistant with + // in-progress tool calls - we intentionally preserve those for tool execution. + + return result +} + +function ensureUserFirst(msgs: ModelMessage[]): ModelMessage[] { + if (msgs.length === 0) return msgs + + const firstNonSystemIndex = msgs.findIndex((m) => m.role !== "system") + if (firstNonSystemIndex === -1) return msgs + + const firstNonSystem = msgs[firstNonSystemIndex] + if (firstNonSystem.role === "user") return msgs + + const syntheticUser: ModelMessage = { + role: "user", + content: [{ type: "text", text: "[Session context restored]" }], + } as ModelMessage + + return [...msgs.slice(0, firstNonSystemIndex), syntheticUser, ...msgs.slice(firstNonSystemIndex)] +} + +function isAnthropicLike(model: Provider.Model): boolean { + return ( + model.providerID === "anthropic" || + model.providerID === "google-vertex-anthropic" || + model.api.id.includes("anthropic") || + model.api.id.includes("claude") || + model.id.includes("anthropic") || + model.id.includes("claude") || + model.api.npm === "@ai-sdk/anthropic" + ) +} + export function message(msgs: ModelMessage[], model: Provider.Model, options: Record) { msgs = unsupportedParts(msgs, model) msgs = normalizeMessages(msgs, model, options) + msgs = ensureToolIntegrity(msgs) + if (isAnthropicLike(model)) { + msgs = ensureUserFirst(msgs) + } if ( (model.providerID === "anthropic" || model.providerID === "google-vertex-anthropic" || diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index d9a980d966ac..37467ccf35e4 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -1599,6 +1599,7 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => test("filters out empty text parts from array content", () => { const msgs = [ + { role: "user", content: "Start" }, { role: "assistant", content: [ @@ -1611,13 +1612,14 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => const result = ProviderTransform.message(msgs, anthropicModel, {}) - expect(result).toHaveLength(1) - expect(result[0].content).toHaveLength(1) - expect(result[0].content[0]).toEqual({ type: "text", text: "Hello" }) + expect(result).toHaveLength(2) + expect(result[1].content).toHaveLength(1) + expect(result[1].content[0]).toEqual({ type: "text", text: "Hello" }) }) test("filters out empty reasoning parts from array content", () => { const msgs = [ + { role: "user", content: "Start" }, { role: "assistant", content: [ @@ -1630,9 +1632,9 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => const result = ProviderTransform.message(msgs, anthropicModel, {}) - expect(result).toHaveLength(1) - expect(result[0].content).toHaveLength(1) - expect(result[0].content[0]).toEqual({ type: "text", text: "Answer" }) + expect(result).toHaveLength(2) + expect(result[1].content).toHaveLength(1) + expect(result[1].content[0]).toEqual({ type: "text", text: "Answer" }) }) test("removes entire message when all parts are empty", () => { @@ -1657,6 +1659,7 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => test("keeps non-text/reasoning parts even if text parts are empty", () => { const msgs = [ + { role: "user", content: "Start" }, { role: "assistant", content: [ @@ -1664,13 +1667,14 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => { type: "tool-call", toolCallId: "123", toolName: "bash", input: { command: "ls" } }, ], }, + { role: "tool", content: [{ type: "tool-result", toolCallId: "123", toolName: "bash", output: { type: "text", value: "done" } }] }, ] as any[] const result = ProviderTransform.message(msgs, anthropicModel, {}) - expect(result).toHaveLength(1) - expect(result[0].content).toHaveLength(1) - expect(result[0].content[0]).toEqual({ + expect(result).toHaveLength(3) + expect(result[1].content).toHaveLength(1) + expect(result[1].content[0]).toEqual({ type: "tool-call", toolCallId: "123", toolName: "bash", @@ -1680,6 +1684,7 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => test("keeps messages with valid text alongside empty parts", () => { const msgs = [ + { role: "user", content: "Start" }, { role: "assistant", content: [ @@ -1692,10 +1697,10 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => const result = ProviderTransform.message(msgs, anthropicModel, {}) - expect(result).toHaveLength(1) - expect(result[0].content).toHaveLength(2) - expect(result[0].content[0]).toEqual({ type: "reasoning", text: "Thinking..." }) - expect(result[0].content[1]).toEqual({ type: "text", text: "Result" }) + expect(result).toHaveLength(2) + expect(result[1].content).toHaveLength(2) + expect(result[1].content[0]).toEqual({ type: "reasoning", text: "Thinking..." }) + expect(result[1].content[1]).toEqual({ type: "text", text: "Result" }) }) test("filters empty content for bedrock provider", () => { @@ -1742,6 +1747,7 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => } const msgs = [ + { role: "user", content: "Start" }, { role: "assistant", content: "" }, { role: "assistant", @@ -1751,13 +1757,14 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => const result = ProviderTransform.message(msgs, openaiModel, {}) - expect(result).toHaveLength(2) - expect(result[0].content).toBe("") - expect(result[1].content).toHaveLength(1) + expect(result).toHaveLength(3) + expect(result[1].content).toBe("") + expect(result[2].content).toHaveLength(1) }) test("leaves valid anthropic assistant tool ordering unchanged", () => { const msgs = [ + { role: "user", content: "Start" }, { role: "assistant", content: [ @@ -1766,12 +1773,16 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => { type: "tool-call", toolCallId: "toolu_2", toolName: "glob", input: { pattern: "**/*.pdf" } }, ], }, + { role: "tool", content: [ + { type: "tool-result", toolCallId: "toolu_1", toolName: "read", output: { type: "text", value: "done" } }, + { type: "tool-result", toolCallId: "toolu_2", toolName: "glob", output: { type: "text", value: "done" } }, + ]}, ] as any[] const result = ProviderTransform.message(msgs, anthropicModel, {}) as any[] - expect(result).toHaveLength(1) - expect(result[0].content).toMatchObject([ + expect(result).toHaveLength(3) + expect(result[1].content).toMatchObject([ { type: "text", text: "I checked your home directory and looked for PDF files." }, { type: "tool-call", toolCallId: "toolu_1", toolName: "read", input: { filePath: "/root" } }, { type: "tool-call", toolCallId: "toolu_2", toolName: "glob", input: { pattern: "**/*.pdf" } }, @@ -2008,6 +2019,7 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( }, } const msgs = [ + { role: "user", content: "Start" }, { role: "assistant", content: [ @@ -2027,7 +2039,7 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( // store=false preserves metadata for non-openai packages const result = ProviderTransform.message(msgs, anthropicModel, { store: false }) as any[] - expect(result[0].content[0].providerOptions?.openai?.itemId).toBe("msg_123") + expect(result[1].content[0].providerOptions?.openai?.itemId).toBe("msg_123") }) test("preserves metadata using providerID key when store is false", () => { @@ -2117,6 +2129,10 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( }, } const msgs = [ + { + role: "user", + content: [{ type: "text", text: "Hello" }], + }, { role: "assistant", content: [ @@ -2135,7 +2151,7 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( const result = ProviderTransform.message(msgs, anthropicModel, {}) as any[] - expect(result[0].content[0].providerOptions?.openai?.itemId).toBe("msg_123") + expect(result[1].content[0].providerOptions?.openai?.itemId).toBe("msg_123") }) }) @@ -4089,3 +4105,379 @@ describe("ProviderTransform.providerOptions - ai-gateway-provider", () => { expect(result).toEqual({ openaiCompatible: { reasoningEffort: "high" } }) }) }) + +describe("ProviderTransform.message - orphaned tool_use repair", () => { + const anthropicModel = { + id: "anthropic/claude-3-5-sonnet", + providerID: "anthropic", + api: { + id: "claude-3-5-sonnet-20241022", + url: "https://api.anthropic.com", + npm: "@ai-sdk/anthropic", + }, + name: "Claude 3.5 Sonnet", + capabilities: { + temperature: true, + reasoning: false, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: true }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { + input: 0.003, + output: 0.015, + cache: { read: 0.0003, write: 0.00375 }, + }, + limit: { + context: 200000, + output: 8192, + }, + status: "active", + options: {}, + headers: {}, + } as any + + test("injects synthetic tool-result for orphaned tool-call", () => { + const msgs = [ + { role: "user", content: "Hello" }, + { + role: "assistant", + content: [ + { type: "text", text: "Let me check that." }, + { type: "tool-call", toolCallId: "toolu_orphan123", toolName: "read", args: { path: "/test" } }, + ], + }, + { role: "user", content: "Continue" }, + ] as any[] + + const result = ProviderTransform.message(msgs, anthropicModel, {}) as any[] + + expect(result).toHaveLength(4) + expect(result[2].role).toBe("tool") + expect(result[2].content).toHaveLength(1) + expect(result[2].content[0].type).toBe("tool-result") + expect(result[2].content[0].toolCallId).toBe("toolu_orphan123") + expect(result[2].content[0].output.type).toBe("error-text") + }) + + test("does not modify messages when all tool-calls have matching tool-results", () => { + const msgs = [ + { role: "user", content: "Hello" }, + { + role: "assistant", + content: [ + { type: "text", text: "Let me check that." }, + { type: "tool-call", toolCallId: "toolu_matched123", toolName: "read", args: { path: "/test" } }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "toolu_matched123", + toolName: "read", + output: { type: "text", value: "file contents" }, + }, + ], + }, + { role: "user", content: "Thanks" }, + ] as any[] + + const result = ProviderTransform.message(msgs, anthropicModel, {}) as any[] + + expect(result).toHaveLength(4) + expect(result[2].role).toBe("tool") + expect(result[2].content[0].toolCallId).toBe("toolu_matched123") + expect(result[2].content[0].output.type).toBe("text") + }) + + test("handles multiple orphaned tool-calls in single assistant message", () => { + const msgs = [ + { role: "user", content: "Hello" }, + { + role: "assistant", + content: [ + { type: "tool-call", toolCallId: "toolu_orphan1", toolName: "read", args: {} }, + { type: "tool-call", toolCallId: "toolu_orphan2", toolName: "bash", args: {} }, + ], + }, + { role: "user", content: "Continue" }, + ] as any[] + + const result = ProviderTransform.message(msgs, anthropicModel, {}) as any[] + + expect(result).toHaveLength(4) + expect(result[2].role).toBe("tool") + expect(result[2].content).toHaveLength(2) + expect(result[2].content[0].toolCallId).toBe("toolu_orphan1") + expect(result[2].content[1].toolCallId).toBe("toolu_orphan2") + }) + + test("appends to existing tool message when partial orphans exist", () => { + const msgs = [ + { role: "user", content: "Hello" }, + { + role: "assistant", + content: [ + { type: "tool-call", toolCallId: "toolu_matched", toolName: "read", args: {} }, + { type: "tool-call", toolCallId: "toolu_orphan", toolName: "bash", args: {} }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "toolu_matched", + toolName: "read", + output: { type: "text", value: "ok" }, + }, + ], + }, + { role: "user", content: "Continue" }, + ] as any[] + + const result = ProviderTransform.message(msgs, anthropicModel, {}) as any[] + + expect(result).toHaveLength(4) + expect(result[2].role).toBe("tool") + expect(result[2].content).toHaveLength(2) + expect(result[2].content.find((c: any) => c.toolCallId === "toolu_orphan")).toBeDefined() + }) + + test("does not mutate original input messages when appending orphan results", () => { + const originalToolContent = [ + { + type: "tool-result", + toolCallId: "toolu_matched", + toolName: "read", + output: { type: "text", value: "ok" }, + }, + ] + const msgs = [ + { role: "user", content: "Hello" }, + { + role: "assistant", + content: [ + { type: "tool-call", toolCallId: "toolu_matched", toolName: "read", args: {} }, + { type: "tool-call", toolCallId: "toolu_orphan", toolName: "bash", args: {} }, + ], + }, + { + role: "tool", + content: originalToolContent, + }, + { role: "user", content: "Continue" }, + ] as any[] + + const originalContentLength = originalToolContent.length + + const result = ProviderTransform.message(msgs, anthropicModel, {}) as any[] + + expect(originalToolContent.length).toBe(originalContentLength) + expect(originalToolContent).toHaveLength(1) + expect(originalToolContent[0].toolCallId).toBe("toolu_matched") + + const toolMsg = result.find((m: any) => m.role === "tool") + expect(toolMsg.content).toHaveLength(2) + expect(toolMsg.content).not.toBe(originalToolContent) + }) + + test("does not inject synthetic result when orphan is at end of message array (in-progress turn)", () => { + const msgs = [ + { role: "user", content: "Hello" }, + { + role: "assistant", + content: [ + { type: "text", text: "Let me check." }, + { type: "tool-call", toolCallId: "toolu_final", toolName: "read", args: {} }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, anthropicModel, {}) as any[] + + expect(result).toHaveLength(2) + expect(result[1].role).toBe("assistant") + }) + + test("handles orphan when assistant is followed by another assistant", () => { + const msgs = [ + { role: "user", content: "Hello" }, + { + role: "assistant", + content: [ + { type: "tool-call", toolCallId: "toolu_orphan", toolName: "read", args: {} }, + ], + }, + { + role: "assistant", + content: [{ type: "text", text: "Actually, let me try something else." }], + }, + { role: "user", content: "OK" }, + ] as any[] + + const result = ProviderTransform.message(msgs, anthropicModel, {}) as any[] + + const hasToolResult = result.some( + (m: any) => m.role === "tool" && m.content?.some((c: any) => c.toolCallId === "toolu_orphan"), + ) + expect(hasToolResult).toBe(true) + }) +}) + +describe("ProviderTransform.message - ensureUserFirst for Anthropic", () => { + const anthropicModel = { + id: "anthropic/claude-3-5-sonnet", + providerID: "anthropic", + api: { + id: "claude-3-5-sonnet-20241022", + url: "https://api.anthropic.com", + npm: "@ai-sdk/anthropic", + }, + name: "Claude 3.5 Sonnet", + capabilities: { + temperature: true, + reasoning: false, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: true }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { input: 0.003, output: 0.015, cache: { read: 0.0003, write: 0.00375 } }, + limit: { context: 200000, output: 8192 }, + status: "active", + options: {}, + headers: {}, + } as any + + const openaiModel = { + id: "openai/gpt-4o", + providerID: "openai", + api: { id: "gpt-4o", url: "https://api.openai.com", npm: "@ai-sdk/openai" }, + name: "GPT-4o", + capabilities: { + temperature: true, + reasoning: false, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: true, + }, + cost: { input: 0.005, output: 0.015 }, + limit: { context: 128000, output: 16000 }, + status: "active", + options: {}, + headers: {}, + } as any + + test("inserts synthetic user after system messages when first non-system is assistant", () => { + const msgs = [ + { role: "system", content: "You are helpful" }, + { role: "system", content: "Additional instructions" }, + { role: "assistant", content: [{ type: "text", text: "Hello" }] }, + { role: "user", content: [{ type: "text", text: "Hi" }] }, + ] as any[] + + const result = ProviderTransform.message(msgs, anthropicModel, {}) as any[] + + expect(result[0].role).toBe("system") + expect(result[1].role).toBe("system") + expect(result[2].role).toBe("user") + expect(result[2].content[0].text).toBe("[Session context restored]") + expect(result[3].role).toBe("assistant") + }) + + test("inserts synthetic user when messages start with assistant (no system)", () => { + const msgs = [ + { role: "assistant", content: [{ type: "text", text: "Hello" }] }, + { role: "user", content: [{ type: "text", text: "Hi" }] }, + ] as any[] + + const result = ProviderTransform.message(msgs, anthropicModel, {}) as any[] + + expect(result[0].role).toBe("user") + expect(result[0].content[0].text).toBe("[Session context restored]") + expect(result[1].role).toBe("assistant") + }) + + test("does not modify messages when first non-system is user", () => { + const msgs = [ + { role: "system", content: "You are helpful" }, + { role: "user", content: [{ type: "text", text: "Hi" }] }, + { role: "assistant", content: [{ type: "text", text: "Hello" }] }, + ] as any[] + + const result = ProviderTransform.message(msgs, anthropicModel, {}) as any[] + + expect(result).toHaveLength(3) + expect(result[0].role).toBe("system") + expect(result[1].role).toBe("user") + expect(result[2].role).toBe("assistant") + }) + + test("does not modify OpenAI messages when first non-system is assistant", () => { + const msgs = [ + { role: "system", content: "You are helpful" }, + { role: "assistant", content: [{ type: "text", text: "Hello" }] }, + { role: "user", content: [{ type: "text", text: "Hi" }] }, + ] as any[] + + const result = ProviderTransform.message(msgs, openaiModel, {}) as any[] + + expect(result).toHaveLength(3) + expect(result[0].role).toBe("system") + expect(result[1].role).toBe("assistant") + expect(result[2].role).toBe("user") + }) + + test("handles system followed by assistant with tool-call", () => { + const msgs = [ + { role: "system", content: "You are helpful" }, + { + role: "assistant", + content: [ + { type: "text", text: "Let me help" }, + { type: "tool-call", toolCallId: "toolu_123", toolName: "read", input: {} }, + ], + }, + { + role: "tool", + content: [{ type: "tool-result", toolCallId: "toolu_123", toolName: "read", output: { type: "text", value: "content" } }], + }, + { role: "user", content: [{ type: "text", text: "Thanks" }] }, + ] as any[] + + const result = ProviderTransform.message(msgs, anthropicModel, {}) as any[] + + expect(result[0].role).toBe("system") + expect(result[1].role).toBe("user") + expect(result[1].content[0].text).toBe("[Session context restored]") + expect(result[2].role).toBe("assistant") + expect(result[3].role).toBe("tool") + }) + + test("returns empty array unchanged", () => { + const msgs: any[] = [] + const result = ProviderTransform.message(msgs, anthropicModel, {}) as any[] + expect(result).toHaveLength(0) + }) + + test("returns all-system messages unchanged", () => { + const msgs = [ + { role: "system", content: "You are helpful" }, + { role: "system", content: "Additional instructions" }, + ] as any[] + + const result = ProviderTransform.message(msgs, anthropicModel, {}) as any[] + + expect(result).toHaveLength(2) + expect(result[0].role).toBe("system") + expect(result[1].role).toBe("system") + }) +}) diff --git a/packages/opencode/test/session/compaction-tool-orphan.test.ts b/packages/opencode/test/session/compaction-tool-orphan.test.ts new file mode 100644 index 000000000000..881e4f2245ae --- /dev/null +++ b/packages/opencode/test/session/compaction-tool-orphan.test.ts @@ -0,0 +1,315 @@ +import { describe, it, expect } from "bun:test" +import { MessageV2 } from "@/session/message-v2" +import { SessionV1 } from "@opencode-ai/core/v1/session" +import { message as transformMessage } from "@/provider/transform" + +const anthropicModel = { + id: "anthropic/claude-test", + providerID: "anthropic", + api: { + id: "claude-test", + url: "https://api.anthropic.com", + npm: "@ai-sdk/anthropic", + }, + name: "Claude Test", + capabilities: { + temperature: true, + reasoning: true, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: true }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { input: 0.015, output: 0.075, cache: { read: 0.0015, write: 0.01875 } }, + limit: { context: 200000, output: 32000 }, + status: "active", + options: {}, + headers: {}, +} as any + +describe("Compaction tool orphan scenario", () => { + it("should produce tool-result for completed tools in nested compaction scenario", async () => { + // Simulate the exact production scenario: + // After filterCompacted reordering for the second compaction, selected.head contains: + // [0] tail-assistant (from first compaction) with completed tool + // [1] continue-user + // [2...] more messages + + const internalMessages: SessionV1.WithParts[] = [ + // Tail-assistant with completed tool (was at filterCompacted[2], now at head[0]) + { + info: { + id: "msg_tail", + role: "assistant", + parentID: "msg_prev_user", + sessionID: "ses_test", + providerID: "anthropic", + modelID: "claude-test-other", + finish: "tool-calls", + } as any, + parts: [ + { type: "text", text: "Continuing the cleanup." } as any, + { + type: "tool", + tool: "edit", + callID: "toolu_test_completed_tool", + state: { + status: "completed", + input: { filePath: "/test.md", oldString: "old", newString: "new" }, + output: "Edit applied successfully", + time: { start: 1000, end: 2000 }, + }, + } as any, + ], + }, + // Continue-user + { + info: { + id: "msg_continue", + role: "user", + parentID: "msg_tail", + sessionID: "ses_test", + } as any, + parts: [ + { type: "text", text: "Continue" } as any, + ], + }, + // Another assistant (for more context) + { + info: { + id: "msg_assistant2", + role: "assistant", + parentID: "msg_continue", + sessionID: "ses_test", + providerID: "anthropic", + modelID: "claude-test", + finish: "stop", + } as any, + parts: [ + { type: "text", text: "Done with the task." } as any, + ], + }, + ] + + // Run toModelMessagesEffect + const modelMessages = await MessageV2.toModelMessages(internalMessages, anthropicModel, { + stripMedia: true, + toolOutputMaxChars: 2000, + }) + + // Verify tool-result is produced + const hasToolResult = modelMessages.some( + (msg) => msg.role === "tool" && + Array.isArray(msg.content) && + msg.content.some((p: any) => p.type === "tool-result" && p.toolCallId === "toolu_test_completed_tool") + ) + expect(hasToolResult).toBe(true) + + // Apply ProviderTransform.message (which calls ensureToolIntegrity) + const transformed = transformMessage(modelMessages, anthropicModel, {}) + + // Verify no orphaned tool-calls after transform + const toolResultIds = new Set() + for (const msg of transformed) { + if (msg.role === "tool" && Array.isArray(msg.content)) { + for (const part of msg.content as any[]) { + if (part.type === "tool-result") { + toolResultIds.add(part.toolCallId) + } + } + } + } + + const orphanedCalls: string[] = [] + for (const msg of transformed) { + if (msg.role === "assistant" && Array.isArray(msg.content)) { + for (const part of msg.content as any[]) { + if (part.type === "tool-call" && !toolResultIds.has(part.toolCallId)) { + orphanedCalls.push(part.toolCallId) + } + } + } + } + + expect(orphanedCalls).toEqual([]) + }) + + it("should flush orphans when user message follows assistant with orphan tool-call", async () => { + const modelMessages = [ + { role: "user" as const, content: [{ type: "text" as const, text: "Start" }] }, + { + role: "assistant" as const, + content: [ + { type: "text" as const, text: "Working..." }, + { type: "tool-call" as const, toolCallId: "toolu_orphan", toolName: "edit", input: {} } + ] + }, + { role: "user" as const, content: [{ type: "text" as const, text: "Summarize" }] }, + ] + + const transformed = transformMessage(modelMessages as any, anthropicModel, {}) + + const toolMsg = transformed.find( + (msg) => msg.role === "tool" && + Array.isArray(msg.content) && + msg.content.some((p: any) => p.type === "tool-result" && p.toolCallId === "toolu_orphan") + ) + expect(toolMsg).toBeDefined() + + const syntheticResult = (toolMsg?.content as any[])?.find((p: any) => p.toolCallId === "toolu_orphan") + expect(syntheticResult?.output?.value).toBe("[Tool result lost during session recovery]") + expect(syntheticResult?.toolName).toBe("edit") + + const toolIndex = transformed.indexOf(toolMsg!) + const lastUserIndex = transformed.length - 1 + expect(toolIndex).toBeLessThan(lastUserIndex) + expect(transformed[toolIndex + 1]?.role).toBe("user") + }) + + it("should handle different model scenario (differentModel flag)", async () => { + const internalMessages: SessionV1.WithParts[] = [ + { + info: { + id: "msg_tail", + role: "assistant", + parentID: "msg_prev_user", + sessionID: "ses_test", + providerID: "anthropic", + modelID: "claude-test-other", + finish: "tool-calls", + } as any, + parts: [ + { type: "text", text: "Cleanup." } as any, + { + type: "tool", + tool: "edit", + callID: "toolu_different_model", + state: { + status: "completed", + input: { filePath: "/test.md" }, + output: "Done", + time: { start: 1000, end: 2000 }, + }, + } as any, + ], + }, + { + info: { + id: "msg_user", + role: "user", + parentID: "msg_tail", + sessionID: "ses_test", + } as any, + parts: [ + { type: "text", text: "Continue" } as any, + ], + }, + ] + + const modelMessages = await MessageV2.toModelMessages(internalMessages, anthropicModel, {}) + + const hasToolResult = modelMessages.some( + (msg) => msg.role === "tool" && + Array.isArray(msg.content) && + msg.content.some((p: any) => p.type === "tool-result" && p.toolCallId === "toolu_different_model") + ) + expect(hasToolResult).toBe(true) + }) + + it("should NOT inject synthetic tool-result when last message is assistant with in-progress tool-call", () => { + const modelMessages = [ + { role: "user" as const, content: [{ type: "text" as const, text: "Start" }] }, + { + role: "assistant" as const, + content: [ + { type: "text" as const, text: "Working..." }, + { type: "tool-call" as const, toolCallId: "toolu_in_progress", toolName: "bash", input: {} } + ] + }, + ] + + const transformed = transformMessage(modelMessages as any, anthropicModel, {}) + + const hasSyntheticTool = transformed.some( + (msg) => msg.role === "tool" && + Array.isArray(msg.content) && + msg.content.some((p: any) => p.toolCallId === "toolu_in_progress") + ) + expect(hasSyntheticTool).toBe(false) + + expect(transformed[transformed.length - 1]?.role).toBe("assistant") + }) + + it("should augment existing tool message when orphan precedes it", () => { + const modelMessages = [ + { role: "user" as const, content: [{ type: "text" as const, text: "Start" }] }, + { + role: "assistant" as const, + content: [ + { type: "tool-call" as const, toolCallId: "toolu_orphan", toolName: "read", input: {} }, + { type: "tool-call" as const, toolCallId: "toolu_resolved", toolName: "write", input: {} } + ] + }, + { + role: "tool" as const, + content: [ + { type: "tool-result" as const, toolCallId: "toolu_resolved", toolName: "write", output: { type: "text", value: "ok" } } + ] + }, + { role: "user" as const, content: [{ type: "text" as const, text: "Continue" }] }, + ] + + const transformed = transformMessage(modelMessages as any, anthropicModel, {}) + + const toolMsgs = transformed.filter((msg) => msg.role === "tool") + expect(toolMsgs.length).toBe(1) + + const toolContent = toolMsgs[0]?.content as any[] + expect(toolContent.length).toBe(2) + + const orphanResult = toolContent.find((p: any) => p.toolCallId === "toolu_orphan") + expect(orphanResult).toBeDefined() + expect(orphanResult?.output?.value).toBe("[Tool result lost during session recovery]") + + const resolvedResult = toolContent.find((p: any) => p.toolCallId === "toolu_resolved") + expect(resolvedResult).toBeDefined() + }) + + it("should flush orphan when adjacent assistant follows assistant with orphan tool-call", () => { + const modelMessages = [ + { role: "user" as const, content: [{ type: "text" as const, text: "Start" }] }, + { + role: "assistant" as const, + content: [ + { type: "text" as const, text: "Let me check..." }, + { type: "tool-call" as const, toolCallId: "toolu_orphan", toolName: "read", input: {} } + ] + }, + { + role: "assistant" as const, + content: [{ type: "text" as const, text: "Here is the result." }] + }, + ] + + const transformed = transformMessage(modelMessages as any, anthropicModel, {}) + + const toolMsg = transformed.find( + (msg) => msg.role === "tool" && + Array.isArray(msg.content) && + msg.content.some((p: any) => p.toolCallId === "toolu_orphan") + ) + expect(toolMsg).toBeDefined() + + const syntheticResult = (toolMsg?.content as any[])?.find((p: any) => p.toolCallId === "toolu_orphan") + expect(syntheticResult?.output?.value).toBe("[Tool result lost during session recovery]") + + const toolIndex = transformed.findIndex((msg) => msg === toolMsg) + const secondAssistantIndex = transformed.findIndex( + (msg, i) => i > 0 && msg.role === "assistant" && + Array.isArray(msg.content) && + msg.content.some((p: any) => p.text === "Here is the result.") + ) + expect(toolIndex).toBeLessThan(secondAssistantIndex) + }) +})