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