From 49aa5162bbe87feb27c7a1029b1ba10aeb6f6a84 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 21 Feb 2026 02:41:57 +0900 Subject: [PATCH] fix(session-recovery): harden unavailable tool recovery flow --- .../detect-error-type.test.ts | 22 +++++++++++++++ .../session-recovery/detect-error-type.ts | 3 +- src/hooks/session-recovery/hook.ts | 5 ---- .../recover-unavailable-tool.ts | 28 +++++++++++-------- 4 files changed, 40 insertions(+), 18 deletions(-) diff --git a/src/hooks/session-recovery/detect-error-type.test.ts b/src/hooks/session-recovery/detect-error-type.test.ts index 350ad11b7..de5765c05 100644 --- a/src/hooks/session-recovery/detect-error-type.test.ts +++ b/src/hooks/session-recovery/detect-error-type.test.ts @@ -124,6 +124,17 @@ describe("detectErrorType", () => { expect(result).toBe("unavailable_tool") }) + it("#given a NoSuchToolError token #when detecting #then returns unavailable_tool", () => { + //#given + const error = { message: "NoSuchToolError: no such tool invalid" } + + //#when + const result = detectErrorType(error) + + //#then + expect(result).toBe("unavailable_tool") + }) + it("#given a dummy_tool token in nested error #when detecting #then returns unavailable_tool", () => { //#given const error = { @@ -189,4 +200,15 @@ describe("extractUnavailableToolName", () => { //#then expect(result).toBeNull() }) + + it("#given no such tool error with colon format #when extracting #then returns tool name", () => { + //#given + const error = { message: "No such tool: invalid_tool" } + + //#when + const result = extractUnavailableToolName(error) + + //#then + expect(result).toBe("invalid_tool") + }) }) diff --git a/src/hooks/session-recovery/detect-error-type.ts b/src/hooks/session-recovery/detect-error-type.ts index 9282a0686..ea9af562a 100644 --- a/src/hooks/session-recovery/detect-error-type.ts +++ b/src/hooks/session-recovery/detect-error-type.ts @@ -47,7 +47,7 @@ export function extractMessageIndex(error: unknown): number | null { export function extractUnavailableToolName(error: unknown): string | null { try { const message = getErrorMessage(error) - const match = message.match(/unavailable tool ['"]?([^'".\s]+)['"]?/) + const match = message.match(/(?:unavailable tool|no such tool)[:\s'"]+([^'".\s]+)/) return match ? match[1] : null } catch { return null @@ -90,6 +90,7 @@ export function detectErrorType(error: unknown): RecoveryErrorType { message.includes("unavailable tool") || message.includes("model tried to call unavailable") || message.includes("nosuchtoolarror") || + message.includes("nosuchtoolerror") || message.includes("no such tool") ) { return "unavailable_tool" diff --git a/src/hooks/session-recovery/hook.ts b/src/hooks/session-recovery/hook.ts index 7335c2dbc..a7a6420f7 100644 --- a/src/hooks/session-recovery/hook.ts +++ b/src/hooks/session-recovery/hook.ts @@ -110,11 +110,6 @@ export function createSessionRecoveryHook(ctx: PluginInput, options?: SessionRec success = await recoverToolResultMissing(ctx.client, sessionID, failedMsg) } else if (errorType === "unavailable_tool") { success = await recoverUnavailableTool(ctx.client, sessionID, failedMsg) - if (success && experimental?.auto_resume) { - const lastUser = findLastUserMessage(msgs ?? []) - const resumeConfig = extractResumeConfig(lastUser, sessionID) - await resumeSession(ctx.client, resumeConfig) - } } else if (errorType === "thinking_block_order") { success = await recoverThinkingBlockOrder(ctx.client, sessionID, failedMsg, ctx.directory, info.error) if (success && experimental?.auto_resume) { diff --git a/src/hooks/session-recovery/recover-unavailable-tool.ts b/src/hooks/session-recovery/recover-unavailable-tool.ts index e72eeefbc..193f61e68 100644 --- a/src/hooks/session-recovery/recover-unavailable-tool.ts +++ b/src/hooks/session-recovery/recover-unavailable-tool.ts @@ -7,6 +7,17 @@ import { isSqliteBackend } from "../../shared/opencode-storage-detection" type Client = ReturnType +interface ToolResultPart { + type: "tool_result" + tool_use_id: string + content: string +} + +interface PromptWithToolResultInput { + path: { id: string } + body: { parts: ToolResultPart[] } +} + interface ToolUsePart { type: "tool_use" id: string @@ -80,23 +91,16 @@ export async function recoverUnavailableTool( 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.", - }, + 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({ + const promptInput: PromptWithToolResultInput = { path: { id: sessionID }, body: { parts: toolResultParts }, - }) + } + const promptAsync = client.session.promptAsync as (...args: never[]) => unknown + await Reflect.apply(promptAsync, client.session, [promptInput]) return true } catch { return false