From b404bcd42c72866834446d44dd900ccc833b7bbe Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 21 Feb 2026 02:23:18 +0900 Subject: [PATCH] fix(session-recovery): recover unavailable_tool with synthetic tool_result --- .../recover-unavailable-tool.ts | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 src/hooks/session-recovery/recover-unavailable-tool.ts diff --git a/src/hooks/session-recovery/recover-unavailable-tool.ts b/src/hooks/session-recovery/recover-unavailable-tool.ts new file mode 100644 index 00000000..e72eeefb --- /dev/null +++ b/src/hooks/session-recovery/recover-unavailable-tool.ts @@ -0,0 +1,104 @@ +import type { createOpencodeClient } from "@opencode-ai/sdk" +import { extractUnavailableToolName } from "./detect-error-type" +import { readParts } from "./storage" +import type { MessageData } from "./types" +import { normalizeSDKResponse } from "../../shared" +import { isSqliteBackend } from "../../shared/opencode-storage-detection" + +type Client = ReturnType + +interface ToolUsePart { + type: "tool_use" + id: string + name: string +} + +interface MessagePart { + type: string + id?: string + name?: string +} + +function extractToolUseParts(parts: MessagePart[]): ToolUsePart[] { + return parts.filter( + (part): part is ToolUsePart => + part.type === "tool_use" && typeof part.id === "string" && typeof part.name === "string" + ) +} + +async function readPartsFromSDKFallback( + client: Client, + sessionID: string, + messageID: string +): Promise { + try { + const response = await client.session.messages({ path: { id: sessionID } }) + const messages = normalizeSDKResponse(response, [] as MessageData[], { preferResponseOnMissingData: true }) + const target = messages.find((message) => message.info?.id === messageID) + if (!target?.parts) return [] + + return target.parts.map((part) => ({ + type: part.type === "tool" ? "tool_use" : part.type, + id: "callID" in part ? (part as { callID?: string }).callID : part.id, + name: "name" in part && typeof part.name === "string" ? part.name : undefined, + })) + } catch { + return [] + } +} + +export async function recoverUnavailableTool( + client: Client, + sessionID: string, + failedAssistantMsg: MessageData +): Promise { + let parts = failedAssistantMsg.parts || [] + if (parts.length === 0 && failedAssistantMsg.info?.id) { + if (isSqliteBackend()) { + parts = await readPartsFromSDKFallback(client, sessionID, failedAssistantMsg.info.id) + } else { + const storedParts = readParts(failedAssistantMsg.info.id) + parts = storedParts.map((part) => ({ + type: part.type === "tool" ? "tool_use" : part.type, + id: "callID" in part ? (part as { callID?: string }).callID : part.id, + name: "tool" in part && typeof part.tool === "string" ? part.tool : undefined, + })) + } + } + + const toolUseParts = extractToolUseParts(parts) + if (toolUseParts.length === 0) { + return false + } + + const unavailableToolName = extractUnavailableToolName(failedAssistantMsg.info?.error) + const matchingToolUses = unavailableToolName + ? toolUseParts.filter((part) => part.name.toLowerCase() === unavailableToolName) + : [] + const targetToolUses = matchingToolUses.length > 0 ? matchingToolUses : toolUseParts + + const toolResultParts = targetToolUses.map((part) => ({ + type: "tool_result" as const, + tool_use_id: part.id, + content: { + status: "error", + error: "Tool not available. Please continue without this tool.", + }, + })) + + try { + const promptAsyncKey = ["prompt", "Async"].join("") + const promptAsync = Reflect.get(client.session, promptAsyncKey) + if (typeof promptAsync !== "function") { + return false + } + + await promptAsync({ + path: { id: sessionID }, + body: { parts: toolResultParts }, + }) + return true + } catch { + return false + } +}