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

Resolves conflicts with ZWSP agent ordering, display name unification, and test file migration to zauc-mocks split.
This commit is contained in:
code-yeongyu
2026-04-12 06:13:52 +09:00
837 changed files with 51410 additions and 14139 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# src/tools/delegate-task/ — Task Delegation Engine
**Generated:** 2026-03-06
**Generated:** 2026-04-11
## OVERVIEW
@@ -0,0 +1,54 @@
import type { BuiltinCategoryDefinition } from "./builtin-category-definition"
const UNSPECIFIED_LOW_CATEGORY_PROMPT_APPEND = `<Category_Context>
You are working on tasks that don't fit specific categories but require moderate effort.
<Selection_Gate>
BEFORE selecting this category, VERIFY ALL conditions:
1. Task does NOT fit: quick (trivial), visual-engineering (UI), ultrabrain (deep logic), artistry (creative), writing (docs)
2. Task requires more than trivial effort but is NOT system-wide
3. Scope is contained within a few files/modules
If task fits ANY other category, DO NOT select unspecified-low.
This is NOT a default choice - it's for genuinely unclassifiable moderate-effort work.
</Selection_Gate>
</Category_Context>
<Caller_Warning>
THIS CATEGORY USES A MID-TIER MODEL (claude-sonnet-4-6).
**PROVIDE CLEAR STRUCTURE:**
1. MUST DO: Enumerate required actions explicitly
2. MUST NOT DO: State forbidden actions to prevent scope creep
3. EXPECTED OUTPUT: Define concrete success criteria
</Caller_Warning>`
const UNSPECIFIED_HIGH_CATEGORY_PROMPT_APPEND = `<Category_Context>
You are working on tasks that don't fit specific categories but require substantial effort.
<Selection_Gate>
BEFORE selecting this category, VERIFY ALL conditions:
1. Task does NOT fit: quick (trivial), visual-engineering (UI), ultrabrain (deep logic), artistry (creative), writing (docs)
2. Task requires substantial effort across multiple systems/modules
3. Changes have broad impact or require careful coordination
4. NOT just "complex" - must be genuinely unclassifiable AND high-effort
If task fits ANY other category, DO NOT select unspecified-high.
If task is unclassifiable but moderate-effort, use unspecified-low instead.
</Selection_Gate>
</Category_Context>`
export const ANTHROPIC_CATEGORIES: BuiltinCategoryDefinition[] = [
{
name: "unspecified-low",
config: { model: "anthropic/claude-sonnet-4-6" },
description: "Tasks that don't fit other categories, low effort required",
promptAppend: UNSPECIFIED_LOW_CATEGORY_PROMPT_APPEND,
},
{
name: "unspecified-high",
config: { model: "anthropic/claude-opus-4-6", variant: "max" },
description: "Tasks that don't fit other categories, high effort required",
promptAppend: UNSPECIFIED_HIGH_CATEGORY_PROMPT_APPEND,
},
]
@@ -3,6 +3,7 @@ import type { ExecutorContext, ParentContext } from "./executor-types"
import { storeToolMetadata } from "../../features/tool-metadata-store"
import { formatDetailedError } from "./error-formatting"
import { getSessionTools } from "../../shared/session-tools-store"
import { resolveCallID } from "./resolve-call-id"
export async function executeBackgroundContinuation(
args: DelegateTaskArgs,
@@ -37,8 +38,9 @@ export async function executeBackgroundContinuation(
},
}
await ctx.metadata?.(bgContMeta)
if (ctx.callID) {
storeToolMetadata(ctx.sessionID, ctx.callID, bgContMeta)
const callID = resolveCallID(ctx)
if (callID) {
storeToolMetadata(ctx.sessionID, callID, bgContMeta)
}
return `Background task continued.
@@ -49,7 +51,9 @@ Agent: ${task.agent}
Status: ${task.status}
Agent continues with full previous context preserved.
Use \`background_output\` with task_id="${task.id}" to check progress.
System notifies on completion. Use \`background_output\` with task_id="${task.id}" to check.
Do NOT call background_output now. Wait for <system-reminder> notification first.
<task_metadata>
session_id: ${task.sessionID}
@@ -7,6 +7,7 @@ const afterEachFn = bunTest.afterEach
const { executeBackgroundTask } = require("./background-task")
const { __setTimingConfig, __resetTimingConfig } = require("./timing")
const { SessionCategoryRegistry } = require("../../shared/session-category-registry")
describeFn("executeBackgroundTask output/session metadata compatibility", () => {
beforeEachFn(() => {
@@ -19,6 +20,7 @@ describeFn("executeBackgroundTask output/session metadata compatibility", () =>
afterEachFn(() => {
__resetTimingConfig()
SessionCategoryRegistry.clear()
})
testFn("does not emit synthetic pending session metadata when session id is unresolved", async () => {
@@ -201,4 +203,318 @@ describeFn("executeBackgroundTask output/session metadata compatibility", () =>
{ permission: "question", action: "deny", pattern: "*" },
])
})
testFn("strips leading zwsp from agent name before launching background task", async () => {
//#given - display-sorted agent names should be normalized before manager launch
const launchCalls: unknown[] = []
const manager = {
launch: async (input: unknown) => {
launchCalls.push(input)
return {
id: "bg_clean_agent",
sessionID: "ses_clean_agent",
description: "Clean agent",
agent: "sisyphus-junior",
status: "running",
}
},
getTask: () => ({ sessionID: "ses_clean_agent" }),
}
//#when
await executeBackgroundTask(
{
description: "Clean agent",
prompt: "check",
run_in_background: true,
load_skills: [],
},
{
sessionID: "ses_parent",
callID: "call_clean_agent",
metadata: async () => {},
abort: new AbortController().signal,
},
{ manager },
{ sessionID: "ses_parent", messageID: "msg_clean_agent" },
"\u200Bsisyphus-junior",
undefined,
undefined,
undefined,
)
//#then
expectFn(launchCalls).toHaveLength(1)
expectFn((launchCalls[0] as { agent: string }).agent).toBe("sisyphus-junior")
})
testFn("keeps launched background task alive when parent aborts before session id resolves", async () => {
//#given - parallel tool execution can abort the parent call after launch succeeds
const metadataCalls: any[] = []
const abortController = new AbortController()
const manager = {
launch: async () => ({
id: "bg_abort_after_launch",
sessionID: undefined,
description: "Abort after launch",
agent: "explore",
status: "pending",
}),
getTask: () => {
abortController.abort()
return { sessionID: undefined, status: "pending" }
},
}
//#when
const result = await executeBackgroundTask(
{
description: "Abort after launch",
prompt: "check",
run_in_background: true,
load_skills: [],
},
{
sessionID: "ses_parent",
callID: "call_abort_after_launch",
metadata: async (value: any) => metadataCalls.push(value),
abort: abortController.signal,
},
{ manager },
{ sessionID: "ses_parent", messageID: "msg_abort_after_launch" },
"explore",
undefined,
undefined,
undefined,
)
//#then - background launch should still succeed without fake abort failure
expectFn(result).toContain("Background task launched")
expectFn(result).toContain("Background Task ID: bg_abort_after_launch")
expectFn(result).not.toContain("Task aborted while waiting for session to start")
expectFn(metadataCalls).toHaveLength(1)
expectFn("sessionId" in metadataCalls[0].metadata).toBe(false)
})
testFn("registers late session category even when parent aborts before session id resolves", async () => {
//#given - session wiring should continue after returning early on parent abort
const abortController = new AbortController()
abortController.abort()
let reads = 0
const manager = {
launch: async () => ({
id: "bg_abort_category",
sessionID: undefined,
description: "Abort category",
agent: "explore",
status: "pending",
}),
getTask: () => {
reads += 1
return reads >= 2
? { sessionID: "ses_abort_category", status: "running" }
: { sessionID: undefined, status: "pending" }
},
}
//#when
const result = await executeBackgroundTask(
{
description: "Abort category",
prompt: "check",
run_in_background: true,
load_skills: [],
category: "quick",
},
{
sessionID: "ses_parent",
callID: "call_abort_category",
metadata: async () => {},
abort: abortController.signal,
},
{ manager },
{ sessionID: "ses_parent", messageID: "msg_abort_category" },
"explore",
undefined,
undefined,
[{ providers: ["openai"], model: "gpt-5.4" }],
)
await new Promise(resolve => setTimeout(resolve, 5))
//#then - late session setup should still register category for runtime fallback
expectFn(result).toContain("Background task launched")
expectFn(SessionCategoryRegistry.get("ses_abort_category")).toBe("quick")
})
testFn("prefers child terminal status over parent abort while waiting for session id", async () => {
//#given - failed child launch should not be misreported as a successful background launch
const abortController = new AbortController()
abortController.abort()
const manager = {
launch: async () => ({
id: "bg_abort_terminal",
sessionID: undefined,
description: "Abort terminal",
agent: "explore",
status: "pending",
}),
getTask: () => ({ sessionID: undefined, status: "interrupt" }),
}
//#when
const result = await executeBackgroundTask(
{
description: "Abort terminal",
prompt: "check",
run_in_background: true,
load_skills: [],
},
{
sessionID: "ses_parent",
callID: "call_abort_terminal",
metadata: async () => {},
abort: abortController.signal,
},
{ manager },
{ sessionID: "ses_parent", messageID: "msg_abort_terminal" },
"explore",
undefined,
undefined,
undefined,
)
//#then - terminal child status should win over abort and surface the failure
expectFn(result).toContain("Task failed to start")
expectFn(result).toContain("interrupt")
})
testFn("reports failure when manager marks task as error during session startup", async () => {
//#given - session created but startTask throws before prompt is sent
const metadataCalls: any[] = []
let reads = 0
const manager = {
launch: async () => ({
id: "bg_crash_before_prompt",
sessionID: undefined,
description: "Crash before prompt",
agent: "explore",
status: "pending",
}),
getTask: () => {
reads += 1
if (reads >= 2) {
return { sessionID: "ses_orphan", status: "error", error: "crash between session creation and prompt send" }
}
return { sessionID: undefined, status: "pending" }
},
}
//#when
const result = await executeBackgroundTask(
{
description: "Crash before prompt",
prompt: "check",
run_in_background: true,
load_skills: [],
},
{
sessionID: "ses_parent",
callID: "call_crash",
metadata: async (value: any) => metadataCalls.push(value),
abort: new AbortController().signal,
},
{ manager },
{ sessionID: "ses_parent", messageID: "msg_crash" },
"explore",
undefined,
undefined,
undefined,
)
//#then - polling loop should detect terminal status and report failure
expectFn(result).toContain("Task failed to start")
expectFn(result).toContain("error")
})
testFn("keeps sibling background launch alive when two tasks start concurrently", async () => {
//#given - one aborted parent call should not interrupt a sibling launch from the same parent session
const firstAbortController = new AbortController()
const secondAbortController = new AbortController()
const states = new Map([
["bg_first", { reads: 0, abortOnFirstRead: true, sessionID: "ses_first" }],
["bg_second", { reads: 0, abortOnFirstRead: false, sessionID: "ses_second" }],
])
let launchCount = 0
const manager = {
launch: async () => {
launchCount += 1
return launchCount === 1
? { id: "bg_first", sessionID: undefined, description: "First", agent: "explore", status: "pending" }
: { id: "bg_second", sessionID: undefined, description: "Second", agent: "explore", status: "pending" }
},
getTask: (taskID: string) => {
const state = states.get(taskID)
if (!state) return undefined
state.reads += 1
if (state.abortOnFirstRead && state.reads === 1) {
firstAbortController.abort()
}
return state.reads >= 2
? { sessionID: state.sessionID, status: "running" }
: { sessionID: undefined, status: "pending" }
},
}
//#when
const [firstResult, secondResult] = await Promise.all([
executeBackgroundTask(
{
description: "First",
prompt: "check",
run_in_background: true,
load_skills: [],
},
{
sessionID: "ses_parent",
callID: "call_first",
metadata: async () => {},
abort: firstAbortController.signal,
},
{ manager },
{ sessionID: "ses_parent", messageID: "msg_first" },
"explore",
undefined,
undefined,
undefined,
),
executeBackgroundTask(
{
description: "Second",
prompt: "check",
run_in_background: true,
load_skills: [],
},
{
sessionID: "ses_parent",
callID: "call_second",
metadata: async () => {},
abort: secondAbortController.signal,
},
{ manager },
{ sessionID: "ses_parent", messageID: "msg_second" },
"explore",
undefined,
undefined,
undefined,
),
])
//#then - both tasks still launch and the sibling is not reported as interrupted
expectFn(firstResult).toContain("Background task launched")
expectFn(firstResult).not.toContain("Task failed to start")
expectFn(secondResult).toContain("Background task launched")
expectFn(secondResult).toContain("session_id: ses_second")
expectFn(secondResult).not.toContain("interrupt")
})
})
+65 -9
View File
@@ -4,11 +4,50 @@ import type { FallbackEntry } from "../../shared/model-requirements"
import { getTimingConfig } from "./timing"
import { buildTaskPrompt } from "./prompt-builder"
import { storeToolMetadata } from "../../features/tool-metadata-store"
import { resolveCallID } from "./resolve-call-id"
import { formatDetailedError } from "./error-formatting"
import { getSessionTools } from "../../shared/session-tools-store"
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
import { QUESTION_DENIED_SESSION_PERMISSION } from "../../shared/question-denied-session-permission"
import { setSessionFallbackChain } from "../../hooks/model-fallback/hook"
import { stripAgentListSortPrefix } from "../../shared/agent-display-names"
function continueSessionSetup(args: {
taskID: string
manager: ExecutorContext["manager"]
timing: ReturnType<typeof getTimingConfig>
fallbackChain?: FallbackEntry[]
category?: string
}): void {
if (!args.fallbackChain && !args.category) {
return
}
void (async () => {
const waitStart = Date.now()
while (Date.now() - waitStart < args.timing.WAIT_FOR_SESSION_TIMEOUT_MS) {
await new Promise(resolve => setTimeout(resolve, args.timing.WAIT_FOR_SESSION_INTERVAL_MS))
const updated = args.manager.getTask(args.taskID)
if (!updated) {
return
}
if (updated.status === "error" || updated.status === "cancelled" || updated.status === "interrupt") {
return
}
const sessionId = updated.sessionID
if (!sessionId) {
continue
}
setSessionFallbackChain(sessionId, args.fallbackChain)
if (args.category) {
SessionCategoryRegistry.register(sessionId, args.category)
}
return
}
})()
}
export async function executeBackgroundTask(
args: DelegateTaskArgs,
@@ -24,11 +63,12 @@ export async function executeBackgroundTask(
try {
const tddEnabled = executorCtx.sisyphusAgentConfig?.tdd
const effectivePrompt = buildTaskPrompt(args.prompt, agentToUse, tddEnabled)
const normalizedAgent = stripAgentListSortPrefix(agentToUse)
const effectivePrompt = buildTaskPrompt(args.prompt, normalizedAgent, tddEnabled)
const task = await manager.launch({
description: args.description,
prompt: effectivePrompt,
agent: agentToUse,
agent: normalizedAgent,
parentSessionID: parentContext.sessionID,
parentMessageID: parentContext.messageID,
parentModel: parentContext.model,
@@ -50,12 +90,25 @@ export async function executeBackgroundTask(
const waitStart = Date.now()
let sessionId = task.sessionID
while (!sessionId && Date.now() - waitStart < timing.WAIT_FOR_SESSION_TIMEOUT_MS) {
const updated = manager.getTask(task.id)
if (updated?.status === "error" || updated?.status === "cancelled" || updated?.status === "interrupt") {
return `Task failed to start (status: ${updated.status}).\n\nTask ID: ${task.id}`
}
sessionId = updated?.sessionID
if (sessionId) {
break
}
if (ctx.abort?.aborted) {
return `Task aborted while waiting for session to start.\n\nTask ID: ${task.id}`
continueSessionSetup({
taskID: task.id,
manager,
timing,
fallbackChain,
category: args.category,
})
break
}
await new Promise(resolve => setTimeout(resolve, timing.WAIT_FOR_SESSION_INTERVAL_MS))
const updated = manager.getTask(task.id)
sessionId = updated?.sessionID
}
if (sessionId) {
@@ -82,8 +135,9 @@ export async function executeBackgroundTask(
metadata,
}
await ctx.metadata?.(unstableMeta)
if (ctx.callID) {
storeToolMetadata(ctx.sessionID, ctx.callID, unstableMeta)
const callID = resolveCallID(ctx)
if (callID) {
storeToolMetadata(ctx.sessionID, callID, unstableMeta)
}
const taskMetadataBlock = sessionId
@@ -97,12 +151,14 @@ Description: ${task.description}
Agent: ${task.agent}${args.category ? ` (category: ${args.category})` : ""}
Status: ${task.status}
System notifies on completion. Use \`background_output\` with task_id="${task.id}" to check.${taskMetadataBlock}`
System notifies on completion. Use \`background_output\` with task_id="${task.id}" to check.
Do NOT call background_output now. Wait for <system-reminder> notification first.${taskMetadataBlock}`
} catch (error) {
return formatDetailedError(error, {
operation: "Launch background task",
args,
agent: agentToUse,
agent: stripAgentListSortPrefix(agentToUse),
category: args.category,
})
}
@@ -0,0 +1,33 @@
import type { CategoryConfig } from "../../config/schema"
import { ANTHROPIC_CATEGORIES } from "./anthropic-categories"
import type { BuiltinCategoryDefinition } from "./builtin-category-definition"
import { GOOGLE_CATEGORIES } from "./google-categories"
import { KIMI_CATEGORIES } from "./kimi-categories"
import { OPENAI_CATEGORIES } from "./openai-categories"
const BUILTIN_CATEGORIES: BuiltinCategoryDefinition[] = [
...GOOGLE_CATEGORIES,
...OPENAI_CATEGORIES,
...ANTHROPIC_CATEGORIES,
...KIMI_CATEGORIES,
]
function buildCategoryRecord<TValue>(
selector: (definition: BuiltinCategoryDefinition) => TValue
): Record<string, TValue> {
return Object.fromEntries(
BUILTIN_CATEGORIES.map((definition) => [definition.name, selector(definition)])
)
}
export const DEFAULT_CATEGORIES: Record<string, CategoryConfig> = buildCategoryRecord(
(definition) => definition.config
)
export const CATEGORY_PROMPT_APPENDS: Record<string, string> = buildCategoryRecord(
(definition) => definition.promptAppend
)
export const CATEGORY_DESCRIPTIONS: Record<string, string> = buildCategoryRecord(
(definition) => definition.description
)
@@ -0,0 +1,8 @@
import type { CategoryConfig } from "../../config/schema"
export type BuiltinCategoryDefinition = {
name: string
config: CategoryConfig
description: string
promptAppend: string
}
@@ -0,0 +1,43 @@
declare const require: (name: string) => any
const { afterEach, beforeEach, describe, expect, mock, spyOn, test } = require("bun:test")
import { resolveCategoryExecution } from "./category-resolver"
import type { ExecutorContext } from "./executor-types"
import * as availableModels from "./available-models"
describe("resolveCategoryExecution unknown category handling", () => {
beforeEach(() => {
mock.restore()
})
afterEach(() => {
mock.restore()
})
test("#given unknown category #when resolving category execution #then it rejects before fetching available models", async () => {
//#given
const availableModelsSpy = spyOn(availableModels, "getAvailableModelsForDelegateTask")
const executorContext: ExecutorContext = {
client: {} as ExecutorContext["client"],
manager: {} as ExecutorContext["manager"],
directory: "/tmp/test",
userCategories: {},
sisyphusJuniorModel: undefined,
}
const args = {
category: "backend-engineer",
prompt: "test prompt",
description: "Test task",
run_in_background: false,
load_skills: [],
blockedBy: undefined,
enableSkillTools: false,
}
//#when
const result = await resolveCategoryExecution(args, executorContext, undefined, "anthropic/claude-sonnet-4-6")
//#then
expect(result.error).toContain('Unknown category: "backend-engineer"')
expect(availableModelsSpy).not.toHaveBeenCalled()
})
})
+114 -18
View File
@@ -124,7 +124,7 @@ describe("resolveCategoryExecution", () => {
})
const agentsSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
const args = {
category: "deep",
category: "quick",
prompt: "test prompt",
description: "Test task",
run_in_background: false,
@@ -134,7 +134,7 @@ describe("resolveCategoryExecution", () => {
}
const executorCtx = createMockExecutorContext()
executorCtx.userCategories = {
deep: {
quick: {
fallback_models: [
{
model: "openai/gpt-5.4 high",
@@ -169,16 +169,10 @@ describe("resolveCategoryExecution", () => {
agentsSpy.mockRestore()
})
test("does not apply object-style fallback settings when the configured primary model matches directly", async () => {
test("preserves inline variant from category model string when no explicit variant is configured", async () => {
//#given
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
models: { openai: ["gpt-5.4-preview"] },
connected: ["openai"],
updatedAt: "2026-03-03T00:00:00.000Z",
})
const agentsSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
const args = {
category: "deep",
category: "quick",
prompt: "test prompt",
description: "Test task",
run_in_background: false,
@@ -188,7 +182,49 @@ describe("resolveCategoryExecution", () => {
}
const executorCtx = createMockExecutorContext()
executorCtx.userCategories = {
deep: {
quick: {
model: "openai/gpt-5.4 high",
},
}
//#when
const result = await resolveCategoryExecution(args, executorCtx, undefined, "anthropic/claude-sonnet-4-6")
//#then
expect(result.error).toBeUndefined()
expect(result.actualModel).toBeDefined()
expect(result.categoryModel).toBeDefined()
if (!result.actualModel || !result.categoryModel) {
throw new Error("Expected resolved model and category model")
}
expect(result.actualModel).toBe("openai/gpt-5.4")
expect(result.categoryModel).toEqual({
providerID: "openai",
modelID: "gpt-5.4",
variant: "high",
})
})
test("does not apply object-style fallback settings when the configured primary model matches directly", async () => {
//#given
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
models: { openai: ["gpt-5.4-preview"] },
connected: ["openai"],
updatedAt: "2026-03-03T00:00:00.000Z",
})
const agentsSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
const args = {
category: "quick",
prompt: "test prompt",
description: "Test task",
run_in_background: false,
load_skills: [],
blockedBy: undefined,
enableSkillTools: false,
}
const executorCtx = createMockExecutorContext()
executorCtx.userCategories = {
quick: {
model: "openai/gpt-5.4-preview",
fallback_models: [
{
@@ -209,7 +245,7 @@ describe("resolveCategoryExecution", () => {
expect(result.categoryModel).toEqual({
providerID: "openai",
modelID: "gpt-5.4-preview",
variant: "medium",
variant: undefined,
})
cacheSpy.mockRestore()
agentsSpy.mockRestore()
@@ -224,7 +260,7 @@ describe("resolveCategoryExecution", () => {
})
const agentsSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
const args = {
category: "deep",
category: "quick",
prompt: "test prompt",
description: "Test task",
run_in_background: false,
@@ -234,7 +270,7 @@ describe("resolveCategoryExecution", () => {
}
const executorCtx = createMockExecutorContext()
executorCtx.userCategories = {
deep: {
quick: {
fallback_models: [
{
model: "openai/gpt-5.4",
@@ -278,7 +314,7 @@ describe("resolveCategoryExecution", () => {
})
const agentsSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
const args = {
category: "deep",
category: "quick",
prompt: "test prompt",
description: "Test task",
run_in_background: false,
@@ -288,7 +324,7 @@ describe("resolveCategoryExecution", () => {
}
const executorCtx = createMockExecutorContext()
executorCtx.userCategories = {
deep: {
quick: {
fallback_models: [
{
model: "openai/gpt-5.4",
@@ -329,7 +365,7 @@ describe("resolveCategoryExecution", () => {
})
const agentsSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
const args = {
category: "deep",
category: "quick",
prompt: "test prompt",
description: "Test task",
run_in_background: false,
@@ -339,7 +375,7 @@ describe("resolveCategoryExecution", () => {
}
const executorCtx = createMockExecutorContext()
executorCtx.userCategories = {
deep: {
quick: {
fallback_models: [
{
model: "openai/gpt-5.4",
@@ -416,4 +452,64 @@ describe("resolveCategoryExecution", () => {
cacheSpy.mockRestore()
agentsSpy.mockRestore()
})
test("does not inherit hardcoded fallbackChain when user configures a category model [regression #3040]", async () => {
//#given
const args = {
category: "quick",
prompt: "test prompt",
description: "Test task",
run_in_background: false,
load_skills: [],
blockedBy: undefined,
enableSkillTools: false,
}
const executorCtx = createMockExecutorContext()
executorCtx.userCategories = {
quick: {
model: "animal-gateway-xai/grok-4-fast-non-reasoning",
},
}
//#when
const result = await resolveCategoryExecution(args, executorCtx, undefined, "anthropic/claude-sonnet-4-6")
//#then
expect(result.error).toBeUndefined()
expect(result.actualModel).toBe("animal-gateway-xai/grok-4-fast-non-reasoning")
expect(result.categoryModel).toEqual({
providerID: "animal-gateway-xai",
modelID: "grok-4-fast-non-reasoning",
variant: undefined,
})
expect(result.fallbackChain).toBeUndefined()
})
test("does not inherit hardcoded fallbackChain when sisyphus-junior model override is set [regression #2941]", async () => {
//#given
const args = {
category: "quick",
prompt: "test prompt",
description: "Test task",
run_in_background: false,
load_skills: [],
blockedBy: undefined,
enableSkillTools: false,
}
const executorCtx = createMockExecutorContext()
executorCtx.sisyphusJuniorModel = "anthropic/claude-sonnet-4-6"
//#when
const result = await resolveCategoryExecution(args, executorCtx, undefined, "anthropic/claude-sonnet-4-6")
//#then
expect(result.error).toBeUndefined()
expect(result.actualModel).toBe("anthropic/claude-sonnet-4-6")
expect(result.categoryModel).toEqual({
providerID: "anthropic",
modelID: "claude-sonnet-4-6",
variant: undefined,
})
expect(result.fallbackChain).toBeUndefined()
})
})
+26 -11
View File
@@ -9,6 +9,7 @@ import { parseModelString } from "./model-string-parser"
import { CATEGORY_MODEL_REQUIREMENTS } from "../../shared/model-requirements"
import { normalizeFallbackModels, flattenToFallbackModelStrings } from "../../shared/model-resolver"
import { buildFallbackChainFromModels, findMostSpecificFallbackEntry } from "../../shared/fallback-chain-from-models"
import { CONFIG_BASENAME } from "../../shared/plugin-identity"
import { getAvailableModelsForDelegateTask } from "./available-models"
import { resolveModelForDelegateTask } from "./model-selection"
@@ -45,12 +46,26 @@ export async function resolveCategoryExecution(
): Promise<CategoryResolutionResult> {
const { client, userCategories, sisyphusJuniorModel } = executorCtx
const availableModels = await getAvailableModelsForDelegateTask(client)
const categoryName = args.category!
const enabledCategories = mergeCategories(userCategories)
const categoryExists = enabledCategories[categoryName] !== undefined
if (!categoryExists) {
const allCategoryNames = Object.keys(enabledCategories).join(", ")
return {
agentToUse: "",
categoryModel: undefined,
categoryPromptAppend: undefined,
maxPromptTokens: undefined,
modelInfo: undefined,
actualModel: undefined,
isUnstableAgent: false,
error: `Unknown category: "${categoryName}". Available: ${allCategoryNames}`,
}
}
const availableModels = await getAvailableModelsForDelegateTask(client)
const resolved = resolveCategoryConfig(categoryName, {
userCategories,
inheritedModel,
@@ -75,7 +90,7 @@ export async function resolveCategoryExecution(
To use this category:
1. Connect a provider with this model: ${requirement.requiresModel}
2. Or configure an alternative model in your oh-my-opencode.json for this category
2. Or configure an alternative model in your ${CONFIG_BASENAME}.json for this category
Available categories: ${allCategoryNames}`,
}
@@ -117,7 +132,7 @@ Available categories: ${allCategoryNames}`,
const parsedModel = parseModelString(actualModel)
const variantToUse = userCategories?.[args.category!]?.variant ?? resolved.config.variant
categoryModel = parsedModel
? applyCategoryParams({ ...parsedModel, variant: variantToUse }, resolved.config)
? applyCategoryParams({ ...parsedModel, variant: variantToUse ?? parsedModel.variant }, resolved.config)
: undefined
}
} else {
@@ -136,12 +151,12 @@ Available categories: ${allCategoryNames}`,
const userModelOverride = explicitCategoryModel ?? overrideModel
if (userModelOverride) {
actualModel = userModelOverride
const parsedModel = parseModelString(actualModel)
const parsedModel = parseModelString(userModelOverride)
const variantToUse = userCategories?.[args.category!]?.variant ?? resolved.config.variant
categoryModel = parsedModel
? applyCategoryParams({ ...parsedModel, variant: variantToUse }, resolved.config)
? applyCategoryParams({ ...parsedModel, variant: variantToUse ?? parsedModel.variant }, resolved.config)
: undefined
modelInfo = { model: actualModel, type: "user-defined", source: "override" }
modelInfo = { model: userModelOverride, type: "user-defined", source: "override" }
}
} else if (resolution) {
const {
@@ -186,7 +201,7 @@ Available categories: ${allCategoryNames}`,
const parsedModel = parseModelString(actualModel)
const variantToUse = userCategories?.[args.category!]?.variant ?? resolvedVariant ?? resolved.config.variant
categoryModel = parsedModel
? applyCategoryParams({ ...parsedModel, variant: variantToUse }, resolved.config)
? applyCategoryParams({ ...parsedModel, variant: variantToUse ?? parsedModel.variant }, resolved.config)
: undefined
}
}
@@ -211,7 +226,7 @@ Available categories: ${allCategoryNames}`,
Configure in one of:
1. OpenCode: Set "model" in opencode.json
2. Oh-My-OpenCode: Set category model in oh-my-opencode.json
2. Oh-My-OpenCode: Set category model in ${CONFIG_BASENAME}.json
3. Provider: Connect a provider with available models
Current category: ${args.category}
@@ -220,7 +235,7 @@ Available categories: ${categoryNames.join(", ")}`,
}
const resolvedModel = actualModel?.toLowerCase()
const isUnstableAgent = resolved.config.is_unstable_agent ?? (resolvedModel ? resolvedModel.includes("gemini") || resolvedModel.includes("minimax") || resolvedModel.includes("kimi") : false)
const isUnstableAgent = resolved.config.is_unstable_agent ?? (resolvedModel ? resolvedModel.includes("gemini") || resolvedModel.includes("minimax") : false)
const defaultProviderID = categoryModel?.providerID
?? parseModelString(actualModel ?? "")?.providerID
@@ -261,6 +276,6 @@ Available categories: ${categoryNames.join(", ")}`,
actualModel,
isUnstableAgent,
// Don't use hardcoded fallback chain when resolution was skipped (cold cache)
fallbackChain: configuredFallbackChain ?? (isModelResolutionSkipped ? undefined : requirement?.fallbackChain),
fallbackChain: configuredFallbackChain ?? ((isModelResolutionSkipped || explicitCategoryModel || overrideModel) ? undefined : requirement?.fallbackChain),
}
}
+9 -319
View File
@@ -1,322 +1,14 @@
import type { CategoryConfig } from "../../config/schema"
import type {
AvailableCategory,
AvailableSkill,
} from "../../agents/dynamic-agent-prompt-builder"
import { getAgentConfigKey } from "../../shared/agent-display-names"
import { truncateDescription } from "../../shared/truncate-description"
export const VISUAL_CATEGORY_PROMPT_APPEND = `<Category_Context>
You are working on VISUAL/UI tasks.
<DESIGN_SYSTEM_WORKFLOW_MANDATE>
## YOU ARE A VISUAL ENGINEER. FOLLOW THIS WORKFLOW OR YOUR OUTPUT IS REJECTED.
**YOUR FAILURE MODE**: You skip design system analysis and jump straight to writing components with hardcoded colors, arbitrary spacing, and ad-hoc font sizes. The result is INCONSISTENT GARBAGE that looks like 5 different people built it. THIS STOPS NOW.
**EVERY visual task follows this EXACT workflow. VIOLATION = BROKEN OUTPUT.**
### PHASE 1: ANALYZE THE DESIGN SYSTEM (MANDATORY FIRST ACTION)
**BEFORE writing a SINGLE line of CSS, HTML, JSX, Svelte, or component code — you MUST:**
1. **SEARCH for the design system.** Use Grep, Glob, Read — actually LOOK:
- Design tokens: colors, spacing, typography, shadows, border-radii
- Theme files: CSS variables, Tailwind config, \`theme.ts\`, styled-components theme, design tokens file
- Shared/base components: Button, Card, Input, Layout primitives
- Existing UI patterns: How are pages structured? What spacing grid? What color usage?
2. **READ at minimum 5-10 existing UI components.** Understand:
- Naming conventions (BEM? Atomic? Utility-first? Component-scoped?)
- Spacing system (4px grid? 8px? Tailwind scale? CSS variables?)
- Color usage (semantic tokens? Direct hex? Theme references?)
- Typography scale (heading levels, body, caption — how many? What font stack?)
- Component composition patterns (slots? children? compound components?)
**DO NOT proceed to Phase 2 until you can answer ALL of these. If you cannot, you have not explored enough. EXPLORE MORE.**
### PHASE 2: NO DESIGN SYSTEM? BUILD ONE. NOW.
If Phase 1 reveals NO coherent design system (or scattered, inconsistent patterns):
1. **STOP. Do NOT build the requested UI yet.**
2. **Extract what exists** — even inconsistent patterns have salvageable decisions.
3. **Create a minimal design system FIRST:**
- Color palette: primary, secondary, neutral, semantic (success/warning/error/info)
- Typography scale: heading levels (h1-h4 minimum), body, small, caption
- Spacing scale: consistent increments (4px or 8px base)
- Border radii, shadows, transitions — systematic, not random
- Component primitives: the reusable building blocks
4. **Commit/save the design system, THEN proceed to Phase 3.**
A design system is NOT optional overhead. It is the FOUNDATION. Building UI without one is like building a house on sand. It WILL collapse into inconsistency.
### PHASE 3: BUILD WITH THE SYSTEM. NEVER AROUND IT.
**NOW and ONLY NOW** — implement the requested visual work:
| Element | CORRECT | WRONG (WILL BE REJECTED) |
|---------|---------|--------------------------|
| Color | Design token / CSS variable | Hardcoded \`#3b82f6\`, \`rgb(59,130,246)\` |
| Spacing | System value (\`space-4\`, \`gap-md\`, \`var(--spacing-4)\`) | Arbitrary \`margin: 13px\`, \`padding: 7px\` |
| Typography | Scale value (\`text-lg\`, \`heading-2\`, token) | Ad-hoc \`font-size: 17px\` |
| Component | Extend/compose from existing primitives | One-off div soup with inline styles |
| Border radius | System token | Random \`border-radius: 6px\` |
**IF the design requires something OUTSIDE the current system:**
- **Extend the system FIRST** — add the new token/primitive
- **THEN use the new token** in your component
- **NEVER one-off override.** That is how design systems die.
### PHASE 4: VERIFY BEFORE CLAIMING DONE
BEFORE reporting visual work as complete, answer these:
- [ ] Does EVERY color reference a design token or CSS variable?
- [ ] Does EVERY spacing use the system scale?
- [ ] Does EVERY component follow the existing composition pattern?
- [ ] Would a designer see CONSISTENCY across old and new components?
- [ ] Are there ZERO hardcoded magic numbers for visual properties?
**If ANY answer is NO — FIX IT. You are NOT done.**
</DESIGN_SYSTEM_WORKFLOW_MANDATE>
<DESIGN_QUALITY>
Design-first mindset (AFTER design system is established):
- Bold aesthetic choices over safe defaults
- Unexpected layouts, asymmetry, grid-breaking elements
- Distinctive typography (avoid: Arial, Inter, Roboto, Space Grotesk)
- Cohesive color palettes with sharp accents
- High-impact animations with staggered reveals
- Atmosphere: gradient meshes, noise textures, layered transparencies
AVOID: Generic fonts, purple gradients on white, predictable layouts, cookie-cutter patterns.
</DESIGN_QUALITY>
</Category_Context>`
export const ULTRABRAIN_CATEGORY_PROMPT_APPEND = `<Category_Context>
You are working on DEEP LOGICAL REASONING / COMPLEX ARCHITECTURE tasks.
**CRITICAL - CODE STYLE REQUIREMENTS (NON-NEGOTIABLE)**:
1. BEFORE writing ANY code, SEARCH the existing codebase to find similar patterns/styles
2. Your code MUST match the project's existing conventions - blend in seamlessly
3. Write READABLE code that humans can easily understand - no clever tricks
4. If unsure about style, explore more files until you find the pattern
Strategic advisor mindset:
- Bias toward simplicity: least complex solution that fulfills requirements
- Leverage existing code/patterns over new components
- Prioritize developer experience and maintainability
- One clear recommendation with effort estimate (Quick/Short/Medium/Large)
- Signal when advanced approach warranted
Response format:
- Bottom line (2-3 sentences)
- Action plan (numbered steps)
- Risks and mitigations (if relevant)
</Category_Context>`
export const ARTISTRY_CATEGORY_PROMPT_APPEND = `<Category_Context>
You are working on HIGHLY CREATIVE / ARTISTIC tasks.
Artistic genius mindset:
- Push far beyond conventional boundaries
- Explore radical, unconventional directions
- Surprise and delight: unexpected twists, novel combinations
- Rich detail and vivid expression
- Break patterns deliberately when it serves the creative vision
Approach:
- Generate diverse, bold options first
- Embrace ambiguity and wild experimentation
- Balance novelty with coherence
- This is for tasks requiring exceptional creativity
</Category_Context>`
export const QUICK_CATEGORY_PROMPT_APPEND = `<Category_Context>
You are working on SMALL / QUICK tasks.
Efficient execution mindset:
- Fast, focused, minimal overhead
- Get to the point immediately
- No over-engineering
- Simple solutions for simple problems
Approach:
- Minimal viable implementation
- Skip unnecessary abstractions
- Direct and concise
</Category_Context>
<Caller_Warning>
THIS CATEGORY USES A SMALLER/FASTER MODEL (gpt-5.4-mini).
The model executing this task is optimized for speed over depth. Your prompt MUST be:
**EXHAUSTIVELY EXPLICIT** - Leave NOTHING to interpretation:
1. MUST DO: List every required action as atomic, numbered steps
2. MUST NOT DO: Explicitly forbid likely mistakes and deviations
3. EXPECTED OUTPUT: Describe exact success criteria with concrete examples
**WHY THIS MATTERS:**
- Smaller models benefit from explicit guardrails
- Vague instructions may lead to unpredictable results
- Implicit expectations may be missed
**PROMPT STRUCTURE (MANDATORY):**
\`\`\`
TASK: [One-sentence goal]
MUST DO:
1. [Specific action with exact details]
2. [Another specific action]
...
MUST NOT DO:
- [Forbidden action + why]
- [Another forbidden action]
...
EXPECTED OUTPUT:
- [Exact deliverable description]
- [Success criteria / verification method]
\`\`\`
If your prompt lacks this structure, REWRITE IT before delegating.
</Caller_Warning>`
export const UNSPECIFIED_LOW_CATEGORY_PROMPT_APPEND = `<Category_Context>
You are working on tasks that don't fit specific categories but require moderate effort.
<Selection_Gate>
BEFORE selecting this category, VERIFY ALL conditions:
1. Task does NOT fit: quick (trivial), visual-engineering (UI), ultrabrain (deep logic), artistry (creative), writing (docs)
2. Task requires more than trivial effort but is NOT system-wide
3. Scope is contained within a few files/modules
If task fits ANY other category, DO NOT select unspecified-low.
This is NOT a default choice - it's for genuinely unclassifiable moderate-effort work.
</Selection_Gate>
</Category_Context>
<Caller_Warning>
THIS CATEGORY USES A MID-TIER MODEL (claude-sonnet-4-6).
**PROVIDE CLEAR STRUCTURE:**
1. MUST DO: Enumerate required actions explicitly
2. MUST NOT DO: State forbidden actions to prevent scope creep
3. EXPECTED OUTPUT: Define concrete success criteria
</Caller_Warning>`
export const UNSPECIFIED_HIGH_CATEGORY_PROMPT_APPEND = `<Category_Context>
You are working on tasks that don't fit specific categories but require substantial effort.
<Selection_Gate>
BEFORE selecting this category, VERIFY ALL conditions:
1. Task does NOT fit: quick (trivial), visual-engineering (UI), ultrabrain (deep logic), artistry (creative), writing (docs)
2. Task requires substantial effort across multiple systems/modules
3. Changes have broad impact or require careful coordination
4. NOT just "complex" - must be genuinely unclassifiable AND high-effort
If task fits ANY other category, DO NOT select unspecified-high.
If task is unclassifiable but moderate-effort, use unspecified-low instead.
</Selection_Gate>
</Category_Context>`
export const WRITING_CATEGORY_PROMPT_APPEND = `<Category_Context>
You are working on WRITING / PROSE tasks.
Wordsmith mindset:
- Clear, flowing prose
- Appropriate tone and voice
- Engaging and readable
- Proper structure and organization
Approach:
- Understand the audience
- Draft with care
- Polish for clarity and impact
- Documentation, READMEs, articles, technical writing
ANTI-AI-SLOP RULES (NON-NEGOTIABLE):
- NEVER use em dashes (—) or en dashes (). Use commas, periods, ellipses, or line breaks instead. Zero tolerance.
- Remove AI-sounding phrases: "delve", "it's important to note", "I'd be happy to", "certainly", "please don't hesitate", "leverage", "utilize", "in order to", "moving forward", "circle back", "at the end of the day", "robust", "streamline", "facilitate"
- Pick plain words. "Use" not "utilize". "Start" not "commence". "Help" not "facilitate".
- Use contractions naturally: "don't" not "do not", "it's" not "it is".
- Vary sentence length. Don't make every sentence the same length.
- NEVER start consecutive sentences with the same word.
- No filler openings: skip "In today's world...", "As we all know...", "It goes without saying..."
- Write like a human, not a corporate template.
</Category_Context>`
export const DEEP_CATEGORY_PROMPT_APPEND = `<Category_Context>
You are working on GOAL-ORIENTED AUTONOMOUS tasks.
**CRITICAL - AUTONOMOUS EXECUTION MINDSET (NON-NEGOTIABLE)**:
You are NOT an interactive assistant. You are an autonomous problem-solver.
**BEFORE making ANY changes**:
1. SILENTLY explore the codebase extensively (5-15 minutes of reading is normal)
2. Read related files, trace dependencies, understand the full context
3. Build a complete mental model of the problem space
4. DO NOT ask clarifying questions - the goal is already defined
**Autonomous executor mindset**:
- You receive a GOAL. When the goal includes numbered steps or phases, treat them as one atomic task broken into sub-steps - NOT as separate independent tasks.
- Figure out HOW to achieve the goal yourself
- Thorough research before any action
- Fix hairy problems that require deep understanding
- Work independently without frequent check-ins
**Single vs. multi-step context**:
- Sub-steps of ONE goal (e.g., "Step 1: analyze X, Step 2: implement Y, Step 3: test Z" for a single feature) = execute all steps, they are phases of one atomic task.
- Genuinely independent tasks (e.g., "Task A: refactor module X" AND "Task B: fix unrelated bug Y") = flag and refuse, require separate delegations.
**Approach**:
- Explore extensively, understand deeply, then act decisively
- Prefer comprehensive solutions over quick patches
- If the goal is unclear, make reasonable assumptions and proceed
- Document your reasoning in code comments only when non-obvious
**Response format**:
- Minimal status updates (user trusts your autonomy)
- Focus on results, not play-by-play progress
- Report completion with summary of changes made
</Category_Context>`
export const DEFAULT_CATEGORIES: Record<string, CategoryConfig> = {
"visual-engineering": { model: "google/gemini-3.1-pro", variant: "high" },
ultrabrain: { model: "openai/gpt-5.4", variant: "xhigh" },
deep: { model: "openai/gpt-5.3-codex", variant: "medium" },
artistry: { model: "google/gemini-3.1-pro", variant: "high" },
quick: { model: "openai/gpt-5.4-mini" },
"unspecified-low": { model: "anthropic/claude-sonnet-4-6" },
"unspecified-high": { model: "anthropic/claude-opus-4-6", variant: "max" },
writing: { model: "kimi-for-coding/k2p5" },
}
export const CATEGORY_PROMPT_APPENDS: Record<string, string> = {
"visual-engineering": VISUAL_CATEGORY_PROMPT_APPEND,
ultrabrain: ULTRABRAIN_CATEGORY_PROMPT_APPEND,
deep: DEEP_CATEGORY_PROMPT_APPEND,
artistry: ARTISTRY_CATEGORY_PROMPT_APPEND,
quick: QUICK_CATEGORY_PROMPT_APPEND,
"unspecified-low": UNSPECIFIED_LOW_CATEGORY_PROMPT_APPEND,
"unspecified-high": UNSPECIFIED_HIGH_CATEGORY_PROMPT_APPEND,
writing: WRITING_CATEGORY_PROMPT_APPEND,
}
export const CATEGORY_DESCRIPTIONS: Record<string, string> = {
"visual-engineering": "Frontend, UI/UX, design, styling, animation",
ultrabrain: "Use ONLY for genuinely hard, logic-heavy tasks. Give clear goals only, not step-by-step instructions.",
deep: "Goal-oriented autonomous problem-solving. Thorough research before action. For hairy problems requiring deep understanding.",
artistry: "Complex problem-solving with unconventional, creative approaches - beyond standard patterns",
quick: "Trivial tasks - single file changes, typo fixes, simple modifications",
"unspecified-low": "Tasks that don't fit other categories, low effort required",
"unspecified-high": "Tasks that don't fit other categories, high effort required",
writing: "Documentation, prose, technical writing",
}
export {
CATEGORY_DESCRIPTIONS,
CATEGORY_PROMPT_APPENDS,
DEFAULT_CATEGORIES,
} from "./builtin-categories"
/**
* System prompt prepended to plan agent invocations.
@@ -634,7 +326,7 @@ export const PLAN_AGENT_NAMES = ["plan"]
export function isPlanAgent(agentName: string | undefined): boolean {
if (!agentName) return false
const lowerName = agentName.toLowerCase().trim()
return PLAN_AGENT_NAMES.some(name => lowerName === name || lowerName.includes(name))
return PLAN_AGENT_NAMES.some(name => lowerName === name)
}
/**
@@ -650,8 +342,6 @@ export function isPlanFamily(category: string): boolean
export function isPlanFamily(category: string | undefined): boolean
export function isPlanFamily(category: string | undefined): boolean {
if (!category) return false
const lowerCategory = category.toLowerCase().trim()
return PLAN_FAMILY_NAMES.some(
(name) => lowerCategory === name || lowerCategory.includes(name)
)
const lowerCategory = getAgentConfigKey(category).toLowerCase().trim()
return PLAN_FAMILY_NAMES.some((name) => lowerCategory === name)
}
@@ -0,0 +1,122 @@
import type { BuiltinCategoryDefinition } from "./builtin-category-definition"
const VISUAL_CATEGORY_PROMPT_APPEND = `<Category_Context>
You are working on VISUAL/UI tasks.
<DESIGN_SYSTEM_WORKFLOW_MANDATE>
## YOU ARE A VISUAL ENGINEER. FOLLOW THIS WORKFLOW OR YOUR OUTPUT IS REJECTED.
**YOUR FAILURE MODE**: You skip design system analysis and jump straight to writing components with hardcoded colors, arbitrary spacing, and ad-hoc font sizes. The result is INCONSISTENT GARBAGE that looks like 5 different people built it. THIS STOPS NOW.
**EVERY visual task follows this EXACT workflow. VIOLATION = BROKEN OUTPUT.**
### PHASE 1: ANALYZE THE DESIGN SYSTEM (MANDATORY FIRST ACTION)
**BEFORE writing a SINGLE line of CSS, HTML, JSX, Svelte, or component code - you MUST:**
1. **SEARCH for the design system.** Use Grep, Glob, Read - actually LOOK:
- Design tokens: colors, spacing, typography, shadows, border-radii
- Theme files: CSS variables, Tailwind config, \`theme.ts\`, styled-components theme, design tokens file
- Shared/base components: Button, Card, Input, Layout primitives
- Existing UI patterns: How are pages structured? What spacing grid? What color usage?
2. **READ at minimum 5-10 existing UI components.** Understand:
- Naming conventions (BEM? Atomic? Utility-first? Component-scoped?)
- Spacing system (4px grid? 8px? Tailwind scale? CSS variables?)
- Color usage (semantic tokens? Direct hex? Theme references?)
- Typography scale (heading levels, body, caption - how many? What font stack?)
- Component composition patterns (slots? children? compound components?)
**DO NOT proceed to Phase 2 until you can answer ALL of these. If you cannot, you have not explored enough. EXPLORE MORE.**
### PHASE 2: NO DESIGN SYSTEM? BUILD ONE. NOW.
If Phase 1 reveals NO coherent design system (or scattered, inconsistent patterns):
1. **STOP. Do NOT build the requested UI yet.**
2. **Extract what exists** - even inconsistent patterns have salvageable decisions.
3. **Create a minimal design system FIRST:**
- Color palette: primary, secondary, neutral, semantic (success/warning/error/info)
- Typography scale: heading levels (h1-h4 minimum), body, small, caption
- Spacing scale: consistent increments (4px or 8px base)
- Border radii, shadows, transitions - systematic, not random
- Component primitives: the reusable building blocks
4. **Commit/save the design system, THEN proceed to Phase 3.**
A design system is NOT optional overhead. It is the FOUNDATION. Building UI without one is like building a house on sand. It WILL collapse into inconsistency.
### PHASE 3: BUILD WITH THE SYSTEM. NEVER AROUND IT.
**NOW and ONLY NOW** - implement the requested visual work:
| Element | CORRECT | WRONG (WILL BE REJECTED) |
|---------|---------|--------------------------|
| Color | Design token / CSS variable | Hardcoded \`#3b82f6\`, \`rgb(59,130,246)\` |
| Spacing | System value (\`space-4\`, \`gap-md\`, \`var(--spacing-4)\`) | Arbitrary \`margin: 13px\`, \`padding: 7px\` |
| Typography | Scale value (\`text-lg\`, \`heading-2\`, token) | Ad-hoc \`font-size: 17px\` |
| Component | Extend/compose from existing primitives | One-off div soup with inline styles |
| Border radius | System token | Random \`border-radius: 6px\` |
**IF the design requires something OUTSIDE the current system:**
- **Extend the system FIRST** - add the new token/primitive
- **THEN use the new token** in your component
- **NEVER one-off override.** That is how design systems die.
### PHASE 4: VERIFY BEFORE CLAIMING DONE
BEFORE reporting visual work as complete, answer these:
- [ ] Does EVERY color reference a design token or CSS variable?
- [ ] Does EVERY spacing use the system scale?
- [ ] Does EVERY component follow the existing composition pattern?
- [ ] Would a designer see CONSISTENCY across old and new components?
- [ ] Are there ZERO hardcoded magic numbers for visual properties?
**If ANY answer is NO - FIX IT. You are NOT done.**
</DESIGN_SYSTEM_WORKFLOW_MANDATE>
<DESIGN_QUALITY>
Design-first mindset (AFTER design system is established):
- Bold aesthetic choices over safe defaults
- Unexpected layouts, asymmetry, grid-breaking elements
- Distinctive typography (avoid: Arial, Inter, Roboto, Space Grotesk)
- Cohesive color palettes with sharp accents
- High-impact animations with staggered reveals
- Atmosphere: gradient meshes, noise textures, layered transparencies
AVOID: Generic fonts, purple gradients on white, predictable layouts, cookie-cutter patterns.
</DESIGN_QUALITY>
</Category_Context>`
const ARTISTRY_CATEGORY_PROMPT_APPEND = `<Category_Context>
You are working on HIGHLY CREATIVE / ARTISTIC tasks.
Artistic genius mindset:
- Push far beyond conventional boundaries
- Explore radical, unconventional directions
- Surprise and delight: unexpected twists, novel combinations
- Rich detail and vivid expression
- Break patterns deliberately when it serves the creative vision
Approach:
- Generate diverse, bold options first
- Embrace ambiguity and wild experimentation
- Balance novelty with coherence
- This is for tasks requiring exceptional creativity
</Category_Context>`
export const GOOGLE_CATEGORIES: BuiltinCategoryDefinition[] = [
{
name: "visual-engineering",
config: { model: "google/gemini-3.1-pro", variant: "high" },
description: "Frontend, UI/UX, design, styling, animation",
promptAppend: VISUAL_CATEGORY_PROMPT_APPEND,
},
{
name: "artistry",
config: { model: "google/gemini-3.1-pro", variant: "high" },
description: "Complex problem-solving with unconventional, creative approaches - beyond standard patterns",
promptAppend: ARTISTRY_CATEGORY_PROMPT_APPEND,
},
]
@@ -0,0 +1,36 @@
import type { BuiltinCategoryDefinition } from "./builtin-category-definition"
const WRITING_CATEGORY_PROMPT_APPEND = `<Category_Context>
You are working on WRITING / PROSE tasks.
Wordsmith mindset:
- Clear, flowing prose
- Appropriate tone and voice
- Engaging and readable
- Proper structure and organization
Approach:
- Understand the audience
- Draft with care
- Polish for clarity and impact
- Documentation, READMEs, articles, technical writing
ANTI-AI-SLOP RULES (NON-NEGOTIABLE):
- NEVER use em dashes (-) or en dashes (-). Use commas, periods, ellipses, or line breaks instead. Zero tolerance.
- Remove AI-sounding phrases: "delve", "it's important to note", "I'd be happy to", "certainly", "please don't hesitate", "leverage", "utilize", "in order to", "moving forward", "circle back", "at the end of the day", "robust", "streamline", "facilitate"
- Pick plain words. "Use" not "utilize". "Start" not "commence". "Help" not "facilitate".
- Use contractions naturally: "don't" not "do not", "it's" not "it is".
- Vary sentence length. Don't make every sentence the same length.
- NEVER start consecutive sentences with the same word.
- No filler openings: skip "In today's world...", "As we all know...", "It goes without saying..."
- Write like a human, not a corporate template.
</Category_Context>`
export const KIMI_CATEGORIES: BuiltinCategoryDefinition[] = [
{
name: "writing",
config: { model: "kimi-for-coding/k2p5" },
description: "Documentation, prose, technical writing",
promptAppend: WRITING_CATEGORY_PROMPT_APPEND,
},
]
+115 -20
View File
@@ -1,5 +1,6 @@
declare const require: (name: string) => any
const { afterEach, beforeEach, describe, expect, mock, spyOn, test } = require("bun:test")
/// <reference types="bun-types" />
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"
import { resolveModelForDelegateTask } from "./model-selection"
import * as connectedProvidersCache from "../../shared/connected-providers-cache"
@@ -25,12 +26,12 @@ describe("resolveModelForDelegateTask", () => {
describe("#when availableModels is empty and no user model override", () => {
test("#then returns skipped sentinel to leave model unpinned", () => {
const result = resolveModelForDelegateTask({
categoryDefaultModel: "anthropic/claude-sonnet-4-6",
categoryDefaultModel: "anthropic/claude-sonnet-4.6",
fallbackChain: [
{ providers: ["anthropic"], model: "claude-sonnet-4-6" },
],
availableModels: new Set(),
systemDefaultModel: "anthropic/claude-sonnet-4-6",
systemDefaultModel: "anthropic/claude-sonnet-4.6",
})
expect(result).toEqual({ skipped: true })
@@ -41,12 +42,12 @@ describe("resolveModelForDelegateTask", () => {
test("#then returns the user model regardless of cache state", () => {
const result = resolveModelForDelegateTask({
userModel: "openai/gpt-5.4",
categoryDefaultModel: "anthropic/claude-sonnet-4-6",
categoryDefaultModel: "anthropic/claude-sonnet-4.6",
fallbackChain: [
{ providers: ["anthropic"], model: "claude-sonnet-4-6" },
],
availableModels: new Set(),
systemDefaultModel: "anthropic/claude-sonnet-4-6",
systemDefaultModel: "anthropic/claude-sonnet-4.6",
})
expect(result).toEqual({ model: "openai/gpt-5.4" })
@@ -57,7 +58,7 @@ describe("resolveModelForDelegateTask", () => {
test("#then returns skipped sentinel (skip fallback resolution without cache)", () => {
const result = resolveModelForDelegateTask({
userFallbackModels: ["openai/gpt-5.4", "google/gemini-3.1-pro"],
categoryDefaultModel: "anthropic/claude-sonnet-4-6",
categoryDefaultModel: "anthropic/claude-sonnet-4.6",
fallbackChain: [
{ providers: ["anthropic"], model: "claude-sonnet-4-6" },
],
@@ -80,15 +81,15 @@ describe("resolveModelForDelegateTask", () => {
const readConnectedProvidersSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["anthropic"])
const result = resolveModelForDelegateTask({
categoryDefaultModel: "anthropic/claude-sonnet-4-6",
categoryDefaultModel: "anthropic/claude-sonnet-4.6",
fallbackChain: [
{ providers: ["anthropic"], model: "claude-sonnet-4-6" },
],
availableModels: new Set(),
systemDefaultModel: "anthropic/claude-sonnet-4-6",
systemDefaultModel: "anthropic/claude-sonnet-4.6",
})
expect(result).toEqual({ model: "anthropic/claude-sonnet-4-6" })
expect(result).toEqual({ model: "anthropic/claude-sonnet-4.6" })
readConnectedProvidersSpy.mockRestore()
})
@@ -96,12 +97,12 @@ describe("resolveModelForDelegateTask", () => {
const readConnectedProvidersSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
const result = resolveModelForDelegateTask({
categoryDefaultModel: "anthropic/claude-sonnet-4-6",
categoryDefaultModel: "anthropic/claude-sonnet-4.6",
fallbackChain: [
{ providers: ["openai"], model: "gpt-5.4", variant: "high" },
],
availableModels: new Set(),
systemDefaultModel: "anthropic/claude-sonnet-4-6",
systemDefaultModel: "anthropic/claude-sonnet-4.6",
})
expect(result).toEqual({
@@ -117,7 +118,7 @@ describe("resolveModelForDelegateTask", () => {
const readConnectedProvidersSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
const result = resolveModelForDelegateTask({
userFallbackModels: ["anthropic/claude-sonnet-4-6", "openai/gpt-5.4"],
userFallbackModels: ["anthropic/claude-sonnet-4.6", "openai/gpt-5.4"],
availableModels: new Set(),
})
@@ -129,14 +130,14 @@ describe("resolveModelForDelegateTask", () => {
describe("#when availableModels has entries and category default matches", () => {
test("#then resolves via fuzzy match (existing behavior)", () => {
const result = resolveModelForDelegateTask({
categoryDefaultModel: "anthropic/claude-sonnet-4-6",
categoryDefaultModel: "anthropic/claude-sonnet-4.6",
fallbackChain: [
{ providers: ["anthropic"], model: "claude-sonnet-4-6" },
],
availableModels: new Set(["anthropic/claude-sonnet-4-6"]),
availableModels: new Set(["anthropic/claude-sonnet-4.6"]),
})
expect(result).toEqual({ model: "anthropic/claude-sonnet-4-6" })
expect(result).toEqual({ model: "anthropic/claude-sonnet-4.6" })
})
test("#then trusts user-configured category model without fuzzy validation", () => {
@@ -200,7 +201,7 @@ describe("resolveModelForDelegateTask", () => {
expect(result).toBeDefined()
expect(result).not.toHaveProperty("skipped")
const resolved = result as { model: string; variant?: string }
expect(resolved.model).toBe("anthropic/claude-haiku-4-5")
expect(resolved.model).toBe("anthropic/claude-haiku-4.5")
})
test("#then resolves first provider in entry that is connected", () => {
@@ -229,10 +230,10 @@ describe("resolveModelForDelegateTask", () => {
{ providers: ["opencode-go"], model: "minimax-m2.7" },
],
availableModels: new Set(),
systemDefaultModel: "anthropic/claude-sonnet-4-6",
systemDefaultModel: "anthropic/claude-sonnet-4.6",
})
expect(result).toEqual({ model: "anthropic/claude-sonnet-4-6" })
expect(result).toEqual({ model: "anthropic/claude-sonnet-4.6" })
})
})
@@ -254,6 +255,100 @@ describe("resolveModelForDelegateTask", () => {
})
})
describe("#given user model override includes variant syntax", () => {
describe("#when userModel contains space-separated variant", () => {
test("#then extracts the variant and returns the base model separately", () => {
const result = resolveModelForDelegateTask({
userModel: "openai/gpt-5.4 high",
categoryDefaultModel: "anthropic/claude-sonnet-4.6",
fallbackChain: [
{ providers: ["anthropic"], model: "claude-sonnet-4-6" },
],
availableModels: new Set(["openai/gpt-5.4"]),
})
expect(result).toEqual({ model: "openai/gpt-5.4", variant: "high" })
})
})
describe("#when userModel contains parenthesized variant", () => {
test("#then extracts the variant and returns the base model separately", () => {
const result = resolveModelForDelegateTask({
userModel: "openai/gpt-5.4(max)",
categoryDefaultModel: "anthropic/claude-sonnet-4.6",
availableModels: new Set(),
})
expect(result).toEqual({ model: "openai/gpt-5.4", variant: "max" })
})
})
describe("#when userModel has no variant syntax", () => {
test("#then returns the model without a variant (backward compat)", () => {
const result = resolveModelForDelegateTask({
userModel: "openai/gpt-5.4",
availableModels: new Set(),
})
expect(result).toEqual({ model: "openai/gpt-5.4" })
})
})
describe("#when userModel has a non-variant suffix (e.g. -high in model name)", () => {
test("#then preserves the full model name without extracting a variant", () => {
const result = resolveModelForDelegateTask({
userModel: "new-api-openai/gpt-5.4-high",
availableModels: new Set(),
})
expect(result).toEqual({ model: "new-api-openai/gpt-5.4-high" })
})
})
})
describe("#given user-configured category model includes variant syntax", () => {
beforeEach(() => {
hasConnectedProvidersSpy = spyOn(connectedProvidersCache, "hasConnectedProvidersCache").mockReturnValue(true)
hasProviderModelsSpy = spyOn(connectedProvidersCache, "hasProviderModelsCache").mockReturnValue(true)
})
describe("#when categoryDefaultModel with isUserConfiguredCategoryModel contains a space-separated variant", () => {
test("#then extracts the variant and returns the base model separately", () => {
const result = resolveModelForDelegateTask({
categoryDefaultModel: "openai/gpt-5.4 medium",
isUserConfiguredCategoryModel: true,
availableModels: new Set(["openai/gpt-5.4"]),
})
expect(result).toEqual({ model: "openai/gpt-5.4", variant: "medium" })
})
})
describe("#when categoryDefaultModel with isUserConfiguredCategoryModel contains a parenthesized variant", () => {
test("#then extracts the variant and returns the base model separately", () => {
const result = resolveModelForDelegateTask({
categoryDefaultModel: "openai/gpt-5.4(xhigh)",
isUserConfiguredCategoryModel: true,
availableModels: new Set(),
})
expect(result).toEqual({ model: "openai/gpt-5.4", variant: "xhigh" })
})
})
describe("#when categoryDefaultModel with isUserConfiguredCategoryModel has no variant", () => {
test("#then returns the model without a variant (backward compat)", () => {
const result = resolveModelForDelegateTask({
categoryDefaultModel: "new-api-openai/gpt-5.4-high",
isUserConfiguredCategoryModel: true,
availableModels: new Set(["openai/gpt-5.4"]),
})
expect(result).toEqual({ model: "new-api-openai/gpt-5.4-high" })
})
})
})
describe("#given only connected providers cache exists (no provider-models cache)", () => {
beforeEach(() => {
hasConnectedProvidersSpy = spyOn(connectedProvidersCache, "hasConnectedProvidersCache").mockReturnValue(true)
@@ -265,7 +360,7 @@ describe("resolveModelForDelegateTask", () => {
const readConnectedProvidersSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
const result = resolveModelForDelegateTask({
categoryDefaultModel: "anthropic/claude-sonnet-4-6",
categoryDefaultModel: "anthropic/claude-sonnet-4.6",
fallbackChain: [
{ providers: ["openai"], model: "gpt-5.4" },
],
@@ -56,6 +56,10 @@ export function resolveModelForDelegateTask(input: {
}): { model: string; variant?: string; fallbackEntry?: FallbackEntry; matchedFallback?: boolean } | { skipped: true } | undefined {
const userModel = normalizeModel(input.userModel)
if (userModel) {
const parsed = parseUserFallbackModel(userModel)
if (parsed?.variant) {
return { model: parsed.baseModel, variant: parsed.variant }
}
return { model: userModel }
}
@@ -75,6 +79,10 @@ export function resolveModelForDelegateTask(input: {
log("[resolveModelForDelegateTask] using user-configured category model (bypass validation)", {
categoryDefaultModel: categoryDefault,
})
const parsed = parseUserFallbackModel(categoryDefault)
if (parsed?.variant) {
return { model: parsed.baseModel, variant: parsed.variant }
}
return { model: categoryDefault }
}
@@ -0,0 +1,116 @@
import type { BuiltinCategoryDefinition } from "./builtin-category-definition"
const ULTRABRAIN_CATEGORY_PROMPT_APPEND = `<Category_Context>
You are working on DEEP LOGICAL REASONING / COMPLEX ARCHITECTURE tasks.
**CRITICAL - CODE STYLE REQUIREMENTS (NON-NEGOTIABLE)**:
1. BEFORE writing ANY code, SEARCH the existing codebase to find similar patterns/styles
2. Your code MUST match the project's existing conventions - blend in seamlessly
3. Write READABLE code that humans can easily understand - no clever tricks
4. If unsure about style, explore more files until you find the pattern
Strategic advisor mindset:
- Bias toward simplicity: least complex solution that fulfills requirements
- Leverage existing code/patterns over new components
- Prioritize developer experience and maintainability
- One clear recommendation with effort estimate (Quick/Short/Medium/Large)
- Signal when advanced approach warranted
Response format:
- Bottom line (2-3 sentences)
- Action plan (numbered steps)
- Risks and mitigations (if relevant)
</Category_Context>`
const DEEP_CATEGORY_PROMPT_APPEND = `<Category_Context>
You are working on GOAL-ORIENTED AUTONOMOUS tasks.
You are NOT an interactive assistant. You are an autonomous problem-solver.
BEFORE making ANY changes:
1. Silently explore the codebase extensively (5-15 minutes of reading is normal)
2. Read related files, trace dependencies, understand the full context
3. Build a complete mental model of the problem space
4. Do not ask clarifying questions - the goal is already defined
You receive a GOAL. When the goal includes numbered steps or phases, treat them as one atomic task broken into sub-steps, not as separate independent tasks. Figure out HOW to achieve it yourself. Thorough research before any action.
Sub-steps of ONE goal = execute all steps as phases of one atomic task.
Genuinely independent tasks = flag and refuse, require separate delegations.
Approach: explore extensively, understand deeply, then act decisively. Prefer comprehensive solutions over quick patches. If the goal is unclear, make reasonable assumptions and proceed.
Minimal status updates. Focus on results, not play-by-play. Report completion with summary of changes.
</Category_Context>`
const QUICK_CATEGORY_PROMPT_APPEND = `<Category_Context>
You are working on SMALL / QUICK tasks.
Efficient execution mindset:
- Fast, focused, minimal overhead
- Get to the point immediately
- No over-engineering
- Simple solutions for simple problems
Approach:
- Minimal viable implementation
- Skip unnecessary abstractions
- Direct and concise
</Category_Context>
<Caller_Warning>
THIS CATEGORY USES A SMALLER/FASTER MODEL (gpt-5.4-mini).
The model executing this task is optimized for speed over depth. Your prompt MUST be:
**EXHAUSTIVELY EXPLICIT** - Leave NOTHING to interpretation:
1. MUST DO: List every required action as atomic, numbered steps
2. MUST NOT DO: Explicitly forbid likely mistakes and deviations
3. EXPECTED OUTPUT: Describe exact success criteria with concrete examples
**WHY THIS MATTERS:**
- Smaller models benefit from explicit guardrails
- Vague instructions may lead to unpredictable results
- Implicit expectations may be missed
**PROMPT STRUCTURE (MANDATORY):**
\`\`\`
TASK: [One-sentence goal]
MUST DO:
1. [Specific action with exact details]
2. [Another specific action]
...
MUST NOT DO:
- [Forbidden action + why]
- [Another forbidden action]
...
EXPECTED OUTPUT:
- [Exact deliverable description]
- [Success criteria / verification method]
\`\`\`
If your prompt lacks this structure, REWRITE IT before delegating.
</Caller_Warning>`
export const OPENAI_CATEGORIES: BuiltinCategoryDefinition[] = [
{
name: "ultrabrain",
config: { model: "openai/gpt-5.4", variant: "xhigh" },
description: "Use ONLY for genuinely hard, logic-heavy tasks. Give clear goals only, not step-by-step instructions.",
promptAppend: ULTRABRAIN_CATEGORY_PROMPT_APPEND,
},
{
name: "deep",
config: { model: "openai/gpt-5.4", variant: "medium" },
description: "Goal-oriented autonomous problem-solving. Thorough research before action. For hairy problems requiring deep understanding.",
promptAppend: DEEP_CATEGORY_PROMPT_APPEND,
},
{
name: "quick",
config: { model: "openai/gpt-5.4-mini" },
description: "Trivial tasks - single file changes, typo fixes, simple modifications",
promptAppend: QUICK_CATEGORY_PROMPT_APPEND,
},
]
@@ -0,0 +1,125 @@
declare const require: (name: string) => unknown
const { describe, test, expect } = require("bun:test") as {
describe: (name: string, fn: () => void) => void
test: (name: string, fn: () => void) => void
expect: (value: unknown) => {
toBe: (expected: unknown) => void
toContain: (expected: string) => void
toBeUndefined: () => void
toBeDefined: () => void
not: {
toContain: (expected: string) => void
toBeUndefined: () => void
}
}
}
import { buildSystemContent } from "./prompt-builder"
import type { AvailableSkill, AvailableCategory } from "../../agents/dynamic-agent-prompt-builder"
describe("prompt-builder", () => {
describe("buildSystemContent", () => {
describe("#given non-plan agent with availableSkills", () => {
test("#when availableSkills contains project-level skills #then system content includes available_skills section", () => {
// given
const availableSkills: AvailableSkill[] = [
{ name: "git-master", description: "Git workflow automation", location: "plugin" },
{ name: "my-project-skill", description: "Project-specific deployment", location: "project" },
]
const availableCategories: AvailableCategory[] = [
{ name: "quick", description: "Trivial tasks", model: "openai/gpt-5.4-mini" },
]
// when
const result = buildSystemContent({
agentName: "sisyphus-junior",
availableSkills,
availableCategories,
})
// then
expect(result).toBeDefined()
expect(result).toContain("my-project-skill")
expect(result).toContain("git-master")
})
test("#when agent is explore #then system content includes available_skills section", () => {
// given
const availableSkills: AvailableSkill[] = [
{ name: "code-review", description: "Review code quality", location: "project" },
]
// when
const result = buildSystemContent({
agentName: "explore",
availableSkills,
})
// then
expect(result).toBeDefined()
expect(result).toContain("code-review")
})
test("#when availableSkills is empty #then system content does not include available_skills section", () => {
// given
const availableSkills: AvailableSkill[] = []
// when
const result = buildSystemContent({
agentName: "sisyphus-junior",
availableSkills,
categoryPromptAppend: "some category context",
})
// then
expect(result).toBeDefined()
expect(result).not.toContain("available_skills")
})
})
describe("#given plan agent with availableSkills", () => {
test("#when availableSkills provided #then system content includes plan agent prepend with skills", () => {
// given
const availableSkills: AvailableSkill[] = [
{ name: "git-master", description: "Git workflow automation", location: "plugin" },
]
const availableCategories: AvailableCategory[] = [
{ name: "quick", description: "Trivial tasks", model: "openai/gpt-5.4-mini" },
]
// when
const result = buildSystemContent({
agentName: "plan",
availableSkills,
availableCategories,
})
// then
expect(result).toBeDefined()
expect(result).toContain("git-master")
expect(result).toContain("AVAILABLE SKILLS")
})
})
describe("#given non-plan agent with agentsContext override", () => {
test("#when agentsContext is provided #then it takes precedence and skills section is appended", () => {
// given
const availableSkills: AvailableSkill[] = [
{ name: "deploy-skill", description: "Deployment automation", location: "project" },
]
// when
const result = buildSystemContent({
agentName: "sisyphus-junior",
agentsContext: "Custom agent context here",
availableSkills,
})
// then
expect(result).toBeDefined()
expect(result).toContain("Custom agent context here")
expect(result).toContain("deploy-skill")
})
})
})
})
+29 -2
View File
@@ -1,4 +1,5 @@
import type { BuildSystemContentInput } from "./types"
import type { AvailableSkill } from "../../agents/dynamic-agent-prompt-builder"
import { buildPlanAgentSystemPrepend, isPlanAgent } from "./constants"
import { buildSystemContentWithTokenLimit } from "./token-limiter"
@@ -21,6 +22,22 @@ ${TDD_LINE}`
return PLAN_AGENT_PROMPT_BASE
}
function buildAvailableSkillsSection(skills: AvailableSkill[]): string {
if (skills.length === 0) {
return ""
}
const rows = skills
.map((s) => `- \`${s.name}\`: ${s.description || s.name}`)
.join("\n")
return `<available_skills>
Skills provide specialized instructions. Load via load_skills parameter when delegating tasks.
${rows}
</available_skills>`
}
function usesFreeOrLocalModel(model: { providerID: string; modelID: string; variant?: string } | undefined): boolean {
if (!model) {
return false
@@ -51,10 +68,20 @@ export function buildSystemContent(input: BuildSystemContentInput): string | und
availableSkills,
} = input
const planAgentPrepend = isPlanAgent(agentName)
const isPlan = isPlanAgent(agentName)
const planAgentPrepend = isPlan
? buildPlanAgentSystemPrepend(availableCategories, availableSkills)
: ""
const skillsSection = !isPlan
? buildAvailableSkillsSection(availableSkills ?? [])
: ""
const baseAgentsContext = agentsContext ?? planAgentPrepend
const effectiveAgentsContext = !isPlan && skillsSection
? [baseAgentsContext, skillsSection].filter(Boolean).join("\n\n")
: baseAgentsContext
const effectiveMaxPromptTokens = maxPromptTokens
?? (usesFreeOrLocalModel(model) ? FREE_OR_LOCAL_PROMPT_TOKEN_LIMIT : undefined)
@@ -63,7 +90,7 @@ export function buildSystemContent(input: BuildSystemContentInput): string | und
skillContent,
skillContents,
categoryPromptAppend,
agentsContext: agentsContext ?? planAgentPrepend,
agentsContext: effectiveAgentsContext,
planAgentPrepend,
},
effectiveMaxPromptTokens
@@ -0,0 +1,40 @@
import { describe, test, expect } from "bun:test"
import { resolveCallID } from "./resolve-call-id"
import type { ToolContextWithMetadata } from "./types"
describe("resolveCallID", () => {
function makeCtx(overrides: Partial<ToolContextWithMetadata> = {}): ToolContextWithMetadata {
return {
sessionID: "ses_test",
messageID: "msg_test",
agent: "sisyphus",
abort: new AbortController().signal,
...overrides,
}
}
test("#given callID is set #then returns callID", () => {
const ctx = makeCtx({ callID: "call_abc" })
expect(resolveCallID(ctx)).toBe("call_abc")
})
test("#given only callId is set #then returns callId", () => {
const ctx = makeCtx({ callId: "call_def" })
expect(resolveCallID(ctx)).toBe("call_def")
})
test("#given only call_id is set #then returns call_id", () => {
const ctx = makeCtx({ call_id: "call_ghi" })
expect(resolveCallID(ctx)).toBe("call_ghi")
})
test("#given callID and callId are both set #then prefers callID", () => {
const ctx = makeCtx({ callID: "preferred", callId: "fallback" })
expect(resolveCallID(ctx)).toBe("preferred")
})
test("#given no call ID variants are set #then returns undefined", () => {
const ctx = makeCtx()
expect(resolveCallID(ctx)).toBeUndefined()
})
})
@@ -0,0 +1,5 @@
import type { ToolContextWithMetadata } from "./types"
export function resolveCallID(ctx: ToolContextWithMetadata): string | undefined {
return ctx.callID ?? ctx.callId ?? ctx.call_id
}
+99 -87
View File
@@ -7,15 +7,80 @@ import { normalizeModelFormat } from "../../shared/model-format-normalizer"
import { AGENT_MODEL_REQUIREMENTS } from "../../shared/model-requirements"
import { normalizeFallbackModels, flattenToFallbackModelStrings } from "../../shared/model-resolver"
import { buildFallbackChainFromModels, findMostSpecificFallbackEntry } from "../../shared/fallback-chain-from-models"
import { getAgentDisplayName, getAgentConfigKey } from "../../shared/agent-display-names"
import { getAgentDisplayName, getAgentConfigKey, stripAgentListSortPrefix } from "../../shared/agent-display-names"
import { normalizeSDKResponse } from "../../shared"
import { log } from "../../shared/logger"
import { getAvailableModelsForDelegateTask } from "./available-models"
import type { FallbackEntry } from "../../shared/model-requirements"
import { resolveModelForDelegateTask } from "./model-selection"
import { fuzzyMatchModel } from "../../shared/model-availability"
import type { CategoryConfig } from "../../config/schema"
import { loadUserAgents, loadProjectAgents } from "../../features/claude-code-agent-loader"
type AgentMode = "subagent" | "primary" | "all" | undefined
type AgentInfo = {
name: string
mode?: "subagent" | "primary" | "all"
model?: string | { providerID: string; modelID: string }
}
function applyCategoryParams(
base: DelegatedModelConfig,
config: CategoryConfig | undefined,
): DelegatedModelConfig {
if (!config) {
return base
}
return {
...base,
...(config.reasoningEffort !== undefined ? { reasoningEffort: config.reasoningEffort } : {}),
...(config.temperature !== undefined ? { temperature: config.temperature } : {}),
...(config.top_p !== undefined ? { top_p: config.top_p } : {}),
...(config.maxTokens !== undefined ? { maxTokens: config.maxTokens } : {}),
...(config.thinking !== undefined ? { thinking: config.thinking } : {}),
}
}
function mergeWithClaudeCodeAgents(
serverAgents: AgentInfo[],
directory: string | undefined,
): AgentInfo[] {
const userAgentsRecord = loadUserAgents()
const projectAgentsRecord = loadProjectAgents(directory)
const toAgentInfoList = (record: Record<string, { mode?: string; model?: AgentInfo["model"] }>): AgentInfo[] =>
Object.entries(record).map(([name, config]) => ({
name,
mode: config.mode as AgentInfo["mode"],
model: config.model,
}))
const projectAgentsList = toAgentInfoList(projectAgentsRecord)
const userAgentsList = toAgentInfoList(userAgentsRecord)
const mergedAgentMap = new Map<string, AgentInfo>()
const addIfAbsent = (agent: AgentInfo): void => {
const key = agent.name.toLowerCase()
if (!mergedAgentMap.has(key)) {
mergedAgentMap.set(key, agent)
}
}
for (const agent of serverAgents) {
addIfAbsent(agent)
}
for (const agent of projectAgentsList) {
addIfAbsent(agent)
}
for (const agent of userAgentsList) {
addIfAbsent(agent)
}
return Array.from(mergedAgentMap.values())
}
export async function resolveSubagentExecution(
args: DelegateTaskArgs,
executorCtx: ExecutorContext,
@@ -28,7 +93,9 @@ export async function resolveSubagentExecution(
return { agentToUse: "", categoryModel: undefined, error: `Agent name cannot be empty.` }
}
const agentName = args.subagent_type.trim()
// Strip wrapping characters (backslashes, quotes) that LLMs sometimes add around agent names
// e.g. \hephaestus\ -> hephaestus, "oracle" -> oracle, 'explore' -> explore
const agentName = args.subagent_type.trim().replace(/^[\\\/"']+|[\\\/"']+$/g, "").trim()
if (agentName.toLowerCase() === SISYPHUS_JUNIOR_AGENT.toLowerCase()) {
return {
@@ -54,82 +121,27 @@ Create the work plan directly - that's your job as the planning agent.`,
let categoryModel: DelegatedModelConfig | undefined
let fallbackChain: FallbackEntry[] | undefined = undefined
type AgentInfo = {
name: string
mode?: "subagent" | "primary" | "all"
model?: string | { providerID: string; modelID: string }
}
try {
const agentsResult = await client.app.agents()
const agents = normalizeSDKResponse(agentsResult, [] as AgentInfo[], {
preferResponseOnMissingData: true,
})
// Load user and project agents
const userAgentsRecord = loadUserAgents()
const projectAgentsRecord = loadProjectAgents(executorCtx.directory)
const mergedAgents = mergeWithClaudeCodeAgents(agents, executorCtx.directory)
const callableAgents = mergedAgents.filter((agent) => isTaskCallableAgentMode(agent.mode))
// Convert user/project agent configs to AgentInfo format
const userAgentsList: AgentInfo[] = Object.entries(userAgentsRecord).map(([name, config]) => ({
name,
mode: config.mode as "subagent" | "primary" | "all",
model: config.model,
}))
const projectAgentsList: AgentInfo[] = Object.entries(projectAgentsRecord).map(([name, config]) => ({
name,
mode: config.mode as "subagent" | "primary" | "all",
model: config.model,
}))
// Merge user and project agents into the server's agent list
// Server agents take precedence; project agents override user agents
const mergedAgentMap = new Map<string, AgentInfo>()
// First add server agents (they take precedence)
for (const agent of agents) {
mergedAgentMap.set(agent.name.toLowerCase(), agent)
}
// Then add project agents (overrides user agents, server wins on collision)
for (const agent of projectAgentsList) {
if (!mergedAgentMap.has(agent.name.toLowerCase())) {
mergedAgentMap.set(agent.name.toLowerCase(), agent)
}
}
// Then add user agents (only if not already added by server or project)
for (const agent of userAgentsList) {
if (!mergedAgentMap.has(agent.name.toLowerCase())) {
mergedAgentMap.set(agent.name.toLowerCase(), agent)
}
}
const mergedAgents = Array.from(mergedAgentMap.values())
const callableAgents = mergedAgents.filter((a) => a.mode !== "primary")
const resolvedDisplayName = getAgentDisplayName(agentToUse)
const resolvedDisplayName = stripAgentListSortPrefix(getAgentDisplayName(agentToUse))
const normalizedAgentToUse = stripAgentListSortPrefix(agentToUse)
const matchedAgent = callableAgents.find(
(agent) => agent.name.toLowerCase() === agentToUse.toLowerCase()
|| agent.name.toLowerCase() === resolvedDisplayName.toLowerCase()
(agent) => {
const normalizedListedAgentName = stripAgentListSortPrefix(agent.name)
return normalizedListedAgentName.toLowerCase() === normalizedAgentToUse.toLowerCase()
|| normalizedListedAgentName.toLowerCase() === resolvedDisplayName.toLowerCase()
}
)
if (!matchedAgent) {
const isPrimaryAgent = agents
.filter((a) => a.mode === "primary")
.find((agent) => agent.name.toLowerCase() === agentToUse.toLowerCase()
|| agent.name.toLowerCase() === resolvedDisplayName.toLowerCase())
if (isPrimaryAgent) {
return {
agentToUse: "",
categoryModel: undefined,
error: `Cannot call primary agent "${isPrimaryAgent.name}" via task. Primary agents are top-level orchestrators.`,
}
}
const availableAgents = callableAgents
.map((a) => a.name)
.map((a) => stripAgentListSortPrefix(a.name))
.sort()
.join(", ")
return {
@@ -139,18 +151,19 @@ Create the work plan directly - that's your job as the planning agent.`,
}
}
agentToUse = matchedAgent.name
agentToUse = stripAgentListSortPrefix(matchedAgent.name)
const agentConfigKey = getAgentConfigKey(agentToUse)
const agentOverride = agentOverrides?.[agentConfigKey as keyof typeof agentOverrides]
?? (agentOverrides ? Object.entries(agentOverrides).find(([key]) => key.toLowerCase() === agentConfigKey)?.[1] : undefined)
const agentRequirement = AGENT_MODEL_REQUIREMENTS[agentConfigKey]
const agentCategoryModel = agentOverride?.category
? userCategories?.[agentOverride.category]?.model
const agentCategoryConfig = agentOverride?.category
? userCategories?.[agentOverride.category]
: undefined
const agentCategoryModel = agentCategoryConfig?.model
const normalizedAgentFallbackModels = normalizeFallbackModels(
agentOverride?.fallback_models
?? (agentOverride?.category ? userCategories?.[agentOverride.category]?.fallback_models : undefined)
?? agentCategoryConfig?.fallback_models
)
const availableModels = await getAvailableModelsForDelegateTask(client)
@@ -178,19 +191,16 @@ Create the work plan directly - that's your job as the planning agent.`,
if (resolution && !resolutionSkipped) {
const normalized = normalizeModelFormat(resolution.model)
if (normalized) {
const variantToUse = agentOverride?.variant ?? resolution.variant
categoryModel = variantToUse ? { ...normalized, variant: variantToUse } : normalized
const variantToUse = agentOverride?.variant ?? resolution.variant ?? agentCategoryConfig?.variant
const resolvedModel = variantToUse ? { ...normalized, variant: variantToUse } : normalized
categoryModel = applyCategoryParams(resolvedModel, agentCategoryConfig)
}
} else if (resolutionSkipped && (agentOverride?.model ?? agentCategoryModel)) {
// Cold cache: resolution was skipped but user explicitly configured a model.
// Honor the user override directly — don't fall through to hardcoded fallback chain.
const normalized = normalizeModelFormat((agentOverride?.model ?? agentCategoryModel)!)
if (normalized) {
const agentCategoryVariant = agentOverride?.category
? userCategories?.[agentOverride.category]?.variant
: undefined
const variantToUse = agentOverride?.variant ?? agentCategoryVariant
categoryModel = variantToUse ? { ...normalized, variant: variantToUse } : normalized
const variantToUse = agentOverride?.variant ?? agentCategoryConfig?.variant
const resolvedModel = variantToUse ? { ...normalized, variant: variantToUse } : normalized
categoryModel = applyCategoryParams(resolvedModel, agentCategoryConfig)
log("[delegate-task] Cold cache: using explicit user override for subagent", {
agent: agentToUse,
model: agentOverride?.model ?? agentCategoryModel,
@@ -205,8 +215,6 @@ Create the work plan directly - that's your job as the planning agent.`,
normalizedAgentFallbackModels,
defaultProviderID,
)
// Don't assign hardcoded fallback chain when resolution was skipped (cold cache)
// — the chain may contain model IDs that don't exist in the provider yet.
fallbackChain = configuredFallbackChain ?? (resolutionSkipped ? undefined : agentRequirement?.fallbackChain)
// Only promote fallback-only settings when resolution actually selected a fallback model.
@@ -225,11 +233,11 @@ Create the work plan directly - that's your job as the planning agent.`,
categoryModel = {
...categoryModel,
variant: agentOverride?.variant ?? effectiveEntry.variant ?? categoryModel.variant,
reasoningEffort: effectiveEntry.reasoningEffort,
temperature: effectiveEntry.temperature,
top_p: effectiveEntry.top_p,
maxTokens: effectiveEntry.maxTokens,
thinking: effectiveEntry.thinking,
reasoningEffort: effectiveEntry.reasoningEffort ?? categoryModel.reasoningEffort,
temperature: effectiveEntry.temperature ?? categoryModel.temperature,
top_p: effectiveEntry.top_p ?? categoryModel.top_p,
maxTokens: effectiveEntry.maxTokens ?? categoryModel.maxTokens,
thinking: effectiveEntry.thinking ?? categoryModel.thinking,
}
}
}
@@ -265,3 +273,7 @@ Create the work plan directly - that's your job as the planning agent.`,
return { agentToUse, categoryModel, fallbackChain }
}
function isTaskCallableAgentMode(mode: AgentMode): boolean {
return mode === "all" || mode === "subagent"
}
@@ -605,8 +605,8 @@ describe("executeSyncContinuation - toast cleanup error paths", () => {
})
})
test("keeps plan-family task delegation available during sync continuation", async () => {
//#given - a resumed plan-family session should keep its intended task capability
test("keeps task delegation enabled during prometheus sync continuation", async () => {
//#given - a resumed prometheus session should keep plan-family task permission
const promptAsyncCalls: Array<{ path: { id: string }; body: Record<string, unknown> }> = []
const mockClient = {
session: {
@@ -656,7 +656,7 @@ describe("executeSyncContinuation - toast cleanup error paths", () => {
const args = {
session_id: "ses_test_12345678",
prompt: "continue planning",
description: "resume plan task",
description: "resume prometheus task",
load_skills: [],
run_in_background: false,
}
+4 -2
View File
@@ -2,6 +2,7 @@ import type { DelegateTaskArgs, ToolContextWithMetadata } from "./types"
import type { ExecutorContext, SessionMessage } from "./executor-types"
import { isPlanFamily } from "./constants"
import { storeToolMetadata } from "../../features/tool-metadata-store"
import { resolveCallID } from "./resolve-call-id"
import { getTaskToastManager } from "../../features/task-toast-manager"
import { getAgentToolRestrictions } from "../../shared/agent-tool-restrictions"
import { getMessageDir } from "../../shared"
@@ -78,8 +79,9 @@ export async function executeSyncContinuation(
},
}
await ctx.metadata?.(syncContMeta)
if (ctx.callID) {
storeToolMetadata(ctx.sessionID, ctx.callID, syncContMeta)
const callID = resolveCallID(ctx)
if (callID) {
storeToolMetadata(ctx.sessionID, callID, syncContMeta)
}
const allowTask = isPlanFamily(resumeAgent)
@@ -274,17 +274,65 @@ bunDescribe("sendSyncPrompt", () => {
modelID: "gpt-5.4",
})
bunExpect(promptArgs.body.variant).toBe("low")
bunExpect(promptArgs.body.options).toBeUndefined()
bunExpect(promptArgs.body.options).toEqual({
reasoningEffort: "high",
thinking: { type: "disabled" },
})
bunExpect(promptArgs.body.maxOutputTokens).toBe(4096)
bunExpect(getSessionPromptParams("test-session")).toEqual({
temperature: 0.4,
topP: 0.7,
maxOutputTokens: 4096,
options: {
reasoningEffort: "high",
thinking: { type: "disabled" },
maxTokens: 4096,
},
})
})
bunTest("forwards category temperature through the sync prompt body", async () => {
//#given
const { sendSyncPrompt } = require("./sync-prompt-sender")
let promptArgs: any
const promptWithModelSuggestionRetry = bunMock(async (_client: any, input: any) => {
promptArgs = input
})
const input = {
sessionID: "test-session",
agentToUse: "sisyphus-junior",
args: {
description: "test task",
prompt: "test prompt",
category: "quick",
run_in_background: false,
load_skills: [],
},
systemContent: undefined,
categoryModel: {
providerID: "openai",
modelID: "gpt-5.4",
temperature: 0.25,
},
toastManager: null,
taskId: undefined,
}
//#when
await sendSyncPrompt(
{ session: { promptAsync: bunMock(async () => ({ data: {} })) } },
input,
{
promptWithModelSuggestionRetry,
promptSyncWithModelSuggestionRetry: bunMock(async () => {}),
},
)
//#then
bunExpect(promptWithModelSuggestionRetry).toHaveBeenCalledTimes(1)
bunExpect(promptArgs.body.temperature).toBe(0.25)
})
bunTest("retries with promptSync for oracle when promptAsync fails with unexpected EOF", async () => {
//#given
const { sendSyncPrompt } = require("./sync-prompt-sender")
+20 -1
View File
@@ -22,6 +22,24 @@ const sendSyncPromptDeps: SendSyncPromptDeps = {
promptSyncWithModelSuggestionRetry,
}
function buildPromptGenerationParams(model: DelegatedModelConfig | undefined): Record<string, unknown> {
if (!model) {
return {}
}
const promptOptions: Record<string, unknown> = {
...(model.reasoningEffort ? { reasoningEffort: model.reasoningEffort } : {}),
...(model.thinking ? { thinking: model.thinking } : {}),
}
return {
...(model.temperature !== undefined ? { temperature: model.temperature } : {}),
...(model.top_p !== undefined ? { topP: model.top_p } : {}),
...(model.maxTokens !== undefined ? { maxOutputTokens: model.maxTokens } : {}),
...(Object.keys(promptOptions).length > 0 ? { options: promptOptions } : {}),
}
}
function isOracleAgent(agentToUse: string): boolean {
return agentToUse.toLowerCase() === "oracle"
}
@@ -62,7 +80,7 @@ export async function sendSyncPrompt(
const promptArgs = {
path: { id: input.sessionID },
body: {
agent: input.agentToUse,
agent: input.agentToUse.replace(/^\u200B+/, ""),
system: input.systemContent,
tools,
parts: [createInternalAgentTextPart(effectivePrompt)],
@@ -75,6 +93,7 @@ export async function sendSyncPrompt(
}
: {}),
...(input.categoryModel?.variant ? { variant: input.categoryModel.variant } : {}),
...buildPromptGenerationParams(input.categoryModel),
},
}
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
declare const require: (name: string) => any
const { describe, test, expect, beforeEach, afterEach } = require("bun:test")
import { __setTimingConfig, __resetTimingConfig } from "./timing"
function createMockCtx(aborted = false) {
@@ -33,7 +33,6 @@ describe("pollSyncSession", () => {
// and the assistant id > user id (native opencode condition)
const { pollSyncSession } = require("./sync-session-poller")
let pollCount = 0
const mockClient = {
session: {
messages: async () => ({
@@ -165,6 +164,58 @@ describe("pollSyncSession", () => {
expect(callCount).toBeGreaterThan(1)
})
test("keeps polling when finish is 'stop' but assistant still has tool-call parts", async () => {
//#given
const { pollSyncSession } = require("./sync-session-poller")
let callCount = 0
const mockClient = {
session: {
messages: async () => {
callCount++
if (callCount <= 1) {
return {
data: [
{ info: { id: "msg_001", role: "user", time: { created: 1000 } } },
{
info: { id: "msg_002", role: "assistant", time: { created: 2000 }, finish: "stop" },
parts: [{ type: "tool-call", text: "calling tool" }],
},
],
}
}
return {
data: [
{ info: { id: "msg_001", role: "user", time: { created: 1000 } } },
{
info: { id: "msg_002", role: "assistant", time: { created: 2000 }, finish: "stop" },
parts: [{ type: "tool-call", text: "calling tool" }],
},
{ info: { id: "msg_003", role: "user", time: { created: 3000 } } },
{
info: { id: "msg_004", role: "assistant", time: { created: 4000 }, finish: "stop" },
parts: [{ type: "text", text: "Done" }],
},
],
}
},
status: async () => ({ data: { "ses_test": { type: "idle" } } }),
},
}
//#when
const result = await pollSyncSession(createMockCtx(), mockClient, {
sessionID: "ses_test",
agentToUse: "test-agent",
toastManager: null,
taskId: undefined,
})
//#then
expect(result).toBeNull()
expect(callCount).toBeGreaterThan(1)
})
test("does not complete when assistant id < user id (user sent after assistant)", async () => {
//#given - assistant finished but user message came after it (agent still processing)
const { pollSyncSession } = require("./sync-session-poller")
@@ -220,6 +271,55 @@ describe("pollSyncSession", () => {
})
describe("abort handling", () => {
test("#given session completed AND abort fires #then returns completion result not abort", async () => {
//#given
const { pollSyncSession } = require("./sync-session-poller")
const controller = new AbortController()
controller.abort()
let abortCount = 0
let messageCallCount = 0
const mockClient = {
session: {
abort: async () => {
abortCount++
},
messages: async () => {
messageCallCount++
return {
data: [
{ info: { id: "msg_001", role: "user", time: { created: 1000 } } },
{
info: { id: "msg_002", role: "assistant", time: { created: 2000 }, finish: "stop" },
parts: [{ type: "text", text: "Done" }],
},
],
}
},
status: async () => ({ data: {} }),
},
}
//#when
const result = await pollSyncSession({
sessionID: "parent-session",
messageID: "parent-message",
agent: "test-agent",
abort: controller.signal,
}, mockClient, {
sessionID: "ses_abort_complete",
agentToUse: "test-agent",
toastManager: { removeTask: () => {} },
taskId: "task_123",
anchorMessageCount: 1,
})
//#then
expect(result).toBeNull()
expect(messageCallCount).toBe(1)
expect(abortCount).toBe(0)
})
test("returns abort message when signal is aborted", async () => {
//#given
const { pollSyncSession } = require("./sync-session-poller")
@@ -295,7 +395,7 @@ describe("pollSyncSession", () => {
//#given
const { pollSyncSession } = require("./sync-session-poller")
let statusCallCount = 0
let statusCallCount = 0
let messageCallCount = 0
const mockClient = {
session: {
@@ -421,6 +521,44 @@ describe("pollSyncSession", () => {
expect(result).toBe(false)
})
test("returns false when finish is stop but assistant has tool-call parts", () => {
const { isSessionComplete } = require("./sync-session-poller")
//#given - provider marks stop even though tool execution is still pending
const messages = [
{ info: { id: "msg_001", role: "user", time: { created: 1000 } } },
{
info: { id: "msg_002", role: "assistant", time: { created: 2000 }, finish: "stop" },
parts: [{ type: "tool-call", text: "calling tool" }],
},
]
//#when
const result = isSessionComplete(messages)
//#then - should return false because tool execution is still pending
expect(result).toBe(false)
})
test("returns false when finish is end_turn but assistant has tool-call parts", () => {
const { isSessionComplete } = require("./sync-session-poller")
//#given - assistant emitted a terminal finish but still contains pending tool calls
const messages = [
{ info: { id: "msg_001", role: "user", time: { created: 1000 } } },
{
info: { id: "msg_002", role: "assistant", time: { created: 2000 }, finish: "end_turn" },
parts: [{ type: "tool-call", text: "calling tool" }],
},
]
//#when
const result = isSessionComplete(messages)
//#then - should return false because tool execution is still pending
expect(result).toBe(false)
})
test("returns false when user message has missing info.id field", () => {
const { isSessionComplete } = require("./sync-session-poller")
@@ -438,7 +576,7 @@ describe("pollSyncSession", () => {
//#then - should return false (missing user id)
expect(result).toBe(false)
})
})
})
})
+32 -8
View File
@@ -5,6 +5,7 @@ import { log } from "../../shared/logger"
import { normalizeSDKResponse } from "../../shared"
const NON_TERMINAL_FINISH_REASONS = new Set(["tool-calls", "unknown"])
const PENDING_TOOL_PART_TYPES = new Set(["tool", "tool_use", "tool-call"])
function wait(milliseconds: number): Promise<void> {
const sharedBuffer = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT)
@@ -22,6 +23,15 @@ function abortSyncSession(client: OpencodeClient, sessionID: string, reason: str
})
}
async function fetchSessionMessages(
client: OpencodeClient,
sessionID: string
): Promise<SessionMessage[]> {
const messagesResult = await client.session.messages({ path: { id: sessionID } })
const rawData = (messagesResult as { data?: unknown })?.data ?? messagesResult
return Array.isArray(rawData) ? (rawData as SessionMessage[]) : []
}
export function isSessionComplete(messages: SessionMessage[]): boolean {
let lastUser: SessionMessage | undefined
let lastAssistant: SessionMessage | undefined
@@ -35,6 +45,7 @@ export function isSessionComplete(messages: SessionMessage[]): boolean {
if (!lastAssistant?.info?.finish) return false
if (NON_TERMINAL_FINISH_REASONS.has(lastAssistant.info.finish)) return false
if (lastAssistant.parts?.some((part) => part.type && PENDING_TOOL_PART_TYPES.has(part.type))) return false
if (!lastUser?.info?.id || !lastAssistant?.info?.id) return false
return lastUser.info.id < lastAssistant.info.id
}
@@ -67,6 +78,21 @@ export async function pollSyncSession(
while (Date.now() - pollStart < maxPollTimeMs) {
if (ctx.abort?.aborted) {
try {
const messages = await fetchSessionMessages(client, input.sessionID)
const hasNewMessages =
input.anchorMessageCount === undefined || messages.length > input.anchorMessageCount
if (hasNewMessages && isSessionComplete(messages)) {
log("[task] Abort detected after session already completed", { sessionID: input.sessionID })
return null
}
} catch (error) {
log("[task] Final messages fetch failed after abort, continuing with abort", {
sessionID: input.sessionID,
error: String(error),
})
}
log("[task] Aborted by user", { sessionID: input.sessionID })
abortSyncSession(client, input.sessionID, "parent_abort")
if (input.toastManager && input.taskId) input.toastManager.removeTask(input.taskId)
@@ -99,27 +125,25 @@ export async function pollSyncSession(
continue
}
let messagesResult: { data?: unknown } | SessionMessage[]
let messages: SessionMessage[]
try {
messagesResult = await client.session.messages({ path: { id: input.sessionID } })
messages = await fetchSessionMessages(client, input.sessionID)
} catch (error) {
log("[task] Poll messages fetch failed, retrying", { sessionID: input.sessionID, error: String(error) })
continue
}
const rawData = (messagesResult as { data?: unknown })?.data ?? messagesResult
const msgs = Array.isArray(rawData) ? (rawData as SessionMessage[]) : []
if (input.anchorMessageCount !== undefined && msgs.length <= input.anchorMessageCount) {
if (input.anchorMessageCount !== undefined && messages.length <= input.anchorMessageCount) {
continue
}
if (isSessionComplete(msgs)) {
if (isSessionComplete(messages)) {
log("[task] Poll complete - terminal finish detected", { sessionID: input.sessionID, pollCount })
break
}
// 计数新出现的 assistant 轮次,用于熔断无限循环
const lastAssistant = [...msgs].reverse().find((m) => m.info?.role === "assistant")
const lastAssistant = [...messages].reverse().find((m) => m.info?.role === "assistant")
if (lastAssistant?.info?.id && lastAssistant.info.id !== lastSeenAssistantId) {
lastSeenAssistantId = lastAssistant.info.id
assistantTurnCount++
@@ -135,7 +159,7 @@ export async function pollSyncSession(
}
}
const hasAssistantText = msgs.some((m) => {
const hasAssistantText = messages.some((m) => {
if (m.info?.role !== "assistant") return false
const parts = m.parts ?? []
return parts.some((p) => {
@@ -0,0 +1,68 @@
import type { FallbackEntry } from "../../shared/model-requirements"
import type { DelegatedModelConfig } from "./types"
import type { ModelFallbackState } from "../../hooks/model-fallback/hook"
import { getNextReachableFallback } from "../../hooks/model-fallback/next-fallback"
function toDelegatedModelConfig(fallback: NonNullable<ReturnType<typeof getNextReachableFallback>>): DelegatedModelConfig {
return {
providerID: fallback.providerID,
modelID: fallback.modelID,
variant: fallback.variant,
reasoningEffort: fallback.reasoningEffort,
temperature: fallback.temperature,
top_p: fallback.top_p,
maxTokens: fallback.maxTokens,
thinking: fallback.thinking,
}
}
export async function retrySyncPromptWithFallbacks(input: {
sessionID: string
initialError: string
categoryModel: DelegatedModelConfig | undefined
fallbackChain: FallbackEntry[] | undefined
sendPrompt: (categoryModel: DelegatedModelConfig) => Promise<string | null>
}): Promise<{ promptError: string | null; categoryModel: DelegatedModelConfig | undefined }> {
const { sessionID, initialError, categoryModel, fallbackChain, sendPrompt } = input
if (!categoryModel || !fallbackChain || fallbackChain.length === 0) {
return {
promptError: initialError,
categoryModel,
}
}
const fallbackState: ModelFallbackState = {
providerID: categoryModel.providerID,
modelID: categoryModel.modelID,
fallbackChain,
attemptCount: 0,
pending: true,
}
let finalError = initialError
while (true) {
const nextFallback = getNextReachableFallback(sessionID, fallbackState)
if (!nextFallback) {
return {
promptError: finalError,
categoryModel,
}
}
const fallbackModel = toDelegatedModelConfig(nextFallback)
const promptError = await sendPrompt(fallbackModel)
if (!promptError) {
return {
promptError: null,
categoryModel: fallbackModel,
}
}
finalError = promptError
fallbackState.providerID = fallbackModel.providerID
fallbackState.modelID = fallbackModel.modelID
fallbackState.pending = true
}
}
+276
View File
@@ -1,5 +1,12 @@
const { describe, test, expect, beforeEach, afterEach, mock, spyOn } = require("bun:test")
function clearRequireCache(modulePath: string): void {
const resolvedPath = require.resolve(modulePath)
if (require.cache?.[resolvedPath]) {
delete require.cache[resolvedPath]
}
}
describe("executeSyncTask - cleanup on error paths", () => {
let removeTaskCalls: string[] = []
let addTaskCalls: any[] = []
@@ -23,6 +30,8 @@ describe("executeSyncTask - cleanup on error paths", () => {
deleteCalls = []
addCalls = []
clearRequireCache("./sync-task")
//#given - initialize real task toast manager (avoid global module mocks)
const { initTaskToastManager, _resetTaskToastManagerForTesting } = require("../../features/task-toast-manager/manager")
_resetTaskToastManagerForTesting()
@@ -219,6 +228,140 @@ describe("executeSyncTask - cleanup on error paths", () => {
expect(deleteCalls[0]).toBe("ses_test_12345678")
})
test("#given fallback chain set #when sendSyncPrompt fails #then retries with next model", async () => {
//#given
const mockClient = {
session: {
create: async () => ({ data: { id: "ses_test_12345678" } }),
},
}
const { executeSyncTask } = require("./sync-task")
const attemptedModels: Array<{ providerID: string; modelID: string; variant?: string } | undefined> = []
const deps = {
createSyncSession: async () => ({ ok: true, sessionID: "ses_test_12345678" }),
sendSyncPrompt: async (_client: unknown, input: { categoryModel?: { providerID: string; modelID: string; variant?: string } }) => {
attemptedModels.push(input.categoryModel)
return attemptedModels.length === 1 ? "Initial failure" : null
},
pollSyncSession: async () => null,
fetchSyncResult: async () => ({ ok: true as const, textContent: "Result" }),
}
const mockCtx = {
sessionID: "parent-session",
callID: "call-123",
metadata: () => {},
}
const mockExecutorCtx = {
client: mockClient,
directory: "/tmp",
onSyncSessionCreated: null,
}
const args = {
prompt: "test prompt",
description: "test task",
category: "test",
load_skills: [],
run_in_background: false,
command: null,
}
const initialModel = {
providerID: "anthropic",
modelID: "claude-opus-4-6",
variant: "max",
}
const fallbackChain = [
{ providers: ["anthropic"], model: "claude-opus-4-6", variant: "max" },
{ providers: ["opencode-go"], model: "kimi-k2.5" },
]
//#when
const result = await executeSyncTask(args, mockCtx, mockExecutorCtx, {
sessionID: "parent-session",
}, "test-agent", initialModel, undefined, undefined, fallbackChain, deps)
//#then
expect(result).toContain("Task completed")
expect(result).toContain("Model: opencode-go/kimi-k2.5")
expect(attemptedModels).toEqual([
{ providerID: "anthropic", modelID: "claude-opus-4-6", variant: "max" },
{ providerID: "opencode-go", modelID: "kimi-k2.5", variant: undefined },
])
})
test("#given fallback chain exhausted #when all retries fail #then returns final error", async () => {
//#given
const mockClient = {
session: {
create: async () => ({ data: { id: "ses_test_12345678" } }),
},
}
const { executeSyncTask } = require("./sync-task")
const attemptedModels: Array<{ providerID: string; modelID: string; variant?: string } | undefined> = []
const promptErrors = ["Initial failure", "Second failure", "Final failure"]
const deps = {
createSyncSession: async () => ({ ok: true, sessionID: "ses_test_12345678" }),
sendSyncPrompt: async (_client: unknown, input: { categoryModel?: { providerID: string; modelID: string; variant?: string } }) => {
attemptedModels.push(input.categoryModel)
return promptErrors[attemptedModels.length - 1] ?? "Unexpected extra retry"
},
pollSyncSession: async () => null,
fetchSyncResult: async () => ({ ok: true as const, textContent: "Result" }),
}
const mockCtx = {
sessionID: "parent-session",
callID: "call-123",
metadata: () => {},
}
const mockExecutorCtx = {
client: mockClient,
directory: "/tmp",
onSyncSessionCreated: null,
}
const args = {
prompt: "test prompt",
description: "test task",
category: "test",
load_skills: [],
run_in_background: false,
command: null,
}
const initialModel = {
providerID: "anthropic",
modelID: "claude-opus-4-6",
variant: "max",
}
const fallbackChain = [
{ providers: ["anthropic"], model: "claude-opus-4-6", variant: "max" },
{ providers: ["opencode-go"], model: "kimi-k2.5" },
{ providers: ["openai"], model: "gpt-5.4", variant: "medium" },
]
//#when
const result = await executeSyncTask(args, mockCtx, mockExecutorCtx, {
sessionID: "parent-session",
}, "test-agent", initialModel, undefined, undefined, fallbackChain, deps)
//#then
expect(result).toBe("Final failure")
expect(attemptedModels).toEqual([
{ providerID: "anthropic", modelID: "claude-opus-4-6", variant: "max" },
{ providerID: "opencode-go", modelID: "kimi-k2.5", variant: undefined },
{ providerID: "openai", modelID: "gpt-5.4", variant: "medium" },
])
})
test("cleans up toast and subagentSessions on successful completion", async () => {
const mockClient = {
session: {
@@ -282,6 +425,139 @@ describe("executeSyncTask - cleanup on error paths", () => {
expect(deleteCalls.length).toBe(1)
expect(deleteCalls[0]).toBe("ses_test_12345678")
})
test("depth regression: blocks spawn when reserveSubagentSpawn throws depth limit error", async () => {
// This is a smoke test guarding against regressions where the depth limit
// would be silently bypassed (e.g. via a fallback path that hardcodes
// childDepth: 1).
const mockClient = {
session: {
create: async () => ({ data: { id: "ses_test_12345678" } }),
},
}
const { executeSyncTask } = require("./sync-task")
const reserveSubagentSpawn = mock(async () => {
throw new Error(
"Subagent spawn blocked: child depth 4 exceeds background_task.maxDepth=3. Parent session: parent. Root session: root. Continue in an existing subagent session instead of spawning another."
)
})
const deps = {
createSyncSession: async () => ({ ok: true, sessionID: "ses_test_12345678" }),
sendSyncPrompt: async () => null,
pollSyncSession: async () => null,
fetchSyncResult: async () => ({ ok: true as const, textContent: "Result" }),
}
const mockCtx = {
sessionID: "parent-session",
callID: "call-123",
metadata: () => {},
}
const mockExecutorCtx = {
manager: { reserveSubagentSpawn },
client: mockClient,
directory: "/tmp",
onSyncSessionCreated: null,
}
const args = {
prompt: "test prompt",
description: "test task",
category: "test",
load_skills: [],
run_in_background: false,
command: null,
}
//#when - executeSyncTask is called from a session at max depth
const result = await executeSyncTask(args, mockCtx, mockExecutorCtx, {
sessionID: "parent-session",
}, "test-agent", undefined, undefined, undefined, undefined, deps)
//#then - should propagate the depth limit error and NOT create the session
expect(result).toContain("Subagent spawn blocked")
expect(result).toContain("child depth 4")
expect(result).toContain("maxDepth=3")
expect(reserveSubagentSpawn).toHaveBeenCalledWith("parent-session")
// critical: createSyncSession must NOT have been called -- if it was,
// the depth guard was bypassed.
expect(addCalls.length).toBe(0)
})
test("depth regression: does not silently fall back to childDepth: 1 when manager methods are present", async () => {
// Guards against the dangerous fallback path in sync-task.ts that
// hardcodes childDepth: 1 if reserveSubagentSpawn / assertCanSpawn are
// not functions. With a real manager present, the fallback must NOT be
// taken.
const mockClient = {
session: {
create: async () => ({ data: { id: "ses_test_12345678" } }),
},
}
const { executeSyncTask } = require("./sync-task")
let reservedDepth: number | undefined
const commit = mock(() => 1)
const rollback = mock(() => {})
const reserveSubagentSpawn = mock(async () => {
// Return a depth that proves the real manager was consulted
reservedDepth = 3
return {
spawnContext: { rootSessionID: "root", parentDepth: 2, childDepth: 3 },
descendantCount: 5,
commit,
rollback,
}
})
const deps = {
createSyncSession: async () => ({ ok: true, sessionID: "ses_test_12345678" }),
sendSyncPrompt: async () => null,
pollSyncSession: async () => null,
fetchSyncResult: async () => ({ ok: true as const, textContent: "Result" }),
}
const metadataCalls: any[] = []
const mockCtx = {
sessionID: "parent-session",
callID: "call-123",
metadata: (input: any) => { metadataCalls.push(input) },
}
const mockExecutorCtx = {
manager: { reserveSubagentSpawn },
client: mockClient,
directory: "/tmp",
onSyncSessionCreated: null,
}
const args = {
prompt: "test prompt",
description: "test task",
category: "test",
load_skills: [],
run_in_background: false,
command: null,
}
//#when
await executeSyncTask(args, mockCtx, mockExecutorCtx, {
sessionID: "parent-session",
}, "test-agent", undefined, undefined, undefined, undefined, deps)
//#then - the spawnDepth recorded in metadata MUST match what reserveSubagentSpawn returned
expect(reservedDepth).toBe(3)
const taskMeta = metadataCalls.find((c) => c.metadata?.spawnDepth !== undefined)
expect(taskMeta).toBeDefined()
expect(taskMeta.metadata.spawnDepth).toBe(3) // NOT 1 (the fallback value)
})
})
export {}
+58 -15
View File
@@ -3,6 +3,7 @@ import type { DelegateTaskArgs, ToolContextWithMetadata, DelegatedModelConfig }
import type { ExecutorContext, ParentContext } from "./executor-types"
import { getTaskToastManager } from "../../features/task-toast-manager"
import { storeToolMetadata } from "../../features/tool-metadata-store"
import { resolveCallID } from "./resolve-call-id"
import { subagentSessions, syncSubagentSessions, setSessionAgent } from "../../features/claude-code-session-state"
import { log } from "../../shared/logger"
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
@@ -10,6 +11,7 @@ import { formatDuration } from "./time-formatter"
import { formatDetailedError } from "./error-formatting"
import { syncTaskDeps, type SyncTaskDeps } from "./sync-task-deps"
import { setSessionFallbackChain, clearSessionFallbackChain } from "../../hooks/model-fallback/hook"
import { retrySyncPromptWithFallbacks } from "./sync-task-fallback"
export async function executeSyncTask(
args: DelegateTaskArgs,
@@ -36,14 +38,29 @@ export async function executeSyncTask(
spawnReservation = await manager.reserveSubagentSpawn(parentContext.sessionID)
}
const spawnContext = spawnReservation?.spawnContext
?? (typeof manager?.assertCanSpawn === "function"
? await manager.assertCanSpawn(parentContext.sessionID)
: {
rootSessionID: parentContext.sessionID,
parentDepth: 0,
childDepth: 1,
})
// Depth/descendant guard. We must NOT silently fall back to childDepth: 1
// when the manager is unavailable or lacks the spawn methods, because that
// would let subagents recurse without bound. The only safe fallback is
// when the manager genuinely cannot enforce limits (legacy SDK), in which
// case we still record childDepth: 1 but log a warning so regressions are
// visible.
let spawnContext: { rootSessionID: string; parentDepth: number; childDepth: number }
if (spawnReservation?.spawnContext) {
spawnContext = spawnReservation.spawnContext
} else if (typeof manager?.assertCanSpawn === "function") {
spawnContext = await manager.assertCanSpawn(parentContext.sessionID)
} else {
log(
"[task] WARNING: BackgroundManager has no spawn enforcement methods (reserveSubagentSpawn / assertCanSpawn). " +
"Depth and descendant limits cannot be enforced for this task. This indicates an old SDK or a misconfiguration.",
{ parentSessionID: parentContext.sessionID }
)
spawnContext = {
rootSessionID: parentContext.sessionID,
parentDepth: 0,
childDepth: 1,
}
}
const createSessionResult = await deps.createSyncSession(client, {
parentSessionID: parentContext.sessionID,
@@ -114,22 +131,48 @@ export async function executeSyncTask(
},
}
await ctx.metadata?.(syncTaskMeta)
if (ctx.callID) {
storeToolMetadata(ctx.sessionID, ctx.callID, syncTaskMeta)
const callID = resolveCallID(ctx)
if (callID) {
storeToolMetadata(ctx.sessionID, callID, syncTaskMeta)
}
const promptError = await deps.sendSyncPrompt(client, {
let effectiveCategoryModel = categoryModel
let promptError = await deps.sendSyncPrompt(client, {
sessionID,
agentToUse,
args,
systemContent,
categoryModel,
categoryModel: effectiveCategoryModel,
toastManager,
taskId,
sisyphusAgentConfig: executorCtx.sisyphusAgentConfig,
})
if (promptError) {
return promptError
const promptResult = await retrySyncPromptWithFallbacks({
sessionID,
initialError: promptError,
categoryModel: effectiveCategoryModel,
fallbackChain,
sendPrompt: async (fallbackModel) => {
return deps.sendSyncPrompt(client, {
sessionID,
agentToUse,
args,
systemContent,
categoryModel: fallbackModel,
toastManager,
taskId,
sisyphusAgentConfig: executorCtx.sisyphusAgentConfig,
})
},
})
promptError = promptResult.promptError
effectiveCategoryModel = promptResult.categoryModel
if (promptError) {
return promptError
}
}
try {
@@ -151,8 +194,8 @@ export async function executeSyncTask(
const duration = formatDuration(startTime)
// 检测模型路由是否与父 session 不同,给用户可见的提示
const actualModelStr = categoryModel
? `${categoryModel.providerID}/${categoryModel.modelID}`
const actualModelStr = effectiveCategoryModel
? `${effectiveCategoryModel.providerID}/${effectiveCategoryModel.modelID}`
: undefined
const parentModelStr = parentContext.model
? `${parentContext.model.providerID}/${parentContext.model.modelID}`
@@ -0,0 +1,34 @@
const { describe, expect, test } = require("bun:test")
function requireFresh<T>(modulePath: string): T {
const resolvedPath = require.resolve(modulePath)
if (require.cache?.[resolvedPath]) {
delete require.cache[resolvedPath]
}
return require(modulePath) as T
}
function createDelegateTask(...args: Parameters<typeof import("./tools").createDelegateTask>): ReturnType<typeof import("./tools").createDelegateTask> {
return requireFresh<typeof import("./tools")>("./tools").createDelegateTask(...args)
}
describe("createDelegateTask schema", () => {
test("#given category arg #when tool is created #then category accepts any string", () => {
//#given
const toolDefinition = createDelegateTask({ manager: {} as never, client: {} as never, directory: "/tmp/test" })
//#when
const categorySchema = toolDefinition.args.category as unknown as {
def: {
type: string
innerType: {
def: { type: string }
}
}
}
//#then
expect(categorySchema.def.type).toBe("optional")
expect(categorySchema.def.innerType.def.type).toBe("string")
})
})
+314 -42
View File
@@ -1,7 +1,7 @@
declare const require: (name: string) => any
declare const require: NodeJS.Require
const { describe, test, expect, beforeEach, afterEach, spyOn, mock } = require("bun:test")
import { DEFAULT_CATEGORIES, CATEGORY_PROMPT_APPENDS, CATEGORY_DESCRIPTIONS, isPlanAgent, PLAN_AGENT_NAMES, isPlanFamily, PLAN_FAMILY_NAMES } from "./constants"
import { resolveCategoryConfig } from "./tools"
import { getAgentDisplayName, getAgentListDisplayName } from "../../shared/agent-display-names"
import type { CategoryConfig } from "../../config/schema"
import type { DelegateTaskArgs } from "./types"
import { __resetModelCache } from "../../shared/model-availability"
@@ -10,6 +10,20 @@ import { __setTimingConfig, __resetTimingConfig } from "./timing"
import * as connectedProvidersCache from "../../shared/connected-providers-cache"
import * as executor from "./executor"
const runtimeRequire = require as NodeJS.Require & { cache?: Record<string, unknown> }
function clearRequireCache(modulePath: string): void {
const resolvedPath = runtimeRequire.resolve(modulePath)
if (runtimeRequire.cache?.[resolvedPath]) {
delete runtimeRequire.cache[resolvedPath]
}
}
function resolveCategoryConfig(...args: Parameters<typeof import("./tools").resolveCategoryConfig>): ReturnType<typeof import("./tools").resolveCategoryConfig> {
clearRequireCache("./tools")
return require("./tools").resolveCategoryConfig(...args)
}
const SYSTEM_DEFAULT_MODEL = "anthropic/claude-sonnet-4-6"
const TEST_CONNECTED_PROVIDERS = ["anthropic", "google", "openai"]
@@ -37,6 +51,7 @@ describe("sisyphus-task", () => {
beforeEach(() => {
mock.restore()
clearRequireCache("./tools")
__resetModelCache()
clearSkillCache()
__setTimingConfig({
@@ -93,7 +108,7 @@ describe("sisyphus-task", () => {
// when / #then
expect(category).toBeDefined()
expect(category.model).toBe("openai/gpt-5.3-codex")
expect(category.model).toBe("openai/gpt-5.4")
expect(category.variant).toBe("medium")
})
@@ -180,8 +195,8 @@ describe("sisyphus-task", () => {
//#given / #when
const result = isPlanAgent("planner")
//#then - "planner" contains "plan" so it matches via includes
expect(result).toBe(true)
//#then - "planner" is NOT an exact match for "plan" (T37 exact match fix)
expect(result).toBe(false)
})
test("returns true for case-insensitive match 'PLAN'", () => {
@@ -253,6 +268,20 @@ describe("sisyphus-task", () => {
expect(result).toBe(true)
})
test("returns true for prometheus display name", () => {
//#given / #when
const result = isPlanFamily(getAgentDisplayName("prometheus"))
//#then
expect(result).toBe(true)
})
test("returns true for prometheus list display name with zwsp prefix", () => {
//#given / #when
const result = isPlanFamily(getAgentListDisplayName("prometheus"))
//#then
expect(result).toBe(true)
})
test("returns false for 'oracle'", () => {
//#given / #when
const result = isPlanFamily("oracle")
@@ -705,8 +734,8 @@ describe("sisyphus-task", () => {
})
test("blocks requiresModel when availability is known and missing the required model", () => {
// given
const categoryName = "deep"
// given - artistry has requiresModel: gemini-3.1-pro
const categoryName = "artistry"
const availableModels = new Set<string>(["anthropic/claude-opus-4-6"])
// when
@@ -720,8 +749,8 @@ describe("sisyphus-task", () => {
})
test("blocks requiresModel when availability is empty", () => {
// given
const categoryName = "deep"
// given - artistry has requiresModel: gemini-3.1-pro
const categoryName = "artistry"
const availableModels = new Set<string>()
// when
@@ -1366,6 +1395,134 @@ describe("sisyphus-task", () => {
)).rejects.toThrow("Invalid arguments: 'run_in_background' parameter is REQUIRED")
})
test("#given category without description #when executing #then auto-generates description from prompt", async () => {
// given
const { createDelegateTask } = require("./tools")
let capturedTitle: string | undefined
const mockManager = { launch: async () => ({}) }
const mockClient = {
app: { agents: async () => ({ data: [] }) },
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
session: {
create: async () => ({ data: { id: "test-session" } }),
prompt: async () => ({ data: {} }),
promptAsync: async () => ({ data: {} }),
messages: async () => ({ data: [] }),
},
}
const tool = createDelegateTask({ manager: mockManager, client: mockClient })
// when
try {
await tool.execute(
{
prompt: "Fix the broken unit tests in parser module",
category: "quick",
run_in_background: false,
load_skills: [],
},
{
sessionID: "parent-session",
messageID: "parent-message",
agent: "sisyphus",
abort: new AbortController().signal,
metadata: async (meta: { title?: string }) => { capturedTitle = meta.title },
}
)
} catch {
// execution may fail due to incomplete mocks — we only care about the title
}
// then — description auto-generated from first 4 words of prompt
expect(capturedTitle).toBe("Fix the broken unit")
})
test("#given empty description #when executing #then auto-generates description from prompt", async () => {
// given
const { createDelegateTask } = require("./tools")
let capturedTitle: string | undefined
const mockManager = { launch: async () => ({}) }
const mockClient = {
app: { agents: async () => ({ data: [] }) },
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
session: {
create: async () => ({ data: { id: "test-session" } }),
prompt: async () => ({ data: {} }),
promptAsync: async () => ({ data: {} }),
messages: async () => ({ data: [] }),
},
}
const tool = createDelegateTask({ manager: mockManager, client: mockClient })
// when
try {
await tool.execute(
{
description: " ",
prompt: "Refactor authentication module completely",
category: "quick",
run_in_background: false,
load_skills: [],
},
{
sessionID: "parent-session",
messageID: "parent-message",
agent: "sisyphus",
abort: new AbortController().signal,
metadata: async (meta: { title?: string }) => { capturedTitle = meta.title },
}
)
} catch {
// execution may fail due to incomplete mocks
}
// then
expect(capturedTitle).toBe("Refactor authentication module completely")
})
test("#given explicit description #when executing #then preserves provided description", async () => {
// given
const { createDelegateTask } = require("./tools")
let capturedTitle: string | undefined
const mockManager = { launch: async () => ({}) }
const mockClient = {
app: { agents: async () => ({ data: [] }) },
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
session: {
create: async () => ({ data: { id: "test-session" } }),
prompt: async () => ({ data: {} }),
promptAsync: async () => ({ data: {} }),
messages: async () => ({ data: [] }),
},
}
const tool = createDelegateTask({ manager: mockManager, client: mockClient })
// when
try {
await tool.execute(
{
description: "My custom task name",
prompt: "Do something else entirely",
category: "quick",
run_in_background: false,
load_skills: [],
},
{
sessionID: "parent-session",
messageID: "parent-message",
agent: "sisyphus",
abort: new AbortController().signal,
metadata: async (meta: { title?: string }) => { capturedTitle = meta.title },
}
)
} catch {
// execution may fail due to incomplete mocks
}
// then — explicit description preserved
expect(capturedTitle).toBe("My custom task name")
})
test("#given explicit run_in_background=false #when executing #then sync execution succeeds", async () => {
// given
const { createDelegateTask } = require("./tools")
@@ -1453,6 +1610,92 @@ describe("sisyphus-task", () => {
expect(launchCalled).toBe(true)
expect(result).toContain("Background task launched")
}, { timeout: 10000 })
test("#given concurrent background launches from the same parent #when one parent call aborts during session wait #then sibling launch is not interrupted", async () => {
// given
const { createDelegateTask } = require("./tools")
const firstAbortController = new AbortController()
const secondAbortController = new AbortController()
const taskStates = new Map([
["bg_tool_first", { reads: 0, abortOnFirstRead: true, sessionID: "ses_tool_first" }],
["bg_tool_second", { reads: 0, abortOnFirstRead: false, sessionID: "ses_tool_second" }],
])
let launchCount = 0
const mockManager = {
launch: async () => {
launchCount += 1
return launchCount === 1
? {
id: "bg_tool_first",
sessionID: undefined,
description: "Tool first",
agent: "Sisyphus-Junior",
status: "running",
}
: {
id: "bg_tool_second",
sessionID: undefined,
description: "Tool second",
agent: "Sisyphus-Junior",
status: "running",
}
},
getTask: (taskID: string) => {
const state = taskStates.get(taskID)
if (!state) return undefined
state.reads += 1
if (state.abortOnFirstRead && state.reads === 1) {
firstAbortController.abort()
}
return state.reads >= 2
? { sessionID: state.sessionID, status: "running" }
: { sessionID: undefined, status: "pending" }
},
}
const mockClient = {
app: { agents: async () => ({ data: [] }) },
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
model: { list: async () => [] },
session: {
create: async () => ({ data: { id: "ses_bg_explicit_true" } }),
prompt: async () => ({ data: {} }),
promptAsync: async () => ({ data: {} }),
messages: async () => ({ data: [] }),
},
}
const tool = createDelegateTask({ manager: mockManager, client: mockClient })
// when
const [firstResult, secondResult] = await Promise.all([
tool.execute(
{
description: "Tool first",
prompt: "Run background",
category: "quick",
run_in_background: true,
load_skills: [],
},
{ sessionID: "parent-session", messageID: "parent-message-1", agent: "sisyphus", abort: firstAbortController.signal }
),
tool.execute(
{
description: "Tool second",
prompt: "Run background",
category: "quick",
run_in_background: true,
load_skills: [],
},
{ sessionID: "parent-session", messageID: "parent-message-2", agent: "sisyphus", abort: secondAbortController.signal }
),
])
// then
expect(firstResult).toContain("Background task launched")
expect(firstResult).not.toContain("Task failed to start")
expect(secondResult).toContain("Background task launched")
expect(secondResult).toContain("session_id: ses_tool_second")
expect(secondResult).not.toContain("interrupt")
}, { timeout: 10000 })
})
describe("session_id with background parameter", () => {
@@ -2282,60 +2525,68 @@ describe("sisyphus-task", () => {
expect(result).toContain("Artistry result here")
}, { timeout: 20000 })
test("writing category (kimi) with run_in_background=false should force background but wait for result", async () => {
// given - writing uses kimi-for-coding/k2p5
test("writing category (kimi) with run_in_background=false should run sync when kimi provider is available", async () => {
// given - writing uses kimi model which is no longer considered unstable
// Override provider cache to include kimi-for-coding provider
providerModelsSpy.mockReturnValue({
models: {
anthropic: ["claude-opus-4-6", "claude-sonnet-4-6", "claude-haiku-4-5"],
google: ["gemini-3.1-pro", "gemini-3-flash"],
openai: ["gpt-5.4", "gpt-5.3-codex"],
"kimi-for-coding": ["k2p5"],
},
connected: ["anthropic", "google", "openai", "kimi-for-coding"],
updatedAt: "2026-01-01T00:00:00.000Z",
})
cacheSpy.mockReturnValue(["anthropic", "google", "openai", "kimi-for-coding"])
const { createDelegateTask } = require("./tools")
let launchCalled = false
const launchedTask = {
id: "task-writing",
sessionID: "ses_writing_gemini",
description: "Writing gemini task",
agent: "sisyphus-junior",
status: "running",
}
let promptCalled = false
const mockManager = {
launch: async () => {
launchCalled = true
return launchedTask
return { id: "should-not-be-called", sessionID: "x", description: "x", agent: "x", status: "running" }
},
getTask: () => launchedTask,
}
const promptMock = async () => {
promptCalled = true
return { data: {} }
}
const mockClient = {
app: { agents: async () => ({ data: [] }) },
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
model: { list: async () => [{ provider: "google", id: "gemini-3-flash" }] },
session: {
get: async () => ({ data: { directory: "/project" } }),
create: async () => ({ data: { id: "ses_writing_gemini" } }),
prompt: async () => ({ data: {} }),
promptAsync: async () => ({ data: {} }),
create: async () => ({ data: { id: "ses_writing_kimi" } }),
prompt: promptMock,
promptAsync: promptMock,
messages: async () => ({
data: [
{ info: { role: "assistant", time: { created: Date.now() } }, parts: [{ type: "text", text: "Writing result here" }] }
]
data: [{ info: { role: "assistant" }, parts: [{ type: "text", text: "Writing result here" }] }]
}),
status: async () => ({ data: { "ses_writing_gemini": { type: "idle" } } }),
status: async () => ({ data: { "ses_writing_kimi": { type: "idle" } } }),
},
}
const tool = createDelegateTask({
manager: mockManager,
client: mockClient,
})
const toolContext = {
sessionID: "parent-session",
messageID: "parent-message",
agent: "sisyphus",
abort: new AbortController().signal,
}
// when - writing category (gemini-3-flash)
// when - writing category (kimi) with run_in_background=false
const result = await tool.execute(
{
description: "Test writing forced background",
description: "Test writing sync",
prompt: "Write something",
category: "writing",
run_in_background: false,
@@ -2343,11 +2594,11 @@ describe("sisyphus-task", () => {
},
toolContext
)
// then - should launch as background BUT wait for and return actual result
expect(launchCalled).toBe(true)
expect(result).toContain("SUPERVISED TASK COMPLETED")
expect(result).toContain("Writing result here")
// then - should run sync, NOT forced to background (kimi is not unstable)
expect(launchCalled).toBe(false)
expect(promptCalled).toBe(true)
expect(result).not.toContain("SUPERVISED TASK COMPLETED")
}, { timeout: 20000 })
test("is_unstable_agent=true should force background but wait for result", async () => {
@@ -2741,6 +2992,7 @@ describe("sisyphus-task", () => {
// then - sisyphus-junior override model should be used, not category default
expect(launchInput.model.providerID).toBe("anthropic")
expect(launchInput.model.modelID).toBe("claude-sonnet-4-6")
expect(launchInput.fallbackChain).toBeUndefined()
})
test("sisyphus-junior model override works with user-defined category (#1295)", async () => {
@@ -3385,6 +3637,26 @@ describe("sisyphus-task", () => {
expect(result).toContain("plan-family")
})
test("prometheus display name cannot delegate to plan (cross-blocking)", async () => {
//#given
const { createDelegateTask } = require("./tools")
const mockClient = {
app: { agents: async () => ({ data: [{ name: "plan", mode: "subagent" }] }) },
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
session: { get: async () => ({ data: { directory: "/project" } }), create: async () => ({ data: { id: "s" } }), prompt: async () => ({ data: {} }), promptAsync: async () => ({ data: {} }), messages: async () => ({ data: [] }), status: async () => ({ data: {} }) },
}
const tool = createDelegateTask({ manager: { launch: async () => ({}) }, client: mockClient })
//#when
const result = await tool.execute(
{ description: "test", prompt: "Create a plan", subagent_type: "plan", run_in_background: false, load_skills: [] },
{ sessionID: "p", messageID: "m", agent: getAgentDisplayName("prometheus"), abort: new AbortController().signal }
)
//#then
expect(result).toContain("plan-family")
})
test("plan cannot delegate to prometheus (cross-blocking)", async () => {
//#given
const { createDelegateTask } = require("./tools")
@@ -3882,7 +4154,7 @@ describe("sisyphus-task", () => {
expect(promptBody.tools.task).toBe(true)
}, { timeout: 20000 })
test("prometheus subagent should have task permission (plan family)", async () => {
test("prometheus subagent should have task permission as part of the plan family", async () => {
//#given
const { createDelegateTask } = require("./tools")
let promptBody: any
@@ -3907,7 +4179,7 @@ describe("sisyphus-task", () => {
{ sessionID: "p", messageID: "m", agent: "sisyphus", abort: new AbortController().signal }
)
//#then
//#then - prometheus shares task permission with the plan family
expect(promptBody.tools.task).toBe(true)
}, { timeout: 20000 })
+18 -15
View File
@@ -76,13 +76,13 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini
- category: For task delegation (uses Sisyphus-Junior with category-optimized model)
- subagent_type: For direct agent invocation (explore, librarian, oracle, etc.)
**DO NOT provide both.** category and subagent_type are mutually exclusive.
**DO NOT provide both.** If category is provided, subagent_type is ignored.
- load_skills: ALWAYS REQUIRED. Pass [] if no skills needed, or ["skill-1", "skill-2"] for category tasks.
- category: Use predefined category → Spawns Sisyphus-Junior with category config
Available categories:
${categoryList}
- subagent_type: Use a specific callable non-primary agent directly (for example: explore, librarian, oracle, metis, momus)
- subagent_type: Use specific agent directly (explore, librarian, oracle, metis, momus)
- run_in_background: REQUIRED. true=async (returns task_id), false=sync (waits). Use background=true ONLY for parallel exploration with 5+ independent queries.
- session_id: Existing Task session to continue (from previous task output). Continues agent with FULL CONTEXT PRESERVED - saves tokens, maintains continuity.
- command: The command that triggered this task (optional, for slash command tracking).
@@ -98,28 +98,34 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini
description,
args: {
load_skills: tool.schema.array(tool.schema.string()).describe("Skill names to inject. REQUIRED - pass [] if no skills needed."),
description: tool.schema.string().describe("Short task description (3-5 words)"),
description: tool.schema.string().optional().describe("Short task description (3-5 words). Auto-generated from prompt if omitted."),
prompt: tool.schema.string().describe("Full detailed prompt for the agent"),
run_in_background: tool.schema.boolean().describe("REQUIRED. true=async (returns task_id), false=sync (waits). Use false for task delegation, true ONLY for parallel exploration."),
category: tool.schema.string().optional().describe(`REQUIRED if subagent_type not provided. Do NOT provide both category and subagent_type.`),
subagent_type: tool.schema.string().optional().describe("REQUIRED if category not provided. Do NOT provide both category and subagent_type. Must be a callable non-primary agent name returned by app.agents()."),
subagent_type: tool.schema.string().optional().describe("REQUIRED if category not provided. Do NOT provide both category and subagent_type."),
session_id: tool.schema.string().optional().describe("Existing Task session to continue"),
command: tool.schema.string().optional().describe("The command that triggered this task"),
},
async execute(args: DelegateTaskArgs, toolContext) {
const ctx = toolContext as ToolContextWithMetadata
let categoryOverrideNote: string | undefined
if (args.category && args.subagent_type) {
categoryOverrideNote = `[Note: You provided both category="${args.category}" and subagent_type="${args.subagent_type}". category takes precedence \u2014 subagent_type was ignored. Next time, provide ONLY category.]`
}
if (args.category) {
if (args.subagent_type && args.subagent_type !== SISYPHUS_JUNIOR_AGENT) {
log("[task] category provided - overriding subagent_type to sisyphus-junior", {
category: args.category,
subagent_type: args.subagent_type,
})
}
args.subagent_type = SISYPHUS_JUNIOR_AGENT
}
// Auto-generate description from prompt when missing or empty
if (!args.description || typeof args.description !== "string" || args.description.trim() === "") {
const words = (args.prompt || "").trim().split(/\s+/)
args.description = words.slice(0, 4).join(" ") || "Delegated task"
}
await ctx.metadata?.({
title: args.description,
})
if (args.run_in_background === undefined) {
throw new Error(`Invalid arguments: 'run_in_background' parameter is REQUIRED. Specify run_in_background=false for task delegation, or run_in_background=true for parallel exploration.`)
}
@@ -221,8 +227,7 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini
availableCategories,
availableSkills,
})
const result = await executeUnstableAgentTask(args, ctx, options, parentContext, agentToUse, categoryModel, systemContent, actualModel)
return categoryOverrideNote ? `${categoryOverrideNote}\n\n${result}` : result
return executeUnstableAgentTask(args, ctx, options, parentContext, agentToUse, categoryModel, systemContent, actualModel)
}
} else {
const resolution = await resolveSubagentExecution(args, options, parentContext.agent, categoryExamples)
@@ -245,13 +250,11 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini
availableSkills,
})
const prependNote = (result: string) => categoryOverrideNote ? `${categoryOverrideNote}\n\n${result}` : result
if (runInBackground) {
return prependNote(await executeBackgroundTask(args, ctx, options, parentContext, agentToUse, categoryModel, systemContent, fallbackChain))
return executeBackgroundTask(args, ctx, options, parentContext, agentToUse, categoryModel, systemContent, fallbackChain)
}
return prependNote(await executeSyncTask(args, ctx, options, parentContext, agentToUse, categoryModel, systemContent, modelInfo, fallbackChain))
return executeSyncTask(args, ctx, options, parentContext, agentToUse, categoryModel, systemContent, modelInfo, fallbackChain)
},
})
}
@@ -4,6 +4,7 @@ import { DEFAULT_SYNC_POLL_TIMEOUT_MS, getTimingConfig } from "./timing"
import { buildTaskPrompt } from "./prompt-builder"
import { cancelUnstableAgentTask } from "./cancel-unstable-agent-task"
import { storeToolMetadata } from "../../features/tool-metadata-store"
import { resolveCallID } from "./resolve-call-id"
import { formatDuration } from "./time-formatter"
import { formatDetailedError } from "./error-formatting"
import { getSessionTools } from "../../shared/session-tools-store"
@@ -81,8 +82,9 @@ export async function executeUnstableAgentTask(
},
}
await ctx.metadata?.(bgTaskMeta)
if (ctx.callID) {
storeToolMetadata(ctx.sessionID, ctx.callID, bgTaskMeta)
const callID = resolveCallID(ctx)
if (callID) {
storeToolMetadata(ctx.sessionID, callID, bgTaskMeta)
}
const startTime = new Date()
@@ -1,11 +1,36 @@
declare const require: (name: string) => any
const { describe, test, expect, beforeEach, afterEach, spyOn, mock, vi } = require("bun:test")
import { resolveSubagentExecution } from "./subagent-resolver"
import type { DelegateTaskArgs } from "./types"
import type { ExecutorContext } from "./executor-types"
import * as logger from "../../shared/logger"
import * as connectedProvidersCache from "../../shared/connected-providers-cache"
import * as agentLoader from "../../features/claude-code-agent-loader"
import { describe, test, expect, beforeEach, afterEach, mock } from "bun:test"
import type { DelegateTaskArgs } from "../types"
import type { ExecutorContext } from "../executor-types"
type SubagentResolverModule = typeof import("../subagent-resolver")
const logMock = mock((..._args: unknown[]) => {})
const readConnectedProvidersCacheMock = mock(() => null as string[] | null)
const readProviderModelsCacheMock = mock(
() => null as {
models: Record<string, string[]>
connected: string[]
updatedAt: string
} | null,
)
type ClaudeCodeAgentRecord = Record<
string,
{
description?: string
mode?: string
prompt?: string
model?: string | { providerID: string; modelID: string }
}
>
const loadUserAgentsMock = mock((): ClaudeCodeAgentRecord => ({}))
const loadProjectAgentsMock = mock((_directory?: string): ClaudeCodeAgentRecord => ({}))
async function importFreshSubagentResolverModule(): Promise<SubagentResolverModule> {
return await import(`../subagent-resolver?test=${Date.now()}-${Math.random()}`)
}
function createBaseArgs(overrides?: Partial<DelegateTaskArgs>): DelegateTaskArgs {
return {
@@ -37,21 +62,42 @@ function createExecutorContext(
}
describe("resolveSubagentExecution", () => {
let logSpy: ReturnType<typeof spyOn> | undefined
let mockLoadUserAgents: ReturnType<typeof spyOn>
let mockLoadProjectAgents: ReturnType<typeof spyOn>
let resolveSubagentExecution: SubagentResolverModule["resolveSubagentExecution"]
beforeEach(() => {
beforeEach(async () => {
mock.restore()
logSpy = spyOn(logger, "log").mockImplementation(() => {})
mockLoadUserAgents = spyOn(agentLoader, "loadUserAgents").mockReturnValue({})
mockLoadProjectAgents = spyOn(agentLoader, "loadProjectAgents").mockReturnValue({})
logMock.mockClear()
readConnectedProvidersCacheMock.mockReset()
readProviderModelsCacheMock.mockReset()
readConnectedProvidersCacheMock.mockReturnValue(null)
readProviderModelsCacheMock.mockReturnValue(null)
loadUserAgentsMock.mockReset()
loadProjectAgentsMock.mockReset()
loadUserAgentsMock.mockImplementation(() => ({}))
loadProjectAgentsMock.mockImplementation(() => ({}))
mock.module("../../../shared/logger", () => ({
log: logMock,
}))
mock.module("../../../shared/connected-providers-cache", () => ({
readConnectedProvidersCache: readConnectedProvidersCacheMock,
readProviderModelsCache: readProviderModelsCacheMock,
hasConnectedProvidersCache: () => readConnectedProvidersCacheMock() !== null,
hasProviderModelsCache: () => readProviderModelsCacheMock() !== null,
_resetMemCacheForTesting: () => {},
}))
mock.module("../../../features/claude-code-agent-loader/loader", () => ({
loadUserAgents: loadUserAgentsMock,
loadProjectAgents: loadProjectAgentsMock,
}))
mock.module("../../../features/claude-code-agent-loader", () => ({
loadUserAgents: loadUserAgentsMock,
loadProjectAgents: loadProjectAgentsMock,
}))
;({ resolveSubagentExecution } = await importFreshSubagentResolverModule())
})
afterEach(() => {
logSpy?.mockRestore()
mockLoadUserAgents?.mockRestore()
mockLoadProjectAgents?.mockRestore()
mock.restore()
})
test("returns delegation error when agent discovery fails instead of silently proceeding", async () => {
@@ -71,7 +117,7 @@ describe("resolveSubagentExecution", () => {
expect(result.error).toBe("Failed to delegate to agent \"oracle\": agents API unavailable")
})
test("logs failure details when subagent resolution throws", async () => {
test("returns delegation error when subagent resolution throws", async () => {
//#given
const args = createBaseArgs({ subagent_type: "review" })
const executorCtx = createExecutorContext(async () => {
@@ -79,22 +125,52 @@ describe("resolveSubagentExecution", () => {
})
//#when
await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep")
const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep")
//#then
expect(logSpy).toHaveBeenCalledTimes(1)
const callArgs = logSpy?.mock.calls[0]
expect(callArgs?.[0]).toBe("[delegate-task] Failed to resolve subagent execution")
expect(callArgs?.[1]).toEqual({
requestedAgent: "review",
parentAgent: "sisyphus",
error: "network timeout",
})
expect(result.agentToUse).toBe("")
expect(result.categoryModel).toBeUndefined()
expect(result.error).toBe('Failed to delegate to agent "review": network timeout')
})
test("hides primary agents from task delegation lookups", async () => {
//#given
const args = createBaseArgs({ subagent_type: "sisyphus" })
const executorCtx = createExecutorContext(async () => ([
{ name: "sisyphus", mode: "primary" },
{ name: "oracle", mode: "subagent" },
{ name: "metis", mode: "all" },
]))
//#when
const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep")
//#then
expect(result.agentToUse).toBe("")
expect(result.categoryModel).toBeUndefined()
expect(result.error).toBe('Unknown agent: "sisyphus". Available agents: metis, oracle')
})
test("requires explicit all or subagent mode for task-callable agents", async () => {
//#given
const args = createBaseArgs({ subagent_type: "custom-worker" })
const executorCtx = createExecutorContext(async () => ([
{ name: "custom-worker" },
{ name: "oracle", mode: "subagent" },
]))
//#when
const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep")
//#then
expect(result.agentToUse).toBe("")
expect(result.categoryModel).toBeUndefined()
expect(result.error).toBe('Unknown agent: "custom-worker". Available agents: oracle')
})
test("normalizes matched agent model string before returning categoryModel", async () => {
//#given
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
readProviderModelsCacheMock.mockReturnValue({
models: { openai: ["grok-3", "gpt-5.3-codex"] },
connected: ["openai"],
updatedAt: "2026-03-03T00:00:00.000Z",
@@ -110,12 +186,26 @@ describe("resolveSubagentExecution", () => {
//#then
expect(result.error).toBeUndefined()
expect(result.categoryModel).toEqual({ providerID: "openai", modelID: "gpt-5.3-codex" })
cacheSpy.mockRestore()
})
test("matches agents even when zero-width characters are present in the requested name", async () => {
//#given
const args = createBaseArgs({ subagent_type: "\uFEFFSisyphus - Ultraworker" })
const executorCtx = createExecutorContext(async () => ([
{ name: "\u200BSisyphus - Ultraworker", mode: "subagent", model: "openai/gpt-5.3-codex" },
]))
//#when
const result = await resolveSubagentExecution(args, executorCtx, "oracle", "deep")
//#then
expect(result.error).toBeUndefined()
expect(result.agentToUse).toBe("Sisyphus - Ultraworker")
})
test("uses agent override fallback_models for subagent runtime fallback chain", async () => {
//#given
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
readProviderModelsCacheMock.mockReturnValue({
models: { quotio: ["claude-haiku-4-5"] },
connected: ["quotio"],
updatedAt: "2026-03-03T00:00:00.000Z",
@@ -143,12 +233,11 @@ describe("resolveSubagentExecution", () => {
{ providers: ["quotio"], model: "gpt-5.2", variant: undefined },
{ providers: ["quotio"], model: "glm-5", variant: "max" },
])
cacheSpy.mockRestore()
})
test("uses category fallback_models when agent override points at category", async () => {
//#given
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
readProviderModelsCacheMock.mockReturnValue({
models: { anthropic: ["claude-haiku-4-5"] },
connected: ["anthropic"],
updatedAt: "2026-03-03T00:00:00.000Z",
@@ -180,17 +269,16 @@ describe("resolveSubagentExecution", () => {
expect(result.fallbackChain).toEqual([
{ providers: ["anthropic"], model: "claude-haiku-4-5", variant: undefined },
])
cacheSpy.mockRestore()
})
test("promotes object-style fallback model settings to categoryModel when subagent fallback becomes initial model", async () => {
//#given
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
readProviderModelsCacheMock.mockReturnValue({
models: { openai: ["gpt-5.4"] },
connected: ["openai"],
updatedAt: "2026-03-03T00:00:00.000Z",
})
const connectedSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
readConnectedProvidersCacheMock.mockReturnValue(["openai"])
const args = createBaseArgs({ subagent_type: "explore" })
const executorCtx = createExecutorContext(
async () => ([
@@ -230,18 +318,16 @@ describe("resolveSubagentExecution", () => {
maxTokens: 2048,
thinking: { type: "disabled" },
})
cacheSpy.mockRestore()
connectedSpy.mockRestore()
})
test("does not apply object-style fallback settings when the subagent primary model matches directly", async () => {
//#given
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
readProviderModelsCacheMock.mockReturnValue({
models: { openai: ["gpt-5.4-preview"] },
connected: ["openai"],
updatedAt: "2026-03-03T00:00:00.000Z",
})
const connectedSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
readConnectedProvidersCacheMock.mockReturnValue(["openai"])
const args = createBaseArgs({ subagent_type: "explore" })
const executorCtx = createExecutorContext(
async () => ([
@@ -271,18 +357,16 @@ describe("resolveSubagentExecution", () => {
providerID: "openai",
modelID: "gpt-5.4-preview",
})
cacheSpy.mockRestore()
connectedSpy.mockRestore()
})
test("matches promoted fallback settings after fuzzy model resolution", async () => {
//#given
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
readProviderModelsCacheMock.mockReturnValue({
models: { openai: ["gpt-5.4-preview"] },
connected: ["openai"],
updatedAt: "2026-03-03T00:00:00.000Z",
})
const connectedSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
readConnectedProvidersCacheMock.mockReturnValue(["openai"])
const args = createBaseArgs({ subagent_type: "explore" })
const executorCtx = createExecutorContext(
async () => ([
@@ -322,18 +406,16 @@ describe("resolveSubagentExecution", () => {
maxTokens: 2222,
thinking: { type: "disabled" },
})
cacheSpy.mockRestore()
connectedSpy.mockRestore()
})
test("prefers exact promoted fallback match over earlier fuzzy prefix match", async () => {
//#given
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
readProviderModelsCacheMock.mockReturnValue({
models: { openai: ["gpt-5.4-preview"] },
connected: ["openai"],
updatedAt: "2026-03-03T00:00:00.000Z",
})
const connectedSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
readConnectedProvidersCacheMock.mockReturnValue(["openai"])
const args = createBaseArgs({ subagent_type: "explore" })
const executorCtx = createExecutorContext(
async () => ([
@@ -370,18 +452,16 @@ describe("resolveSubagentExecution", () => {
variant: "max",
reasoningEffort: "high",
})
cacheSpy.mockRestore()
connectedSpy.mockRestore()
})
test("matches promoted fallback settings when fuzzy resolution extends configured model without hyphen", async () => {
//#given
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
readProviderModelsCacheMock.mockReturnValue({
models: { openai: ["gpt-5.4o"] },
connected: ["openai"],
updatedAt: "2026-03-03T00:00:00.000Z",
})
const connectedSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
readConnectedProvidersCacheMock.mockReturnValue(["openai"])
const args = createBaseArgs({ subagent_type: "explore" })
const executorCtx = createExecutorContext(
async () => ([
@@ -413,18 +493,16 @@ describe("resolveSubagentExecution", () => {
variant: "low",
reasoningEffort: "high",
})
cacheSpy.mockRestore()
connectedSpy.mockRestore()
})
test("does not use unavailable matchedAgent.model as fallback for custom subagent", async () => {
//#given
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
readProviderModelsCacheMock.mockReturnValue({
models: { minimaxi: ["MiniMax-M2.7"] },
connected: ["minimaxi"],
updatedAt: "2026-03-03T00:00:00.000Z",
})
const connectedSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["minimaxi"])
readConnectedProvidersCacheMock.mockReturnValue(["minimaxi"])
const args = createBaseArgs({ subagent_type: "my-custom-agent" })
const executorCtx = createExecutorContext(
async () => ([
@@ -438,18 +516,16 @@ describe("resolveSubagentExecution", () => {
//#then
expect(result.error).toBeUndefined()
expect(result.categoryModel?.modelID).not.toBe("MiniMax-M2.7-highspeed")
cacheSpy.mockRestore()
connectedSpy.mockRestore()
})
test("uses matchedAgent.model as fallback when model is available", async () => {
//#given
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
readProviderModelsCacheMock.mockReturnValue({
models: { minimaxi: ["MiniMax-M2.7-highspeed"] },
connected: ["minimaxi"],
updatedAt: "2026-03-03T00:00:00.000Z",
})
const connectedSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["minimaxi"])
readConnectedProvidersCacheMock.mockReturnValue(["minimaxi"])
const args = createBaseArgs({ subagent_type: "my-custom-agent" })
const executorCtx = createExecutorContext(
async () => ([
@@ -463,18 +539,16 @@ describe("resolveSubagentExecution", () => {
//#then
expect(result.error).toBeUndefined()
expect(result.categoryModel).toEqual({ providerID: "minimaxi", modelID: "MiniMax-M2.7-highspeed" })
cacheSpy.mockRestore()
connectedSpy.mockRestore()
})
test("prefers the most specific prefix match when fallback entries share a prefix", async () => {
//#given
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
readProviderModelsCacheMock.mockReturnValue({
models: { openai: ["gpt-4o-preview"] },
connected: ["openai"],
updatedAt: "2026-03-03T00:00:00.000Z",
})
const connectedSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
readConnectedProvidersCacheMock.mockReturnValue(["openai"])
const args = createBaseArgs({ subagent_type: "explore" })
const executorCtx = createExecutorContext(
async () => ([
@@ -511,29 +585,122 @@ describe("resolveSubagentExecution", () => {
variant: "max",
reasoningEffort: "high",
})
cacheSpy.mockRestore()
connectedSpy.mockRestore()
})
test("resolves user agent from loadUserAgents when calling task(subagent_type=...)", async () => {
test("preserves category temperature when fallback entry leaves temperature undefined", async () => {
//#given
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
readProviderModelsCacheMock.mockReturnValue({
models: { openai: ["gpt-5.4"] },
connected: ["openai"],
updatedAt: "2026-03-03T00:00:00.000Z",
})
const connectedSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
readConnectedProvidersCacheMock.mockReturnValue(["openai"])
const args = createBaseArgs({ subagent_type: "explore" })
const executorCtx = createExecutorContext(
async () => ([
{ name: "explore", mode: "subagent", model: "quotio/claude-haiku-4-5-unavailable" },
]),
{
agentOverrides: {
explore: {
category: "research",
},
} as ExecutorContext["agentOverrides"],
userCategories: {
research: {
fallback_models: [
{
model: "openai/gpt-5.4",
variant: "max",
},
],
temperature: 0.55,
top_p: 0.45,
},
} as ExecutorContext["userCategories"],
}
)
mockLoadUserAgents.mockReturnValue({
//#when
const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep")
//#then
expect(result.error).toBeUndefined()
expect(result.categoryModel).toEqual({
providerID: "openai",
modelID: "gpt-5.4",
variant: "max",
temperature: 0.55,
top_p: 0.45,
})
})
test("applies category tuning params in the cold-cache override path", async () => {
//#given
readProviderModelsCacheMock.mockReturnValue({
models: {},
connected: [],
updatedAt: "2026-03-03T00:00:00.000Z",
})
readConnectedProvidersCacheMock.mockReturnValue([])
const args = createBaseArgs({ subagent_type: "explore" })
const executorCtx = createExecutorContext(
async () => ([
{ name: "explore", mode: "subagent", model: "openai/gpt-5.4" },
]),
{
agentOverrides: {
explore: {
category: "research",
},
} as ExecutorContext["agentOverrides"],
userCategories: {
research: {
model: "openai/gpt-5.4",
variant: "high",
temperature: 0.61,
top_p: 0.62,
maxTokens: 3200,
reasoningEffort: "medium",
thinking: { type: "disabled" },
},
} as ExecutorContext["userCategories"],
}
)
//#when
const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep")
//#then
expect(result.error).toBeUndefined()
expect(result.categoryModel).toEqual({
providerID: "openai",
modelID: "gpt-5.4",
variant: "high",
temperature: 0.61,
top_p: 0.62,
maxTokens: 3200,
reasoningEffort: "medium",
thinking: { type: "disabled" },
})
})
test("resolves user agent from loadUserAgents when calling task(subagent_type=...)", async () => {
//#given
readProviderModelsCacheMock.mockReturnValue({
models: { openai: ["gpt-5.4"] },
connected: ["openai"],
updatedAt: "2026-03-03T00:00:00.000Z",
})
readConnectedProvidersCacheMock.mockReturnValue(["openai"])
loadUserAgentsMock.mockImplementation(() => ({
"my-user-agent": {
description: "A user agent",
mode: "subagent",
prompt: "Do something",
model: "openai/gpt-5.4",
},
})
mockLoadProjectAgents.mockReturnValue({})
}))
const args = createBaseArgs({ subagent_type: "my-user-agent" })
const executorCtx = createExecutorContext(async () => [])
@@ -544,30 +711,24 @@ describe("resolveSubagentExecution", () => {
expect(result.error).toBeUndefined()
expect(result.agentToUse).toBe("my-user-agent")
expect(result.categoryModel?.modelID).toBe("gpt-5.4")
cacheSpy.mockRestore()
connectedSpy.mockRestore()
})
test("resolves project agent from loadProjectAgents when calling task(subagent_type=...)", async () => {
//#given
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
readProviderModelsCacheMock.mockReturnValue({
models: { anthropic: ["claude-sonnet-4"] },
connected: ["anthropic"],
updatedAt: "2026-03-03T00:00:00.000Z",
})
const connectedSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["anthropic"])
mockLoadUserAgents.mockReturnValue({})
mockLoadProjectAgents.mockReturnValue({
readConnectedProvidersCacheMock.mockReturnValue(["anthropic"])
loadProjectAgentsMock.mockImplementation(() => ({
"my-project-agent": {
description: "A project agent",
mode: "subagent",
prompt: "Do project work",
model: "anthropic/claude-sonnet-4",
},
})
}))
const args = createBaseArgs({ subagent_type: "my-project-agent" })
const executorCtx = createExecutorContext(async () => [])
@@ -578,31 +739,24 @@ describe("resolveSubagentExecution", () => {
expect(result.error).toBeUndefined()
expect(result.agentToUse).toBe("my-project-agent")
expect(result.categoryModel?.modelID).toBe("claude-sonnet-4")
cacheSpy.mockRestore()
connectedSpy.mockRestore()
})
test("server agent takes precedence over user agent with same name", async () => {
//#given
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
models: { openai: ["gpt-5.4"] },
readProviderModelsCacheMock.mockReturnValue({
models: { openai: ["gpt-5.4", "gpt-3.5"] },
connected: ["openai"],
updatedAt: "2026-03-03T00:00:00.000Z",
})
const connectedSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
mockLoadUserAgents.mockReturnValue({
readConnectedProvidersCacheMock.mockReturnValue(["openai"])
loadUserAgentsMock.mockImplementation(() => ({
"explore": {
description: "User explore agent",
mode: "subagent",
prompt: "User prompt",
model: "openai/gpt-3.5",
},
})
mockLoadProjectAgents.mockReturnValue({})
// Server has "explore" agent
}))
const args = createBaseArgs({ subagent_type: "explore" })
const executorCtx = createExecutorContext(async () => ([
{ name: "explore", mode: "subagent", model: "openai/gpt-5.4" },
@@ -614,39 +768,33 @@ describe("resolveSubagentExecution", () => {
//#then
expect(result.error).toBeUndefined()
expect(result.agentToUse).toBe("explore")
// Should use server's model, not user's
expect(result.categoryModel?.modelID).toBe("gpt-5.4")
cacheSpy.mockRestore()
connectedSpy.mockRestore()
})
test("project agent takes precedence over user agent with same name", async () => {
//#given
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
readProviderModelsCacheMock.mockReturnValue({
models: { minimaxi: ["MiniMax-M2.7-highspeed", "claude-3-haiku"] },
connected: ["minimaxi"],
updatedAt: "2026-03-03T00:00:00.000Z",
})
const connectedSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["minimaxi"])
mockLoadUserAgents.mockReturnValue({
readConnectedProvidersCacheMock.mockReturnValue(["minimaxi"])
loadUserAgentsMock.mockImplementation(() => ({
"my-custom-agent": {
description: "User agent",
mode: "subagent",
prompt: "User prompt",
model: "minimaxi/claude-3-haiku",
},
})
mockLoadProjectAgents.mockReturnValue({
}))
loadProjectAgentsMock.mockImplementation(() => ({
"my-custom-agent": {
description: "Project agent",
mode: "subagent",
prompt: "Project prompt",
model: "minimaxi/MiniMax-M2.7-highspeed",
},
})
}))
const args = createBaseArgs({ subagent_type: "my-custom-agent" })
const executorCtx = createExecutorContext(async () => [])
@@ -657,22 +805,17 @@ describe("resolveSubagentExecution", () => {
expect(result.error).toBeUndefined()
expect(result.agentToUse).toBe("my-custom-agent")
expect(result.categoryModel?.modelID).toBe("MiniMax-M2.7-highspeed")
cacheSpy.mockRestore()
connectedSpy.mockRestore()
})
test("filters out primary agents from user/project when resolving", async () => {
//#given
mockLoadUserAgents.mockReturnValue({
loadUserAgentsMock.mockImplementation(() => ({
"my-primary-agent": {
description: "A primary agent",
mode: "primary",
prompt: "I am primary",
},
})
mockLoadProjectAgents.mockReturnValue({})
}))
const args = createBaseArgs({ subagent_type: "my-primary-agent" })
const executorCtx = createExecutorContext(async () => [])
@@ -684,3 +827,123 @@ describe("resolveSubagentExecution", () => {
expect(result.agentToUse).toBe("")
})
})
describe("resolveSubagentExecution - agent name sanitization", () => {
let resolveSubagentExecution: SubagentResolverModule["resolveSubagentExecution"]
beforeEach(async () => {
mock.restore()
logMock.mockClear()
readConnectedProvidersCacheMock.mockReset()
readProviderModelsCacheMock.mockReset()
readConnectedProvidersCacheMock.mockReturnValue(null)
readProviderModelsCacheMock.mockReturnValue(null)
loadUserAgentsMock.mockReset()
loadProjectAgentsMock.mockReset()
loadUserAgentsMock.mockImplementation(() => ({}))
loadProjectAgentsMock.mockImplementation(() => ({}))
mock.module("../../../shared/logger", () => ({
log: logMock,
}))
mock.module("../../../shared/connected-providers-cache", () => ({
readConnectedProvidersCache: readConnectedProvidersCacheMock,
readProviderModelsCache: readProviderModelsCacheMock,
hasConnectedProvidersCache: () => readConnectedProvidersCacheMock() !== null,
hasProviderModelsCache: () => readProviderModelsCacheMock() !== null,
_resetMemCacheForTesting: () => {},
}))
mock.module("../../../features/claude-code-agent-loader/loader", () => ({
loadUserAgents: loadUserAgentsMock,
loadProjectAgents: loadProjectAgentsMock,
}))
mock.module("../../../features/claude-code-agent-loader", () => ({
loadUserAgents: loadUserAgentsMock,
loadProjectAgents: loadProjectAgentsMock,
}))
;({ resolveSubagentExecution } = await importFreshSubagentResolverModule())
})
afterEach(() => {
mock.restore()
})
test("strips backslash-wrapped agent names like \\hephaestus\\", async () => {
//#given
readProviderModelsCacheMock.mockReturnValue({
models: {},
connected: [],
updatedAt: "2026-03-03T00:00:00.000Z",
})
const args = createBaseArgs({ subagent_type: "\\hephaestus\\" })
const executorCtx = createExecutorContext(async () => ([
{ name: "Hephaestus - Deep Agent", mode: "subagent", model: "openai/gpt-5.3-codex" },
]))
//#when
const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep")
//#then
expect(result.error).toBeUndefined()
expect(result.agentToUse).toBe("Hephaestus - Deep Agent")
})
test("strips double-quoted agent names", async () => {
//#given
readProviderModelsCacheMock.mockReturnValue({
models: {},
connected: [],
updatedAt: "2026-03-03T00:00:00.000Z",
})
const args = createBaseArgs({ subagent_type: '"oracle"' })
const executorCtx = createExecutorContext(async () => ([
{ name: "oracle", mode: "subagent" },
]))
//#when
const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep")
//#then
expect(result.error).toBeUndefined()
expect(result.agentToUse).toBe("oracle")
})
test("strips single-quoted agent names", async () => {
//#given
readProviderModelsCacheMock.mockReturnValue({
models: {},
connected: [],
updatedAt: "2026-03-03T00:00:00.000Z",
})
const args = createBaseArgs({ subagent_type: "'explore'" })
const executorCtx = createExecutorContext(async () => ([
{ name: "explore", mode: "subagent" },
]))
//#when
const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep")
//#then
expect(result.error).toBeUndefined()
expect(result.agentToUse).toBe("explore")
})
test("matches runtime agent names that include invisible sort prefixes", async () => {
//#given
readProviderModelsCacheMock.mockReturnValue({
models: {},
connected: [],
updatedAt: "2026-03-03T00:00:00.000Z",
})
const args = createBaseArgs({ subagent_type: "Sisyphus - Ultraworker" })
const executorCtx = createExecutorContext(async () => ([
{ name: "\u200BSisyphus - Ultraworker", mode: "subagent", model: "openai/gpt-5.3-codex" },
]))
//#when
const result = await resolveSubagentExecution(args, executorCtx, "oracle", "deep")
//#then
expect(result.error).toBeUndefined()
expect(result.agentToUse).toBe("Sisyphus - Ultraworker")
})
})