diff --git a/src/hooks/anthropic-context-window-limit-recovery/recovery-hook.test.ts b/src/hooks/anthropic-context-window-limit-recovery/recovery-hook.test.ts index a0d799a2..72dd3746 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/recovery-hook.test.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/recovery-hook.test.ts @@ -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() diff --git a/src/hooks/session-recovery/detect-error-type.test.ts b/src/hooks/session-recovery/detect-error-type.test.ts index d20e7cc9..de5765c0 100644 --- a/src/hooks/session-recovery/detect-error-type.test.ts +++ b/src/hooks/session-recovery/detect-error-type.test.ts @@ -1,6 +1,6 @@ /// 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") + }) +}) diff --git a/src/hooks/session-recovery/detect-error-type.ts b/src/hooks/session-recovery/detect-error-type.ts index 3f2f9a1c..b5783dae 100644 --- a/src/hooks/session-recovery/detect-error-type.ts +++ b/src/hooks/session-recovery/detect-error-type.ts @@ -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 diff --git a/src/hooks/session-recovery/hook.ts b/src/hooks/session-recovery/hook.ts index 8dedd3a0..a7a6420f 100644 --- a/src/hooks/session-recovery/hook.ts +++ b/src/hooks/session-recovery/hook.ts @@ -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 = { 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 = { 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) { 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..3aa937e7 --- /dev/null +++ b/src/hooks/session-recovery/recover-unavailable-tool.ts @@ -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 + +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 { + 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 { + 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 + } +} diff --git a/src/plugin-handlers/agent-config-handler.ts b/src/plugin-handlers/agent-config-handler.ts index 067ee83f..c5d59e14 100644 --- a/src/plugin-handlers/agent-config-handler.ts +++ b/src/plugin-handlers/agent-config-handler.ts @@ -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, diff --git a/src/plugin-handlers/config-handler.test.ts b/src/plugin-handlers/config-handler.test.ts index a3a81f92..e405ee82 100644 --- a/src/plugin-handlers/config-handler.test.ts +++ b/src/plugin-handlers/config-handler.test.ts @@ -1277,12 +1277,15 @@ describe("per-agent todowrite/todoread deny when task_system enabled", () => { }) describe("disable_omo_env pass-through", () => { - test("omits 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) => 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 - const sisyphusPrompt = agentResult[getAgentDisplayName("sisyphus")]?.prompt - expect(sisyphusPrompt).toBeDefined() - expect(sisyphusPrompt).not.toContain("") + const lastCall = + createBuiltinAgentsMock.mock.calls[createBuiltinAgentsMock.mock.calls.length - 1] + expect(lastCall).toBeDefined() + expect(lastCall?.[12]).toBe(true) }) - test("keeps 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) => void + mock: { calls: unknown[][] } + } + createBuiltinAgentsMock.mockResolvedValue({ + sisyphus: { name: "sisyphus", prompt: "with-env", mode: "primary" }, + }) const pluginConfig: OhMyOpenCodeConfig = {} const config: Record = { @@ -1335,9 +1341,9 @@ describe("disable_omo_env pass-through", () => { await handler(config) //#then - const agentResult = config.agent as Record - const sisyphusPrompt = agentResult[getAgentDisplayName("sisyphus")]?.prompt - expect(sisyphusPrompt).toBeDefined() - expect(sisyphusPrompt).toContain("") + const lastCall = + createBuiltinAgentsMock.mock.calls[createBuiltinAgentsMock.mock.calls.length - 1] + expect(lastCall).toBeDefined() + expect(lastCall?.[12]).toBe(false) }) }) diff --git a/src/plugin/chat-headers.test.ts b/src/plugin/chat-headers.test.ts index 3375eb2a..f2858605 100644 --- a/src/plugin/chat-headers.test.ts +++ b/src/plugin/chat-headers.test.ts @@ -66,7 +66,7 @@ describe("createChatHeadersHandler", () => { sessionID: "ses_1", provider: { id: "openai" }, message: { - id: "msg_1", + id: "msg_2", role: "user", }, }, diff --git a/src/plugin/hooks/create-session-hooks.ts b/src/plugin/hooks/create-session-hooks.ts index 0ae0bca1..6057bf65 100644 --- a/src/plugin/hooks/create-session-hooks.ts +++ b/src/plugin/hooks/create-session-hooks.ts @@ -15,7 +15,6 @@ import { createInteractiveBashSessionHook, createRalphLoopHook, createEditErrorRecoveryHook, - createJsonErrorRecoveryHook, createDelegateTaskRetryHook, createTaskResumeInfoHook, createStartWorkHook, @@ -51,7 +50,6 @@ export type SessionHooks = { interactiveBashSession: ReturnType | null ralphLoop: ReturnType | null editErrorRecovery: ReturnType | null - jsonErrorRecovery: ReturnType | null delegateTaskRetry: ReturnType | null startWork: ReturnType | null prometheusMdOnly: ReturnType | 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, diff --git a/src/plugin/hooks/create-tool-guard-hooks.ts b/src/plugin/hooks/create-tool-guard-hooks.ts index 521e3675..3cc4d517 100644 --- a/src/plugin/hooks/create-tool-guard-hooks.ts +++ b/src/plugin/hooks/create-tool-guard-hooks.ts @@ -12,7 +12,6 @@ import { createTasksTodowriteDisablerHook, createWriteExistingFileGuardHook, createHashlineReadEnhancerHook, - createHashlineEditDiffEnhancerHook, } from "../../hooks" import { getOpenCodeVersion, @@ -32,7 +31,6 @@ export type ToolGuardHooks = { tasksTodowriteDisabler: ReturnType | null writeExistingFileGuard: ReturnType | null hashlineReadEnhancer: ReturnType | null - hashlineEditDiffEnhancer: ReturnType | 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, } } diff --git a/src/plugin/tool-execute-after.ts b/src/plugin/tool-execute-after.ts index a6b6fae2..31f20f59 100644 --- a/src/plugin/tool-execute-after.ts +++ b/src/plugin/tool-execute-after.ts @@ -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) } } diff --git a/src/plugin/tool-execute-before.ts b/src/plugin/tool-execute-before.ts index d9a06c70..09ae1681 100644 --- a/src/plugin/tool-execute-before.ts +++ b/src/plugin/tool-execute-before.ts @@ -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