Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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()
Expand Down
87 changes: 86 additions & 1 deletion src/hooks/session-recovery/detect-error-type.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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", () => {
Expand All @@ -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")
})
})
21 changes: 21 additions & 0 deletions src/hooks/session-recovery/detect-error-type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export type RecoveryErrorType =
| "thinking_block_order"
| "thinking_disabled_violation"
| "assistant_prefill_unsupported"
| "unavailable_tool"
| null

function getErrorMessage(error: unknown): string {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions src/hooks/session-recovery/hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.",
Expand All @@ -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) {
Expand Down
108 changes: 108 additions & 0 deletions src/hooks/session-recovery/recover-unavailable-tool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import type { createOpencodeClient } from "@opencode-ai/sdk"
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Duplicate code: readPartsFromSDKFallback exactly mirrors the function in recover-tool-result-missing.ts.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/hooks/session-recovery/recover-unavailable-tool.ts, line 40:

<comment>Duplicate code: `readPartsFromSDKFallback` exactly mirrors the function in `recover-tool-result-missing.ts`.</comment>

<file context>
@@ -0,0 +1,108 @@
+  )
+}
+
+async function readPartsFromSDKFallback(
+  client: Client,
+  sessionID: string,
</file context>
Fix with Cubic

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
Copy link

@cubic-dev-ai cubic-dev-ai bot Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0: Custom agent: Opencode Compatibility

Return a tool_result for EVERY tool_use block in the message, not just the unavailable one. Filtering out valid tool uses causes a 400 Bad Request error from the Anthropic API.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/hooks/session-recovery/recover-unavailable-tool.ts, line 89:

<comment>Return a `tool_result` for EVERY `tool_use` block in the message, not just the unavailable one. Filtering out valid tool uses causes a 400 Bad Request error from the Anthropic API.</comment>

<file context>
@@ -0,0 +1,108 @@
+  const matchingToolUses = unavailableToolName
+    ? toolUseParts.filter((part) => part.name.toLowerCase() === unavailableToolName)
+    : []
+  const targetToolUses = matchingToolUses.length > 0 ? matchingToolUses : toolUseParts
+
+  const toolResultParts = targetToolUses.map((part) => ({
</file context>
Fix with Cubic


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
}
}
2 changes: 1 addition & 1 deletion src/plugin-handlers/agent-config-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
42 changes: 24 additions & 18 deletions src/plugin-handlers/config-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand All @@ -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> = {
Expand All @@ -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)
})
})
2 changes: 1 addition & 1 deletion src/plugin/chat-headers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ describe("createChatHeadersHandler", () => {
sessionID: "ses_1",
provider: { id: "openai" },
message: {
id: "msg_1",
id: "msg_2",
role: "user",
},
},
Expand Down
Loading