diff --git a/src/hooks/session-recovery/recover-tool-result-missing.test.ts b/src/hooks/session-recovery/recover-tool-result-missing.test.ts index 430f4e719..add36d9a3 100644 --- a/src/hooks/session-recovery/recover-tool-result-missing.test.ts +++ b/src/hooks/session-recovery/recover-tool-result-missing.test.ts @@ -82,8 +82,10 @@ describe("recoverToolResultMissing", () => { body: { parts: [{ type: "tool_result", + toolUseId: "call_recovered", tool_use_id: "call_recovered", - content: "Operation cancelled by user (ESC pressed)", + isError: true, + content: [{ type: "text", text: "Operation cancelled by user (ESC pressed)" }], }], }, }) @@ -123,8 +125,10 @@ describe("recoverToolResultMissing", () => { body: { parts: [{ type: "tool_result", + toolUseId: "toolu_recovered", tool_use_id: "toolu_recovered", - content: "Operation cancelled by user (ESC pressed)", + isError: true, + content: [{ type: "text", text: "Operation cancelled by user (ESC pressed)" }], }], }, }) diff --git a/src/hooks/session-recovery/recover-tool-result-missing.ts b/src/hooks/session-recovery/recover-tool-result-missing.ts index 60d5f7aec..b01c926ec 100644 --- a/src/hooks/session-recovery/recover-tool-result-missing.ts +++ b/src/hooks/session-recovery/recover-tool-result-missing.ts @@ -6,6 +6,14 @@ import { normalizeSDKResponse } from "../../shared" import { promptAsyncAfterSessionIdle } from "../shared/prompt-async-gate" type Client = ReturnType +type ToolResultContent = { type: "text"; text: string } +type ToolResultPart = { + type: "tool_result" + toolUseId: string + tool_use_id?: string + isError?: boolean + content: ToolResultContent[] +} type ClientWithPromptAsync = { session: { promptAsync: (opts: { path: { id: string }; body: Record }) => Promise @@ -96,8 +104,10 @@ export async function recoverToolResultMissing( const toolResultParts = toolUseIds.map((id) => ({ type: "tool_result" as const, + toolUseId: id, tool_use_id: id, - content: "Operation cancelled by user (ESC pressed)", + isError: true, + content: [{ type: "text" as const, text: "Operation cancelled by user (ESC pressed)" }], })) const launchAgent = resumeConfig?.agent diff --git a/src/hooks/session-recovery/recover-unavailable-tool.test.ts b/src/hooks/session-recovery/recover-unavailable-tool.test.ts new file mode 100644 index 000000000..4076283f5 --- /dev/null +++ b/src/hooks/session-recovery/recover-unavailable-tool.test.ts @@ -0,0 +1,105 @@ +import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test" + +import type { MessageData } from "./types" + +let sqliteBackend = false +let storedParts: Array<{ type: string; id?: string; callID?: string; name?: string; tool?: string; [key: string]: unknown }> = [] + +mock.module("../../shared/opencode-storage-detection", () => ({ + isSqliteBackend: () => sqliteBackend, +})) + +mock.module("./storage", () => ({ + readParts: () => storedParts, +})) + +const { recoverUnavailableTool } = await import("./recover-unavailable-tool") + +const failedAssistantMsg: MessageData = { + info: { id: "msg_failed", role: "assistant", error: 'No such tool: bash' }, + parts: [], +} + +function createMockClient(messages: MessageData[] = []) { + const promptAsync = mock(() => Promise.resolve({})) + + return { + client: { + session: { + messages: mock(() => Promise.resolve({ data: messages })), + promptAsync, + }, + } as never, + promptAsync, + } +} + +describe("recoverUnavailableTool", () => { + beforeEach(() => { + sqliteBackend = false + storedParts = [] + }) + + afterEach(() => { + mock.restore() + }) + + it("sends a schema-compatible recovered tool result for sqlite fallback", async () => { + //#given + sqliteBackend = true + const { client, promptAsync } = createMockClient([ + { + info: { id: "msg_failed", role: "assistant" }, + parts: [{ type: "tool", id: "prt_valid_call", callID: "call_recovered", name: "bash", input: {} }], + }, + ]) + + //#when + const result = await recoverUnavailableTool(client, "ses_1", failedAssistantMsg) + + //#then + expect(result).toBe(true) + expect(promptAsync).toHaveBeenCalledWith({ + path: { id: "ses_1" }, + body: { + parts: [{ + type: "tool_result", + toolUseId: "call_recovered", + tool_use_id: "call_recovered", + isError: true, + content: [{ type: "text", text: '{"status":"error","error":"Tool not available. Please continue without this tool."}' }], + }], + }, + }) + }) + + it("sends a schema-compatible recovered tool result for stored parts fallback", async () => { + //#given + storedParts = [{ + type: "tool", + id: "prt_stored_valid_call", + callID: "toolu_recovered", + tool: "bash", + state: { input: {} }, + }] + const { client, promptAsync } = createMockClient() + + //#when + const result = await recoverUnavailableTool(client, "ses_2", failedAssistantMsg) + + //#then + expect(result).toBe(true) + expect(promptAsync).toHaveBeenCalledWith({ + path: { id: "ses_2" }, + body: { + parts: [{ + type: "tool_result", + toolUseId: "toolu_recovered", + tool_use_id: "toolu_recovered", + isError: true, + content: [{ type: "text", text: '{"status":"error","error":"Tool not available. Please continue without this tool."}' }], + }], + }, + }) + }) +}) diff --git a/src/hooks/session-recovery/recover-unavailable-tool.ts b/src/hooks/session-recovery/recover-unavailable-tool.ts index b45a4f7ff..01c6660c9 100644 --- a/src/hooks/session-recovery/recover-unavailable-tool.ts +++ b/src/hooks/session-recovery/recover-unavailable-tool.ts @@ -10,8 +10,10 @@ type Client = ReturnType interface ToolResultPart { type: "tool_result" - tool_use_id: string - content: string + toolUseId: string + tool_use_id?: string + isError?: boolean + content: Array<{ type: "text"; text: string }> } interface PromptWithToolResultInput { @@ -91,8 +93,10 @@ export async function recoverUnavailableTool( const toolResultParts = targetToolUses.map((part) => ({ type: "tool_result" as const, + toolUseId: part.id, tool_use_id: part.id, - content: '{"status":"error","error":"Tool not available. Please continue without this tool."}', + isError: true, + content: [{ type: "text" as const, text: '{"status":"error","error":"Tool not available. Please continue without this tool."}' }], })) try { diff --git a/src/hooks/tool-pair-validator/hook.test.ts b/src/hooks/tool-pair-validator/hook.test.ts index a6449da76..af97fa76a 100644 --- a/src/hooks/tool-pair-validator/hook.test.ts +++ b/src/hooks/tool-pair-validator/hook.test.ts @@ -14,8 +14,10 @@ type TestPart = { type: string id?: string callID?: string + toolUseId?: string tool_use_id?: string - content?: string + isError?: boolean + content?: string | Array<{ type: "text"; text: string }> text?: string } @@ -65,7 +67,13 @@ describe("createToolPairValidatorHook", () => { //#then expect(messages[1]?.parts).toEqual([ - { type: "tool_result", tool_use_id: "toolu_1", content: TOOL_RESULT_PLACEHOLDER }, + { + type: "tool_result", + toolUseId: "toolu_1", + tool_use_id: "toolu_1", + isError: true, + content: [{ type: "text", text: TOOL_RESULT_PLACEHOLDER }], + }, { type: "text", text: "continue" }, ]) }) @@ -99,8 +107,20 @@ describe("createToolPairValidatorHook", () => { { info: { role: "user" }, parts: [ - { type: "tool_result", tool_use_id: "toolu_1", content: TOOL_RESULT_PLACEHOLDER }, - { type: "tool_result", tool_use_id: "toolu_2", content: TOOL_RESULT_PLACEHOLDER }, + { + type: "tool_result", + toolUseId: "toolu_1", + tool_use_id: "toolu_1", + isError: true, + content: [{ type: "text", text: TOOL_RESULT_PLACEHOLDER }], + }, + { + type: "tool_result", + toolUseId: "toolu_2", + tool_use_id: "toolu_2", + isError: true, + content: [{ type: "text", text: TOOL_RESULT_PLACEHOLDER }], + }, ], }, ]) @@ -122,7 +142,13 @@ describe("createToolPairValidatorHook", () => { { info: { role: "assistant" }, parts: [{ type: "tool_use", id: "toolu_1" }] }, { info: { role: "user" }, - parts: [{ type: "tool_result", tool_use_id: "toolu_1", content: TOOL_RESULT_PLACEHOLDER }], + parts: [{ + type: "tool_result", + toolUseId: "toolu_1", + tool_use_id: "toolu_1", + isError: true, + content: [{ type: "text", text: TOOL_RESULT_PLACEHOLDER }], + }], }, { info: { role: "assistant" }, parts: [{ type: "text", text: "follow-up" }] }, ]) @@ -150,7 +176,13 @@ describe("createToolPairValidatorHook", () => { //#then expect(messages[1]?.parts).toEqual([ { type: "tool_result", tool_use_id: "toolu_1", content: "done" }, - { type: "tool_result", tool_use_id: "call_2", content: TOOL_RESULT_PLACEHOLDER }, + { + type: "tool_result", + toolUseId: "call_2", + tool_use_id: "call_2", + isError: true, + content: [{ type: "text", text: TOOL_RESULT_PLACEHOLDER }], + }, { type: "text", text: "continue" }, ]) }) @@ -189,11 +221,34 @@ describe("createToolPairValidatorHook", () => { //#then expect(backgroundMessages).toEqual(originalBackgroundMessages) expect(mainMessages[1]?.parts).toEqual([ - { type: "tool_result", tool_use_id: "toolu_main_1", content: TOOL_RESULT_PLACEHOLDER }, + { + type: "tool_result", + tool_use_id: "toolu_main_1", + toolUseId: "toolu_main_1", + isError: true, + content: [{ type: "text", text: TOOL_RESULT_PLACEHOLDER }], + }, { type: "text", text: "continue main session" }, ]) } finally { _resetForTesting() } }) + + it("treats existing camelCase toolUseId results as already paired", async () => { + //#given + const messages = [ + { info: { role: "assistant" }, parts: [{ type: "tool_use", id: "toolu_1" }] }, + { info: { role: "user" }, parts: [{ type: "tool_result", toolUseId: "toolu_1", content: [{ type: "text", text: "done" }] }] }, + ] satisfies TestMessage[] + + //#when + await runTransform(messages) + + //#then + expect(messages).toEqual([ + { info: { role: "assistant" }, parts: [{ type: "tool_use", id: "toolu_1" }] }, + { info: { role: "user" }, parts: [{ type: "tool_result", toolUseId: "toolu_1", content: [{ type: "text", text: "done" }] }] }, + ]) + }) }) diff --git a/src/hooks/tool-pair-validator/hook.ts b/src/hooks/tool-pair-validator/hook.ts index 72d53fee0..9a4107810 100644 --- a/src/hooks/tool-pair-validator/hook.ts +++ b/src/hooks/tool-pair-validator/hook.ts @@ -13,8 +13,10 @@ type ToolUsePart = { type ToolResultPart = { type: "tool_result" - tool_use_id: string - content: string + toolUseId: string + tool_use_id?: string + isError?: boolean + content: Array<{ type: "text"; text: string }> [key: string]: unknown } @@ -52,9 +54,17 @@ function getToolUseID(part: TransformPart): string | null { } function getToolResultID(part: TransformPart): string | null { - const candidate = part as { type?: unknown; tool_use_id?: unknown } + const candidate = part as { type?: unknown; toolUseId?: unknown; tool_use_id?: unknown } - if (candidate.type === "tool_result" && typeof candidate.tool_use_id === "string" && candidate.tool_use_id.length > 0) { + if (candidate.type !== "tool_result") { + return null + } + + if (typeof candidate.toolUseId === "string" && candidate.toolUseId.length > 0) { + return candidate.toolUseId + } + + if (typeof candidate.tool_use_id === "string" && candidate.tool_use_id.length > 0) { return candidate.tool_use_id } @@ -94,8 +104,10 @@ function extractToolResultIDs(parts: TransformPart[]): Set { function createToolResultPart(toolUseID: string): ToolResultPart { return { type: "tool_result", + toolUseId: toolUseID, tool_use_id: toolUseID, - content: TOOL_RESULT_PLACEHOLDER, + isError: true, + content: [{ type: "text", text: TOOL_RESULT_PLACEHOLDER }], } } diff --git a/src/plugin/messages-transform.test.ts b/src/plugin/messages-transform.test.ts index d4cb0637d..1620d1c3b 100644 --- a/src/plugin/messages-transform.test.ts +++ b/src/plugin/messages-transform.test.ts @@ -139,12 +139,20 @@ describe("createMessagesTransformHandler", () => { expect(messages).toHaveLength(5) expect(messages[2]).toEqual({ info: { role: "user" }, - parts: [{ type: "tool_result", tool_use_id: "toolu_01SRMQs3DUtVKWoSxC8bxxVA", content: "Tool output unavailable (context compacted)" }], + parts: [{ + type: "tool_result", + toolUseId: "toolu_01SRMQs3DUtVKWoSxC8bxxVA", + tool_use_id: "toolu_01SRMQs3DUtVKWoSxC8bxxVA", + isError: true, + content: [{ type: "text", text: "Tool output unavailable (context compacted)" }], + }], }) expect(messages[4]?.parts[0]).toEqual({ type: "tool_result", + toolUseId: "toolu_01Lu5cHvRtEvzoifP1UVBVRb", tool_use_id: "toolu_01Lu5cHvRtEvzoifP1UVBVRb", - content: "Tool output unavailable (context compacted)", + isError: true, + content: [{ type: "text", text: "Tool output unavailable (context compacted)" }], }) expect(messages[4]?.parts[1]).toEqual({ type: "text", text: "next" }) })