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:
+77
-88
@@ -1,108 +1,97 @@
|
||||
# src/tools/ - 26 Tools Across 16 Directories
|
||||
# src/tools/ — 20–39 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,4 +1,4 @@
|
||||
import { spawn } from "bun"
|
||||
import { spawn } from "../../shared/bun-spawn-shim"
|
||||
import { existsSync } from "fs"
|
||||
import {
|
||||
getSgCliPath,
|
||||
|
||||
@@ -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
@@ -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,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,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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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", () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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 () => ({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
import { resolve } from "node:path"
|
||||
import { spawn } from "bun"
|
||||
import { spawn } from "../../shared/bun-spawn-shim"
|
||||
import {
|
||||
resolveGrepCli,
|
||||
type GrepBackend,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { spawn } from "bun"
|
||||
import { spawn } from "../../shared/bun-spawn-shim"
|
||||
import {
|
||||
resolveGrepCli,
|
||||
type ResolvedCli,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# src/tools/hashline-edit/ — Hash-Anchored File Edit Tool
|
||||
|
||||
**Generated:** 2026-04-11
|
||||
**Generated:** 2026-05-08
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -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")
|
||||
})
|
||||
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
# src/tools/lsp/ — LSP Tool Implementations
|
||||
|
||||
**Generated:** 2026-04-11
|
||||
**Generated:** 2026-05-08
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
{},
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user