merge(dev): resolve background-agent delegated fallback conflicts

Reconcile the latest dev branch changes with the delegated child-session fallback work. Preserve the upstream background-agent updates while keeping the delegated bootstrap cleanup and compatibility wiring fixes intact, then re-verify the affected regression suites and typecheck.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
tw-yshuang
2026-05-11 03:02:14 +08:00
668 changed files with 44608 additions and 6160 deletions
+77 -88
View File
@@ -1,108 +1,97 @@
# src/tools/ - 26 Tools Across 16 Directories
# src/tools/ 2039 Tools Across 16 Directories
**Generated:** 2026-04-18
**Generated:** 2026-05-08
## OVERVIEW
26 tools registered via `createToolRegistry()`. Two patterns: factory functions (`createXXXTool`) for 19 tools, direct `ToolDefinition` for 7 (LSP + interactive_bash).
Tools registered via [`createToolRegistry()`](file:///Users/yeongyu/local-workspaces/omo/src/plugin/tool-registry.ts) in `src/plugin/`. Two patterns: factory functions (`createXXXTool`) for most tools, direct `ToolDefinition` exports for the 6 LSP tools and `interactive_bash`. The total exposed count varies between 20 (minimum) and 39 (with all flags on) based on config gates listed below.
## TOOL CATALOG
### Task Management (4)
### Always On (20)
| Tool | Factory | Parameters |
|------|---------|------------|
| `task_create` | `createTaskCreateTool` | subject, description, blockedBy, blocks, metadata, parentID |
| `task_list` | `createTaskList` | (none) |
| `task_get` | `createTaskGetTool` | id |
| `task_update` | `createTaskUpdateTool` | id, subject, description, status, addBlocks, addBlockedBy, owner, metadata |
| Group | Tools |
|-------|-------|
| **LSP** (6) | `lsp_goto_definition`, `lsp_find_references`, `lsp_symbols`, `lsp_diagnostics`, `lsp_prepare_rename`, `lsp_rename` |
| **Search** (4) | `grep`, `glob`, `ast_grep_search`, `ast_grep_replace` |
| **Sessions** (4) | `session_list`, `session_read`, `session_search`, `session_info` |
| **Background tasks** (2) | `background_output`, `background_cancel` |
| **Delegation** (2) | `task` (delegate, full skill+category support), `call_omo_agent` (named agent only: explore, librarian) |
| **Skills/MCP** (2) | `skill` (load skill or invoke command), `skill_mcp` (call skill-embedded MCP tool/resource/prompt) |
### Delegation (1)
### Conditional (up to +19)
| Tool | Factory | Parameters |
|------|---------|------------|
| `task` | `createDelegateTask` | description, prompt, category, subagent_type, run_in_background, session_id, load_skills, command |
| Tool(s) | Gate | Source |
|---------|------|--------|
| `look_at` | not in `disabled_agents` for `multimodal-looker` | `look-at/` |
| `interactive_bash` | `isInteractiveBashEnabled(config)` (tmux config) | `interactive-bash/` |
| `task_create`, `task_get`, `task_list`, `task_update` | `experimental.task_system` | `task/` |
| `edit` (hashline-edit) | `hashline_edit: true` | `hashline-edit/` |
| 12 `team_*` tools | `team_mode.enabled: true` | `../features/team-mode/tools/` |
**8 Built-in Categories**: visual-engineering, ultrabrain, deep, artistry, quick, unspecified-low, unspecified-high, writing
### 12 team_* Tools (when team_mode enabled)
### Agent Invocation (1)
| Tool | Purpose |
|------|---------|
| `team_create` | Spawn team + member sessions from a TeamSpec (named or inline) |
| `team_delete` | Tear down — removes mailbox, tasklist, worktrees, optional tmux layout |
| `team_shutdown_request` | Member or lead requests its own shutdown |
| `team_approve_shutdown` | Lead acks a pending shutdown |
| `team_reject_shutdown` | Lead rejects a shutdown with reason |
| `team_send_message` | Async message to specific member or `*` broadcast |
| `team_task_create` | Create task on shared list |
| `team_task_list` | List tasks (filter by status, owner) |
| `team_task_update` | Claim/complete/delete (atomic file lock) |
| `team_task_get` | Fetch single task |
| `team_status` | Full team run status (members, tasks, mailbox) |
| `team_list` | List declared + active teams |
| Tool | Factory | Parameters |
|------|---------|------------|
| `call_omo_agent` | `createCallOmoAgent` | description, prompt, subagent_type, run_in_background, session_id |
## DELEGATION CATEGORIES (built-in 8)
### Background Tasks (2)
`task` (delegate) selects model by category. Default category models live in provider-specific files under `src/tools/delegate-task/` and aggregate via `BUILTIN_CATEGORIES` in `builtin-categories.ts`. Authoritative fallback chains in [`src/shared/model-requirements.ts`](file:///Users/yeongyu/local-workspaces/omo/src/shared/model-requirements.ts) `CATEGORY_MODEL_REQUIREMENTS`.
| Tool | Factory | Parameters |
|------|---------|------------|
| `background_output` | `createBackgroundOutput` | task_id, block, timeout, full_session, include_thinking, message_limit, since_message_id, thinking_max_chars |
| `background_cancel` | `createBackgroundCancel` | taskId, all |
| Category | Default Model | Source File | Domain |
|----------|---------------|-------------|--------|
| `visual-engineering` | google/gemini-3.1-pro (variant: high) | google-categories.ts | Frontend, UI/UX |
| `ultrabrain` | openai/gpt-5.5 (variant: xhigh) | openai-categories.ts | Hard logic / heavy reasoning |
| `deep` | openai/gpt-5.5 (variant: medium) | openai-categories.ts | Autonomous multi-step problem-solving |
| `artistry` | google/gemini-3.1-pro (variant: high) | google-categories.ts | Creative / unconventional approaches |
| `quick` | openai/gpt-5.4-mini | openai-categories.ts | Trivial single-file changes |
| `unspecified-low` | anthropic/claude-sonnet-4-6 | anthropic-categories.ts | Moderate effort fallback |
| `unspecified-high` | anthropic/claude-opus-4-7 (variant: max) | anthropic-categories.ts | High effort fallback |
| `writing` | kimi-for-coding/k2p5 (default) → gemini-3-flash (first fallback) | kimi-categories.ts | Documentation, prose |
### LSP Refactoring (6) - Direct ToolDefinition
User-defined categories declared in `categories: { ... }` config override and extend this set.
| Tool | Parameters |
|------|------------|
| `lsp_goto_definition` | filePath, line, character |
| `lsp_find_references` | filePath, line, character, includeDeclaration |
| `lsp_symbols` | filePath, scope (document/workspace), query, limit |
| `lsp_diagnostics` | filePath, severity |
| `lsp_prepare_rename` | filePath, line, character |
| `lsp_rename` | filePath, line, character, newName |
## TOOL DIR LAYOUT
### Code Search (4)
```
tools/
├── ast-grep/ # ast_grep_search, ast_grep_replace
├── background-task/ # background_output, background_cancel (LLM interface; engine in features/background-agent)
├── call-omo-agent/ # call_omo_agent (explore + librarian only)
├── delegate-task/ # task — full delegation with categories + skills
├── glob/ # glob (60s timeout, 100 file limit)
├── grep/ # grep (60s timeout, 10MB limit)
├── hashline-edit/ # edit — hash-anchored line edits with LINE#ID validation
├── interactive-bash/ # interactive_bash — tmux session control
├── look-at/ # look_at — image/PDF analysis
├── lsp/ # 6 LSP tools (direct ToolDefinition)
├── session-manager/ # 4 session_* tools
├── skill/ # skill — load skill or run command
├── skill-mcp/ # skill_mcp — call skill-embedded MCP servers
├── slashcommand/ # discoverCommandsSync — feeds skill tool with /-command list
├── task/ # 4 task_* tools (Sisyphus task system)
└── index.ts # barrel exports
```
| Tool | Factory | Parameters |
|------|---------|------------|
| `ast_grep_search` | `createAstGrepTools` | pattern, lang, paths, globs, context |
| `ast_grep_replace` | `createAstGrepTools` | pattern, rewrite, lang, paths, globs, dryRun |
| `grep` | `createGrepTools` | pattern, path, include (60s timeout, 10MB limit) |
| `glob` | `createGlobTools` | pattern, path (60s timeout, 100 file limit) |
## ADDING A NEW TOOL
### Session History (4)
| Tool | Factory | Parameters |
|------|---------|------------|
| `session_list` | `createSessionManagerTools` | (none) |
| `session_read` | `createSessionManagerTools` | session_id, include_todos, limit |
| `session_search` | `createSessionManagerTools` | query, session_id, case_sensitive, limit |
| `session_info` | `createSessionManagerTools` | session_id |
### Skill/Command (2)
| Tool | Factory | Parameters |
|------|---------|------------|
| `skill` | `createSkillTool` | name, user_message |
| `skill_mcp` | `createSkillMcpTool` | mcp_name, tool_name/resource_name/prompt_name, arguments, grep |
### System (2)
| Tool | Factory | Parameters |
|------|---------|------------|
| `interactive_bash` | Direct | tmux_command |
| `look_at` | `createLookAt` | file_path, image_data, goal |
### Editing (1) - Conditional
| Tool | Factory | Parameters |
|------|---------|------------|
| `hashline_edit` | `createHashlineEditTool` | file, edits[] |
## DELEGATION CATEGORIES
| Category | Model | Domain |
|----------|-------|--------|
| visual-engineering | gemini-3.1-pro high | Frontend, UI/UX |
| ultrabrain | gpt-5.5 xhigh | Hard logic |
| deep | gpt-5.5 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-7 max | High effort |
| writing | gemini-3-flash | Documentation |
## HOW TO ADD A TOOL
1. Create `src/tools/{name}/index.ts` exporting factory
2. Create `src/tools/{name}/types.ts` for parameter schemas
3. Create `src/tools/{name}/tools.ts` for implementation
4. Register in `src/plugin/tool-registry.ts`
1. Create `src/tools/{name}/index.ts` with factory `createXXXTool`
2. Add `types.ts` for parameter Zod schemas
3. Add `tools.ts` (or single index.ts) for implementation
4. Export factory from `src/tools/index.ts`
5. Register in `src/plugin/tool-registry.ts`:
- Always-on: spread into `allTools` directly
- Conditional: build a `Record<string, ToolDefinition>` and gate-spread
6. If the tool needs disabling, ensure it appears in `filterDisabledTools` allow-list (its name will be matched against `disabled_tools`)
+1 -1
View File
@@ -1,4 +1,4 @@
import { spawn } from "bun"
import { spawn } from "../../shared/bun-spawn-shim"
import { existsSync } from "fs"
import {
getSgCliPath,
+55
View File
@@ -0,0 +1,55 @@
/// <reference types="bun-types" />
import { beforeEach, describe, expect, it, mock } from "bun:test"
import { AST_GREP_REPLACE_DESCRIPTION, AST_GREP_SEARCH_DESCRIPTION } from "./tool-descriptions"
const runSgMock = mock(async () => ({
matches: [],
totalMatches: 0,
truncated: false,
}))
mock.module("./cli", () => ({
runSg: runSgMock,
}))
import { createAstGrepTools } from "./tools"
describe("createAstGrepTools", () => {
beforeEach(() => {
runSgMock.mockClear()
})
it("#given the production tool factory #when creating tools #then exposes shared ast-grep descriptions", () => {
// given / when
const tools = createAstGrepTools({ directory: "/repo" } as never)
// then
expect(tools.ast_grep_search.description).toBe(AST_GREP_SEARCH_DESCRIPTION)
expect(tools.ast_grep_replace.description).toBe(AST_GREP_REPLACE_DESCRIPTION)
expect(tools.ast_grep_search.description).toContain("NOT regex")
})
it("#given empty search results from a regex-shaped pattern #when executing #then appends the pattern hint", async () => {
// given
const tools = createAstGrepTools({ directory: "/repo" } as never)
// when
const output = await tools.ast_grep_search.execute(
{ pattern: "foo|bar", lang: "typescript" },
{},
)
// then
expect(output).toContain("No matches found")
expect(output).toContain("alternation")
expect(output).toContain("grep")
expect(runSgMock).toHaveBeenCalledWith({
pattern: "foo|bar",
lang: "typescript",
paths: ["/repo"],
globs: undefined,
context: undefined,
})
})
})
+10 -35
View File
@@ -3,6 +3,12 @@ import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
import { CLI_LANGUAGES } from "./constants"
import { runSg } from "./cli"
import { formatSearchResult, formatReplaceResult } from "./result-formatter"
import { getPatternHint } from "./pattern-hints"
import {
AST_GREP_REPLACE_DESCRIPTION,
AST_GREP_SEARCH_DESCRIPTION,
AST_GREP_SEARCH_PATTERN_PARAM,
} from "./tool-descriptions"
import type { CliLanguage } from "./types"
async function showOutputToUser(context: unknown, output: string): Promise<void> {
@@ -12,39 +18,11 @@ async function showOutputToUser(context: unknown, output: string): Promise<void>
await ctx.metadata?.({ metadata: { output } })
}
function getEmptyResultHint(pattern: string, lang: CliLanguage): string | null {
const src = pattern.trim()
if (lang === "python") {
if (src.startsWith("class ") && src.endsWith(":")) {
const withoutColon = src.slice(0, -1)
return `Hint: Remove trailing colon. Try: "${withoutColon}"`
}
if ((src.startsWith("def ") || src.startsWith("async def ")) && src.endsWith(":")) {
const withoutColon = src.slice(0, -1)
return `Hint: Remove trailing colon. Try: "${withoutColon}"`
}
}
if (["javascript", "typescript", "tsx"].includes(lang)) {
if (/^(export\s+)?(async\s+)?function\s+\$[A-Z_]+\s*$/i.test(src)) {
return `Hint: Function patterns need params and body. Try "function $NAME($$$) { $$$ }"`
}
}
return null
}
export function createAstGrepTools(ctx: PluginInput): Record<string, ToolDefinition> {
const ast_grep_search: ToolDefinition = tool({
description:
"Search code patterns across filesystem using AST-aware matching. Supports 25 languages. " +
"Use meta-variables: $VAR (single node), $$$ (multiple nodes). " +
"IMPORTANT: Patterns must be complete AST nodes (valid code). " +
"For functions, include params and body: 'export async function $NAME($$$) { $$$ }' not 'export async function $NAME'. " +
"Examples: 'console.log($MSG)', 'def $FUNC($$$):', 'async function $NAME($$$)'",
description: AST_GREP_SEARCH_DESCRIPTION,
args: {
pattern: tool.schema.string().describe("AST pattern with meta-variables ($VAR, $$$). Must be complete AST node."),
pattern: tool.schema.string().describe(AST_GREP_SEARCH_PATTERN_PARAM),
lang: tool.schema.enum(CLI_LANGUAGES).describe("Target language"),
paths: tool.schema.array(tool.schema.string()).optional().describe("Paths to search (default: ['.'])"),
globs: tool.schema.array(tool.schema.string()).optional().describe("Include/exclude globs (prefix ! to exclude)"),
@@ -63,7 +41,7 @@ export function createAstGrepTools(ctx: PluginInput): Record<string, ToolDefinit
let output = formatSearchResult(result)
if (result.matches.length === 0 && !result.error) {
const hint = getEmptyResultHint(args.pattern, args.lang as CliLanguage)
const hint = getPatternHint(args.pattern, args.lang as CliLanguage)
if (hint) {
output += `\n\n${hint}`
}
@@ -80,10 +58,7 @@ export function createAstGrepTools(ctx: PluginInput): Record<string, ToolDefinit
})
const ast_grep_replace: ToolDefinition = tool({
description:
"Replace code patterns across filesystem with AST-aware rewriting. " +
"Dry-run by default. Use meta-variables in rewrite to preserve matched content. " +
"Example: pattern='console.log($MSG)' rewrite='logger.info($MSG)'",
description: AST_GREP_REPLACE_DESCRIPTION,
args: {
pattern: tool.schema.string().describe("AST pattern to match"),
rewrite: tool.schema.string().describe("Replacement pattern (can use $VAR from pattern)"),
+1 -1
View File
@@ -1,6 +1,6 @@
# src/tools/background-task/ — Background Task Tool Wrappers
**Generated:** 2026-04-11
**Generated:** 2026-05-08
## OVERVIEW
@@ -17,7 +17,17 @@ const mockContext = {
abort: new AbortController().signal,
metadata: () => {},
ask: async () => {},
} as unknown as ToolContext
$: () => {
const result = { stdout: Buffer.from(""), stderr: Buffer.from(""), exitCode: 0 }
const promise = Promise.resolve(result) as Promise<typeof result> & {
quiet: () => Promise<typeof result>
nothrow: () => typeof promise
}
promise.quiet = () => promise
promise.nothrow = () => promise
return promise
},
} as ToolContext
function createTask(overrides: Partial<BackgroundTask> = {}): BackgroundTask {
return {
@@ -65,4 +65,46 @@ describe("createBackgroundOutput metadata", () => {
clearPendingStore()
})
test("explains when a session id is passed as the background task id", async () => {
// #given
const task: BackgroundTask = {
id: "bg-real-task",
sessionId: "ses-child-task",
parentSessionId: "main-1",
parentMessageId: "msg-1",
description: "background task",
prompt: "do work",
agent: "test-agent",
status: "completed",
}
const manager: BackgroundOutputManager = {
getTask: id => (id === task.id ? task : undefined),
}
const client: BackgroundOutputClient = {
session: {
messages: async () => ({ data: [] }),
},
}
const tool = createBackgroundOutput(manager, client)
const context = {
sessionID: "test-session",
messageID: "test-message",
agent: "test-agent",
directory: projectDir,
worktree: projectDir,
abort: new AbortController().signal,
metadata: () => {},
ask: async () => {},
callID: "call-1",
} satisfies ToolContextWithCallID
// #when
const output = await tool.execute({ task_id: "ses-child-task" }, context)
// #then
expect(output).toContain("background_output expects a background task ID")
expect(output).toContain("bg_")
expect(output).toContain('session_read(session_id="ses-child-task")')
})
})
@@ -36,6 +36,22 @@ function appendTimeoutNote(output: string, timeoutMs: number): string {
return `${output}\n\n> **Timed out waiting** after ${timeoutMs}ms. Task is still running; showing latest available output.`
}
function isSessionId(value: string): boolean {
return /^ses[_-]/.test(value)
}
function formatTaskNotFoundMessage(taskId: string): string {
if (!isSessionId(taskId)) {
return `Task not found: ${taskId}`
}
return `Task not found: ${taskId}
background_output expects a background task ID such as \`bg_...\`, not a session ID.
Use the \`background_task_id\` / \`Background Task ID\` from the task launch output or completion notification.
To inspect this session directly, use \`session_read(session_id="${taskId}")\`, \`session_info\`, or \`session_search\`.`
}
export function createBackgroundOutput(manager: BackgroundOutputManager, client: BackgroundOutputClient): ToolDefinition {
return tool({
description: BACKGROUND_OUTPUT_DESCRIPTION,
@@ -60,7 +76,7 @@ export function createBackgroundOutput(manager: BackgroundOutputManager, client:
const ctx = toolContext as ToolContextWithMetadata
const task = manager.getTask(args.task_id)
if (!task) {
return `Task not found: ${args.task_id}`
return formatTaskNotFoundMessage(args.task_id)
}
const meta = {
+1 -1
View File
@@ -1,6 +1,6 @@
# src/tools/call-omo-agent/ — Direct Agent Invocation Tool
**Generated:** 2026-04-11
**Generated:** 2026-05-08
## OVERVIEW
@@ -7,13 +7,13 @@ import { executeBackgroundAgent } from "./background-agent-executor"
describe("executeBackgroundAgent", () => {
const launchMock = mock(async (): Promise<{
id: string
sessionID: string | null
sessionId: string | null
description: string
agent: string
status: string
}> => ({
id: "test-task-id",
sessionID: null,
sessionId: null,
description: "Test task",
agent: "test-agent",
status: "pending",
@@ -23,7 +23,7 @@ describe("executeBackgroundAgent", () => {
const mockManager = {
launch: launchMock,
getTask: getTaskMock,
} as unknown as BackgroundManager
} as BackgroundManager
const testContext = {
sessionID: "test-session",
@@ -43,20 +43,20 @@ describe("executeBackgroundAgent", () => {
session: {
messages: mock(() => Promise.resolve({ data: [] })),
},
} as unknown as PluginInput["client"]
} as PluginInput["client"]
test("detects interrupted task as failure", async () => {
//#given
launchMock.mockResolvedValueOnce({
id: "test-task-id",
sessionID: null,
sessionId: null,
description: "Test task",
agent: "test-agent",
status: "pending",
})
getTaskMock.mockReturnValueOnce({
id: "test-task-id",
sessionID: null,
sessionId: null,
description: "Test task",
agent: "test-agent",
status: "interrupt",
@@ -76,14 +76,14 @@ describe("executeBackgroundAgent", () => {
const abortController = new AbortController()
launchMock.mockResolvedValueOnce({
id: "test-task-id",
sessionID: null,
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" }
return { id: "test-task-id", sessionId: null, description: "Test task", agent: "test-agent", status: "pending" }
})
//#when
@@ -108,15 +108,15 @@ describe("executeBackgroundAgent", () => {
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" }],
["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" }
? { 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)
@@ -126,8 +126,8 @@ describe("executeBackgroundAgent", () => {
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" }
? { id: taskID, sessionId: state.sessionId, description: "Task", agent: "test-agent", status: "pending" }
: { id: taskID, sessionId: null, description: "Task", agent: "test-agent", status: "pending" }
})
//#when
@@ -7,13 +7,13 @@ import { executeBackground } from "./background-executor"
describe("executeBackground", () => {
const launchMock = mock(async (_input?: { fallbackChain?: unknown }): Promise<{
id: string
sessionID: string | null
sessionId: string | null
description: string
agent: string
status: string
}> => ({
id: "test-task-id",
sessionID: null,
sessionId: null,
description: "Test task",
agent: "test-agent",
status: "pending",
@@ -23,7 +23,7 @@ describe("executeBackground", () => {
const mockManager = {
launch: launchMock,
getTask: getTaskMock,
} as unknown as BackgroundManager
} as BackgroundManager
const testContext = {
sessionID: "test-session",
@@ -43,20 +43,20 @@ describe("executeBackground", () => {
session: {
messages: mock(() => Promise.resolve({ data: [] })),
},
} as unknown as PluginInput["client"]
} as PluginInput["client"]
test("detects interrupted task as failure", async () => {
//#given
launchMock.mockResolvedValueOnce({
id: "test-task-id",
sessionID: null,
sessionId: null,
description: "Test task",
agent: "test-agent",
status: "pending",
})
getTaskMock.mockReturnValueOnce({
id: "test-task-id",
sessionID: null,
sessionId: null,
description: "Test task",
agent: "test-agent",
status: "interrupt",
@@ -79,7 +79,7 @@ describe("executeBackground", () => {
]
launchMock.mockResolvedValueOnce({
id: "test-task-id",
sessionID: "sub-session",
sessionId: "sub-session",
description: "Test task",
agent: "test-agent",
status: "pending",
@@ -100,19 +100,48 @@ describe("executeBackground", () => {
expect(launchArgs.fallbackChain).toEqual(fallbackChain)
})
test("sanitizes subagent_type before passing to background manager launch", async () => {
//#given
const wrappedArgs = {
...testArgs,
subagent_type: "\\hephaestus\\",
}
launchMock.mockResolvedValueOnce({
id: "test-task-id",
sessionId: "sub-session",
description: "Test task",
agent: "hephaestus",
status: "pending",
})
//#when
await executeBackground(wrappedArgs, testContext, mockManager, mockClient)
//#then
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.agent).toBe("hephaestus")
})
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,
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" }
return { id: "test-task-id", sessionId: null, description: "Test task", agent: "test-agent", status: "pending" }
})
//#when
@@ -137,15 +166,15 @@ describe("executeBackground", () => {
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" }],
["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" }
? { 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)
@@ -155,8 +184,8 @@ describe("executeBackground", () => {
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" }
? { id: taskID, sessionId: state.sessionId, description: "Task", agent: "test-agent", status: "pending" }
: { id: taskID, sessionId: null, description: "Task", agent: "test-agent", status: "pending" }
})
//#when
@@ -8,6 +8,7 @@ import { resolveMessageContext } from "../../features/hook-message-injector"
import { getSessionAgent } from "../../features/claude-code-session-state"
import { getMessageDir } from "./message-dir"
import { getSessionTools } from "../../shared/session-tools-store"
import { sanitizeSubagentType } from "../delegate-task/subagent-discovery"
export async function executeBackground(
args: CallOmoAgentArgs,
@@ -47,7 +48,7 @@ export async function executeBackground(
const task = await manager.launch({
description: args.description,
prompt: args.prompt,
agent: args.subagent_type,
agent: sanitizeSubagentType(args.subagent_type),
parentSessionId: toolContext.sessionID,
parentMessageId: toolContext.messageID,
parentAgent,
@@ -21,7 +21,7 @@ function createMockCtx(agents: Array<{ name: string; mode?: string }> = []): Plu
},
},
directory: "/test",
} as unknown as PluginInput
}
}
const DEFAULT_AGENTS = [
@@ -103,12 +103,12 @@ describe("createCallOmoAgent edge cases", () => {
reserveSubagentSpawn: reserveSubagentSpawnMock,
launch: mock(() => Promise.resolve({
id: "task-id",
sessionID: "ses-1",
sessionId: "ses-1",
description: "Test",
agent: "bug-fixer",
status: "pending",
})),
getTask: mock(() => ({ status: "pending", sessionID: "ses-1" })),
getTask: mock(() => ({ status: "pending", sessionId: "ses-1" })),
}
const toolDef = createCallOmoAgent(mockCtx, mockManager, [])
const executeFunc = toolDef.execute as Function
@@ -139,12 +139,12 @@ describe("createCallOmoAgent edge cases", () => {
reserveSubagentSpawn: reserveSubagentSpawnMock,
launch: mock(() => Promise.resolve({
id: "task-id",
sessionID: "ses-1",
sessionId: "ses-1",
description: "Test",
agent: "explore",
status: "pending",
})),
getTask: mock(() => ({ status: "pending", sessionID: "ses-1" })),
getTask: mock(() => ({ status: "pending", sessionId: "ses-1" })),
}
const toolDef = createCallOmoAgent(mockCtx, mockManager, [])
const executeFunc = toolDef.execute as Function
+23 -10
View File
@@ -18,7 +18,7 @@ function createMockCtx(agents: Array<{ name: string; mode?: string }> = []): Plu
},
},
directory: "/test",
} as unknown as PluginInput
}
}
function createFailingMockCtx(error: Error = new Error("API unavailable")): PluginInput {
@@ -29,7 +29,7 @@ function createFailingMockCtx(error: Error = new Error("API unavailable")): Plug
},
},
directory: "/test",
} as unknown as PluginInput
}
}
const DEFAULT_AGENTS = [
@@ -57,13 +57,13 @@ const mockBackgroundManager = {
reserveSubagentSpawn: reserveSubagentSpawnMock,
launch: mock(() => Promise.resolve({
id: "test-task-id",
sessionID: null,
sessionId: null,
description: "Test task",
agent: "test-agent",
status: "pending",
})),
getTask: mock(() => ({ status: "pending", sessionID: "ses-123" })),
} as unknown as BackgroundManager
getTask: mock(() => ({ status: "pending", sessionId: "ses-123" })),
} as BackgroundManager
const toolCtx = {
sessionID: "test",
@@ -136,6 +136,19 @@ describe("createCallOmoAgent", () => {
})
describe("dynamic custom agent resolution", () => {
test("should reject missing subagent_type without throwing", async () => {
const mockCtx = createMockCtx(DEFAULT_AGENTS)
const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, [])
const executeFunc = toolDef.execute as Function
const result = await executeFunc(
{ description: "Test", prompt: "Fix bug", run_in_background: true },
toolCtx
)
expect(result).toContain("subagent_type is required")
})
test("should accept a custom agent returned by client.app.agents()", async () => {
const agents = [...DEFAULT_AGENTS, { name: "bug-fixer", mode: "subagent" }]
const mockCtx = createMockCtx(agents)
@@ -240,7 +253,7 @@ describe("createCallOmoAgent", () => {
//#given
const launch = mock((_input: { fallbackChain?: Array<{ providers: string[]; model: string; variant?: string }> }) => Promise.resolve({
id: "task-fallback",
sessionID: "sub-session",
sessionId: "sub-session",
description: "Test task",
agent: "explore",
status: "pending",
@@ -290,7 +303,7 @@ describe("createCallOmoAgent", () => {
//#given
const launch = mock((_input: { model?: { providerID: string; modelID: string }; fallbackChain?: unknown[] }) => Promise.resolve({
id: "task-model",
sessionID: "sub-session",
sessionId: "sub-session",
description: "Test task",
agent: "explore",
status: "pending",
@@ -339,7 +352,7 @@ describe("createCallOmoAgent", () => {
//#given
const launch = mock((_input: { model?: { providerID: string; modelID: string; variant?: string } }) => Promise.resolve({
id: "task-variant",
sessionID: "sub-session",
sessionId: "sub-session",
description: "Test task",
agent: "explore",
status: "pending",
@@ -390,7 +403,7 @@ describe("createCallOmoAgent", () => {
//#given
const launch = mock((_input: { model?: { providerID: string; modelID: string; variant?: string } }) => Promise.resolve({
id: "task-inline-variant",
sessionID: "sub-session",
sessionId: "sub-session",
description: "Test task",
agent: "explore",
status: "pending",
@@ -440,7 +453,7 @@ describe("createCallOmoAgent", () => {
//#given
const launch = mock((_input: { model?: { providerID: string; modelID: string } }) => Promise.resolve({
id: "task-category-model",
sessionID: "sub-session",
sessionId: "sub-session",
description: "Test task",
agent: "explore",
status: "pending",
+4
View File
@@ -140,6 +140,10 @@ export function createCallOmoAgent(
`[call_omo_agent] Starting with agent: ${args.subagent_type}, background: ${args.run_in_background}`,
);
if (typeof args.subagent_type !== "string" || args.subagent_type.trim() === "") {
return "Error: subagent_type is required."
}
const callableAgents = await resolveCallableAgents(ctx.client);
// Strip ZWSP and case-insensitive agent validation - allows "Explore", "EXPLORE", "explore" etc.
+1 -1
View File
@@ -1,6 +1,6 @@
# src/tools/delegate-task/ — Task Delegation Engine
**Generated:** 2026-04-11
**Generated:** 2026-05-08
## OVERVIEW
@@ -45,6 +45,9 @@ describe("executeBackgroundContinuation - subagent metadata", () => {
expect(result).toContain("<task_metadata>")
expect(result).toContain("subagent: oracle")
expect(result).toContain("session_id: ses_resumed_123")
expect(result).toContain("background_task_id: bg_task_001")
expect(result).not.toContain("task_id: ses_resumed_123")
expect(result).toContain("Background Task ID: bg_task_001")
})
test("omits subagent from task_metadata when task agent is undefined", async () => {
@@ -60,7 +60,7 @@ export async function executeBackgroundContinuation(
return `Background task continued.
Task ID: ${backgroundTaskId}
Background Task ID: ${backgroundTaskId}
Description: ${task.description}
Agent: ${task.agent}
Status: ${task.status}
@@ -72,7 +72,6 @@ Do NOT call background_output now. Wait for <system-reminder> notification first
${buildTaskMetadataBlock({
sessionId,
taskId: sessionId,
backgroundTaskId,
agent: task.agent,
category: task.category,
@@ -104,7 +104,7 @@ describeFn("executeBackgroundTask output/session metadata compatibility", () =>
//#then - output and metadata should include canonical session linkage
expectFn(result).toContain("<task_metadata>")
expectFn(result).toContain("session_id: ses_sub_123")
expectFn(result).toContain("task_id: ses_sub_123")
expectFn(result).not.toContain("task_id: ses_sub_123")
expectFn(result).toContain("background_task_id: bg_resolved")
expectFn(result).toContain("subagent: explore")
expectFn(result).toContain("Background Task ID: bg_resolved")
@@ -114,6 +114,49 @@ describeFn("executeBackgroundTask output/session metadata compatibility", () =>
expectFn(metadataCalls[0].metadata.backgroundTaskId).toBe("bg_resolved")
})
testFn("keeps continuation taskId out of visible background metadata", async () => {
//#given - launched background task with both a background id and session id
const metadataCalls: Array<{ metadata: Record<string, unknown> }> = []
const manager = {
launch: async () => ({
id: "bg_visible_contract",
sessionId: "ses_visible_contract",
description: "Visible contract",
agent: "explore",
status: "running",
}),
getTask: () => ({ sessionId: "ses_visible_contract" }),
}
const result = await executeBackgroundTask(
{
description: "Visible contract",
prompt: "check",
run_in_background: true,
load_skills: [],
},
{
sessionID: "ses_parent",
callID: "call_visible_contract",
metadata: async (value: { metadata: Record<string, unknown> }) => metadataCalls.push(value),
abort: new AbortController().signal,
},
{ manager },
{ sessionID: "ses_parent", messageID: "msg_visible_contract" },
"explore",
undefined,
undefined,
undefined,
)
//#then - machine metadata keeps OpenCode compatibility, visible text avoids the overloaded task_id label
expectFn(result).toContain("session_id: ses_visible_contract")
expectFn(result).toContain("background_task_id: bg_visible_contract")
expectFn(result).not.toContain("task_id: ses_visible_contract")
expectFn(metadataCalls[0].metadata.taskId).toBe("ses_visible_contract")
expectFn(metadataCalls[0].metadata.backgroundTaskId).toBe("bg_visible_contract")
})
testFn("captures late-resolved session id and emits synced metadata", async () => {
//#given - background task session id appears after launch via manager polling
const metadataCalls: any[] = []
@@ -155,7 +198,7 @@ describeFn("executeBackgroundTask output/session metadata compatibility", () =>
//#then - late session id still propagates to task metadata contract
expectFn(result).toContain("session_id: ses_late_123")
expectFn(result).toContain("task_id: ses_late_123")
expectFn(result).not.toContain("task_id: ses_late_123")
expectFn(result).toContain("background_task_id: bg_late")
expectFn(metadataCalls).toHaveLength(1)
expectFn(metadataCalls[0].metadata.sessionId).toBe("ses_late_123")
@@ -190,7 +190,6 @@ export async function executeBackgroundTask(
const taskMetadataBlock = sessionId
? `\n\n${buildTaskMetadataBlock({
sessionId,
taskId: sessionId,
backgroundTaskId: task.id,
agent: task.agent,
category: args.category,
@@ -0,0 +1,63 @@
const KNOWN_VARIANTS = new Set([
"low",
"medium",
"high",
"xhigh",
"max",
"minimal",
"none",
"auto",
"thinking",
])
export function parseVariantFromModelID(rawModelID: string): { modelID: string; variant?: string } {
const trimmedModelID = rawModelID.trim()
if (!trimmedModelID) {
return { modelID: "" }
}
const parenthesizedVariant = trimmedModelID.match(/^(.*)\(([^()]+)\)\s*$/)
if (parenthesizedVariant) {
const modelID = parenthesizedVariant[1]?.trim() ?? ""
const variant = parenthesizedVariant[2]?.trim()
return variant ? { modelID, variant } : { modelID }
}
const spaceVariant = trimmedModelID.match(/^(.*\S)\s+([a-z][a-z0-9_-]*)$/i)
if (spaceVariant) {
const modelID = spaceVariant[1]?.trim() ?? ""
const variant = spaceVariant[2]?.trim().toLowerCase()
if (variant && KNOWN_VARIANTS.has(variant)) {
return { modelID, variant }
}
}
return { modelID: trimmedModelID }
}
export function parseModelString(
model: string,
): { providerID: string; modelID: string; variant?: string } | undefined {
const trimmedModel = model.trim()
if (!trimmedModel) return undefined
const parts = trimmedModel.split("/")
if (parts.length < 2) {
return undefined
}
const providerID = parts[0]?.trim()
const rawModelID = parts.slice(1).join("/").trim()
if (!providerID || !rawModelID) {
return undefined
}
const parsedModel = parseVariantFromModelID(rawModelID)
if (!parsedModel.modelID) {
return undefined
}
return parsedModel.variant
? { providerID, modelID: parsedModel.modelID, variant: parsedModel.variant }
: { providerID, modelID: parsedModel.modelID }
}
@@ -3,6 +3,7 @@ const { describe, test, expect } = require("bun:test")
import {
DEEP_CATEGORY_PROMPT_APPEND,
DEEP_CATEGORY_PROMPT_APPEND_GPT_5_3_CODEX,
DEEP_CATEGORY_PROMPT_APPEND_GPT_5_5,
OPENAI_CATEGORIES,
resolveDeepCategoryPromptAppend,
@@ -52,6 +53,59 @@ describe("DEEP_CATEGORY_PROMPT_APPEND_GPT_5_5", () => {
})
})
describe("DEEP_CATEGORY_PROMPT_APPEND_GPT_5_3_CODEX", () => {
test("uses Category_Context wrapper with name=\"deep\"", () => {
//#given
const prompt = DEEP_CATEGORY_PROMPT_APPEND_GPT_5_3_CODEX
//#then
expect(prompt).toContain('<Category_Context name="deep">')
expect(prompt).toContain("</Category_Context>")
})
test("contains GPT-5.3-Codex-specific style markers", () => {
//#given
const prompt = DEEP_CATEGORY_PROMPT_APPEND_GPT_5_3_CODEX
//#then
expect(prompt).toContain("GPT-5.3-Codex")
expect(prompt).toContain("Autonomy and persistence")
expect(prompt).toContain("Goal, not plan")
expect(prompt).toContain("Code implementation")
expect(prompt).toContain("Worktree safety")
expect(prompt).toContain("Completion bar")
expect(prompt).toContain("Final message")
})
test("preserves legacy DEEP knowledge from both default and 5.5 variants", () => {
//#given
const prompt = DEEP_CATEGORY_PROMPT_APPEND_GPT_5_3_CODEX
//#then
expect(prompt).toContain("atomic task")
expect(prompt).toContain("root cause")
expect(prompt).toContain("Bias to action")
expect(prompt).toContain("complete mental model")
expect(prompt).toContain("Ambition scaled")
})
test("uses parallel-batch exploration framing instead of legacy silent-exploration", () => {
//#given
const prompt = DEEP_CATEGORY_PROMPT_APPEND_GPT_5_3_CODEX
//#then
expect(prompt).toContain("Batch everything")
expect(prompt).toContain("maximize parallelism")
expect(prompt).not.toContain("five to fifteen minutes")
})
test("is materially different from both DEEP_CATEGORY_PROMPT_APPEND and DEEP_CATEGORY_PROMPT_APPEND_GPT_5_5", () => {
//#then
expect(DEEP_CATEGORY_PROMPT_APPEND_GPT_5_3_CODEX).not.toBe(DEEP_CATEGORY_PROMPT_APPEND)
expect(DEEP_CATEGORY_PROMPT_APPEND_GPT_5_3_CODEX).not.toBe(DEEP_CATEGORY_PROMPT_APPEND_GPT_5_5)
})
})
describe("resolveDeepCategoryPromptAppend", () => {
test("returns GPT-5.5 prompt for openai/gpt-5.5", () => {
//#when
@@ -85,12 +139,20 @@ describe("resolveDeepCategoryPromptAppend", () => {
expect(result).toBe(DEEP_CATEGORY_PROMPT_APPEND)
})
test("returns legacy prompt for openai/gpt-5.3-codex", () => {
test("returns GPT-5.3-codex prompt for openai/gpt-5.3-codex", () => {
//#when
const result = resolveDeepCategoryPromptAppend("openai/gpt-5.3-codex")
//#then
expect(result).toBe(DEEP_CATEGORY_PROMPT_APPEND)
expect(result).toBe(DEEP_CATEGORY_PROMPT_APPEND_GPT_5_3_CODEX)
})
test("returns GPT-5.3-codex prompt for the gpt-5-3-codex hyphenated form", () => {
//#when
const result = resolveDeepCategoryPromptAppend("openai/gpt-5-3-codex")
//#then
expect(result).toBe(DEEP_CATEGORY_PROMPT_APPEND_GPT_5_3_CODEX)
})
test("returns legacy prompt for undefined model", () => {
+71 -2
View File
@@ -1,4 +1,4 @@
import { isGpt5_5Model } from "../../agents/types"
import { isGpt5_3CodexModel, isGpt5_5Model } from "../../agents/types"
import type { BuiltinCategoryDefinition } from "./builtin-category-definition"
const ULTRABRAIN_CATEGORY_PROMPT_APPEND = `<Category_Context>
@@ -44,6 +44,72 @@ Approach: explore extensively, understand deeply, then act decisively. Prefer co
Minimal status updates. Focus on results, not play-by-play. Report completion with summary of changes.
</Category_Context>`
export const DEEP_CATEGORY_PROMPT_APPEND_GPT_5_3_CODEX = `<Category_Context name="deep">
You are operating in DEEP mode on GPT-5.3-Codex. This category is reserved for goal-oriented autonomous coding work on hairy problems that reward depth over speed and a complete solution over a quick patch.
The orchestrator routed you here for autonomous execution. Do not stop to ask the orchestrator for permission, do not produce an upfront plan and wait for approval, do not stop at a proof of concept.
# Autonomy and persistence
- Once the goal is given, gather context, implement, verify, and explain outcomes within this turn whenever feasible.
- Persist end-to-end: do not stop at analysis or partial fixes; carry changes through implementation, verification, and a clear explanation unless you hit a genuine blocker (missing secret, design decision only the user can make, three materially different attempts all failed).
- Bias to action: default to implementing with reasonable assumptions. Do not end your turn with clarifying questions unless truly blocked. Document assumptions in the final message instead.
- Avoid excessive looping. If you find yourself re-reading or re-editing the same files without clear progress, stop and end the turn with a concise summary and any clarifying questions needed.
# Goal, not plan
You receive a GOAL describing the desired outcome. You figure out HOW. The orchestrator deliberately did not hand you a step-by-step plan; producing one and pausing for approval is not what was asked.
When the goal contains numbered steps or phases, treat them as sub-steps of ONE atomic task and execute them all in this turn. Splitting them across turns is wrong unless they reveal an architectural blocker that requires the user's input. If the steps turn out to be genuinely independent tasks that should have been separate delegations, flag that in your final message and refuse the ones beyond scope.
# Exploration
- Think first. Before any tool call, decide ALL files and resources you will need.
- Batch everything. If you need multiple files (even from different places), read them together using parallel tool calls.
- Always maximize parallelism: never read files one-by-one unless logically unavoidable. For broader questions fire 2-5 explore/librarian sub-agents in parallel.
- Workflow: (a) plan all needed reads, (b) issue one parallel batch, (c) analyze results, (d) repeat if new unpredictable reads arise. Sequential reads only when you truly cannot know the next file without seeing a prior result first.
Build a complete mental model before the first edit. Exploration is an investment, not overhead - the orchestrator routed depth tasks here specifically because rushing to implementation is the failure mode.
# Code implementation
- Discerning engineer mindset: optimize for correctness, clarity, and reliability over speed. Cover the root cause, not just a symptom or a narrow slice. Trace at least two levels up before settling - a null check around \`foo()\` is a symptom; fixing what causes \`foo()\` to return unexpected values is the root.
- Conform to codebase conventions: follow existing patterns, helpers, naming, formatting, localization. If you must diverge, state why.
- Behavior-safe defaults: preserve intended behavior and UX; gate or flag intentional changes; add tests when behavior shifts.
- Tight error handling: no broad try/catch blocks, no success-shaped fallbacks; propagate or surface errors explicitly. No silent failures - do not early-return on invalid input without logging consistent with repo patterns.
- Efficient, coherent edits: read enough context before changing a file; batch logical edits together rather than thrashing with many tiny patches.
- Type safety: changes must pass build and type-check; avoid \`as any\` or \`as unknown as ...\`; prefer proper types and guards; reuse existing helpers.
- Reuse / DRY: search for prior art before adding helpers; reuse or extract a shared helper instead of duplicating.
- Ambition scaled to context: greenfield = strong defaults, avoid AI-slop, produce work you would hand to another senior engineer. Existing codebase = surgical, respect existing patterns. Depth does not mean invasiveness.
# Completion bar
"Simplified version", "proof of concept", and "you can extend this later" are not acceptable for a deep task. The orchestrator routed here specifically for a complete solution. If you hit a genuine blocker, document it and return; otherwise, finish the task.
# Worktree safety
- NEVER revert existing changes you did not make unless explicitly requested - those changes were made by the user.
- If asked to commit and there are unrelated changes in those files, do not revert them.
- If you notice unexpected changes you did not make in unrelated files, ignore them.
- If you notice unexpected mid-rollout changes you did not make and are not sure how to proceed, stop and ask.
- NEVER use destructive commands like \`git reset --hard\` or \`git checkout --\` unless explicitly requested.
# Status cadence
The user is not on the other side of this conversation; the orchestrator is, and they will synthesize your progress. Send commentary only at meaningful phase transitions (starting exploration, starting implementation, starting verification, hitting a genuine blocker). Do not narrate every tool call; silence during focused work is expected.
If you used a planning tool, mark every previously stated intention as Done, Blocked (one-sentence reason + targeted question), or Cancelled (with reason) before finishing. Do not end with in_progress or pending items.
# Final message
- Be concise; pragmatic, not chatty. Higher actionable information per token; fewer social flourishes.
- Lead with a quick explanation of the change, then context covering where and why. Do not start with "Summary"; jump in.
- Reference paths only - do not dump file contents. Do not say "save/copy this file" - the user is on the same machine.
- For substantial work, summarize clearly with high-level headings.
- File references: inline code with standalone path. Examples: \`src/app.ts\`, \`src/app.ts:42\`. Do not use \`file://\`, \`vscode://\`, or \`https://\` URIs. Do not provide line ranges.
- Suggest natural next steps (tests, commits, build) only if there are real ones; otherwise omit.
</Category_Context>`
export const DEEP_CATEGORY_PROMPT_APPEND_GPT_5_5 = `<Category_Context name="deep">
You are operating in DEEP mode. This is the category reserved for goal-oriented autonomous work on hairy problems that reward thorough exploration and comprehensive solutions.
@@ -67,6 +133,9 @@ The orchestrator chose this category because the task benefits from depth over s
</Category_Context>`
export function resolveDeepCategoryPromptAppend(model: string | undefined): string {
if (model && isGpt5_3CodexModel(model)) {
return DEEP_CATEGORY_PROMPT_APPEND_GPT_5_3_CODEX
}
if (model && isGpt5_5Model(model)) {
return DEEP_CATEGORY_PROMPT_APPEND_GPT_5_5
}
@@ -134,7 +203,7 @@ export const OPENAI_CATEGORIES: BuiltinCategoryDefinition[] = [
{
name: "deep",
config: { model: "openai/gpt-5.5", variant: "medium" },
description: "Goal-oriented autonomous problem-solving. Thorough research before action. For hairy problems requiring deep understanding.",
description: "Goal-oriented autonomous problem-solving on hairy problems requiring deep research. ONE goal + ONE deliverable per call — multiple goals must fan out as parallel `deep` calls, never bundled into one.",
promptAppend: DEEP_CATEGORY_PROMPT_APPEND,
resolvePromptAppend: resolveDeepCategoryPromptAppend,
},
@@ -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
}
+7 -1
View File
@@ -4,7 +4,13 @@ import { discoverSkills } from "../../features/opencode-skill-loader"
export async function resolveSkillContent(
skills: string[],
options: { gitMasterConfig?: GitMasterConfig; browserProvider?: BrowserAutomationProvider, disabledSkills?: Set<string>, directory?: string }
options: {
gitMasterConfig?: GitMasterConfig
browserProvider?: BrowserAutomationProvider
disabledSkills?: Set<string>
teamModeEnabled?: boolean
directory?: string
}
): Promise<{ content: string | undefined; contents: string[]; error: string | null }> {
if (skills.length === 0) {
return { content: undefined, contents: [], error: null }
+24 -6
View File
@@ -26,11 +26,17 @@ import type { FallbackEntry } from "../../shared/model-requirements"
import { resolveModelForDelegateTask } from "./model-selection"
import { fuzzyMatchModel } from "../../shared/model-availability"
export interface ResolveSubagentExecutionOptions {
allowSisyphusJuniorDirect?: boolean
allowPrimaryAgentDelegation?: boolean
}
export async function resolveSubagentExecution(
args: DelegateTaskArgs,
executorCtx: ExecutorContext,
parentAgent: string | undefined,
categoryExamples: string
categoryExamples: string,
options: ResolveSubagentExecutionOptions = {},
): Promise<{ agentToUse: string; categoryModel: DelegatedModelConfig | undefined; fallbackChain?: FallbackEntry[]; error?: string }> {
const { client, agentOverrides, userCategories } = executorCtx
@@ -40,11 +46,17 @@ export async function resolveSubagentExecution(
const agentName = sanitizeSubagentType(args.subagent_type)
if (agentName.toLowerCase() === SISYPHUS_JUNIOR_AGENT.toLowerCase()) {
if (
!options.allowSisyphusJuniorDirect &&
agentName.toLowerCase() === SISYPHUS_JUNIOR_AGENT.toLowerCase()
) {
const exampleHint = categoryExamples.trim() !== ""
? `Use category parameter instead (e.g., ${categoryExamples}).`
: `Use the category parameter instead (pick one of: quick, deep, ultrabrain, visual-engineering, artistry, writing).`
return {
agentToUse: "",
categoryModel: undefined,
error: `Cannot use subagent_type="${SISYPHUS_JUNIOR_AGENT}" directly. Use category parameter instead (e.g., ${categoryExamples}).
error: `Cannot use subagent_type="${SISYPHUS_JUNIOR_AGENT}" directly. ${exampleHint}
Sisyphus-Junior is spawned automatically when you specify a category. Pick the appropriate category for your task domain.`,
}
@@ -73,7 +85,7 @@ Create the work plan directly - that's your job as the planning agent.`,
const mergedAgents = mergeWithClaudeCodeAgents(agents, executorCtx.directory)
const matchedPrimaryAgent = findPrimaryAgentMatch(mergedAgents, agentToUse)
if (matchedPrimaryAgent) {
if (matchedPrimaryAgent && !options.allowPrimaryAgentDelegation) {
return {
agentToUse: "",
categoryModel: undefined,
@@ -81,7 +93,11 @@ Create the work plan directly - that's your job as the planning agent.`,
}
}
const matchedAgent = findCallableAgentMatch(mergedAgents, agentToUse)
const usePrimary = options.allowPrimaryAgentDelegation && matchedPrimaryAgent !== undefined
const matchedAgent = usePrimary
? matchedPrimaryAgent
: findCallableAgentMatch(mergedAgents, agentToUse)
if (!matchedAgent) {
return {
agentToUse: "",
@@ -90,7 +106,9 @@ Create the work plan directly - that's your job as the planning agent.`,
}
}
agentToUse = stripAgentListSortPrefix(matchedAgent.name)
agentToUse = usePrimary
? matchedAgent.name
: stripAgentListSortPrefix(matchedAgent.name)
const agentConfigKey = getAgentConfigKey(agentToUse)
const agentOverride = agentOverrides?.[agentConfigKey as keyof typeof agentOverrides]
@@ -1,5 +1,20 @@
const { describe, test, expect, beforeEach, afterEach, mock, spyOn } = require("bun:test")
const TEAM_TOOL_DENIALS = {
team_create: false,
team_delete: false,
team_shutdown_request: false,
team_approve_shutdown: false,
team_reject_shutdown: false,
team_send_message: false,
team_task_create: false,
team_task_list: false,
team_task_update: false,
team_task_get: false,
team_status: false,
team_list: false,
}
describe("executeSyncContinuation - toast cleanup error paths", () => {
let removeTaskCalls: string[] = []
let addTaskCalls: any[] = []
@@ -532,6 +547,7 @@ describe("executeSyncContinuation - toast cleanup error paths", () => {
question: false,
write: false,
edit: false,
...TEAM_TOOL_DENIALS,
})
})
@@ -602,6 +618,7 @@ describe("executeSyncContinuation - toast cleanup error paths", () => {
question: false,
write: false,
edit: false,
...TEAM_TOOL_DENIALS,
})
})
@@ -670,6 +687,7 @@ describe("executeSyncContinuation - toast cleanup error paths", () => {
task: true,
call_omo_agent: true,
question: false,
...TEAM_TOOL_DENIALS,
})
})
})
@@ -75,10 +75,56 @@ describe("syncPollTimeoutMs threading", () => {
taskId: undefined,
}, 120_000)
expect(result).toBe("Poll timeout reached after 120000ms for session ses_custom")
expect(result).toBe("Poll inactivity timeout reached after 120000ms without active OpenCode status for session ses_custom")
expect(abortCount).toBe(1)
})
})
test("#then active OpenCode statuses do not consume the inactivity timeout", async () => {
const { pollSyncSession } = require("./sync-session-poller")
let abortCount = 0
let statusCallCount = 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 () => {
statusCallCount++
if (statusCallCount === 1) return { data: { ses_active: { type: "busy" } } }
if (statusCallCount === 2) return { data: { ses_active: { type: "retry" } } }
return { data: { ses_active: { type: "idle" } } }
},
},
}
await withMockedDateNow(60_000, async () => {
const result = await pollSyncSession(createMockCtx(), mockClient, {
sessionID: "ses_active",
agentToUse: "oracle",
toastManager: null,
taskId: undefined,
}, 120_000)
expect(result).toBeNull()
expect(abortCount).toBe(0)
expect(statusCallCount).toBe(3)
expect(messageCallCount).toBe(1)
})
})
})
describe("#when timeoutMs is omitted", () => {
@@ -95,7 +141,7 @@ describe("syncPollTimeoutMs threading", () => {
taskId: undefined,
})
expect(result).toBe(`Poll timeout reached after ${MAX_POLL_TIME_MS}ms for session ses_default`)
expect(result).toBe(`Poll inactivity timeout reached after ${MAX_POLL_TIME_MS}ms without active OpenCode status for session ses_default`)
})
})
@@ -113,7 +159,7 @@ describe("syncPollTimeoutMs threading", () => {
taskId: undefined,
})
expect(result).toBe("Poll timeout reached after 120000ms for session ses_legacy")
expect(result).toBe("Poll inactivity timeout reached after 120000ms without active OpenCode status for session ses_legacy")
})
})
})
@@ -131,7 +177,7 @@ describe("syncPollTimeoutMs threading", () => {
taskId: undefined,
}, 10)
expect(result).toBe("Poll timeout reached after 50ms for session ses_guard")
expect(result).toBe("Poll inactivity timeout reached after 50ms without active OpenCode status for session ses_guard")
})
})
})
@@ -100,7 +100,7 @@ describe("pollSyncSession", () => {
}, 50)
// then: times out (ignores stale error)
expect(result).toContain("Poll timeout reached")
expect(result).toContain("Poll inactivity timeout reached")
})
test("detects completion when assistant message has terminal finish reason", async () => {
@@ -459,7 +459,7 @@ describe("pollSyncSession", () => {
}, 0)
// then: returns timeout error
expect(result).toBe("Poll timeout reached after 50ms for session ses_timeout")
expect(result).toBe("Poll inactivity timeout reached after 50ms without active OpenCode status for session ses_timeout")
expect(abortCount).toBe(1)
})
})
+21 -6
View File
@@ -7,6 +7,7 @@ import { extractErrorMessage } from "../../features/background-agent/error-class
const NON_TERMINAL_FINISH_REASONS = new Set(["tool-calls", "unknown"])
const PENDING_TOOL_PART_TYPES = new Set(["tool", "tool_use", "tool-call"])
const ACTIVE_SESSION_STATUSES = new Set(["busy", "retry", "running"])
function wait(milliseconds: number): Promise<void> {
const sharedBuffer = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT)
@@ -24,6 +25,10 @@ function abortSyncSession(client: OpencodeClient, sessionID: string, reason: str
})
}
function isActiveSessionStatus(status: { type: string } | undefined): boolean {
return status !== undefined && ACTIVE_SESSION_STATUSES.has(status.type)
}
async function fetchSessionMessages(
client: OpencodeClient,
sessionID: string
@@ -84,6 +89,7 @@ export async function pollSyncSession(
const maxPollTimeMs = Math.max(timeoutMs ?? getDefaultSyncPollTimeoutMs(), 50)
const maxTurns = input.maxAssistantTurns ?? DEFAULT_MAX_ASSISTANT_TURNS
const pollStart = Date.now()
let inactiveStart = pollStart
let pollCount = 0
let timedOut = false
let assistantTurnCount = 0
@@ -91,7 +97,13 @@ export async function pollSyncSession(
log("[task] Starting poll loop", { sessionID: input.sessionID, agentToUse: input.agentToUse, maxTurns })
while (Date.now() - pollStart < maxPollTimeMs) {
while (true) {
const inactiveElapsedMs = Date.now() - inactiveStart
if (inactiveElapsedMs >= maxPollTimeMs) {
timedOut = true
break
}
if (ctx.abort?.aborted) {
try {
const messages = await fetchSessionMessages(client, input.sessionID)
@@ -132,11 +144,13 @@ export async function pollSyncSession(
sessionID: input.sessionID,
pollCount,
elapsed: Math.floor((Date.now() - pollStart) / 1000) + "s",
inactiveElapsed: Math.floor(inactiveElapsedMs / 1000) + "s",
sessionStatus: sessionStatus?.type ?? "not_in_status",
})
}
if (sessionStatus && sessionStatus.type !== "idle") {
if (isActiveSessionStatus(sessionStatus)) {
inactiveStart = Date.now()
continue
}
@@ -199,11 +213,12 @@ export async function pollSyncSession(
}
}
if (Date.now() - pollStart >= maxPollTimeMs) {
timedOut = true
log("[task] Poll timeout reached", { sessionID: input.sessionID, pollCount })
if (timedOut) {
log("[task] Poll inactivity timeout reached", { sessionID: input.sessionID, pollCount })
abortSyncSession(client, input.sessionID, "poll_timeout")
}
return timedOut ? `Poll timeout reached after ${maxPollTimeMs}ms for session ${input.sessionID}` : null
return timedOut
? `Poll inactivity timeout reached after ${maxPollTimeMs}ms without active OpenCode status for session ${input.sessionID}`
: null
}
+5 -5
View File
@@ -299,7 +299,7 @@ describe("executeSyncTask - cleanup on error paths", () => {
}
const fallbackChain = [
{ providers: ["anthropic"], model: "claude-opus-4-7", variant: "max" },
{ providers: ["opencode-go"], model: "kimi-k2.5" },
{ providers: ["opencode-go"], model: "kimi-k2.6" },
]
//#when
@@ -309,10 +309,10 @@ describe("executeSyncTask - cleanup on error paths", () => {
//#then
expect(result).toContain("Task completed")
expect(result).toContain("Model: opencode-go/kimi-k2.5")
expect(result).toContain("Model: opencode-go/kimi-k2.6")
expect(attemptedModels).toEqual([
{ providerID: "anthropic", modelID: "claude-opus-4-7", variant: "max" },
{ providerID: "opencode-go", modelID: "kimi-k2.5", variant: undefined },
{ providerID: "opencode-go", modelID: "kimi-k2.6", variant: undefined },
])
expect(setSessionFallbackChain).toHaveBeenCalledWith("ses_test_12345678", fallbackChain)
expect(bootstrapSnapshots[0]?.retryParts[0]?.text).toContain("test prompt")
@@ -374,7 +374,7 @@ describe("executeSyncTask - cleanup on error paths", () => {
}
const fallbackChain = [
{ providers: ["anthropic"], model: "claude-opus-4-7", variant: "max" },
{ providers: ["opencode-go"], model: "kimi-k2.5" },
{ providers: ["opencode-go"], model: "kimi-k2.6" },
{ providers: ["openai"], model: "gpt-5.4", variant: "medium" },
]
@@ -387,7 +387,7 @@ describe("executeSyncTask - cleanup on error paths", () => {
expect(result).toBe("Final failure")
expect(attemptedModels).toEqual([
{ providerID: "anthropic", modelID: "claude-opus-4-7", variant: "max" },
{ providerID: "opencode-go", modelID: "kimi-k2.5", variant: undefined },
{ providerID: "opencode-go", modelID: "kimi-k2.6", variant: undefined },
{ providerID: "openai", modelID: "gpt-5.4", variant: "medium" },
])
})
+2 -2
View File
@@ -3,7 +3,7 @@ const { describe, expect, test } = require("bun:test")
import { __resetTimingConfig, __setTimingConfig, getDefaultSyncPollTimeoutMs, getTimingConfig } from "./timing"
describe("timing sync poll timeout defaults", () => {
test("default sync timeout is 30 minutes", () => {
test("default sync inactivity timeout is 30 minutes", () => {
// #given
__resetTimingConfig()
@@ -14,7 +14,7 @@ describe("timing sync poll timeout defaults", () => {
expect(timeout).toBe(30 * 60 * 1000)
})
test("default sync timeout accessor follows MAX_POLL_TIME_MS config", () => {
test("default sync inactivity timeout accessor follows MAX_POLL_TIME_MS config", () => {
// #given
__resetTimingConfig()
@@ -0,0 +1,18 @@
import { describe, expect, test } from "bun:test"
import { createDelegateTaskPresentation } from "./tool-description"
describe("createDelegateTaskPresentation", () => {
test("#given sync task usage #when description is rendered #then timeout is described as inactivity based", () => {
//#given
const presentation = createDelegateTaskPresentation({})
//#when
const description = presentation.description
//#then
expect(description).toContain("30-minute inactivity window")
expect(description).toContain("busy/retry/running")
expect(description).toContain("not a total wall-clock limit")
})
})
@@ -67,6 +67,7 @@ export function createDelegateTaskPresentation(options: DelegateTaskToolOptions)
${categoryList}
- 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.
Sync waits use a 30-minute inactivity window: OpenCode busy/retry/running status resets the window, so this is not a total wall-clock limit.
- task_id: Existing task to continue (from previous task output). Continues the same subagent session with FULL CONTEXT PRESERVED.
- command: The command that triggered this task (optional, for slash command tracking).
+12 -13
View File
@@ -381,7 +381,7 @@ describe("sisyphus-task", () => {
}
//#when
await tool.execute(args as DelegateTaskArgs, toolContext)
await tool.execute(args, toolContext)
//#then
expect(args.load_skills).toEqual(["playwright", "git-master"])
@@ -444,7 +444,7 @@ describe("sisyphus-task", () => {
}
//#when
await tool.execute(args as DelegateTaskArgs, toolContext)
await tool.execute(args, toolContext)
//#then
expect(args.load_skills).toEqual([])
@@ -755,8 +755,8 @@ describe("sisyphus-task", () => {
expect(result).toBeNull()
})
test("blocks requiresModel when availability is known and missing the required model", () => {
// given - artistry has requiresModel: gemini-3.1-pro
test("allows artistry to use its fallback chain when gemini is missing", () => {
// given - artistry can fall back from gemini to another capable model
const categoryName = "artistry"
const availableModels = new Set<string>(["anthropic/claude-opus-4-7"])
@@ -767,11 +767,12 @@ describe("sisyphus-task", () => {
})
// then
expect(result).toBeNull()
expect(result).not.toBeNull()
expect(result?.model).toBe("google/gemini-3.1-pro")
})
test("blocks requiresModel when availability is empty", () => {
// given - artistry has requiresModel: gemini-3.1-pro
test("allows artistry when availability is empty", () => {
// given - empty availability should not disable fallback-capable categories
const categoryName = "artistry"
const availableModels = new Set<string>()
@@ -782,7 +783,8 @@ describe("sisyphus-task", () => {
})
// then
expect(result).toBeNull()
expect(result).not.toBeNull()
expect(result?.model).toBe("google/gemini-3.1-pro")
})
test("bypasses requiresModel when explicit user config provided", () => {
@@ -1825,7 +1827,7 @@ describe("sisyphus-task", () => {
//#given a session with a previous message that has variant "max"
const { createDelegateTask } = require("./tools")
const promptMock = mock(async (input: any) => {
const promptMock = mock(async () => {
return { data: {} }
})
@@ -3144,8 +3146,6 @@ describe("sisyphus-task", () => {
test("should resolve agent-browser skill even when browserProvider is not set", async () => {
// given - delegate_task without browserProvider
const { createDelegateTask } = require("./tools")
let promptBody: any
const mockManager = { launch: async () => ({}) }
const mockClient = {
app: { agents: async () => ({ data: [] }) },
@@ -3153,8 +3153,7 @@ describe("sisyphus-task", () => {
session: {
get: async () => ({ data: { directory: "/project" } }),
create: async () => ({ data: { id: "ses_no_browser_provider" } }),
prompt: async (input: any) => {
promptBody = input.body
prompt: async () => {
return { data: {} }
},
messages: async () => ({
+1
View File
@@ -47,6 +47,7 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini
gitMasterConfig: options.gitMasterConfig,
browserProvider: options.browserProvider,
disabledSkills: options.disabledSkills,
teamModeEnabled: options.teamModeEnabled,
directory: options.directory,
})
if (skillError) {
+1
View File
@@ -62,6 +62,7 @@ export interface DelegateTaskToolOptions {
sisyphusJuniorModel?: string
browserProvider?: BrowserAutomationProvider
disabledSkills?: Set<string>
teamModeEnabled?: boolean
availableCategories?: AvailableCategory[]
availableSkills?: AvailableSkill[]
agentOverrides?: AgentOverrides
@@ -89,7 +89,6 @@ export async function executeUnstableAgentTask(
const taskMetadataBlock = buildTaskMetadataBlock({
sessionId: sessionID,
taskId: sessionID,
backgroundTaskId: task.id,
agent: agentToUse,
category: args.category,
@@ -168,6 +168,69 @@ describe("resolveSubagentExecution", () => {
expect(result.error).toBe('Cannot delegate to primary agent "Prometheus - Plan Builder" via task. Select that agent directly instead.')
})
test("allows delegating to a primary agent when allowPrimaryAgentDelegation is enabled (team-mode path)", async () => {
//#given
readProviderModelsCacheMock.mockReturnValue({
models: { anthropic: ["claude-opus-4-7"] },
connected: ["anthropic"],
updatedAt: "2026-03-03T00:00:00.000Z",
})
const args = createBaseArgs({ subagent_type: "sisyphus" })
const executorCtx = createExecutorContext(async () => ([
{ name: "\u200BSisyphus - Ultraworker", mode: "primary", model: "anthropic/claude-opus-4-7" },
{ name: "oracle", mode: "subagent" },
]))
//#when
const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep", {
allowPrimaryAgentDelegation: true,
})
//#then
expect(result.error).toBeUndefined()
expect(result.agentToUse).toBe("\u200BSisyphus - Ultraworker")
})
test("allows delegating to Sisyphus-Junior when allowSisyphusJuniorDirect is enabled (team-mode path)", async () => {
//#given
readProviderModelsCacheMock.mockReturnValue({
models: { anthropic: ["claude-sonnet-4-6"] },
connected: ["anthropic"],
updatedAt: "2026-03-03T00:00:00.000Z",
})
const args = createBaseArgs({ subagent_type: "sisyphus-junior" })
const executorCtx = createExecutorContext(async () => ([
{ name: "Sisyphus-Junior", mode: "subagent", model: "anthropic/claude-sonnet-4-6" },
{ name: "oracle", mode: "subagent" },
]))
//#when
const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep", {
allowSisyphusJuniorDirect: true,
})
//#then
expect(result.error).toBeUndefined()
expect(result.agentToUse).toBe("Sisyphus-Junior")
})
test("renders a usable fallback hint when categoryExamples is empty for the default Sisyphus-Junior block", async () => {
//#given
const args = createBaseArgs({ subagent_type: "sisyphus-junior" })
const executorCtx = createExecutorContext(async () => ([
{ name: "Sisyphus-Junior", mode: "subagent" },
]))
//#when
const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "")
//#then
expect(result.agentToUse).toBe("")
expect(result.error).toBeDefined()
expect(result.error).not.toContain("(e.g., )")
expect(result.error).toContain("pick one of: quick, deep, ultrabrain")
})
test("requires explicit all or subagent mode for task-callable agents", async () => {
//#given
const args = createBaseArgs({ subagent_type: "custom-worker" })
+1 -1
View File
@@ -1,5 +1,5 @@
import { resolve } from "node:path"
import { spawn } from "bun"
import { spawn } from "../../shared/bun-spawn-shim"
import {
resolveGrepCli,
type GrepBackend,
+1 -1
View File
@@ -1,4 +1,4 @@
import { spawn } from "bun"
import { spawn } from "../../shared/bun-spawn-shim"
import {
resolveGrepCli,
type ResolvedCli,
+1 -1
View File
@@ -1,6 +1,6 @@
# src/tools/hashline-edit/ — Hash-Anchored File Edit Tool
**Generated:** 2026-04-11
**Generated:** 2026-05-08
## OVERVIEW
+2 -1
View File
@@ -1,5 +1,6 @@
import path from "path"
import { log } from "../../shared"
import { spawn as bunSpawn } from "../../shared/bun-spawn-shim"
interface FormatterConfig {
disabled?: boolean
@@ -106,7 +107,7 @@ export async function runFormattersForFile(
const cmd = buildFormatterCommand(formatter.command, filePath)
try {
log("[formatter-trigger] Running formatter", { command: cmd, file: filePath })
const proc = Bun.spawn(cmd, {
const proc = bunSpawn(cmd, {
cwd: directory,
env: { ...process.env, ...formatter.environment },
stdout: "ignore",
+1
View File
@@ -44,6 +44,7 @@ export {
createTaskUpdateTool,
} from "./task"
export { createHashlineEditTool } from "./hashline-edit"
export { createTeamSendMessageTool } from "../features/team-mode/tools/messaging"
export function createBackgroundTools(manager: BackgroundManager, client: OpencodeClient): Record<string, ToolDefinition> {
const outputManager: BackgroundOutputManager = manager
@@ -1,4 +1,4 @@
import { spawn } from "bun"
import { spawn } from "../../shared/bun-spawn-shim"
let tmuxPath: string | null = null
let initPromise: Promise<string | null> | null = null
@@ -0,0 +1,28 @@
import { describe, expect, test } from "bun:test"
import { getMissingLookAtFilePath } from "./missing-file-error"
describe("getMissingLookAtFilePath", () => {
test("#given ENOENT error with path property #when formatting look_at error #then returns missing path", () => {
//#given
const error = new Error("ENOENT: no such file or directory")
Object.defineProperty(error, "code", { value: "ENOENT" })
Object.defineProperty(error, "path", { value: "/tmp/missing.png" })
//#when
const path = getMissingLookAtFilePath(error, { file_path: "/tmp/fallback.png", goal: "inspect" })
//#then
expect(path).toBe("/tmp/missing.png")
})
test("#given ENOENT message without path property #when formatting look_at error #then extracts open path", () => {
//#given
const error = new Error("ENOENT: no such file or directory, open '/tmp/from-message.png'")
//#when
const path = getMissingLookAtFilePath(error, { file_path: "/tmp/fallback.png", goal: "inspect" })
//#then
expect(path).toBe("/tmp/from-message.png")
})
})
+45
View File
@@ -0,0 +1,45 @@
import type { LookAtArgs } from "./types"
export function getMissingLookAtFilePath(error: unknown, args: LookAtArgs): string | null {
if (!isMissingFileError(error)) {
return null
}
const pathFromError = getMissingFilePathFromError(error)
if (pathFromError) {
return pathFromError
}
return args.file_path ?? null
}
function getMissingFilePathFromError(error: unknown): string | null {
if (!(error instanceof Error)) {
return null
}
const path = Reflect.get(error, "path")
if (typeof path === "string" && path.length > 0) {
return path
}
if (error instanceof Error) {
const match = /open '([^']+)'/.exec(error.message)
return match?.[1] ?? null
}
return null
}
function isMissingFileError(error: unknown): boolean {
if (!(error instanceof Error)) {
return false
}
const code = Reflect.get(error, "code")
if (code === "ENOENT") {
return true
}
return error.message.includes("ENOENT") && error.message.includes("no such file or directory")
}
@@ -5,29 +5,29 @@ describe("buildMultimodalLookerFallbackChain", () => {
// given
const { buildMultimodalLookerFallbackChain } = await import("./multimodal-fallback-chain")
const visionCapableModels = [
{ providerID: "openai", modelID: "gpt-5.4" },
{ providerID: "opencode", modelID: "gpt-5.4" },
{ providerID: "openai", modelID: "gpt-5.5" },
{ providerID: "opencode", modelID: "gpt-5.5" },
]
// when
const result = buildMultimodalLookerFallbackChain(visionCapableModels)
// then
const gpt54Entries = result.filter((entry) => entry.model === "gpt-5.4")
expect(gpt54Entries.length).toBeGreaterThan(0)
const gpt55Entries = result.filter((entry) => entry.model === "gpt-5.5")
expect(gpt55Entries.length).toBeGreaterThan(0)
})
it("avoids duplicates when adding hardcoded entries", async () => {
// given
const { buildMultimodalLookerFallbackChain } = await import("./multimodal-fallback-chain")
const visionCapableModels = [{ providerID: "openai", modelID: "gpt-5.4" }]
const visionCapableModels = [{ providerID: "openai", modelID: "gpt-5.5" }]
// when
const result = buildMultimodalLookerFallbackChain(visionCapableModels)
// then
expect(result.length).toBeGreaterThan(0)
expect(result[0].model).toBe("gpt-5.4")
expect(result[0].model).toBe("gpt-5.5")
expect(result[0].providers).toContain("openai")
})
+7
View File
@@ -6,6 +6,7 @@ import type { LookAtArgsWithAlias } from "./look-at-arguments"
import { normalizeArgs, validateArgs } from "./look-at-arguments"
import { prepareLookAtInput } from "./look-at-input-preparer"
import { runLookAtSession } from "./look-at-session-runner"
import { getMissingLookAtFilePath } from "./missing-file-error"
export { normalizeArgs, validateArgs } from "./look-at-arguments"
@@ -43,6 +44,12 @@ export function createLookAt(ctx: PluginInput): ToolDefinition {
isBase64Input,
})
} catch (error) {
const missingFilePath = getMissingLookAtFilePath(error, args)
if (missingFilePath) {
log(`[look_at] Missing file while analyzing ${sourceDescription}:`, error)
return `Error: File not found: ${missingFilePath}`
}
const errorMessage = error instanceof Error ? error.message : String(error)
log(`[look_at] Unexpected error analyzing ${sourceDescription}:`, error)
return `Error: Failed to analyze ${sourceDescription}: ${errorMessage}`
+1 -1
View File
@@ -1,6 +1,6 @@
# src/tools/lsp/ — LSP Tool Implementations
**Generated:** 2026-04-11
**Generated:** 2026-05-08
## OVERVIEW
+8 -8
View File
@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs"
import { tmpdir } from "os"
import { join } from "path"
import os from "os"
import * as configModule from "./config"
import { lspManager } from "./lsp-server"
@@ -40,7 +40,7 @@ describe("directory diagnostics", () => {
priority: 1,
},
})
spyOn(lspManager, "getClient").mockImplementation(getClientMock)
spyOn(lspManager, "getClient").mockImplementation(getClientMock as never)
spyOn(lspManager, "releaseClient").mockImplementation(releaseClientMock)
})
@@ -50,7 +50,7 @@ describe("directory diagnostics", () => {
describe("isDirectoryPath", () => {
it("returns true for existing directory", () => {
const tmp = mkdtempSync(join(os.tmpdir(), "omo-isdir-"))
const tmp = mkdtempSync(join(tmpdir(), "omo-isdir-"))
try {
expect(isDirectoryPath(tmp)).toBe(true)
} finally {
@@ -59,7 +59,7 @@ describe("directory diagnostics", () => {
})
it("returns false for existing file", () => {
const tmp = mkdtempSync(join(os.tmpdir(), "omo-isdir-file-"))
const tmp = mkdtempSync(join(tmpdir(), "omo-isdir-file-"))
try {
const file = join(tmp, "test.txt")
writeFileSync(file, "content")
@@ -70,14 +70,14 @@ describe("directory diagnostics", () => {
})
it("returns false for non-existent path", () => {
const nonExistent = join(os.tmpdir(), "omo-nonexistent-" + Date.now())
const nonExistent = join(tmpdir(), "omo-nonexistent-" + Date.now())
expect(isDirectoryPath(nonExistent)).toBe(false)
})
})
describe("aggregateDiagnosticsForDirectory", () => {
it("throws error when extension does not start with dot", async () => {
const tmp = mkdtempSync(join(os.tmpdir(), "omo-aggr-ext-"))
const tmp = mkdtempSync(join(tmpdir(), "omo-aggr-ext-"))
try {
await expect(aggregateDiagnosticsForDirectory(tmp, "ts")).rejects.toThrow(
'Extension must start with a dot (e.g., ".ts", not "ts")'
@@ -88,14 +88,14 @@ describe("directory diagnostics", () => {
})
it("throws error when directory does not exist", async () => {
const nonExistent = join(os.tmpdir(), "omo-nonexistent-dir-" + Date.now())
const nonExistent = join(tmpdir(), "omo-nonexistent-dir-" + Date.now())
await expect(aggregateDiagnosticsForDirectory(nonExistent, ".ts")).rejects.toThrow(
"Directory does not exist"
)
})
it("#given diagnostics from multiple files #when aggregating directory diagnostics #then each entry includes the source file path", async () => {
const tmp = mkdtempSync(join(os.tmpdir(), "omo-aggr-files-"))
const tmp = mkdtempSync(join(tmpdir(), "omo-aggr-files-"))
try {
const firstFile = join(tmp, "first.ts")
const secondFile = join(tmp, "second.ts")
+2 -2
View File
@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test"
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs"
import { tmpdir } from "os"
import { join } from "path"
import os from "os"
import { inferExtensionFromDirectory } from "./infer-extension"
@@ -9,7 +9,7 @@ describe("inferExtensionFromDirectory", () => {
let tmpDir: string
beforeEach(() => {
tmpDir = mkdtempSync(join(os.tmpdir(), "omo-infer-ext-"))
tmpDir = mkdtempSync(join(tmpdir(), "omo-infer-ext-"))
})
afterEach(() => {
+1 -1
View File
@@ -1,4 +1,4 @@
import { spawn as bunSpawn } from "bun"
import { spawn as bunSpawn } from "../../shared/bun-spawn-shim"
import { spawn as nodeSpawn, type ChildProcess } from "node:child_process"
import { existsSync, statSync } from "fs"
import { log } from "../../shared/logger"
+3 -3
View File
@@ -1,14 +1,14 @@
import { describe, expect, it } from "bun:test"
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs"
import { tmpdir } from "os"
import { join } from "path"
import os from "os"
import { findWorkspaceRoot } from "./lsp-client-wrapper"
describe("lsp utils", () => {
describe("findWorkspaceRoot", () => {
it("returns an existing directory even when the file path points to a non-existent nested path", () => {
const tmp = mkdtempSync(join(os.tmpdir(), "omo-lsp-root-"))
const tmp = mkdtempSync(join(tmpdir(), "omo-lsp-root-"))
try {
// Add a marker so the function can discover the workspace root.
writeFileSync(join(tmp, "package.json"), "{}")
@@ -23,7 +23,7 @@ describe("lsp utils", () => {
})
it("prefers the nearest marker directory when markers exist above the file", () => {
const tmp = mkdtempSync(join(os.tmpdir(), "omo-lsp-marker-"))
const tmp = mkdtempSync(join(tmpdir(), "omo-lsp-marker-"))
try {
const repo = join(tmp, "repo")
const src = join(repo, "src")
+26
View File
@@ -192,6 +192,32 @@ describe("skill_mcp tool", () => {
{},
)
})
it("passes toolContext.directory to the manager", 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: () => "session-1",
})
// when
await tool.execute({ mcp_name: "test-server", tool_name: "some-tool" }, mockContext)
// then
expect(callToolSpy).toHaveBeenCalledWith(
expect.objectContaining({ directory: "/test" }),
expect.any(Object),
"some-tool",
{},
)
})
})
})
+1
View File
@@ -144,6 +144,7 @@ export function createSkillMcpTool(options: SkillMcpToolOptions): ToolDefinition
skillName: found.skill.name,
sessionID,
scope: found.skill.scope,
directory: toolContext.directory,
}
const context: SkillMcpServerContext = {
+23
View File
@@ -5,6 +5,7 @@ import type { ToolContext } from "@opencode-ai/plugin/tool"
import type { LoadedSkill } from "../../features/opencode-skill-loader/types"
import * as skillContent from "../../features/opencode-skill-loader/skill-content"
import * as commandDiscovery from "../slashcommand/command-discovery"
import type { CommandInfo } from "../slashcommand/types"
const discoverCommandsSync = mock(() => [])
@@ -128,4 +129,26 @@ describe("createSkillTool", () => {
expect(clearSkillCache.mock.calls.length).toBe(baselineClearSkillCacheCalls + 2)
expect(getAllSkills.mock.calls.length).toBe(baselineGetAllSkillsCalls + 4)
})
it("executes precomputed commands without rediscovering commands", async () => {
// given
const baselineDiscoverCommandsSyncCalls = discoverCommandsSync.mock.calls.length
const command: CommandInfo = {
name: "seeded-command",
metadata: {
name: "seeded-command",
description: "Seeded command",
},
content: "Seeded command body",
scope: "project",
}
const skillTool = await createSkillTool({ skills: [], commands: [command] })
// when
const result = await skillTool.execute({ name: "seeded-command" }, mockContext)
// then
expect(result).toContain("Seeded command body")
expect(discoverCommandsSync.mock.calls.length).toBe(baselineDiscoverCommandsSyncCalls)
})
})
+3
View File
@@ -36,6 +36,7 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
const discovered = (await getAllSkills({
disabledSkills: options?.disabledSkills,
browserProvider: options?.browserProvider,
teamModeEnabled: options?.teamModeEnabled,
})) ?? []
const allSkills = options.skills ? [...options.skills] : discovered
@@ -51,6 +52,8 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
}
const getCommands = (): CommandInfo[] => {
if (options.commands) return [...options.commands]
return commandDiscovery.discoverCommandsSync(undefined, {
pluginsEnabled: options.pluginsEnabled,
enabledPluginsOverride: options.enabledPluginsOverride,
+2
View File
@@ -35,6 +35,8 @@ export interface SkillLoadOptions {
disabledSkills?: Set<string>
/** Browser automation provider for provider-gated skill filtering */
browserProvider?: BrowserAutomationProvider
/** Whether team mode built-in docs should be exposed */
teamModeEnabled?: boolean
/** Include Claude marketplace plugin commands in discovery (default: true) */
pluginsEnabled?: boolean
/** Override plugin enablement from Claude settings by plugin key */