Merge pull request #2005 from code-yeongyu/fix/1803-session-recovery-unavailable-tool

fix(session-recovery): handle unavailable_tool (dummy_tool) errors
This commit is contained in:
YeonGyu-Kim
2026-02-21 05:40:32 +09:00
committed by GitHub
12 changed files with 251 additions and 39 deletions

View File

@@ -1,4 +1,4 @@
import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test"
import { afterAll, afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
import type { PluginInput } from "@opencode-ai/plugin"
import * as originalExecutor from "./executor"
import * as originalParser from "./parser"
@@ -81,6 +81,10 @@ describe("createAnthropicContextWindowLimitRecoveryHook", () => {
parseAnthropicTokenLimitErrorMock.mockClear()
})
afterEach(() => {
mock.restore()
})
test("cancels pending timer when session.idle handles compaction first", async () => {
//#given
const { restore, getClearTimeoutCalls } = setupDelayedTimeoutMocks()

View File

@@ -1,6 +1,6 @@
/// <reference types="bun-types" />
import { describe, expect, it } from "bun:test"
import { detectErrorType, extractMessageIndex } from "./detect-error-type"
import { detectErrorType, extractMessageIndex, extractUnavailableToolName } from "./detect-error-type"
describe("detectErrorType", () => {
it("#given a tool_use/tool_result error #when detecting #then returns tool_result_missing", () => {
@@ -101,6 +101,56 @@ describe("detectErrorType", () => {
//#then
expect(result).toBe("tool_result_missing")
})
it("#given a dummy_tool unavailable tool error #when detecting #then returns unavailable_tool", () => {
//#given
const error = { message: "model tried to call unavailable tool 'invalid'" }
//#when
const result = detectErrorType(error)
//#then
expect(result).toBe("unavailable_tool")
})
it("#given a no such tool error #when detecting #then returns unavailable_tool", () => {
//#given
const error = { message: "No such tool: grepppp" }
//#when
const result = detectErrorType(error)
//#then
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 = {
data: {
error: {
message: "dummy_tool Model tried to call unavailable tool 'invalid'",
},
},
}
//#when
const result = detectErrorType(error)
//#then
expect(result).toBe("unavailable_tool")
})
})
describe("extractMessageIndex", () => {
@@ -127,3 +177,38 @@ describe("extractMessageIndex", () => {
expect(result).toBeNull()
})
})
describe("extractUnavailableToolName", () => {
it("#given unavailable tool error with quoted tool name #when extracting #then returns tool name", () => {
//#given
const error = { message: "model tried to call unavailable tool 'invalid'" }
//#when
const result = extractUnavailableToolName(error)
//#then
expect(result).toBe("invalid")
})
it("#given error without unavailable tool name #when extracting #then returns null", () => {
//#given
const error = { message: "dummy_tool appeared without tool name" }
//#when
const result = extractUnavailableToolName(error)
//#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

@@ -3,6 +3,7 @@ export type RecoveryErrorType =
| "thinking_block_order"
| "thinking_disabled_violation"
| "assistant_prefill_unsupported"
| "unavailable_tool"
| null
function getErrorMessage(error: unknown): string {
@@ -43,6 +44,16 @@ 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|no such tool)[:\s'"]+([^'".\s]+)/)
return match ? match[1] : null
} catch {
return null
}
}
export function detectErrorType(error: unknown): RecoveryErrorType {
try {
const message = getErrorMessage(error)
@@ -74,6 +85,16 @@ export function detectErrorType(error: unknown): RecoveryErrorType {
return "tool_result_missing"
}
if (
message.includes("dummy_tool") ||
message.includes("unavailable tool") ||
message.includes("model tried to call unavailable") ||
message.includes("nosuchtoolerror") ||
message.includes("no such tool")
) {
return "unavailable_tool"
}
return null
} catch {
return null

View File

@@ -5,6 +5,7 @@ import { detectErrorType } from "./detect-error-type"
import type { RecoveryErrorType } from "./detect-error-type"
import type { MessageData } from "./types"
import { recoverToolResultMissing } from "./recover-tool-result-missing"
import { recoverUnavailableTool } from "./recover-unavailable-tool"
import { recoverThinkingBlockOrder } from "./recover-thinking-block-order"
import { recoverThinkingDisabledViolation } from "./recover-thinking-disabled-violation"
import { extractResumeConfig, findLastUserMessage, resumeSession } from "./resume"
@@ -79,12 +80,14 @@ export function createSessionRecoveryHook(ctx: PluginInput, options?: SessionRec
const toastTitles: Record<RecoveryErrorType & string, string> = {
tool_result_missing: "Tool Crash Recovery",
unavailable_tool: "Tool Recovery",
thinking_block_order: "Thinking Block Recovery",
thinking_disabled_violation: "Thinking Strip Recovery",
"assistant_prefill_unsupported": "Prefill Unsupported",
}
const toastMessages: Record<RecoveryErrorType & string, string> = {
tool_result_missing: "Injecting cancelled tool results...",
unavailable_tool: "Recovering from unavailable tool call...",
thinking_block_order: "Fixing message structure...",
thinking_disabled_violation: "Stripping thinking blocks...",
"assistant_prefill_unsupported": "Prefill not supported; continuing without recovery.",
@@ -105,6 +108,8 @@ export function createSessionRecoveryHook(ctx: PluginInput, options?: SessionRec
if (errorType === "tool_result_missing") {
success = await recoverToolResultMissing(ctx.client, sessionID, failedMsg)
} else if (errorType === "unavailable_tool") {
success = await recoverUnavailableTool(ctx.client, sessionID, failedMsg)
} else if (errorType === "thinking_block_order") {
success = await recoverThinkingBlockOrder(ctx.client, sessionID, failedMsg, ctx.directory, info.error)
if (success && experimental?.auto_resume) {

View File

@@ -0,0 +1,108 @@
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<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
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<MessagePart[]> {
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 : ("tool" in part && typeof (part as { tool?: unknown }).tool === "string" ? (part as { tool: string }).tool : undefined),
}))
} catch {
return []
}
}
export async function recoverUnavailableTool(
client: Client,
sessionID: string,
failedAssistantMsg: MessageData
): Promise<boolean> {
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 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
}
}

View File

@@ -82,7 +82,7 @@ export async function applyAgentConfig(params: {
migratedDisabledAgents,
params.pluginConfig.agents,
params.ctx.directory,
undefined,
currentModel,
params.pluginConfig.categories,
params.pluginConfig.git_master,
allDiscoveredSkills,

View File

@@ -1277,12 +1277,15 @@ describe("per-agent todowrite/todoread deny when task_system enabled", () => {
})
describe("disable_omo_env pass-through", () => {
test("omits <omo-env> in generated sisyphus prompt when disable_omo_env is true", async () => {
test("passes disable_omo_env=true to createBuiltinAgents", async () => {
//#given
;(agents.createBuiltinAgents as any)?.mockRestore?.()
;(shared.fetchAvailableModels as any).mockResolvedValue(
new Set(["anthropic/claude-opus-4-6", "google/gemini-3-flash"])
)
const createBuiltinAgentsMock = agents.createBuiltinAgents as unknown as {
mockResolvedValue: (value: Record<string, unknown>) => void
mock: { calls: unknown[][] }
}
createBuiltinAgentsMock.mockResolvedValue({
sisyphus: { name: "sisyphus", prompt: "without-env", mode: "primary" },
})
const pluginConfig: OhMyOpenCodeConfig = {
experimental: { disable_omo_env: true },
@@ -1304,18 +1307,21 @@ describe("disable_omo_env pass-through", () => {
await handler(config)
//#then
const agentResult = config.agent as Record<string, { prompt?: string }>
const sisyphusPrompt = agentResult[getAgentDisplayName("sisyphus")]?.prompt
expect(sisyphusPrompt).toBeDefined()
expect(sisyphusPrompt).not.toContain("<omo-env>")
const lastCall =
createBuiltinAgentsMock.mock.calls[createBuiltinAgentsMock.mock.calls.length - 1]
expect(lastCall).toBeDefined()
expect(lastCall?.[12]).toBe(true)
})
test("keeps <omo-env> in generated sisyphus prompt when disable_omo_env is omitted", async () => {
test("passes disable_omo_env=false to createBuiltinAgents when omitted", async () => {
//#given
;(agents.createBuiltinAgents as any)?.mockRestore?.()
;(shared.fetchAvailableModels as any).mockResolvedValue(
new Set(["anthropic/claude-opus-4-6", "google/gemini-3-flash"])
)
const createBuiltinAgentsMock = agents.createBuiltinAgents as unknown as {
mockResolvedValue: (value: Record<string, unknown>) => void
mock: { calls: unknown[][] }
}
createBuiltinAgentsMock.mockResolvedValue({
sisyphus: { name: "sisyphus", prompt: "with-env", mode: "primary" },
})
const pluginConfig: OhMyOpenCodeConfig = {}
const config: Record<string, unknown> = {
@@ -1335,9 +1341,9 @@ describe("disable_omo_env pass-through", () => {
await handler(config)
//#then
const agentResult = config.agent as Record<string, { prompt?: string }>
const sisyphusPrompt = agentResult[getAgentDisplayName("sisyphus")]?.prompt
expect(sisyphusPrompt).toBeDefined()
expect(sisyphusPrompt).toContain("<omo-env>")
const lastCall =
createBuiltinAgentsMock.mock.calls[createBuiltinAgentsMock.mock.calls.length - 1]
expect(lastCall).toBeDefined()
expect(lastCall?.[12]).toBe(false)
})
})

View File

@@ -66,7 +66,7 @@ describe("createChatHeadersHandler", () => {
sessionID: "ses_1",
provider: { id: "openai" },
message: {
id: "msg_1",
id: "msg_2",
role: "user",
},
},

View File

@@ -15,7 +15,6 @@ import {
createInteractiveBashSessionHook,
createRalphLoopHook,
createEditErrorRecoveryHook,
createJsonErrorRecoveryHook,
createDelegateTaskRetryHook,
createTaskResumeInfoHook,
createStartWorkHook,
@@ -51,7 +50,6 @@ export type SessionHooks = {
interactiveBashSession: ReturnType<typeof createInteractiveBashSessionHook> | null
ralphLoop: ReturnType<typeof createRalphLoopHook> | null
editErrorRecovery: ReturnType<typeof createEditErrorRecoveryHook> | null
jsonErrorRecovery: ReturnType<typeof createJsonErrorRecoveryHook> | null
delegateTaskRetry: ReturnType<typeof createDelegateTaskRetryHook> | null
startWork: ReturnType<typeof createStartWorkHook> | null
prometheusMdOnly: ReturnType<typeof createPrometheusMdOnlyHook> | null
@@ -212,10 +210,6 @@ export function createSessionHooks(args: {
? safeHook("edit-error-recovery", () => createEditErrorRecoveryHook(ctx))
: null
const jsonErrorRecovery = isHookEnabled("json-error-recovery")
? safeHook("json-error-recovery", () => createJsonErrorRecoveryHook(ctx))
: null
const delegateTaskRetry = isHookEnabled("delegate-task-retry")
? safeHook("delegate-task-retry", () => createDelegateTaskRetryHook(ctx))
: null
@@ -268,7 +262,6 @@ export function createSessionHooks(args: {
interactiveBashSession,
ralphLoop,
editErrorRecovery,
jsonErrorRecovery,
delegateTaskRetry,
startWork,
prometheusMdOnly,

View File

@@ -12,7 +12,6 @@ import {
createTasksTodowriteDisablerHook,
createWriteExistingFileGuardHook,
createHashlineReadEnhancerHook,
createHashlineEditDiffEnhancerHook,
} from "../../hooks"
import {
getOpenCodeVersion,
@@ -32,7 +31,6 @@ export type ToolGuardHooks = {
tasksTodowriteDisabler: ReturnType<typeof createTasksTodowriteDisablerHook> | null
writeExistingFileGuard: ReturnType<typeof createWriteExistingFileGuardHook> | null
hashlineReadEnhancer: ReturnType<typeof createHashlineReadEnhancerHook> | null
hashlineEditDiffEnhancer: ReturnType<typeof createHashlineEditDiffEnhancerHook> | null
}
export function createToolGuardHooks(args: {
@@ -101,10 +99,6 @@ export function createToolGuardHooks(args: {
? safeHook("hashline-read-enhancer", () => createHashlineReadEnhancerHook(ctx, { hashline_edit: { enabled: pluginConfig.hashline_edit ?? true } }))
: null
const hashlineEditDiffEnhancer = isHookEnabled("hashline-edit-diff-enhancer")
? safeHook("hashline-edit-diff-enhancer", () => createHashlineEditDiffEnhancerHook({ hashline_edit: { enabled: pluginConfig.hashline_edit ?? true } }))
: null
return {
commentChecker,
toolOutputTruncator,
@@ -115,6 +109,5 @@ export function createToolGuardHooks(args: {
tasksTodowriteDisabler,
writeExistingFileGuard,
hashlineReadEnhancer,
hashlineEditDiffEnhancer,
}
}

View File

@@ -40,11 +40,9 @@ export function createToolExecuteAfterHandler(args: {
await hooks.categorySkillReminder?.["tool.execute.after"]?.(input, output)
await hooks.interactiveBashSession?.["tool.execute.after"]?.(input, output)
await hooks.editErrorRecovery?.["tool.execute.after"]?.(input, output)
await hooks.jsonErrorRecovery?.["tool.execute.after"]?.(input, output)
await hooks.delegateTaskRetry?.["tool.execute.after"]?.(input, output)
await hooks.atlasHook?.["tool.execute.after"]?.(input, output)
await hooks.taskResumeInfo?.["tool.execute.after"]?.(input, output)
await hooks.hashlineReadEnhancer?.["tool.execute.after"]?.(input, output)
await hooks.hashlineEditDiffEnhancer?.["tool.execute.after"]?.(input, output)
}
}

View File

@@ -29,7 +29,6 @@ export function createToolExecuteBeforeHandler(args: {
await hooks.prometheusMdOnly?.["tool.execute.before"]?.(input, output)
await hooks.sisyphusJuniorNotepad?.["tool.execute.before"]?.(input, output)
await hooks.atlasHook?.["tool.execute.before"]?.(input, output)
await hooks.hashlineEditDiffEnhancer?.["tool.execute.before"]?.(input, output)
if (input.tool === "task") {
const argsObject = output.args
const category = typeof argsObject.category === "string" ? argsObject.category : undefined