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
108 changes: 108 additions & 0 deletions src/features/claude-code-agent-loader/claude-model-mapper.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/// <reference types="bun-types" />

import { describe, it, expect } from "bun:test"
import { mapClaudeModelToOpenCode } from "./claude-model-mapper"

describe("mapClaudeModelToOpenCode", () => {
describe("#given undefined or empty input", () => {
it("#when called with undefined #then returns undefined", () => {
expect(mapClaudeModelToOpenCode(undefined)).toBeUndefined()
})

it("#when called with empty string #then returns undefined", () => {
expect(mapClaudeModelToOpenCode("")).toBeUndefined()
})

it("#when called with whitespace-only string #then returns undefined", () => {
expect(mapClaudeModelToOpenCode(" ")).toBeUndefined()
})
})

describe("#given Claude Code alias", () => {
it("#when called with sonnet #then maps to anthropic claude-sonnet-4-6 object", () => {
expect(mapClaudeModelToOpenCode("sonnet")).toEqual({ providerID: "anthropic", modelID: "claude-sonnet-4-6" })
})

it("#when called with opus #then maps to anthropic claude-opus-4-6 object", () => {
expect(mapClaudeModelToOpenCode("opus")).toEqual({ providerID: "anthropic", modelID: "claude-opus-4-6" })
})

it("#when called with haiku #then maps to anthropic claude-haiku-4-5 object", () => {
expect(mapClaudeModelToOpenCode("haiku")).toEqual({ providerID: "anthropic", modelID: "claude-haiku-4-5" })
})

it("#when called with Sonnet (capitalized) #then maps case-insensitively to object", () => {
expect(mapClaudeModelToOpenCode("Sonnet")).toEqual({ providerID: "anthropic", modelID: "claude-sonnet-4-6" })
})
})

describe("#given inherit", () => {
it("#when called with inherit #then returns undefined", () => {
expect(mapClaudeModelToOpenCode("inherit")).toBeUndefined()
})
})

describe("#given bare Claude model name", () => {
it("#when called with claude-sonnet-4-5-20250514 #then adds anthropic object format", () => {
expect(mapClaudeModelToOpenCode("claude-sonnet-4-5-20250514")).toEqual({ providerID: "anthropic", modelID: "claude-sonnet-4-5-20250514" })
})

it("#when called with claude-opus-4-6 #then adds anthropic object format", () => {
expect(mapClaudeModelToOpenCode("claude-opus-4-6")).toEqual({ providerID: "anthropic", modelID: "claude-opus-4-6" })
})

it("#when called with claude-haiku-4-5-20251001 #then adds anthropic object format", () => {
expect(mapClaudeModelToOpenCode("claude-haiku-4-5-20251001")).toEqual({ providerID: "anthropic", modelID: "claude-haiku-4-5-20251001" })
})

it("#when called with claude-3-5-sonnet-20241022 #then adds anthropic object format", () => {
expect(mapClaudeModelToOpenCode("claude-3-5-sonnet-20241022")).toEqual({ providerID: "anthropic", modelID: "claude-3-5-sonnet-20241022" })
})
})

describe("#given model with dot version numbers", () => {
it("#when called with claude-3.5-sonnet #then normalizes dots and returns object format", () => {
expect(mapClaudeModelToOpenCode("claude-3.5-sonnet")).toEqual({ providerID: "anthropic", modelID: "claude-3-5-sonnet" })
})

it("#when called with claude-3.5-sonnet-20241022 #then normalizes dots and returns object format", () => {
expect(mapClaudeModelToOpenCode("claude-3.5-sonnet-20241022")).toEqual({ providerID: "anthropic", modelID: "claude-3-5-sonnet-20241022" })
})
})

describe("#given model already in provider/model format", () => {
it("#when called with anthropic/claude-sonnet-4-6 #then splits into object format", () => {
expect(mapClaudeModelToOpenCode("anthropic/claude-sonnet-4-6")).toEqual({ providerID: "anthropic", modelID: "claude-sonnet-4-6" })
})

it("#when called with openai/gpt-5.2 #then splits into object format", () => {
expect(mapClaudeModelToOpenCode("openai/gpt-5.2")).toEqual({ providerID: "openai", modelID: "gpt-5.2" })
})
})

describe("#given non-Claude bare model", () => {
it("#when called with gpt-5.2 #then returns undefined", () => {
expect(mapClaudeModelToOpenCode("gpt-5.2")).toBeUndefined()
})

it("#when called with gemini-3-flash #then returns undefined", () => {
expect(mapClaudeModelToOpenCode("gemini-3-flash")).toBeUndefined()
})
})

describe("#given prototype property name", () => {
it("#when called with constructor #then returns undefined", () => {
expect(mapClaudeModelToOpenCode("constructor")).toBeUndefined()
})

it("#when called with toString #then returns undefined", () => {
expect(mapClaudeModelToOpenCode("toString")).toBeUndefined()
})
})

describe("#given model with leading/trailing whitespace", () => {
it("#when called with padded string #then trims before returning object format", () => {
expect(mapClaudeModelToOpenCode(" claude-sonnet-4-6 ")).toEqual({ providerID: "anthropic", modelID: "claude-sonnet-4-6" })
})
})
})
39 changes: 39 additions & 0 deletions src/features/claude-code-agent-loader/claude-model-mapper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { normalizeModelFormat } from "../../shared/model-format-normalizer"
import { normalizeModelID } from "../../shared/model-normalization"

const ANTHROPIC_PREFIX = "anthropic/"

const CLAUDE_CODE_ALIAS_MAP = new Map<string, string>([
["sonnet", `${ANTHROPIC_PREFIX}claude-sonnet-4-6`],
["opus", `${ANTHROPIC_PREFIX}claude-opus-4-6`],
["haiku", `${ANTHROPIC_PREFIX}claude-haiku-4-5`],
])

function mapClaudeModelString(model: string | undefined): string | undefined {
if (!model) return undefined

const trimmed = model.trim()
if (trimmed.length === 0) return undefined

if (trimmed === "inherit") return undefined

const aliasResult = CLAUDE_CODE_ALIAS_MAP.get(trimmed.toLowerCase())
if (aliasResult) return aliasResult

if (trimmed.includes("/")) return trimmed

const normalized = normalizeModelID(trimmed)

if (normalized.startsWith("claude-")) {
return `${ANTHROPIC_PREFIX}${normalized}`
}

return undefined
}

export function mapClaudeModelToOpenCode(
model: string | undefined
): { providerID: string; modelID: string } | undefined {
const mappedModel = mapClaudeModelString(model)
return mappedModel ? normalizeModelFormat(mappedModel) : undefined
}
17 changes: 10 additions & 7 deletions src/features/claude-code-agent-loader/loader.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { existsSync, readdirSync, readFileSync } from "fs"
import { join, basename } from "path"
import type { AgentConfig } from "@opencode-ai/sdk"
import { parseFrontmatter } from "../../shared/frontmatter"
import { isMarkdownFile } from "../../shared/file-utils"
import { getClaudeConfigDir } from "../../shared"
import type { AgentScope, AgentFrontmatter, LoadedAgent } from "./types"
import type { AgentScope, AgentFrontmatter, ClaudeCodeAgentConfig, LoadedAgent } from "./types"
import { mapClaudeModelToOpenCode } from "./claude-model-mapper"

function parseToolsConfig(toolsStr?: string): Record<string, boolean> | undefined {
if (!toolsStr) return undefined
Expand Down Expand Up @@ -42,10 +42,13 @@ function loadAgentsFromDir(agentsDir: string, scope: AgentScope): LoadedAgent[]

const formattedDescription = `(${scope}) ${originalDescription}`

const config: AgentConfig = {
const mappedModelOverride = mapClaudeModelToOpenCode(data.model)
Copy link

@cubic-dev-ai cubic-dev-ai bot Mar 11, 2026

Choose a reason for hiding this comment

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

P1: Non-string YAML values in data.model can cause mapping to throw, silently skipping the agent

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/features/claude-code-agent-loader/loader.ts, line 45:

<comment>Non-string YAML values in `data.model` can cause mapping to throw, silently skipping the agent</comment>

<file context>
@@ -42,10 +42,13 @@ function loadAgentsFromDir(agentsDir: string, scope: AgentScope): LoadedAgent[]
        const formattedDescription = `(${scope}) ${originalDescription}`
 
-       const config: AgentConfig = {
+       const mappedModelOverride = mapClaudeModelToOpenCode(data.model)
+
+       const config: ClaudeCodeAgentConfig = {
</file context>
Fix with Cubic


const config: ClaudeCodeAgentConfig = {
description: formattedDescription,
mode: "subagent",
prompt: body.trim(),
...(mappedModelOverride ? { model: mappedModelOverride } : {}),
Copy link

@cubic-dev-ai cubic-dev-ai bot Mar 11, 2026

Choose a reason for hiding this comment

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

P1: Custom agent: Opencode Compatibility

The OpenCode SDK's AgentConfig requires model to be a string in provider/model format, but mapClaudeModelToOpenCode returns an object ({ providerID: string; modelID: string }). Passing this object directly bypasses TypeScript using a custom type but will cause runtime errors in OpenCode model resolution. Reconstruct the string format before assigning it.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/features/claude-code-agent-loader/loader.ts, line 51:

<comment>The OpenCode SDK's `AgentConfig` requires `model` to be a `string` in `provider/model` format, but `mapClaudeModelToOpenCode` returns an object (`{ providerID: string; modelID: string }`). Passing this object directly bypasses TypeScript using a custom type but will cause runtime errors in OpenCode model resolution. Reconstruct the string format before assigning it.</comment>

<file context>
@@ -42,10 +42,13 @@ function loadAgentsFromDir(agentsDir: string, scope: AgentScope): LoadedAgent[]
          description: formattedDescription,
          mode: "subagent",
          prompt: body.trim(),
+         ...(mappedModelOverride ? { model: mappedModelOverride } : {}),
        }
 
</file context>
Suggested change
...(mappedModelOverride ? { model: mappedModelOverride } : {}),
...(mappedModelOverride ? { model: `${mappedModelOverride.providerID}/${mappedModelOverride.modelID}` } : {}),
Fix with Cubic

}

const toolsConfig = parseToolsConfig(data.tools)
Expand All @@ -67,22 +70,22 @@ function loadAgentsFromDir(agentsDir: string, scope: AgentScope): LoadedAgent[]
return agents
}

export function loadUserAgents(): Record<string, AgentConfig> {
export function loadUserAgents(): Record<string, ClaudeCodeAgentConfig> {
const userAgentsDir = join(getClaudeConfigDir(), "agents")
const agents = loadAgentsFromDir(userAgentsDir, "user")

const result: Record<string, AgentConfig> = {}
const result: Record<string, ClaudeCodeAgentConfig> = {}
for (const agent of agents) {
result[agent.name] = agent.config
}
return result
}

export function loadProjectAgents(directory?: string): Record<string, AgentConfig> {
export function loadProjectAgents(directory?: string): Record<string, ClaudeCodeAgentConfig> {
const projectAgentsDir = join(directory ?? process.cwd(), ".claude", "agents")
const agents = loadAgentsFromDir(projectAgentsDir, "project")

const result: Record<string, AgentConfig> = {}
const result: Record<string, ClaudeCodeAgentConfig> = {}
for (const agent of agents) {
result[agent.name] = agent.config
}
Expand Down
6 changes: 5 additions & 1 deletion src/features/claude-code-agent-loader/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import type { AgentConfig } from "@opencode-ai/sdk"

export type AgentScope = "user" | "project"

export type ClaudeCodeAgentConfig = Omit<AgentConfig, "model"> & {
model?: string | { providerID: string; modelID: string }
}

export interface AgentFrontmatter {
name?: string
description?: string
Expand All @@ -12,6 +16,6 @@ export interface AgentFrontmatter {
export interface LoadedAgent {
name: string
path: string
config: AgentConfig
config: ClaudeCodeAgentConfig
scope: AgentScope
}
13 changes: 8 additions & 5 deletions src/features/claude-code-plugin-loader/agent-loader.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { existsSync, readdirSync, readFileSync } from "fs"
import { basename, join } from "path"
import type { AgentConfig } from "@opencode-ai/sdk"
import { parseFrontmatter } from "../../shared/frontmatter"
import { isMarkdownFile } from "../../shared/file-utils"
import { log } from "../../shared/logger"
import type { AgentFrontmatter } from "../claude-code-agent-loader/types"
import type { AgentFrontmatter, ClaudeCodeAgentConfig } from "../claude-code-agent-loader/types"
import { mapClaudeModelToOpenCode } from "../claude-code-agent-loader/claude-model-mapper"
import type { LoadedPlugin } from "./types"

function parseToolsConfig(toolsStr?: string): Record<string, boolean> | undefined {
Expand All @@ -24,8 +24,8 @@ function parseToolsConfig(toolsStr?: string): Record<string, boolean> | undefine
return result
}

export function loadPluginAgents(plugins: LoadedPlugin[]): Record<string, AgentConfig> {
const agents: Record<string, AgentConfig> = {}
export function loadPluginAgents(plugins: LoadedPlugin[]): Record<string, ClaudeCodeAgentConfig> {
const agents: Record<string, ClaudeCodeAgentConfig> = {}

for (const plugin of plugins) {
if (!plugin.agentsDir || !existsSync(plugin.agentsDir)) continue
Expand All @@ -46,10 +46,13 @@ export function loadPluginAgents(plugins: LoadedPlugin[]): Record<string, AgentC
const originalDescription = data.description || ""
const formattedDescription = `(plugin: ${plugin.name}) ${originalDescription}`

const config: AgentConfig = {
const mappedModelOverride = mapClaudeModelToOpenCode(data.model)
Copy link

@cubic-dev-ai cubic-dev-ai bot Mar 11, 2026

Choose a reason for hiding this comment

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

P2: Runtime type validation missing in mapClaudeModelString - YAML can parse non-string types (e.g., model: 3.5 as Number) causing TypeError on .trim() call

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/features/claude-code-plugin-loader/agent-loader.ts, line 49:

<comment>Runtime type validation missing in `mapClaudeModelString` - YAML can parse non-string types (e.g., `model: 3.5` as Number) causing TypeError on `.trim()` call</comment>

<file context>
@@ -46,10 +46,13 @@ export function loadPluginAgents(plugins: LoadedPlugin[]): Record<string, AgentC
         const formattedDescription = `(plugin: ${plugin.name}) ${originalDescription}`
 
-        const config: AgentConfig = {
+        const mappedModelOverride = mapClaudeModelToOpenCode(data.model)
+
+        const config: ClaudeCodeAgentConfig = {
</file context>
Fix with Cubic


const config: ClaudeCodeAgentConfig = {
description: formattedDescription,
mode: "subagent",
prompt: body.trim(),
...(mappedModelOverride ? { model: mappedModelOverride } : {}),
}

const toolsConfig = parseToolsConfig(data.tools)
Expand Down
4 changes: 2 additions & 2 deletions src/features/claude-code-plugin-loader/loader.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { log } from "../../shared/logger"
import type { AgentConfig } from "@opencode-ai/sdk"
import type { CommandDefinition } from "../claude-code-command-loader/types"
import type { McpServerConfig } from "../claude-code-mcp-loader/types"
import type { ClaudeCodeAgentConfig } from "../claude-code-agent-loader/types"
import type { HooksConfig, LoadedPlugin, PluginLoadError, PluginLoaderOptions } from "./types"
import { discoverInstalledPlugins } from "./discovery"
import { loadPluginCommands } from "./command-loader"
Expand All @@ -20,7 +20,7 @@ export { loadPluginHooksConfigs } from "./hook-loader"
export interface PluginComponentsResult {
commands: Record<string, CommandDefinition>
skills: Record<string, CommandDefinition>
agents: Record<string, AgentConfig>
agents: Record<string, ClaudeCodeAgentConfig>
mcpServers: Record<string, McpServerConfig>
hooksConfigs: HooksConfig[]
plugins: LoadedPlugin[]
Expand Down
Loading