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:
@@ -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")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
+377
-114
@@ -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")
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user