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")
})
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")
})
})

View File

@@ -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"

View File

@@ -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) {

View File

@@ -7,6 +7,17 @@ import { isSqliteBackend } from "../../shared/opencode-storage-detection"
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 {
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