refactor: rename sisyphus_task to delegate_task
- Rename directories: sisyphus-task → delegate-task - Rename types: SisyphusTaskArgs → DelegateTaskArgs, etc. - Rename functions: createSisyphusTask → createDelegateTask - Rename constants: SISYPHUS_TASK_* → DELEGATE_TASK_* - Update tool name: sisyphus_task → delegate_task - Update all prompts, docs, and tests
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
import type { CategoryConfig } from "../../config/schema"
|
||||
|
||||
export const VISUAL_CATEGORY_PROMPT_APPEND = `<Category_Context>
|
||||
You are working on VISUAL/UI tasks.
|
||||
|
||||
Design-first mindset:
|
||||
- Bold aesthetic choices over safe defaults
|
||||
- Unexpected layouts, asymmetry, grid-breaking elements
|
||||
- Distinctive typography (avoid: Arial, Inter, Roboto, Space Grotesk)
|
||||
- Cohesive color palettes with sharp accents
|
||||
- High-impact animations with staggered reveals
|
||||
- Atmosphere: gradient meshes, noise textures, layered transparencies
|
||||
|
||||
AVOID: Generic fonts, purple gradients on white, predictable layouts, cookie-cutter patterns.
|
||||
</Category_Context>`
|
||||
|
||||
export const STRATEGIC_CATEGORY_PROMPT_APPEND = `<Category_Context>
|
||||
You are working on BUSINESS LOGIC / ARCHITECTURE tasks.
|
||||
|
||||
Strategic advisor mindset:
|
||||
- Bias toward simplicity: least complex solution that fulfills requirements
|
||||
- Leverage existing code/patterns over new components
|
||||
- Prioritize developer experience and maintainability
|
||||
- One clear recommendation with effort estimate (Quick/Short/Medium/Large)
|
||||
- Signal when advanced approach warranted
|
||||
|
||||
Response format:
|
||||
- Bottom line (2-3 sentences)
|
||||
- Action plan (numbered steps)
|
||||
- Risks and mitigations (if relevant)
|
||||
</Category_Context>`
|
||||
|
||||
export const ARTISTRY_CATEGORY_PROMPT_APPEND = `<Category_Context>
|
||||
You are working on HIGHLY CREATIVE / ARTISTIC tasks.
|
||||
|
||||
Artistic genius mindset:
|
||||
- Push far beyond conventional boundaries
|
||||
- Explore radical, unconventional directions
|
||||
- Surprise and delight: unexpected twists, novel combinations
|
||||
- Rich detail and vivid expression
|
||||
- Break patterns deliberately when it serves the creative vision
|
||||
|
||||
Approach:
|
||||
- Generate diverse, bold options first
|
||||
- Embrace ambiguity and wild experimentation
|
||||
- Balance novelty with coherence
|
||||
- This is for tasks requiring exceptional creativity
|
||||
</Category_Context>`
|
||||
|
||||
export const QUICK_CATEGORY_PROMPT_APPEND = `<Category_Context>
|
||||
You are working on SMALL / QUICK tasks.
|
||||
|
||||
Efficient execution mindset:
|
||||
- Fast, focused, minimal overhead
|
||||
- Get to the point immediately
|
||||
- No over-engineering
|
||||
- Simple solutions for simple problems
|
||||
|
||||
Approach:
|
||||
- Minimal viable implementation
|
||||
- Skip unnecessary abstractions
|
||||
- Direct and concise
|
||||
</Category_Context>
|
||||
|
||||
<Caller_Warning>
|
||||
⚠️ THIS CATEGORY USES A LESS CAPABLE MODEL (claude-haiku-4-5).
|
||||
|
||||
The model executing this task has LIMITED reasoning capacity. Your prompt MUST be:
|
||||
|
||||
**EXHAUSTIVELY EXPLICIT** - Leave NOTHING to interpretation:
|
||||
1. MUST DO: List every required action as atomic, numbered steps
|
||||
2. MUST NOT DO: Explicitly forbid likely mistakes and deviations
|
||||
3. EXPECTED OUTPUT: Describe exact success criteria with concrete examples
|
||||
|
||||
**WHY THIS MATTERS:**
|
||||
- Less capable models WILL deviate without explicit guardrails
|
||||
- Vague instructions → unpredictable results
|
||||
- Implicit expectations → missed requirements
|
||||
|
||||
**PROMPT STRUCTURE (MANDATORY):**
|
||||
\`\`\`
|
||||
TASK: [One-sentence goal]
|
||||
|
||||
MUST DO:
|
||||
1. [Specific action with exact details]
|
||||
2. [Another specific action]
|
||||
...
|
||||
|
||||
MUST NOT DO:
|
||||
- [Forbidden action + why]
|
||||
- [Another forbidden action]
|
||||
...
|
||||
|
||||
EXPECTED OUTPUT:
|
||||
- [Exact deliverable description]
|
||||
- [Success criteria / verification method]
|
||||
\`\`\`
|
||||
|
||||
If your prompt lacks this structure, REWRITE IT before delegating.
|
||||
</Caller_Warning>`
|
||||
|
||||
export const MOST_CAPABLE_CATEGORY_PROMPT_APPEND = `<Category_Context>
|
||||
You are working on COMPLEX / MOST-CAPABLE tasks.
|
||||
|
||||
Maximum capability mindset:
|
||||
- Bring full reasoning power to bear
|
||||
- Consider all edge cases and implications
|
||||
- Deep analysis before action
|
||||
- Quality over speed
|
||||
|
||||
Approach:
|
||||
- Thorough understanding first
|
||||
- Comprehensive solution design
|
||||
- Meticulous execution
|
||||
- This is for the most challenging problems
|
||||
</Category_Context>`
|
||||
|
||||
export const WRITING_CATEGORY_PROMPT_APPEND = `<Category_Context>
|
||||
You are working on WRITING / PROSE tasks.
|
||||
|
||||
Wordsmith mindset:
|
||||
- Clear, flowing prose
|
||||
- Appropriate tone and voice
|
||||
- Engaging and readable
|
||||
- Proper structure and organization
|
||||
|
||||
Approach:
|
||||
- Understand the audience
|
||||
- Draft with care
|
||||
- Polish for clarity and impact
|
||||
- Documentation, READMEs, articles, technical writing
|
||||
</Category_Context>`
|
||||
|
||||
export const GENERAL_CATEGORY_PROMPT_APPEND = `<Category_Context>
|
||||
You are working on GENERAL tasks.
|
||||
|
||||
Balanced execution mindset:
|
||||
- Practical, straightforward approach
|
||||
- Good enough is good enough
|
||||
- Focus on getting things done
|
||||
|
||||
Approach:
|
||||
- Standard best practices
|
||||
- Reasonable trade-offs
|
||||
- Efficient completion
|
||||
</Category_Context>
|
||||
|
||||
<Caller_Warning>
|
||||
⚠️ THIS CATEGORY USES A MID-TIER MODEL (claude-sonnet-4-5).
|
||||
|
||||
While capable, this model benefits significantly from EXPLICIT instructions.
|
||||
|
||||
**PROVIDE CLEAR STRUCTURE:**
|
||||
1. MUST DO: Enumerate required actions explicitly - don't assume inference
|
||||
2. MUST NOT DO: State forbidden actions to prevent scope creep or wrong approaches
|
||||
3. EXPECTED OUTPUT: Define concrete success criteria and deliverables
|
||||
|
||||
**COMMON PITFALLS WITHOUT EXPLICIT INSTRUCTIONS:**
|
||||
- Model may take shortcuts that miss edge cases
|
||||
- Implicit requirements get overlooked
|
||||
- Output format may not match expectations
|
||||
- Scope may expand beyond intended boundaries
|
||||
|
||||
**RECOMMENDED PROMPT PATTERN:**
|
||||
\`\`\`
|
||||
TASK: [Clear, single-purpose goal]
|
||||
|
||||
CONTEXT: [Relevant background the model needs]
|
||||
|
||||
MUST DO:
|
||||
- [Explicit requirement 1]
|
||||
- [Explicit requirement 2]
|
||||
|
||||
MUST NOT DO:
|
||||
- [Boundary/constraint 1]
|
||||
- [Boundary/constraint 2]
|
||||
|
||||
EXPECTED OUTPUT:
|
||||
- [What success looks like]
|
||||
- [How to verify completion]
|
||||
\`\`\`
|
||||
|
||||
The more explicit your prompt, the better the results.
|
||||
</Caller_Warning>`
|
||||
|
||||
export const DEFAULT_CATEGORIES: Record<string, CategoryConfig> = {
|
||||
"visual-engineering": {
|
||||
model: "google/gemini-3-pro-preview",
|
||||
temperature: 0.7,
|
||||
},
|
||||
ultrabrain: {
|
||||
model: "openai/gpt-5.2",
|
||||
temperature: 0.1,
|
||||
},
|
||||
artistry: {
|
||||
model: "google/gemini-3-pro-preview",
|
||||
temperature: 0.9,
|
||||
},
|
||||
quick: {
|
||||
model: "anthropic/claude-haiku-4-5",
|
||||
temperature: 0.3,
|
||||
},
|
||||
"most-capable": {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
temperature: 0.1,
|
||||
},
|
||||
writing: {
|
||||
model: "google/gemini-3-flash-preview",
|
||||
temperature: 0.5,
|
||||
},
|
||||
general: {
|
||||
model: "anthropic/claude-sonnet-4-5",
|
||||
temperature: 0.3,
|
||||
},
|
||||
}
|
||||
|
||||
export const CATEGORY_PROMPT_APPENDS: Record<string, string> = {
|
||||
"visual-engineering": VISUAL_CATEGORY_PROMPT_APPEND,
|
||||
ultrabrain: STRATEGIC_CATEGORY_PROMPT_APPEND,
|
||||
artistry: ARTISTRY_CATEGORY_PROMPT_APPEND,
|
||||
quick: QUICK_CATEGORY_PROMPT_APPEND,
|
||||
"most-capable": MOST_CAPABLE_CATEGORY_PROMPT_APPEND,
|
||||
writing: WRITING_CATEGORY_PROMPT_APPEND,
|
||||
general: GENERAL_CATEGORY_PROMPT_APPEND,
|
||||
}
|
||||
|
||||
export const CATEGORY_DESCRIPTIONS: Record<string, string> = {
|
||||
"visual-engineering": "Frontend, UI/UX, design, styling, animation",
|
||||
ultrabrain: "Strict architecture design, very complex business logic",
|
||||
artistry: "Highly creative/artistic tasks, novel ideas",
|
||||
quick: "Cheap & fast - small tasks with minimal overhead, budget-friendly",
|
||||
"most-capable": "Complex tasks requiring maximum capability",
|
||||
writing: "Documentation, prose, technical writing",
|
||||
general: "General purpose tasks",
|
||||
}
|
||||
|
||||
const BUILTIN_CATEGORIES = Object.keys(DEFAULT_CATEGORIES).join(", ")
|
||||
|
||||
export const DELEGATE_TASK_DESCRIPTION = `Spawn agent task with category-based or direct agent selection.
|
||||
|
||||
MUTUALLY EXCLUSIVE: Provide EITHER category OR agent, not both (unless resuming).
|
||||
|
||||
- category: Use predefined category (${BUILTIN_CATEGORIES}) → Spawns Sisyphus-Junior with category config
|
||||
- agent: Use specific agent directly (e.g., "oracle", "explore")
|
||||
- background: true=async (returns task_id), false=sync (waits for result). Default: false. Use background=true ONLY for parallel exploration with 5+ independent queries.
|
||||
- resume: Session ID to resume (from previous task output). Continues agent with FULL CONTEXT PRESERVED - saves tokens, maintains continuity.
|
||||
- skills: Array of skill names to prepend to prompt (e.g., ["playwright", "frontend-ui-ux"]). Skills will be resolved and their content prepended with a separator. Empty array [] is NOT allowed - use null if no skills needed.
|
||||
|
||||
**WHEN TO USE resume:**
|
||||
- Task failed/incomplete → resume with "fix: [specific issue]"
|
||||
- Need follow-up on previous result → resume with additional question
|
||||
- Multi-turn conversation with same agent → always resume instead of new task
|
||||
|
||||
Prompts MUST be in English.`
|
||||
@@ -0,0 +1,3 @@
|
||||
export { createDelegateTask, type DelegateTaskToolOptions } from "./tools"
|
||||
export type * from "./types"
|
||||
export * from "./constants"
|
||||
@@ -0,0 +1,977 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import { DEFAULT_CATEGORIES, CATEGORY_PROMPT_APPENDS, CATEGORY_DESCRIPTIONS, DELEGATE_TASK_DESCRIPTION } from "./constants"
|
||||
import type { CategoryConfig } from "../../config/schema"
|
||||
|
||||
function resolveCategoryConfig(
|
||||
categoryName: string,
|
||||
options: {
|
||||
userCategories?: Record<string, CategoryConfig>
|
||||
parentModelString?: string
|
||||
systemDefaultModel?: string
|
||||
}
|
||||
): { config: CategoryConfig; promptAppend: string; model: string | undefined } | null {
|
||||
const { userCategories, parentModelString, systemDefaultModel } = options
|
||||
const defaultConfig = DEFAULT_CATEGORIES[categoryName]
|
||||
const userConfig = userCategories?.[categoryName]
|
||||
const defaultPromptAppend = CATEGORY_PROMPT_APPENDS[categoryName] ?? ""
|
||||
|
||||
if (!defaultConfig && !userConfig) {
|
||||
return null
|
||||
}
|
||||
|
||||
const model = userConfig?.model ?? parentModelString ?? defaultConfig?.model ?? systemDefaultModel
|
||||
const config: CategoryConfig = {
|
||||
...defaultConfig,
|
||||
...userConfig,
|
||||
model,
|
||||
}
|
||||
|
||||
let promptAppend = defaultPromptAppend
|
||||
if (userConfig?.prompt_append) {
|
||||
promptAppend = defaultPromptAppend
|
||||
? defaultPromptAppend + "\n\n" + userConfig.prompt_append
|
||||
: userConfig.prompt_append
|
||||
}
|
||||
|
||||
return { config, promptAppend, model }
|
||||
}
|
||||
|
||||
describe("sisyphus-task", () => {
|
||||
describe("DEFAULT_CATEGORIES", () => {
|
||||
test("visual-engineering category has gemini model", () => {
|
||||
// #given
|
||||
const category = DEFAULT_CATEGORIES["visual-engineering"]
|
||||
|
||||
// #when / #then
|
||||
expect(category).toBeDefined()
|
||||
expect(category.model).toBe("google/gemini-3-pro-preview")
|
||||
expect(category.temperature).toBe(0.7)
|
||||
})
|
||||
|
||||
test("ultrabrain category has gpt model", () => {
|
||||
// #given
|
||||
const category = DEFAULT_CATEGORIES["ultrabrain"]
|
||||
|
||||
// #when / #then
|
||||
expect(category).toBeDefined()
|
||||
expect(category.model).toBe("openai/gpt-5.2")
|
||||
expect(category.temperature).toBe(0.1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("CATEGORY_PROMPT_APPENDS", () => {
|
||||
test("visual-engineering category has design-focused prompt", () => {
|
||||
// #given
|
||||
const promptAppend = CATEGORY_PROMPT_APPENDS["visual-engineering"]
|
||||
|
||||
// #when / #then
|
||||
expect(promptAppend).toContain("VISUAL/UI")
|
||||
expect(promptAppend).toContain("Design-first")
|
||||
})
|
||||
|
||||
test("ultrabrain category has strategic prompt", () => {
|
||||
// #given
|
||||
const promptAppend = CATEGORY_PROMPT_APPENDS["ultrabrain"]
|
||||
|
||||
// #when / #then
|
||||
expect(promptAppend).toContain("BUSINESS LOGIC")
|
||||
expect(promptAppend).toContain("Strategic advisor")
|
||||
})
|
||||
})
|
||||
|
||||
describe("CATEGORY_DESCRIPTIONS", () => {
|
||||
test("has description for all default categories", () => {
|
||||
// #given
|
||||
const defaultCategoryNames = Object.keys(DEFAULT_CATEGORIES)
|
||||
|
||||
// #when / #then
|
||||
for (const name of defaultCategoryNames) {
|
||||
expect(CATEGORY_DESCRIPTIONS[name]).toBeDefined()
|
||||
expect(CATEGORY_DESCRIPTIONS[name].length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
test("most-capable category exists and has description", () => {
|
||||
// #given / #when
|
||||
const description = CATEGORY_DESCRIPTIONS["most-capable"]
|
||||
|
||||
// #then
|
||||
expect(description).toBeDefined()
|
||||
expect(description).toContain("Complex")
|
||||
})
|
||||
})
|
||||
|
||||
describe("DELEGATE_TASK_DESCRIPTION", () => {
|
||||
test("documents background parameter as required with default false", () => {
|
||||
// #given / #when / #then
|
||||
expect(DELEGATE_TASK_DESCRIPTION).toContain("background")
|
||||
expect(DELEGATE_TASK_DESCRIPTION).toContain("Default: false")
|
||||
})
|
||||
|
||||
test("warns about parallel exploration usage", () => {
|
||||
// #given / #when / #then
|
||||
expect(DELEGATE_TASK_DESCRIPTION).toContain("5+")
|
||||
})
|
||||
})
|
||||
|
||||
describe("resolveCategoryConfig", () => {
|
||||
test("returns null for unknown category without user config", () => {
|
||||
// #given
|
||||
const categoryName = "unknown-category"
|
||||
|
||||
// #when
|
||||
const result = resolveCategoryConfig(categoryName, {})
|
||||
|
||||
// #then
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
test("returns default config for builtin category", () => {
|
||||
// #given
|
||||
const categoryName = "visual-engineering"
|
||||
|
||||
// #when
|
||||
const result = resolveCategoryConfig(categoryName, {})
|
||||
|
||||
// #then
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.config.model).toBe("google/gemini-3-pro-preview")
|
||||
expect(result!.promptAppend).toContain("VISUAL/UI")
|
||||
})
|
||||
|
||||
test("user config overrides default model", () => {
|
||||
// #given
|
||||
const categoryName = "visual-engineering"
|
||||
const userCategories = {
|
||||
"visual-engineering": { model: "anthropic/claude-opus-4-5" },
|
||||
}
|
||||
|
||||
// #when
|
||||
const result = resolveCategoryConfig(categoryName, { userCategories })
|
||||
|
||||
// #then
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.config.model).toBe("anthropic/claude-opus-4-5")
|
||||
})
|
||||
|
||||
test("user prompt_append is appended to default", () => {
|
||||
// #given
|
||||
const categoryName = "visual-engineering"
|
||||
const userCategories = {
|
||||
"visual-engineering": {
|
||||
model: "google/gemini-3-pro-preview",
|
||||
prompt_append: "Custom instructions here",
|
||||
},
|
||||
}
|
||||
|
||||
// #when
|
||||
const result = resolveCategoryConfig(categoryName, { userCategories })
|
||||
|
||||
// #then
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.promptAppend).toContain("VISUAL/UI")
|
||||
expect(result!.promptAppend).toContain("Custom instructions here")
|
||||
})
|
||||
|
||||
test("user can define custom category", () => {
|
||||
// #given
|
||||
const categoryName = "my-custom"
|
||||
const userCategories = {
|
||||
"my-custom": {
|
||||
model: "openai/gpt-5.2",
|
||||
temperature: 0.5,
|
||||
prompt_append: "You are a custom agent",
|
||||
},
|
||||
}
|
||||
|
||||
// #when
|
||||
const result = resolveCategoryConfig(categoryName, { userCategories })
|
||||
|
||||
// #then
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.config.model).toBe("openai/gpt-5.2")
|
||||
expect(result!.config.temperature).toBe(0.5)
|
||||
expect(result!.promptAppend).toBe("You are a custom agent")
|
||||
})
|
||||
|
||||
test("user category overrides temperature", () => {
|
||||
// #given
|
||||
const categoryName = "visual-engineering"
|
||||
const userCategories = {
|
||||
"visual-engineering": {
|
||||
model: "google/gemini-3-pro-preview",
|
||||
temperature: 0.3,
|
||||
},
|
||||
}
|
||||
|
||||
// #when
|
||||
const result = resolveCategoryConfig(categoryName, { userCategories })
|
||||
|
||||
// #then
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.config.temperature).toBe(0.3)
|
||||
})
|
||||
|
||||
test("parentModelString is used when no user model and takes precedence over default", () => {
|
||||
// #given
|
||||
const categoryName = "visual-engineering"
|
||||
const parentModelString = "cliproxy/claude-opus-4-5"
|
||||
|
||||
// #when
|
||||
const result = resolveCategoryConfig(categoryName, { parentModelString })
|
||||
|
||||
// #then
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.config.model).toBe("cliproxy/claude-opus-4-5")
|
||||
})
|
||||
|
||||
test("user model takes precedence over parentModelString", () => {
|
||||
// #given
|
||||
const categoryName = "visual-engineering"
|
||||
const userCategories = {
|
||||
"visual-engineering": { model: "my-provider/my-model" },
|
||||
}
|
||||
const parentModelString = "cliproxy/claude-opus-4-5"
|
||||
|
||||
// #when
|
||||
const result = resolveCategoryConfig(categoryName, { userCategories, parentModelString })
|
||||
|
||||
// #then
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.config.model).toBe("my-provider/my-model")
|
||||
})
|
||||
|
||||
test("default model is used when no user model and no parentModelString", () => {
|
||||
// #given
|
||||
const categoryName = "visual-engineering"
|
||||
|
||||
// #when
|
||||
const result = resolveCategoryConfig(categoryName, {})
|
||||
|
||||
// #then
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.config.model).toBe("google/gemini-3-pro-preview")
|
||||
})
|
||||
})
|
||||
|
||||
describe("category variant", () => {
|
||||
test("passes variant to background model payload", async () => {
|
||||
// #given
|
||||
const { createDelegateTask } = require("./tools")
|
||||
let launchInput: any
|
||||
|
||||
const mockManager = {
|
||||
launch: async (input: any) => {
|
||||
launchInput = input
|
||||
return {
|
||||
id: "task-variant",
|
||||
sessionID: "session-variant",
|
||||
description: "Variant task",
|
||||
agent: "Sisyphus-Junior",
|
||||
status: "running",
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const mockClient = {
|
||||
app: { agents: async () => ({ data: [] }) },
|
||||
config: { get: async () => ({}) },
|
||||
session: {
|
||||
create: async () => ({ data: { id: "test-session" } }),
|
||||
prompt: async () => ({ data: {} }),
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
}
|
||||
|
||||
const tool = createDelegateTask({
|
||||
manager: mockManager,
|
||||
client: mockClient,
|
||||
userCategories: {
|
||||
ultrabrain: { model: "openai/gpt-5.2", variant: "xhigh" },
|
||||
},
|
||||
})
|
||||
|
||||
const toolContext = {
|
||||
sessionID: "parent-session",
|
||||
messageID: "parent-message",
|
||||
agent: "Sisyphus",
|
||||
abort: new AbortController().signal,
|
||||
}
|
||||
|
||||
// #when
|
||||
await tool.execute(
|
||||
{
|
||||
description: "Variant task",
|
||||
prompt: "Do something",
|
||||
category: "ultrabrain",
|
||||
run_in_background: true,
|
||||
skills: null,
|
||||
},
|
||||
toolContext
|
||||
)
|
||||
|
||||
// #then
|
||||
expect(launchInput.model).toEqual({
|
||||
providerID: "openai",
|
||||
modelID: "gpt-5.2",
|
||||
variant: "xhigh",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("skills parameter", () => {
|
||||
test("DELEGATE_TASK_DESCRIPTION documents skills parameter with null option", () => {
|
||||
// #given / #when / #then
|
||||
expect(DELEGATE_TASK_DESCRIPTION).toContain("skills")
|
||||
expect(DELEGATE_TASK_DESCRIPTION).toContain("Array of skill names")
|
||||
expect(DELEGATE_TASK_DESCRIPTION).toContain("Empty array [] is NOT allowed")
|
||||
expect(DELEGATE_TASK_DESCRIPTION).toContain("null if no skills needed")
|
||||
})
|
||||
|
||||
test("skills parameter is required - returns error when not provided", async () => {
|
||||
// #given
|
||||
const { createDelegateTask } = require("./tools")
|
||||
|
||||
const mockManager = { launch: async () => ({}) }
|
||||
const mockClient = {
|
||||
app: { agents: async () => ({ data: [] }) },
|
||||
config: { get: async () => ({}) },
|
||||
session: {
|
||||
create: async () => ({ data: { id: "test-session" } }),
|
||||
prompt: async () => ({ data: {} }),
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
}
|
||||
|
||||
const tool = createDelegateTask({
|
||||
manager: mockManager,
|
||||
client: mockClient,
|
||||
})
|
||||
|
||||
const toolContext = {
|
||||
sessionID: "parent-session",
|
||||
messageID: "parent-message",
|
||||
agent: "Sisyphus",
|
||||
abort: new AbortController().signal,
|
||||
}
|
||||
|
||||
// #when - skills not provided (undefined)
|
||||
const result = await tool.execute(
|
||||
{
|
||||
description: "Test task",
|
||||
prompt: "Do something",
|
||||
category: "ultrabrain",
|
||||
run_in_background: false,
|
||||
},
|
||||
toolContext
|
||||
)
|
||||
|
||||
// #then - should return error about missing skills
|
||||
expect(result).toContain("skills")
|
||||
expect(result).toContain("REQUIRED")
|
||||
})
|
||||
|
||||
test("empty array [] returns error with available skills list", async () => {
|
||||
// #given
|
||||
const { createDelegateTask } = require("./tools")
|
||||
|
||||
const mockManager = { launch: async () => ({}) }
|
||||
const mockClient = {
|
||||
app: { agents: async () => ({ data: [] }) },
|
||||
config: { get: async () => ({}) },
|
||||
session: {
|
||||
create: async () => ({ data: { id: "test-session" } }),
|
||||
prompt: async () => ({ data: {} }),
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
}
|
||||
|
||||
const tool = createDelegateTask({
|
||||
manager: mockManager,
|
||||
client: mockClient,
|
||||
})
|
||||
|
||||
const toolContext = {
|
||||
sessionID: "parent-session",
|
||||
messageID: "parent-message",
|
||||
agent: "Sisyphus",
|
||||
abort: new AbortController().signal,
|
||||
}
|
||||
|
||||
// #when - empty array passed
|
||||
const result = await tool.execute(
|
||||
{
|
||||
description: "Test task",
|
||||
prompt: "Do something",
|
||||
category: "ultrabrain",
|
||||
run_in_background: false,
|
||||
skills: [],
|
||||
},
|
||||
toolContext
|
||||
)
|
||||
|
||||
// #then - should return error about empty array with guidance
|
||||
expect(result).toContain("❌")
|
||||
expect(result).toContain("Empty array []")
|
||||
expect(result).toContain("not allowed")
|
||||
expect(result).toContain("skills=null")
|
||||
})
|
||||
|
||||
test("null skills is allowed and proceeds without skill content", async () => {
|
||||
// #given
|
||||
const { createDelegateTask } = require("./tools")
|
||||
let promptBody: any
|
||||
|
||||
const mockManager = { launch: async () => ({}) }
|
||||
const mockClient = {
|
||||
app: { agents: async () => ({ data: [] }) },
|
||||
config: { get: async () => ({}) },
|
||||
session: {
|
||||
get: async () => ({ data: { directory: "/project" } }),
|
||||
create: async () => ({ data: { id: "test-session" } }),
|
||||
prompt: async (input: any) => {
|
||||
promptBody = input.body
|
||||
return { data: {} }
|
||||
},
|
||||
messages: async () => ({
|
||||
data: [{ info: { role: "assistant" }, parts: [{ type: "text", text: "Done" }] }]
|
||||
}),
|
||||
status: async () => ({ data: {} }),
|
||||
},
|
||||
}
|
||||
|
||||
const tool = createDelegateTask({
|
||||
manager: mockManager,
|
||||
client: mockClient,
|
||||
})
|
||||
|
||||
const toolContext = {
|
||||
sessionID: "parent-session",
|
||||
messageID: "parent-message",
|
||||
agent: "Sisyphus",
|
||||
abort: new AbortController().signal,
|
||||
}
|
||||
|
||||
// #when - null skills passed
|
||||
await tool.execute(
|
||||
{
|
||||
description: "Test task",
|
||||
prompt: "Do something",
|
||||
category: "ultrabrain",
|
||||
run_in_background: false,
|
||||
skills: null,
|
||||
},
|
||||
toolContext
|
||||
)
|
||||
|
||||
// #then - should proceed without system content from skills
|
||||
expect(promptBody).toBeDefined()
|
||||
// system should not contain skill content (only category prompt append if any)
|
||||
}, { timeout: 20000 })
|
||||
})
|
||||
|
||||
describe("resume with background parameter", () => {
|
||||
test("resume with background=false should wait for result and return content", async () => {
|
||||
// Note: This test needs extended timeout because the implementation has MIN_STABILITY_TIME_MS = 5000
|
||||
// #given
|
||||
const { createDelegateTask } = require("./tools")
|
||||
|
||||
const mockTask = {
|
||||
id: "task-123",
|
||||
sessionID: "ses_resume_test",
|
||||
description: "Resumed task",
|
||||
agent: "explore",
|
||||
status: "running",
|
||||
}
|
||||
|
||||
const mockManager = {
|
||||
resume: async () => mockTask,
|
||||
launch: async () => mockTask,
|
||||
}
|
||||
|
||||
const mockClient = {
|
||||
session: {
|
||||
prompt: async () => ({ data: {} }),
|
||||
messages: async () => ({
|
||||
data: [
|
||||
{
|
||||
info: { role: "assistant", time: { created: Date.now() } },
|
||||
parts: [{ type: "text", text: "This is the resumed task result" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
config: { get: async () => ({}) },
|
||||
app: {
|
||||
agents: async () => ({ data: [] }),
|
||||
},
|
||||
}
|
||||
|
||||
const tool = createDelegateTask({
|
||||
manager: mockManager,
|
||||
client: mockClient,
|
||||
})
|
||||
|
||||
const toolContext = {
|
||||
sessionID: "parent-session",
|
||||
messageID: "parent-message",
|
||||
agent: "Sisyphus",
|
||||
abort: new AbortController().signal,
|
||||
}
|
||||
|
||||
// #when
|
||||
const result = await tool.execute(
|
||||
{
|
||||
description: "Resume test",
|
||||
prompt: "Continue the task",
|
||||
resume: "ses_resume_test",
|
||||
run_in_background: false,
|
||||
skills: null,
|
||||
},
|
||||
toolContext
|
||||
)
|
||||
|
||||
// #then - should contain actual result, not just "Background task resumed"
|
||||
expect(result).toContain("This is the resumed task result")
|
||||
expect(result).not.toContain("Background task resumed")
|
||||
}, { timeout: 10000 })
|
||||
|
||||
test("resume with background=true should return immediately without waiting", async () => {
|
||||
// #given
|
||||
const { createDelegateTask } = require("./tools")
|
||||
|
||||
const mockTask = {
|
||||
id: "task-456",
|
||||
sessionID: "ses_bg_resume",
|
||||
description: "Background resumed task",
|
||||
agent: "explore",
|
||||
status: "running",
|
||||
}
|
||||
|
||||
const mockManager = {
|
||||
resume: async () => mockTask,
|
||||
}
|
||||
|
||||
const mockClient = {
|
||||
session: {
|
||||
prompt: async () => ({ data: {} }),
|
||||
messages: async () => ({
|
||||
data: [],
|
||||
}),
|
||||
},
|
||||
config: { get: async () => ({}) },
|
||||
}
|
||||
|
||||
const tool = createDelegateTask({
|
||||
manager: mockManager,
|
||||
client: mockClient,
|
||||
})
|
||||
|
||||
const toolContext = {
|
||||
sessionID: "parent-session",
|
||||
messageID: "parent-message",
|
||||
agent: "Sisyphus",
|
||||
abort: new AbortController().signal,
|
||||
}
|
||||
|
||||
// #when
|
||||
const result = await tool.execute(
|
||||
{
|
||||
description: "Resume bg test",
|
||||
prompt: "Continue in background",
|
||||
resume: "ses_bg_resume",
|
||||
run_in_background: true,
|
||||
skills: null,
|
||||
},
|
||||
toolContext
|
||||
)
|
||||
|
||||
// #then - should return background message
|
||||
expect(result).toContain("Background task resumed")
|
||||
expect(result).toContain("task-456")
|
||||
})
|
||||
})
|
||||
|
||||
describe("sync mode new task (run_in_background=false)", () => {
|
||||
test("sync mode prompt error returns error message immediately", async () => {
|
||||
// #given
|
||||
const { createDelegateTask } = require("./tools")
|
||||
|
||||
const mockManager = {
|
||||
launch: async () => ({}),
|
||||
}
|
||||
|
||||
const mockClient = {
|
||||
session: {
|
||||
get: async () => ({ data: { directory: "/project" } }),
|
||||
create: async () => ({ data: { id: "ses_sync_error_test" } }),
|
||||
prompt: async () => {
|
||||
throw new Error("JSON Parse error: Unexpected EOF")
|
||||
},
|
||||
messages: async () => ({ data: [] }),
|
||||
status: async () => ({ data: {} }),
|
||||
},
|
||||
config: { get: async () => ({}) },
|
||||
app: {
|
||||
agents: async () => ({ data: [{ name: "ultrabrain", mode: "subagent" }] }),
|
||||
},
|
||||
}
|
||||
|
||||
const tool = createDelegateTask({
|
||||
manager: mockManager,
|
||||
client: mockClient,
|
||||
})
|
||||
|
||||
const toolContext = {
|
||||
sessionID: "parent-session",
|
||||
messageID: "parent-message",
|
||||
agent: "Sisyphus",
|
||||
abort: new AbortController().signal,
|
||||
}
|
||||
|
||||
// #when
|
||||
const result = await tool.execute(
|
||||
{
|
||||
description: "Sync error test",
|
||||
prompt: "Do something",
|
||||
category: "ultrabrain",
|
||||
run_in_background: false,
|
||||
skills: null,
|
||||
},
|
||||
toolContext
|
||||
)
|
||||
|
||||
// #then - should return detailed error message with args and stack trace
|
||||
expect(result).toContain("❌")
|
||||
expect(result).toContain("Send prompt failed")
|
||||
expect(result).toContain("JSON Parse error")
|
||||
expect(result).toContain("**Arguments**:")
|
||||
expect(result).toContain("**Stack Trace**:")
|
||||
})
|
||||
|
||||
test("sync mode success returns task result with content", async () => {
|
||||
// #given
|
||||
const { createDelegateTask } = require("./tools")
|
||||
|
||||
const mockManager = {
|
||||
launch: async () => ({}),
|
||||
}
|
||||
|
||||
const mockClient = {
|
||||
session: {
|
||||
get: async () => ({ data: { directory: "/project" } }),
|
||||
create: async () => ({ data: { id: "ses_sync_success" } }),
|
||||
prompt: async () => ({ data: {} }),
|
||||
messages: async () => ({
|
||||
data: [
|
||||
{
|
||||
info: { role: "assistant", time: { created: Date.now() } },
|
||||
parts: [{ type: "text", text: "Sync task completed successfully" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
status: async () => ({ data: { "ses_sync_success": { type: "idle" } } }),
|
||||
},
|
||||
config: { get: async () => ({}) },
|
||||
app: {
|
||||
agents: async () => ({ data: [{ name: "ultrabrain", mode: "subagent" }] }),
|
||||
},
|
||||
}
|
||||
|
||||
const tool = createDelegateTask({
|
||||
manager: mockManager,
|
||||
client: mockClient,
|
||||
})
|
||||
|
||||
const toolContext = {
|
||||
sessionID: "parent-session",
|
||||
messageID: "parent-message",
|
||||
agent: "Sisyphus",
|
||||
abort: new AbortController().signal,
|
||||
}
|
||||
|
||||
// #when
|
||||
const result = await tool.execute(
|
||||
{
|
||||
description: "Sync success test",
|
||||
prompt: "Do something",
|
||||
category: "ultrabrain",
|
||||
run_in_background: false,
|
||||
skills: null,
|
||||
},
|
||||
toolContext
|
||||
)
|
||||
|
||||
// #then - should return the task result content
|
||||
expect(result).toContain("Sync task completed successfully")
|
||||
expect(result).toContain("Task completed")
|
||||
}, { timeout: 20000 })
|
||||
|
||||
test("sync mode agent not found returns helpful error", async () => {
|
||||
// #given
|
||||
const { createDelegateTask } = require("./tools")
|
||||
|
||||
const mockManager = {
|
||||
launch: async () => ({}),
|
||||
}
|
||||
|
||||
const mockClient = {
|
||||
session: {
|
||||
get: async () => ({ data: { directory: "/project" } }),
|
||||
create: async () => ({ data: { id: "ses_agent_notfound" } }),
|
||||
prompt: async () => {
|
||||
throw new Error("Cannot read property 'name' of undefined agent.name")
|
||||
},
|
||||
messages: async () => ({ data: [] }),
|
||||
status: async () => ({ data: {} }),
|
||||
},
|
||||
config: { get: async () => ({}) },
|
||||
app: {
|
||||
agents: async () => ({ data: [{ name: "ultrabrain", mode: "subagent" }] }),
|
||||
},
|
||||
}
|
||||
|
||||
const tool = createDelegateTask({
|
||||
manager: mockManager,
|
||||
client: mockClient,
|
||||
})
|
||||
|
||||
const toolContext = {
|
||||
sessionID: "parent-session",
|
||||
messageID: "parent-message",
|
||||
agent: "Sisyphus",
|
||||
abort: new AbortController().signal,
|
||||
}
|
||||
|
||||
// #when
|
||||
const result = await tool.execute(
|
||||
{
|
||||
description: "Agent not found test",
|
||||
prompt: "Do something",
|
||||
category: "ultrabrain",
|
||||
run_in_background: false,
|
||||
skills: null,
|
||||
},
|
||||
toolContext
|
||||
)
|
||||
|
||||
// #then - should return agent not found error
|
||||
expect(result).toContain("❌")
|
||||
expect(result).toContain("not found")
|
||||
expect(result).toContain("registered")
|
||||
})
|
||||
|
||||
test("sync mode passes category model to prompt", async () => {
|
||||
// #given
|
||||
const { createDelegateTask } = require("./tools")
|
||||
let promptBody: any
|
||||
|
||||
const mockManager = { launch: async () => ({}) }
|
||||
const mockClient = {
|
||||
session: {
|
||||
get: async () => ({ data: { directory: "/project" } }),
|
||||
create: async () => ({ data: { id: "ses_sync_model" } }),
|
||||
prompt: async (input: any) => {
|
||||
promptBody = input.body
|
||||
return { data: {} }
|
||||
},
|
||||
messages: async () => ({
|
||||
data: [{ info: { role: "assistant" }, parts: [{ type: "text", text: "Done" }] }]
|
||||
}),
|
||||
status: async () => ({ data: {} }),
|
||||
},
|
||||
config: { get: async () => ({}) },
|
||||
app: { agents: async () => ({ data: [] }) },
|
||||
}
|
||||
|
||||
const tool = createDelegateTask({
|
||||
manager: mockManager,
|
||||
client: mockClient,
|
||||
userCategories: {
|
||||
"custom-cat": { model: "provider/custom-model" }
|
||||
}
|
||||
})
|
||||
|
||||
const toolContext = {
|
||||
sessionID: "parent",
|
||||
messageID: "msg",
|
||||
agent: "Sisyphus",
|
||||
abort: new AbortController().signal
|
||||
}
|
||||
|
||||
// #when
|
||||
await tool.execute({
|
||||
description: "Sync model test",
|
||||
prompt: "test",
|
||||
category: "custom-cat",
|
||||
run_in_background: false,
|
||||
skills: null
|
||||
}, toolContext)
|
||||
|
||||
// #then
|
||||
expect(promptBody.model).toEqual({
|
||||
providerID: "provider",
|
||||
modelID: "custom-model"
|
||||
})
|
||||
}, { timeout: 20000 })
|
||||
})
|
||||
|
||||
describe("buildSystemContent", () => {
|
||||
test("returns undefined when no skills and no category promptAppend", () => {
|
||||
// #given
|
||||
const { buildSystemContent } = require("./tools")
|
||||
|
||||
// #when
|
||||
const result = buildSystemContent({ skillContent: undefined, categoryPromptAppend: undefined })
|
||||
|
||||
// #then
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
test("returns skill content only when skills provided without category", () => {
|
||||
// #given
|
||||
const { buildSystemContent } = require("./tools")
|
||||
const skillContent = "You are a playwright expert"
|
||||
|
||||
// #when
|
||||
const result = buildSystemContent({ skillContent, categoryPromptAppend: undefined })
|
||||
|
||||
// #then
|
||||
expect(result).toBe(skillContent)
|
||||
})
|
||||
|
||||
test("returns category promptAppend only when no skills", () => {
|
||||
// #given
|
||||
const { buildSystemContent } = require("./tools")
|
||||
const categoryPromptAppend = "Focus on visual design"
|
||||
|
||||
// #when
|
||||
const result = buildSystemContent({ skillContent: undefined, categoryPromptAppend })
|
||||
|
||||
// #then
|
||||
expect(result).toBe(categoryPromptAppend)
|
||||
})
|
||||
|
||||
test("combines skill content and category promptAppend with separator", () => {
|
||||
// #given
|
||||
const { buildSystemContent } = require("./tools")
|
||||
const skillContent = "You are a playwright expert"
|
||||
const categoryPromptAppend = "Focus on visual design"
|
||||
|
||||
// #when
|
||||
const result = buildSystemContent({ skillContent, categoryPromptAppend })
|
||||
|
||||
// #then
|
||||
expect(result).toContain(skillContent)
|
||||
expect(result).toContain(categoryPromptAppend)
|
||||
expect(result).toContain("\n\n")
|
||||
})
|
||||
})
|
||||
|
||||
describe("modelInfo detection via resolveCategoryConfig", () => {
|
||||
test("when parentModelString exists but default model wins - modelInfo should report category-default", () => {
|
||||
// #given - Bug scenario: parentModelString is passed but userModel is undefined,
|
||||
// and the resolution order is: userModel ?? parentModelString ?? defaultModel
|
||||
// If parentModelString matches the resolved model, it's "inherited"
|
||||
// If defaultModel matches, it's "category-default"
|
||||
const categoryName = "ultrabrain"
|
||||
const parentModelString = undefined
|
||||
|
||||
// #when
|
||||
const resolved = resolveCategoryConfig(categoryName, { parentModelString })
|
||||
|
||||
// #then - actualModel should be defaultModel, type should be "category-default"
|
||||
expect(resolved).not.toBeNull()
|
||||
const actualModel = resolved!.config.model
|
||||
const defaultModel = DEFAULT_CATEGORIES[categoryName]?.model
|
||||
expect(actualModel).toBe(defaultModel)
|
||||
expect(actualModel).toBe("openai/gpt-5.2")
|
||||
})
|
||||
|
||||
test("when parentModelString is used - modelInfo should report inherited", () => {
|
||||
// #given
|
||||
const categoryName = "ultrabrain"
|
||||
const parentModelString = "cliproxy/claude-opus-4-5"
|
||||
|
||||
// #when
|
||||
const resolved = resolveCategoryConfig(categoryName, { parentModelString })
|
||||
|
||||
// #then - actualModel should be parentModelString, type should be "inherited"
|
||||
expect(resolved).not.toBeNull()
|
||||
const actualModel = resolved!.config.model
|
||||
expect(actualModel).toBe(parentModelString)
|
||||
})
|
||||
|
||||
test("when user defines model - modelInfo should report user-defined regardless of parentModelString", () => {
|
||||
// #given
|
||||
const categoryName = "ultrabrain"
|
||||
const userCategories = { "ultrabrain": { model: "my-provider/custom-model" } }
|
||||
const parentModelString = "cliproxy/claude-opus-4-5"
|
||||
|
||||
// #when
|
||||
const resolved = resolveCategoryConfig(categoryName, { userCategories, parentModelString })
|
||||
|
||||
// #then - actualModel should be userModel, type should be "user-defined"
|
||||
expect(resolved).not.toBeNull()
|
||||
const actualModel = resolved!.config.model
|
||||
const userDefinedModel = userCategories[categoryName]?.model
|
||||
expect(actualModel).toBe(userDefinedModel)
|
||||
expect(actualModel).toBe("my-provider/custom-model")
|
||||
})
|
||||
|
||||
test("detection logic: actualModel comparison correctly identifies source", () => {
|
||||
// #given - This test verifies the fix for PR #770 bug
|
||||
// The bug was: checking `if (parentModelString)` instead of `if (actualModel === parentModelString)`
|
||||
const categoryName = "ultrabrain"
|
||||
const parentModelString = "cliproxy/claude-opus-4-5"
|
||||
const userCategories = { "ultrabrain": { model: "user/model" } }
|
||||
|
||||
// #when - user model wins
|
||||
const resolved = resolveCategoryConfig(categoryName, { userCategories, parentModelString })
|
||||
const actualModel = resolved!.config.model
|
||||
const userDefinedModel = userCategories[categoryName]?.model
|
||||
const defaultModel = DEFAULT_CATEGORIES[categoryName]?.model
|
||||
|
||||
// #then - detection should compare against actual resolved model
|
||||
const detectedType = actualModel === userDefinedModel
|
||||
? "user-defined"
|
||||
: actualModel === parentModelString
|
||||
? "inherited"
|
||||
: actualModel === defaultModel
|
||||
? "category-default"
|
||||
: undefined
|
||||
|
||||
expect(detectedType).toBe("user-defined")
|
||||
expect(actualModel).not.toBe(parentModelString)
|
||||
})
|
||||
|
||||
test("systemDefaultModel is used when no other model is available", () => {
|
||||
// #given - custom category with no model, but systemDefaultModel is set
|
||||
const categoryName = "my-custom"
|
||||
// Using type assertion since we're testing fallback behavior for categories without model
|
||||
const userCategories = { "my-custom": { temperature: 0.5 } } as unknown as Record<string, CategoryConfig>
|
||||
const systemDefaultModel = "anthropic/claude-sonnet-4-5"
|
||||
|
||||
// #when
|
||||
const resolved = resolveCategoryConfig(categoryName, { userCategories, systemDefaultModel })
|
||||
|
||||
// #then - actualModel should be systemDefaultModel
|
||||
expect(resolved).not.toBeNull()
|
||||
expect(resolved!.model).toBe(systemDefaultModel)
|
||||
})
|
||||
|
||||
test("model is undefined when no model available anywhere", () => {
|
||||
// #given - custom category with no model, no systemDefaultModel
|
||||
const categoryName = "my-custom"
|
||||
// Using type assertion since we're testing fallback behavior for categories without model
|
||||
const userCategories = { "my-custom": { temperature: 0.5 } } as unknown as Record<string, CategoryConfig>
|
||||
|
||||
// #when
|
||||
const resolved = resolveCategoryConfig(categoryName, { userCategories })
|
||||
|
||||
// #then - model should be undefined
|
||||
expect(resolved).not.toBeNull()
|
||||
expect(resolved!.model).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,771 @@
|
||||
import { tool, type PluginInput, type ToolDefinition } from "@opencode-ai/plugin"
|
||||
import { existsSync, readdirSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import type { BackgroundManager } from "../../features/background-agent"
|
||||
import type { DelegateTaskArgs } from "./types"
|
||||
import type { CategoryConfig, CategoriesConfig, GitMasterConfig } from "../../config/schema"
|
||||
import { DELEGATE_TASK_DESCRIPTION, DEFAULT_CATEGORIES, CATEGORY_PROMPT_APPENDS } from "./constants"
|
||||
import { findNearestMessageWithFields, findFirstMessageWithAgent, MESSAGE_STORAGE } from "../../features/hook-message-injector"
|
||||
import { resolveMultipleSkillsAsync } from "../../features/opencode-skill-loader/skill-content"
|
||||
import { discoverSkills } from "../../features/opencode-skill-loader"
|
||||
import { getTaskToastManager } from "../../features/task-toast-manager"
|
||||
import type { ModelFallbackInfo } from "../../features/task-toast-manager/types"
|
||||
import { subagentSessions, getSessionAgent } from "../../features/claude-code-session-state"
|
||||
import { log, getAgentToolRestrictions } from "../../shared"
|
||||
|
||||
type OpencodeClient = PluginInput["client"]
|
||||
|
||||
const SISYPHUS_JUNIOR_AGENT = "Sisyphus-Junior"
|
||||
const CATEGORY_EXAMPLES = Object.keys(DEFAULT_CATEGORIES).map(k => `'${k}'`).join(", ")
|
||||
|
||||
function parseModelString(model: string): { providerID: string; modelID: string } | undefined {
|
||||
const parts = model.split("/")
|
||||
if (parts.length >= 2) {
|
||||
return { providerID: parts[0], modelID: parts.slice(1).join("/") }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function getMessageDir(sessionID: string): string | null {
|
||||
if (!existsSync(MESSAGE_STORAGE)) return null
|
||||
|
||||
const directPath = join(MESSAGE_STORAGE, sessionID)
|
||||
if (existsSync(directPath)) return directPath
|
||||
|
||||
for (const dir of readdirSync(MESSAGE_STORAGE)) {
|
||||
const sessionPath = join(MESSAGE_STORAGE, dir, sessionID)
|
||||
if (existsSync(sessionPath)) return sessionPath
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function formatDuration(start: Date, end?: Date): string {
|
||||
const duration = (end ?? new Date()).getTime() - start.getTime()
|
||||
const seconds = Math.floor(duration / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const hours = Math.floor(minutes / 60)
|
||||
|
||||
if (hours > 0) return `${hours}h ${minutes % 60}m ${seconds % 60}s`
|
||||
if (minutes > 0) return `${minutes}m ${seconds % 60}s`
|
||||
return `${seconds}s`
|
||||
}
|
||||
|
||||
interface ErrorContext {
|
||||
operation: string
|
||||
args?: DelegateTaskArgs
|
||||
sessionID?: string
|
||||
agent?: string
|
||||
category?: string
|
||||
}
|
||||
|
||||
function formatDetailedError(error: unknown, ctx: ErrorContext): string {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
const stack = error instanceof Error ? error.stack : undefined
|
||||
|
||||
const lines: string[] = [
|
||||
`❌ ${ctx.operation} failed`,
|
||||
"",
|
||||
`**Error**: ${message}`,
|
||||
]
|
||||
|
||||
if (ctx.sessionID) {
|
||||
lines.push(`**Session ID**: ${ctx.sessionID}`)
|
||||
}
|
||||
|
||||
if (ctx.agent) {
|
||||
lines.push(`**Agent**: ${ctx.agent}${ctx.category ? ` (category: ${ctx.category})` : ""}`)
|
||||
}
|
||||
|
||||
if (ctx.args) {
|
||||
lines.push("", "**Arguments**:")
|
||||
lines.push(`- description: "${ctx.args.description}"`)
|
||||
lines.push(`- category: ${ctx.args.category ?? "(none)"}`)
|
||||
lines.push(`- subagent_type: ${ctx.args.subagent_type ?? "(none)"}`)
|
||||
lines.push(`- run_in_background: ${ctx.args.run_in_background}`)
|
||||
lines.push(`- skills: [${ctx.args.skills?.join(", ") ?? ""}]`)
|
||||
if (ctx.args.resume) {
|
||||
lines.push(`- resume: ${ctx.args.resume}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (stack) {
|
||||
lines.push("", "**Stack Trace**:")
|
||||
lines.push("```")
|
||||
lines.push(stack.split("\n").slice(0, 10).join("\n"))
|
||||
lines.push("```")
|
||||
}
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
type ToolContextWithMetadata = {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
agent: string
|
||||
abort: AbortSignal
|
||||
metadata?: (input: { title?: string; metadata?: Record<string, unknown> }) => void
|
||||
}
|
||||
|
||||
function resolveCategoryConfig(
|
||||
categoryName: string,
|
||||
options: {
|
||||
userCategories?: CategoriesConfig
|
||||
parentModelString?: string
|
||||
systemDefaultModel?: string
|
||||
}
|
||||
): { config: CategoryConfig; promptAppend: string; model: string | undefined } | null {
|
||||
const { userCategories, parentModelString, systemDefaultModel } = options
|
||||
const defaultConfig = DEFAULT_CATEGORIES[categoryName]
|
||||
const userConfig = userCategories?.[categoryName]
|
||||
const defaultPromptAppend = CATEGORY_PROMPT_APPENDS[categoryName] ?? ""
|
||||
|
||||
if (!defaultConfig && !userConfig) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Model priority: user override > parent model (inherit) > category default > system default
|
||||
// Parent model takes precedence over category default so custom providers work out-of-box
|
||||
const model = userConfig?.model ?? parentModelString ?? defaultConfig?.model ?? systemDefaultModel
|
||||
const config: CategoryConfig = {
|
||||
...defaultConfig,
|
||||
...userConfig,
|
||||
model,
|
||||
}
|
||||
|
||||
let promptAppend = defaultPromptAppend
|
||||
if (userConfig?.prompt_append) {
|
||||
promptAppend = defaultPromptAppend
|
||||
? defaultPromptAppend + "\n\n" + userConfig.prompt_append
|
||||
: userConfig.prompt_append
|
||||
}
|
||||
|
||||
return { config, promptAppend, model }
|
||||
}
|
||||
|
||||
export interface DelegateTaskToolOptions {
|
||||
manager: BackgroundManager
|
||||
client: OpencodeClient
|
||||
directory: string
|
||||
userCategories?: CategoriesConfig
|
||||
gitMasterConfig?: GitMasterConfig
|
||||
}
|
||||
|
||||
export interface BuildSystemContentInput {
|
||||
skillContent?: string
|
||||
categoryPromptAppend?: string
|
||||
}
|
||||
|
||||
export function buildSystemContent(input: BuildSystemContentInput): string | undefined {
|
||||
const { skillContent, categoryPromptAppend } = input
|
||||
|
||||
if (!skillContent && !categoryPromptAppend) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (skillContent && categoryPromptAppend) {
|
||||
return `${skillContent}\n\n${categoryPromptAppend}`
|
||||
}
|
||||
|
||||
return skillContent || categoryPromptAppend
|
||||
}
|
||||
|
||||
export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefinition {
|
||||
const { manager, client, directory, userCategories, gitMasterConfig } = options
|
||||
|
||||
return tool({
|
||||
description: DELEGATE_TASK_DESCRIPTION,
|
||||
args: {
|
||||
description: tool.schema.string().describe("Short task description"),
|
||||
prompt: tool.schema.string().describe("Full detailed prompt for the agent"),
|
||||
category: tool.schema.string().optional().describe(`Category name (e.g., ${CATEGORY_EXAMPLES}). Mutually exclusive with subagent_type.`),
|
||||
subagent_type: tool.schema.string().optional().describe("Agent name directly (e.g., 'oracle', 'explore'). Mutually exclusive with category."),
|
||||
run_in_background: tool.schema.boolean().describe("Run in background. MUST be explicitly set. Use false for task delegation, true only for parallel exploration."),
|
||||
resume: tool.schema.string().optional().describe("Session ID to resume - continues previous agent session with full context"),
|
||||
skills: tool.schema.array(tool.schema.string()).nullable().describe("Array of skill names to prepend to the prompt. Use null if no skills needed. Empty array [] is NOT allowed."),
|
||||
},
|
||||
async execute(args: DelegateTaskArgs, toolContext) {
|
||||
const ctx = toolContext as ToolContextWithMetadata
|
||||
if (args.run_in_background === undefined) {
|
||||
return `❌ Invalid arguments: 'run_in_background' parameter is REQUIRED. Use run_in_background=false for task delegation, run_in_background=true only for parallel exploration.`
|
||||
}
|
||||
if (args.skills === undefined) {
|
||||
return `❌ Invalid arguments: 'skills' parameter is REQUIRED. Use skills=null if no skills are needed, or provide an array of skill names.`
|
||||
}
|
||||
if (Array.isArray(args.skills) && args.skills.length === 0) {
|
||||
const allSkills = await discoverSkills({ includeClaudeCodePaths: true })
|
||||
const availableSkillsList = allSkills.map(s => ` - ${s.name}`).slice(0, 15).join("\n")
|
||||
return `❌ Invalid arguments: Empty array [] is not allowed for 'skills' parameter.
|
||||
|
||||
Use skills=null if this task genuinely requires no specialized skills.
|
||||
Otherwise, select appropriate skills from available options:
|
||||
|
||||
${availableSkillsList}${allSkills.length > 15 ? `\n ... and ${allSkills.length - 15} more` : ""}
|
||||
|
||||
If you believe no skills are needed, you MUST explicitly explain why to the user before using skills=null.`
|
||||
}
|
||||
const runInBackground = args.run_in_background === true
|
||||
|
||||
let skillContent: string | undefined
|
||||
if (args.skills !== null && args.skills.length > 0) {
|
||||
const { resolved, notFound } = await resolveMultipleSkillsAsync(args.skills, { gitMasterConfig })
|
||||
if (notFound.length > 0) {
|
||||
const allSkills = await discoverSkills({ includeClaudeCodePaths: true })
|
||||
const available = allSkills.map(s => s.name).join(", ")
|
||||
return `❌ Skills not found: ${notFound.join(", ")}. Available: ${available}`
|
||||
}
|
||||
skillContent = Array.from(resolved.values()).join("\n\n")
|
||||
}
|
||||
|
||||
const messageDir = getMessageDir(ctx.sessionID)
|
||||
const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
|
||||
const firstMessageAgent = messageDir ? findFirstMessageWithAgent(messageDir) : null
|
||||
const sessionAgent = getSessionAgent(ctx.sessionID)
|
||||
const parentAgent = ctx.agent ?? sessionAgent ?? firstMessageAgent ?? prevMessage?.agent
|
||||
|
||||
log("[delegate_task] parentAgent resolution", {
|
||||
sessionID: ctx.sessionID,
|
||||
messageDir,
|
||||
ctxAgent: ctx.agent,
|
||||
sessionAgent,
|
||||
firstMessageAgent,
|
||||
prevMessageAgent: prevMessage?.agent,
|
||||
resolvedParentAgent: parentAgent,
|
||||
})
|
||||
const parentModel = prevMessage?.model?.providerID && prevMessage?.model?.modelID
|
||||
? { providerID: prevMessage.model.providerID, modelID: prevMessage.model.modelID }
|
||||
: undefined
|
||||
|
||||
if (args.resume) {
|
||||
if (runInBackground) {
|
||||
try {
|
||||
const task = await manager.resume({
|
||||
sessionId: args.resume,
|
||||
prompt: args.prompt,
|
||||
parentSessionID: ctx.sessionID,
|
||||
parentMessageID: ctx.messageID,
|
||||
parentModel,
|
||||
parentAgent,
|
||||
})
|
||||
|
||||
ctx.metadata?.({
|
||||
title: `Resume: ${task.description}`,
|
||||
metadata: { sessionId: task.sessionID },
|
||||
})
|
||||
|
||||
return `Background task resumed.
|
||||
|
||||
Task ID: ${task.id}
|
||||
Session ID: ${task.sessionID}
|
||||
Description: ${task.description}
|
||||
Agent: ${task.agent}
|
||||
Status: ${task.status}
|
||||
|
||||
Agent continues with full previous context preserved.
|
||||
Use \`background_output\` with task_id="${task.id}" to check progress.`
|
||||
} catch (error) {
|
||||
return formatDetailedError(error, {
|
||||
operation: "Resume background task",
|
||||
args,
|
||||
sessionID: args.resume,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const toastManager = getTaskToastManager()
|
||||
const taskId = `resume_sync_${args.resume.slice(0, 8)}`
|
||||
const startTime = new Date()
|
||||
|
||||
if (toastManager) {
|
||||
toastManager.addTask({
|
||||
id: taskId,
|
||||
description: args.description,
|
||||
agent: "resume",
|
||||
isBackground: false,
|
||||
})
|
||||
}
|
||||
|
||||
ctx.metadata?.({
|
||||
title: `Resume: ${args.description}`,
|
||||
metadata: { sessionId: args.resume, sync: true },
|
||||
})
|
||||
|
||||
try {
|
||||
let resumeAgent: string | undefined
|
||||
let resumeModel: { providerID: string; modelID: string } | undefined
|
||||
|
||||
try {
|
||||
const messagesResp = await client.session.messages({ path: { id: args.resume } })
|
||||
const messages = (messagesResp.data ?? []) as Array<{
|
||||
info?: { agent?: string; model?: { providerID: string; modelID: string }; modelID?: string; providerID?: string }
|
||||
}>
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const info = messages[i].info
|
||||
if (info?.agent || info?.model || (info?.modelID && info?.providerID)) {
|
||||
resumeAgent = info.agent
|
||||
resumeModel = info.model ?? (info.providerID && info.modelID ? { providerID: info.providerID, modelID: info.modelID } : undefined)
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
const resumeMessageDir = getMessageDir(args.resume)
|
||||
const resumeMessage = resumeMessageDir ? findNearestMessageWithFields(resumeMessageDir) : null
|
||||
resumeAgent = resumeMessage?.agent
|
||||
resumeModel = resumeMessage?.model?.providerID && resumeMessage?.model?.modelID
|
||||
? { providerID: resumeMessage.model.providerID, modelID: resumeMessage.model.modelID }
|
||||
: undefined
|
||||
}
|
||||
|
||||
await client.session.prompt({
|
||||
path: { id: args.resume },
|
||||
body: {
|
||||
...(resumeAgent !== undefined ? { agent: resumeAgent } : {}),
|
||||
...(resumeModel !== undefined ? { model: resumeModel } : {}),
|
||||
tools: {
|
||||
...(resumeAgent ? getAgentToolRestrictions(resumeAgent) : {}),
|
||||
task: false,
|
||||
delegate_task: false,
|
||||
call_omo_agent: true,
|
||||
},
|
||||
parts: [{ type: "text", text: args.prompt }],
|
||||
},
|
||||
})
|
||||
} catch (promptError) {
|
||||
if (toastManager) {
|
||||
toastManager.removeTask(taskId)
|
||||
}
|
||||
const errorMessage = promptError instanceof Error ? promptError.message : String(promptError)
|
||||
return `❌ Failed to send resume prompt: ${errorMessage}\n\nSession ID: ${args.resume}`
|
||||
}
|
||||
|
||||
// Wait for message stability after prompt completes
|
||||
const POLL_INTERVAL_MS = 500
|
||||
const MIN_STABILITY_TIME_MS = 5000
|
||||
const STABILITY_POLLS_REQUIRED = 3
|
||||
const pollStart = Date.now()
|
||||
let lastMsgCount = 0
|
||||
let stablePolls = 0
|
||||
|
||||
while (Date.now() - pollStart < 60000) {
|
||||
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS))
|
||||
|
||||
const elapsed = Date.now() - pollStart
|
||||
if (elapsed < MIN_STABILITY_TIME_MS) continue
|
||||
|
||||
const messagesCheck = await client.session.messages({ path: { id: args.resume } })
|
||||
const msgs = ((messagesCheck as { data?: unknown }).data ?? messagesCheck) as Array<unknown>
|
||||
const currentMsgCount = msgs.length
|
||||
|
||||
if (currentMsgCount > 0 && currentMsgCount === lastMsgCount) {
|
||||
stablePolls++
|
||||
if (stablePolls >= STABILITY_POLLS_REQUIRED) break
|
||||
} else {
|
||||
stablePolls = 0
|
||||
lastMsgCount = currentMsgCount
|
||||
}
|
||||
}
|
||||
|
||||
const messagesResult = await client.session.messages({
|
||||
path: { id: args.resume },
|
||||
})
|
||||
|
||||
if (messagesResult.error) {
|
||||
if (toastManager) {
|
||||
toastManager.removeTask(taskId)
|
||||
}
|
||||
return `❌ Error fetching result: ${messagesResult.error}\n\nSession ID: ${args.resume}`
|
||||
}
|
||||
|
||||
const messages = ((messagesResult as { data?: unknown }).data ?? messagesResult) as Array<{
|
||||
info?: { role?: string; time?: { created?: number } }
|
||||
parts?: Array<{ type?: string; text?: string }>
|
||||
}>
|
||||
|
||||
const assistantMessages = messages
|
||||
.filter((m) => m.info?.role === "assistant")
|
||||
.sort((a, b) => (b.info?.time?.created ?? 0) - (a.info?.time?.created ?? 0))
|
||||
const lastMessage = assistantMessages[0]
|
||||
|
||||
if (toastManager) {
|
||||
toastManager.removeTask(taskId)
|
||||
}
|
||||
|
||||
if (!lastMessage) {
|
||||
return `❌ No assistant response found.\n\nSession ID: ${args.resume}`
|
||||
}
|
||||
|
||||
// Extract text from both "text" and "reasoning" parts (thinking models use "reasoning")
|
||||
const textParts = lastMessage?.parts?.filter((p) => p.type === "text" || p.type === "reasoning") ?? []
|
||||
const textContent = textParts.map((p) => p.text ?? "").filter(Boolean).join("\n")
|
||||
|
||||
const duration = formatDuration(startTime)
|
||||
|
||||
return `Task resumed and completed in ${duration}.
|
||||
|
||||
Session ID: ${args.resume}
|
||||
|
||||
---
|
||||
|
||||
${textContent || "(No text output)"}`
|
||||
}
|
||||
|
||||
if (args.category && args.subagent_type) {
|
||||
return `❌ Invalid arguments: Provide EITHER category OR subagent_type, not both.`
|
||||
}
|
||||
|
||||
if (!args.category && !args.subagent_type) {
|
||||
return `❌ Invalid arguments: Must provide either category or subagent_type.`
|
||||
}
|
||||
|
||||
// Fetch OpenCode config at boundary to get system default model
|
||||
let systemDefaultModel: string | undefined
|
||||
try {
|
||||
const openCodeConfig = await client.config.get()
|
||||
systemDefaultModel = (openCodeConfig as { model?: string })?.model
|
||||
} catch {
|
||||
// Config fetch failed, proceed without system default
|
||||
systemDefaultModel = undefined
|
||||
}
|
||||
|
||||
let agentToUse: string
|
||||
let categoryModel: { providerID: string; modelID: string; variant?: string } | undefined
|
||||
let categoryPromptAppend: string | undefined
|
||||
|
||||
const parentModelString = parentModel
|
||||
? `${parentModel.providerID}/${parentModel.modelID}`
|
||||
: undefined
|
||||
|
||||
let modelInfo: ModelFallbackInfo | undefined
|
||||
|
||||
if (args.category) {
|
||||
const resolved = resolveCategoryConfig(args.category, {
|
||||
userCategories,
|
||||
parentModelString,
|
||||
systemDefaultModel,
|
||||
})
|
||||
if (!resolved) {
|
||||
return `❌ Unknown category: "${args.category}". Available: ${Object.keys({ ...DEFAULT_CATEGORIES, ...userCategories }).join(", ")}`
|
||||
}
|
||||
|
||||
// Determine model source by comparing against the actual resolved model
|
||||
const actualModel = resolved.model
|
||||
const userDefinedModel = userCategories?.[args.category]?.model
|
||||
const categoryDefaultModel = DEFAULT_CATEGORIES[args.category]?.model
|
||||
|
||||
if (!actualModel) {
|
||||
return `❌ No model configured. Set a model in your OpenCode config, plugin config, or use a category with a default model.`
|
||||
}
|
||||
|
||||
if (!parseModelString(actualModel)) {
|
||||
return `❌ Invalid model format "${actualModel}". Expected "provider/model" format (e.g., "anthropic/claude-sonnet-4-5").`
|
||||
}
|
||||
|
||||
switch (actualModel) {
|
||||
case userDefinedModel:
|
||||
modelInfo = { model: actualModel, type: "user-defined" }
|
||||
break
|
||||
case parentModelString:
|
||||
modelInfo = { model: actualModel, type: "inherited" }
|
||||
break
|
||||
case categoryDefaultModel:
|
||||
modelInfo = { model: actualModel, type: "category-default" }
|
||||
break
|
||||
case systemDefaultModel:
|
||||
modelInfo = { model: actualModel, type: "system-default" }
|
||||
break
|
||||
}
|
||||
|
||||
agentToUse = SISYPHUS_JUNIOR_AGENT
|
||||
const parsedModel = parseModelString(actualModel)
|
||||
categoryModel = parsedModel
|
||||
? (resolved.config.variant
|
||||
? { ...parsedModel, variant: resolved.config.variant }
|
||||
: parsedModel)
|
||||
: undefined
|
||||
categoryPromptAppend = resolved.promptAppend || undefined
|
||||
} else {
|
||||
if (!args.subagent_type?.trim()) {
|
||||
return `❌ Agent name cannot be empty.`
|
||||
}
|
||||
const agentName = args.subagent_type.trim()
|
||||
agentToUse = agentName
|
||||
|
||||
// Validate agent exists and is callable (not a primary agent)
|
||||
try {
|
||||
const agentsResult = await client.app.agents()
|
||||
type AgentInfo = { name: string; mode?: "subagent" | "primary" | "all" }
|
||||
const agents = (agentsResult as { data?: AgentInfo[] }).data ?? agentsResult as unknown as AgentInfo[]
|
||||
|
||||
const callableAgents = agents.filter((a) => a.mode !== "primary")
|
||||
const callableNames = callableAgents.map((a) => a.name)
|
||||
|
||||
if (!callableNames.includes(agentToUse)) {
|
||||
const isPrimaryAgent = agents.some((a) => a.name === agentToUse && a.mode === "primary")
|
||||
if (isPrimaryAgent) {
|
||||
return `❌ Cannot call primary agent "${agentToUse}" via delegate_task. Primary agents are top-level orchestrators.`
|
||||
}
|
||||
|
||||
const availableAgents = callableNames
|
||||
.sort()
|
||||
.join(", ")
|
||||
return `❌ Unknown agent: "${agentToUse}". Available agents: ${availableAgents}`
|
||||
}
|
||||
} catch {
|
||||
// If we can't fetch agents, proceed anyway - the session.prompt will fail with a clearer error
|
||||
}
|
||||
}
|
||||
|
||||
const systemContent = buildSystemContent({ skillContent, categoryPromptAppend })
|
||||
|
||||
if (runInBackground) {
|
||||
try {
|
||||
const task = await manager.launch({
|
||||
description: args.description,
|
||||
prompt: args.prompt,
|
||||
agent: agentToUse,
|
||||
parentSessionID: ctx.sessionID,
|
||||
parentMessageID: ctx.messageID,
|
||||
parentModel,
|
||||
parentAgent,
|
||||
model: categoryModel,
|
||||
skills: args.skills ?? undefined,
|
||||
skillContent: systemContent,
|
||||
})
|
||||
|
||||
ctx.metadata?.({
|
||||
title: args.description,
|
||||
metadata: { sessionId: task.sessionID, category: args.category },
|
||||
})
|
||||
|
||||
return `Background task launched.
|
||||
|
||||
Task ID: ${task.id}
|
||||
Session ID: ${task.sessionID}
|
||||
Description: ${task.description}
|
||||
Agent: ${task.agent}${args.category ? ` (category: ${args.category})` : ""}
|
||||
Status: ${task.status}
|
||||
|
||||
System notifies on completion. Use \`background_output\` with task_id="${task.id}" to check.`
|
||||
} catch (error) {
|
||||
return formatDetailedError(error, {
|
||||
operation: "Launch background task",
|
||||
args,
|
||||
agent: agentToUse,
|
||||
category: args.category,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const toastManager = getTaskToastManager()
|
||||
let taskId: string | undefined
|
||||
let syncSessionID: string | undefined
|
||||
|
||||
try {
|
||||
const parentSession = client.session.get
|
||||
? await client.session.get({ path: { id: ctx.sessionID } }).catch(() => null)
|
||||
: null
|
||||
const parentDirectory = parentSession?.data?.directory ?? directory
|
||||
|
||||
const createResult = await client.session.create({
|
||||
body: {
|
||||
parentID: ctx.sessionID,
|
||||
title: `Task: ${args.description}`,
|
||||
},
|
||||
query: {
|
||||
directory: parentDirectory,
|
||||
},
|
||||
})
|
||||
|
||||
if (createResult.error) {
|
||||
return `❌ Failed to create session: ${createResult.error}`
|
||||
}
|
||||
|
||||
const sessionID = createResult.data.id
|
||||
syncSessionID = sessionID
|
||||
subagentSessions.add(sessionID)
|
||||
taskId = `sync_${sessionID.slice(0, 8)}`
|
||||
const startTime = new Date()
|
||||
|
||||
if (toastManager) {
|
||||
toastManager.addTask({
|
||||
id: taskId,
|
||||
description: args.description,
|
||||
agent: agentToUse,
|
||||
isBackground: false,
|
||||
skills: args.skills ?? undefined,
|
||||
modelInfo,
|
||||
})
|
||||
}
|
||||
|
||||
ctx.metadata?.({
|
||||
title: args.description,
|
||||
metadata: { sessionId: sessionID, category: args.category, sync: true },
|
||||
})
|
||||
|
||||
try {
|
||||
await client.session.prompt({
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: agentToUse,
|
||||
system: systemContent,
|
||||
tools: {
|
||||
task: false,
|
||||
delegate_task: false,
|
||||
call_omo_agent: true,
|
||||
},
|
||||
parts: [{ type: "text", text: args.prompt }],
|
||||
...(categoryModel ? { model: categoryModel } : {}),
|
||||
},
|
||||
})
|
||||
} catch (promptError) {
|
||||
if (toastManager && taskId !== undefined) {
|
||||
toastManager.removeTask(taskId)
|
||||
}
|
||||
const errorMessage = promptError instanceof Error ? promptError.message : String(promptError)
|
||||
if (errorMessage.includes("agent.name") || errorMessage.includes("undefined")) {
|
||||
return formatDetailedError(new Error(`Agent "${agentToUse}" not found. Make sure the agent is registered in your opencode.json or provided by a plugin.`), {
|
||||
operation: "Send prompt to agent",
|
||||
args,
|
||||
sessionID,
|
||||
agent: agentToUse,
|
||||
category: args.category,
|
||||
})
|
||||
}
|
||||
return formatDetailedError(promptError, {
|
||||
operation: "Send prompt",
|
||||
args,
|
||||
sessionID,
|
||||
agent: agentToUse,
|
||||
category: args.category,
|
||||
})
|
||||
}
|
||||
|
||||
// Poll for session completion with stability detection
|
||||
// The session may show as "idle" before messages appear, so we also check message stability
|
||||
const POLL_INTERVAL_MS = 500
|
||||
const MAX_POLL_TIME_MS = 10 * 60 * 1000
|
||||
const MIN_STABILITY_TIME_MS = 10000 // Minimum 10s before accepting completion
|
||||
const STABILITY_POLLS_REQUIRED = 3
|
||||
const pollStart = Date.now()
|
||||
let lastMsgCount = 0
|
||||
let stablePolls = 0
|
||||
let pollCount = 0
|
||||
|
||||
log("[delegate_task] Starting poll loop", { sessionID, agentToUse })
|
||||
|
||||
while (Date.now() - pollStart < MAX_POLL_TIME_MS) {
|
||||
if (ctx.abort?.aborted) {
|
||||
log("[delegate_task] Aborted by user", { sessionID })
|
||||
if (toastManager && taskId) toastManager.removeTask(taskId)
|
||||
return `Task aborted.\n\nSession ID: ${sessionID}`
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS))
|
||||
pollCount++
|
||||
|
||||
const statusResult = await client.session.status()
|
||||
const allStatuses = (statusResult.data ?? {}) as Record<string, { type: string }>
|
||||
const sessionStatus = allStatuses[sessionID]
|
||||
|
||||
if (pollCount % 10 === 0) {
|
||||
log("[delegate_task] Poll status", {
|
||||
sessionID,
|
||||
pollCount,
|
||||
elapsed: Math.floor((Date.now() - pollStart) / 1000) + "s",
|
||||
sessionStatus: sessionStatus?.type ?? "not_in_status",
|
||||
stablePolls,
|
||||
lastMsgCount,
|
||||
})
|
||||
}
|
||||
|
||||
if (sessionStatus && sessionStatus.type !== "idle") {
|
||||
stablePolls = 0
|
||||
lastMsgCount = 0
|
||||
continue
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - pollStart
|
||||
if (elapsed < MIN_STABILITY_TIME_MS) {
|
||||
continue
|
||||
}
|
||||
|
||||
const messagesCheck = await client.session.messages({ path: { id: sessionID } })
|
||||
const msgs = ((messagesCheck as { data?: unknown }).data ?? messagesCheck) as Array<unknown>
|
||||
const currentMsgCount = msgs.length
|
||||
|
||||
if (currentMsgCount === lastMsgCount) {
|
||||
stablePolls++
|
||||
if (stablePolls >= STABILITY_POLLS_REQUIRED) {
|
||||
log("[delegate_task] Poll complete - messages stable", { sessionID, pollCount, currentMsgCount })
|
||||
break
|
||||
}
|
||||
} else {
|
||||
stablePolls = 0
|
||||
lastMsgCount = currentMsgCount
|
||||
}
|
||||
}
|
||||
|
||||
if (Date.now() - pollStart >= MAX_POLL_TIME_MS) {
|
||||
log("[delegate_task] Poll timeout reached", { sessionID, pollCount, lastMsgCount, stablePolls })
|
||||
}
|
||||
|
||||
const messagesResult = await client.session.messages({
|
||||
path: { id: sessionID },
|
||||
})
|
||||
|
||||
if (messagesResult.error) {
|
||||
return `❌ Error fetching result: ${messagesResult.error}\n\nSession ID: ${sessionID}`
|
||||
}
|
||||
|
||||
const messages = ((messagesResult as { data?: unknown }).data ?? messagesResult) as Array<{
|
||||
info?: { role?: string; time?: { created?: number } }
|
||||
parts?: Array<{ type?: string; text?: string }>
|
||||
}>
|
||||
|
||||
const assistantMessages = messages
|
||||
.filter((m) => m.info?.role === "assistant")
|
||||
.sort((a, b) => (b.info?.time?.created ?? 0) - (a.info?.time?.created ?? 0))
|
||||
const lastMessage = assistantMessages[0]
|
||||
|
||||
if (!lastMessage) {
|
||||
return `❌ No assistant response found.\n\nSession ID: ${sessionID}`
|
||||
}
|
||||
|
||||
// Extract text from both "text" and "reasoning" parts (thinking models use "reasoning")
|
||||
const textParts = lastMessage?.parts?.filter((p) => p.type === "text" || p.type === "reasoning") ?? []
|
||||
const textContent = textParts.map((p) => p.text ?? "").filter(Boolean).join("\n")
|
||||
|
||||
const duration = formatDuration(startTime)
|
||||
|
||||
if (toastManager) {
|
||||
toastManager.removeTask(taskId)
|
||||
}
|
||||
|
||||
subagentSessions.delete(sessionID)
|
||||
|
||||
return `Task completed in ${duration}.
|
||||
|
||||
Agent: ${agentToUse}${args.category ? ` (category: ${args.category})` : ""}
|
||||
Session ID: ${sessionID}
|
||||
|
||||
---
|
||||
|
||||
${textContent || "(No text output)"}`
|
||||
} catch (error) {
|
||||
if (toastManager && taskId !== undefined) {
|
||||
toastManager.removeTask(taskId)
|
||||
}
|
||||
if (syncSessionID) {
|
||||
subagentSessions.delete(syncSessionID)
|
||||
}
|
||||
return formatDetailedError(error, {
|
||||
operation: "Execute task",
|
||||
args,
|
||||
sessionID: syncSessionID,
|
||||
agent: agentToUse,
|
||||
category: args.category,
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export interface DelegateTaskArgs {
|
||||
description: string
|
||||
prompt: string
|
||||
category?: string
|
||||
subagent_type?: string
|
||||
run_in_background: boolean
|
||||
resume?: string
|
||||
skills: string[] | null
|
||||
}
|
||||
Reference in New Issue
Block a user