fix(session-recovery): harden unavailable tool recovery flow

This commit is contained in:
YeonGyu-Kim 2026-02-21 02:41:57 +09:00
parent 414099534e
commit 49aa5162bb
4 changed files with 40 additions and 18 deletions

View File

@ -124,6 +124,17 @@ describe("detectErrorType", () => {
expect(result).toBe("unavailable_tool") 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", () => { it("#given a dummy_tool token in nested error #when detecting #then returns unavailable_tool", () => {
//#given //#given
const error = { const error = {
@ -189,4 +200,15 @@ describe("extractUnavailableToolName", () => {
//#then //#then
expect(result).toBeNull() 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")
})
}) })

View File

@ -47,7 +47,7 @@ export function extractMessageIndex(error: unknown): number | null {
export function extractUnavailableToolName(error: unknown): string | null { export function extractUnavailableToolName(error: unknown): string | null {
try { try {
const message = getErrorMessage(error) 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 return match ? match[1] : null
} catch { } catch {
return null return null
@ -90,6 +90,7 @@ export function detectErrorType(error: unknown): RecoveryErrorType {
message.includes("unavailable tool") || message.includes("unavailable tool") ||
message.includes("model tried to call unavailable") || message.includes("model tried to call unavailable") ||
message.includes("nosuchtoolarror") || message.includes("nosuchtoolarror") ||
message.includes("nosuchtoolerror") ||
message.includes("no such tool") message.includes("no such tool")
) { ) {
return "unavailable_tool" return "unavailable_tool"

View File

@ -110,11 +110,6 @@ export function createSessionRecoveryHook(ctx: PluginInput, options?: SessionRec
success = await recoverToolResultMissing(ctx.client, sessionID, failedMsg) success = await recoverToolResultMissing(ctx.client, sessionID, failedMsg)
} else if (errorType === "unavailable_tool") { } else if (errorType === "unavailable_tool") {
success = await recoverUnavailableTool(ctx.client, sessionID, failedMsg) 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") { } else if (errorType === "thinking_block_order") {
success = await recoverThinkingBlockOrder(ctx.client, sessionID, failedMsg, ctx.directory, info.error) success = await recoverThinkingBlockOrder(ctx.client, sessionID, failedMsg, ctx.directory, info.error)
if (success && experimental?.auto_resume) { if (success && experimental?.auto_resume) {

View File

@ -7,6 +7,17 @@ import { isSqliteBackend } from "../../shared/opencode-storage-detection"
type Client = ReturnType<typeof createOpencodeClient> type Client = ReturnType<typeof createOpencodeClient>
interface ToolResultPart {
type: "tool_result"
tool_use_id: string
content: string
}
interface PromptWithToolResultInput {
path: { id: string }
body: { parts: ToolResultPart[] }
}
interface ToolUsePart { interface ToolUsePart {
type: "tool_use" type: "tool_use"
id: string id: string
@ -80,23 +91,16 @@ export async function recoverUnavailableTool(
const toolResultParts = targetToolUses.map((part) => ({ const toolResultParts = targetToolUses.map((part) => ({
type: "tool_result" as const, type: "tool_result" as const,
tool_use_id: part.id, tool_use_id: part.id,
content: { content: '{"status":"error","error":"Tool not available. Please continue without this tool."}',
status: "error",
error: "Tool not available. Please continue without this tool.",
},
})) }))
try { try {
const promptAsyncKey = ["prompt", "Async"].join("") const promptInput: PromptWithToolResultInput = {
const promptAsync = Reflect.get(client.session, promptAsyncKey)
if (typeof promptAsync !== "function") {
return false
}
await promptAsync({
path: { id: sessionID }, path: { id: sessionID },
body: { parts: toolResultParts }, body: { parts: toolResultParts },
}) }
const promptAsync = client.session.promptAsync as (...args: never[]) => unknown
await Reflect.apply(promptAsync, client.session, [promptInput])
return true return true
} catch { } catch {
return false return false