Merge branch 'dev' into fix/user-agents-callable-v2

Resolves conflicts with ZWSP agent ordering, display name unification, and test file migration to zauc-mocks split.
This commit is contained in:
code-yeongyu
2026-04-12 06:13:52 +09:00
837 changed files with 51410 additions and 14139 deletions
+6 -6
View File
@@ -1,6 +1,6 @@
# src/tools/ 26 Tools Across 15 Directories
# src/tools/ - 26 Tools Across 16 Directories
**Generated:** 2026-03-06
**Generated:** 2026-04-11
## OVERVIEW
@@ -38,7 +38,7 @@
| `background_output` | `createBackgroundOutput` | task_id, block, timeout, full_session, include_thinking, message_limit, since_message_id, thinking_max_chars |
| `background_cancel` | `createBackgroundCancel` | taskId, all |
### LSP Refactoring (6) Direct ToolDefinition
### LSP Refactoring (6) - Direct ToolDefinition
| Tool | Parameters |
|------|------------|
@@ -81,7 +81,7 @@
| `interactive_bash` | Direct | tmux_command |
| `look_at` | `createLookAt` | file_path, image_data, goal |
### Editing (1) Conditional
### Editing (1) - Conditional
| Tool | Factory | Parameters |
|------|---------|------------|
@@ -93,12 +93,12 @@
|----------|-------|--------|
| visual-engineering | gemini-3.1-pro high | Frontend, UI/UX |
| ultrabrain | gpt-5.4 xhigh | Hard logic |
| deep | gpt-5.3-codex medium | Autonomous problem-solving |
| deep | gpt-5.4 medium | Autonomous problem-solving |
| artistry | gemini-3.1-pro high | Creative approaches |
| quick | gpt-5.4-mini | Trivial tasks |
| unspecified-low | claude-sonnet-4-6 | Moderate effort |
| unspecified-high | claude-opus-4-6 max | High effort |
| writing | kimi-k2p5 | Documentation |
| writing | gemini-3-flash | Documentation |
## HOW TO ADD A TOOL
+7 -6
View File
@@ -11,6 +11,7 @@ import {
getCachedBinaryPath as getCachedBinaryPathShared,
} from "../../shared/binary-downloader"
import { log } from "../../shared/logger"
import { CACHE_DIR_NAME, PUBLISHED_PACKAGE_NAME } from "../../shared/plugin-identity"
const REPO = "ast-grep/ast-grep"
@@ -47,12 +48,12 @@ export function getCacheDir(): string {
if (process.platform === "win32") {
const localAppData = process.env.LOCALAPPDATA || process.env.APPDATA
const base = localAppData || join(homedir(), "AppData", "Local")
return join(base, "oh-my-opencode", "bin")
return join(base, CACHE_DIR_NAME, "bin")
}
const xdgCache = process.env.XDG_CACHE_HOME
const base = xdgCache || join(homedir(), ".cache")
return join(base, "oh-my-opencode", "bin")
return join(base, CACHE_DIR_NAME, "bin")
}
export function getBinaryName(): string {
@@ -70,7 +71,7 @@ export async function downloadAstGrep(version: string = DEFAULT_VERSION): Promis
const platformInfo = PLATFORM_MAP[platformKey]
if (!platformInfo) {
log(`[oh-my-opencode] Unsupported platform for ast-grep: ${platformKey}`)
log(`[${PUBLISHED_PACKAGE_NAME}] Unsupported platform for ast-grep: ${platformKey}`)
return null
}
@@ -86,7 +87,7 @@ export async function downloadAstGrep(version: string = DEFAULT_VERSION): Promis
const assetName = `app-${arch}-${os}.zip`
const downloadUrl = `https://github.com/${REPO}/releases/download/${version}/${assetName}`
log(`[oh-my-opencode] Downloading ast-grep binary...`)
log(`[${PUBLISHED_PACKAGE_NAME}] Downloading ast-grep binary...`)
try {
const archivePath = join(cacheDir, assetName)
@@ -96,12 +97,12 @@ export async function downloadAstGrep(version: string = DEFAULT_VERSION): Promis
cleanupArchive(archivePath)
ensureExecutable(binaryPath)
log(`[oh-my-opencode] ast-grep binary ready.`)
log(`[${PUBLISHED_PACKAGE_NAME}] ast-grep binary ready.`)
return binaryPath
} catch (err) {
log(
`[oh-my-opencode] Failed to download ast-grep: ${err instanceof Error ? err.message : err}`
`[${PUBLISHED_PACKAGE_NAME}] Failed to download ast-grep: ${err instanceof Error ? err.message : err}`
)
return null
}
+1 -1
View File
@@ -1,6 +1,6 @@
# src/tools/background-task/ — Background Task Tool Wrappers
**Generated:** 2026-03-06
**Generated:** 2026-04-11
## OVERVIEW
+3 -1
View File
@@ -2,6 +2,8 @@ export const BACKGROUND_TASK_DESCRIPTION = `Run agent task in background. Return
Use \`background_output\` to get results. Prompts MUST be in English.`
export const BACKGROUND_OUTPUT_DESCRIPTION = `Get output from background task. Use full_session=true to fetch session messages with filters. System notifies on completion, so block=true rarely needed. - Timeout values are in milliseconds (ms), NOT seconds.`
export const BACKGROUND_OUTPUT_DESCRIPTION = `Get output from background task. Use full_session=true to fetch session messages with filters. System notifies on completion, so block=true rarely needed. - Timeout values are in milliseconds (ms), NOT seconds.
IMPORTANT: ONLY call this tool AFTER receiving a <system-reminder> notification for the task. Do NOT call immediately after launching a background task - wait for the notification first.`
export const BACKGROUND_CANCEL_DESCRIPTION = `Cancel running background task(s). Use all=true to cancel ALL before final answer.`
@@ -6,7 +6,13 @@ import type { PluginInput } from "@opencode-ai/plugin"
import { createBackgroundTask } from "./create-background-task"
describe("createBackgroundTask", () => {
const launchMock = mock(() => Promise.resolve({
const launchMock = mock(async (): Promise<{
id: string
sessionID: string | null
description: string
agent: string
status: string
}> => ({
id: "test-task-id",
sessionID: null,
description: "Test task",
@@ -32,7 +38,11 @@ describe("createBackgroundTask", () => {
sessionID: "test-session",
messageID: "test-message",
agent: "test-agent",
directory: "/Users/yeongyu/local-workspaces/omo",
worktree: "/Users/yeongyu/local-workspaces/omo",
abort: new AbortController().signal,
metadata: () => {},
ask: async () => {},
}
const testArgs = {
@@ -65,4 +75,83 @@ describe("createBackgroundTask", () => {
expect(result).toContain("Task entered error state")
expect(result).toContain("test-task-id")
})
test("keeps launched background task alive when parent aborts before session id resolves", async () => {
//#given - background launch should survive parent abort during session-id wait
const abortController = new AbortController()
launchMock.mockResolvedValueOnce({
id: "test-task-id",
sessionID: null,
description: "Test task",
agent: "test-agent",
status: "pending",
})
getTaskMock.mockImplementationOnce(() => {
abortController.abort()
return {
id: "test-task-id",
sessionID: null,
description: "Test task",
agent: "test-agent",
status: "pending",
}
})
//#when
const result = await tool.execute(testArgs, {
...testContext,
abort: abortController.signal,
})
//#then - tool should still report successful launch instead of cancelling child task
expect(result).toContain("Background task launched successfully.")
expect(result).toContain("Task ID: test-task-id")
expect(result).not.toContain("Task aborted and cancelled while waiting for session to start")
})
test("keeps sibling background task alive when two tasks start concurrently", async () => {
//#given - one aborted parent call should not interrupt a sibling launch from the same parent session
const firstAbortController = new AbortController()
const secondAbortController = new AbortController()
const states = new Map([
["task-1", { reads: 0, abortOnFirstRead: true, sessionID: "ses-1" }],
["task-2", { reads: 0, abortOnFirstRead: false, sessionID: "ses-2" }],
])
let launchCount = 0
launchMock.mockImplementation(async () => {
launchCount += 1
return launchCount === 1
? { id: "task-1", sessionID: null, description: "Task 1", agent: "test-agent", status: "pending" }
: { id: "task-2", sessionID: null, description: "Task 2", agent: "test-agent", status: "pending" }
})
getTaskMock.mockImplementation((taskID: string) => {
const state = states.get(taskID)
if (!state) return undefined
state.reads += 1
if (state.abortOnFirstRead && state.reads === 1) {
firstAbortController.abort()
}
return state.reads >= 2
? { id: taskID, sessionID: state.sessionID, description: "Task", agent: "test-agent", status: "pending" }
: { id: taskID, sessionID: null, description: "Task", agent: "test-agent", status: "pending" }
})
//#when
const [firstResult, secondResult] = await Promise.all([
tool.execute(testArgs, {
...testContext,
abort: firstAbortController.signal,
}),
tool.execute(testArgs, {
...testContext,
abort: secondAbortController.signal,
}),
])
//#then - both launches still succeed and the sibling is not marked interrupted
expect(firstResult).toContain("Background task launched successfully.")
expect(secondResult).toContain("Background task launched successfully.")
expect(secondResult).toContain("Task ID: task-2")
expect(secondResult).not.toContain("interrupt")
})
})
@@ -80,16 +80,18 @@ export function createBackgroundTask(
const waitStart = Date.now()
let sessionId = task.sessionID
while (!sessionId && Date.now() - waitStart < WAIT_FOR_SESSION_TIMEOUT_MS) {
if (ctx.abort?.aborted) {
await manager.cancelTask(task.id)
return `Task aborted and cancelled while waiting for session to start.\n\nTask ID: ${task.id}`
}
await delay(WAIT_FOR_SESSION_INTERVAL_MS)
const updated = manager.getTask(task.id)
if (!updated || updated.status === "error" || updated.status === "cancelled" || updated.status === "interrupt") {
return `Task ${!updated ? "was deleted" : `entered error state`}\.\n\nTask ID: ${task.id}`
if (updated?.status === "error" || updated?.status === "cancelled" || updated?.status === "interrupt") {
return `Task ${`entered error state`}\.\n\nTask ID: ${task.id}`
}
sessionId = updated?.sessionID
if (sessionId) {
break
}
if (ctx.abort?.aborted) {
break
}
await delay(WAIT_FOR_SESSION_INTERVAL_MS)
}
const bgMeta = {
@@ -112,10 +114,9 @@ Description: ${task.description}
Agent: ${task.agent}
Status: ${task.status}
The system will notify you when the task completes.
Use \`background_output\` tool with task_id="${task.id}" to check progress:
- block=false (default): Check status immediately - returns full status info
- block=true: Wait for completion (rarely needed since system notifies)`
System notifies on completion. Use \`background_output\` with task_id="${task.id}" to check.
Do NOT call background_output now. Wait for <system-reminder> notification first.`
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return `[ERROR] Failed to launch background task: ${message}`
+1 -1
View File
@@ -1,6 +1,6 @@
# src/tools/call-omo-agent/ — Direct Agent Invocation Tool
**Generated:** 2026-03-06
**Generated:** 2026-04-11
## OVERVIEW
@@ -5,7 +5,13 @@ import type { PluginInput } from "@opencode-ai/plugin"
import { executeBackgroundAgent } from "./background-agent-executor"
describe("executeBackgroundAgent", () => {
const launchMock = mock(() => Promise.resolve({
const launchMock = mock(async (): Promise<{
id: string
sessionID: string | null
description: string
agent: string
status: string
}> => ({
id: "test-task-id",
sessionID: null,
description: "Test task",
@@ -64,4 +70,86 @@ describe("executeBackgroundAgent", () => {
expect(result).toContain("interrupt")
expect(result).toContain("test-task-id")
})
test("keeps launched background task alive when parent aborts before session id resolves", async () => {
//#given - parent abort after launch should stop waiting, not fail the background task
const abortController = new AbortController()
launchMock.mockResolvedValueOnce({
id: "test-task-id",
sessionID: null,
description: "Test task",
agent: "test-agent",
status: "pending",
})
getTaskMock.mockImplementationOnce(() => {
abortController.abort()
return { id: "test-task-id", sessionID: null, description: "Test task", agent: "test-agent", status: "pending" }
})
//#when
const result = await executeBackgroundAgent(
testArgs,
{
...testContext,
abort: abortController.signal,
},
mockManager,
mockClient
)
//#then - background launch should still be reported as launched
expect(result).toContain("Background agent task launched successfully")
expect(result).toContain("Task ID: test-task-id")
expect(result).not.toContain("Task aborted while waiting for session to start")
})
test("keeps sibling background agent launch alive when two tasks start concurrently", async () => {
//#given - one aborted parent call should not interrupt a sibling launch from the same parent session
const firstAbortController = new AbortController()
const secondAbortController = new AbortController()
const states = new Map([
["task-1", { reads: 0, abortOnFirstRead: true, sessionID: "ses-1" }],
["task-2", { reads: 0, abortOnFirstRead: false, sessionID: "ses-2" }],
])
let launchCount = 0
launchMock.mockImplementation(async () => {
launchCount += 1
return launchCount === 1
? { id: "task-1", sessionID: null, description: "Task 1", agent: "test-agent", status: "pending" }
: { id: "task-2", sessionID: null, description: "Task 2", agent: "test-agent", status: "pending" }
})
getTaskMock.mockImplementation((taskID: string) => {
const state = states.get(taskID)
if (!state) return undefined
state.reads += 1
if (state.abortOnFirstRead && state.reads === 1) {
firstAbortController.abort()
}
return state.reads >= 2
? { id: taskID, sessionID: state.sessionID, description: "Task", agent: "test-agent", status: "pending" }
: { id: taskID, sessionID: null, description: "Task", agent: "test-agent", status: "pending" }
})
//#when
const [firstResult, secondResult] = await Promise.all([
executeBackgroundAgent(
testArgs,
{ ...testContext, abort: firstAbortController.signal },
mockManager,
mockClient,
),
executeBackgroundAgent(
testArgs,
{ ...testContext, abort: secondAbortController.signal },
mockManager,
mockClient,
),
])
//#then - both launches still succeed and the sibling is not marked interrupted
expect(firstResult).toContain("Background agent task launched successfully")
expect(secondResult).toContain("Background agent task launched successfully")
expect(secondResult).toContain("Task ID: task-2")
expect(secondResult).not.toContain("interrupt")
})
})
@@ -52,17 +52,20 @@ export async function executeBackgroundAgent(
let sessionId = task.sessionID
while (!sessionId && Date.now() - waitStart < waitTimeoutMs) {
if (toolContext.abort?.aborted) {
return `Task aborted while waiting for session to start.\n\nTask ID: ${task.id}`
}
const updated = manager.getTask(task.id)
if (updated?.status === "error" || updated?.status === "cancelled" || updated?.status === "interrupt") {
return `Task failed to start (status: ${updated.status}).\n\nTask ID: ${task.id}`
}
sessionId = updated?.sessionID
if (sessionId) {
break
}
if (toolContext.abort?.aborted) {
break
}
await new Promise<void>((resolve) => {
setTimeout(resolve, waitIntervalMs)
})
sessionId = manager.getTask(task.id)?.sessionID
}
await toolContext.metadata?.({
@@ -78,10 +81,9 @@ Description: ${task.description}
Agent: ${task.agent} (subagent)
Status: ${task.status}
The system will notify you when the task completes.
Use \`background_output\` tool with task_id="${task.id}" to check progress:
- block=false (default): Check status immediately - returns full status info
- block=true: Wait for completion (rarely needed since system notifies)`
System notifies on completion. Use \`background_output\` with task_id="${task.id}" to check.
Do NOT call background_output now. Wait for <system-reminder> notification first.`
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return `Failed to launch background agent task: ${message}`
@@ -5,7 +5,13 @@ import type { PluginInput } from "@opencode-ai/plugin"
import { executeBackground } from "./background-executor"
describe("executeBackground", () => {
const launchMock = mock(() => Promise.resolve({
const launchMock = mock(async (_input?: { fallbackChain?: unknown }): Promise<{
id: string
sessionID: string | null
description: string
agent: string
status: string
}> => ({
id: "test-task-id",
sessionID: null,
description: "Test task",
@@ -83,7 +89,96 @@ describe("executeBackground", () => {
await executeBackground(testArgs, testContext, mockManager, mockClient, fallbackChain)
//#then
const launchArgs = launchMock.mock.calls.at(-1)?.[0]
const latestCall = [...launchMock.mock.calls].pop()
if (!latestCall) {
throw new Error("Expected background manager launch to be called")
}
const launchArgs = latestCall[0]
if (!launchArgs) {
throw new Error("Expected launch arguments")
}
expect(launchArgs.fallbackChain).toEqual(fallbackChain)
})
test("keeps launched background task alive when parent aborts before session id resolves", async () => {
//#given - parent abort after launch should stop waiting, not fail the background task
const abortController = new AbortController()
launchMock.mockResolvedValueOnce({
id: "test-task-id",
sessionID: null,
description: "Test task",
agent: "test-agent",
status: "pending",
})
getTaskMock.mockImplementationOnce(() => {
abortController.abort()
return { id: "test-task-id", sessionID: null, description: "Test task", agent: "test-agent", status: "pending" }
})
//#when
const result = await executeBackground(
testArgs,
{
...testContext,
abort: abortController.signal,
},
mockManager,
mockClient
)
//#then - background launch should still be reported as launched
expect(result).toContain("Background agent task launched successfully")
expect(result).toContain("Task ID: test-task-id")
expect(result).not.toContain("Task aborted while waiting for session to start")
})
test("keeps sibling background launch alive when two tasks start concurrently", async () => {
//#given - one aborted parent call should not interrupt a sibling launch from the same parent session
const firstAbortController = new AbortController()
const secondAbortController = new AbortController()
const states = new Map([
["task-1", { reads: 0, abortOnFirstRead: true, sessionID: "ses-1" }],
["task-2", { reads: 0, abortOnFirstRead: false, sessionID: "ses-2" }],
])
let launchCount = 0
launchMock.mockImplementation(async () => {
launchCount += 1
return launchCount === 1
? { id: "task-1", sessionID: null, description: "Task 1", agent: "test-agent", status: "pending" }
: { id: "task-2", sessionID: null, description: "Task 2", agent: "test-agent", status: "pending" }
})
getTaskMock.mockImplementation((taskID: string) => {
const state = states.get(taskID)
if (!state) return undefined
state.reads += 1
if (state.abortOnFirstRead && state.reads === 1) {
firstAbortController.abort()
}
return state.reads >= 2
? { id: taskID, sessionID: state.sessionID, description: "Task", agent: "test-agent", status: "pending" }
: { id: taskID, sessionID: null, description: "Task", agent: "test-agent", status: "pending" }
})
//#when
const [firstResult, secondResult] = await Promise.all([
executeBackground(
testArgs,
{ ...testContext, abort: firstAbortController.signal },
mockManager,
mockClient,
),
executeBackground(
testArgs,
{ ...testContext, abort: secondAbortController.signal },
mockManager,
mockClient,
),
])
//#then - both launches still succeed and the sibling is not marked interrupted
expect(firstResult).toContain("Background agent task launched successfully")
expect(secondResult).toContain("Background agent task launched successfully")
expect(secondResult).toContain("Task ID: task-2")
expect(secondResult).not.toContain("interrupt")
})
})
@@ -61,15 +61,18 @@ export async function executeBackground(
const waitStart = Date.now()
let sessionId = task.sessionID
while (!sessionId && Date.now() - waitStart < WAIT_FOR_SESSION_TIMEOUT_MS) {
if (toolContext.abort?.aborted) {
return `Task aborted while waiting for session to start.\n\nTask ID: ${task.id}`
}
const updated = manager.getTask(task.id)
if (updated?.status === "error" || updated?.status === "cancelled" || updated?.status === "interrupt") {
return `Task failed to start (status: ${updated.status}).\n\nTask ID: ${task.id}`
}
sessionId = updated?.sessionID
if (sessionId) {
break
}
if (toolContext.abort?.aborted) {
break
}
await new Promise(resolve => setTimeout(resolve, WAIT_FOR_SESSION_INTERVAL_MS))
sessionId = manager.getTask(task.id)?.sessionID
}
await toolContext.metadata?.({
@@ -85,10 +88,9 @@ Description: ${task.description}
Agent: ${task.agent} (subagent)
Status: ${task.status}
The system will notify you when the task completes.
Use \`background_output\` tool with task_id="${task.id}" to check progress:
- block=false (default): Check status immediately - returns full status info
- block=true: Wait for completion (rarely needed since system notifies)`
System notifies on completion. Use \`background_output\` with task_id="${task.id}" to check.
Do NOT call background_output now. Wait for <system-reminder> notification first.`
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return `Failed to launch background agent task: ${message}`
@@ -15,7 +15,6 @@ export async function waitForCompletion(
): Promise<void> {
log(`[call_omo_agent] Polling for completion...`)
// Poll for session completion
const POLL_INTERVAL_MS = 500
const MAX_POLL_TIME_MS = 5 * 60 * 1000 // 5 minutes max
const pollStart = Date.now()
@@ -24,7 +23,6 @@ export async function waitForCompletion(
const STABILITY_REQUIRED = 3
while (Date.now() - pollStart < MAX_POLL_TIME_MS) {
// Check if aborted
if (toolContext.abort?.aborted) {
log(`[call_omo_agent] Aborted by user`)
throw new Error("Task aborted.")
@@ -32,19 +30,16 @@ export async function waitForCompletion(
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS))
// Check session status
const statusResult = await ctx.client.session.status()
const allStatuses = normalizeSDKResponse(statusResult, {} as Record<string, { type: string }>)
const sessionStatus = allStatuses[sessionID]
// If session is actively running, reset stability counter
if (sessionStatus && sessionStatus.type !== "idle") {
stablePolls = 0
lastMsgCount = 0
continue
}
// Session is idle - check message stability
const messagesCheck = await ctx.client.session.messages({ path: { id: sessionID } })
const msgs = normalizeSDKResponse(messagesCheck, [] as Array<unknown>, {
preferResponseOnMissingData: true,
@@ -8,6 +8,11 @@ type PromptAsyncInput = {
agent: string
tools: Record<string, boolean>
parts: Array<{ type: string; text: string }>
model?: { providerID: string; modelID: string }
variant?: string
temperature?: number
topP?: number
options?: Record<string, unknown>
}
}
@@ -110,6 +115,27 @@ describe("executeSync", () => {
expect(promptInput?.body.parts).toEqual([{ type: "text", text: "find something" }])
})
test("removes invisible agent characters before sending the sync prompt", async () => {
//#given
const executeSync = await importExecuteSync()
const deps = createDependencies()
const toolContext = createToolContext()
const recorder = createPromptAsyncRecorder()
const args = {
subagent_type: "\u200BSisyphus\u200B - Ultraworker",
description: "test task",
prompt: "find something",
run_in_background: false,
}
//#when
await executeSync(args, toolContext, createContext(recorder.promptAsync) as never, deps)
//#then
const promptInput = recorder.getCapturedInput()
expect(promptInput?.body.agent).toBe("Sisyphus - Ultraworker")
})
test("returns processed response with task metadata footer", async () => {
//#given
const executeSync = await importExecuteSync()
@@ -141,6 +167,56 @@ describe("executeSync", () => {
)
})
test("forwards delegated model tuning params in the sync prompt body", async () => {
//#given
const executeSync = await importExecuteSync()
const deps = createDependencies()
const toolContext = createToolContext()
const recorder = createPromptAsyncRecorder()
const args = {
subagent_type: "explore",
description: "test task",
prompt: "find something",
run_in_background: false,
}
const model = {
providerID: "openai",
modelID: "gpt-5.4",
variant: "high",
temperature: 0.12,
top_p: 0.34,
maxTokens: 5678,
reasoningEffort: "medium",
thinking: { type: "disabled" as const },
}
//#when
await executeSync(
args,
toolContext,
createContext(recorder.promptAsync) as never,
deps,
undefined,
undefined,
model,
)
//#then
const promptInput = recorder.getCapturedInput()
expect(promptInput?.body.model).toEqual({
providerID: "openai",
modelID: "gpt-5.4",
})
expect(promptInput?.body.variant).toBe("high")
expect(promptInput?.body.temperature).toBe(0.12)
expect(promptInput?.body.topP).toBe(0.34)
expect(promptInput?.body.options).toEqual({
reasoningEffort: "medium",
thinking: { type: "disabled" },
})
expect(promptInput?.body.maxOutputTokens).toBe(5678)
})
test("records metadata with description and created session id", async () => {
//#given
const executeSync = await importExecuteSync()
@@ -225,6 +301,27 @@ describe("executeSync", () => {
expect(deps.processMessages).not.toHaveBeenCalled()
})
test("strips invisible sort prefixes before sending sync prompts", async () => {
//#given
const executeSync = await importExecuteSync()
const deps = createDependencies()
const toolContext = createToolContext()
const recorder = createPromptAsyncRecorder()
const args = {
subagent_type: "\u200BSisyphus - Ultraworker",
description: "prefixed agent",
prompt: "find something",
run_in_background: false,
}
//#when
await executeSync(args, toolContext, createContext(recorder.promptAsync) as never, deps)
//#then
const promptInput = recorder.getCapturedInput()
expect(promptInput?.body.agent).toBe("Sisyphus - Ultraworker")
})
test("returns generic prompt failure with task metadata", async () => {
//#given
const executeSync = await importExecuteSync()
+27 -3
View File
@@ -3,8 +3,10 @@ import type { PluginInput } from "@opencode-ai/plugin"
import { subagentSessions, syncSubagentSessions } from "../../features/claude-code-session-state"
import { clearSessionFallbackChain, setSessionFallbackChain } from "../../hooks/model-fallback/hook"
import { getAgentToolRestrictions, log } from "../../shared"
import { applySessionPromptParams } from "../../shared/session-prompt-params-helpers"
import type { DelegatedModelConfig } from "../../shared/model-resolution-types"
import type { FallbackEntry } from "../../shared/model-requirements"
import { stripAgentListSortPrefix } from "../../shared/agent-display-names"
import { waitForCompletion } from "./completion-poller"
import { processMessages } from "./message-processor"
import { createOrGetSession } from "./session-creator"
@@ -34,6 +36,24 @@ const defaultDeps: ExecuteSyncDeps = {
clearSessionFallbackChain,
}
function buildPromptGenerationParams(model: DelegatedModelConfig | undefined): Record<string, unknown> {
if (!model) {
return {}
}
const promptOptions: Record<string, unknown> = {
...(model.reasoningEffort ? { reasoningEffort: model.reasoningEffort } : {}),
...(model.thinking ? { thinking: model.thinking } : {}),
}
return {
...(model.temperature !== undefined ? { temperature: model.temperature } : {}),
...(model.top_p !== undefined ? { topP: model.top_p } : {}),
...(model.maxTokens !== undefined ? { maxOutputTokens: model.maxTokens } : {}),
...(Object.keys(promptOptions).length > 0 ? { options: promptOptions } : {}),
}
}
export async function executeSync(
args: CallOmoAgentArgs,
toolContext: {
@@ -69,6 +89,8 @@ export async function executeSync(
appliedFallbackChain = true
}
applySessionPromptParams(sessionID, model)
await Promise.resolve(
toolContext.metadata?.({
title: args.description,
@@ -78,27 +100,29 @@ export async function executeSync(
log(`[call_omo_agent] Sending prompt to session ${sessionID}`)
log(`[call_omo_agent] Prompt text:`, args.prompt.substring(0, 100))
const normalizedSubagentType = stripAgentListSortPrefix(args.subagent_type)
try {
await (ctx.client.session as unknown as SessionWithPromptAsync).promptAsync({
path: { id: sessionID },
body: {
agent: args.subagent_type,
agent: normalizedSubagentType,
tools: {
...getAgentToolRestrictions(args.subagent_type),
...getAgentToolRestrictions(normalizedSubagentType),
task: false,
question: false,
},
parts: [{ type: "text", text: args.prompt }],
...(model ? { model: { providerID: model.providerID, modelID: model.modelID } } : {}),
...(model?.variant ? { variant: model.variant } : {}),
...buildPromptGenerationParams(model),
},
})
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
log(`[call_omo_agent] Prompt error:`, errorMessage)
if (errorMessage.includes("agent.name") || errorMessage.includes("undefined")) {
return `Error: Agent "${args.subagent_type}" not found. Make sure the agent is registered in your opencode.json or provided by a plugin.\n\n<task_metadata>\nsession_id: ${sessionID}\n</task_metadata>`
return `Error: Agent "${normalizedSubagentType}" not found. Make sure the agent is registered in your opencode.json or provided by a plugin.\n\n<task_metadata>\nsession_id: ${sessionID}\n</task_metadata>`
}
return `Error: Failed to send prompt: ${errorMessage}\n\n<task_metadata>\nsession_id: ${sessionID}\n</task_metadata>`
}
+104
View File
@@ -265,6 +265,110 @@ describe("createCallOmoAgent", () => {
})
})
test("parses inline model variant from agent config override", async () => {
//#given
const launch = mock((_input: { model?: { providerID: string; modelID: string; variant?: string } }) => Promise.resolve({
id: "task-inline-variant",
sessionID: "sub-session",
description: "Test task",
agent: "explore",
status: "pending",
}))
const managerWithLaunch = {
launch,
getTask: mock(() => undefined),
}
const toolDef = createCallOmoAgent(
mockCtx,
managerWithLaunch,
[],
{
explore: {
model: "openai/gpt-5.4 high",
},
},
)
const executeFunc = toolDef.execute as Function
//#when
await executeFunc(
{
description: "Test inline variant",
prompt: "Test prompt",
subagent_type: "explore",
run_in_background: true,
},
{ sessionID: "test", messageID: "msg", agent: "test", abort: new AbortController().signal }
)
//#then
const firstLaunchCall = launch.mock.calls[0]
if (firstLaunchCall === undefined) {
throw new Error("Expected launch to be called")
}
const [launchArgs] = firstLaunchCall
expect(launchArgs.model).toEqual({
providerID: "openai",
modelID: "gpt-5.4",
variant: "high",
})
})
test("forwards category-derived model override to background executor", async () => {
//#given
const launch = mock((_input: { model?: { providerID: string; modelID: string } }) => Promise.resolve({
id: "task-category-model",
sessionID: "sub-session",
description: "Test task",
agent: "explore",
status: "pending",
}))
const managerWithLaunch = {
launch,
getTask: mock(() => undefined),
}
const toolDef = createCallOmoAgent(
mockCtx,
managerWithLaunch,
[],
{
explore: {
category: "research",
},
},
{
research: {
model: "openai/gpt-5.4",
},
},
)
const executeFunc = toolDef.execute as Function
//#when
await executeFunc(
{
description: "Test category model override",
prompt: "Test prompt",
subagent_type: "explore",
run_in_background: true,
},
{ sessionID: "test", messageID: "msg", agent: "test", abort: new AbortController().signal }
)
//#then
const firstLaunchCall = launch.mock.calls[0]
if (firstLaunchCall === undefined) {
throw new Error("Expected launch to be called")
}
const [launchArgs] = firstLaunchCall
expect(launchArgs.model).toEqual({
providerID: "openai",
modelID: "gpt-5.4",
})
})
test("should return a tool error when sync spawn depth validation fails", async () => {
//#given
reserveSubagentSpawnMock.mockRejectedValueOnce(new Error("Subagent spawn blocked: child depth 4 exceeds background_task.maxDepth=3."))
+22 -3
View File
@@ -7,10 +7,11 @@ import type { DelegatedModelConfig } from "../../shared/model-resolution-types"
import type { FallbackEntry } from "../../shared/model-requirements"
import { AGENT_MODEL_REQUIREMENTS } from "../../shared/model-requirements"
import { getAgentConfigKey } from "../../shared/agent-display-names"
import { normalizeModelFormat } from "../../shared/model-format-normalizer"
import { normalizeFallbackModels } from "../../shared/model-resolver"
import { buildFallbackChainFromModels } from "../../shared/fallback-chain-from-models"
import { log } from "../../shared"
import { CONFIG_BASENAME } from "../../shared/plugin-identity"
import { parseModelString } from "../delegate-task/model-string-parser"
import { executeBackground } from "./background-executor"
import { executeSync } from "./sync-executor"
@@ -27,10 +28,16 @@ function resolveModelAndFallbackChain(args: {
?? (agentOverrides
? Object.entries(agentOverrides).find(([key]) => key.toLowerCase() === agentConfigKey)?.[1]
: undefined)
const agentCategoryModel = agentOverride?.category
? userCategories?.[agentOverride.category]?.model
: undefined
const agentCategoryVariant = agentOverride?.category
? userCategories?.[agentOverride.category]?.variant
: undefined
let model: DelegatedModelConfig | undefined
if (agentOverride?.model) {
const normalized = normalizeModelFormat(agentOverride.model)
const normalized = parseModelString(agentOverride.model)
if (normalized) {
model = agentOverride.variant ? { ...normalized, variant: agentOverride.variant } : normalized
log("[call_omo_agent] Resolved model override from agent config", {
@@ -39,6 +46,18 @@ function resolveModelAndFallbackChain(args: {
variant: agentOverride.variant,
})
}
} else if (agentCategoryModel) {
const normalized = parseModelString(agentCategoryModel)
if (normalized) {
const variantToUse = agentOverride?.variant ?? agentCategoryVariant
model = variantToUse ? { ...normalized, variant: variantToUse } : normalized
log("[call_omo_agent] Resolved model override from agent category", {
agent: subagentType,
category: agentOverride?.category,
model: agentCategoryModel,
variant: variantToUse,
})
}
}
const normalizedFallbackModels = normalizeFallbackModels(
@@ -99,7 +118,7 @@ export function createCallOmoAgent(
// Check if agent is disabled
if (disabledAgents.some((disabled) => disabled.toLowerCase() === normalizedAgent)) {
return `Error: Agent "${normalizedAgent}" is disabled via disabled_agents configuration. Remove it from disabled_agents in your oh-my-opencode.json to use it.`
return `Error: Agent "${normalizedAgent}" is disabled via disabled_agents configuration. Remove it from disabled_agents in your ${CONFIG_BASENAME}.json to use it.`
}
const { model: resolvedModel, fallbackChain } = resolveModelAndFallbackChain({
+1 -1
View File
@@ -1,6 +1,6 @@
# src/tools/delegate-task/ — Task Delegation Engine
**Generated:** 2026-03-06
**Generated:** 2026-04-11
## OVERVIEW
@@ -0,0 +1,54 @@
import type { BuiltinCategoryDefinition } from "./builtin-category-definition"
const UNSPECIFIED_LOW_CATEGORY_PROMPT_APPEND = `<Category_Context>
You are working on tasks that don't fit specific categories but require moderate effort.
<Selection_Gate>
BEFORE selecting this category, VERIFY ALL conditions:
1. Task does NOT fit: quick (trivial), visual-engineering (UI), ultrabrain (deep logic), artistry (creative), writing (docs)
2. Task requires more than trivial effort but is NOT system-wide
3. Scope is contained within a few files/modules
If task fits ANY other category, DO NOT select unspecified-low.
This is NOT a default choice - it's for genuinely unclassifiable moderate-effort work.
</Selection_Gate>
</Category_Context>
<Caller_Warning>
THIS CATEGORY USES A MID-TIER MODEL (claude-sonnet-4-6).
**PROVIDE CLEAR STRUCTURE:**
1. MUST DO: Enumerate required actions explicitly
2. MUST NOT DO: State forbidden actions to prevent scope creep
3. EXPECTED OUTPUT: Define concrete success criteria
</Caller_Warning>`
const UNSPECIFIED_HIGH_CATEGORY_PROMPT_APPEND = `<Category_Context>
You are working on tasks that don't fit specific categories but require substantial effort.
<Selection_Gate>
BEFORE selecting this category, VERIFY ALL conditions:
1. Task does NOT fit: quick (trivial), visual-engineering (UI), ultrabrain (deep logic), artistry (creative), writing (docs)
2. Task requires substantial effort across multiple systems/modules
3. Changes have broad impact or require careful coordination
4. NOT just "complex" - must be genuinely unclassifiable AND high-effort
If task fits ANY other category, DO NOT select unspecified-high.
If task is unclassifiable but moderate-effort, use unspecified-low instead.
</Selection_Gate>
</Category_Context>`
export const ANTHROPIC_CATEGORIES: BuiltinCategoryDefinition[] = [
{
name: "unspecified-low",
config: { model: "anthropic/claude-sonnet-4-6" },
description: "Tasks that don't fit other categories, low effort required",
promptAppend: UNSPECIFIED_LOW_CATEGORY_PROMPT_APPEND,
},
{
name: "unspecified-high",
config: { model: "anthropic/claude-opus-4-6", variant: "max" },
description: "Tasks that don't fit other categories, high effort required",
promptAppend: UNSPECIFIED_HIGH_CATEGORY_PROMPT_APPEND,
},
]
@@ -3,6 +3,7 @@ import type { ExecutorContext, ParentContext } from "./executor-types"
import { storeToolMetadata } from "../../features/tool-metadata-store"
import { formatDetailedError } from "./error-formatting"
import { getSessionTools } from "../../shared/session-tools-store"
import { resolveCallID } from "./resolve-call-id"
export async function executeBackgroundContinuation(
args: DelegateTaskArgs,
@@ -37,8 +38,9 @@ export async function executeBackgroundContinuation(
},
}
await ctx.metadata?.(bgContMeta)
if (ctx.callID) {
storeToolMetadata(ctx.sessionID, ctx.callID, bgContMeta)
const callID = resolveCallID(ctx)
if (callID) {
storeToolMetadata(ctx.sessionID, callID, bgContMeta)
}
return `Background task continued.
@@ -49,7 +51,9 @@ Agent: ${task.agent}
Status: ${task.status}
Agent continues with full previous context preserved.
Use \`background_output\` with task_id="${task.id}" to check progress.
System notifies on completion. Use \`background_output\` with task_id="${task.id}" to check.
Do NOT call background_output now. Wait for <system-reminder> notification first.
<task_metadata>
session_id: ${task.sessionID}
@@ -7,6 +7,7 @@ const afterEachFn = bunTest.afterEach
const { executeBackgroundTask } = require("./background-task")
const { __setTimingConfig, __resetTimingConfig } = require("./timing")
const { SessionCategoryRegistry } = require("../../shared/session-category-registry")
describeFn("executeBackgroundTask output/session metadata compatibility", () => {
beforeEachFn(() => {
@@ -19,6 +20,7 @@ describeFn("executeBackgroundTask output/session metadata compatibility", () =>
afterEachFn(() => {
__resetTimingConfig()
SessionCategoryRegistry.clear()
})
testFn("does not emit synthetic pending session metadata when session id is unresolved", async () => {
@@ -201,4 +203,318 @@ describeFn("executeBackgroundTask output/session metadata compatibility", () =>
{ permission: "question", action: "deny", pattern: "*" },
])
})
testFn("strips leading zwsp from agent name before launching background task", async () => {
//#given - display-sorted agent names should be normalized before manager launch
const launchCalls: unknown[] = []
const manager = {
launch: async (input: unknown) => {
launchCalls.push(input)
return {
id: "bg_clean_agent",
sessionID: "ses_clean_agent",
description: "Clean agent",
agent: "sisyphus-junior",
status: "running",
}
},
getTask: () => ({ sessionID: "ses_clean_agent" }),
}
//#when
await executeBackgroundTask(
{
description: "Clean agent",
prompt: "check",
run_in_background: true,
load_skills: [],
},
{
sessionID: "ses_parent",
callID: "call_clean_agent",
metadata: async () => {},
abort: new AbortController().signal,
},
{ manager },
{ sessionID: "ses_parent", messageID: "msg_clean_agent" },
"\u200Bsisyphus-junior",
undefined,
undefined,
undefined,
)
//#then
expectFn(launchCalls).toHaveLength(1)
expectFn((launchCalls[0] as { agent: string }).agent).toBe("sisyphus-junior")
})
testFn("keeps launched background task alive when parent aborts before session id resolves", async () => {
//#given - parallel tool execution can abort the parent call after launch succeeds
const metadataCalls: any[] = []
const abortController = new AbortController()
const manager = {
launch: async () => ({
id: "bg_abort_after_launch",
sessionID: undefined,
description: "Abort after launch",
agent: "explore",
status: "pending",
}),
getTask: () => {
abortController.abort()
return { sessionID: undefined, status: "pending" }
},
}
//#when
const result = await executeBackgroundTask(
{
description: "Abort after launch",
prompt: "check",
run_in_background: true,
load_skills: [],
},
{
sessionID: "ses_parent",
callID: "call_abort_after_launch",
metadata: async (value: any) => metadataCalls.push(value),
abort: abortController.signal,
},
{ manager },
{ sessionID: "ses_parent", messageID: "msg_abort_after_launch" },
"explore",
undefined,
undefined,
undefined,
)
//#then - background launch should still succeed without fake abort failure
expectFn(result).toContain("Background task launched")
expectFn(result).toContain("Background Task ID: bg_abort_after_launch")
expectFn(result).not.toContain("Task aborted while waiting for session to start")
expectFn(metadataCalls).toHaveLength(1)
expectFn("sessionId" in metadataCalls[0].metadata).toBe(false)
})
testFn("registers late session category even when parent aborts before session id resolves", async () => {
//#given - session wiring should continue after returning early on parent abort
const abortController = new AbortController()
abortController.abort()
let reads = 0
const manager = {
launch: async () => ({
id: "bg_abort_category",
sessionID: undefined,
description: "Abort category",
agent: "explore",
status: "pending",
}),
getTask: () => {
reads += 1
return reads >= 2
? { sessionID: "ses_abort_category", status: "running" }
: { sessionID: undefined, status: "pending" }
},
}
//#when
const result = await executeBackgroundTask(
{
description: "Abort category",
prompt: "check",
run_in_background: true,
load_skills: [],
category: "quick",
},
{
sessionID: "ses_parent",
callID: "call_abort_category",
metadata: async () => {},
abort: abortController.signal,
},
{ manager },
{ sessionID: "ses_parent", messageID: "msg_abort_category" },
"explore",
undefined,
undefined,
[{ providers: ["openai"], model: "gpt-5.4" }],
)
await new Promise(resolve => setTimeout(resolve, 5))
//#then - late session setup should still register category for runtime fallback
expectFn(result).toContain("Background task launched")
expectFn(SessionCategoryRegistry.get("ses_abort_category")).toBe("quick")
})
testFn("prefers child terminal status over parent abort while waiting for session id", async () => {
//#given - failed child launch should not be misreported as a successful background launch
const abortController = new AbortController()
abortController.abort()
const manager = {
launch: async () => ({
id: "bg_abort_terminal",
sessionID: undefined,
description: "Abort terminal",
agent: "explore",
status: "pending",
}),
getTask: () => ({ sessionID: undefined, status: "interrupt" }),
}
//#when
const result = await executeBackgroundTask(
{
description: "Abort terminal",
prompt: "check",
run_in_background: true,
load_skills: [],
},
{
sessionID: "ses_parent",
callID: "call_abort_terminal",
metadata: async () => {},
abort: abortController.signal,
},
{ manager },
{ sessionID: "ses_parent", messageID: "msg_abort_terminal" },
"explore",
undefined,
undefined,
undefined,
)
//#then - terminal child status should win over abort and surface the failure
expectFn(result).toContain("Task failed to start")
expectFn(result).toContain("interrupt")
})
testFn("reports failure when manager marks task as error during session startup", async () => {
//#given - session created but startTask throws before prompt is sent
const metadataCalls: any[] = []
let reads = 0
const manager = {
launch: async () => ({
id: "bg_crash_before_prompt",
sessionID: undefined,
description: "Crash before prompt",
agent: "explore",
status: "pending",
}),
getTask: () => {
reads += 1
if (reads >= 2) {
return { sessionID: "ses_orphan", status: "error", error: "crash between session creation and prompt send" }
}
return { sessionID: undefined, status: "pending" }
},
}
//#when
const result = await executeBackgroundTask(
{
description: "Crash before prompt",
prompt: "check",
run_in_background: true,
load_skills: [],
},
{
sessionID: "ses_parent",
callID: "call_crash",
metadata: async (value: any) => metadataCalls.push(value),
abort: new AbortController().signal,
},
{ manager },
{ sessionID: "ses_parent", messageID: "msg_crash" },
"explore",
undefined,
undefined,
undefined,
)
//#then - polling loop should detect terminal status and report failure
expectFn(result).toContain("Task failed to start")
expectFn(result).toContain("error")
})
testFn("keeps sibling background launch alive when two tasks start concurrently", async () => {
//#given - one aborted parent call should not interrupt a sibling launch from the same parent session
const firstAbortController = new AbortController()
const secondAbortController = new AbortController()
const states = new Map([
["bg_first", { reads: 0, abortOnFirstRead: true, sessionID: "ses_first" }],
["bg_second", { reads: 0, abortOnFirstRead: false, sessionID: "ses_second" }],
])
let launchCount = 0
const manager = {
launch: async () => {
launchCount += 1
return launchCount === 1
? { id: "bg_first", sessionID: undefined, description: "First", agent: "explore", status: "pending" }
: { id: "bg_second", sessionID: undefined, description: "Second", agent: "explore", status: "pending" }
},
getTask: (taskID: string) => {
const state = states.get(taskID)
if (!state) return undefined
state.reads += 1
if (state.abortOnFirstRead && state.reads === 1) {
firstAbortController.abort()
}
return state.reads >= 2
? { sessionID: state.sessionID, status: "running" }
: { sessionID: undefined, status: "pending" }
},
}
//#when
const [firstResult, secondResult] = await Promise.all([
executeBackgroundTask(
{
description: "First",
prompt: "check",
run_in_background: true,
load_skills: [],
},
{
sessionID: "ses_parent",
callID: "call_first",
metadata: async () => {},
abort: firstAbortController.signal,
},
{ manager },
{ sessionID: "ses_parent", messageID: "msg_first" },
"explore",
undefined,
undefined,
undefined,
),
executeBackgroundTask(
{
description: "Second",
prompt: "check",
run_in_background: true,
load_skills: [],
},
{
sessionID: "ses_parent",
callID: "call_second",
metadata: async () => {},
abort: secondAbortController.signal,
},
{ manager },
{ sessionID: "ses_parent", messageID: "msg_second" },
"explore",
undefined,
undefined,
undefined,
),
])
//#then - both tasks still launch and the sibling is not reported as interrupted
expectFn(firstResult).toContain("Background task launched")
expectFn(firstResult).not.toContain("Task failed to start")
expectFn(secondResult).toContain("Background task launched")
expectFn(secondResult).toContain("session_id: ses_second")
expectFn(secondResult).not.toContain("interrupt")
})
})
+65 -9
View File
@@ -4,11 +4,50 @@ import type { FallbackEntry } from "../../shared/model-requirements"
import { getTimingConfig } from "./timing"
import { buildTaskPrompt } from "./prompt-builder"
import { storeToolMetadata } from "../../features/tool-metadata-store"
import { resolveCallID } from "./resolve-call-id"
import { formatDetailedError } from "./error-formatting"
import { getSessionTools } from "../../shared/session-tools-store"
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
import { QUESTION_DENIED_SESSION_PERMISSION } from "../../shared/question-denied-session-permission"
import { setSessionFallbackChain } from "../../hooks/model-fallback/hook"
import { stripAgentListSortPrefix } from "../../shared/agent-display-names"
function continueSessionSetup(args: {
taskID: string
manager: ExecutorContext["manager"]
timing: ReturnType<typeof getTimingConfig>
fallbackChain?: FallbackEntry[]
category?: string
}): void {
if (!args.fallbackChain && !args.category) {
return
}
void (async () => {
const waitStart = Date.now()
while (Date.now() - waitStart < args.timing.WAIT_FOR_SESSION_TIMEOUT_MS) {
await new Promise(resolve => setTimeout(resolve, args.timing.WAIT_FOR_SESSION_INTERVAL_MS))
const updated = args.manager.getTask(args.taskID)
if (!updated) {
return
}
if (updated.status === "error" || updated.status === "cancelled" || updated.status === "interrupt") {
return
}
const sessionId = updated.sessionID
if (!sessionId) {
continue
}
setSessionFallbackChain(sessionId, args.fallbackChain)
if (args.category) {
SessionCategoryRegistry.register(sessionId, args.category)
}
return
}
})()
}
export async function executeBackgroundTask(
args: DelegateTaskArgs,
@@ -24,11 +63,12 @@ export async function executeBackgroundTask(
try {
const tddEnabled = executorCtx.sisyphusAgentConfig?.tdd
const effectivePrompt = buildTaskPrompt(args.prompt, agentToUse, tddEnabled)
const normalizedAgent = stripAgentListSortPrefix(agentToUse)
const effectivePrompt = buildTaskPrompt(args.prompt, normalizedAgent, tddEnabled)
const task = await manager.launch({
description: args.description,
prompt: effectivePrompt,
agent: agentToUse,
agent: normalizedAgent,
parentSessionID: parentContext.sessionID,
parentMessageID: parentContext.messageID,
parentModel: parentContext.model,
@@ -50,12 +90,25 @@ export async function executeBackgroundTask(
const waitStart = Date.now()
let sessionId = task.sessionID
while (!sessionId && Date.now() - waitStart < timing.WAIT_FOR_SESSION_TIMEOUT_MS) {
const updated = manager.getTask(task.id)
if (updated?.status === "error" || updated?.status === "cancelled" || updated?.status === "interrupt") {
return `Task failed to start (status: ${updated.status}).\n\nTask ID: ${task.id}`
}
sessionId = updated?.sessionID
if (sessionId) {
break
}
if (ctx.abort?.aborted) {
return `Task aborted while waiting for session to start.\n\nTask ID: ${task.id}`
continueSessionSetup({
taskID: task.id,
manager,
timing,
fallbackChain,
category: args.category,
})
break
}
await new Promise(resolve => setTimeout(resolve, timing.WAIT_FOR_SESSION_INTERVAL_MS))
const updated = manager.getTask(task.id)
sessionId = updated?.sessionID
}
if (sessionId) {
@@ -82,8 +135,9 @@ export async function executeBackgroundTask(
metadata,
}
await ctx.metadata?.(unstableMeta)
if (ctx.callID) {
storeToolMetadata(ctx.sessionID, ctx.callID, unstableMeta)
const callID = resolveCallID(ctx)
if (callID) {
storeToolMetadata(ctx.sessionID, callID, unstableMeta)
}
const taskMetadataBlock = sessionId
@@ -97,12 +151,14 @@ Description: ${task.description}
Agent: ${task.agent}${args.category ? ` (category: ${args.category})` : ""}
Status: ${task.status}
System notifies on completion. Use \`background_output\` with task_id="${task.id}" to check.${taskMetadataBlock}`
System notifies on completion. Use \`background_output\` with task_id="${task.id}" to check.
Do NOT call background_output now. Wait for <system-reminder> notification first.${taskMetadataBlock}`
} catch (error) {
return formatDetailedError(error, {
operation: "Launch background task",
args,
agent: agentToUse,
agent: stripAgentListSortPrefix(agentToUse),
category: args.category,
})
}
@@ -0,0 +1,33 @@
import type { CategoryConfig } from "../../config/schema"
import { ANTHROPIC_CATEGORIES } from "./anthropic-categories"
import type { BuiltinCategoryDefinition } from "./builtin-category-definition"
import { GOOGLE_CATEGORIES } from "./google-categories"
import { KIMI_CATEGORIES } from "./kimi-categories"
import { OPENAI_CATEGORIES } from "./openai-categories"
const BUILTIN_CATEGORIES: BuiltinCategoryDefinition[] = [
...GOOGLE_CATEGORIES,
...OPENAI_CATEGORIES,
...ANTHROPIC_CATEGORIES,
...KIMI_CATEGORIES,
]
function buildCategoryRecord<TValue>(
selector: (definition: BuiltinCategoryDefinition) => TValue
): Record<string, TValue> {
return Object.fromEntries(
BUILTIN_CATEGORIES.map((definition) => [definition.name, selector(definition)])
)
}
export const DEFAULT_CATEGORIES: Record<string, CategoryConfig> = buildCategoryRecord(
(definition) => definition.config
)
export const CATEGORY_PROMPT_APPENDS: Record<string, string> = buildCategoryRecord(
(definition) => definition.promptAppend
)
export const CATEGORY_DESCRIPTIONS: Record<string, string> = buildCategoryRecord(
(definition) => definition.description
)
@@ -0,0 +1,8 @@
import type { CategoryConfig } from "../../config/schema"
export type BuiltinCategoryDefinition = {
name: string
config: CategoryConfig
description: string
promptAppend: string
}
@@ -0,0 +1,43 @@
declare const require: (name: string) => any
const { afterEach, beforeEach, describe, expect, mock, spyOn, test } = require("bun:test")
import { resolveCategoryExecution } from "./category-resolver"
import type { ExecutorContext } from "./executor-types"
import * as availableModels from "./available-models"
describe("resolveCategoryExecution unknown category handling", () => {
beforeEach(() => {
mock.restore()
})
afterEach(() => {
mock.restore()
})
test("#given unknown category #when resolving category execution #then it rejects before fetching available models", async () => {
//#given
const availableModelsSpy = spyOn(availableModels, "getAvailableModelsForDelegateTask")
const executorContext: ExecutorContext = {
client: {} as ExecutorContext["client"],
manager: {} as ExecutorContext["manager"],
directory: "/tmp/test",
userCategories: {},
sisyphusJuniorModel: undefined,
}
const args = {
category: "backend-engineer",
prompt: "test prompt",
description: "Test task",
run_in_background: false,
load_skills: [],
blockedBy: undefined,
enableSkillTools: false,
}
//#when
const result = await resolveCategoryExecution(args, executorContext, undefined, "anthropic/claude-sonnet-4-6")
//#then
expect(result.error).toContain('Unknown category: "backend-engineer"')
expect(availableModelsSpy).not.toHaveBeenCalled()
})
})
+114 -18
View File
@@ -124,7 +124,7 @@ describe("resolveCategoryExecution", () => {
})
const agentsSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
const args = {
category: "deep",
category: "quick",
prompt: "test prompt",
description: "Test task",
run_in_background: false,
@@ -134,7 +134,7 @@ describe("resolveCategoryExecution", () => {
}
const executorCtx = createMockExecutorContext()
executorCtx.userCategories = {
deep: {
quick: {
fallback_models: [
{
model: "openai/gpt-5.4 high",
@@ -169,16 +169,10 @@ describe("resolveCategoryExecution", () => {
agentsSpy.mockRestore()
})
test("does not apply object-style fallback settings when the configured primary model matches directly", async () => {
test("preserves inline variant from category model string when no explicit variant is configured", async () => {
//#given
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
models: { openai: ["gpt-5.4-preview"] },
connected: ["openai"],
updatedAt: "2026-03-03T00:00:00.000Z",
})
const agentsSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
const args = {
category: "deep",
category: "quick",
prompt: "test prompt",
description: "Test task",
run_in_background: false,
@@ -188,7 +182,49 @@ describe("resolveCategoryExecution", () => {
}
const executorCtx = createMockExecutorContext()
executorCtx.userCategories = {
deep: {
quick: {
model: "openai/gpt-5.4 high",
},
}
//#when
const result = await resolveCategoryExecution(args, executorCtx, undefined, "anthropic/claude-sonnet-4-6")
//#then
expect(result.error).toBeUndefined()
expect(result.actualModel).toBeDefined()
expect(result.categoryModel).toBeDefined()
if (!result.actualModel || !result.categoryModel) {
throw new Error("Expected resolved model and category model")
}
expect(result.actualModel).toBe("openai/gpt-5.4")
expect(result.categoryModel).toEqual({
providerID: "openai",
modelID: "gpt-5.4",
variant: "high",
})
})
test("does not apply object-style fallback settings when the configured primary model matches directly", async () => {
//#given
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
models: { openai: ["gpt-5.4-preview"] },
connected: ["openai"],
updatedAt: "2026-03-03T00:00:00.000Z",
})
const agentsSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
const args = {
category: "quick",
prompt: "test prompt",
description: "Test task",
run_in_background: false,
load_skills: [],
blockedBy: undefined,
enableSkillTools: false,
}
const executorCtx = createMockExecutorContext()
executorCtx.userCategories = {
quick: {
model: "openai/gpt-5.4-preview",
fallback_models: [
{
@@ -209,7 +245,7 @@ describe("resolveCategoryExecution", () => {
expect(result.categoryModel).toEqual({
providerID: "openai",
modelID: "gpt-5.4-preview",
variant: "medium",
variant: undefined,
})
cacheSpy.mockRestore()
agentsSpy.mockRestore()
@@ -224,7 +260,7 @@ describe("resolveCategoryExecution", () => {
})
const agentsSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
const args = {
category: "deep",
category: "quick",
prompt: "test prompt",
description: "Test task",
run_in_background: false,
@@ -234,7 +270,7 @@ describe("resolveCategoryExecution", () => {
}
const executorCtx = createMockExecutorContext()
executorCtx.userCategories = {
deep: {
quick: {
fallback_models: [
{
model: "openai/gpt-5.4",
@@ -278,7 +314,7 @@ describe("resolveCategoryExecution", () => {
})
const agentsSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
const args = {
category: "deep",
category: "quick",
prompt: "test prompt",
description: "Test task",
run_in_background: false,
@@ -288,7 +324,7 @@ describe("resolveCategoryExecution", () => {
}
const executorCtx = createMockExecutorContext()
executorCtx.userCategories = {
deep: {
quick: {
fallback_models: [
{
model: "openai/gpt-5.4",
@@ -329,7 +365,7 @@ describe("resolveCategoryExecution", () => {
})
const agentsSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
const args = {
category: "deep",
category: "quick",
prompt: "test prompt",
description: "Test task",
run_in_background: false,
@@ -339,7 +375,7 @@ describe("resolveCategoryExecution", () => {
}
const executorCtx = createMockExecutorContext()
executorCtx.userCategories = {
deep: {
quick: {
fallback_models: [
{
model: "openai/gpt-5.4",
@@ -416,4 +452,64 @@ describe("resolveCategoryExecution", () => {
cacheSpy.mockRestore()
agentsSpy.mockRestore()
})
test("does not inherit hardcoded fallbackChain when user configures a category model [regression #3040]", async () => {
//#given
const args = {
category: "quick",
prompt: "test prompt",
description: "Test task",
run_in_background: false,
load_skills: [],
blockedBy: undefined,
enableSkillTools: false,
}
const executorCtx = createMockExecutorContext()
executorCtx.userCategories = {
quick: {
model: "animal-gateway-xai/grok-4-fast-non-reasoning",
},
}
//#when
const result = await resolveCategoryExecution(args, executorCtx, undefined, "anthropic/claude-sonnet-4-6")
//#then
expect(result.error).toBeUndefined()
expect(result.actualModel).toBe("animal-gateway-xai/grok-4-fast-non-reasoning")
expect(result.categoryModel).toEqual({
providerID: "animal-gateway-xai",
modelID: "grok-4-fast-non-reasoning",
variant: undefined,
})
expect(result.fallbackChain).toBeUndefined()
})
test("does not inherit hardcoded fallbackChain when sisyphus-junior model override is set [regression #2941]", async () => {
//#given
const args = {
category: "quick",
prompt: "test prompt",
description: "Test task",
run_in_background: false,
load_skills: [],
blockedBy: undefined,
enableSkillTools: false,
}
const executorCtx = createMockExecutorContext()
executorCtx.sisyphusJuniorModel = "anthropic/claude-sonnet-4-6"
//#when
const result = await resolveCategoryExecution(args, executorCtx, undefined, "anthropic/claude-sonnet-4-6")
//#then
expect(result.error).toBeUndefined()
expect(result.actualModel).toBe("anthropic/claude-sonnet-4-6")
expect(result.categoryModel).toEqual({
providerID: "anthropic",
modelID: "claude-sonnet-4-6",
variant: undefined,
})
expect(result.fallbackChain).toBeUndefined()
})
})
+26 -11
View File
@@ -9,6 +9,7 @@ import { parseModelString } from "./model-string-parser"
import { CATEGORY_MODEL_REQUIREMENTS } from "../../shared/model-requirements"
import { normalizeFallbackModels, flattenToFallbackModelStrings } from "../../shared/model-resolver"
import { buildFallbackChainFromModels, findMostSpecificFallbackEntry } from "../../shared/fallback-chain-from-models"
import { CONFIG_BASENAME } from "../../shared/plugin-identity"
import { getAvailableModelsForDelegateTask } from "./available-models"
import { resolveModelForDelegateTask } from "./model-selection"
@@ -45,12 +46,26 @@ export async function resolveCategoryExecution(
): Promise<CategoryResolutionResult> {
const { client, userCategories, sisyphusJuniorModel } = executorCtx
const availableModels = await getAvailableModelsForDelegateTask(client)
const categoryName = args.category!
const enabledCategories = mergeCategories(userCategories)
const categoryExists = enabledCategories[categoryName] !== undefined
if (!categoryExists) {
const allCategoryNames = Object.keys(enabledCategories).join(", ")
return {
agentToUse: "",
categoryModel: undefined,
categoryPromptAppend: undefined,
maxPromptTokens: undefined,
modelInfo: undefined,
actualModel: undefined,
isUnstableAgent: false,
error: `Unknown category: "${categoryName}". Available: ${allCategoryNames}`,
}
}
const availableModels = await getAvailableModelsForDelegateTask(client)
const resolved = resolveCategoryConfig(categoryName, {
userCategories,
inheritedModel,
@@ -75,7 +90,7 @@ export async function resolveCategoryExecution(
To use this category:
1. Connect a provider with this model: ${requirement.requiresModel}
2. Or configure an alternative model in your oh-my-opencode.json for this category
2. Or configure an alternative model in your ${CONFIG_BASENAME}.json for this category
Available categories: ${allCategoryNames}`,
}
@@ -117,7 +132,7 @@ Available categories: ${allCategoryNames}`,
const parsedModel = parseModelString(actualModel)
const variantToUse = userCategories?.[args.category!]?.variant ?? resolved.config.variant
categoryModel = parsedModel
? applyCategoryParams({ ...parsedModel, variant: variantToUse }, resolved.config)
? applyCategoryParams({ ...parsedModel, variant: variantToUse ?? parsedModel.variant }, resolved.config)
: undefined
}
} else {
@@ -136,12 +151,12 @@ Available categories: ${allCategoryNames}`,
const userModelOverride = explicitCategoryModel ?? overrideModel
if (userModelOverride) {
actualModel = userModelOverride
const parsedModel = parseModelString(actualModel)
const parsedModel = parseModelString(userModelOverride)
const variantToUse = userCategories?.[args.category!]?.variant ?? resolved.config.variant
categoryModel = parsedModel
? applyCategoryParams({ ...parsedModel, variant: variantToUse }, resolved.config)
? applyCategoryParams({ ...parsedModel, variant: variantToUse ?? parsedModel.variant }, resolved.config)
: undefined
modelInfo = { model: actualModel, type: "user-defined", source: "override" }
modelInfo = { model: userModelOverride, type: "user-defined", source: "override" }
}
} else if (resolution) {
const {
@@ -186,7 +201,7 @@ Available categories: ${allCategoryNames}`,
const parsedModel = parseModelString(actualModel)
const variantToUse = userCategories?.[args.category!]?.variant ?? resolvedVariant ?? resolved.config.variant
categoryModel = parsedModel
? applyCategoryParams({ ...parsedModel, variant: variantToUse }, resolved.config)
? applyCategoryParams({ ...parsedModel, variant: variantToUse ?? parsedModel.variant }, resolved.config)
: undefined
}
}
@@ -211,7 +226,7 @@ Available categories: ${allCategoryNames}`,
Configure in one of:
1. OpenCode: Set "model" in opencode.json
2. Oh-My-OpenCode: Set category model in oh-my-opencode.json
2. Oh-My-OpenCode: Set category model in ${CONFIG_BASENAME}.json
3. Provider: Connect a provider with available models
Current category: ${args.category}
@@ -220,7 +235,7 @@ Available categories: ${categoryNames.join(", ")}`,
}
const resolvedModel = actualModel?.toLowerCase()
const isUnstableAgent = resolved.config.is_unstable_agent ?? (resolvedModel ? resolvedModel.includes("gemini") || resolvedModel.includes("minimax") || resolvedModel.includes("kimi") : false)
const isUnstableAgent = resolved.config.is_unstable_agent ?? (resolvedModel ? resolvedModel.includes("gemini") || resolvedModel.includes("minimax") : false)
const defaultProviderID = categoryModel?.providerID
?? parseModelString(actualModel ?? "")?.providerID
@@ -261,6 +276,6 @@ Available categories: ${categoryNames.join(", ")}`,
actualModel,
isUnstableAgent,
// Don't use hardcoded fallback chain when resolution was skipped (cold cache)
fallbackChain: configuredFallbackChain ?? (isModelResolutionSkipped ? undefined : requirement?.fallbackChain),
fallbackChain: configuredFallbackChain ?? ((isModelResolutionSkipped || explicitCategoryModel || overrideModel) ? undefined : requirement?.fallbackChain),
}
}
+9 -319
View File
@@ -1,322 +1,14 @@
import type { CategoryConfig } from "../../config/schema"
import type {
AvailableCategory,
AvailableSkill,
} from "../../agents/dynamic-agent-prompt-builder"
import { getAgentConfigKey } from "../../shared/agent-display-names"
import { truncateDescription } from "../../shared/truncate-description"
export const VISUAL_CATEGORY_PROMPT_APPEND = `<Category_Context>
You are working on VISUAL/UI tasks.
<DESIGN_SYSTEM_WORKFLOW_MANDATE>
## YOU ARE A VISUAL ENGINEER. FOLLOW THIS WORKFLOW OR YOUR OUTPUT IS REJECTED.
**YOUR FAILURE MODE**: You skip design system analysis and jump straight to writing components with hardcoded colors, arbitrary spacing, and ad-hoc font sizes. The result is INCONSISTENT GARBAGE that looks like 5 different people built it. THIS STOPS NOW.
**EVERY visual task follows this EXACT workflow. VIOLATION = BROKEN OUTPUT.**
### PHASE 1: ANALYZE THE DESIGN SYSTEM (MANDATORY FIRST ACTION)
**BEFORE writing a SINGLE line of CSS, HTML, JSX, Svelte, or component code — you MUST:**
1. **SEARCH for the design system.** Use Grep, Glob, Read — actually LOOK:
- Design tokens: colors, spacing, typography, shadows, border-radii
- Theme files: CSS variables, Tailwind config, \`theme.ts\`, styled-components theme, design tokens file
- Shared/base components: Button, Card, Input, Layout primitives
- Existing UI patterns: How are pages structured? What spacing grid? What color usage?
2. **READ at minimum 5-10 existing UI components.** Understand:
- Naming conventions (BEM? Atomic? Utility-first? Component-scoped?)
- Spacing system (4px grid? 8px? Tailwind scale? CSS variables?)
- Color usage (semantic tokens? Direct hex? Theme references?)
- Typography scale (heading levels, body, caption — how many? What font stack?)
- Component composition patterns (slots? children? compound components?)
**DO NOT proceed to Phase 2 until you can answer ALL of these. If you cannot, you have not explored enough. EXPLORE MORE.**
### PHASE 2: NO DESIGN SYSTEM? BUILD ONE. NOW.
If Phase 1 reveals NO coherent design system (or scattered, inconsistent patterns):
1. **STOP. Do NOT build the requested UI yet.**
2. **Extract what exists** — even inconsistent patterns have salvageable decisions.
3. **Create a minimal design system FIRST:**
- Color palette: primary, secondary, neutral, semantic (success/warning/error/info)
- Typography scale: heading levels (h1-h4 minimum), body, small, caption
- Spacing scale: consistent increments (4px or 8px base)
- Border radii, shadows, transitions — systematic, not random
- Component primitives: the reusable building blocks
4. **Commit/save the design system, THEN proceed to Phase 3.**
A design system is NOT optional overhead. It is the FOUNDATION. Building UI without one is like building a house on sand. It WILL collapse into inconsistency.
### PHASE 3: BUILD WITH THE SYSTEM. NEVER AROUND IT.
**NOW and ONLY NOW** — implement the requested visual work:
| Element | CORRECT | WRONG (WILL BE REJECTED) |
|---------|---------|--------------------------|
| Color | Design token / CSS variable | Hardcoded \`#3b82f6\`, \`rgb(59,130,246)\` |
| Spacing | System value (\`space-4\`, \`gap-md\`, \`var(--spacing-4)\`) | Arbitrary \`margin: 13px\`, \`padding: 7px\` |
| Typography | Scale value (\`text-lg\`, \`heading-2\`, token) | Ad-hoc \`font-size: 17px\` |
| Component | Extend/compose from existing primitives | One-off div soup with inline styles |
| Border radius | System token | Random \`border-radius: 6px\` |
**IF the design requires something OUTSIDE the current system:**
- **Extend the system FIRST** — add the new token/primitive
- **THEN use the new token** in your component
- **NEVER one-off override.** That is how design systems die.
### PHASE 4: VERIFY BEFORE CLAIMING DONE
BEFORE reporting visual work as complete, answer these:
- [ ] Does EVERY color reference a design token or CSS variable?
- [ ] Does EVERY spacing use the system scale?
- [ ] Does EVERY component follow the existing composition pattern?
- [ ] Would a designer see CONSISTENCY across old and new components?
- [ ] Are there ZERO hardcoded magic numbers for visual properties?
**If ANY answer is NO — FIX IT. You are NOT done.**
</DESIGN_SYSTEM_WORKFLOW_MANDATE>
<DESIGN_QUALITY>
Design-first mindset (AFTER design system is established):
- Bold aesthetic choices over safe defaults
- Unexpected layouts, asymmetry, grid-breaking elements
- Distinctive typography (avoid: Arial, Inter, Roboto, Space Grotesk)
- Cohesive color palettes with sharp accents
- High-impact animations with staggered reveals
- Atmosphere: gradient meshes, noise textures, layered transparencies
AVOID: Generic fonts, purple gradients on white, predictable layouts, cookie-cutter patterns.
</DESIGN_QUALITY>
</Category_Context>`
export const ULTRABRAIN_CATEGORY_PROMPT_APPEND = `<Category_Context>
You are working on DEEP LOGICAL REASONING / COMPLEX ARCHITECTURE tasks.
**CRITICAL - CODE STYLE REQUIREMENTS (NON-NEGOTIABLE)**:
1. BEFORE writing ANY code, SEARCH the existing codebase to find similar patterns/styles
2. Your code MUST match the project's existing conventions - blend in seamlessly
3. Write READABLE code that humans can easily understand - no clever tricks
4. If unsure about style, explore more files until you find the pattern
Strategic advisor mindset:
- Bias toward simplicity: least complex solution that fulfills requirements
- Leverage existing code/patterns over new components
- Prioritize developer experience and maintainability
- One clear recommendation with effort estimate (Quick/Short/Medium/Large)
- Signal when advanced approach warranted
Response format:
- Bottom line (2-3 sentences)
- Action plan (numbered steps)
- Risks and mitigations (if relevant)
</Category_Context>`
export const ARTISTRY_CATEGORY_PROMPT_APPEND = `<Category_Context>
You are working on HIGHLY CREATIVE / ARTISTIC tasks.
Artistic genius mindset:
- Push far beyond conventional boundaries
- Explore radical, unconventional directions
- Surprise and delight: unexpected twists, novel combinations
- Rich detail and vivid expression
- Break patterns deliberately when it serves the creative vision
Approach:
- Generate diverse, bold options first
- Embrace ambiguity and wild experimentation
- Balance novelty with coherence
- This is for tasks requiring exceptional creativity
</Category_Context>`
export const QUICK_CATEGORY_PROMPT_APPEND = `<Category_Context>
You are working on SMALL / QUICK tasks.
Efficient execution mindset:
- Fast, focused, minimal overhead
- Get to the point immediately
- No over-engineering
- Simple solutions for simple problems
Approach:
- Minimal viable implementation
- Skip unnecessary abstractions
- Direct and concise
</Category_Context>
<Caller_Warning>
THIS CATEGORY USES A SMALLER/FASTER MODEL (gpt-5.4-mini).
The model executing this task is optimized for speed over depth. Your prompt MUST be:
**EXHAUSTIVELY EXPLICIT** - Leave NOTHING to interpretation:
1. MUST DO: List every required action as atomic, numbered steps
2. MUST NOT DO: Explicitly forbid likely mistakes and deviations
3. EXPECTED OUTPUT: Describe exact success criteria with concrete examples
**WHY THIS MATTERS:**
- Smaller models benefit from explicit guardrails
- Vague instructions may lead to unpredictable results
- Implicit expectations may be missed
**PROMPT STRUCTURE (MANDATORY):**
\`\`\`
TASK: [One-sentence goal]
MUST DO:
1. [Specific action with exact details]
2. [Another specific action]
...
MUST NOT DO:
- [Forbidden action + why]
- [Another forbidden action]
...
EXPECTED OUTPUT:
- [Exact deliverable description]
- [Success criteria / verification method]
\`\`\`
If your prompt lacks this structure, REWRITE IT before delegating.
</Caller_Warning>`
export const UNSPECIFIED_LOW_CATEGORY_PROMPT_APPEND = `<Category_Context>
You are working on tasks that don't fit specific categories but require moderate effort.
<Selection_Gate>
BEFORE selecting this category, VERIFY ALL conditions:
1. Task does NOT fit: quick (trivial), visual-engineering (UI), ultrabrain (deep logic), artistry (creative), writing (docs)
2. Task requires more than trivial effort but is NOT system-wide
3. Scope is contained within a few files/modules
If task fits ANY other category, DO NOT select unspecified-low.
This is NOT a default choice - it's for genuinely unclassifiable moderate-effort work.
</Selection_Gate>
</Category_Context>
<Caller_Warning>
THIS CATEGORY USES A MID-TIER MODEL (claude-sonnet-4-6).
**PROVIDE CLEAR STRUCTURE:**
1. MUST DO: Enumerate required actions explicitly
2. MUST NOT DO: State forbidden actions to prevent scope creep
3. EXPECTED OUTPUT: Define concrete success criteria
</Caller_Warning>`
export const UNSPECIFIED_HIGH_CATEGORY_PROMPT_APPEND = `<Category_Context>
You are working on tasks that don't fit specific categories but require substantial effort.
<Selection_Gate>
BEFORE selecting this category, VERIFY ALL conditions:
1. Task does NOT fit: quick (trivial), visual-engineering (UI), ultrabrain (deep logic), artistry (creative), writing (docs)
2. Task requires substantial effort across multiple systems/modules
3. Changes have broad impact or require careful coordination
4. NOT just "complex" - must be genuinely unclassifiable AND high-effort
If task fits ANY other category, DO NOT select unspecified-high.
If task is unclassifiable but moderate-effort, use unspecified-low instead.
</Selection_Gate>
</Category_Context>`
export const WRITING_CATEGORY_PROMPT_APPEND = `<Category_Context>
You are working on WRITING / PROSE tasks.
Wordsmith mindset:
- Clear, flowing prose
- Appropriate tone and voice
- Engaging and readable
- Proper structure and organization
Approach:
- Understand the audience
- Draft with care
- Polish for clarity and impact
- Documentation, READMEs, articles, technical writing
ANTI-AI-SLOP RULES (NON-NEGOTIABLE):
- NEVER use em dashes (—) or en dashes (). Use commas, periods, ellipses, or line breaks instead. Zero tolerance.
- Remove AI-sounding phrases: "delve", "it's important to note", "I'd be happy to", "certainly", "please don't hesitate", "leverage", "utilize", "in order to", "moving forward", "circle back", "at the end of the day", "robust", "streamline", "facilitate"
- Pick plain words. "Use" not "utilize". "Start" not "commence". "Help" not "facilitate".
- Use contractions naturally: "don't" not "do not", "it's" not "it is".
- Vary sentence length. Don't make every sentence the same length.
- NEVER start consecutive sentences with the same word.
- No filler openings: skip "In today's world...", "As we all know...", "It goes without saying..."
- Write like a human, not a corporate template.
</Category_Context>`
export const DEEP_CATEGORY_PROMPT_APPEND = `<Category_Context>
You are working on GOAL-ORIENTED AUTONOMOUS tasks.
**CRITICAL - AUTONOMOUS EXECUTION MINDSET (NON-NEGOTIABLE)**:
You are NOT an interactive assistant. You are an autonomous problem-solver.
**BEFORE making ANY changes**:
1. SILENTLY explore the codebase extensively (5-15 minutes of reading is normal)
2. Read related files, trace dependencies, understand the full context
3. Build a complete mental model of the problem space
4. DO NOT ask clarifying questions - the goal is already defined
**Autonomous executor mindset**:
- You receive a GOAL. When the goal includes numbered steps or phases, treat them as one atomic task broken into sub-steps - NOT as separate independent tasks.
- Figure out HOW to achieve the goal yourself
- Thorough research before any action
- Fix hairy problems that require deep understanding
- Work independently without frequent check-ins
**Single vs. multi-step context**:
- Sub-steps of ONE goal (e.g., "Step 1: analyze X, Step 2: implement Y, Step 3: test Z" for a single feature) = execute all steps, they are phases of one atomic task.
- Genuinely independent tasks (e.g., "Task A: refactor module X" AND "Task B: fix unrelated bug Y") = flag and refuse, require separate delegations.
**Approach**:
- Explore extensively, understand deeply, then act decisively
- Prefer comprehensive solutions over quick patches
- If the goal is unclear, make reasonable assumptions and proceed
- Document your reasoning in code comments only when non-obvious
**Response format**:
- Minimal status updates (user trusts your autonomy)
- Focus on results, not play-by-play progress
- Report completion with summary of changes made
</Category_Context>`
export const DEFAULT_CATEGORIES: Record<string, CategoryConfig> = {
"visual-engineering": { model: "google/gemini-3.1-pro", variant: "high" },
ultrabrain: { model: "openai/gpt-5.4", variant: "xhigh" },
deep: { model: "openai/gpt-5.3-codex", variant: "medium" },
artistry: { model: "google/gemini-3.1-pro", variant: "high" },
quick: { model: "openai/gpt-5.4-mini" },
"unspecified-low": { model: "anthropic/claude-sonnet-4-6" },
"unspecified-high": { model: "anthropic/claude-opus-4-6", variant: "max" },
writing: { model: "kimi-for-coding/k2p5" },
}
export const CATEGORY_PROMPT_APPENDS: Record<string, string> = {
"visual-engineering": VISUAL_CATEGORY_PROMPT_APPEND,
ultrabrain: ULTRABRAIN_CATEGORY_PROMPT_APPEND,
deep: DEEP_CATEGORY_PROMPT_APPEND,
artistry: ARTISTRY_CATEGORY_PROMPT_APPEND,
quick: QUICK_CATEGORY_PROMPT_APPEND,
"unspecified-low": UNSPECIFIED_LOW_CATEGORY_PROMPT_APPEND,
"unspecified-high": UNSPECIFIED_HIGH_CATEGORY_PROMPT_APPEND,
writing: WRITING_CATEGORY_PROMPT_APPEND,
}
export const CATEGORY_DESCRIPTIONS: Record<string, string> = {
"visual-engineering": "Frontend, UI/UX, design, styling, animation",
ultrabrain: "Use ONLY for genuinely hard, logic-heavy tasks. Give clear goals only, not step-by-step instructions.",
deep: "Goal-oriented autonomous problem-solving. Thorough research before action. For hairy problems requiring deep understanding.",
artistry: "Complex problem-solving with unconventional, creative approaches - beyond standard patterns",
quick: "Trivial tasks - single file changes, typo fixes, simple modifications",
"unspecified-low": "Tasks that don't fit other categories, low effort required",
"unspecified-high": "Tasks that don't fit other categories, high effort required",
writing: "Documentation, prose, technical writing",
}
export {
CATEGORY_DESCRIPTIONS,
CATEGORY_PROMPT_APPENDS,
DEFAULT_CATEGORIES,
} from "./builtin-categories"
/**
* System prompt prepended to plan agent invocations.
@@ -634,7 +326,7 @@ export const PLAN_AGENT_NAMES = ["plan"]
export function isPlanAgent(agentName: string | undefined): boolean {
if (!agentName) return false
const lowerName = agentName.toLowerCase().trim()
return PLAN_AGENT_NAMES.some(name => lowerName === name || lowerName.includes(name))
return PLAN_AGENT_NAMES.some(name => lowerName === name)
}
/**
@@ -650,8 +342,6 @@ export function isPlanFamily(category: string): boolean
export function isPlanFamily(category: string | undefined): boolean
export function isPlanFamily(category: string | undefined): boolean {
if (!category) return false
const lowerCategory = category.toLowerCase().trim()
return PLAN_FAMILY_NAMES.some(
(name) => lowerCategory === name || lowerCategory.includes(name)
)
const lowerCategory = getAgentConfigKey(category).toLowerCase().trim()
return PLAN_FAMILY_NAMES.some((name) => lowerCategory === name)
}
@@ -0,0 +1,122 @@
import type { BuiltinCategoryDefinition } from "./builtin-category-definition"
const VISUAL_CATEGORY_PROMPT_APPEND = `<Category_Context>
You are working on VISUAL/UI tasks.
<DESIGN_SYSTEM_WORKFLOW_MANDATE>
## YOU ARE A VISUAL ENGINEER. FOLLOW THIS WORKFLOW OR YOUR OUTPUT IS REJECTED.
**YOUR FAILURE MODE**: You skip design system analysis and jump straight to writing components with hardcoded colors, arbitrary spacing, and ad-hoc font sizes. The result is INCONSISTENT GARBAGE that looks like 5 different people built it. THIS STOPS NOW.
**EVERY visual task follows this EXACT workflow. VIOLATION = BROKEN OUTPUT.**
### PHASE 1: ANALYZE THE DESIGN SYSTEM (MANDATORY FIRST ACTION)
**BEFORE writing a SINGLE line of CSS, HTML, JSX, Svelte, or component code - you MUST:**
1. **SEARCH for the design system.** Use Grep, Glob, Read - actually LOOK:
- Design tokens: colors, spacing, typography, shadows, border-radii
- Theme files: CSS variables, Tailwind config, \`theme.ts\`, styled-components theme, design tokens file
- Shared/base components: Button, Card, Input, Layout primitives
- Existing UI patterns: How are pages structured? What spacing grid? What color usage?
2. **READ at minimum 5-10 existing UI components.** Understand:
- Naming conventions (BEM? Atomic? Utility-first? Component-scoped?)
- Spacing system (4px grid? 8px? Tailwind scale? CSS variables?)
- Color usage (semantic tokens? Direct hex? Theme references?)
- Typography scale (heading levels, body, caption - how many? What font stack?)
- Component composition patterns (slots? children? compound components?)
**DO NOT proceed to Phase 2 until you can answer ALL of these. If you cannot, you have not explored enough. EXPLORE MORE.**
### PHASE 2: NO DESIGN SYSTEM? BUILD ONE. NOW.
If Phase 1 reveals NO coherent design system (or scattered, inconsistent patterns):
1. **STOP. Do NOT build the requested UI yet.**
2. **Extract what exists** - even inconsistent patterns have salvageable decisions.
3. **Create a minimal design system FIRST:**
- Color palette: primary, secondary, neutral, semantic (success/warning/error/info)
- Typography scale: heading levels (h1-h4 minimum), body, small, caption
- Spacing scale: consistent increments (4px or 8px base)
- Border radii, shadows, transitions - systematic, not random
- Component primitives: the reusable building blocks
4. **Commit/save the design system, THEN proceed to Phase 3.**
A design system is NOT optional overhead. It is the FOUNDATION. Building UI without one is like building a house on sand. It WILL collapse into inconsistency.
### PHASE 3: BUILD WITH THE SYSTEM. NEVER AROUND IT.
**NOW and ONLY NOW** - implement the requested visual work:
| Element | CORRECT | WRONG (WILL BE REJECTED) |
|---------|---------|--------------------------|
| Color | Design token / CSS variable | Hardcoded \`#3b82f6\`, \`rgb(59,130,246)\` |
| Spacing | System value (\`space-4\`, \`gap-md\`, \`var(--spacing-4)\`) | Arbitrary \`margin: 13px\`, \`padding: 7px\` |
| Typography | Scale value (\`text-lg\`, \`heading-2\`, token) | Ad-hoc \`font-size: 17px\` |
| Component | Extend/compose from existing primitives | One-off div soup with inline styles |
| Border radius | System token | Random \`border-radius: 6px\` |
**IF the design requires something OUTSIDE the current system:**
- **Extend the system FIRST** - add the new token/primitive
- **THEN use the new token** in your component
- **NEVER one-off override.** That is how design systems die.
### PHASE 4: VERIFY BEFORE CLAIMING DONE
BEFORE reporting visual work as complete, answer these:
- [ ] Does EVERY color reference a design token or CSS variable?
- [ ] Does EVERY spacing use the system scale?
- [ ] Does EVERY component follow the existing composition pattern?
- [ ] Would a designer see CONSISTENCY across old and new components?
- [ ] Are there ZERO hardcoded magic numbers for visual properties?
**If ANY answer is NO - FIX IT. You are NOT done.**
</DESIGN_SYSTEM_WORKFLOW_MANDATE>
<DESIGN_QUALITY>
Design-first mindset (AFTER design system is established):
- Bold aesthetic choices over safe defaults
- Unexpected layouts, asymmetry, grid-breaking elements
- Distinctive typography (avoid: Arial, Inter, Roboto, Space Grotesk)
- Cohesive color palettes with sharp accents
- High-impact animations with staggered reveals
- Atmosphere: gradient meshes, noise textures, layered transparencies
AVOID: Generic fonts, purple gradients on white, predictable layouts, cookie-cutter patterns.
</DESIGN_QUALITY>
</Category_Context>`
const ARTISTRY_CATEGORY_PROMPT_APPEND = `<Category_Context>
You are working on HIGHLY CREATIVE / ARTISTIC tasks.
Artistic genius mindset:
- Push far beyond conventional boundaries
- Explore radical, unconventional directions
- Surprise and delight: unexpected twists, novel combinations
- Rich detail and vivid expression
- Break patterns deliberately when it serves the creative vision
Approach:
- Generate diverse, bold options first
- Embrace ambiguity and wild experimentation
- Balance novelty with coherence
- This is for tasks requiring exceptional creativity
</Category_Context>`
export const GOOGLE_CATEGORIES: BuiltinCategoryDefinition[] = [
{
name: "visual-engineering",
config: { model: "google/gemini-3.1-pro", variant: "high" },
description: "Frontend, UI/UX, design, styling, animation",
promptAppend: VISUAL_CATEGORY_PROMPT_APPEND,
},
{
name: "artistry",
config: { model: "google/gemini-3.1-pro", variant: "high" },
description: "Complex problem-solving with unconventional, creative approaches - beyond standard patterns",
promptAppend: ARTISTRY_CATEGORY_PROMPT_APPEND,
},
]
@@ -0,0 +1,36 @@
import type { BuiltinCategoryDefinition } from "./builtin-category-definition"
const WRITING_CATEGORY_PROMPT_APPEND = `<Category_Context>
You are working on WRITING / PROSE tasks.
Wordsmith mindset:
- Clear, flowing prose
- Appropriate tone and voice
- Engaging and readable
- Proper structure and organization
Approach:
- Understand the audience
- Draft with care
- Polish for clarity and impact
- Documentation, READMEs, articles, technical writing
ANTI-AI-SLOP RULES (NON-NEGOTIABLE):
- NEVER use em dashes (-) or en dashes (-). Use commas, periods, ellipses, or line breaks instead. Zero tolerance.
- Remove AI-sounding phrases: "delve", "it's important to note", "I'd be happy to", "certainly", "please don't hesitate", "leverage", "utilize", "in order to", "moving forward", "circle back", "at the end of the day", "robust", "streamline", "facilitate"
- Pick plain words. "Use" not "utilize". "Start" not "commence". "Help" not "facilitate".
- Use contractions naturally: "don't" not "do not", "it's" not "it is".
- Vary sentence length. Don't make every sentence the same length.
- NEVER start consecutive sentences with the same word.
- No filler openings: skip "In today's world...", "As we all know...", "It goes without saying..."
- Write like a human, not a corporate template.
</Category_Context>`
export const KIMI_CATEGORIES: BuiltinCategoryDefinition[] = [
{
name: "writing",
config: { model: "kimi-for-coding/k2p5" },
description: "Documentation, prose, technical writing",
promptAppend: WRITING_CATEGORY_PROMPT_APPEND,
},
]
+115 -20
View File
@@ -1,5 +1,6 @@
declare const require: (name: string) => any
const { afterEach, beforeEach, describe, expect, mock, spyOn, test } = require("bun:test")
/// <reference types="bun-types" />
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"
import { resolveModelForDelegateTask } from "./model-selection"
import * as connectedProvidersCache from "../../shared/connected-providers-cache"
@@ -25,12 +26,12 @@ describe("resolveModelForDelegateTask", () => {
describe("#when availableModels is empty and no user model override", () => {
test("#then returns skipped sentinel to leave model unpinned", () => {
const result = resolveModelForDelegateTask({
categoryDefaultModel: "anthropic/claude-sonnet-4-6",
categoryDefaultModel: "anthropic/claude-sonnet-4.6",
fallbackChain: [
{ providers: ["anthropic"], model: "claude-sonnet-4-6" },
],
availableModels: new Set(),
systemDefaultModel: "anthropic/claude-sonnet-4-6",
systemDefaultModel: "anthropic/claude-sonnet-4.6",
})
expect(result).toEqual({ skipped: true })
@@ -41,12 +42,12 @@ describe("resolveModelForDelegateTask", () => {
test("#then returns the user model regardless of cache state", () => {
const result = resolveModelForDelegateTask({
userModel: "openai/gpt-5.4",
categoryDefaultModel: "anthropic/claude-sonnet-4-6",
categoryDefaultModel: "anthropic/claude-sonnet-4.6",
fallbackChain: [
{ providers: ["anthropic"], model: "claude-sonnet-4-6" },
],
availableModels: new Set(),
systemDefaultModel: "anthropic/claude-sonnet-4-6",
systemDefaultModel: "anthropic/claude-sonnet-4.6",
})
expect(result).toEqual({ model: "openai/gpt-5.4" })
@@ -57,7 +58,7 @@ describe("resolveModelForDelegateTask", () => {
test("#then returns skipped sentinel (skip fallback resolution without cache)", () => {
const result = resolveModelForDelegateTask({
userFallbackModels: ["openai/gpt-5.4", "google/gemini-3.1-pro"],
categoryDefaultModel: "anthropic/claude-sonnet-4-6",
categoryDefaultModel: "anthropic/claude-sonnet-4.6",
fallbackChain: [
{ providers: ["anthropic"], model: "claude-sonnet-4-6" },
],
@@ -80,15 +81,15 @@ describe("resolveModelForDelegateTask", () => {
const readConnectedProvidersSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["anthropic"])
const result = resolveModelForDelegateTask({
categoryDefaultModel: "anthropic/claude-sonnet-4-6",
categoryDefaultModel: "anthropic/claude-sonnet-4.6",
fallbackChain: [
{ providers: ["anthropic"], model: "claude-sonnet-4-6" },
],
availableModels: new Set(),
systemDefaultModel: "anthropic/claude-sonnet-4-6",
systemDefaultModel: "anthropic/claude-sonnet-4.6",
})
expect(result).toEqual({ model: "anthropic/claude-sonnet-4-6" })
expect(result).toEqual({ model: "anthropic/claude-sonnet-4.6" })
readConnectedProvidersSpy.mockRestore()
})
@@ -96,12 +97,12 @@ describe("resolveModelForDelegateTask", () => {
const readConnectedProvidersSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
const result = resolveModelForDelegateTask({
categoryDefaultModel: "anthropic/claude-sonnet-4-6",
categoryDefaultModel: "anthropic/claude-sonnet-4.6",
fallbackChain: [
{ providers: ["openai"], model: "gpt-5.4", variant: "high" },
],
availableModels: new Set(),
systemDefaultModel: "anthropic/claude-sonnet-4-6",
systemDefaultModel: "anthropic/claude-sonnet-4.6",
})
expect(result).toEqual({
@@ -117,7 +118,7 @@ describe("resolveModelForDelegateTask", () => {
const readConnectedProvidersSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
const result = resolveModelForDelegateTask({
userFallbackModels: ["anthropic/claude-sonnet-4-6", "openai/gpt-5.4"],
userFallbackModels: ["anthropic/claude-sonnet-4.6", "openai/gpt-5.4"],
availableModels: new Set(),
})
@@ -129,14 +130,14 @@ describe("resolveModelForDelegateTask", () => {
describe("#when availableModels has entries and category default matches", () => {
test("#then resolves via fuzzy match (existing behavior)", () => {
const result = resolveModelForDelegateTask({
categoryDefaultModel: "anthropic/claude-sonnet-4-6",
categoryDefaultModel: "anthropic/claude-sonnet-4.6",
fallbackChain: [
{ providers: ["anthropic"], model: "claude-sonnet-4-6" },
],
availableModels: new Set(["anthropic/claude-sonnet-4-6"]),
availableModels: new Set(["anthropic/claude-sonnet-4.6"]),
})
expect(result).toEqual({ model: "anthropic/claude-sonnet-4-6" })
expect(result).toEqual({ model: "anthropic/claude-sonnet-4.6" })
})
test("#then trusts user-configured category model without fuzzy validation", () => {
@@ -200,7 +201,7 @@ describe("resolveModelForDelegateTask", () => {
expect(result).toBeDefined()
expect(result).not.toHaveProperty("skipped")
const resolved = result as { model: string; variant?: string }
expect(resolved.model).toBe("anthropic/claude-haiku-4-5")
expect(resolved.model).toBe("anthropic/claude-haiku-4.5")
})
test("#then resolves first provider in entry that is connected", () => {
@@ -229,10 +230,10 @@ describe("resolveModelForDelegateTask", () => {
{ providers: ["opencode-go"], model: "minimax-m2.7" },
],
availableModels: new Set(),
systemDefaultModel: "anthropic/claude-sonnet-4-6",
systemDefaultModel: "anthropic/claude-sonnet-4.6",
})
expect(result).toEqual({ model: "anthropic/claude-sonnet-4-6" })
expect(result).toEqual({ model: "anthropic/claude-sonnet-4.6" })
})
})
@@ -254,6 +255,100 @@ describe("resolveModelForDelegateTask", () => {
})
})
describe("#given user model override includes variant syntax", () => {
describe("#when userModel contains space-separated variant", () => {
test("#then extracts the variant and returns the base model separately", () => {
const result = resolveModelForDelegateTask({
userModel: "openai/gpt-5.4 high",
categoryDefaultModel: "anthropic/claude-sonnet-4.6",
fallbackChain: [
{ providers: ["anthropic"], model: "claude-sonnet-4-6" },
],
availableModels: new Set(["openai/gpt-5.4"]),
})
expect(result).toEqual({ model: "openai/gpt-5.4", variant: "high" })
})
})
describe("#when userModel contains parenthesized variant", () => {
test("#then extracts the variant and returns the base model separately", () => {
const result = resolveModelForDelegateTask({
userModel: "openai/gpt-5.4(max)",
categoryDefaultModel: "anthropic/claude-sonnet-4.6",
availableModels: new Set(),
})
expect(result).toEqual({ model: "openai/gpt-5.4", variant: "max" })
})
})
describe("#when userModel has no variant syntax", () => {
test("#then returns the model without a variant (backward compat)", () => {
const result = resolveModelForDelegateTask({
userModel: "openai/gpt-5.4",
availableModels: new Set(),
})
expect(result).toEqual({ model: "openai/gpt-5.4" })
})
})
describe("#when userModel has a non-variant suffix (e.g. -high in model name)", () => {
test("#then preserves the full model name without extracting a variant", () => {
const result = resolveModelForDelegateTask({
userModel: "new-api-openai/gpt-5.4-high",
availableModels: new Set(),
})
expect(result).toEqual({ model: "new-api-openai/gpt-5.4-high" })
})
})
})
describe("#given user-configured category model includes variant syntax", () => {
beforeEach(() => {
hasConnectedProvidersSpy = spyOn(connectedProvidersCache, "hasConnectedProvidersCache").mockReturnValue(true)
hasProviderModelsSpy = spyOn(connectedProvidersCache, "hasProviderModelsCache").mockReturnValue(true)
})
describe("#when categoryDefaultModel with isUserConfiguredCategoryModel contains a space-separated variant", () => {
test("#then extracts the variant and returns the base model separately", () => {
const result = resolveModelForDelegateTask({
categoryDefaultModel: "openai/gpt-5.4 medium",
isUserConfiguredCategoryModel: true,
availableModels: new Set(["openai/gpt-5.4"]),
})
expect(result).toEqual({ model: "openai/gpt-5.4", variant: "medium" })
})
})
describe("#when categoryDefaultModel with isUserConfiguredCategoryModel contains a parenthesized variant", () => {
test("#then extracts the variant and returns the base model separately", () => {
const result = resolveModelForDelegateTask({
categoryDefaultModel: "openai/gpt-5.4(xhigh)",
isUserConfiguredCategoryModel: true,
availableModels: new Set(),
})
expect(result).toEqual({ model: "openai/gpt-5.4", variant: "xhigh" })
})
})
describe("#when categoryDefaultModel with isUserConfiguredCategoryModel has no variant", () => {
test("#then returns the model without a variant (backward compat)", () => {
const result = resolveModelForDelegateTask({
categoryDefaultModel: "new-api-openai/gpt-5.4-high",
isUserConfiguredCategoryModel: true,
availableModels: new Set(["openai/gpt-5.4"]),
})
expect(result).toEqual({ model: "new-api-openai/gpt-5.4-high" })
})
})
})
describe("#given only connected providers cache exists (no provider-models cache)", () => {
beforeEach(() => {
hasConnectedProvidersSpy = spyOn(connectedProvidersCache, "hasConnectedProvidersCache").mockReturnValue(true)
@@ -265,7 +360,7 @@ describe("resolveModelForDelegateTask", () => {
const readConnectedProvidersSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
const result = resolveModelForDelegateTask({
categoryDefaultModel: "anthropic/claude-sonnet-4-6",
categoryDefaultModel: "anthropic/claude-sonnet-4.6",
fallbackChain: [
{ providers: ["openai"], model: "gpt-5.4" },
],
@@ -56,6 +56,10 @@ export function resolveModelForDelegateTask(input: {
}): { model: string; variant?: string; fallbackEntry?: FallbackEntry; matchedFallback?: boolean } | { skipped: true } | undefined {
const userModel = normalizeModel(input.userModel)
if (userModel) {
const parsed = parseUserFallbackModel(userModel)
if (parsed?.variant) {
return { model: parsed.baseModel, variant: parsed.variant }
}
return { model: userModel }
}
@@ -75,6 +79,10 @@ export function resolveModelForDelegateTask(input: {
log("[resolveModelForDelegateTask] using user-configured category model (bypass validation)", {
categoryDefaultModel: categoryDefault,
})
const parsed = parseUserFallbackModel(categoryDefault)
if (parsed?.variant) {
return { model: parsed.baseModel, variant: parsed.variant }
}
return { model: categoryDefault }
}
@@ -0,0 +1,116 @@
import type { BuiltinCategoryDefinition } from "./builtin-category-definition"
const ULTRABRAIN_CATEGORY_PROMPT_APPEND = `<Category_Context>
You are working on DEEP LOGICAL REASONING / COMPLEX ARCHITECTURE tasks.
**CRITICAL - CODE STYLE REQUIREMENTS (NON-NEGOTIABLE)**:
1. BEFORE writing ANY code, SEARCH the existing codebase to find similar patterns/styles
2. Your code MUST match the project's existing conventions - blend in seamlessly
3. Write READABLE code that humans can easily understand - no clever tricks
4. If unsure about style, explore more files until you find the pattern
Strategic advisor mindset:
- Bias toward simplicity: least complex solution that fulfills requirements
- Leverage existing code/patterns over new components
- Prioritize developer experience and maintainability
- One clear recommendation with effort estimate (Quick/Short/Medium/Large)
- Signal when advanced approach warranted
Response format:
- Bottom line (2-3 sentences)
- Action plan (numbered steps)
- Risks and mitigations (if relevant)
</Category_Context>`
const DEEP_CATEGORY_PROMPT_APPEND = `<Category_Context>
You are working on GOAL-ORIENTED AUTONOMOUS tasks.
You are NOT an interactive assistant. You are an autonomous problem-solver.
BEFORE making ANY changes:
1. Silently explore the codebase extensively (5-15 minutes of reading is normal)
2. Read related files, trace dependencies, understand the full context
3. Build a complete mental model of the problem space
4. Do not ask clarifying questions - the goal is already defined
You receive a GOAL. When the goal includes numbered steps or phases, treat them as one atomic task broken into sub-steps, not as separate independent tasks. Figure out HOW to achieve it yourself. Thorough research before any action.
Sub-steps of ONE goal = execute all steps as phases of one atomic task.
Genuinely independent tasks = flag and refuse, require separate delegations.
Approach: explore extensively, understand deeply, then act decisively. Prefer comprehensive solutions over quick patches. If the goal is unclear, make reasonable assumptions and proceed.
Minimal status updates. Focus on results, not play-by-play. Report completion with summary of changes.
</Category_Context>`
const QUICK_CATEGORY_PROMPT_APPEND = `<Category_Context>
You are working on SMALL / QUICK tasks.
Efficient execution mindset:
- Fast, focused, minimal overhead
- Get to the point immediately
- No over-engineering
- Simple solutions for simple problems
Approach:
- Minimal viable implementation
- Skip unnecessary abstractions
- Direct and concise
</Category_Context>
<Caller_Warning>
THIS CATEGORY USES A SMALLER/FASTER MODEL (gpt-5.4-mini).
The model executing this task is optimized for speed over depth. Your prompt MUST be:
**EXHAUSTIVELY EXPLICIT** - Leave NOTHING to interpretation:
1. MUST DO: List every required action as atomic, numbered steps
2. MUST NOT DO: Explicitly forbid likely mistakes and deviations
3. EXPECTED OUTPUT: Describe exact success criteria with concrete examples
**WHY THIS MATTERS:**
- Smaller models benefit from explicit guardrails
- Vague instructions may lead to unpredictable results
- Implicit expectations may be missed
**PROMPT STRUCTURE (MANDATORY):**
\`\`\`
TASK: [One-sentence goal]
MUST DO:
1. [Specific action with exact details]
2. [Another specific action]
...
MUST NOT DO:
- [Forbidden action + why]
- [Another forbidden action]
...
EXPECTED OUTPUT:
- [Exact deliverable description]
- [Success criteria / verification method]
\`\`\`
If your prompt lacks this structure, REWRITE IT before delegating.
</Caller_Warning>`
export const OPENAI_CATEGORIES: BuiltinCategoryDefinition[] = [
{
name: "ultrabrain",
config: { model: "openai/gpt-5.4", variant: "xhigh" },
description: "Use ONLY for genuinely hard, logic-heavy tasks. Give clear goals only, not step-by-step instructions.",
promptAppend: ULTRABRAIN_CATEGORY_PROMPT_APPEND,
},
{
name: "deep",
config: { model: "openai/gpt-5.4", variant: "medium" },
description: "Goal-oriented autonomous problem-solving. Thorough research before action. For hairy problems requiring deep understanding.",
promptAppend: DEEP_CATEGORY_PROMPT_APPEND,
},
{
name: "quick",
config: { model: "openai/gpt-5.4-mini" },
description: "Trivial tasks - single file changes, typo fixes, simple modifications",
promptAppend: QUICK_CATEGORY_PROMPT_APPEND,
},
]
@@ -0,0 +1,125 @@
declare const require: (name: string) => unknown
const { describe, test, expect } = require("bun:test") as {
describe: (name: string, fn: () => void) => void
test: (name: string, fn: () => void) => void
expect: (value: unknown) => {
toBe: (expected: unknown) => void
toContain: (expected: string) => void
toBeUndefined: () => void
toBeDefined: () => void
not: {
toContain: (expected: string) => void
toBeUndefined: () => void
}
}
}
import { buildSystemContent } from "./prompt-builder"
import type { AvailableSkill, AvailableCategory } from "../../agents/dynamic-agent-prompt-builder"
describe("prompt-builder", () => {
describe("buildSystemContent", () => {
describe("#given non-plan agent with availableSkills", () => {
test("#when availableSkills contains project-level skills #then system content includes available_skills section", () => {
// given
const availableSkills: AvailableSkill[] = [
{ name: "git-master", description: "Git workflow automation", location: "plugin" },
{ name: "my-project-skill", description: "Project-specific deployment", location: "project" },
]
const availableCategories: AvailableCategory[] = [
{ name: "quick", description: "Trivial tasks", model: "openai/gpt-5.4-mini" },
]
// when
const result = buildSystemContent({
agentName: "sisyphus-junior",
availableSkills,
availableCategories,
})
// then
expect(result).toBeDefined()
expect(result).toContain("my-project-skill")
expect(result).toContain("git-master")
})
test("#when agent is explore #then system content includes available_skills section", () => {
// given
const availableSkills: AvailableSkill[] = [
{ name: "code-review", description: "Review code quality", location: "project" },
]
// when
const result = buildSystemContent({
agentName: "explore",
availableSkills,
})
// then
expect(result).toBeDefined()
expect(result).toContain("code-review")
})
test("#when availableSkills is empty #then system content does not include available_skills section", () => {
// given
const availableSkills: AvailableSkill[] = []
// when
const result = buildSystemContent({
agentName: "sisyphus-junior",
availableSkills,
categoryPromptAppend: "some category context",
})
// then
expect(result).toBeDefined()
expect(result).not.toContain("available_skills")
})
})
describe("#given plan agent with availableSkills", () => {
test("#when availableSkills provided #then system content includes plan agent prepend with skills", () => {
// given
const availableSkills: AvailableSkill[] = [
{ name: "git-master", description: "Git workflow automation", location: "plugin" },
]
const availableCategories: AvailableCategory[] = [
{ name: "quick", description: "Trivial tasks", model: "openai/gpt-5.4-mini" },
]
// when
const result = buildSystemContent({
agentName: "plan",
availableSkills,
availableCategories,
})
// then
expect(result).toBeDefined()
expect(result).toContain("git-master")
expect(result).toContain("AVAILABLE SKILLS")
})
})
describe("#given non-plan agent with agentsContext override", () => {
test("#when agentsContext is provided #then it takes precedence and skills section is appended", () => {
// given
const availableSkills: AvailableSkill[] = [
{ name: "deploy-skill", description: "Deployment automation", location: "project" },
]
// when
const result = buildSystemContent({
agentName: "sisyphus-junior",
agentsContext: "Custom agent context here",
availableSkills,
})
// then
expect(result).toBeDefined()
expect(result).toContain("Custom agent context here")
expect(result).toContain("deploy-skill")
})
})
})
})
+29 -2
View File
@@ -1,4 +1,5 @@
import type { BuildSystemContentInput } from "./types"
import type { AvailableSkill } from "../../agents/dynamic-agent-prompt-builder"
import { buildPlanAgentSystemPrepend, isPlanAgent } from "./constants"
import { buildSystemContentWithTokenLimit } from "./token-limiter"
@@ -21,6 +22,22 @@ ${TDD_LINE}`
return PLAN_AGENT_PROMPT_BASE
}
function buildAvailableSkillsSection(skills: AvailableSkill[]): string {
if (skills.length === 0) {
return ""
}
const rows = skills
.map((s) => `- \`${s.name}\`: ${s.description || s.name}`)
.join("\n")
return `<available_skills>
Skills provide specialized instructions. Load via load_skills parameter when delegating tasks.
${rows}
</available_skills>`
}
function usesFreeOrLocalModel(model: { providerID: string; modelID: string; variant?: string } | undefined): boolean {
if (!model) {
return false
@@ -51,10 +68,20 @@ export function buildSystemContent(input: BuildSystemContentInput): string | und
availableSkills,
} = input
const planAgentPrepend = isPlanAgent(agentName)
const isPlan = isPlanAgent(agentName)
const planAgentPrepend = isPlan
? buildPlanAgentSystemPrepend(availableCategories, availableSkills)
: ""
const skillsSection = !isPlan
? buildAvailableSkillsSection(availableSkills ?? [])
: ""
const baseAgentsContext = agentsContext ?? planAgentPrepend
const effectiveAgentsContext = !isPlan && skillsSection
? [baseAgentsContext, skillsSection].filter(Boolean).join("\n\n")
: baseAgentsContext
const effectiveMaxPromptTokens = maxPromptTokens
?? (usesFreeOrLocalModel(model) ? FREE_OR_LOCAL_PROMPT_TOKEN_LIMIT : undefined)
@@ -63,7 +90,7 @@ export function buildSystemContent(input: BuildSystemContentInput): string | und
skillContent,
skillContents,
categoryPromptAppend,
agentsContext: agentsContext ?? planAgentPrepend,
agentsContext: effectiveAgentsContext,
planAgentPrepend,
},
effectiveMaxPromptTokens
@@ -0,0 +1,40 @@
import { describe, test, expect } from "bun:test"
import { resolveCallID } from "./resolve-call-id"
import type { ToolContextWithMetadata } from "./types"
describe("resolveCallID", () => {
function makeCtx(overrides: Partial<ToolContextWithMetadata> = {}): ToolContextWithMetadata {
return {
sessionID: "ses_test",
messageID: "msg_test",
agent: "sisyphus",
abort: new AbortController().signal,
...overrides,
}
}
test("#given callID is set #then returns callID", () => {
const ctx = makeCtx({ callID: "call_abc" })
expect(resolveCallID(ctx)).toBe("call_abc")
})
test("#given only callId is set #then returns callId", () => {
const ctx = makeCtx({ callId: "call_def" })
expect(resolveCallID(ctx)).toBe("call_def")
})
test("#given only call_id is set #then returns call_id", () => {
const ctx = makeCtx({ call_id: "call_ghi" })
expect(resolveCallID(ctx)).toBe("call_ghi")
})
test("#given callID and callId are both set #then prefers callID", () => {
const ctx = makeCtx({ callID: "preferred", callId: "fallback" })
expect(resolveCallID(ctx)).toBe("preferred")
})
test("#given no call ID variants are set #then returns undefined", () => {
const ctx = makeCtx()
expect(resolveCallID(ctx)).toBeUndefined()
})
})
@@ -0,0 +1,5 @@
import type { ToolContextWithMetadata } from "./types"
export function resolveCallID(ctx: ToolContextWithMetadata): string | undefined {
return ctx.callID ?? ctx.callId ?? ctx.call_id
}
+99 -87
View File
@@ -7,15 +7,80 @@ import { normalizeModelFormat } from "../../shared/model-format-normalizer"
import { AGENT_MODEL_REQUIREMENTS } from "../../shared/model-requirements"
import { normalizeFallbackModels, flattenToFallbackModelStrings } from "../../shared/model-resolver"
import { buildFallbackChainFromModels, findMostSpecificFallbackEntry } from "../../shared/fallback-chain-from-models"
import { getAgentDisplayName, getAgentConfigKey } from "../../shared/agent-display-names"
import { getAgentDisplayName, getAgentConfigKey, stripAgentListSortPrefix } from "../../shared/agent-display-names"
import { normalizeSDKResponse } from "../../shared"
import { log } from "../../shared/logger"
import { getAvailableModelsForDelegateTask } from "./available-models"
import type { FallbackEntry } from "../../shared/model-requirements"
import { resolveModelForDelegateTask } from "./model-selection"
import { fuzzyMatchModel } from "../../shared/model-availability"
import type { CategoryConfig } from "../../config/schema"
import { loadUserAgents, loadProjectAgents } from "../../features/claude-code-agent-loader"
type AgentMode = "subagent" | "primary" | "all" | undefined
type AgentInfo = {
name: string
mode?: "subagent" | "primary" | "all"
model?: string | { providerID: string; modelID: string }
}
function applyCategoryParams(
base: DelegatedModelConfig,
config: CategoryConfig | undefined,
): DelegatedModelConfig {
if (!config) {
return base
}
return {
...base,
...(config.reasoningEffort !== undefined ? { reasoningEffort: config.reasoningEffort } : {}),
...(config.temperature !== undefined ? { temperature: config.temperature } : {}),
...(config.top_p !== undefined ? { top_p: config.top_p } : {}),
...(config.maxTokens !== undefined ? { maxTokens: config.maxTokens } : {}),
...(config.thinking !== undefined ? { thinking: config.thinking } : {}),
}
}
function mergeWithClaudeCodeAgents(
serverAgents: AgentInfo[],
directory: string | undefined,
): AgentInfo[] {
const userAgentsRecord = loadUserAgents()
const projectAgentsRecord = loadProjectAgents(directory)
const toAgentInfoList = (record: Record<string, { mode?: string; model?: AgentInfo["model"] }>): AgentInfo[] =>
Object.entries(record).map(([name, config]) => ({
name,
mode: config.mode as AgentInfo["mode"],
model: config.model,
}))
const projectAgentsList = toAgentInfoList(projectAgentsRecord)
const userAgentsList = toAgentInfoList(userAgentsRecord)
const mergedAgentMap = new Map<string, AgentInfo>()
const addIfAbsent = (agent: AgentInfo): void => {
const key = agent.name.toLowerCase()
if (!mergedAgentMap.has(key)) {
mergedAgentMap.set(key, agent)
}
}
for (const agent of serverAgents) {
addIfAbsent(agent)
}
for (const agent of projectAgentsList) {
addIfAbsent(agent)
}
for (const agent of userAgentsList) {
addIfAbsent(agent)
}
return Array.from(mergedAgentMap.values())
}
export async function resolveSubagentExecution(
args: DelegateTaskArgs,
executorCtx: ExecutorContext,
@@ -28,7 +93,9 @@ export async function resolveSubagentExecution(
return { agentToUse: "", categoryModel: undefined, error: `Agent name cannot be empty.` }
}
const agentName = args.subagent_type.trim()
// Strip wrapping characters (backslashes, quotes) that LLMs sometimes add around agent names
// e.g. \hephaestus\ -> hephaestus, "oracle" -> oracle, 'explore' -> explore
const agentName = args.subagent_type.trim().replace(/^[\\\/"']+|[\\\/"']+$/g, "").trim()
if (agentName.toLowerCase() === SISYPHUS_JUNIOR_AGENT.toLowerCase()) {
return {
@@ -54,82 +121,27 @@ Create the work plan directly - that's your job as the planning agent.`,
let categoryModel: DelegatedModelConfig | undefined
let fallbackChain: FallbackEntry[] | undefined = undefined
type AgentInfo = {
name: string
mode?: "subagent" | "primary" | "all"
model?: string | { providerID: string; modelID: string }
}
try {
const agentsResult = await client.app.agents()
const agents = normalizeSDKResponse(agentsResult, [] as AgentInfo[], {
preferResponseOnMissingData: true,
})
// Load user and project agents
const userAgentsRecord = loadUserAgents()
const projectAgentsRecord = loadProjectAgents(executorCtx.directory)
const mergedAgents = mergeWithClaudeCodeAgents(agents, executorCtx.directory)
const callableAgents = mergedAgents.filter((agent) => isTaskCallableAgentMode(agent.mode))
// Convert user/project agent configs to AgentInfo format
const userAgentsList: AgentInfo[] = Object.entries(userAgentsRecord).map(([name, config]) => ({
name,
mode: config.mode as "subagent" | "primary" | "all",
model: config.model,
}))
const projectAgentsList: AgentInfo[] = Object.entries(projectAgentsRecord).map(([name, config]) => ({
name,
mode: config.mode as "subagent" | "primary" | "all",
model: config.model,
}))
// Merge user and project agents into the server's agent list
// Server agents take precedence; project agents override user agents
const mergedAgentMap = new Map<string, AgentInfo>()
// First add server agents (they take precedence)
for (const agent of agents) {
mergedAgentMap.set(agent.name.toLowerCase(), agent)
}
// Then add project agents (overrides user agents, server wins on collision)
for (const agent of projectAgentsList) {
if (!mergedAgentMap.has(agent.name.toLowerCase())) {
mergedAgentMap.set(agent.name.toLowerCase(), agent)
}
}
// Then add user agents (only if not already added by server or project)
for (const agent of userAgentsList) {
if (!mergedAgentMap.has(agent.name.toLowerCase())) {
mergedAgentMap.set(agent.name.toLowerCase(), agent)
}
}
const mergedAgents = Array.from(mergedAgentMap.values())
const callableAgents = mergedAgents.filter((a) => a.mode !== "primary")
const resolvedDisplayName = getAgentDisplayName(agentToUse)
const resolvedDisplayName = stripAgentListSortPrefix(getAgentDisplayName(agentToUse))
const normalizedAgentToUse = stripAgentListSortPrefix(agentToUse)
const matchedAgent = callableAgents.find(
(agent) => agent.name.toLowerCase() === agentToUse.toLowerCase()
|| agent.name.toLowerCase() === resolvedDisplayName.toLowerCase()
(agent) => {
const normalizedListedAgentName = stripAgentListSortPrefix(agent.name)
return normalizedListedAgentName.toLowerCase() === normalizedAgentToUse.toLowerCase()
|| normalizedListedAgentName.toLowerCase() === resolvedDisplayName.toLowerCase()
}
)
if (!matchedAgent) {
const isPrimaryAgent = agents
.filter((a) => a.mode === "primary")
.find((agent) => agent.name.toLowerCase() === agentToUse.toLowerCase()
|| agent.name.toLowerCase() === resolvedDisplayName.toLowerCase())
if (isPrimaryAgent) {
return {
agentToUse: "",
categoryModel: undefined,
error: `Cannot call primary agent "${isPrimaryAgent.name}" via task. Primary agents are top-level orchestrators.`,
}
}
const availableAgents = callableAgents
.map((a) => a.name)
.map((a) => stripAgentListSortPrefix(a.name))
.sort()
.join(", ")
return {
@@ -139,18 +151,19 @@ Create the work plan directly - that's your job as the planning agent.`,
}
}
agentToUse = matchedAgent.name
agentToUse = stripAgentListSortPrefix(matchedAgent.name)
const agentConfigKey = getAgentConfigKey(agentToUse)
const agentOverride = agentOverrides?.[agentConfigKey as keyof typeof agentOverrides]
?? (agentOverrides ? Object.entries(agentOverrides).find(([key]) => key.toLowerCase() === agentConfigKey)?.[1] : undefined)
const agentRequirement = AGENT_MODEL_REQUIREMENTS[agentConfigKey]
const agentCategoryModel = agentOverride?.category
? userCategories?.[agentOverride.category]?.model
const agentCategoryConfig = agentOverride?.category
? userCategories?.[agentOverride.category]
: undefined
const agentCategoryModel = agentCategoryConfig?.model
const normalizedAgentFallbackModels = normalizeFallbackModels(
agentOverride?.fallback_models
?? (agentOverride?.category ? userCategories?.[agentOverride.category]?.fallback_models : undefined)
?? agentCategoryConfig?.fallback_models
)
const availableModels = await getAvailableModelsForDelegateTask(client)
@@ -178,19 +191,16 @@ Create the work plan directly - that's your job as the planning agent.`,
if (resolution && !resolutionSkipped) {
const normalized = normalizeModelFormat(resolution.model)
if (normalized) {
const variantToUse = agentOverride?.variant ?? resolution.variant
categoryModel = variantToUse ? { ...normalized, variant: variantToUse } : normalized
const variantToUse = agentOverride?.variant ?? resolution.variant ?? agentCategoryConfig?.variant
const resolvedModel = variantToUse ? { ...normalized, variant: variantToUse } : normalized
categoryModel = applyCategoryParams(resolvedModel, agentCategoryConfig)
}
} else if (resolutionSkipped && (agentOverride?.model ?? agentCategoryModel)) {
// Cold cache: resolution was skipped but user explicitly configured a model.
// Honor the user override directly — don't fall through to hardcoded fallback chain.
const normalized = normalizeModelFormat((agentOverride?.model ?? agentCategoryModel)!)
if (normalized) {
const agentCategoryVariant = agentOverride?.category
? userCategories?.[agentOverride.category]?.variant
: undefined
const variantToUse = agentOverride?.variant ?? agentCategoryVariant
categoryModel = variantToUse ? { ...normalized, variant: variantToUse } : normalized
const variantToUse = agentOverride?.variant ?? agentCategoryConfig?.variant
const resolvedModel = variantToUse ? { ...normalized, variant: variantToUse } : normalized
categoryModel = applyCategoryParams(resolvedModel, agentCategoryConfig)
log("[delegate-task] Cold cache: using explicit user override for subagent", {
agent: agentToUse,
model: agentOverride?.model ?? agentCategoryModel,
@@ -205,8 +215,6 @@ Create the work plan directly - that's your job as the planning agent.`,
normalizedAgentFallbackModels,
defaultProviderID,
)
// Don't assign hardcoded fallback chain when resolution was skipped (cold cache)
// — the chain may contain model IDs that don't exist in the provider yet.
fallbackChain = configuredFallbackChain ?? (resolutionSkipped ? undefined : agentRequirement?.fallbackChain)
// Only promote fallback-only settings when resolution actually selected a fallback model.
@@ -225,11 +233,11 @@ Create the work plan directly - that's your job as the planning agent.`,
categoryModel = {
...categoryModel,
variant: agentOverride?.variant ?? effectiveEntry.variant ?? categoryModel.variant,
reasoningEffort: effectiveEntry.reasoningEffort,
temperature: effectiveEntry.temperature,
top_p: effectiveEntry.top_p,
maxTokens: effectiveEntry.maxTokens,
thinking: effectiveEntry.thinking,
reasoningEffort: effectiveEntry.reasoningEffort ?? categoryModel.reasoningEffort,
temperature: effectiveEntry.temperature ?? categoryModel.temperature,
top_p: effectiveEntry.top_p ?? categoryModel.top_p,
maxTokens: effectiveEntry.maxTokens ?? categoryModel.maxTokens,
thinking: effectiveEntry.thinking ?? categoryModel.thinking,
}
}
}
@@ -265,3 +273,7 @@ Create the work plan directly - that's your job as the planning agent.`,
return { agentToUse, categoryModel, fallbackChain }
}
function isTaskCallableAgentMode(mode: AgentMode): boolean {
return mode === "all" || mode === "subagent"
}
@@ -605,8 +605,8 @@ describe("executeSyncContinuation - toast cleanup error paths", () => {
})
})
test("keeps plan-family task delegation available during sync continuation", async () => {
//#given - a resumed plan-family session should keep its intended task capability
test("keeps task delegation enabled during prometheus sync continuation", async () => {
//#given - a resumed prometheus session should keep plan-family task permission
const promptAsyncCalls: Array<{ path: { id: string }; body: Record<string, unknown> }> = []
const mockClient = {
session: {
@@ -656,7 +656,7 @@ describe("executeSyncContinuation - toast cleanup error paths", () => {
const args = {
session_id: "ses_test_12345678",
prompt: "continue planning",
description: "resume plan task",
description: "resume prometheus task",
load_skills: [],
run_in_background: false,
}
+4 -2
View File
@@ -2,6 +2,7 @@ import type { DelegateTaskArgs, ToolContextWithMetadata } from "./types"
import type { ExecutorContext, SessionMessage } from "./executor-types"
import { isPlanFamily } from "./constants"
import { storeToolMetadata } from "../../features/tool-metadata-store"
import { resolveCallID } from "./resolve-call-id"
import { getTaskToastManager } from "../../features/task-toast-manager"
import { getAgentToolRestrictions } from "../../shared/agent-tool-restrictions"
import { getMessageDir } from "../../shared"
@@ -78,8 +79,9 @@ export async function executeSyncContinuation(
},
}
await ctx.metadata?.(syncContMeta)
if (ctx.callID) {
storeToolMetadata(ctx.sessionID, ctx.callID, syncContMeta)
const callID = resolveCallID(ctx)
if (callID) {
storeToolMetadata(ctx.sessionID, callID, syncContMeta)
}
const allowTask = isPlanFamily(resumeAgent)
@@ -274,17 +274,65 @@ bunDescribe("sendSyncPrompt", () => {
modelID: "gpt-5.4",
})
bunExpect(promptArgs.body.variant).toBe("low")
bunExpect(promptArgs.body.options).toBeUndefined()
bunExpect(promptArgs.body.options).toEqual({
reasoningEffort: "high",
thinking: { type: "disabled" },
})
bunExpect(promptArgs.body.maxOutputTokens).toBe(4096)
bunExpect(getSessionPromptParams("test-session")).toEqual({
temperature: 0.4,
topP: 0.7,
maxOutputTokens: 4096,
options: {
reasoningEffort: "high",
thinking: { type: "disabled" },
maxTokens: 4096,
},
})
})
bunTest("forwards category temperature through the sync prompt body", async () => {
//#given
const { sendSyncPrompt } = require("./sync-prompt-sender")
let promptArgs: any
const promptWithModelSuggestionRetry = bunMock(async (_client: any, input: any) => {
promptArgs = input
})
const input = {
sessionID: "test-session",
agentToUse: "sisyphus-junior",
args: {
description: "test task",
prompt: "test prompt",
category: "quick",
run_in_background: false,
load_skills: [],
},
systemContent: undefined,
categoryModel: {
providerID: "openai",
modelID: "gpt-5.4",
temperature: 0.25,
},
toastManager: null,
taskId: undefined,
}
//#when
await sendSyncPrompt(
{ session: { promptAsync: bunMock(async () => ({ data: {} })) } },
input,
{
promptWithModelSuggestionRetry,
promptSyncWithModelSuggestionRetry: bunMock(async () => {}),
},
)
//#then
bunExpect(promptWithModelSuggestionRetry).toHaveBeenCalledTimes(1)
bunExpect(promptArgs.body.temperature).toBe(0.25)
})
bunTest("retries with promptSync for oracle when promptAsync fails with unexpected EOF", async () => {
//#given
const { sendSyncPrompt } = require("./sync-prompt-sender")
+20 -1
View File
@@ -22,6 +22,24 @@ const sendSyncPromptDeps: SendSyncPromptDeps = {
promptSyncWithModelSuggestionRetry,
}
function buildPromptGenerationParams(model: DelegatedModelConfig | undefined): Record<string, unknown> {
if (!model) {
return {}
}
const promptOptions: Record<string, unknown> = {
...(model.reasoningEffort ? { reasoningEffort: model.reasoningEffort } : {}),
...(model.thinking ? { thinking: model.thinking } : {}),
}
return {
...(model.temperature !== undefined ? { temperature: model.temperature } : {}),
...(model.top_p !== undefined ? { topP: model.top_p } : {}),
...(model.maxTokens !== undefined ? { maxOutputTokens: model.maxTokens } : {}),
...(Object.keys(promptOptions).length > 0 ? { options: promptOptions } : {}),
}
}
function isOracleAgent(agentToUse: string): boolean {
return agentToUse.toLowerCase() === "oracle"
}
@@ -62,7 +80,7 @@ export async function sendSyncPrompt(
const promptArgs = {
path: { id: input.sessionID },
body: {
agent: input.agentToUse,
agent: input.agentToUse.replace(/^\u200B+/, ""),
system: input.systemContent,
tools,
parts: [createInternalAgentTextPart(effectivePrompt)],
@@ -75,6 +93,7 @@ export async function sendSyncPrompt(
}
: {}),
...(input.categoryModel?.variant ? { variant: input.categoryModel.variant } : {}),
...buildPromptGenerationParams(input.categoryModel),
},
}
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
declare const require: (name: string) => any
const { describe, test, expect, beforeEach, afterEach } = require("bun:test")
import { __setTimingConfig, __resetTimingConfig } from "./timing"
function createMockCtx(aborted = false) {
@@ -33,7 +33,6 @@ describe("pollSyncSession", () => {
// and the assistant id > user id (native opencode condition)
const { pollSyncSession } = require("./sync-session-poller")
let pollCount = 0
const mockClient = {
session: {
messages: async () => ({
@@ -165,6 +164,58 @@ describe("pollSyncSession", () => {
expect(callCount).toBeGreaterThan(1)
})
test("keeps polling when finish is 'stop' but assistant still has tool-call parts", async () => {
//#given
const { pollSyncSession } = require("./sync-session-poller")
let callCount = 0
const mockClient = {
session: {
messages: async () => {
callCount++
if (callCount <= 1) {
return {
data: [
{ info: { id: "msg_001", role: "user", time: { created: 1000 } } },
{
info: { id: "msg_002", role: "assistant", time: { created: 2000 }, finish: "stop" },
parts: [{ type: "tool-call", text: "calling tool" }],
},
],
}
}
return {
data: [
{ info: { id: "msg_001", role: "user", time: { created: 1000 } } },
{
info: { id: "msg_002", role: "assistant", time: { created: 2000 }, finish: "stop" },
parts: [{ type: "tool-call", text: "calling tool" }],
},
{ info: { id: "msg_003", role: "user", time: { created: 3000 } } },
{
info: { id: "msg_004", role: "assistant", time: { created: 4000 }, finish: "stop" },
parts: [{ type: "text", text: "Done" }],
},
],
}
},
status: async () => ({ data: { "ses_test": { type: "idle" } } }),
},
}
//#when
const result = await pollSyncSession(createMockCtx(), mockClient, {
sessionID: "ses_test",
agentToUse: "test-agent",
toastManager: null,
taskId: undefined,
})
//#then
expect(result).toBeNull()
expect(callCount).toBeGreaterThan(1)
})
test("does not complete when assistant id < user id (user sent after assistant)", async () => {
//#given - assistant finished but user message came after it (agent still processing)
const { pollSyncSession } = require("./sync-session-poller")
@@ -220,6 +271,55 @@ describe("pollSyncSession", () => {
})
describe("abort handling", () => {
test("#given session completed AND abort fires #then returns completion result not abort", async () => {
//#given
const { pollSyncSession } = require("./sync-session-poller")
const controller = new AbortController()
controller.abort()
let abortCount = 0
let messageCallCount = 0
const mockClient = {
session: {
abort: async () => {
abortCount++
},
messages: async () => {
messageCallCount++
return {
data: [
{ info: { id: "msg_001", role: "user", time: { created: 1000 } } },
{
info: { id: "msg_002", role: "assistant", time: { created: 2000 }, finish: "stop" },
parts: [{ type: "text", text: "Done" }],
},
],
}
},
status: async () => ({ data: {} }),
},
}
//#when
const result = await pollSyncSession({
sessionID: "parent-session",
messageID: "parent-message",
agent: "test-agent",
abort: controller.signal,
}, mockClient, {
sessionID: "ses_abort_complete",
agentToUse: "test-agent",
toastManager: { removeTask: () => {} },
taskId: "task_123",
anchorMessageCount: 1,
})
//#then
expect(result).toBeNull()
expect(messageCallCount).toBe(1)
expect(abortCount).toBe(0)
})
test("returns abort message when signal is aborted", async () => {
//#given
const { pollSyncSession } = require("./sync-session-poller")
@@ -295,7 +395,7 @@ describe("pollSyncSession", () => {
//#given
const { pollSyncSession } = require("./sync-session-poller")
let statusCallCount = 0
let statusCallCount = 0
let messageCallCount = 0
const mockClient = {
session: {
@@ -421,6 +521,44 @@ describe("pollSyncSession", () => {
expect(result).toBe(false)
})
test("returns false when finish is stop but assistant has tool-call parts", () => {
const { isSessionComplete } = require("./sync-session-poller")
//#given - provider marks stop even though tool execution is still pending
const messages = [
{ info: { id: "msg_001", role: "user", time: { created: 1000 } } },
{
info: { id: "msg_002", role: "assistant", time: { created: 2000 }, finish: "stop" },
parts: [{ type: "tool-call", text: "calling tool" }],
},
]
//#when
const result = isSessionComplete(messages)
//#then - should return false because tool execution is still pending
expect(result).toBe(false)
})
test("returns false when finish is end_turn but assistant has tool-call parts", () => {
const { isSessionComplete } = require("./sync-session-poller")
//#given - assistant emitted a terminal finish but still contains pending tool calls
const messages = [
{ info: { id: "msg_001", role: "user", time: { created: 1000 } } },
{
info: { id: "msg_002", role: "assistant", time: { created: 2000 }, finish: "end_turn" },
parts: [{ type: "tool-call", text: "calling tool" }],
},
]
//#when
const result = isSessionComplete(messages)
//#then - should return false because tool execution is still pending
expect(result).toBe(false)
})
test("returns false when user message has missing info.id field", () => {
const { isSessionComplete } = require("./sync-session-poller")
@@ -438,7 +576,7 @@ describe("pollSyncSession", () => {
//#then - should return false (missing user id)
expect(result).toBe(false)
})
})
})
})
+32 -8
View File
@@ -5,6 +5,7 @@ import { log } from "../../shared/logger"
import { normalizeSDKResponse } from "../../shared"
const NON_TERMINAL_FINISH_REASONS = new Set(["tool-calls", "unknown"])
const PENDING_TOOL_PART_TYPES = new Set(["tool", "tool_use", "tool-call"])
function wait(milliseconds: number): Promise<void> {
const sharedBuffer = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT)
@@ -22,6 +23,15 @@ function abortSyncSession(client: OpencodeClient, sessionID: string, reason: str
})
}
async function fetchSessionMessages(
client: OpencodeClient,
sessionID: string
): Promise<SessionMessage[]> {
const messagesResult = await client.session.messages({ path: { id: sessionID } })
const rawData = (messagesResult as { data?: unknown })?.data ?? messagesResult
return Array.isArray(rawData) ? (rawData as SessionMessage[]) : []
}
export function isSessionComplete(messages: SessionMessage[]): boolean {
let lastUser: SessionMessage | undefined
let lastAssistant: SessionMessage | undefined
@@ -35,6 +45,7 @@ export function isSessionComplete(messages: SessionMessage[]): boolean {
if (!lastAssistant?.info?.finish) return false
if (NON_TERMINAL_FINISH_REASONS.has(lastAssistant.info.finish)) return false
if (lastAssistant.parts?.some((part) => part.type && PENDING_TOOL_PART_TYPES.has(part.type))) return false
if (!lastUser?.info?.id || !lastAssistant?.info?.id) return false
return lastUser.info.id < lastAssistant.info.id
}
@@ -67,6 +78,21 @@ export async function pollSyncSession(
while (Date.now() - pollStart < maxPollTimeMs) {
if (ctx.abort?.aborted) {
try {
const messages = await fetchSessionMessages(client, input.sessionID)
const hasNewMessages =
input.anchorMessageCount === undefined || messages.length > input.anchorMessageCount
if (hasNewMessages && isSessionComplete(messages)) {
log("[task] Abort detected after session already completed", { sessionID: input.sessionID })
return null
}
} catch (error) {
log("[task] Final messages fetch failed after abort, continuing with abort", {
sessionID: input.sessionID,
error: String(error),
})
}
log("[task] Aborted by user", { sessionID: input.sessionID })
abortSyncSession(client, input.sessionID, "parent_abort")
if (input.toastManager && input.taskId) input.toastManager.removeTask(input.taskId)
@@ -99,27 +125,25 @@ export async function pollSyncSession(
continue
}
let messagesResult: { data?: unknown } | SessionMessage[]
let messages: SessionMessage[]
try {
messagesResult = await client.session.messages({ path: { id: input.sessionID } })
messages = await fetchSessionMessages(client, input.sessionID)
} catch (error) {
log("[task] Poll messages fetch failed, retrying", { sessionID: input.sessionID, error: String(error) })
continue
}
const rawData = (messagesResult as { data?: unknown })?.data ?? messagesResult
const msgs = Array.isArray(rawData) ? (rawData as SessionMessage[]) : []
if (input.anchorMessageCount !== undefined && msgs.length <= input.anchorMessageCount) {
if (input.anchorMessageCount !== undefined && messages.length <= input.anchorMessageCount) {
continue
}
if (isSessionComplete(msgs)) {
if (isSessionComplete(messages)) {
log("[task] Poll complete - terminal finish detected", { sessionID: input.sessionID, pollCount })
break
}
// 计数新出现的 assistant 轮次,用于熔断无限循环
const lastAssistant = [...msgs].reverse().find((m) => m.info?.role === "assistant")
const lastAssistant = [...messages].reverse().find((m) => m.info?.role === "assistant")
if (lastAssistant?.info?.id && lastAssistant.info.id !== lastSeenAssistantId) {
lastSeenAssistantId = lastAssistant.info.id
assistantTurnCount++
@@ -135,7 +159,7 @@ export async function pollSyncSession(
}
}
const hasAssistantText = msgs.some((m) => {
const hasAssistantText = messages.some((m) => {
if (m.info?.role !== "assistant") return false
const parts = m.parts ?? []
return parts.some((p) => {
@@ -0,0 +1,68 @@
import type { FallbackEntry } from "../../shared/model-requirements"
import type { DelegatedModelConfig } from "./types"
import type { ModelFallbackState } from "../../hooks/model-fallback/hook"
import { getNextReachableFallback } from "../../hooks/model-fallback/next-fallback"
function toDelegatedModelConfig(fallback: NonNullable<ReturnType<typeof getNextReachableFallback>>): DelegatedModelConfig {
return {
providerID: fallback.providerID,
modelID: fallback.modelID,
variant: fallback.variant,
reasoningEffort: fallback.reasoningEffort,
temperature: fallback.temperature,
top_p: fallback.top_p,
maxTokens: fallback.maxTokens,
thinking: fallback.thinking,
}
}
export async function retrySyncPromptWithFallbacks(input: {
sessionID: string
initialError: string
categoryModel: DelegatedModelConfig | undefined
fallbackChain: FallbackEntry[] | undefined
sendPrompt: (categoryModel: DelegatedModelConfig) => Promise<string | null>
}): Promise<{ promptError: string | null; categoryModel: DelegatedModelConfig | undefined }> {
const { sessionID, initialError, categoryModel, fallbackChain, sendPrompt } = input
if (!categoryModel || !fallbackChain || fallbackChain.length === 0) {
return {
promptError: initialError,
categoryModel,
}
}
const fallbackState: ModelFallbackState = {
providerID: categoryModel.providerID,
modelID: categoryModel.modelID,
fallbackChain,
attemptCount: 0,
pending: true,
}
let finalError = initialError
while (true) {
const nextFallback = getNextReachableFallback(sessionID, fallbackState)
if (!nextFallback) {
return {
promptError: finalError,
categoryModel,
}
}
const fallbackModel = toDelegatedModelConfig(nextFallback)
const promptError = await sendPrompt(fallbackModel)
if (!promptError) {
return {
promptError: null,
categoryModel: fallbackModel,
}
}
finalError = promptError
fallbackState.providerID = fallbackModel.providerID
fallbackState.modelID = fallbackModel.modelID
fallbackState.pending = true
}
}
+276
View File
@@ -1,5 +1,12 @@
const { describe, test, expect, beforeEach, afterEach, mock, spyOn } = require("bun:test")
function clearRequireCache(modulePath: string): void {
const resolvedPath = require.resolve(modulePath)
if (require.cache?.[resolvedPath]) {
delete require.cache[resolvedPath]
}
}
describe("executeSyncTask - cleanup on error paths", () => {
let removeTaskCalls: string[] = []
let addTaskCalls: any[] = []
@@ -23,6 +30,8 @@ describe("executeSyncTask - cleanup on error paths", () => {
deleteCalls = []
addCalls = []
clearRequireCache("./sync-task")
//#given - initialize real task toast manager (avoid global module mocks)
const { initTaskToastManager, _resetTaskToastManagerForTesting } = require("../../features/task-toast-manager/manager")
_resetTaskToastManagerForTesting()
@@ -219,6 +228,140 @@ describe("executeSyncTask - cleanup on error paths", () => {
expect(deleteCalls[0]).toBe("ses_test_12345678")
})
test("#given fallback chain set #when sendSyncPrompt fails #then retries with next model", async () => {
//#given
const mockClient = {
session: {
create: async () => ({ data: { id: "ses_test_12345678" } }),
},
}
const { executeSyncTask } = require("./sync-task")
const attemptedModels: Array<{ providerID: string; modelID: string; variant?: string } | undefined> = []
const deps = {
createSyncSession: async () => ({ ok: true, sessionID: "ses_test_12345678" }),
sendSyncPrompt: async (_client: unknown, input: { categoryModel?: { providerID: string; modelID: string; variant?: string } }) => {
attemptedModels.push(input.categoryModel)
return attemptedModels.length === 1 ? "Initial failure" : null
},
pollSyncSession: async () => null,
fetchSyncResult: async () => ({ ok: true as const, textContent: "Result" }),
}
const mockCtx = {
sessionID: "parent-session",
callID: "call-123",
metadata: () => {},
}
const mockExecutorCtx = {
client: mockClient,
directory: "/tmp",
onSyncSessionCreated: null,
}
const args = {
prompt: "test prompt",
description: "test task",
category: "test",
load_skills: [],
run_in_background: false,
command: null,
}
const initialModel = {
providerID: "anthropic",
modelID: "claude-opus-4-6",
variant: "max",
}
const fallbackChain = [
{ providers: ["anthropic"], model: "claude-opus-4-6", variant: "max" },
{ providers: ["opencode-go"], model: "kimi-k2.5" },
]
//#when
const result = await executeSyncTask(args, mockCtx, mockExecutorCtx, {
sessionID: "parent-session",
}, "test-agent", initialModel, undefined, undefined, fallbackChain, deps)
//#then
expect(result).toContain("Task completed")
expect(result).toContain("Model: opencode-go/kimi-k2.5")
expect(attemptedModels).toEqual([
{ providerID: "anthropic", modelID: "claude-opus-4-6", variant: "max" },
{ providerID: "opencode-go", modelID: "kimi-k2.5", variant: undefined },
])
})
test("#given fallback chain exhausted #when all retries fail #then returns final error", async () => {
//#given
const mockClient = {
session: {
create: async () => ({ data: { id: "ses_test_12345678" } }),
},
}
const { executeSyncTask } = require("./sync-task")
const attemptedModels: Array<{ providerID: string; modelID: string; variant?: string } | undefined> = []
const promptErrors = ["Initial failure", "Second failure", "Final failure"]
const deps = {
createSyncSession: async () => ({ ok: true, sessionID: "ses_test_12345678" }),
sendSyncPrompt: async (_client: unknown, input: { categoryModel?: { providerID: string; modelID: string; variant?: string } }) => {
attemptedModels.push(input.categoryModel)
return promptErrors[attemptedModels.length - 1] ?? "Unexpected extra retry"
},
pollSyncSession: async () => null,
fetchSyncResult: async () => ({ ok: true as const, textContent: "Result" }),
}
const mockCtx = {
sessionID: "parent-session",
callID: "call-123",
metadata: () => {},
}
const mockExecutorCtx = {
client: mockClient,
directory: "/tmp",
onSyncSessionCreated: null,
}
const args = {
prompt: "test prompt",
description: "test task",
category: "test",
load_skills: [],
run_in_background: false,
command: null,
}
const initialModel = {
providerID: "anthropic",
modelID: "claude-opus-4-6",
variant: "max",
}
const fallbackChain = [
{ providers: ["anthropic"], model: "claude-opus-4-6", variant: "max" },
{ providers: ["opencode-go"], model: "kimi-k2.5" },
{ providers: ["openai"], model: "gpt-5.4", variant: "medium" },
]
//#when
const result = await executeSyncTask(args, mockCtx, mockExecutorCtx, {
sessionID: "parent-session",
}, "test-agent", initialModel, undefined, undefined, fallbackChain, deps)
//#then
expect(result).toBe("Final failure")
expect(attemptedModels).toEqual([
{ providerID: "anthropic", modelID: "claude-opus-4-6", variant: "max" },
{ providerID: "opencode-go", modelID: "kimi-k2.5", variant: undefined },
{ providerID: "openai", modelID: "gpt-5.4", variant: "medium" },
])
})
test("cleans up toast and subagentSessions on successful completion", async () => {
const mockClient = {
session: {
@@ -282,6 +425,139 @@ describe("executeSyncTask - cleanup on error paths", () => {
expect(deleteCalls.length).toBe(1)
expect(deleteCalls[0]).toBe("ses_test_12345678")
})
test("depth regression: blocks spawn when reserveSubagentSpawn throws depth limit error", async () => {
// This is a smoke test guarding against regressions where the depth limit
// would be silently bypassed (e.g. via a fallback path that hardcodes
// childDepth: 1).
const mockClient = {
session: {
create: async () => ({ data: { id: "ses_test_12345678" } }),
},
}
const { executeSyncTask } = require("./sync-task")
const reserveSubagentSpawn = mock(async () => {
throw new Error(
"Subagent spawn blocked: child depth 4 exceeds background_task.maxDepth=3. Parent session: parent. Root session: root. Continue in an existing subagent session instead of spawning another."
)
})
const deps = {
createSyncSession: async () => ({ ok: true, sessionID: "ses_test_12345678" }),
sendSyncPrompt: async () => null,
pollSyncSession: async () => null,
fetchSyncResult: async () => ({ ok: true as const, textContent: "Result" }),
}
const mockCtx = {
sessionID: "parent-session",
callID: "call-123",
metadata: () => {},
}
const mockExecutorCtx = {
manager: { reserveSubagentSpawn },
client: mockClient,
directory: "/tmp",
onSyncSessionCreated: null,
}
const args = {
prompt: "test prompt",
description: "test task",
category: "test",
load_skills: [],
run_in_background: false,
command: null,
}
//#when - executeSyncTask is called from a session at max depth
const result = await executeSyncTask(args, mockCtx, mockExecutorCtx, {
sessionID: "parent-session",
}, "test-agent", undefined, undefined, undefined, undefined, deps)
//#then - should propagate the depth limit error and NOT create the session
expect(result).toContain("Subagent spawn blocked")
expect(result).toContain("child depth 4")
expect(result).toContain("maxDepth=3")
expect(reserveSubagentSpawn).toHaveBeenCalledWith("parent-session")
// critical: createSyncSession must NOT have been called -- if it was,
// the depth guard was bypassed.
expect(addCalls.length).toBe(0)
})
test("depth regression: does not silently fall back to childDepth: 1 when manager methods are present", async () => {
// Guards against the dangerous fallback path in sync-task.ts that
// hardcodes childDepth: 1 if reserveSubagentSpawn / assertCanSpawn are
// not functions. With a real manager present, the fallback must NOT be
// taken.
const mockClient = {
session: {
create: async () => ({ data: { id: "ses_test_12345678" } }),
},
}
const { executeSyncTask } = require("./sync-task")
let reservedDepth: number | undefined
const commit = mock(() => 1)
const rollback = mock(() => {})
const reserveSubagentSpawn = mock(async () => {
// Return a depth that proves the real manager was consulted
reservedDepth = 3
return {
spawnContext: { rootSessionID: "root", parentDepth: 2, childDepth: 3 },
descendantCount: 5,
commit,
rollback,
}
})
const deps = {
createSyncSession: async () => ({ ok: true, sessionID: "ses_test_12345678" }),
sendSyncPrompt: async () => null,
pollSyncSession: async () => null,
fetchSyncResult: async () => ({ ok: true as const, textContent: "Result" }),
}
const metadataCalls: any[] = []
const mockCtx = {
sessionID: "parent-session",
callID: "call-123",
metadata: (input: any) => { metadataCalls.push(input) },
}
const mockExecutorCtx = {
manager: { reserveSubagentSpawn },
client: mockClient,
directory: "/tmp",
onSyncSessionCreated: null,
}
const args = {
prompt: "test prompt",
description: "test task",
category: "test",
load_skills: [],
run_in_background: false,
command: null,
}
//#when
await executeSyncTask(args, mockCtx, mockExecutorCtx, {
sessionID: "parent-session",
}, "test-agent", undefined, undefined, undefined, undefined, deps)
//#then - the spawnDepth recorded in metadata MUST match what reserveSubagentSpawn returned
expect(reservedDepth).toBe(3)
const taskMeta = metadataCalls.find((c) => c.metadata?.spawnDepth !== undefined)
expect(taskMeta).toBeDefined()
expect(taskMeta.metadata.spawnDepth).toBe(3) // NOT 1 (the fallback value)
})
})
export {}
+58 -15
View File
@@ -3,6 +3,7 @@ import type { DelegateTaskArgs, ToolContextWithMetadata, DelegatedModelConfig }
import type { ExecutorContext, ParentContext } from "./executor-types"
import { getTaskToastManager } from "../../features/task-toast-manager"
import { storeToolMetadata } from "../../features/tool-metadata-store"
import { resolveCallID } from "./resolve-call-id"
import { subagentSessions, syncSubagentSessions, setSessionAgent } from "../../features/claude-code-session-state"
import { log } from "../../shared/logger"
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
@@ -10,6 +11,7 @@ import { formatDuration } from "./time-formatter"
import { formatDetailedError } from "./error-formatting"
import { syncTaskDeps, type SyncTaskDeps } from "./sync-task-deps"
import { setSessionFallbackChain, clearSessionFallbackChain } from "../../hooks/model-fallback/hook"
import { retrySyncPromptWithFallbacks } from "./sync-task-fallback"
export async function executeSyncTask(
args: DelegateTaskArgs,
@@ -36,14 +38,29 @@ export async function executeSyncTask(
spawnReservation = await manager.reserveSubagentSpawn(parentContext.sessionID)
}
const spawnContext = spawnReservation?.spawnContext
?? (typeof manager?.assertCanSpawn === "function"
? await manager.assertCanSpawn(parentContext.sessionID)
: {
rootSessionID: parentContext.sessionID,
parentDepth: 0,
childDepth: 1,
})
// Depth/descendant guard. We must NOT silently fall back to childDepth: 1
// when the manager is unavailable or lacks the spawn methods, because that
// would let subagents recurse without bound. The only safe fallback is
// when the manager genuinely cannot enforce limits (legacy SDK), in which
// case we still record childDepth: 1 but log a warning so regressions are
// visible.
let spawnContext: { rootSessionID: string; parentDepth: number; childDepth: number }
if (spawnReservation?.spawnContext) {
spawnContext = spawnReservation.spawnContext
} else if (typeof manager?.assertCanSpawn === "function") {
spawnContext = await manager.assertCanSpawn(parentContext.sessionID)
} else {
log(
"[task] WARNING: BackgroundManager has no spawn enforcement methods (reserveSubagentSpawn / assertCanSpawn). " +
"Depth and descendant limits cannot be enforced for this task. This indicates an old SDK or a misconfiguration.",
{ parentSessionID: parentContext.sessionID }
)
spawnContext = {
rootSessionID: parentContext.sessionID,
parentDepth: 0,
childDepth: 1,
}
}
const createSessionResult = await deps.createSyncSession(client, {
parentSessionID: parentContext.sessionID,
@@ -114,22 +131,48 @@ export async function executeSyncTask(
},
}
await ctx.metadata?.(syncTaskMeta)
if (ctx.callID) {
storeToolMetadata(ctx.sessionID, ctx.callID, syncTaskMeta)
const callID = resolveCallID(ctx)
if (callID) {
storeToolMetadata(ctx.sessionID, callID, syncTaskMeta)
}
const promptError = await deps.sendSyncPrompt(client, {
let effectiveCategoryModel = categoryModel
let promptError = await deps.sendSyncPrompt(client, {
sessionID,
agentToUse,
args,
systemContent,
categoryModel,
categoryModel: effectiveCategoryModel,
toastManager,
taskId,
sisyphusAgentConfig: executorCtx.sisyphusAgentConfig,
})
if (promptError) {
return promptError
const promptResult = await retrySyncPromptWithFallbacks({
sessionID,
initialError: promptError,
categoryModel: effectiveCategoryModel,
fallbackChain,
sendPrompt: async (fallbackModel) => {
return deps.sendSyncPrompt(client, {
sessionID,
agentToUse,
args,
systemContent,
categoryModel: fallbackModel,
toastManager,
taskId,
sisyphusAgentConfig: executorCtx.sisyphusAgentConfig,
})
},
})
promptError = promptResult.promptError
effectiveCategoryModel = promptResult.categoryModel
if (promptError) {
return promptError
}
}
try {
@@ -151,8 +194,8 @@ export async function executeSyncTask(
const duration = formatDuration(startTime)
// 检测模型路由是否与父 session 不同,给用户可见的提示
const actualModelStr = categoryModel
? `${categoryModel.providerID}/${categoryModel.modelID}`
const actualModelStr = effectiveCategoryModel
? `${effectiveCategoryModel.providerID}/${effectiveCategoryModel.modelID}`
: undefined
const parentModelStr = parentContext.model
? `${parentContext.model.providerID}/${parentContext.model.modelID}`
@@ -0,0 +1,34 @@
const { describe, expect, test } = require("bun:test")
function requireFresh<T>(modulePath: string): T {
const resolvedPath = require.resolve(modulePath)
if (require.cache?.[resolvedPath]) {
delete require.cache[resolvedPath]
}
return require(modulePath) as T
}
function createDelegateTask(...args: Parameters<typeof import("./tools").createDelegateTask>): ReturnType<typeof import("./tools").createDelegateTask> {
return requireFresh<typeof import("./tools")>("./tools").createDelegateTask(...args)
}
describe("createDelegateTask schema", () => {
test("#given category arg #when tool is created #then category accepts any string", () => {
//#given
const toolDefinition = createDelegateTask({ manager: {} as never, client: {} as never, directory: "/tmp/test" })
//#when
const categorySchema = toolDefinition.args.category as unknown as {
def: {
type: string
innerType: {
def: { type: string }
}
}
}
//#then
expect(categorySchema.def.type).toBe("optional")
expect(categorySchema.def.innerType.def.type).toBe("string")
})
})
+314 -42
View File
@@ -1,7 +1,7 @@
declare const require: (name: string) => any
declare const require: NodeJS.Require
const { describe, test, expect, beforeEach, afterEach, spyOn, mock } = require("bun:test")
import { DEFAULT_CATEGORIES, CATEGORY_PROMPT_APPENDS, CATEGORY_DESCRIPTIONS, isPlanAgent, PLAN_AGENT_NAMES, isPlanFamily, PLAN_FAMILY_NAMES } from "./constants"
import { resolveCategoryConfig } from "./tools"
import { getAgentDisplayName, getAgentListDisplayName } from "../../shared/agent-display-names"
import type { CategoryConfig } from "../../config/schema"
import type { DelegateTaskArgs } from "./types"
import { __resetModelCache } from "../../shared/model-availability"
@@ -10,6 +10,20 @@ import { __setTimingConfig, __resetTimingConfig } from "./timing"
import * as connectedProvidersCache from "../../shared/connected-providers-cache"
import * as executor from "./executor"
const runtimeRequire = require as NodeJS.Require & { cache?: Record<string, unknown> }
function clearRequireCache(modulePath: string): void {
const resolvedPath = runtimeRequire.resolve(modulePath)
if (runtimeRequire.cache?.[resolvedPath]) {
delete runtimeRequire.cache[resolvedPath]
}
}
function resolveCategoryConfig(...args: Parameters<typeof import("./tools").resolveCategoryConfig>): ReturnType<typeof import("./tools").resolveCategoryConfig> {
clearRequireCache("./tools")
return require("./tools").resolveCategoryConfig(...args)
}
const SYSTEM_DEFAULT_MODEL = "anthropic/claude-sonnet-4-6"
const TEST_CONNECTED_PROVIDERS = ["anthropic", "google", "openai"]
@@ -37,6 +51,7 @@ describe("sisyphus-task", () => {
beforeEach(() => {
mock.restore()
clearRequireCache("./tools")
__resetModelCache()
clearSkillCache()
__setTimingConfig({
@@ -93,7 +108,7 @@ describe("sisyphus-task", () => {
// when / #then
expect(category).toBeDefined()
expect(category.model).toBe("openai/gpt-5.3-codex")
expect(category.model).toBe("openai/gpt-5.4")
expect(category.variant).toBe("medium")
})
@@ -180,8 +195,8 @@ describe("sisyphus-task", () => {
//#given / #when
const result = isPlanAgent("planner")
//#then - "planner" contains "plan" so it matches via includes
expect(result).toBe(true)
//#then - "planner" is NOT an exact match for "plan" (T37 exact match fix)
expect(result).toBe(false)
})
test("returns true for case-insensitive match 'PLAN'", () => {
@@ -253,6 +268,20 @@ describe("sisyphus-task", () => {
expect(result).toBe(true)
})
test("returns true for prometheus display name", () => {
//#given / #when
const result = isPlanFamily(getAgentDisplayName("prometheus"))
//#then
expect(result).toBe(true)
})
test("returns true for prometheus list display name with zwsp prefix", () => {
//#given / #when
const result = isPlanFamily(getAgentListDisplayName("prometheus"))
//#then
expect(result).toBe(true)
})
test("returns false for 'oracle'", () => {
//#given / #when
const result = isPlanFamily("oracle")
@@ -705,8 +734,8 @@ describe("sisyphus-task", () => {
})
test("blocks requiresModel when availability is known and missing the required model", () => {
// given
const categoryName = "deep"
// given - artistry has requiresModel: gemini-3.1-pro
const categoryName = "artistry"
const availableModels = new Set<string>(["anthropic/claude-opus-4-6"])
// when
@@ -720,8 +749,8 @@ describe("sisyphus-task", () => {
})
test("blocks requiresModel when availability is empty", () => {
// given
const categoryName = "deep"
// given - artistry has requiresModel: gemini-3.1-pro
const categoryName = "artistry"
const availableModels = new Set<string>()
// when
@@ -1366,6 +1395,134 @@ describe("sisyphus-task", () => {
)).rejects.toThrow("Invalid arguments: 'run_in_background' parameter is REQUIRED")
})
test("#given category without description #when executing #then auto-generates description from prompt", async () => {
// given
const { createDelegateTask } = require("./tools")
let capturedTitle: string | undefined
const mockManager = { launch: async () => ({}) }
const mockClient = {
app: { agents: async () => ({ data: [] }) },
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
session: {
create: async () => ({ data: { id: "test-session" } }),
prompt: async () => ({ data: {} }),
promptAsync: async () => ({ data: {} }),
messages: async () => ({ data: [] }),
},
}
const tool = createDelegateTask({ manager: mockManager, client: mockClient })
// when
try {
await tool.execute(
{
prompt: "Fix the broken unit tests in parser module",
category: "quick",
run_in_background: false,
load_skills: [],
},
{
sessionID: "parent-session",
messageID: "parent-message",
agent: "sisyphus",
abort: new AbortController().signal,
metadata: async (meta: { title?: string }) => { capturedTitle = meta.title },
}
)
} catch {
// execution may fail due to incomplete mocks — we only care about the title
}
// then — description auto-generated from first 4 words of prompt
expect(capturedTitle).toBe("Fix the broken unit")
})
test("#given empty description #when executing #then auto-generates description from prompt", async () => {
// given
const { createDelegateTask } = require("./tools")
let capturedTitle: string | undefined
const mockManager = { launch: async () => ({}) }
const mockClient = {
app: { agents: async () => ({ data: [] }) },
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
session: {
create: async () => ({ data: { id: "test-session" } }),
prompt: async () => ({ data: {} }),
promptAsync: async () => ({ data: {} }),
messages: async () => ({ data: [] }),
},
}
const tool = createDelegateTask({ manager: mockManager, client: mockClient })
// when
try {
await tool.execute(
{
description: " ",
prompt: "Refactor authentication module completely",
category: "quick",
run_in_background: false,
load_skills: [],
},
{
sessionID: "parent-session",
messageID: "parent-message",
agent: "sisyphus",
abort: new AbortController().signal,
metadata: async (meta: { title?: string }) => { capturedTitle = meta.title },
}
)
} catch {
// execution may fail due to incomplete mocks
}
// then
expect(capturedTitle).toBe("Refactor authentication module completely")
})
test("#given explicit description #when executing #then preserves provided description", async () => {
// given
const { createDelegateTask } = require("./tools")
let capturedTitle: string | undefined
const mockManager = { launch: async () => ({}) }
const mockClient = {
app: { agents: async () => ({ data: [] }) },
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
session: {
create: async () => ({ data: { id: "test-session" } }),
prompt: async () => ({ data: {} }),
promptAsync: async () => ({ data: {} }),
messages: async () => ({ data: [] }),
},
}
const tool = createDelegateTask({ manager: mockManager, client: mockClient })
// when
try {
await tool.execute(
{
description: "My custom task name",
prompt: "Do something else entirely",
category: "quick",
run_in_background: false,
load_skills: [],
},
{
sessionID: "parent-session",
messageID: "parent-message",
agent: "sisyphus",
abort: new AbortController().signal,
metadata: async (meta: { title?: string }) => { capturedTitle = meta.title },
}
)
} catch {
// execution may fail due to incomplete mocks
}
// then — explicit description preserved
expect(capturedTitle).toBe("My custom task name")
})
test("#given explicit run_in_background=false #when executing #then sync execution succeeds", async () => {
// given
const { createDelegateTask } = require("./tools")
@@ -1453,6 +1610,92 @@ describe("sisyphus-task", () => {
expect(launchCalled).toBe(true)
expect(result).toContain("Background task launched")
}, { timeout: 10000 })
test("#given concurrent background launches from the same parent #when one parent call aborts during session wait #then sibling launch is not interrupted", async () => {
// given
const { createDelegateTask } = require("./tools")
const firstAbortController = new AbortController()
const secondAbortController = new AbortController()
const taskStates = new Map([
["bg_tool_first", { reads: 0, abortOnFirstRead: true, sessionID: "ses_tool_first" }],
["bg_tool_second", { reads: 0, abortOnFirstRead: false, sessionID: "ses_tool_second" }],
])
let launchCount = 0
const mockManager = {
launch: async () => {
launchCount += 1
return launchCount === 1
? {
id: "bg_tool_first",
sessionID: undefined,
description: "Tool first",
agent: "Sisyphus-Junior",
status: "running",
}
: {
id: "bg_tool_second",
sessionID: undefined,
description: "Tool second",
agent: "Sisyphus-Junior",
status: "running",
}
},
getTask: (taskID: string) => {
const state = taskStates.get(taskID)
if (!state) return undefined
state.reads += 1
if (state.abortOnFirstRead && state.reads === 1) {
firstAbortController.abort()
}
return state.reads >= 2
? { sessionID: state.sessionID, status: "running" }
: { sessionID: undefined, status: "pending" }
},
}
const mockClient = {
app: { agents: async () => ({ data: [] }) },
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
model: { list: async () => [] },
session: {
create: async () => ({ data: { id: "ses_bg_explicit_true" } }),
prompt: async () => ({ data: {} }),
promptAsync: async () => ({ data: {} }),
messages: async () => ({ data: [] }),
},
}
const tool = createDelegateTask({ manager: mockManager, client: mockClient })
// when
const [firstResult, secondResult] = await Promise.all([
tool.execute(
{
description: "Tool first",
prompt: "Run background",
category: "quick",
run_in_background: true,
load_skills: [],
},
{ sessionID: "parent-session", messageID: "parent-message-1", agent: "sisyphus", abort: firstAbortController.signal }
),
tool.execute(
{
description: "Tool second",
prompt: "Run background",
category: "quick",
run_in_background: true,
load_skills: [],
},
{ sessionID: "parent-session", messageID: "parent-message-2", agent: "sisyphus", abort: secondAbortController.signal }
),
])
// then
expect(firstResult).toContain("Background task launched")
expect(firstResult).not.toContain("Task failed to start")
expect(secondResult).toContain("Background task launched")
expect(secondResult).toContain("session_id: ses_tool_second")
expect(secondResult).not.toContain("interrupt")
}, { timeout: 10000 })
})
describe("session_id with background parameter", () => {
@@ -2282,60 +2525,68 @@ describe("sisyphus-task", () => {
expect(result).toContain("Artistry result here")
}, { timeout: 20000 })
test("writing category (kimi) with run_in_background=false should force background but wait for result", async () => {
// given - writing uses kimi-for-coding/k2p5
test("writing category (kimi) with run_in_background=false should run sync when kimi provider is available", async () => {
// given - writing uses kimi model which is no longer considered unstable
// Override provider cache to include kimi-for-coding provider
providerModelsSpy.mockReturnValue({
models: {
anthropic: ["claude-opus-4-6", "claude-sonnet-4-6", "claude-haiku-4-5"],
google: ["gemini-3.1-pro", "gemini-3-flash"],
openai: ["gpt-5.4", "gpt-5.3-codex"],
"kimi-for-coding": ["k2p5"],
},
connected: ["anthropic", "google", "openai", "kimi-for-coding"],
updatedAt: "2026-01-01T00:00:00.000Z",
})
cacheSpy.mockReturnValue(["anthropic", "google", "openai", "kimi-for-coding"])
const { createDelegateTask } = require("./tools")
let launchCalled = false
const launchedTask = {
id: "task-writing",
sessionID: "ses_writing_gemini",
description: "Writing gemini task",
agent: "sisyphus-junior",
status: "running",
}
let promptCalled = false
const mockManager = {
launch: async () => {
launchCalled = true
return launchedTask
return { id: "should-not-be-called", sessionID: "x", description: "x", agent: "x", status: "running" }
},
getTask: () => launchedTask,
}
const promptMock = async () => {
promptCalled = true
return { data: {} }
}
const mockClient = {
app: { agents: async () => ({ data: [] }) },
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
model: { list: async () => [{ provider: "google", id: "gemini-3-flash" }] },
session: {
get: async () => ({ data: { directory: "/project" } }),
create: async () => ({ data: { id: "ses_writing_gemini" } }),
prompt: async () => ({ data: {} }),
promptAsync: async () => ({ data: {} }),
create: async () => ({ data: { id: "ses_writing_kimi" } }),
prompt: promptMock,
promptAsync: promptMock,
messages: async () => ({
data: [
{ info: { role: "assistant", time: { created: Date.now() } }, parts: [{ type: "text", text: "Writing result here" }] }
]
data: [{ info: { role: "assistant" }, parts: [{ type: "text", text: "Writing result here" }] }]
}),
status: async () => ({ data: { "ses_writing_gemini": { type: "idle" } } }),
status: async () => ({ data: { "ses_writing_kimi": { type: "idle" } } }),
},
}
const tool = createDelegateTask({
manager: mockManager,
client: mockClient,
})
const toolContext = {
sessionID: "parent-session",
messageID: "parent-message",
agent: "sisyphus",
abort: new AbortController().signal,
}
// when - writing category (gemini-3-flash)
// when - writing category (kimi) with run_in_background=false
const result = await tool.execute(
{
description: "Test writing forced background",
description: "Test writing sync",
prompt: "Write something",
category: "writing",
run_in_background: false,
@@ -2343,11 +2594,11 @@ describe("sisyphus-task", () => {
},
toolContext
)
// then - should launch as background BUT wait for and return actual result
expect(launchCalled).toBe(true)
expect(result).toContain("SUPERVISED TASK COMPLETED")
expect(result).toContain("Writing result here")
// then - should run sync, NOT forced to background (kimi is not unstable)
expect(launchCalled).toBe(false)
expect(promptCalled).toBe(true)
expect(result).not.toContain("SUPERVISED TASK COMPLETED")
}, { timeout: 20000 })
test("is_unstable_agent=true should force background but wait for result", async () => {
@@ -2741,6 +2992,7 @@ describe("sisyphus-task", () => {
// then - sisyphus-junior override model should be used, not category default
expect(launchInput.model.providerID).toBe("anthropic")
expect(launchInput.model.modelID).toBe("claude-sonnet-4-6")
expect(launchInput.fallbackChain).toBeUndefined()
})
test("sisyphus-junior model override works with user-defined category (#1295)", async () => {
@@ -3385,6 +3637,26 @@ describe("sisyphus-task", () => {
expect(result).toContain("plan-family")
})
test("prometheus display name cannot delegate to plan (cross-blocking)", async () => {
//#given
const { createDelegateTask } = require("./tools")
const mockClient = {
app: { agents: async () => ({ data: [{ name: "plan", mode: "subagent" }] }) },
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
session: { get: async () => ({ data: { directory: "/project" } }), create: async () => ({ data: { id: "s" } }), prompt: async () => ({ data: {} }), promptAsync: async () => ({ data: {} }), messages: async () => ({ data: [] }), status: async () => ({ data: {} }) },
}
const tool = createDelegateTask({ manager: { launch: async () => ({}) }, client: mockClient })
//#when
const result = await tool.execute(
{ description: "test", prompt: "Create a plan", subagent_type: "plan", run_in_background: false, load_skills: [] },
{ sessionID: "p", messageID: "m", agent: getAgentDisplayName("prometheus"), abort: new AbortController().signal }
)
//#then
expect(result).toContain("plan-family")
})
test("plan cannot delegate to prometheus (cross-blocking)", async () => {
//#given
const { createDelegateTask } = require("./tools")
@@ -3882,7 +4154,7 @@ describe("sisyphus-task", () => {
expect(promptBody.tools.task).toBe(true)
}, { timeout: 20000 })
test("prometheus subagent should have task permission (plan family)", async () => {
test("prometheus subagent should have task permission as part of the plan family", async () => {
//#given
const { createDelegateTask } = require("./tools")
let promptBody: any
@@ -3907,7 +4179,7 @@ describe("sisyphus-task", () => {
{ sessionID: "p", messageID: "m", agent: "sisyphus", abort: new AbortController().signal }
)
//#then
//#then - prometheus shares task permission with the plan family
expect(promptBody.tools.task).toBe(true)
}, { timeout: 20000 })
+18 -15
View File
@@ -76,13 +76,13 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini
- category: For task delegation (uses Sisyphus-Junior with category-optimized model)
- subagent_type: For direct agent invocation (explore, librarian, oracle, etc.)
**DO NOT provide both.** category and subagent_type are mutually exclusive.
**DO NOT provide both.** If category is provided, subagent_type is ignored.
- load_skills: ALWAYS REQUIRED. Pass [] if no skills needed, or ["skill-1", "skill-2"] for category tasks.
- category: Use predefined category → Spawns Sisyphus-Junior with category config
Available categories:
${categoryList}
- subagent_type: Use a specific callable non-primary agent directly (for example: explore, librarian, oracle, metis, momus)
- subagent_type: Use specific agent directly (explore, librarian, oracle, metis, momus)
- run_in_background: REQUIRED. true=async (returns task_id), false=sync (waits). Use background=true ONLY for parallel exploration with 5+ independent queries.
- session_id: Existing Task session to continue (from previous task output). Continues agent with FULL CONTEXT PRESERVED - saves tokens, maintains continuity.
- command: The command that triggered this task (optional, for slash command tracking).
@@ -98,28 +98,34 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini
description,
args: {
load_skills: tool.schema.array(tool.schema.string()).describe("Skill names to inject. REQUIRED - pass [] if no skills needed."),
description: tool.schema.string().describe("Short task description (3-5 words)"),
description: tool.schema.string().optional().describe("Short task description (3-5 words). Auto-generated from prompt if omitted."),
prompt: tool.schema.string().describe("Full detailed prompt for the agent"),
run_in_background: tool.schema.boolean().describe("REQUIRED. true=async (returns task_id), false=sync (waits). Use false for task delegation, true ONLY for parallel exploration."),
category: tool.schema.string().optional().describe(`REQUIRED if subagent_type not provided. Do NOT provide both category and subagent_type.`),
subagent_type: tool.schema.string().optional().describe("REQUIRED if category not provided. Do NOT provide both category and subagent_type. Must be a callable non-primary agent name returned by app.agents()."),
subagent_type: tool.schema.string().optional().describe("REQUIRED if category not provided. Do NOT provide both category and subagent_type."),
session_id: tool.schema.string().optional().describe("Existing Task session to continue"),
command: tool.schema.string().optional().describe("The command that triggered this task"),
},
async execute(args: DelegateTaskArgs, toolContext) {
const ctx = toolContext as ToolContextWithMetadata
let categoryOverrideNote: string | undefined
if (args.category && args.subagent_type) {
categoryOverrideNote = `[Note: You provided both category="${args.category}" and subagent_type="${args.subagent_type}". category takes precedence \u2014 subagent_type was ignored. Next time, provide ONLY category.]`
}
if (args.category) {
if (args.subagent_type && args.subagent_type !== SISYPHUS_JUNIOR_AGENT) {
log("[task] category provided - overriding subagent_type to sisyphus-junior", {
category: args.category,
subagent_type: args.subagent_type,
})
}
args.subagent_type = SISYPHUS_JUNIOR_AGENT
}
// Auto-generate description from prompt when missing or empty
if (!args.description || typeof args.description !== "string" || args.description.trim() === "") {
const words = (args.prompt || "").trim().split(/\s+/)
args.description = words.slice(0, 4).join(" ") || "Delegated task"
}
await ctx.metadata?.({
title: args.description,
})
if (args.run_in_background === undefined) {
throw new Error(`Invalid arguments: 'run_in_background' parameter is REQUIRED. Specify run_in_background=false for task delegation, or run_in_background=true for parallel exploration.`)
}
@@ -221,8 +227,7 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini
availableCategories,
availableSkills,
})
const result = await executeUnstableAgentTask(args, ctx, options, parentContext, agentToUse, categoryModel, systemContent, actualModel)
return categoryOverrideNote ? `${categoryOverrideNote}\n\n${result}` : result
return executeUnstableAgentTask(args, ctx, options, parentContext, agentToUse, categoryModel, systemContent, actualModel)
}
} else {
const resolution = await resolveSubagentExecution(args, options, parentContext.agent, categoryExamples)
@@ -245,13 +250,11 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini
availableSkills,
})
const prependNote = (result: string) => categoryOverrideNote ? `${categoryOverrideNote}\n\n${result}` : result
if (runInBackground) {
return prependNote(await executeBackgroundTask(args, ctx, options, parentContext, agentToUse, categoryModel, systemContent, fallbackChain))
return executeBackgroundTask(args, ctx, options, parentContext, agentToUse, categoryModel, systemContent, fallbackChain)
}
return prependNote(await executeSyncTask(args, ctx, options, parentContext, agentToUse, categoryModel, systemContent, modelInfo, fallbackChain))
return executeSyncTask(args, ctx, options, parentContext, agentToUse, categoryModel, systemContent, modelInfo, fallbackChain)
},
})
}
@@ -4,6 +4,7 @@ import { DEFAULT_SYNC_POLL_TIMEOUT_MS, getTimingConfig } from "./timing"
import { buildTaskPrompt } from "./prompt-builder"
import { cancelUnstableAgentTask } from "./cancel-unstable-agent-task"
import { storeToolMetadata } from "../../features/tool-metadata-store"
import { resolveCallID } from "./resolve-call-id"
import { formatDuration } from "./time-formatter"
import { formatDetailedError } from "./error-formatting"
import { getSessionTools } from "../../shared/session-tools-store"
@@ -81,8 +82,9 @@ export async function executeUnstableAgentTask(
},
}
await ctx.metadata?.(bgTaskMeta)
if (ctx.callID) {
storeToolMetadata(ctx.sessionID, ctx.callID, bgTaskMeta)
const callID = resolveCallID(ctx)
if (callID) {
storeToolMetadata(ctx.sessionID, callID, bgTaskMeta)
}
const startTime = new Date()
@@ -1,11 +1,36 @@
declare const require: (name: string) => any
const { describe, test, expect, beforeEach, afterEach, spyOn, mock, vi } = require("bun:test")
import { resolveSubagentExecution } from "./subagent-resolver"
import type { DelegateTaskArgs } from "./types"
import type { ExecutorContext } from "./executor-types"
import * as logger from "../../shared/logger"
import * as connectedProvidersCache from "../../shared/connected-providers-cache"
import * as agentLoader from "../../features/claude-code-agent-loader"
import { describe, test, expect, beforeEach, afterEach, mock } from "bun:test"
import type { DelegateTaskArgs } from "../types"
import type { ExecutorContext } from "../executor-types"
type SubagentResolverModule = typeof import("../subagent-resolver")
const logMock = mock((..._args: unknown[]) => {})
const readConnectedProvidersCacheMock = mock(() => null as string[] | null)
const readProviderModelsCacheMock = mock(
() => null as {
models: Record<string, string[]>
connected: string[]
updatedAt: string
} | null,
)
type ClaudeCodeAgentRecord = Record<
string,
{
description?: string
mode?: string
prompt?: string
model?: string | { providerID: string; modelID: string }
}
>
const loadUserAgentsMock = mock((): ClaudeCodeAgentRecord => ({}))
const loadProjectAgentsMock = mock((_directory?: string): ClaudeCodeAgentRecord => ({}))
async function importFreshSubagentResolverModule(): Promise<SubagentResolverModule> {
return await import(`../subagent-resolver?test=${Date.now()}-${Math.random()}`)
}
function createBaseArgs(overrides?: Partial<DelegateTaskArgs>): DelegateTaskArgs {
return {
@@ -37,21 +62,42 @@ function createExecutorContext(
}
describe("resolveSubagentExecution", () => {
let logSpy: ReturnType<typeof spyOn> | undefined
let mockLoadUserAgents: ReturnType<typeof spyOn>
let mockLoadProjectAgents: ReturnType<typeof spyOn>
let resolveSubagentExecution: SubagentResolverModule["resolveSubagentExecution"]
beforeEach(() => {
beforeEach(async () => {
mock.restore()
logSpy = spyOn(logger, "log").mockImplementation(() => {})
mockLoadUserAgents = spyOn(agentLoader, "loadUserAgents").mockReturnValue({})
mockLoadProjectAgents = spyOn(agentLoader, "loadProjectAgents").mockReturnValue({})
logMock.mockClear()
readConnectedProvidersCacheMock.mockReset()
readProviderModelsCacheMock.mockReset()
readConnectedProvidersCacheMock.mockReturnValue(null)
readProviderModelsCacheMock.mockReturnValue(null)
loadUserAgentsMock.mockReset()
loadProjectAgentsMock.mockReset()
loadUserAgentsMock.mockImplementation(() => ({}))
loadProjectAgentsMock.mockImplementation(() => ({}))
mock.module("../../../shared/logger", () => ({
log: logMock,
}))
mock.module("../../../shared/connected-providers-cache", () => ({
readConnectedProvidersCache: readConnectedProvidersCacheMock,
readProviderModelsCache: readProviderModelsCacheMock,
hasConnectedProvidersCache: () => readConnectedProvidersCacheMock() !== null,
hasProviderModelsCache: () => readProviderModelsCacheMock() !== null,
_resetMemCacheForTesting: () => {},
}))
mock.module("../../../features/claude-code-agent-loader/loader", () => ({
loadUserAgents: loadUserAgentsMock,
loadProjectAgents: loadProjectAgentsMock,
}))
mock.module("../../../features/claude-code-agent-loader", () => ({
loadUserAgents: loadUserAgentsMock,
loadProjectAgents: loadProjectAgentsMock,
}))
;({ resolveSubagentExecution } = await importFreshSubagentResolverModule())
})
afterEach(() => {
logSpy?.mockRestore()
mockLoadUserAgents?.mockRestore()
mockLoadProjectAgents?.mockRestore()
mock.restore()
})
test("returns delegation error when agent discovery fails instead of silently proceeding", async () => {
@@ -71,7 +117,7 @@ describe("resolveSubagentExecution", () => {
expect(result.error).toBe("Failed to delegate to agent \"oracle\": agents API unavailable")
})
test("logs failure details when subagent resolution throws", async () => {
test("returns delegation error when subagent resolution throws", async () => {
//#given
const args = createBaseArgs({ subagent_type: "review" })
const executorCtx = createExecutorContext(async () => {
@@ -79,22 +125,52 @@ describe("resolveSubagentExecution", () => {
})
//#when
await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep")
const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep")
//#then
expect(logSpy).toHaveBeenCalledTimes(1)
const callArgs = logSpy?.mock.calls[0]
expect(callArgs?.[0]).toBe("[delegate-task] Failed to resolve subagent execution")
expect(callArgs?.[1]).toEqual({
requestedAgent: "review",
parentAgent: "sisyphus",
error: "network timeout",
})
expect(result.agentToUse).toBe("")
expect(result.categoryModel).toBeUndefined()
expect(result.error).toBe('Failed to delegate to agent "review": network timeout')
})
test("hides primary agents from task delegation lookups", async () => {
//#given
const args = createBaseArgs({ subagent_type: "sisyphus" })
const executorCtx = createExecutorContext(async () => ([
{ name: "sisyphus", mode: "primary" },
{ name: "oracle", mode: "subagent" },
{ name: "metis", mode: "all" },
]))
//#when
const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep")
//#then
expect(result.agentToUse).toBe("")
expect(result.categoryModel).toBeUndefined()
expect(result.error).toBe('Unknown agent: "sisyphus". Available agents: metis, oracle')
})
test("requires explicit all or subagent mode for task-callable agents", async () => {
//#given
const args = createBaseArgs({ subagent_type: "custom-worker" })
const executorCtx = createExecutorContext(async () => ([
{ name: "custom-worker" },
{ name: "oracle", mode: "subagent" },
]))
//#when
const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep")
//#then
expect(result.agentToUse).toBe("")
expect(result.categoryModel).toBeUndefined()
expect(result.error).toBe('Unknown agent: "custom-worker". Available agents: oracle')
})
test("normalizes matched agent model string before returning categoryModel", async () => {
//#given
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
readProviderModelsCacheMock.mockReturnValue({
models: { openai: ["grok-3", "gpt-5.3-codex"] },
connected: ["openai"],
updatedAt: "2026-03-03T00:00:00.000Z",
@@ -110,12 +186,26 @@ describe("resolveSubagentExecution", () => {
//#then
expect(result.error).toBeUndefined()
expect(result.categoryModel).toEqual({ providerID: "openai", modelID: "gpt-5.3-codex" })
cacheSpy.mockRestore()
})
test("matches agents even when zero-width characters are present in the requested name", async () => {
//#given
const args = createBaseArgs({ subagent_type: "\uFEFFSisyphus - Ultraworker" })
const executorCtx = createExecutorContext(async () => ([
{ name: "\u200BSisyphus - Ultraworker", mode: "subagent", model: "openai/gpt-5.3-codex" },
]))
//#when
const result = await resolveSubagentExecution(args, executorCtx, "oracle", "deep")
//#then
expect(result.error).toBeUndefined()
expect(result.agentToUse).toBe("Sisyphus - Ultraworker")
})
test("uses agent override fallback_models for subagent runtime fallback chain", async () => {
//#given
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
readProviderModelsCacheMock.mockReturnValue({
models: { quotio: ["claude-haiku-4-5"] },
connected: ["quotio"],
updatedAt: "2026-03-03T00:00:00.000Z",
@@ -143,12 +233,11 @@ describe("resolveSubagentExecution", () => {
{ providers: ["quotio"], model: "gpt-5.2", variant: undefined },
{ providers: ["quotio"], model: "glm-5", variant: "max" },
])
cacheSpy.mockRestore()
})
test("uses category fallback_models when agent override points at category", async () => {
//#given
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
readProviderModelsCacheMock.mockReturnValue({
models: { anthropic: ["claude-haiku-4-5"] },
connected: ["anthropic"],
updatedAt: "2026-03-03T00:00:00.000Z",
@@ -180,17 +269,16 @@ describe("resolveSubagentExecution", () => {
expect(result.fallbackChain).toEqual([
{ providers: ["anthropic"], model: "claude-haiku-4-5", variant: undefined },
])
cacheSpy.mockRestore()
})
test("promotes object-style fallback model settings to categoryModel when subagent fallback becomes initial model", async () => {
//#given
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
readProviderModelsCacheMock.mockReturnValue({
models: { openai: ["gpt-5.4"] },
connected: ["openai"],
updatedAt: "2026-03-03T00:00:00.000Z",
})
const connectedSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
readConnectedProvidersCacheMock.mockReturnValue(["openai"])
const args = createBaseArgs({ subagent_type: "explore" })
const executorCtx = createExecutorContext(
async () => ([
@@ -230,18 +318,16 @@ describe("resolveSubagentExecution", () => {
maxTokens: 2048,
thinking: { type: "disabled" },
})
cacheSpy.mockRestore()
connectedSpy.mockRestore()
})
test("does not apply object-style fallback settings when the subagent primary model matches directly", async () => {
//#given
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
readProviderModelsCacheMock.mockReturnValue({
models: { openai: ["gpt-5.4-preview"] },
connected: ["openai"],
updatedAt: "2026-03-03T00:00:00.000Z",
})
const connectedSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
readConnectedProvidersCacheMock.mockReturnValue(["openai"])
const args = createBaseArgs({ subagent_type: "explore" })
const executorCtx = createExecutorContext(
async () => ([
@@ -271,18 +357,16 @@ describe("resolveSubagentExecution", () => {
providerID: "openai",
modelID: "gpt-5.4-preview",
})
cacheSpy.mockRestore()
connectedSpy.mockRestore()
})
test("matches promoted fallback settings after fuzzy model resolution", async () => {
//#given
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
readProviderModelsCacheMock.mockReturnValue({
models: { openai: ["gpt-5.4-preview"] },
connected: ["openai"],
updatedAt: "2026-03-03T00:00:00.000Z",
})
const connectedSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
readConnectedProvidersCacheMock.mockReturnValue(["openai"])
const args = createBaseArgs({ subagent_type: "explore" })
const executorCtx = createExecutorContext(
async () => ([
@@ -322,18 +406,16 @@ describe("resolveSubagentExecution", () => {
maxTokens: 2222,
thinking: { type: "disabled" },
})
cacheSpy.mockRestore()
connectedSpy.mockRestore()
})
test("prefers exact promoted fallback match over earlier fuzzy prefix match", async () => {
//#given
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
readProviderModelsCacheMock.mockReturnValue({
models: { openai: ["gpt-5.4-preview"] },
connected: ["openai"],
updatedAt: "2026-03-03T00:00:00.000Z",
})
const connectedSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
readConnectedProvidersCacheMock.mockReturnValue(["openai"])
const args = createBaseArgs({ subagent_type: "explore" })
const executorCtx = createExecutorContext(
async () => ([
@@ -370,18 +452,16 @@ describe("resolveSubagentExecution", () => {
variant: "max",
reasoningEffort: "high",
})
cacheSpy.mockRestore()
connectedSpy.mockRestore()
})
test("matches promoted fallback settings when fuzzy resolution extends configured model without hyphen", async () => {
//#given
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
readProviderModelsCacheMock.mockReturnValue({
models: { openai: ["gpt-5.4o"] },
connected: ["openai"],
updatedAt: "2026-03-03T00:00:00.000Z",
})
const connectedSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
readConnectedProvidersCacheMock.mockReturnValue(["openai"])
const args = createBaseArgs({ subagent_type: "explore" })
const executorCtx = createExecutorContext(
async () => ([
@@ -413,18 +493,16 @@ describe("resolveSubagentExecution", () => {
variant: "low",
reasoningEffort: "high",
})
cacheSpy.mockRestore()
connectedSpy.mockRestore()
})
test("does not use unavailable matchedAgent.model as fallback for custom subagent", async () => {
//#given
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
readProviderModelsCacheMock.mockReturnValue({
models: { minimaxi: ["MiniMax-M2.7"] },
connected: ["minimaxi"],
updatedAt: "2026-03-03T00:00:00.000Z",
})
const connectedSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["minimaxi"])
readConnectedProvidersCacheMock.mockReturnValue(["minimaxi"])
const args = createBaseArgs({ subagent_type: "my-custom-agent" })
const executorCtx = createExecutorContext(
async () => ([
@@ -438,18 +516,16 @@ describe("resolveSubagentExecution", () => {
//#then
expect(result.error).toBeUndefined()
expect(result.categoryModel?.modelID).not.toBe("MiniMax-M2.7-highspeed")
cacheSpy.mockRestore()
connectedSpy.mockRestore()
})
test("uses matchedAgent.model as fallback when model is available", async () => {
//#given
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
readProviderModelsCacheMock.mockReturnValue({
models: { minimaxi: ["MiniMax-M2.7-highspeed"] },
connected: ["minimaxi"],
updatedAt: "2026-03-03T00:00:00.000Z",
})
const connectedSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["minimaxi"])
readConnectedProvidersCacheMock.mockReturnValue(["minimaxi"])
const args = createBaseArgs({ subagent_type: "my-custom-agent" })
const executorCtx = createExecutorContext(
async () => ([
@@ -463,18 +539,16 @@ describe("resolveSubagentExecution", () => {
//#then
expect(result.error).toBeUndefined()
expect(result.categoryModel).toEqual({ providerID: "minimaxi", modelID: "MiniMax-M2.7-highspeed" })
cacheSpy.mockRestore()
connectedSpy.mockRestore()
})
test("prefers the most specific prefix match when fallback entries share a prefix", async () => {
//#given
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
readProviderModelsCacheMock.mockReturnValue({
models: { openai: ["gpt-4o-preview"] },
connected: ["openai"],
updatedAt: "2026-03-03T00:00:00.000Z",
})
const connectedSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
readConnectedProvidersCacheMock.mockReturnValue(["openai"])
const args = createBaseArgs({ subagent_type: "explore" })
const executorCtx = createExecutorContext(
async () => ([
@@ -511,29 +585,122 @@ describe("resolveSubagentExecution", () => {
variant: "max",
reasoningEffort: "high",
})
cacheSpy.mockRestore()
connectedSpy.mockRestore()
})
test("resolves user agent from loadUserAgents when calling task(subagent_type=...)", async () => {
test("preserves category temperature when fallback entry leaves temperature undefined", async () => {
//#given
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
readProviderModelsCacheMock.mockReturnValue({
models: { openai: ["gpt-5.4"] },
connected: ["openai"],
updatedAt: "2026-03-03T00:00:00.000Z",
})
const connectedSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
readConnectedProvidersCacheMock.mockReturnValue(["openai"])
const args = createBaseArgs({ subagent_type: "explore" })
const executorCtx = createExecutorContext(
async () => ([
{ name: "explore", mode: "subagent", model: "quotio/claude-haiku-4-5-unavailable" },
]),
{
agentOverrides: {
explore: {
category: "research",
},
} as ExecutorContext["agentOverrides"],
userCategories: {
research: {
fallback_models: [
{
model: "openai/gpt-5.4",
variant: "max",
},
],
temperature: 0.55,
top_p: 0.45,
},
} as ExecutorContext["userCategories"],
}
)
mockLoadUserAgents.mockReturnValue({
//#when
const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep")
//#then
expect(result.error).toBeUndefined()
expect(result.categoryModel).toEqual({
providerID: "openai",
modelID: "gpt-5.4",
variant: "max",
temperature: 0.55,
top_p: 0.45,
})
})
test("applies category tuning params in the cold-cache override path", async () => {
//#given
readProviderModelsCacheMock.mockReturnValue({
models: {},
connected: [],
updatedAt: "2026-03-03T00:00:00.000Z",
})
readConnectedProvidersCacheMock.mockReturnValue([])
const args = createBaseArgs({ subagent_type: "explore" })
const executorCtx = createExecutorContext(
async () => ([
{ name: "explore", mode: "subagent", model: "openai/gpt-5.4" },
]),
{
agentOverrides: {
explore: {
category: "research",
},
} as ExecutorContext["agentOverrides"],
userCategories: {
research: {
model: "openai/gpt-5.4",
variant: "high",
temperature: 0.61,
top_p: 0.62,
maxTokens: 3200,
reasoningEffort: "medium",
thinking: { type: "disabled" },
},
} as ExecutorContext["userCategories"],
}
)
//#when
const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep")
//#then
expect(result.error).toBeUndefined()
expect(result.categoryModel).toEqual({
providerID: "openai",
modelID: "gpt-5.4",
variant: "high",
temperature: 0.61,
top_p: 0.62,
maxTokens: 3200,
reasoningEffort: "medium",
thinking: { type: "disabled" },
})
})
test("resolves user agent from loadUserAgents when calling task(subagent_type=...)", async () => {
//#given
readProviderModelsCacheMock.mockReturnValue({
models: { openai: ["gpt-5.4"] },
connected: ["openai"],
updatedAt: "2026-03-03T00:00:00.000Z",
})
readConnectedProvidersCacheMock.mockReturnValue(["openai"])
loadUserAgentsMock.mockImplementation(() => ({
"my-user-agent": {
description: "A user agent",
mode: "subagent",
prompt: "Do something",
model: "openai/gpt-5.4",
},
})
mockLoadProjectAgents.mockReturnValue({})
}))
const args = createBaseArgs({ subagent_type: "my-user-agent" })
const executorCtx = createExecutorContext(async () => [])
@@ -544,30 +711,24 @@ describe("resolveSubagentExecution", () => {
expect(result.error).toBeUndefined()
expect(result.agentToUse).toBe("my-user-agent")
expect(result.categoryModel?.modelID).toBe("gpt-5.4")
cacheSpy.mockRestore()
connectedSpy.mockRestore()
})
test("resolves project agent from loadProjectAgents when calling task(subagent_type=...)", async () => {
//#given
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
readProviderModelsCacheMock.mockReturnValue({
models: { anthropic: ["claude-sonnet-4"] },
connected: ["anthropic"],
updatedAt: "2026-03-03T00:00:00.000Z",
})
const connectedSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["anthropic"])
mockLoadUserAgents.mockReturnValue({})
mockLoadProjectAgents.mockReturnValue({
readConnectedProvidersCacheMock.mockReturnValue(["anthropic"])
loadProjectAgentsMock.mockImplementation(() => ({
"my-project-agent": {
description: "A project agent",
mode: "subagent",
prompt: "Do project work",
model: "anthropic/claude-sonnet-4",
},
})
}))
const args = createBaseArgs({ subagent_type: "my-project-agent" })
const executorCtx = createExecutorContext(async () => [])
@@ -578,31 +739,24 @@ describe("resolveSubagentExecution", () => {
expect(result.error).toBeUndefined()
expect(result.agentToUse).toBe("my-project-agent")
expect(result.categoryModel?.modelID).toBe("claude-sonnet-4")
cacheSpy.mockRestore()
connectedSpy.mockRestore()
})
test("server agent takes precedence over user agent with same name", async () => {
//#given
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
models: { openai: ["gpt-5.4"] },
readProviderModelsCacheMock.mockReturnValue({
models: { openai: ["gpt-5.4", "gpt-3.5"] },
connected: ["openai"],
updatedAt: "2026-03-03T00:00:00.000Z",
})
const connectedSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
mockLoadUserAgents.mockReturnValue({
readConnectedProvidersCacheMock.mockReturnValue(["openai"])
loadUserAgentsMock.mockImplementation(() => ({
"explore": {
description: "User explore agent",
mode: "subagent",
prompt: "User prompt",
model: "openai/gpt-3.5",
},
})
mockLoadProjectAgents.mockReturnValue({})
// Server has "explore" agent
}))
const args = createBaseArgs({ subagent_type: "explore" })
const executorCtx = createExecutorContext(async () => ([
{ name: "explore", mode: "subagent", model: "openai/gpt-5.4" },
@@ -614,39 +768,33 @@ describe("resolveSubagentExecution", () => {
//#then
expect(result.error).toBeUndefined()
expect(result.agentToUse).toBe("explore")
// Should use server's model, not user's
expect(result.categoryModel?.modelID).toBe("gpt-5.4")
cacheSpy.mockRestore()
connectedSpy.mockRestore()
})
test("project agent takes precedence over user agent with same name", async () => {
//#given
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
readProviderModelsCacheMock.mockReturnValue({
models: { minimaxi: ["MiniMax-M2.7-highspeed", "claude-3-haiku"] },
connected: ["minimaxi"],
updatedAt: "2026-03-03T00:00:00.000Z",
})
const connectedSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["minimaxi"])
mockLoadUserAgents.mockReturnValue({
readConnectedProvidersCacheMock.mockReturnValue(["minimaxi"])
loadUserAgentsMock.mockImplementation(() => ({
"my-custom-agent": {
description: "User agent",
mode: "subagent",
prompt: "User prompt",
model: "minimaxi/claude-3-haiku",
},
})
mockLoadProjectAgents.mockReturnValue({
}))
loadProjectAgentsMock.mockImplementation(() => ({
"my-custom-agent": {
description: "Project agent",
mode: "subagent",
prompt: "Project prompt",
model: "minimaxi/MiniMax-M2.7-highspeed",
},
})
}))
const args = createBaseArgs({ subagent_type: "my-custom-agent" })
const executorCtx = createExecutorContext(async () => [])
@@ -657,22 +805,17 @@ describe("resolveSubagentExecution", () => {
expect(result.error).toBeUndefined()
expect(result.agentToUse).toBe("my-custom-agent")
expect(result.categoryModel?.modelID).toBe("MiniMax-M2.7-highspeed")
cacheSpy.mockRestore()
connectedSpy.mockRestore()
})
test("filters out primary agents from user/project when resolving", async () => {
//#given
mockLoadUserAgents.mockReturnValue({
loadUserAgentsMock.mockImplementation(() => ({
"my-primary-agent": {
description: "A primary agent",
mode: "primary",
prompt: "I am primary",
},
})
mockLoadProjectAgents.mockReturnValue({})
}))
const args = createBaseArgs({ subagent_type: "my-primary-agent" })
const executorCtx = createExecutorContext(async () => [])
@@ -684,3 +827,123 @@ describe("resolveSubagentExecution", () => {
expect(result.agentToUse).toBe("")
})
})
describe("resolveSubagentExecution - agent name sanitization", () => {
let resolveSubagentExecution: SubagentResolverModule["resolveSubagentExecution"]
beforeEach(async () => {
mock.restore()
logMock.mockClear()
readConnectedProvidersCacheMock.mockReset()
readProviderModelsCacheMock.mockReset()
readConnectedProvidersCacheMock.mockReturnValue(null)
readProviderModelsCacheMock.mockReturnValue(null)
loadUserAgentsMock.mockReset()
loadProjectAgentsMock.mockReset()
loadUserAgentsMock.mockImplementation(() => ({}))
loadProjectAgentsMock.mockImplementation(() => ({}))
mock.module("../../../shared/logger", () => ({
log: logMock,
}))
mock.module("../../../shared/connected-providers-cache", () => ({
readConnectedProvidersCache: readConnectedProvidersCacheMock,
readProviderModelsCache: readProviderModelsCacheMock,
hasConnectedProvidersCache: () => readConnectedProvidersCacheMock() !== null,
hasProviderModelsCache: () => readProviderModelsCacheMock() !== null,
_resetMemCacheForTesting: () => {},
}))
mock.module("../../../features/claude-code-agent-loader/loader", () => ({
loadUserAgents: loadUserAgentsMock,
loadProjectAgents: loadProjectAgentsMock,
}))
mock.module("../../../features/claude-code-agent-loader", () => ({
loadUserAgents: loadUserAgentsMock,
loadProjectAgents: loadProjectAgentsMock,
}))
;({ resolveSubagentExecution } = await importFreshSubagentResolverModule())
})
afterEach(() => {
mock.restore()
})
test("strips backslash-wrapped agent names like \\hephaestus\\", async () => {
//#given
readProviderModelsCacheMock.mockReturnValue({
models: {},
connected: [],
updatedAt: "2026-03-03T00:00:00.000Z",
})
const args = createBaseArgs({ subagent_type: "\\hephaestus\\" })
const executorCtx = createExecutorContext(async () => ([
{ name: "Hephaestus - Deep Agent", mode: "subagent", model: "openai/gpt-5.3-codex" },
]))
//#when
const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep")
//#then
expect(result.error).toBeUndefined()
expect(result.agentToUse).toBe("Hephaestus - Deep Agent")
})
test("strips double-quoted agent names", async () => {
//#given
readProviderModelsCacheMock.mockReturnValue({
models: {},
connected: [],
updatedAt: "2026-03-03T00:00:00.000Z",
})
const args = createBaseArgs({ subagent_type: '"oracle"' })
const executorCtx = createExecutorContext(async () => ([
{ name: "oracle", mode: "subagent" },
]))
//#when
const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep")
//#then
expect(result.error).toBeUndefined()
expect(result.agentToUse).toBe("oracle")
})
test("strips single-quoted agent names", async () => {
//#given
readProviderModelsCacheMock.mockReturnValue({
models: {},
connected: [],
updatedAt: "2026-03-03T00:00:00.000Z",
})
const args = createBaseArgs({ subagent_type: "'explore'" })
const executorCtx = createExecutorContext(async () => ([
{ name: "explore", mode: "subagent" },
]))
//#when
const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep")
//#then
expect(result.error).toBeUndefined()
expect(result.agentToUse).toBe("explore")
})
test("matches runtime agent names that include invisible sort prefixes", async () => {
//#given
readProviderModelsCacheMock.mockReturnValue({
models: {},
connected: [],
updatedAt: "2026-03-03T00:00:00.000Z",
})
const args = createBaseArgs({ subagent_type: "Sisyphus - Ultraworker" })
const executorCtx = createExecutorContext(async () => ([
{ name: "\u200BSisyphus - Ultraworker", mode: "subagent", model: "openai/gpt-5.3-codex" },
]))
//#when
const result = await resolveSubagentExecution(args, executorCtx, "oracle", "deep")
//#then
expect(result.error).toBeUndefined()
expect(result.agentToUse).toBe("Sisyphus - Ultraworker")
})
})
+15 -8
View File
@@ -1,6 +1,7 @@
import { spawn } from "bun"
import {
resolveGrepCli,
type ResolvedCli,
type GrepBackend,
DEFAULT_MAX_DEPTH,
DEFAULT_MAX_FILESIZE,
@@ -148,17 +149,17 @@ function parseCountOutput(output: string): CountResult[] {
return results
}
export async function runRg(options: GrepOptions): Promise<GrepResult> {
export async function runRg(options: GrepOptions, resolvedCli?: ResolvedCli): Promise<GrepResult> {
await rgSemaphore.acquire()
try {
return await runRgInternal(options)
return await runRgInternal(options, resolvedCli)
} finally {
rgSemaphore.release()
}
}
async function runRgInternal(options: GrepOptions): Promise<GrepResult> {
const cli = resolveGrepCli()
async function runRgInternal(options: GrepOptions, resolvedCli?: ResolvedCli): Promise<GrepResult> {
const cli = resolvedCli ?? resolveGrepCli()
const args = buildArgs(options, cli.backend)
const timeout = Math.min(options.timeout ?? DEFAULT_TIMEOUT_MS, DEFAULT_TIMEOUT_MS)
@@ -224,17 +225,23 @@ async function runRgInternal(options: GrepOptions): Promise<GrepResult> {
}
}
export async function runRgCount(options: Omit<GrepOptions, "context">): Promise<CountResult[]> {
export async function runRgCount(
options: Omit<GrepOptions, "context">,
resolvedCli?: ResolvedCli
): Promise<CountResult[]> {
await rgSemaphore.acquire()
try {
return await runRgCountInternal(options)
return await runRgCountInternal(options, resolvedCli)
} finally {
rgSemaphore.release()
}
}
async function runRgCountInternal(options: Omit<GrepOptions, "context">): Promise<CountResult[]> {
const cli = resolveGrepCli()
async function runRgCountInternal(
options: Omit<GrepOptions, "context">,
resolvedCli?: ResolvedCli
): Promise<CountResult[]> {
const cli = resolvedCli ?? resolveGrepCli()
const args = buildArgs({ ...options, context: 0 }, cli.backend)
if (cli.backend === "rg") {
+16 -3
View File
@@ -3,10 +3,12 @@ import { join, dirname } from "node:path"
import { spawnSync } from "node:child_process"
import { getInstalledRipgrepPath, downloadAndInstallRipgrep } from "./downloader"
import { getDataDir } from "../../shared/data-path"
import { log } from "../../shared/logger"
import { PUBLISHED_PACKAGE_NAME } from "../../shared/plugin-identity"
export type GrepBackend = "rg" | "grep"
interface ResolvedCli {
export interface ResolvedCli {
path: string
backend: GrepBackend
}
@@ -89,7 +91,7 @@ export function resolveGrepCli(): ResolvedCli {
export async function resolveGrepCliWithAutoInstall(): Promise<ResolvedCli> {
const current = resolveGrepCli()
if (current.backend === "rg") {
if (current.backend === "rg" && current.path !== "rg") {
return current
}
@@ -103,7 +105,18 @@ export async function resolveGrepCliWithAutoInstall(): Promise<ResolvedCli> {
const rgPath = await downloadAndInstallRipgrep()
cachedCli = { path: rgPath, backend: "rg" }
return cachedCli
} catch {
} catch (error) {
if (current.backend === "grep") {
log(`[${PUBLISHED_PACKAGE_NAME}] Failed to auto-install ripgrep. Falling back to GNU grep.`, {
error: error instanceof Error ? error.message : String(error),
grep_path: current.path,
})
} else {
log(`[${PUBLISHED_PACKAGE_NAME}] Failed to auto-install ripgrep and GNU grep was not found.`, {
error: error instanceof Error ? error.message : String(error),
})
}
return current
}
}
+2 -1
View File
@@ -1,6 +1,7 @@
import { existsSync, readdirSync } from "node:fs"
import { join } from "node:path"
import { extractZip as extractZipBase } from "../../shared"
import { CACHE_DIR_NAME } from "../../shared/plugin-identity"
import {
cleanupArchive,
downloadArchive,
@@ -39,7 +40,7 @@ function getPlatformKey(): string {
function getInstallDir(): string {
const homeDir = process.env.HOME || process.env.USERPROFILE || "."
return join(homeDir, ".cache", "oh-my-opencode", "bin")
return join(homeDir, ".cache", CACHE_DIR_NAME, "bin")
}
function getRgPath(): string {
+4 -2
View File
@@ -2,6 +2,7 @@ import { resolve } from "node:path"
import type { PluginInput } from "@opencode-ai/plugin"
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
import { runRg, runRgCount } from "./cli"
import { resolveGrepCliWithAutoInstall } from "./constants"
import { formatGrepResult, formatCountResult } from "./result-formatter"
export function createGrepTools(ctx: PluginInput): Record<string, ToolDefinition> {
@@ -42,13 +43,14 @@ export function createGrepTools(ctx: PluginInput): Record<string, ToolDefinition
const paths = [searchPath]
const outputMode = args.output_mode ?? "files_with_matches"
const headLimit = args.head_limit ?? 0
const cli = await resolveGrepCliWithAutoInstall()
if (outputMode === "count") {
const results = await runRgCount({
pattern: args.pattern,
paths,
globs,
})
}, cli)
const limited = headLimit > 0 ? results.slice(0, headLimit) : results
return formatCountResult(limited)
}
@@ -60,7 +62,7 @@ export function createGrepTools(ctx: PluginInput): Record<string, ToolDefinition
context: 0,
outputMode,
headLimit,
})
}, cli)
return formatGrepResult(result)
} catch (e) {
+1 -1
View File
@@ -1,6 +1,6 @@
# src/tools/hashline-edit/ — Hash-Anchored File Edit Tool
**Generated:** 2026-03-06
**Generated:** 2026-04-11
## OVERVIEW
@@ -227,10 +227,10 @@ describe("hashline edit operations", () => {
})
it("preserves blank lines and indentation in range replace (no false unwrap)", () => {
//#given reproduces the 애국가 bug where blank+indented lines collapse
//#given, reproduces the 애국가 bug where blank+indented lines collapse
const lines = ["", "동해물과 백두산이 마르고 닳도록", "하느님이 보우하사 우리나라 만세", "", "무궁화 삼천리 화려강산", "대한사람 대한으로 길이 보전하세", ""]
//#when replace the range with indented version (blank lines preserved)
//#when, replace the range with indented version (blank lines preserved)
const result = applyReplaceLines(
lines,
anchorFor(lines, 1),
@@ -238,7 +238,7 @@ describe("hashline edit operations", () => {
["", " 동해물과 백두산이 마르고 닳도록", " 하느님이 보우하사 우리나라 만세", "", " 무궁화 삼천리 화려강산", " 대한사람 대한으로 길이 보전하세", ""]
)
//#then all 7 lines preserved with indentation, not collapsed to 3
//#then, all 7 lines preserved with indentation, not collapsed to 3
expect(result).toEqual(["", " 동해물과 백두산이 마르고 닳도록", " 하느님이 보우하사 우리나라 만세", "", " 무궁화 삼천리 화려강산", " 대한사람 대한으로 길이 보전하세", ""])
})
@@ -350,10 +350,10 @@ describe("runFormattersForFile", () => {
},
})
//#when run for a .go file, but only .ts formatters registered
//#when, run for a .go file, but only .ts formatters registered
await runFormattersForFile(client, "/project", "/src/main.go")
//#then no error thrown
//#then, no error thrown
})
it("runs formatter for matching extension", async () => {
@@ -367,10 +367,10 @@ describe("runFormattersForFile", () => {
},
})
//#when echo is a safe no-op command
//#when, echo is a safe no-op command
await runFormattersForFile(client, "/tmp", "/tmp/test.ts")
//#then should complete without error
//#then, should complete without error
expect(client.config.get).toHaveBeenCalledTimes(1)
})
})
+3 -3
View File
@@ -8,8 +8,8 @@ WORKFLOW:
5. Use anchors as "LINE#ID" only (never include trailing "|content").
<must>
- SNAPSHOT: All edits in one call reference the ORIGINAL file state. Do NOT adjust line numbers for prior edits in the same call the system applies them bottom-up automatically.
- replace removes lines pos..end (inclusive) and inserts lines in their place. Lines BEFORE pos and AFTER end are UNTOUCHED do NOT include them in lines. If you do, they will appear twice.
- SNAPSHOT: All edits in one call reference the ORIGINAL file state. Do NOT adjust line numbers for prior edits in the same call - the system applies them bottom-up automatically.
- replace removes lines pos..end (inclusive) and inserts lines in their place. Lines BEFORE pos and AFTER end are UNTOUCHED - do NOT include them in lines. If you do, they will appear twice.
- lines must contain ONLY the content that belongs inside the consumed range. Content after end survives unchanged.
- Tags MUST be copied exactly from read output or >>> mismatch output. NEVER guess tags.
- Batch = multiple operations in edits[], NOT one big replace covering everything. Each operation targets the smallest possible change.
@@ -75,7 +75,7 @@ Insert after line 13 (between functions):
{ op: "append", pos: "13#QR", lines: ["", "function added() {", " return true;", "}"] }
Result: 4 new lines inserted after line 13. All existing lines unchanged.
BAD lines extend past end (DUPLICATES line 13):
BAD - lines extend past end (DUPLICATES line 13):
{ op: "replace", pos: "11#XJ", end: "12#MB", lines: [" return \\"hi\\";", "}"] }
Line 13 is "}" which already exists after end. Including "}" in lines duplicates it.
CORRECT: { op: "replace", pos: "11#XJ", end: "12#MB", lines: [" return \\"hi\\";"] }
+6 -6
View File
@@ -23,10 +23,10 @@ describe("parseLineRef", () => {
})
it("gives specific hint when literal text is used instead of line number", () => {
//#given model sends "LINE#HK" instead of "1#HK"
//#given, model sends "LINE#HK" instead of "1#HK"
const ref = "LINE#HK"
//#when / #then error should mention that LINE is not a valid number
//#when / #then, error should mention that LINE is not a valid number
expect(() => parseLineRef(ref)).toThrow(/not a line number/i)
})
@@ -39,10 +39,10 @@ describe("parseLineRef", () => {
})
it("extracts valid line number from mixed prefix like LINE42 without throwing", () => {
//#given normalizeLineRef extracts 42#VK from LINE42#VK
//#given, normalizeLineRef extracts 42#VK from LINE42#VK
const ref = "LINE42#VK"
//#when / #then should parse successfully as line 42
//#when / #then, should parse successfully as line 42
const result = parseLineRef(ref)
expect(result.line).toBe(42)
expect(result.hash).toBe("VK")
@@ -144,11 +144,11 @@ describe("validateLineRef", () => {
})
it("suggests correct line number when hash matches a file line", () => {
//#given model sends LINE#XX where XX is the actual hash for line 1
//#given, model sends LINE#XX where XX is the actual hash for line 1
const lines = ["function hello() {", " return 42", "}"]
const hash = computeLineHash(1, lines[0])
//#when / #then error should suggest the correct reference
//#when / #then, error should suggest the correct reference
expect(() => validateLineRefs(lines, [`LINE#${hash}`])).toThrow(new RegExp(`1#${hash}`))
})
})
-1
View File
@@ -48,7 +48,6 @@ export function parseLineRef(ref: string): LineRef {
hash: match[2],
}
}
// normalized equals ref.trim() in all error paths — extraction only succeeds for valid refs
const hashIdx = normalized.indexOf('#')
if (hashIdx > 0) {
const prefix = normalized.slice(0, hashIdx)
+1 -1
View File
@@ -1,3 +1,3 @@
export const MULTIMODAL_LOOKER_AGENT = "multimodal-looker" as const
export const LOOK_AT_DESCRIPTION = `Extract basic information from media files (PDFs, images, diagrams) when a quick summary suffices over precise reading. Good for simple text-based content extraction without using the Read tool. NEVER use for visual precision, aesthetic evaluation, or exact accuracy use Read tool instead for those cases.`
export const LOOK_AT_DESCRIPTION = `Extract basic information from media files (PDFs, images, diagrams) when a quick summary suffices over precise reading. Good for simple text-based content extraction without using the Read tool. NEVER use for visual precision, aesthetic evaluation, or exact accuracy - use Read tool instead for those cases.`
+108
View File
@@ -659,4 +659,112 @@ describe("look-at tool", () => {
expect(filePart.url).toContain("base64")
})
})
describe("createLookAt prompt conditional on Read availability", () => {
const captureLastPromptBody = () => {
const captured: { body: any } = { body: undefined }
const mockClient = {
app: {
agents: async () => ({ data: [] }),
},
session: {
get: async () => ({ data: { directory: "/project" } }),
create: async () => ({ data: { id: "ses_prompt_conditional" } }),
prompt: async (input: any) => {
captured.body = input.body
return { data: {} }
},
messages: async () => ({
data: [
{ info: { role: "assistant", time: { created: 1 } }, parts: [{ type: "text", text: "ok" }] },
],
}),
},
}
return { mockClient, captured }
}
const buildToolContext = (): ToolContext => ({
sessionID: "parent-session",
messageID: "parent-message",
agent: "sisyphus",
directory: "/project",
worktree: "/project",
abort: new AbortController().signal,
metadata: () => {},
ask: async () => {},
})
// given file_path mode where Read tool is disabled in invocation
// when LookAt tool sends prompt to multimodal-looker
// then prompt instructs agent to analyze the attached file directly without using Read
test("instructs agent to analyze attached file when Read is disabled (file_path mode)", async () => {
const { mockClient, captured } = captureLastPromptBody()
const tool = createLookAt({
client: mockClient,
directory: "/project",
} as any)
await tool.execute(
{ file_path: "/test/file.png", goal: "describe contents" },
buildToolContext(),
)
expect(captured.body.tools.read).toBe(false)
const promptPart = captured.body.parts.find((p: any) => p.type === "text")
expect(promptPart).toBeDefined()
const promptText: string = promptPart.text
expect(promptText).toContain("attached")
expect(promptText).not.toMatch(/\bRead\s+(?:the\s+)?file\b/i)
expect(promptText).not.toMatch(/\buse\s+Read\b/i)
})
// given image_data mode where no file path exists and Read is disabled
// when LookAt tool sends prompt to multimodal-looker
// then prompt instructs agent to analyze the attached image directly without referencing Read or file path
test("instructs agent to analyze attached image when image_data is provided", async () => {
const { mockClient, captured } = captureLastPromptBody()
const tool = createLookAt({
client: mockClient,
directory: "/project",
} as any)
await tool.execute(
{ image_data: "data:image/png;base64,iVBORw0KGgo=", goal: "describe image" },
buildToolContext(),
)
expect(captured.body.tools.read).toBe(false)
const promptPart = captured.body.parts.find((p: any) => p.type === "text")
expect(promptPart).toBeDefined()
const promptText: string = promptPart.text
expect(promptText).toContain("attached")
expect(promptText).not.toMatch(/\bRead\s+(?:the\s+)?file\b/i)
expect(promptText).not.toMatch(/\buse\s+Read\b/i)
})
// given prompt is generated for any invocation where Read is denied
// when LookAt tool sends prompt to multimodal-looker
// then prompt explicitly tells the agent NOT to attempt Read tool
test("explicitly warns the agent not to attempt Read when Read is disabled", async () => {
const { mockClient, captured } = captureLastPromptBody()
const tool = createLookAt({
client: mockClient,
directory: "/project",
} as any)
await tool.execute(
{ file_path: "/test/file.pdf", goal: "extract text" },
buildToolContext(),
)
const promptPart = captured.body.parts.find((p: any) => p.type === "text")
const promptText: string = promptPart.text
// The prompt must mention the agent cannot use Read so the agent does not hallucinate
expect(promptText.toLowerCase()).toContain("read tool")
})
})
})
+10 -2
View File
@@ -129,7 +129,15 @@ export function createLookAt(ctx: PluginInput): ToolDefinition {
return "Error: Must provide either 'file_path' or 'image_data'."
}
const prompt = `Analyze this ${isBase64Input ? "image" : "file"} and extract the requested information.
const readEnabled = false
const subjectNoun = isBase64Input ? "image" : "file"
const sourceClause = readEnabled
? `Use the Read tool on the provided file path to load its contents, then analyze it.`
: `The ${subjectNoun} is already attached to this message. Analyze it directly from the attachment. Do NOT attempt to use the Read tool. The Read tool is disabled for this invocation and the ${subjectNoun} cannot be loaded by path.`
const prompt = `Analyze the attached ${subjectNoun} and extract the requested information.
${sourceClause}
Goal: ${args.goal}
@@ -182,7 +190,7 @@ Original error: ${createResult.error}`
task: false,
call_omo_agent: false,
look_at: false,
read: false,
read: readEnabled,
},
parts: [
{ type: "text", text: prompt },
+1 -1
View File
@@ -1,6 +1,6 @@
# src/tools/lsp/ — LSP Tool Implementations
**Generated:** 2026-03-06
**Generated:** 2026-04-11
## OVERVIEW
+3 -1
View File
@@ -2,7 +2,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { tmpdir } from "node:os"
import { describe, it, expect, spyOn, mock, beforeEach, afterEach } from "bun:test"
import { describe, it, expect, spyOn, mock, beforeEach, afterEach, afterAll } from "bun:test"
mock.module("vscode-jsonrpc/node", () => ({
createMessageConnection: () => {
@@ -12,6 +12,8 @@ mock.module("vscode-jsonrpc/node", () => ({
StreamMessageWriter: function StreamMessageWriter() {},
}))
afterAll(() => { mock.restore() })
import { LSPClient, lspManager, validateCwd } from "./client"
import type { ResolvedServer } from "./types"
+1 -2
View File
@@ -20,8 +20,7 @@ describe("isServerInstalled", () => {
afterEach(() => {
try {
rmSync(tempDir, { recursive: true, force: true })
} catch (e) {
// cleanup failed — ignored
} catch {
}
if (process.platform === "win32") {
+11 -26
View File
@@ -4,57 +4,42 @@ import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
import { DEFAULT_MAX_DIAGNOSTICS } from "./constants"
import { aggregateDiagnosticsForDirectory } from "./directory-diagnostics"
import { inferExtensionFromDirectory } from "./infer-extension"
import { filterDiagnosticsBySeverity, formatDiagnostic } from "./lsp-formatters"
import { isDirectoryPath, withLspClient } from "./lsp-client-wrapper"
import type { Diagnostic } from "./types"
export const lsp_diagnostics: ToolDefinition = tool({
description:
'Get errors, warnings, hints from language server BEFORE running build. Use filePath for a single file, or filePath with extension for a directory. Do NOT pass both filePath and directory — use filePath for everything.',
'Get errors, warnings, hints from language server BEFORE running build. Works for both single files and directories - file extension is auto-detected for directories.',
args: {
filePath: tool.schema
.string()
.optional()
.describe("File or directory path to check diagnostics for"),
directory: tool.schema
.string()
.optional()
.describe("Alias for filePath when checking a directory. Do NOT provide both filePath and directory."),
severity: tool.schema
.enum(["error", "warning", "information", "hint", "all"])
.optional()
.describe("Filter by severity level"),
extension: tool.schema
.string()
.optional()
.describe("Required if target is a directory. E.g., '.ts', '.py', '.go', '.java'"),
},
execute: async (args, _context) => {
try {
// Accept either filePath or directory (treat directory as alias for filePath)
const targetPath = args.filePath || args.directory
if (!targetPath) {
throw new Error("Provide either 'filePath' or 'directory' parameter.")
if (!args.filePath) {
throw new Error("'filePath' parameter is required.")
}
if (args.filePath && args.directory) {
// Instead of erroring, just use filePath and ignore directory
// This prevents model confusion from causing hard failures
}
const absPath = resolve(targetPath)
const absPath = resolve(args.filePath)
if (isDirectoryPath(absPath)) {
if (!args.extension) {
const extension = inferExtensionFromDirectory(absPath)
if (!extension) {
throw new Error(
`Directory path requires 'extension' parameter.\n\n` +
`Example: lsp_diagnostics(filePath="src", extension=".ts")\n\n` +
`Supported extensions: .ts, .tsx, .js, .py, .go, etc.`
`No supported source files found in directory: ${absPath}`
)
}
return await aggregateDiagnosticsForDirectory(absPath, args.extension, args.severity)
return await aggregateDiagnosticsForDirectory(absPath, extension, args.severity)
}
const result = await withLspClient(targetPath, async (client) => {
return (await client.diagnostics(targetPath)) as { items?: Diagnostic[] } | Diagnostic[] | null
const result = await withLspClient(args.filePath, async (client) => {
return (await client.diagnostics(args.filePath)) as { items?: Diagnostic[] } | Diagnostic[] | null
})
let diagnostics: Diagnostic[] = []
+107
View File
@@ -0,0 +1,107 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test"
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs"
import { join } from "path"
import os from "os"
import { inferExtensionFromDirectory } from "./infer-extension"
describe("inferExtensionFromDirectory", () => {
let tmpDir: string
beforeEach(() => {
tmpDir = mkdtempSync(join(os.tmpdir(), "omo-infer-ext-"))
})
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true })
})
describe("#given a directory with TypeScript files", () => {
beforeEach(() => {
writeFileSync(join(tmpDir, "index.ts"), "export const a = 1")
writeFileSync(join(tmpDir, "utils.ts"), "export const b = 2")
writeFileSync(join(tmpDir, "app.tsx"), "export const c = 3")
})
describe("#when inferring extension", () => {
it("#then returns .ts as the most common extension", () => {
const result = inferExtensionFromDirectory(tmpDir)
expect(result).toBe(".ts")
})
})
})
describe("#given a directory with mixed file types where Python dominates", () => {
beforeEach(() => {
writeFileSync(join(tmpDir, "main.py"), "x = 1")
writeFileSync(join(tmpDir, "utils.py"), "y = 2")
writeFileSync(join(tmpDir, "helper.py"), "z = 3")
writeFileSync(join(tmpDir, "config.ts"), "export default {}")
})
describe("#when inferring extension", () => {
it("#then returns .py as the most common extension", () => {
const result = inferExtensionFromDirectory(tmpDir)
expect(result).toBe(".py")
})
})
})
describe("#given an empty directory", () => {
describe("#when inferring extension", () => {
it("#then returns null", () => {
const result = inferExtensionFromDirectory(tmpDir)
expect(result).toBeNull()
})
})
})
describe("#given a directory with only unsupported files", () => {
beforeEach(() => {
writeFileSync(join(tmpDir, "data.csv"), "a,b,c")
writeFileSync(join(tmpDir, "image.png"), "fake")
})
describe("#when inferring extension", () => {
it("#then returns null", () => {
const result = inferExtensionFromDirectory(tmpDir)
expect(result).toBeNull()
})
})
})
describe("#given a directory with nested subdirectories", () => {
beforeEach(() => {
writeFileSync(join(tmpDir, "root.go"), "package main")
const sub = join(tmpDir, "pkg")
mkdirSync(sub)
writeFileSync(join(sub, "handler.go"), "package pkg")
writeFileSync(join(sub, "model.go"), "package pkg")
})
describe("#when inferring extension", () => {
it("#then counts files recursively", () => {
const result = inferExtensionFromDirectory(tmpDir)
expect(result).toBe(".go")
})
})
})
describe("#given a directory with node_modules", () => {
beforeEach(() => {
writeFileSync(join(tmpDir, "index.ts"), "export {}")
const nm = join(tmpDir, "node_modules", "pkg")
mkdirSync(nm, { recursive: true })
writeFileSync(join(nm, "a.js"), "module.exports = {}")
writeFileSync(join(nm, "b.js"), "module.exports = {}")
writeFileSync(join(nm, "c.js"), "module.exports = {}")
})
describe("#when inferring extension", () => {
it("#then skips node_modules and returns .ts", () => {
const result = inferExtensionFromDirectory(tmpDir)
expect(result).toBe(".ts")
})
})
})
})
+65
View File
@@ -0,0 +1,65 @@
import { readdirSync, lstatSync } from "fs"
import { extname, join } from "path"
import { EXT_TO_LANG } from "./language-mappings"
const SKIP_DIRECTORIES = new Set(["node_modules", ".git", "dist", "build", ".next", "out"])
const MAX_SCAN_ENTRIES = 500
export function inferExtensionFromDirectory(directory: string): string | null {
const extensionCounts = new Map<string, number>()
let scanned = 0
function walk(dir: string): void {
if (scanned >= MAX_SCAN_ENTRIES) return
let entries: string[]
try {
entries = readdirSync(dir)
} catch {
return
}
for (const entry of entries) {
if (scanned >= MAX_SCAN_ENTRIES) return
const fullPath = join(dir, entry)
let stat: ReturnType<typeof lstatSync> | undefined
try {
stat = lstatSync(fullPath)
} catch {
continue
}
if (stat.isSymbolicLink()) continue
scanned++
if (stat.isDirectory()) {
if (!SKIP_DIRECTORIES.has(entry)) {
walk(fullPath)
}
} else if (stat.isFile()) {
const ext = extname(fullPath)
if (ext && ext in EXT_TO_LANG) {
extensionCounts.set(ext, (extensionCounts.get(ext) ?? 0) + 1)
}
}
}
}
walk(directory)
if (extensionCounts.size === 0) return null
let maxExt = ""
let maxCount = 0
for (const [ext, count] of extensionCounts) {
if (count > maxCount) {
maxCount = count
maxExt = ext
}
}
return maxExt || null
}
+2 -1
View File
@@ -5,6 +5,7 @@ import { existsSync, statSync } from "fs"
import { LSPClient, lspManager } from "./client"
import { findServerForExtension } from "./config"
import type { ServerLookupResult } from "./types"
import { CONFIG_BASENAME } from "../../shared/plugin-identity"
export function isDirectoryPath(filePath: string): boolean {
if (!existsSync(filePath)) {
@@ -63,7 +64,7 @@ export function formatServerLookupError(result: Exclude<ServerLookupResult, { st
``,
`Available servers: ${result.availableServers.slice(0, 10).join(", ")}${result.availableServers.length > 10 ? "..." : ""}`,
``,
`To add a custom server, configure 'lsp' in oh-my-opencode.json:`,
`To add a custom server, configure 'lsp' in ${CONFIG_BASENAME}.json:`,
` {`,
` "lsp": {`,
` "my-server": {`,
+20 -8
View File
@@ -1,3 +1,5 @@
import { log } from "../../shared/logger"
type ManagedClientForCleanup = {
client: {
stop: () => Promise<void>;
@@ -22,23 +24,32 @@ export type LspProcessCleanupHandle = {
export function registerLspManagerProcessCleanup(options: ProcessCleanupOptions): LspProcessCleanupHandle {
const handlers: RegisteredHandler[] = [];
// Synchronous cleanup for 'exit' event (cannot await)
const logCleanupError = (phase: string, error: unknown): void => {
log(`[lsp-manager-process-cleanup] ${phase}`, {
error: error instanceof Error ? error.message : String(error),
});
};
const syncCleanup = () => {
for (const [, managed] of options.getClients()) {
try {
// Fire-and-forget during sync exit - process is terminating
void managed.client.stop().catch(() => {});
} catch {}
void managed.client.stop().catch((error) => {
logCleanupError("stop failed during exit cleanup", error);
});
} catch (error) {
logCleanupError("failed to schedule exit cleanup", error);
}
}
options.clearClients();
options.clearCleanupInterval();
};
// Async cleanup for signal handlers - properly await all stops
const asyncCleanup = async () => {
const stopPromises: Promise<void>[] = [];
for (const [, managed] of options.getClients()) {
stopPromises.push(managed.client.stop().catch(() => {}));
stopPromises.push(managed.client.stop().catch((error) => {
logCleanupError("stop failed during signal cleanup", error);
}));
}
await Promise.allSettled(stopPromises);
options.clearClients();
@@ -52,8 +63,9 @@ export function registerLspManagerProcessCleanup(options: ProcessCleanupOptions)
registerHandler("exit", syncCleanup);
// Don't call process.exit() here; other handlers (background-agent manager) handle final exit.
const signalCleanup = () => void asyncCleanup().catch(() => {});
const signalCleanup = () => void asyncCleanup().catch((error) => {
logCleanupError("signal cleanup failed", error);
});
registerHandler("SIGINT", signalCleanup);
registerHandler("SIGTERM", signalCleanup);
if (process.platform === "win32") {
-3
View File
@@ -2,11 +2,9 @@ import { spawn as bunSpawn } from "bun"
import { spawn as nodeSpawn, type ChildProcess } from "node:child_process"
import { existsSync, statSync } from "fs"
import { log } from "../../shared/logger"
// Bun spawn segfaults on Windows (oven-sh/bun#25798) — unfixed as of v1.3.8+
function shouldUseNodeSpawn(): boolean {
return process.platform === "win32"
}
// Prevents segfaults when libuv gets a non-existent cwd (oven-sh/bun#25798)
export function validateCwd(cwd: string): { valid: boolean; error?: string } {
try {
if (!existsSync(cwd)) {
@@ -24,7 +22,6 @@ export function validateCwd(cwd: string): { valid: boolean; error?: string } {
interface StreamReader {
read(): Promise<{ done: boolean; value: Uint8Array | undefined }>
}
// Bridges Bun Subprocess and Node.js ChildProcess under a common API
export interface UnifiedProcess {
stdin: { write(chunk: Uint8Array | string): void }
stdout: { getReader(): StreamReader }
+3
View File
@@ -52,6 +52,9 @@ class LSPServerManager {
this.cleanupInterval = setInterval(() => {
this.cleanupIdleClients();
}, 60000);
if (typeof this.cleanupInterval === "object" && "unref" in this.cleanupInterval) {
this.cleanupInterval.unref();
}
}
private cleanupIdleClients(): void {
+203
View File
@@ -0,0 +1,203 @@
import { existsSync } from "node:fs"
import { readdir, readFile } from "node:fs/promises"
import { join } from "node:path"
import { MESSAGE_STORAGE, PART_STORAGE, SESSION_STORAGE, TODO_DIR, TRANSCRIPT_DIR } from "./constants"
import { getMessageDir } from "../../shared/opencode-message-dir"
import type { SessionInfo, SessionMessage, SessionMetadata, TodoItem } from "./types"
export async function getFileMainSessions(directory?: string): Promise<SessionMetadata[]> {
if (!existsSync(SESSION_STORAGE)) return []
const sessions: SessionMetadata[] = []
try {
const projectDirs = await readdir(SESSION_STORAGE, { withFileTypes: true })
for (const projectDir of projectDirs) {
if (!projectDir.isDirectory()) continue
const projectPath = join(SESSION_STORAGE, projectDir.name)
const sessionFiles = await readdir(projectPath)
for (const file of sessionFiles) {
if (!file.endsWith(".json")) continue
try {
const content = await readFile(join(projectPath, file), "utf-8")
const meta = JSON.parse(content) as SessionMetadata
if (meta.parentID) continue
if (directory && meta.directory !== directory) continue
sessions.push(meta)
} catch {
continue
}
}
}
} catch {
return []
}
return sessions.sort((a, b) => b.time.updated - a.time.updated)
}
export async function getFileAllSessions(): Promise<string[]> {
if (!existsSync(MESSAGE_STORAGE)) return []
const sessions: string[] = []
async function scanDirectory(dir: string): Promise<void> {
try {
const entries = await readdir(dir, { withFileTypes: true })
for (const entry of entries) {
if (!entry.isDirectory()) continue
const sessionPath = join(dir, entry.name)
const files = await readdir(sessionPath)
if (files.some((file) => file.endsWith(".json"))) {
sessions.push(entry.name)
continue
}
await scanDirectory(sessionPath)
}
} catch {
return
}
}
await scanDirectory(MESSAGE_STORAGE)
return [...new Set(sessions)]
}
export async function fileSessionExists(sessionID: string): Promise<boolean> {
return getMessageDir(sessionID) !== null
}
export async function getFileSessionMessages(sessionID: string): Promise<SessionMessage[]> {
const messageDir = getMessageDir(sessionID)
if (!messageDir || !existsSync(messageDir)) return []
const messages: SessionMessage[] = []
try {
const files = await readdir(messageDir)
for (const file of files) {
if (!file.endsWith(".json")) continue
try {
const content = await readFile(join(messageDir, file), "utf-8")
const meta = JSON.parse(content)
const parts = await readParts(meta.id)
messages.push({
id: meta.id,
role: meta.role,
agent: meta.agent,
time: meta.time,
parts,
})
} catch {
continue
}
}
} catch {
return []
}
return messages.sort((a, b) => {
const aTime = a.time?.created ?? 0
const bTime = b.time?.created ?? 0
if (aTime !== bTime) return aTime - bTime
return a.id.localeCompare(b.id)
})
}
async function readParts(messageID: string): Promise<Array<{ id: string; type: string; [key: string]: unknown }>> {
const partDir = join(PART_STORAGE, messageID)
if (!existsSync(partDir)) return []
const parts: Array<{ id: string; type: string; [key: string]: unknown }> = []
try {
const files = await readdir(partDir)
for (const file of files) {
if (!file.endsWith(".json")) continue
try {
const content = await readFile(join(partDir, file), "utf-8")
parts.push(JSON.parse(content))
} catch {
continue
}
}
} catch {
return []
}
return parts.sort((a, b) => a.id.localeCompare(b.id))
}
export async function getFileSessionTodos(sessionID: string): Promise<TodoItem[]> {
if (!existsSync(TODO_DIR)) return []
try {
const allFiles = await readdir(TODO_DIR)
const todoFiles = allFiles.filter((file) => file === `${sessionID}.json`)
for (const file of todoFiles) {
try {
const content = await readFile(join(TODO_DIR, file), "utf-8")
const data = JSON.parse(content)
if (!Array.isArray(data)) continue
return data.map((item) => ({
id: item.id || "",
content: item.content || "",
status: item.status || "pending",
priority: item.priority,
}))
} catch {
continue
}
}
} catch {
return []
}
return []
}
export async function getFileSessionTranscript(sessionID: string): Promise<number> {
if (!existsSync(TRANSCRIPT_DIR)) return 0
const transcriptFile = join(TRANSCRIPT_DIR, `${sessionID}.jsonl`)
if (!existsSync(transcriptFile)) return 0
try {
const content = await readFile(transcriptFile, "utf-8")
return content.trim().split("\n").filter(Boolean).length
} catch {
return 0
}
}
export async function getFileSessionInfo(sessionID: string): Promise<SessionInfo | null> {
const messages = await getFileSessionMessages(sessionID)
if (messages.length === 0) return null
const agentsUsed = new Set<string>()
let firstMessage: Date | undefined
let lastMessage: Date | undefined
for (const msg of messages) {
if (msg.agent) agentsUsed.add(msg.agent)
if (!msg.time?.created) continue
const date = new Date(msg.time.created)
if (!firstMessage || date < firstMessage) firstMessage = date
if (!lastMessage || date > lastMessage) lastMessage = date
}
const todos = await getFileSessionTodos(sessionID)
const transcriptEntries = await getFileSessionTranscript(sessionID)
return {
id: sessionID,
message_count: messages.length,
first_message: firstMessage,
last_message: lastMessage,
agents_used: Array.from(agentsUsed),
has_todos: todos.length > 0,
has_transcript: transcriptEntries > 0,
todos,
transcript_entries: transcriptEntries,
}
}
+135
View File
@@ -0,0 +1,135 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { normalizeSDKResponse } from "../../shared"
import type { SessionMessage, SessionMetadata, TodoItem } from "./types"
import { isSessionSdkUnavailableError } from "./sdk-unavailable"
function unwrapSdkResponseError(response: unknown): unknown {
if (!response || typeof response !== "object" || !("error" in response)) {
return null
}
return (response as { error?: unknown }).error ?? null
}
function throwOnNonFallbackableSdkError(response: unknown): void {
const error = unwrapSdkResponseError(response)
if (!error) return
throw error
}
export async function getSdkMainSessions(
client: PluginInput["client"],
directory?: string,
): Promise<SessionMetadata[]> {
const response = await client.session.list()
const error = unwrapSdkResponseError(response)
if (error) throw error
const sessions = normalizeSDKResponse(response, [] as SessionMetadata[])
const mainSessions = sessions.filter((session) => !session.parentID)
if (directory) {
return mainSessions
.filter((session) => session.directory === directory)
.sort((a, b) => b.time.updated - a.time.updated)
}
return mainSessions.sort((a, b) => b.time.updated - a.time.updated)
}
export async function getSdkAllSessions(client: PluginInput["client"]): Promise<string[]> {
const response = await client.session.list()
throwOnNonFallbackableSdkError(response)
const sessions = normalizeSDKResponse(response, [] as SessionMetadata[])
return sessions.map((session) => session.id)
}
export async function sdkSessionExists(client: PluginInput["client"], sessionID: string): Promise<boolean> {
const response = await client.session.list()
throwOnNonFallbackableSdkError(response)
const sessions = normalizeSDKResponse(response, [] as Array<{ id?: string }>)
return sessions.some((session) => session.id === sessionID)
}
export async function getSdkSessionMessages(
client: PluginInput["client"],
sessionID: string,
): Promise<SessionMessage[]> {
const response = await client.session.messages({ path: { id: sessionID } })
throwOnNonFallbackableSdkError(response)
const rawMessages = normalizeSDKResponse(response, [] as Array<{
info?: {
id?: string
role?: string
agent?: string
time?: { created?: number; updated?: number }
}
parts?: Array<{
id?: string
type?: string
text?: string
thinking?: string
tool?: string
callID?: string
input?: Record<string, unknown>
output?: string
error?: string
}>
}>)
const messages: SessionMessage[] = rawMessages
.filter((message) => message.info?.id)
.map((message) => ({
id: message.info!.id!,
role: (message.info!.role as "user" | "assistant") || "user",
agent: message.info!.agent,
time: message.info!.time?.created
? {
created: message.info!.time.created,
updated: message.info!.time.updated,
}
: undefined,
parts:
message.parts?.map((part) => ({
id: part.id || "",
type: part.type || "text",
text: part.text,
thinking: part.thinking,
tool: part.tool,
callID: part.callID,
input: part.input,
output: part.output,
error: part.error,
})) || [],
}))
return messages.sort((a, b) => {
const aTime = a.time?.created ?? 0
const bTime = b.time?.created ?? 0
if (aTime !== bTime) return aTime - bTime
return a.id.localeCompare(b.id)
})
}
export async function getSdkSessionTodos(client: PluginInput["client"], sessionID: string): Promise<TodoItem[]> {
const response = await client.session.todo({ path: { id: sessionID } })
throwOnNonFallbackableSdkError(response)
const data = normalizeSDKResponse(response, [] as Array<{
id?: string
content?: string
status?: string
priority?: string
}>)
return data.map((item) => ({
id: item.id || "",
content: item.content || "",
status: (item.status as TodoItem["status"]) || "pending",
priority: item.priority,
}))
}
export function shouldFallbackFromSdkError(error: unknown): boolean {
return isSessionSdkUnavailableError(error)
}
@@ -0,0 +1,43 @@
const SDK_UNAVAILABLE_PATTERNS = [
"unable to connect",
"econnrefused",
"fetch failed",
"network error",
"network request failed",
"server unreachable",
"etimedout",
"timed out",
"timeout",
"socket hang up",
] as const
function collectErrorTexts(value: unknown): string[] {
if (value instanceof Error) {
return [value.message, value.name, ...collectErrorTexts(value.cause)]
}
if (typeof value === "string") {
return [value]
}
if (!value || typeof value !== "object") {
return []
}
const record = value as Record<string, unknown>
return [
typeof record.message === "string" ? record.message : "",
typeof record.code === "string" ? record.code : "",
typeof record.name === "string" ? record.name : "",
...collectErrorTexts(record.cause),
...collectErrorTexts(record.error),
].filter(Boolean)
}
export function isSessionSdkUnavailableError(value: unknown): boolean {
const haystack = collectErrorTexts(value)
.join(" ")
.toLowerCase()
return SDK_UNAVAILABLE_PATTERNS.some((pattern) => haystack.includes(pattern))
}
@@ -0,0 +1,248 @@
import { afterAll, afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { randomUUID } from "node:crypto"
const TEST_DIR = join(tmpdir(), `omo-test-session-manager-fallback-${randomUUID()}`)
const TEST_MESSAGE_STORAGE = join(TEST_DIR, "message")
const TEST_PART_STORAGE = join(TEST_DIR, "part")
const TEST_SESSION_STORAGE = join(TEST_DIR, "session")
const TEST_TODO_DIR = join(TEST_DIR, "todos")
const TEST_TRANSCRIPT_DIR = join(TEST_DIR, "transcripts")
let sqliteBackend = false
mock.module("./constants", () => ({
OPENCODE_STORAGE: TEST_DIR,
MESSAGE_STORAGE: TEST_MESSAGE_STORAGE,
PART_STORAGE: TEST_PART_STORAGE,
SESSION_STORAGE: TEST_SESSION_STORAGE,
TODO_DIR: TEST_TODO_DIR,
TRANSCRIPT_DIR: TEST_TRANSCRIPT_DIR,
SESSION_LIST_DESCRIPTION: "test",
SESSION_READ_DESCRIPTION: "test",
SESSION_SEARCH_DESCRIPTION: "test",
SESSION_INFO_DESCRIPTION: "test",
SESSION_DELETE_DESCRIPTION: "test",
TOOL_NAME_PREFIX: "session_",
}))
mock.module("../../shared/opencode-storage-detection", () => ({
isSqliteBackend: () => sqliteBackend,
resetSqliteBackendCache: () => {},
}))
mock.module("../../shared/opencode-message-dir", () => ({
getMessageDir: (sessionID: string) => {
if (!sessionID.startsWith("ses_")) return null
if (/[/\\]|\.\./.test(sessionID)) return null
if (!existsSync(TEST_MESSAGE_STORAGE)) return null
const directPath = join(TEST_MESSAGE_STORAGE, sessionID)
if (existsSync(directPath)) return directPath
for (const dir of readdirSync(TEST_MESSAGE_STORAGE)) {
const nestedPath = join(TEST_MESSAGE_STORAGE, dir, sessionID)
if (existsSync(nestedPath)) return nestedPath
}
return null
},
}))
afterAll(() => {
mock.restore()
})
const storage = await import("./storage")
function createSdkUnavailableError(message: string): Error {
return new Error(message)
}
function createSessionMetadata(projectID: string, sessionID: string, directory: string, updated: number): void {
const projectDir = join(TEST_SESSION_STORAGE, projectID)
mkdirSync(projectDir, { recursive: true })
writeFileSync(
join(projectDir, `${sessionID}.json`),
JSON.stringify({
id: sessionID,
projectID,
directory,
time: { created: updated - 1_000, updated },
}),
)
}
function createSessionMessage(sessionID: string, messageID: string, created: number, role = "user"): void {
const sessionPath = join(TEST_MESSAGE_STORAGE, sessionID)
mkdirSync(sessionPath, { recursive: true })
writeFileSync(
join(sessionPath, `${messageID}.json`),
JSON.stringify({ id: messageID, role, time: { created } }),
)
}
function createSessionTodo(sessionID: string, items: Array<Record<string, unknown>>): void {
mkdirSync(TEST_TODO_DIR, { recursive: true })
writeFileSync(join(TEST_TODO_DIR, `${sessionID}.json`), JSON.stringify(items))
}
describe("session-manager storage fallback", () => {
const mockClient = {
session: {
list: mock((): Promise<unknown> => Promise.resolve({ data: [] })),
messages: mock((): Promise<unknown> => Promise.resolve({ data: [] })),
todo: mock((): Promise<unknown> => Promise.resolve({ data: [] })),
},
}
beforeEach(() => {
sqliteBackend = true
if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true, force: true })
mkdirSync(TEST_DIR, { recursive: true })
mkdirSync(TEST_MESSAGE_STORAGE, { recursive: true })
mkdirSync(TEST_PART_STORAGE, { recursive: true })
mkdirSync(TEST_SESSION_STORAGE, { recursive: true })
mkdirSync(TEST_TODO_DIR, { recursive: true })
mkdirSync(TEST_TRANSCRIPT_DIR, { recursive: true })
mockClient.session.list.mockReset()
mockClient.session.messages.mockReset()
mockClient.session.todo.mockReset()
storage.setStorageClient(mockClient as never)
})
afterEach(() => {
storage.resetStorageClient()
if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true, force: true })
})
test("#given unreachable SDK list response #when getMainSessions runs #then falls back to file sessions", async () => {
createSessionMetadata("proj_test", "ses_file", "/workspace/project", 2_000)
mockClient.session.list.mockImplementation(() => Promise.resolve({ error: createSdkUnavailableError("fetch failed ECONNREFUSED") }))
const sessions = await storage.getMainSessions({ directory: "/workspace/project" })
expect(sessions).toHaveLength(1)
expect(sessions[0].id).toBe("ses_file")
})
test("#given empty SDK list response #when getMainSessions runs #then returns file-backed pre-migration sessions", async () => {
createSessionMetadata("proj_test", "ses_file", "/workspace/project", 2_000)
mockClient.session.list.mockImplementation(() => Promise.resolve({ data: [] }))
const sessions = await storage.getMainSessions({ directory: "/workspace/project" })
expect(sessions).toHaveLength(1)
expect(sessions[0].id).toBe("ses_file")
})
test("#given SDK and file sessions overlap #when getMainSessions runs #then dedupes by id and keeps SDK metadata", async () => {
createSessionMetadata("proj_test", "ses_file", "/workspace/project", 2_000)
createSessionMetadata("proj_test", "ses_sdk", "/workspace/project", 1_500)
mockClient.session.list.mockImplementation(() => Promise.resolve({
data: [
{
id: "ses_sdk",
projectID: "sdk_project",
directory: "/workspace/project",
time: { created: 3_000, updated: 4_000 },
},
],
}))
const sessions = await storage.getMainSessions({ directory: "/workspace/project" })
expect(sessions).toHaveLength(2)
expect(sessions.map((session) => session.id)).toEqual(["ses_sdk", "ses_file"])
expect(sessions[0].projectID).toBe("sdk_project")
})
test("#given empty SDK session list #when getAllSessions runs #then returns file-backed session ids", async () => {
createSessionMessage("ses_file", "msg_001", 1_000)
mockClient.session.list.mockImplementation(() => Promise.resolve({ data: [] }))
const sessionIds = await storage.getAllSessions()
expect(sessionIds).toEqual(["ses_file"])
})
test("#given SDK and file session ids overlap #when getAllSessions runs #then returns deduped union", async () => {
createSessionMessage("ses_file", "msg_001", 1_000)
createSessionMessage("ses_sdk", "msg_002", 2_000)
mockClient.session.list.mockImplementation(() => Promise.resolve({
data: [
{ id: "ses_sdk" },
],
}))
const sessionIds = await storage.getAllSessions()
expect(sessionIds).toEqual(["ses_sdk", "ses_file"])
})
test("#given unreachable SDK messages error #when readSessionMessages runs #then falls back to file messages", async () => {
createSessionMessage("ses_file", "msg_001", 1_000)
mockClient.session.messages.mockImplementation(() => Promise.reject(createSdkUnavailableError("Unable to connect to http://localhost:4096")))
const messages = await storage.readSessionMessages("ses_file")
expect(messages).toHaveLength(1)
expect(messages[0].id).toBe("msg_001")
})
test("#given empty SDK messages response #when readSessionMessages runs #then falls back to file messages", async () => {
createSessionMessage("ses_file", "msg_001", 1_000)
mockClient.session.messages.mockImplementation(() => Promise.resolve({ data: [] }))
const messages = await storage.readSessionMessages("ses_file")
expect(messages).toHaveLength(1)
expect(messages[0].id).toBe("msg_001")
})
test("#given unreachable SDK todo response #when readSessionTodos runs #then falls back to file todos", async () => {
createSessionTodo("ses_file", [{ id: "todo_1", content: "Fallback todo", status: "pending" }])
mockClient.session.todo.mockImplementation(() => Promise.resolve({ error: createSdkUnavailableError("network error: server unreachable") }))
const todos = await storage.readSessionTodos("ses_file")
expect(todos).toHaveLength(1)
expect(todos[0].content).toBe("Fallback todo")
})
test("#given empty SDK todo response #when readSessionTodos runs #then falls back to file todos", async () => {
createSessionTodo("ses_file", [{ id: "todo_1", content: "Fallback todo", status: "pending" }])
mockClient.session.todo.mockImplementation(() => Promise.resolve({ data: [] }))
const todos = await storage.readSessionTodos("ses_file")
expect(todos).toHaveLength(1)
expect(todos[0].content).toBe("Fallback todo")
})
test("#given unreachable SDK list error #when sessionExists runs #then falls back to file existence", async () => {
createSessionMessage("ses_file", "msg_001", 1_000)
mockClient.session.list.mockImplementation(() => Promise.reject(createSdkUnavailableError("ETIMEDOUT while connecting")))
const exists = await storage.sessionExists("ses_file")
expect(exists).toBe(true)
})
test("#given empty SDK session list #when sessionExists runs #then falls back to file existence", async () => {
createSessionMessage("ses_file", "msg_001", 1_000)
mockClient.session.list.mockImplementation(() => Promise.resolve({ data: [] }))
const exists = await storage.sessionExists("ses_file")
expect(exists).toBe(true)
})
test("#given semantic SDK error #when readSessionMessages runs #then rethrows instead of hiding bug", async () => {
mockClient.session.messages.mockImplementation(() => Promise.resolve({ error: new Error("session not found") }))
await expect(storage.readSessionMessages("ses_missing")).rejects.toThrow("session not found")
})
})
+55 -11
View File
@@ -1,4 +1,4 @@
import { describe, test, expect, beforeEach, afterEach, mock } from "bun:test"
import { describe, test, expect, beforeEach, afterEach, afterAll, mock } from "bun:test"
import { mkdirSync, writeFileSync, rmSync, existsSync, readdirSync } from "node:fs"
import { join } from "node:path"
import { tmpdir } from "node:os"
@@ -10,6 +10,7 @@ const TEST_PART_STORAGE = join(TEST_DIR, "part")
const TEST_SESSION_STORAGE = join(TEST_DIR, "session")
const TEST_TODO_DIR = join(TEST_DIR, "todos")
const TEST_TRANSCRIPT_DIR = join(TEST_DIR, "transcripts")
let sqliteBackend = false
mock.module("./constants", () => ({
OPENCODE_STORAGE: TEST_DIR,
@@ -27,7 +28,7 @@ mock.module("./constants", () => ({
}))
mock.module("../../shared/opencode-storage-detection", () => ({
isSqliteBackend: () => false,
isSqliteBackend: () => sqliteBackend,
resetSqliteBackendCache: () => {},
}))
@@ -59,6 +60,9 @@ mock.module("../../shared/opencode-message-dir", () => ({
return null
},
}))
afterAll(() => { mock.restore() })
const { getAllSessions, getMessageDir, sessionExists, readSessionMessages, readSessionTodos, getSessionInfo } =
await import("./storage")
@@ -66,6 +70,7 @@ const storage = await import("./storage")
describe("session-manager storage", () => {
beforeEach(() => {
sqliteBackend = false
if (existsSync(TEST_DIR)) {
rmSync(TEST_DIR, { recursive: true, force: true })
}
@@ -78,6 +83,8 @@ describe("session-manager storage", () => {
})
afterEach(() => {
sqliteBackend = false
storage.resetStorageClient()
if (existsSync(TEST_DIR)) {
rmSync(TEST_DIR, { recursive: true, force: true })
}
@@ -232,6 +239,47 @@ describe("session-manager storage", () => {
expect(info?.agents_used).toContain("build")
expect(info?.agents_used).toContain("oracle")
})
test("getSessionInfo uses SDK session messages on sqlite backend", async () => {
sqliteBackend = true
const now = Date.now()
storage.setStorageClient({
session: {
messages: async () => ({
data: [
{
info: {
id: "msg_sqlite_1",
role: "user",
agent: "atlas",
time: { created: now - 5000, updated: now - 5000 },
},
parts: [],
},
{
info: {
id: "msg_sqlite_2",
role: "assistant",
agent: "prometheus",
time: { created: now, updated: now },
},
parts: [],
},
],
}),
todo: async () => ({ data: [] }),
},
} as never)
const info = await getSessionInfo("ses_sqlite")
expect(info).not.toBeNull()
expect(info?.id).toBe("ses_sqlite")
expect(info?.message_count).toBe(2)
expect(info?.agents_used).toContain("atlas")
expect(info?.agents_used).toContain("prometheus")
})
})
describe("session-manager storage - getMainSessions", () => {
@@ -371,9 +419,9 @@ describe("session-manager storage - getMainSessions", () => {
describe("session-manager storage - SDK path (beta mode)", () => {
const mockClient = {
session: {
list: mock(() => Promise.resolve({ data: [] })),
messages: mock(() => Promise.resolve({ data: [] })),
todo: mock(() => Promise.resolve({ data: [] })),
list: mock((): Promise<unknown> => Promise.resolve({ data: [] })),
messages: mock((): Promise<unknown> => Promise.resolve({ data: [] })),
todo: mock((): Promise<unknown> => Promise.resolve({ data: [] })),
},
}
@@ -497,7 +545,7 @@ describe("session-manager storage - SDK path (beta mode)", () => {
expect(todos[1].status).toBe("completed")
})
test("SDK path returns empty array on error", async () => {
test("SDK path rethrows non-transport errors", async () => {
// given
mockClient.session.messages.mockImplementation(() => Promise.reject(new Error("API error")))
@@ -509,11 +557,7 @@ describe("session-manager storage - SDK path (beta mode)", () => {
const { setStorageClient, readSessionMessages } = await import("./storage")
setStorageClient(mockClient as unknown as Parameters<typeof setStorageClient>[0])
// when
const messages = await readSessionMessages("ses_test")
// then
expect(messages).toEqual([])
await expect(readSessionMessages("ses_test")).rejects.toThrow("API error")
})
test("SDK path returns empty array when client is not set", async () => {
+94 -283
View File
@@ -1,17 +1,35 @@
import { existsSync } from "node:fs"
import { readdir, readFile } from "node:fs/promises"
import { join } from "node:path"
import type { PluginInput } from "@opencode-ai/plugin"
import { MESSAGE_STORAGE, PART_STORAGE, SESSION_STORAGE, TODO_DIR, TRANSCRIPT_DIR } from "./constants"
import { isSqliteBackend } from "../../shared/opencode-storage-detection"
import { getMessageDir } from "../../shared/opencode-message-dir"
import type { SessionMessage, SessionInfo, TodoItem, SessionMetadata } from "./types"
import { normalizeSDKResponse } from "../../shared"
import { log } from "../../shared"
import { getFileAllSessions, getFileMainSessions, fileSessionExists, getFileSessionInfo, getFileSessionMessages, getFileSessionTodos, getFileSessionTranscript } from "./file-storage"
import { getSdkAllSessions, getSdkMainSessions, getSdkSessionMessages, getSdkSessionTodos, sdkSessionExists, shouldFallbackFromSdkError } from "./sdk-storage"
import type { SessionInfo, SessionMessage, SessionMetadata, TodoItem } from "./types"
export interface GetMainSessionsOptions {
directory?: string
}
function mergeSessionMetadataLists(
sdkSessions: SessionMetadata[],
fileSessions: SessionMetadata[],
): SessionMetadata[] {
const merged = new Map<string, SessionMetadata>()
for (const session of fileSessions) {
merged.set(session.id, session)
}
for (const session of sdkSessions) {
merged.set(session.id, session)
}
return [...merged.values()].sort((a, b) => b.time.updated - a.time.updated)
}
function mergeSessionIds(sdkSessionIds: string[], fileSessionIds: string[]): string[] {
return [...new Set([...sdkSessionIds, ...fileSessionIds])]
}
// SDK client reference for beta mode
let sdkClient: PluginInput["client"] | null = null
@@ -24,327 +42,120 @@ export function resetStorageClient(): void {
}
export async function getMainSessions(options: GetMainSessionsOptions): Promise<SessionMetadata[]> {
// Beta mode: use SDK
if (isSqliteBackend() && sdkClient) {
try {
const response = await sdkClient.session.list()
const sessions = normalizeSDKResponse(response, [] as SessionMetadata[])
const mainSessions = sessions.filter((s) => !s.parentID)
if (options.directory) {
return mainSessions
.filter((s) => s.directory === options.directory)
.sort((a, b) => b.time.updated - a.time.updated)
}
return mainSessions.sort((a, b) => b.time.updated - a.time.updated)
} catch {
return []
const sdkSessions = await getSdkMainSessions(sdkClient, options.directory)
const fileSessions = await getFileMainSessions(options.directory)
return mergeSessionMetadataLists(sdkSessions, fileSessions)
} catch (error) {
if (!shouldFallbackFromSdkError(error)) throw error
log("[session-manager] falling back to file session list after SDK unavailable error", { error: String(error) })
}
}
// Stable mode: use JSON files
if (!existsSync(SESSION_STORAGE)) return []
const sessions: SessionMetadata[] = []
try {
const projectDirs = await readdir(SESSION_STORAGE, { withFileTypes: true })
for (const projectDir of projectDirs) {
if (!projectDir.isDirectory()) continue
const projectPath = join(SESSION_STORAGE, projectDir.name)
const sessionFiles = await readdir(projectPath)
for (const file of sessionFiles) {
if (!file.endsWith(".json")) continue
try {
const content = await readFile(join(projectPath, file), "utf-8")
const meta = JSON.parse(content) as SessionMetadata
if (meta.parentID) continue
if (options.directory && meta.directory !== options.directory) continue
sessions.push(meta)
} catch {
continue
}
}
}
} catch {
return []
}
return sessions.sort((a, b) => b.time.updated - a.time.updated)
return getFileMainSessions(options.directory)
}
export async function getAllSessions(): Promise<string[]> {
// Beta mode: use SDK
if (isSqliteBackend() && sdkClient) {
try {
const response = await sdkClient.session.list()
const sessions = normalizeSDKResponse(response, [] as SessionMetadata[])
return sessions.map((s) => s.id)
} catch {
return []
const sdkSessionIds = await getSdkAllSessions(sdkClient)
const fileSessionIds = await getFileAllSessions()
return mergeSessionIds(sdkSessionIds, fileSessionIds)
} catch (error) {
if (!shouldFallbackFromSdkError(error)) throw error
log("[session-manager] falling back to file session ids after SDK unavailable error", { error: String(error) })
}
}
// Stable mode: use JSON files
if (!existsSync(MESSAGE_STORAGE)) return []
const sessions: string[] = []
async function scanDirectory(dir: string): Promise<void> {
try {
const entries = await readdir(dir, { withFileTypes: true })
for (const entry of entries) {
if (entry.isDirectory()) {
const sessionPath = join(dir, entry.name)
const files = await readdir(sessionPath)
if (files.some((f) => f.endsWith(".json"))) {
sessions.push(entry.name)
} else {
await scanDirectory(sessionPath)
}
}
}
} catch {
return
}
}
await scanDirectory(MESSAGE_STORAGE)
return [...new Set(sessions)]
return getFileAllSessions()
}
export { getMessageDir } from "../../shared/opencode-message-dir"
export async function sessionExists(sessionID: string): Promise<boolean> {
if (isSqliteBackend() && sdkClient) {
const response = await sdkClient.session.list()
const sessions = normalizeSDKResponse(response, [] as Array<{ id?: string }>)
return sessions.some((s) => s.id === sessionID)
try {
const existsInSdk = await sdkSessionExists(sdkClient, sessionID)
if (existsInSdk) return true
} catch (error) {
if (!shouldFallbackFromSdkError(error)) throw error
log("[session-manager] falling back to file sessionExists after SDK unavailable error", { error: String(error), sessionID })
}
}
return getMessageDir(sessionID) !== null
return fileSessionExists(sessionID)
}
export async function readSessionMessages(sessionID: string): Promise<SessionMessage[]> {
// Beta mode: use SDK
if (isSqliteBackend() && sdkClient) {
try {
const response = await sdkClient.session.messages({ path: { id: sessionID } })
const rawMessages = normalizeSDKResponse(response, [] as Array<{
info?: {
id?: string
role?: string
agent?: string
time?: { created?: number; updated?: number }
}
parts?: Array<{
id?: string
type?: string
text?: string
thinking?: string
tool?: string
callID?: string
input?: Record<string, unknown>
output?: string
error?: string
}>
}>)
const messages: SessionMessage[] = rawMessages
.filter((m) => m.info?.id)
.map((m) => ({
id: m.info!.id!,
role: (m.info!.role as "user" | "assistant") || "user",
agent: m.info!.agent,
time: m.info!.time?.created
? {
created: m.info!.time.created,
updated: m.info!.time.updated,
}
: undefined,
parts:
m.parts?.map((p) => ({
id: p.id || "",
type: p.type || "text",
text: p.text,
thinking: p.thinking,
tool: p.tool,
callID: p.callID,
input: p.input,
output: p.output,
error: p.error,
})) || [],
}))
return messages.sort((a, b) => {
const aTime = a.time?.created ?? 0
const bTime = b.time?.created ?? 0
if (aTime !== bTime) return aTime - bTime
return a.id.localeCompare(b.id)
})
} catch {
return []
const sdkMessages = await getSdkSessionMessages(sdkClient, sessionID)
if (sdkMessages.length > 0) return sdkMessages
} catch (error) {
if (!shouldFallbackFromSdkError(error)) throw error
log("[session-manager] falling back to file session messages after SDK unavailable error", { error: String(error), sessionID })
}
}
// Stable mode: use JSON files
const messageDir = getMessageDir(sessionID)
if (!messageDir || !existsSync(messageDir)) return []
const messages: SessionMessage[] = []
try {
const files = await readdir(messageDir)
for (const file of files) {
if (!file.endsWith(".json")) continue
try {
const content = await readFile(join(messageDir, file), "utf-8")
const meta = JSON.parse(content)
const parts = await readParts(meta.id)
messages.push({
id: meta.id,
role: meta.role,
agent: meta.agent,
time: meta.time,
parts,
})
} catch {
continue
}
}
} catch {
return []
}
return messages.sort((a, b) => {
const aTime = a.time?.created ?? 0
const bTime = b.time?.created ?? 0
if (aTime !== bTime) return aTime - bTime
return a.id.localeCompare(b.id)
})
}
async function readParts(messageID: string): Promise<Array<{ id: string; type: string; [key: string]: unknown }>> {
const partDir = join(PART_STORAGE, messageID)
if (!existsSync(partDir)) return []
const parts: Array<{ id: string; type: string; [key: string]: unknown }> = []
try {
const files = await readdir(partDir)
for (const file of files) {
if (!file.endsWith(".json")) continue
try {
const content = await readFile(join(partDir, file), "utf-8")
parts.push(JSON.parse(content))
} catch {
continue
}
}
} catch {
return []
}
return parts.sort((a, b) => a.id.localeCompare(b.id))
return getFileSessionMessages(sessionID)
}
export async function readSessionTodos(sessionID: string): Promise<TodoItem[]> {
// Beta mode: use SDK
if (isSqliteBackend() && sdkClient) {
try {
const response = await sdkClient.session.todo({ path: { id: sessionID } })
const data = normalizeSDKResponse(response, [] as Array<{
id?: string
content?: string
status?: string
priority?: string
}>)
return data.map((item) => ({
id: item.id || "",
content: item.content || "",
status: (item.status as TodoItem["status"]) || "pending",
priority: item.priority,
}))
} catch {
return []
const sdkTodos = await getSdkSessionTodos(sdkClient, sessionID)
if (sdkTodos.length > 0) return sdkTodos
} catch (error) {
if (!shouldFallbackFromSdkError(error)) throw error
log("[session-manager] falling back to file session todos after SDK unavailable error", { error: String(error), sessionID })
}
}
// Stable mode: use JSON files
if (!existsSync(TODO_DIR)) return []
try {
const allFiles = await readdir(TODO_DIR)
const todoFiles = allFiles.filter((f) => f === `${sessionID}.json`)
for (const file of todoFiles) {
try {
const content = await readFile(join(TODO_DIR, file), "utf-8")
const data = JSON.parse(content)
if (Array.isArray(data)) {
return data.map((item) => ({
id: item.id || "",
content: item.content || "",
status: item.status || "pending",
priority: item.priority,
}))
}
} catch {
continue
}
}
} catch {
return []
}
return []
return getFileSessionTodos(sessionID)
}
export async function readSessionTranscript(sessionID: string): Promise<number> {
if (!existsSync(TRANSCRIPT_DIR)) return 0
const transcriptFile = join(TRANSCRIPT_DIR, `${sessionID}.jsonl`)
if (!existsSync(transcriptFile)) return 0
try {
const content = await readFile(transcriptFile, "utf-8")
return content.trim().split("\n").filter(Boolean).length
} catch {
return 0
}
return getFileSessionTranscript(sessionID)
}
export async function getSessionInfo(sessionID: string): Promise<SessionInfo | null> {
const messages = await readSessionMessages(sessionID)
if (messages.length === 0) return null
if (isSqliteBackend() && sdkClient) {
try {
const sdkMessages = await getSdkSessionMessages(sdkClient, sessionID)
if (sdkMessages.length > 0) {
const agentsUsed = new Set<string>()
let firstMessage: Date | undefined
let lastMessage: Date | undefined
const agentsUsed = new Set<string>()
let firstMessage: Date | undefined
let lastMessage: Date | undefined
for (const msg of sdkMessages) {
if (msg.agent) agentsUsed.add(msg.agent)
if (msg.time?.created) {
const date = new Date(msg.time.created)
if (!firstMessage || date < firstMessage) firstMessage = date
if (!lastMessage || date > lastMessage) lastMessage = date
}
}
for (const msg of messages) {
if (msg.agent) agentsUsed.add(msg.agent)
if (msg.time?.created) {
const date = new Date(msg.time.created)
if (!firstMessage || date < firstMessage) firstMessage = date
if (!lastMessage || date > lastMessage) lastMessage = date
const todos = await readSessionTodos(sessionID)
const transcriptEntries = await readSessionTranscript(sessionID)
return {
id: sessionID,
message_count: sdkMessages.length,
first_message: firstMessage,
last_message: lastMessage,
agents_used: Array.from(agentsUsed),
has_todos: todos.length > 0,
has_transcript: transcriptEntries > 0,
todos,
transcript_entries: transcriptEntries,
}
}
} catch (error) {
if (!shouldFallbackFromSdkError(error)) throw error
log("[session-manager] falling back to file session info after SDK unavailable error", { error: String(error), sessionID })
}
}
const todos = await readSessionTodos(sessionID)
const transcriptEntries = await readSessionTranscript(sessionID)
return {
id: sessionID,
message_count: messages.length,
first_message: firstMessage,
last_message: lastMessage,
agents_used: Array.from(agentsUsed),
has_todos: todos.length > 0,
has_transcript: transcriptEntries > 0,
todos,
transcript_entries: transcriptEntries,
}
return getFileSessionInfo(sessionID)
}
+74 -2
View File
@@ -2,6 +2,7 @@ import { describe, test, expect } from "bun:test"
import { createSessionManagerTools } from "./tools"
import type { ToolContext } from "@opencode-ai/plugin/tool"
import type { PluginInput } from "@opencode-ai/plugin"
import type { SessionInfo, SessionMessage, SearchResult, SessionMetadata, TodoItem } from "./types"
const projectDir = "/Users/yeongyu/local-workspaces/oh-my-opencode"
@@ -18,23 +19,83 @@ const mockContext: ToolContext = {
ask: async () => {},
}
const tools = createSessionManagerTools(mockCtx)
const { session_list, session_read, session_search, session_info } = tools
function createTestTools() {
return createSessionManagerTools(mockCtx, {
setStorageClient: () => {},
getMainSessions: async (): Promise<SessionMetadata[]> => [
{
id: "ses_test123",
projectID: "project-1",
directory: projectDir,
time: { created: Date.now(), updated: Date.now() },
},
{
id: "ses_test456",
projectID: "project-1",
directory: projectDir,
time: { created: Date.now(), updated: Date.now() },
},
],
filterSessionsByDate: async (sessionIDs) => sessionIDs,
formatSessionList: async (sessionIDs) => `sessions:${sessionIDs.join(",")}`,
sessionExists: async (sessionID) => sessionID === "ses_test123",
readSessionMessages: async (sessionID): Promise<SessionMessage[]> =>
sessionID === "ses_test123"
? [{
id: `${sessionID}-msg`,
role: "user",
time: { created: Date.now() },
parts: [{ id: `${sessionID}-part`, type: "text", text: "hello" }],
}]
: [],
readSessionTodos: async (): Promise<TodoItem[]> => [],
formatSessionMessages: (messages) => `messages:${messages.length}`,
getAllSessions: async () => ["ses_test123", "ses_test456"],
searchInSession: async (sessionID): Promise<SearchResult[]> => [
{
session_id: sessionID,
message_id: `${sessionID}-msg`,
excerpt: "test snippet",
role: "user",
match_count: 1,
},
],
formatSearchResults: (results) => `results:${results.length}`,
getSessionInfo: async (sessionID): Promise<SessionInfo | null> =>
sessionID === "ses_test123"
? {
id: sessionID,
message_count: 1,
first_message: new Date(),
last_message: new Date(),
agents_used: ["test-agent"],
has_todos: false,
has_transcript: false,
todos: [],
transcript_entries: 0,
}
: null,
formatSessionInfo: (info) => `info:${info.id}`,
})
}
describe("session-manager tools", () => {
test("session_list executes without error", async () => {
const { session_list } = createTestTools()
const result = await session_list.execute({}, mockContext)
expect(typeof result).toBe("string")
})
test("session_list respects limit parameter", async () => {
const { session_list } = createTestTools()
const result = await session_list.execute({ limit: 5 }, mockContext)
expect(typeof result).toBe("string")
})
test("session_list filters by date range", async () => {
const { session_list } = createTestTools()
const result = await session_list.execute({
from_date: "2025-12-01T00:00:00Z",
to_date: "2025-12-31T23:59:59Z",
@@ -44,6 +105,7 @@ describe("session-manager tools", () => {
})
test("session_list filters by project_path", async () => {
const { session_list } = createTestTools()
//#given
const projectPath = "/Users/yeongyu/local-workspaces/oh-my-opencode"
@@ -55,6 +117,7 @@ describe("session-manager tools", () => {
})
test("session_list uses ctx.directory as default project_path", async () => {
const { session_list } = createTestTools()
//#given - no project_path provided
//#when
@@ -65,12 +128,14 @@ describe("session-manager tools", () => {
})
test("session_read handles non-existent session", async () => {
const { session_read } = createTestTools()
const result = await session_read.execute({ session_id: "ses_nonexistent" }, mockContext)
expect(result).toContain("not found")
})
test("session_read executes with valid parameters", async () => {
const { session_read } = createTestTools()
const result = await session_read.execute({
session_id: "ses_test123",
include_todos: true,
@@ -81,6 +146,7 @@ describe("session-manager tools", () => {
})
test("session_read respects limit parameter", async () => {
const { session_read } = createTestTools()
const result = await session_read.execute({
session_id: "ses_test123",
limit: 10,
@@ -90,12 +156,14 @@ describe("session-manager tools", () => {
})
test("session_search executes without error", async () => {
const { session_search } = createTestTools()
const result = await session_search.execute({ query: "test" }, mockContext)
expect(typeof result).toBe("string")
})
test("session_search filters by session_id", async () => {
const { session_search } = createTestTools()
const result = await session_search.execute({
query: "test",
session_id: "ses_test123",
@@ -105,6 +173,7 @@ describe("session-manager tools", () => {
})
test("session_search respects case_sensitive parameter", async () => {
const { session_search } = createTestTools()
const result = await session_search.execute({
query: "TEST",
case_sensitive: true,
@@ -114,6 +183,7 @@ describe("session-manager tools", () => {
})
test("session_search respects limit parameter", async () => {
const { session_search } = createTestTools()
const result = await session_search.execute({
query: "test",
limit: 5,
@@ -123,12 +193,14 @@ describe("session-manager tools", () => {
})
test("session_info handles non-existent session", async () => {
const { session_info } = createTestTools()
const result = await session_info.execute({ session_id: "ses_nonexistent" }, mockContext)
expect(result).toContain("not found")
})
test("session_info executes with valid session", async () => {
const { session_info } = createTestTools()
const result = await session_info.execute({ session_id: "ses_test123" }, mockContext)
expect(typeof result).toBe("string")
+54 -15
View File
@@ -27,9 +27,48 @@ function withTimeout<T>(promise: Promise<T>, ms: number, operation: string): Pro
])
}
export function createSessionManagerTools(ctx: PluginInput): Record<string, ToolDefinition> {
type SessionManagerToolDeps = {
getAllSessions: typeof getAllSessions
getMainSessions: typeof getMainSessions
getSessionInfo: typeof getSessionInfo
readSessionMessages: typeof readSessionMessages
readSessionTodos: typeof readSessionTodos
sessionExists: typeof sessionExists
setStorageClient: typeof setStorageClient
filterSessionsByDate: typeof filterSessionsByDate
formatSessionInfo: typeof formatSessionInfo
formatSessionList: typeof formatSessionList
formatSessionMessages: typeof formatSessionMessages
formatSearchResults: typeof formatSearchResults
searchInSession: typeof searchInSession
}
const defaultSessionManagerToolDeps: SessionManagerToolDeps = {
getAllSessions,
getMainSessions,
getSessionInfo,
readSessionMessages,
readSessionTodos,
sessionExists,
setStorageClient,
filterSessionsByDate,
formatSessionInfo,
formatSessionList,
formatSessionMessages,
formatSearchResults,
searchInSession,
}
export function createSessionManagerTools(
ctx: PluginInput,
deps: Partial<SessionManagerToolDeps> = {},
): Record<string, ToolDefinition> {
const resolvedDeps: SessionManagerToolDeps = {
...defaultSessionManagerToolDeps,
...deps,
}
// Initialize storage client for SDK-based operations (beta mode)
setStorageClient(ctx.client)
resolvedDeps.setStorageClient(ctx.client)
const session_list: ToolDefinition = tool({
description: SESSION_LIST_DESCRIPTION,
@@ -42,18 +81,18 @@ export function createSessionManagerTools(ctx: PluginInput): Record<string, Tool
execute: async (args: SessionListArgs, _context) => {
try {
const directory = args.project_path ?? ctx.directory
let sessions = await getMainSessions({ directory })
let sessions = await resolvedDeps.getMainSessions({ directory })
let sessionIDs = sessions.map((s) => s.id)
if (args.from_date || args.to_date) {
sessionIDs = await filterSessionsByDate(sessionIDs, args.from_date, args.to_date)
sessionIDs = await resolvedDeps.filterSessionsByDate(sessionIDs, args.from_date, args.to_date)
}
if (args.limit && args.limit > 0) {
sessionIDs = sessionIDs.slice(0, args.limit)
}
return await formatSessionList(sessionIDs)
return await resolvedDeps.formatSessionList(sessionIDs)
} catch (e) {
return `Error: ${e instanceof Error ? e.message : String(e)}`
}
@@ -70,11 +109,11 @@ export function createSessionManagerTools(ctx: PluginInput): Record<string, Tool
},
execute: async (args: SessionReadArgs, _context) => {
try {
if (!(await sessionExists(args.session_id))) {
if (!(await resolvedDeps.sessionExists(args.session_id))) {
return `Session not found: ${args.session_id}`
}
let messages = await readSessionMessages(args.session_id)
let messages = await resolvedDeps.readSessionMessages(args.session_id)
if (messages.length === 0) {
return `Session not found: ${args.session_id}`
@@ -84,9 +123,9 @@ export function createSessionManagerTools(ctx: PluginInput): Record<string, Tool
messages = messages.slice(0, args.limit)
}
const todos = args.include_todos ? await readSessionTodos(args.session_id) : undefined
const todos = args.include_todos ? await resolvedDeps.readSessionTodos(args.session_id) : undefined
return formatSessionMessages(messages, args.include_todos, todos)
return resolvedDeps.formatSessionMessages(messages, args.include_todos, todos)
} catch (e) {
return `Error: ${e instanceof Error ? e.message : String(e)}`
}
@@ -107,10 +146,10 @@ export function createSessionManagerTools(ctx: PluginInput): Record<string, Tool
const searchOperation = async (): Promise<SearchResult[]> => {
if (args.session_id) {
return searchInSession(args.session_id, args.query, args.case_sensitive, resultLimit)
return resolvedDeps.searchInSession(args.session_id, args.query, args.case_sensitive, resultLimit)
}
const allSessions = await getAllSessions()
const allSessions = await resolvedDeps.getAllSessions()
const sessionsToScan = allSessions.slice(0, MAX_SESSIONS_TO_SCAN)
const allResults: SearchResult[] = []
@@ -118,7 +157,7 @@ export function createSessionManagerTools(ctx: PluginInput): Record<string, Tool
if (allResults.length >= resultLimit) break
const remaining = resultLimit - allResults.length
const sessionResults = await searchInSession(sid, args.query, args.case_sensitive, remaining)
const sessionResults = await resolvedDeps.searchInSession(sid, args.query, args.case_sensitive, remaining)
allResults.push(...sessionResults)
}
@@ -127,7 +166,7 @@ export function createSessionManagerTools(ctx: PluginInput): Record<string, Tool
const results = await withTimeout(searchOperation(), SEARCH_TIMEOUT_MS, "Search")
return formatSearchResults(results)
return resolvedDeps.formatSearchResults(results)
} catch (e) {
return `Error: ${e instanceof Error ? e.message : String(e)}`
}
@@ -141,13 +180,13 @@ export function createSessionManagerTools(ctx: PluginInput): Record<string, Tool
},
execute: async (args: SessionInfoArgs, _context) => {
try {
const info = await getSessionInfo(args.session_id)
const info = await resolvedDeps.getSessionInfo(args.session_id)
if (!info) {
return `Session not found: ${args.session_id}`
}
return formatSessionInfo(info)
return resolvedDeps.formatSessionInfo(info)
} catch (e) {
return `Error: ${e instanceof Error ? e.message : String(e)}`
}
+29 -1
View File
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, mock } from "bun:test"
import { describe, it, expect, beforeEach, mock, spyOn } from "bun:test"
import type { ToolContext } from "@opencode-ai/plugin/tool"
import { createSkillMcpTool, applyGrepFilter } from "./tools"
import { SkillMcpManager } from "../../features/skill-mcp-manager"
@@ -165,6 +165,34 @@ describe("skill_mcp tool", () => {
expect(tool.description).toBeDefined()
})
})
describe("session resolution", () => {
it("uses the tool context sessionID when the fallback getter is empty", async () => {
// given
loadedSkills = [
createMockSkillWithMcp("test-skill", {
"test-server": { command: "echo", args: ["test"] },
}),
]
const callToolSpy = spyOn(manager, "callTool").mockResolvedValue({ content: [] } as never)
const tool = createSkillMcpTool({
manager,
getLoadedSkills: () => loadedSkills,
getSessionID: () => "",
})
// when
await tool.execute({ mcp_name: "test-server", tool_name: "some-tool" }, mockContext)
// then
expect(callToolSpy).toHaveBeenCalledWith(
expect.objectContaining({ sessionID: mockContext.sessionID }),
expect.any(Object),
"some-tool",
{},
)
})
})
})
describe("applyGrepFilter", () => {
+10 -3
View File
@@ -1,4 +1,5 @@
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
import type { ToolContext } from "@opencode-ai/plugin/tool"
import { BUILTIN_MCP_TOOL_HINTS, SKILL_MCP_DESCRIPTION } from "./constants"
import type { SkillMcpArgs } from "./types"
import type { SkillMcpManager, SkillMcpClientInfo, SkillMcpServerContext } from "../../features/skill-mcp-manager"
@@ -7,7 +8,7 @@ import type { LoadedSkill } from "../../features/opencode-skill-loader/types"
interface SkillMcpToolOptions {
manager: SkillMcpManager
getLoadedSkills: () => LoadedSkill[]
getSessionID: () => string
getSessionID?: () => string | undefined
}
type OperationType = { type: "tool" | "resource" | "prompt"; name: string }
@@ -136,7 +137,7 @@ export function createSkillMcpTool(options: SkillMcpToolOptions): ToolDefinition
.optional()
.describe("Regex pattern to filter output lines (only matching lines returned)"),
},
async execute(args: SkillMcpArgs) {
async execute(args: SkillMcpArgs, toolContext: ToolContext) {
const operation = validateOperationParams(args)
const skills = getLoadedSkills()
const found = findMcpServer(args.mcp_name, skills)
@@ -156,10 +157,16 @@ export function createSkillMcpTool(options: SkillMcpToolOptions): ToolDefinition
)
}
const sessionID = toolContext.sessionID || getSessionID?.()
if (!sessionID) {
throw new Error("No active session available for skill MCP call.")
}
const info: SkillMcpClientInfo = {
serverName: args.mcp_name,
skillName: found.skill.name,
sessionID: getSessionID(),
sessionID,
scope: found.skill.scope,
}
const context: SkillMcpServerContext = {
@@ -0,0 +1,82 @@
/// <reference types="bun-types" />
import { describe, expect, it } from "bun:test"
import type { LoadedSkill } from "../../features/opencode-skill-loader/types"
function requireFresh<T>(modulePath: string): T {
const resolvedPath = require.resolve(modulePath)
if (require.cache?.[resolvedPath]) {
delete require.cache[resolvedPath]
}
return require(modulePath) as T
}
function createSkillTool(...args: Parameters<typeof import("./tools").createSkillTool>): ReturnType<typeof import("./tools").createSkillTool> {
return requireFresh<typeof import("./tools")>("./tools").createSkillTool(...args)
}
function createMockSkill(name: string): LoadedSkill {
return {
name,
path: `/test/skills/${name}/SKILL.md`,
resolvedPath: `/test/skills/${name}`,
definition: {
name,
description: `Test skill ${name}`,
template: `Test skill template for ${name}`,
},
scope: "opencode-project",
}
}
async function waitForRefresh(predicate: () => boolean): Promise<void> {
for (let attempt = 0; attempt < 200; attempt += 1) {
if (predicate()) {
return
}
await new Promise<void>((resolve) => setTimeout(resolve, 10))
}
throw new Error("Timed out waiting for async skill description refresh")
}
describe("skill tool - async native skill description refresh", () => {
it("updates description after async native skills resolve", async () => {
//#given
let allCallCount = 0
const tool = createSkillTool({
skills: [createMockSkill("seeded-skill")],
commands: [],
nativeSkills: {
async all() {
allCallCount += 1
return [{
name: "async-native-skill",
description: "Async native skill from plugin input",
location: "/external/skills/async-native-skill/SKILL.md",
content: "Async native skill body",
}]
},
async get() {
return undefined
},
async dirs() {
return []
},
},
})
expect(tool.description).toContain("seeded-skill")
expect(tool.description).not.toContain("async-native-skill")
//#when
await waitForRefresh(() => tool.description.includes("async-native-skill"))
//#then
expect(allCallCount).toBeGreaterThanOrEqual(1)
expect(tool.description).toContain("seeded-skill")
expect(tool.description).toContain("async-native-skill")
})
})
+1 -1
View File
@@ -8,7 +8,7 @@ Skills and commands provide specialized knowledge and step-by-step guidance.
Use this when a task matches an available skill's or command's description.
**How to use:**
- Call with a skill name: name='code-review'
- Call with a skill name: name='review-work'
- Call with a command name (without leading slash): name='publish'
- The tool will return detailed instructions with your context applied.
`
+61
View File
@@ -0,0 +1,61 @@
import { TOOL_DESCRIPTION_NO_SKILLS, TOOL_DESCRIPTION_PREFIX } from "./constants"
import { sortByScopePriority } from "./scope-priority"
import type { SkillInfo } from "./types"
import type { CommandInfo } from "../slashcommand/types"
function formatSkillCommand(skill: SkillInfo): string {
const lines = [
" <command>",
` <name>/${skill.name}</name>`,
` <description>${skill.description}</description>`,
` <scope>${skill.scope}</scope>`,
]
if (skill.compatibility) {
lines.push(` <compatibility>${skill.compatibility}</compatibility>`)
}
lines.push(" </command>")
return lines.join("\n")
}
function formatSlashCommand(command: CommandInfo): string {
const argumentHint = typeof command.metadata.argumentHint === "string"
? command.metadata.argumentHint.trim()
: undefined
const lines = [
" <command>",
` <name>/${command.name}</name>`,
` <description>${command.metadata.description || "(no description)"}</description>`,
` <scope>${command.scope}</scope>`,
]
if (argumentHint) {
lines.push(` <argument>${argumentHint}</argument>`)
}
lines.push(" </command>")
return lines.join("\n")
}
export function formatCombinedDescription(skills: SkillInfo[], commands: CommandInfo[]): string {
if (skills.length === 0 && commands.length === 0) {
return TOOL_DESCRIPTION_NO_SKILLS
}
const availableItems = [
...sortByScopePriority(skills).map(formatSkillCommand),
...sortByScopePriority(commands).map(formatSlashCommand),
]
if (availableItems.length === 0) {
return TOOL_DESCRIPTION_PREFIX
}
return `${TOOL_DESCRIPTION_PREFIX}
<available_items>
Priority: project > user > opencode > builtin/plugin | Skills listed before commands
Invoke via: skill(name="item-name") - omit leading slash for commands.
${availableItems.join("\n")}
</available_items>`
}
@@ -0,0 +1,97 @@
import type { Prompt, Resource, Tool } from "@modelcontextprotocol/sdk/types.js"
import { sanitizeJsonSchema } from "../../plugin/normalize-tool-arg-schemas"
import type {
SkillMcpClientInfo,
SkillMcpManager,
SkillMcpServerContext,
} from "../../features/skill-mcp-manager"
import type { LoadedSkill } from "../../features/opencode-skill-loader"
export async function formatMcpCapabilities(
skill: LoadedSkill,
manager: SkillMcpManager,
sessionID: string
): Promise<string | null> {
if (!skill.mcpConfig || Object.keys(skill.mcpConfig).length === 0) {
return null
}
const sections: string[] = ["", "## Available MCP Servers", ""]
for (const [serverName, config] of Object.entries(skill.mcpConfig)) {
const info: SkillMcpClientInfo = {
serverName,
skillName: skill.name,
sessionID,
scope: skill.scope,
}
const context: SkillMcpServerContext = {
config,
skillName: skill.name,
}
sections.push(`### ${serverName}`, "")
try {
const [tools, resources, prompts] = await Promise.all([
manager.listTools(info, context).catch(() => []),
manager.listResources(info, context).catch(() => []),
manager.listPrompts(info, context).catch(() => []),
])
appendToolSections(sections, tools as Tool[])
appendResourceSection(sections, resources as Resource[])
appendPromptSection(sections, prompts as Prompt[])
if (tools.length === 0 && resources.length === 0 && prompts.length === 0) {
sections.push("*No capabilities discovered*")
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
sections.push(`*Failed to connect: ${errorMessage.split("\n")[0]}*`)
}
sections.push("", `Use \`skill_mcp\` tool with \`mcp_name=\"${serverName}\"\` to invoke.`, "")
}
return sections.join("\n")
}
function appendToolSections(sections: string[], tools: Tool[]): void {
if (tools.length === 0) {
return
}
sections.push("**Tools:**", "")
for (const toolDefinition of tools) {
sections.push(`#### \`${toolDefinition.name}\``)
if (toolDefinition.description) {
sections.push(toolDefinition.description)
}
sections.push(
"",
"**inputSchema:**",
"```json",
JSON.stringify(sanitizeJsonSchema(toolDefinition.inputSchema), null, 2),
"```",
""
)
}
}
function appendResourceSection(sections: string[], resources: Resource[]): void {
if (resources.length === 0) {
return
}
sections.push(`**Resources**: ${resources.map((resource) => resource.uri).join(", ")}`)
}
function appendPromptSection(sections: string[], prompts: Prompt[]): void {
if (prompts.length === 0) {
return
}
sections.push(`**Prompts**: ${prompts.map((prompt) => prompt.name).join(", ")}`)
}
+62
View File
@@ -0,0 +1,62 @@
import type { SkillInfo } from "./types"
import type { LoadedSkill } from "../../features/opencode-skill-loader"
export type NativeSkillEntry = {
name: string
description: string
location: string
content: string
}
export function loadedSkillToInfo(skill: LoadedSkill): SkillInfo {
return {
name: skill.name,
description: skill.definition.description || "",
location: skill.path,
scope: skill.scope,
license: skill.license,
compatibility: skill.compatibility,
metadata: skill.metadata,
allowedTools: skill.allowedTools,
}
}
function nativeSkillToLoadedSkill(native: NativeSkillEntry): LoadedSkill {
return {
name: native.name,
path: native.location,
definition: {
name: native.name,
description: native.description,
template: native.content,
},
scope: "config",
}
}
export function mergeNativeSkills(skills: LoadedSkill[], nativeSkills: NativeSkillEntry[]): void {
const knownNames = new Set(skills.map((skill) => skill.name))
for (const native of nativeSkills) {
if (knownNames.has(native.name)) continue
skills.push(nativeSkillToLoadedSkill(native))
knownNames.add(native.name)
}
}
export function mergeNativeSkillInfos(skillInfos: SkillInfo[], nativeSkills: NativeSkillEntry[]): void {
const knownNames = new Set(skillInfos.map((skill) => skill.name))
for (const native of nativeSkills) {
if (knownNames.has(native.name)) continue
skillInfos.push({
name: native.name,
description: native.description,
location: native.location,
scope: "config",
})
knownNames.add(native.name)
}
}
export function isPromiseLike<TValue>(value: TValue | Promise<TValue>): value is Promise<TValue> {
return typeof value === "object" && value !== null && "then" in value
}
+17
View File
@@ -0,0 +1,17 @@
export const SCOPE_PRIORITY: Record<string, number> = {
project: 4,
user: 3,
opencode: 2,
"opencode-project": 2,
plugin: 1,
config: 1,
builtin: 1,
}
export function sortByScopePriority<TItem extends { scope: string }>(items: TItem[]): TItem[] {
return [...items].sort((left, right) => {
const leftPriority = SCOPE_PRIORITY[left.scope] || 0
const rightPriority = SCOPE_PRIORITY[right.scope] || 0
return rightPriority - leftPriority
})
}
+26
View File
@@ -0,0 +1,26 @@
import type { LoadedSkill } from "../../features/opencode-skill-loader"
import { extractSkillTemplate } from "../../features/opencode-skill-loader/skill-content"
const SKILL_INSTRUCTION_PATTERN = /<skill-instruction>([\s\S]*?)<\/skill-instruction>/
function trimSkillInstruction(template: string): string {
const templateMatch = template.match(SKILL_INSTRUCTION_PATTERN)
return templateMatch ? templateMatch[1].trim() : template
}
export async function extractSkillBody(skill: LoadedSkill): Promise<string> {
if (skill.lazyContent) {
const fullTemplate = await skill.lazyContent.load()
return trimSkillInstruction(fullTemplate)
}
if (skill.scope === "config" && skill.definition.template) {
return trimSkillInstruction(skill.definition.template)
}
if (skill.path) {
return extractSkillTemplate(skill)
}
return trimSkillInstruction(skill.definition.template || "")
}
+40
View File
@@ -0,0 +1,40 @@
import { sortByScopePriority } from "./scope-priority"
import type { CommandInfo } from "../slashcommand/types"
import type { LoadedSkill } from "../../features/opencode-skill-loader"
export function matchSkillByName(skills: LoadedSkill[], requestedName: string): LoadedSkill | undefined {
const normalizedName = requestedName.toLowerCase()
const exactMatch = skills.find((skill) => skill.name.toLowerCase() === normalizedName)
if (exactMatch) {
return exactMatch
}
const shortNameMatches = skills.filter((skill) => {
const parts = skill.name.split("/")
const shortName = parts[parts.length - 1]
return parts.length > 1 && shortName?.toLowerCase() === normalizedName
})
if (shortNameMatches.length === 1) {
return shortNameMatches[0]
}
return undefined
}
export function matchCommandByName(commands: CommandInfo[], requestedName: string): CommandInfo | undefined {
const normalizedName = requestedName.toLowerCase()
return sortByScopePriority(commands).find((command) => command.name.toLowerCase() === normalizedName)
}
export function findPartialMatches(
skills: LoadedSkill[],
commands: CommandInfo[],
requestedName: string
): string[] {
const normalizedName = requestedName.toLowerCase()
return [
...skills.map((skill) => skill.name),
...commands.map((command) => `/${command.name}`),
].filter((name) => name.toLowerCase().includes(normalizedName))
}
+57 -261
View File
@@ -1,257 +1,52 @@
import { dirname } from "node:path"
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
import { TOOL_DESCRIPTION_NO_SKILLS, TOOL_DESCRIPTION_PREFIX } from "./constants"
import type { SkillArgs, SkillInfo, SkillLoadOptions } from "./types"
import type { ToolContext } from "@opencode-ai/plugin/tool"
import { TOOL_DESCRIPTION_PREFIX } from "./constants"
import type { SkillArgs, SkillLoadOptions } from "./types"
import type { LoadedSkill } from "../../features/opencode-skill-loader"
import { getAllSkills, extractSkillTemplate, clearSkillCache } from "../../features/opencode-skill-loader/skill-content"
import { getAllSkills, clearSkillCache } from "../../features/opencode-skill-loader/skill-content"
import { injectGitMasterConfig } from "../../features/opencode-skill-loader/skill-content"
import type { SkillMcpManager, SkillMcpClientInfo, SkillMcpServerContext } from "../../features/skill-mcp-manager"
import type { Tool, Resource, Prompt } from "@modelcontextprotocol/sdk/types.js"
import { sanitizeJsonSchema } from "../../plugin/normalize-tool-arg-schemas"
import { discoverCommandsSync } from "../slashcommand/command-discovery"
import type { CommandInfo } from "../slashcommand/types"
import { formatLoadedCommand } from "../slashcommand/command-output-formatter"
type NativeSkillEntry = {
name: string
description: string
location: string
content: string
}
// Priority: project > user > opencode/opencode-project > builtin/config
const scopePriority: Record<string, number> = {
project: 4,
user: 3,
opencode: 2,
"opencode-project": 2,
plugin: 1,
config: 1,
builtin: 1,
}
function loadedSkillToInfo(skill: LoadedSkill): SkillInfo {
return {
name: skill.name,
description: skill.definition.description || "",
location: skill.path,
scope: skill.scope,
license: skill.license,
compatibility: skill.compatibility,
metadata: skill.metadata,
allowedTools: skill.allowedTools,
}
}
function nativeSkillToLoadedSkill(native: NativeSkillEntry): LoadedSkill {
return {
name: native.name,
path: native.location,
definition: {
name: native.name,
description: native.description,
template: native.content,
},
scope: "config",
}
}
function mergeNativeSkills(skills: LoadedSkill[], nativeSkills: NativeSkillEntry[]): void {
const knownNames = new Set(skills.map(skill => skill.name))
for (const native of nativeSkills) {
if (knownNames.has(native.name)) continue
skills.push(nativeSkillToLoadedSkill(native))
knownNames.add(native.name)
}
}
function mergeNativeSkillInfos(skillInfos: SkillInfo[], nativeSkills: NativeSkillEntry[]): void {
const knownNames = new Set(skillInfos.map(skill => skill.name))
for (const native of nativeSkills) {
if (knownNames.has(native.name)) continue
skillInfos.push({
name: native.name,
description: native.description,
location: native.location,
scope: "config",
})
knownNames.add(native.name)
}
}
function isPromiseLike<T>(value: T | Promise<T>): value is Promise<T> {
return typeof value === "object" && value !== null && "then" in value
}
function formatCombinedDescription(skills: SkillInfo[], commands: CommandInfo[]): string {
const lines: string[] = []
if (skills.length === 0 && commands.length === 0) {
return TOOL_DESCRIPTION_NO_SKILLS
}
// Uses module-level scopePriority for consistent priority ordering
const allItems: string[] = []
// Skills rendered as command items (skills are also slash-invocable)
if (skills.length > 0) {
const sortedSkills = [...skills].sort((a, b) => {
const priorityA = scopePriority[a.scope] || 0
const priorityB = scopePriority[b.scope] || 0
return priorityB - priorityA
})
sortedSkills.forEach(skill => {
const parts = [
" <command>",
` <name>/${skill.name}</name>`,
` <description>${skill.description}</description>`,
` <scope>${skill.scope}</scope>`,
]
if (skill.compatibility) {
parts.push(` <compatibility>${skill.compatibility}</compatibility>`)
}
parts.push(" </command>")
allItems.push(parts.join("\n"))
})
}
// Sort and add commands second (commands after skills)
if (commands.length > 0) {
const sortedCommands = [...commands].sort((a, b) => {
const priorityA = scopePriority[a.scope] || 0
const priorityB = scopePriority[b.scope] || 0
return priorityB - priorityA // Higher priority first
})
sortedCommands.forEach(cmd => {
const hint = cmd.metadata.argumentHint ? ` ${cmd.metadata.argumentHint}` : ""
const parts = [
" <command>",
` <name>/${cmd.name}</name>`,
` <description>${cmd.metadata.description || "(no description)"}</description>`,
` <scope>${cmd.scope}</scope>`,
]
if (hint) {
parts.push(` <argument>${hint.trim()}</argument>`)
}
parts.push(" </command>")
allItems.push(parts.join("\n"))
})
}
if (allItems.length > 0) {
lines.push(`\n<available_items>\nPriority: project > user > opencode > builtin/plugin | Skills listed before commands\nInvoke via: skill(name="item-name") — omit leading slash for commands.\n${allItems.join("\n")}\n</available_items>`)
}
return TOOL_DESCRIPTION_PREFIX + lines.join("")
}
async function extractSkillBody(skill: LoadedSkill): Promise<string> {
if (skill.lazyContent) {
const fullTemplate = await skill.lazyContent.load()
const templateMatch = fullTemplate.match(/<skill-instruction>([\s\S]*?)<\/skill-instruction>/)
return templateMatch ? templateMatch[1].trim() : fullTemplate
}
if (skill.scope === "config" && skill.definition.template) {
const templateMatch = skill.definition.template.match(/<skill-instruction>([\s\S]*?)<\/skill-instruction>/)
return templateMatch ? templateMatch[1].trim() : skill.definition.template
}
if (skill.path) {
return extractSkillTemplate(skill)
}
const templateMatch = skill.definition.template?.match(/<skill-instruction>([\s\S]*?)<\/skill-instruction>/)
return templateMatch ? templateMatch[1].trim() : skill.definition.template || ""
}
async function formatMcpCapabilities(
skill: LoadedSkill,
manager: SkillMcpManager,
sessionID: string
): Promise<string | null> {
if (!skill.mcpConfig || Object.keys(skill.mcpConfig).length === 0) {
return null
}
const sections: string[] = ["", "## Available MCP Servers", ""]
for (const [serverName, config] of Object.entries(skill.mcpConfig)) {
const info: SkillMcpClientInfo = {
serverName,
skillName: skill.name,
sessionID,
}
const context: SkillMcpServerContext = {
config,
skillName: skill.name,
}
sections.push(`### ${serverName}`)
sections.push("")
try {
const [tools, resources, prompts] = await Promise.all([
manager.listTools(info, context).catch(() => []),
manager.listResources(info, context).catch(() => []),
manager.listPrompts(info, context).catch(() => []),
])
if (tools.length > 0) {
sections.push("**Tools:**")
sections.push("")
for (const t of tools as Tool[]) {
sections.push(`#### \`${t.name}\``)
if (t.description) {
sections.push(t.description)
}
sections.push("")
sections.push("**inputSchema:**")
sections.push("```json")
sections.push(JSON.stringify(sanitizeJsonSchema(t.inputSchema), null, 2))
sections.push("```")
sections.push("")
}
}
if (resources.length > 0) {
sections.push(`**Resources**: ${resources.map((r: Resource) => r.uri).join(", ")}`)
}
if (prompts.length > 0) {
sections.push(`**Prompts**: ${prompts.map((p: Prompt) => p.name).join(", ")}`)
}
if (tools.length === 0 && resources.length === 0 && prompts.length === 0) {
sections.push("*No capabilities discovered*")
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
sections.push(`*Failed to connect: ${errorMessage.split("\n")[0]}*`)
}
sections.push("")
sections.push(`Use \`skill_mcp\` tool with \`mcp_name="${serverName}"\` to invoke.`)
sections.push("")
}
return sections.join("\n")
}
import { formatCombinedDescription } from "./description-formatter"
import { formatMcpCapabilities } from "./mcp-capability-formatter"
import {
findPartialMatches,
matchCommandByName,
matchSkillByName,
} from "./skill-matcher"
import { extractSkillBody } from "./skill-body"
import {
isPromiseLike,
loadedSkillToInfo,
mergeNativeSkillInfos,
mergeNativeSkills,
} from "./native-skills"
export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition {
let cachedDescription: string | null = null
const getSkills = async (): Promise<LoadedSkill[]> => {
clearSkillCache()
const discovered = await getAllSkills({disabledSkills: options?.disabledSkills, browserProvider: options?.browserProvider})
const discovered = await getAllSkills({
disabledSkills: options?.disabledSkills,
browserProvider: options?.browserProvider,
})
const allSkills = !options.skills
? discovered
: [...discovered, ...options.skills.filter(s => !new Set(discovered.map(d => d.name)).has(s.name))]
: [
...discovered,
...options.skills.filter(
(skill) => !new Set(discovered.map((discoveredSkill) => discoveredSkill.name)).has(skill.name)
),
]
if (options.nativeSkills) {
try {
const nativeAll = await options.nativeSkills.all()
mergeNativeSkills(allSkills, nativeAll)
} catch {
// Native skill discovery may not be available
}
}
@@ -265,8 +60,8 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
})
}
const buildDescription = async (): Promise<string> => {
if (cachedDescription) return cachedDescription
const buildDescription = async (force = false): Promise<string> => {
if (!force && cachedDescription) return cachedDescription
const skills = await getSkills()
const commands = getCommands()
const skillInfos = skills.map(loadedSkillToInfo)
@@ -288,13 +83,12 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
mergeNativeSkillInfos(skillInfos, nativeAll)
}
} catch {
// Native skill discovery may not be available
}
}
cachedDescription = formatCombinedDescription(skillInfos, commandsForDescription)
if (needsAsyncRefresh) {
void buildDescription()
void buildDescription(true)
}
} else if (options.commands !== undefined) {
cachedDescription = formatCombinedDescription([], options.commands)
@@ -310,23 +104,30 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
return cachedDescription ?? TOOL_DESCRIPTION_PREFIX
},
args: {
name: tool.schema.string().describe("The skill or command name (e.g., 'code-review' or 'publish'). Use without leading slash for commands."),
name: tool.schema.string().describe("The skill or command name (e.g., 'review-work' or 'publish'). Use without leading slash for commands."),
user_message: tool.schema
.string()
.optional()
.describe("Optional arguments or context for command invocation. Example: name='publish', user_message='patch'"),
},
async execute(args: SkillArgs, ctx?: { agent?: string }) {
async execute(args: SkillArgs, ctx?: ToolContext) {
const skills = await getSkills()
const commands = getCommands()
cachedDescription = formatCombinedDescription(skills.map(loadedSkillToInfo), commands)
const requestedName = args.name.replace(/^\//, "")
// Check skills first (exact match, case-insensitive)
const matchedSkill = skills.find(s => s.name.toLowerCase() === requestedName.toLowerCase())
const matchedSkill = matchSkillByName(skills, requestedName)
if (matchedSkill) {
await ctx?.ask({
permission: "skill",
patterns: [matchedSkill.name],
always: [matchedSkill.name],
metadata: {
skill: matchedSkill.name,
},
})
if (matchedSkill.definition.agent && (!ctx?.agent || matchedSkill.definition.agent !== ctx.agent)) {
throw new Error(`Skill "${matchedSkill.name}" is restricted to agent "${matchedSkill.definition.agent}"`)
}
@@ -347,11 +148,17 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
body,
]
if (options.mcpManager && options.getSessionID && matchedSkill.mcpConfig) {
if (options.mcpManager && matchedSkill.mcpConfig) {
const sessionID = ctx?.sessionID || options.getSessionID?.()
if (!sessionID) {
return output.join("\n")
}
const mcpInfo = await formatMcpCapabilities(
matchedSkill,
options.mcpManager,
options.getSessionID()
sessionID
)
if (mcpInfo) {
output.push(mcpInfo)
@@ -361,27 +168,13 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
return output.join("\n")
}
// Check commands (exact match, case-insensitive) - sort by priority first
const sortedCommands = [...commands].sort((a, b) => {
const priorityA = scopePriority[a.scope] || 0
const priorityB = scopePriority[b.scope] || 0
return priorityB - priorityA // Higher priority first
})
const matchedCommand = sortedCommands.find(c => c.name.toLowerCase() === requestedName.toLowerCase())
const matchedCommand = matchCommandByName(commands, requestedName)
if (matchedCommand) {
return await formatLoadedCommand(matchedCommand, args.user_message)
}
// No match found — provide helpful error with partial matches
const allNames = [
...skills.map(s => s.name),
...commands.map(c => `/${c.name}`),
]
const partialMatches = allNames.filter(n =>
n.toLowerCase().includes(requestedName.toLowerCase())
)
const partialMatches = findPartialMatches(skills, commands, requestedName)
if (partialMatches.length > 0) {
throw new Error(
@@ -389,7 +182,10 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
)
}
const available = allNames.join(", ")
const available = [
...skills.map((skill) => skill.name),
...commands.map((command) => `/${command.name}`),
].join(", ")
throw new Error(
`Skill or command "${args.name}" not found. Available: ${available || "none"}`
)
+1 -1
View File
@@ -29,7 +29,7 @@ export interface SkillLoadOptions {
/** MCP manager for querying skill-embedded MCP servers */
mcpManager?: SkillMcpManager
/** Session ID getter for MCP client identification */
getSessionID?: () => string
getSessionID?: () => string | undefined
/** Git master configuration for watermark/co-author settings */
gitMasterConfig?: GitMasterConfig
disabledSkills?: Set<string>
@@ -1,26 +1,32 @@
import { afterAll, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"
import type { ToolContext } from "@opencode-ai/plugin/tool"
import * as fs from "node:fs"
import { createSkillTool } from "./tools"
import { SkillMcpManager } from "../../features/skill-mcp-manager"
import type { LoadedSkill } from "../../features/opencode-skill-loader/types"
import type { CommandInfo } from "../slashcommand/types"
import { SkillMcpManager } from "../../../features/skill-mcp-manager"
import type { LoadedSkill } from "../../../features/opencode-skill-loader/types"
import type { CommandInfo } from "../../slashcommand/types"
import type { Tool as McpTool } from "@modelcontextprotocol/sdk/types.js"
const originalReadFileSync = fs.readFileSync.bind(fs)
mock.module("node:fs", () => ({
...fs,
readFileSync: (path: string, encoding?: string) => {
if (typeof path === "string" && path.includes("/skills/")) {
return `---
let createSkillTool: typeof import("../tools").createSkillTool
beforeEach(async () => {
mock.module("node:fs", () => ({
...fs,
readFileSync: (path: string, encoding?: string) => {
if (typeof path === "string" && path.includes("/skills/")) {
return `---
description: Test skill description
---
Test skill body content`
}
return originalReadFileSync(path, encoding as BufferEncoding)
},
}))
}
return originalReadFileSync(path, encoding as BufferEncoding)
},
}))
const module = await import("../tools")
createSkillTool = module.createSkillTool
})
afterAll(() => {
mock.restore()
@@ -121,6 +127,32 @@ describe("skill tool - agent restriction", () => {
expect(result).toContain("public-skill")
})
it("requests host skill permission before loading the skill", async () => {
// given
const loadedSkills = [createMockSkill("review-work")]
const askCalls: Array<Parameters<ToolContext["ask"]>[0]> = []
const tool = createSkillTool({ skills: loadedSkills })
const context: ToolContext = {
...mockContext,
ask: async (input) => {
askCalls.push(input)
},
}
// when
await tool.execute({ name: "review-work" }, context)
// then
expect(askCalls).toEqual([
{
permission: "skill",
patterns: ["review-work"],
always: ["review-work"],
metadata: { skill: "review-work" },
},
])
})
it("allows skill when agent matches restriction", async () => {
// given
const loadedSkills = [createMockSkill("restricted-skill", { agent: "sisyphus" })]
@@ -141,7 +173,7 @@ describe("skill tool - agent restriction", () => {
const context = { ...mockContext, agent: "oracle" }
// when / #then
await expect(tool.execute({ name: "sisyphus-only-skill" }, context)).rejects.toThrow(
return expect(tool.execute({ name: "sisyphus-only-skill" }, context)).rejects.toThrow(
'Skill "sisyphus-only-skill" is restricted to agent "sisyphus"'
)
})
@@ -153,7 +185,7 @@ describe("skill tool - agent restriction", () => {
const contextWithoutAgent = { ...mockContext, agent: undefined as unknown as string }
// when / #then
await expect(tool.execute({ name: "sisyphus-only-skill" }, contextWithoutAgent)).rejects.toThrow(
return expect(tool.execute({ name: "sisyphus-only-skill" }, contextWithoutAgent)).rejects.toThrow(
'Skill "sisyphus-only-skill" is restricted to agent "sisyphus"'
)
})
@@ -172,6 +204,34 @@ describe("skill tool - MCP schema display", () => {
})
describe("formatMcpCapabilities with inputSchema", () => {
it("uses the tool context sessionID when the fallback getter is empty", async () => {
// given
loadedSkills = [
createMockSkillWithMcp("test-skill", {
playwright: { command: "npx", args: ["-y", "@anthropic-ai/mcp-playwright"] },
}),
]
const listToolsSpy = spyOn(manager, "listTools").mockResolvedValue([])
spyOn(manager, "listResources").mockResolvedValue([])
spyOn(manager, "listPrompts").mockResolvedValue([])
const tool = createSkillTool({
skills: loadedSkills,
mcpManager: manager,
getSessionID: () => "",
})
// when
await tool.execute({ name: "test-skill" }, mockContext)
// then
expect(listToolsSpy).toHaveBeenCalledWith(
expect.objectContaining({ sessionID: mockContext.sessionID }),
expect.any(Object),
)
})
it("displays tool inputSchema when available", async () => {
// given
const mockToolsWithSchema: McpTool[] = [
@@ -533,6 +593,7 @@ describe("skill tool - dynamic description cache invalidation", () => {
// Get initial description - it will build from empty or disk skills
const initialDescription = tool.description
expect(initialDescription).toBeString()
// when: execute() is called, which clears cache AND gets fresh skills
// Note: In real scenario, execute() would discover new skills from disk
@@ -670,3 +731,58 @@ describe("skill tool - nativeSkills integration", () => {
expect(result).toContain("External plugin skill body")
})
})
describe("skill tool - short name resolution", () => {
it("resolves namespaced skill by short name when unambiguous", async () => {
// given
const loadedSkills = [createMockSkill("superpowers/systematic-debugging")]
const tool = createSkillTool({ skills: loadedSkills })
// when
const result = await tool.execute({ name: "systematic-debugging" }, mockContext)
// then
expect(result).toContain("superpowers/systematic-debugging")
})
it("still resolves by exact full name", async () => {
// given
const loadedSkills = [createMockSkill("superpowers/systematic-debugging")]
const tool = createSkillTool({ skills: loadedSkills })
// when
const result = await tool.execute({ name: "superpowers/systematic-debugging" }, mockContext)
// then
expect(result).toContain("superpowers/systematic-debugging")
})
it("does not resolve short name when ambiguous (multiple matches)", async () => {
// given
const loadedSkills = [
createMockSkill("superpowers/debugging"),
createMockSkill("utils/debugging"),
]
const tool = createSkillTool({ skills: loadedSkills })
// when / then, should not resolve (ambiguous), should suggest both
return expect(tool.execute({ name: "debugging" }, mockContext)).rejects.toThrow(
"not found"
)
})
it("prefers exact match over short name match", async () => {
// given, "debugging" exists as both exact and as part of a namespace
const loadedSkills = [
createMockSkill("debugging"),
createMockSkill("superpowers/debugging"),
]
const tool = createSkillTool({ skills: loadedSkills })
// when
const result = await tool.execute({ name: "debugging" }, mockContext)
// then, should match "debugging" exactly, not "superpowers/debugging"
expect(result).toContain("## Skill: debugging")
})
})
@@ -2,7 +2,18 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { discoverCommandsSync } from "./command-discovery"
function requireFresh<T>(modulePath: string): T {
const resolvedPath = require.resolve(modulePath)
if (require.cache?.[resolvedPath]) {
delete require.cache[resolvedPath]
}
return require(modulePath) as T
}
function discoverCommandsSync(...args: Parameters<typeof import("./command-discovery").discoverCommandsSync>): ReturnType<typeof import("./command-discovery").discoverCommandsSync> {
return requireFresh<typeof import("./command-discovery")>("./command-discovery").discoverCommandsSync(...args)
}
const ENV_KEYS = [
"CLAUDE_CONFIG_DIR",
@@ -255,4 +266,64 @@ Use nested command.
expect(nestedCommand?.content).toContain("Use nested command.")
expect(nestedCommand?.scope).toBe("opencode-project")
})
it("keeps builtin start-work routed to Atlas during static discovery", () => {
// given
// when
const commands = discoverCommandsSync(projectDir)
const startWorkCommand = commands.find((command) => command.name === "start-work")
// then
expect(startWorkCommand?.metadata.agent).toBe("atlas")
})
})
describe("non-directory commands path", () => {
let testDir: string
let savedEnv: Record<string, string | undefined>
beforeEach(() => {
testDir = mkdtempSync(join(tmpdir(), "omo-cmd-file-"))
savedEnv = {
CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR,
OPENCODE_CONFIG_DIR: process.env.OPENCODE_CONFIG_DIR,
}
process.env.CLAUDE_CONFIG_DIR = join(testDir, "claude-config")
process.env.OPENCODE_CONFIG_DIR = join(testDir, "opencode-config")
mkdirSync(join(testDir, "claude-config"), { recursive: true })
mkdirSync(join(testDir, "opencode-config"), { recursive: true })
})
afterEach(() => {
Object.entries(savedEnv).forEach(([k, v]) => {
if (v === undefined) delete process.env[k]
else process.env[k] = v
})
rmSync(testDir, { recursive: true, force: true })
})
it("#given .claude/commands is a file #when discoverCommandsSync runs #then returns without crashing", () => {
const projectDir = join(testDir, "project")
mkdirSync(join(projectDir, ".claude"), { recursive: true })
writeFileSync(join(projectDir, ".claude", "commands"), "") // file, not directory
// Should not throw
const commands = discoverCommandsSync(projectDir)
expect(commands).toBeInstanceOf(Array)
})
it("#given .claude/commands is a directory #when discoverCommandsSync runs #then discovers commands normally", () => {
const projectDir = join(testDir, "project")
mkdirSync(join(projectDir, ".claude", "commands"), { recursive: true })
writeFileSync(
join(projectDir, ".claude", "commands", "test-cmd.md"),
"---\ndescription: Test\n---\nTest command content.\n",
)
const commands = discoverCommandsSync(projectDir)
const testCmd = commands.find((c) => c.name === "test-cmd")
expect(testCmd).toBeDefined()
expect(testCmd?.content).toContain("Test command content.")
})
})
+6 -2
View File
@@ -1,4 +1,4 @@
import { existsSync, readdirSync, readFileSync } from "fs"
import { existsSync, readdirSync, readFileSync, statSync } from "fs"
import { basename, join } from "path"
import {
parseFrontmatter,
@@ -9,7 +9,7 @@ import {
} from "../../shared"
import type { CommandFrontmatter } from "../../features/claude-code-command-loader/types"
import { isMarkdownFile } from "../../shared/file-utils"
import { getClaudeConfigDir } from "../../shared"
import { getClaudeConfigDir, log } from "../../shared"
import { loadBuiltinCommands } from "../../features/builtin-commands"
import type { CommandInfo, CommandMetadata, CommandScope } from "./types"
@@ -26,6 +26,10 @@ function discoverCommandsFromDir(
prefix = "",
): CommandInfo[] {
if (!existsSync(commandsDir)) return []
if (!statSync(commandsDir).isDirectory()) {
log(`[command-discovery] Skipping non-directory path: ${commandsDir}`)
return []
}
const entries = readdirSync(commandsDir, { withFileTypes: true })
const commands: CommandInfo[] = []
@@ -2,8 +2,22 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { executeSlashCommand } from "../../hooks/auto-slash-command/executor"
import { discoverCommandsSync } from "./command-discovery"
function requireFresh<T>(modulePath: string): T {
const resolvedPath = require.resolve(modulePath)
if (require.cache?.[resolvedPath]) {
delete require.cache[resolvedPath]
}
return require(modulePath) as T
}
function executeSlashCommand(...args: Parameters<typeof import("../../hooks/auto-slash-command/executor").executeSlashCommand>): ReturnType<typeof import("../../hooks/auto-slash-command/executor").executeSlashCommand> {
return requireFresh<typeof import("../../hooks/auto-slash-command/executor")>("../../hooks/auto-slash-command/executor").executeSlashCommand(...args)
}
function discoverCommandsSync(...args: Parameters<typeof import("./command-discovery").discoverCommandsSync>): ReturnType<typeof import("./command-discovery").discoverCommandsSync> {
return requireFresh<typeof import("./command-discovery")>("./command-discovery").discoverCommandsSync(...args)
}
describe("slashcommand discovery and execution compatibility", () => {
let tempDir = ""
@@ -60,4 +74,32 @@ describe("slashcommand discovery and execution compatibility", () => {
expect(result.replacementText).toContain("Execute from parent config.")
expect(result.replacementText).toContain("**Scope**: opencode")
})
it("executes project commands using the provided directory even when cwd differs", async () => {
// given
const projectDir = join(tempDir, "project")
const commandDir = join(projectDir, ".claude", "commands")
const commandName = "project-only-command"
mkdirSync(commandDir, { recursive: true })
writeFileSync(
join(commandDir, `${commandName}.md`),
`---\ndescription: Project command\n---\nExecute from project directory.\n`,
)
process.chdir("/tmp")
expect(discoverCommandsSync(projectDir).some(command => command.name === commandName)).toBe(true)
// when
const result = await executeSlashCommand({
command: commandName,
args: "",
raw: `/${commandName}`,
}, { skills: [], directory: projectDir })
// then
expect(result.success).toBe(true)
expect(result.replacementText).toContain("Execute from project directory.")
expect(result.replacementText).toContain("**Scope**: project")
})
})
@@ -2,7 +2,18 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { discoverCommandsSync } from "./command-discovery"
function requireFresh<T>(modulePath: string): T {
const resolvedPath = require.resolve(modulePath)
if (require.cache?.[resolvedPath]) {
delete require.cache[resolvedPath]
}
return require(modulePath) as T
}
function discoverCommandsSync(...args: Parameters<typeof import("./command-discovery").discoverCommandsSync>): ReturnType<typeof import("./command-discovery").discoverCommandsSync> {
return requireFresh<typeof import("./command-discovery")>("./command-discovery").discoverCommandsSync(...args)
}
function writeCommand(path: string, description: string, body: string): void {
mkdirSync(join(path, ".."), { recursive: true })
+1 -1
View File
@@ -55,7 +55,7 @@ Returns summary format: id, subject, status, owner, blockedBy (not full descript
// Build summary with filtered blockedBy
const summaries: TaskSummary[] = activeTasks.map((task) => {
// Filter blockedBy to only include unresolved (non-completed) blockers
const unresolvedBlockers = task.blockedBy.filter((blockerId) => {
const unresolvedBlockers = task.blockedBy.filter((blockerId: string) => {
const blockerTask = taskMap.get(blockerId)
// Include if blocker doesn't exist (missing) or if it's not completed
return !blockerTask || blockerTask.status !== "completed"

Some files were not shown because too many files have changed in this diff Show More