23d125755f
isPlanFamily was already fixed to normalize display names via getAgentConfigKey, but isPlanAgent still used raw agentName.toLowerCase() without normalization. Apply the same fix to isPlanAgent for consistency. Also fixes test title: 'returns true for planner' was describing the OLD includes-based behavior (the bug), but the assertion already expected false. Add regression tests covering Metis/Momus/Atlas display names for both functions. Fixes #3312
4420 lines
151 KiB
TypeScript
4420 lines
151 KiB
TypeScript
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 { getAgentDisplayName, getAgentListDisplayName } from "../../shared/agent-display-names"
|
|
import type { CategoryConfig } from "../../config/schema"
|
|
import type { DelegateTaskArgs } from "./types"
|
|
import { __resetModelCache } from "../../shared/model-availability"
|
|
import { clearSkillCache } from "../../features/opencode-skill-loader/skill-content"
|
|
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"]
|
|
const TEST_AVAILABLE_MODELS = new Set([
|
|
"anthropic/claude-opus-4-6",
|
|
"anthropic/claude-sonnet-4-6",
|
|
"anthropic/claude-haiku-4-5",
|
|
"google/gemini-3.1-pro",
|
|
"google/gemini-3-flash",
|
|
"openai/gpt-5.4",
|
|
"openai/gpt-5.3-codex",
|
|
])
|
|
|
|
type DelegateTaskArgsWithSerializedSkills = Omit<DelegateTaskArgs, "load_skills"> & {
|
|
load_skills: string
|
|
}
|
|
|
|
function createTestAvailableModels(): Set<string> {
|
|
return new Set(TEST_AVAILABLE_MODELS)
|
|
}
|
|
|
|
describe("sisyphus-task", () => {
|
|
let cacheSpy: ReturnType<typeof spyOn>
|
|
let providerModelsSpy: ReturnType<typeof spyOn>
|
|
|
|
beforeEach(() => {
|
|
mock.restore()
|
|
clearRequireCache("./tools")
|
|
__resetModelCache()
|
|
clearSkillCache()
|
|
__setTimingConfig({
|
|
POLL_INTERVAL_MS: 10,
|
|
MIN_STABILITY_TIME_MS: 50,
|
|
STABILITY_POLLS_REQUIRED: 1,
|
|
WAIT_FOR_SESSION_INTERVAL_MS: 10,
|
|
WAIT_FOR_SESSION_TIMEOUT_MS: 1000,
|
|
MAX_POLL_TIME_MS: 2000,
|
|
SESSION_CONTINUATION_STABILITY_MS: 50,
|
|
})
|
|
cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["anthropic", "google", "openai"])
|
|
providerModelsSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").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"],
|
|
},
|
|
connected: ["anthropic", "google", "openai"],
|
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
})
|
|
})
|
|
|
|
afterEach(() => {
|
|
__resetTimingConfig()
|
|
cacheSpy?.mockRestore()
|
|
providerModelsSpy?.mockRestore()
|
|
})
|
|
|
|
describe("DEFAULT_CATEGORIES", () => {
|
|
test("visual-engineering category has model and variant config", () => {
|
|
// given
|
|
const category = DEFAULT_CATEGORIES["visual-engineering"]
|
|
|
|
// when / #then
|
|
expect(category).toBeDefined()
|
|
expect(category.model).toBe("google/gemini-3.1-pro")
|
|
expect(category.variant).toBe("high")
|
|
})
|
|
|
|
test("ultrabrain category has model and variant config", () => {
|
|
// given
|
|
const category = DEFAULT_CATEGORIES["ultrabrain"]
|
|
|
|
// when / #then
|
|
expect(category).toBeDefined()
|
|
expect(category.model).toBe("openai/gpt-5.4")
|
|
expect(category.variant).toBe("xhigh")
|
|
})
|
|
|
|
test("deep category has model and variant config", () => {
|
|
// given
|
|
const category = DEFAULT_CATEGORIES["deep"]
|
|
|
|
// when / #then
|
|
expect(category).toBeDefined()
|
|
expect(category.model).toBe("openai/gpt-5.4")
|
|
expect(category.variant).toBe("medium")
|
|
})
|
|
|
|
test("unspecified-high category uses claude-opus-4-6 max as primary", () => {
|
|
// given
|
|
const category = DEFAULT_CATEGORIES["unspecified-high"]
|
|
|
|
// when / #then
|
|
expect(category).toBeDefined()
|
|
expect(category.model).toBe("anthropic/claude-opus-4-6")
|
|
expect(category.variant).toBe("max")
|
|
})
|
|
})
|
|
|
|
describe("CATEGORY_PROMPT_APPENDS", () => {
|
|
test("visual-engineering category has design-focused prompt", () => {
|
|
// given
|
|
const promptAppend = CATEGORY_PROMPT_APPENDS["visual-engineering"]
|
|
|
|
// when / #then
|
|
expect(promptAppend).toContain("VISUAL/UI")
|
|
expect(promptAppend).toContain("Design-first")
|
|
})
|
|
|
|
test("ultrabrain category has deep logical reasoning prompt", () => {
|
|
// given
|
|
const promptAppend = CATEGORY_PROMPT_APPENDS["ultrabrain"]
|
|
|
|
// when / #then
|
|
expect(promptAppend).toContain("DEEP LOGICAL REASONING")
|
|
expect(promptAppend).toContain("Strategic advisor")
|
|
})
|
|
|
|
test("deep category has goal-oriented autonomous prompt", () => {
|
|
// given
|
|
const promptAppend = CATEGORY_PROMPT_APPENDS["deep"]
|
|
|
|
// when / #then
|
|
expect(promptAppend).toContain("GOAL-ORIENTED")
|
|
expect(promptAppend).toContain("autonomous")
|
|
})
|
|
})
|
|
|
|
describe("CATEGORY_DESCRIPTIONS", () => {
|
|
test("has description for all default categories", () => {
|
|
// given
|
|
const defaultCategoryNames = Object.keys(DEFAULT_CATEGORIES)
|
|
|
|
// when / #then
|
|
for (const name of defaultCategoryNames) {
|
|
expect(CATEGORY_DESCRIPTIONS[name]).toBeDefined()
|
|
expect(CATEGORY_DESCRIPTIONS[name].length).toBeGreaterThan(0)
|
|
}
|
|
})
|
|
|
|
test("unspecified-high category exists and has description", () => {
|
|
// given / #when
|
|
const description = CATEGORY_DESCRIPTIONS["unspecified-high"]
|
|
|
|
// then
|
|
expect(description).toBeDefined()
|
|
expect(description).toContain("high effort")
|
|
})
|
|
})
|
|
|
|
describe("isPlanAgent", () => {
|
|
test("returns true for 'plan'", () => {
|
|
// given / #when
|
|
const result = isPlanAgent("plan")
|
|
|
|
// then
|
|
expect(result).toBe(true)
|
|
})
|
|
|
|
test("returns false for 'prometheus' (decoupled from plan)", () => {
|
|
//#given / #when
|
|
const result = isPlanAgent("prometheus")
|
|
|
|
//#then - prometheus is NOT a plan agent
|
|
expect(result).toBe(false)
|
|
})
|
|
|
|
test("returns false for 'planner' (no longer matches via substring)", () => {
|
|
//#given / #when
|
|
const result = isPlanAgent("planner")
|
|
|
|
//#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'", () => {
|
|
// given / #when
|
|
const result = isPlanAgent("PLAN")
|
|
|
|
// then
|
|
expect(result).toBe(true)
|
|
})
|
|
|
|
test("returns false for case-insensitive match 'Prometheus' (decoupled from plan)", () => {
|
|
//#given / #when
|
|
const result = isPlanAgent("Prometheus")
|
|
|
|
//#then - Prometheus is NOT a plan agent
|
|
expect(result).toBe(false)
|
|
})
|
|
|
|
test("returns false for 'oracle'", () => {
|
|
// given / #when
|
|
const result = isPlanAgent("oracle")
|
|
|
|
// then
|
|
expect(result).toBe(false)
|
|
})
|
|
|
|
test("returns false for 'explore'", () => {
|
|
// given / #when
|
|
const result = isPlanAgent("explore")
|
|
|
|
// then
|
|
expect(result).toBe(false)
|
|
})
|
|
|
|
test("returns false for undefined", () => {
|
|
// given / #when
|
|
const result = isPlanAgent(undefined)
|
|
|
|
// then
|
|
expect(result).toBe(false)
|
|
})
|
|
|
|
test("returns false for empty string", () => {
|
|
// given / #when
|
|
const result = isPlanAgent("")
|
|
|
|
// then
|
|
expect(result).toBe(false)
|
|
})
|
|
|
|
test("PLAN_AGENT_NAMES contains only plan", () => {
|
|
//#given / #when / #then
|
|
expect(PLAN_AGENT_NAMES).toEqual(["plan"])
|
|
})
|
|
|
|
test("returns false for non-plan agent display names (regression: isPlanAgent display-name false-positive)", () => {
|
|
//#given / #when / #then
|
|
expect(isPlanAgent(getAgentDisplayName("metis"))).toBe(false)
|
|
expect(isPlanAgent(getAgentDisplayName("momus"))).toBe(false)
|
|
expect(isPlanAgent(getAgentDisplayName("atlas"))).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe("isPlanFamily", () => {
|
|
test("returns true for 'plan'", () => {
|
|
//#given / #when
|
|
const result = isPlanFamily("plan")
|
|
//#then
|
|
expect(result).toBe(true)
|
|
})
|
|
|
|
test("returns true for 'prometheus'", () => {
|
|
//#given / #when
|
|
const result = isPlanFamily("prometheus")
|
|
//#then
|
|
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")
|
|
//#then
|
|
expect(result).toBe(false)
|
|
})
|
|
|
|
test("returns false for undefined", () => {
|
|
//#given / #when
|
|
const result = isPlanFamily(undefined)
|
|
//#then
|
|
expect(result).toBe(false)
|
|
})
|
|
|
|
test("returns false for non-plan-family agent display names (regression: isPlanFamily includes() false-positive)", () => {
|
|
//#given / #when / #then
|
|
expect(isPlanFamily(getAgentDisplayName("metis"))).toBe(false)
|
|
expect(isPlanFamily(getAgentDisplayName("momus"))).toBe(false)
|
|
expect(isPlanFamily(getAgentDisplayName("atlas"))).toBe(false)
|
|
})
|
|
|
|
test("PLAN_FAMILY_NAMES contains plan and prometheus", () => {
|
|
//#given / #when / #then
|
|
expect(PLAN_FAMILY_NAMES).toEqual(["plan", "prometheus"])
|
|
})
|
|
})
|
|
|
|
describe("load_skills parsing", () => {
|
|
test("parses valid JSON string into array before validation", async () => {
|
|
//#given
|
|
const { createDelegateTask } = require("./tools")
|
|
|
|
const mockManager = {
|
|
launch: async () => ({
|
|
id: "task-123",
|
|
status: "pending",
|
|
description: "Parse test",
|
|
agent: "sisyphus-junior",
|
|
sessionID: "test-session",
|
|
}),
|
|
}
|
|
|
|
const mockClient = {
|
|
app: { agents: async () => ({ data: [] }) },
|
|
config: { get: async () => ({}) },
|
|
provider: { list: async () => ({ data: { connected: ["openai"] } }) },
|
|
model: { list: async () => ({ data: [{ provider: "openai", id: "gpt-5.3-codex" }] }) },
|
|
session: {
|
|
create: async () => ({ data: { id: "test-session" } }),
|
|
prompt: async () => ({ data: {} }),
|
|
promptAsync: async () => ({ data: {} }),
|
|
messages: async () => ({ data: [] }),
|
|
status: async () => ({ data: {} }),
|
|
},
|
|
}
|
|
|
|
const tool = createDelegateTask({
|
|
manager: mockManager,
|
|
client: mockClient,
|
|
connectedProvidersOverride: TEST_CONNECTED_PROVIDERS,
|
|
availableModelsOverride: createTestAvailableModels(),
|
|
})
|
|
|
|
const toolContext = {
|
|
sessionID: "parent-session",
|
|
messageID: "parent-message",
|
|
agent: "sisyphus",
|
|
abort: new AbortController().signal,
|
|
}
|
|
|
|
const resolveSkillContentSpy = spyOn(executor, "resolveSkillContent").mockResolvedValue({
|
|
content: "resolved skill content",
|
|
error: null,
|
|
})
|
|
|
|
const args: DelegateTaskArgsWithSerializedSkills = {
|
|
description: "Parse valid string",
|
|
prompt: "Load skill parsing test",
|
|
category: "quick",
|
|
run_in_background: true,
|
|
load_skills: '["playwright", "git-master"]',
|
|
}
|
|
|
|
//#when
|
|
await tool.execute(args as unknown as DelegateTaskArgs, toolContext)
|
|
|
|
//#then
|
|
expect(args.load_skills).toEqual(["playwright", "git-master"])
|
|
expect(resolveSkillContentSpy).toHaveBeenCalledWith(["playwright", "git-master"], expect.any(Object))
|
|
}, { timeout: 10000 })
|
|
|
|
test("defaults to [] when load_skills is malformed JSON", async () => {
|
|
//#given
|
|
const { createDelegateTask } = require("./tools")
|
|
|
|
const mockManager = {
|
|
launch: async () => ({
|
|
id: "task-456",
|
|
status: "pending",
|
|
description: "Parse test",
|
|
agent: "sisyphus-junior",
|
|
sessionID: "test-session",
|
|
}),
|
|
}
|
|
|
|
const mockClient = {
|
|
app: { agents: async () => ({ data: [] }) },
|
|
config: { get: async () => ({}) },
|
|
provider: { list: async () => ({ data: { connected: ["openai"] } }) },
|
|
model: { list: async () => ({ data: [{ provider: "openai", id: "gpt-5.3-codex" }] }) },
|
|
session: {
|
|
create: async () => ({ data: { id: "test-session" } }),
|
|
prompt: async () => ({ data: {} }),
|
|
promptAsync: async () => ({ data: {} }),
|
|
messages: async () => ({ data: [] }),
|
|
status: async () => ({ data: {} }),
|
|
},
|
|
}
|
|
|
|
const tool = createDelegateTask({
|
|
manager: mockManager,
|
|
client: mockClient,
|
|
connectedProvidersOverride: TEST_CONNECTED_PROVIDERS,
|
|
availableModelsOverride: createTestAvailableModels(),
|
|
})
|
|
|
|
const toolContext = {
|
|
sessionID: "parent-session",
|
|
messageID: "parent-message",
|
|
agent: "sisyphus",
|
|
abort: new AbortController().signal,
|
|
}
|
|
|
|
const resolveSkillContentSpy = spyOn(executor, "resolveSkillContent").mockResolvedValue({
|
|
content: "resolved skill content",
|
|
error: null,
|
|
})
|
|
|
|
const args: DelegateTaskArgsWithSerializedSkills = {
|
|
description: "Parse malformed string",
|
|
prompt: "Load skill parsing test",
|
|
category: "quick",
|
|
run_in_background: true,
|
|
load_skills: '["playwright", "git-master"',
|
|
}
|
|
|
|
//#when
|
|
await tool.execute(args as unknown as DelegateTaskArgs, toolContext)
|
|
|
|
//#then
|
|
expect(args.load_skills).toEqual([])
|
|
expect(resolveSkillContentSpy).toHaveBeenCalledWith([], expect.any(Object))
|
|
}, { timeout: 10000 })
|
|
})
|
|
|
|
describe("category delegation config validation", () => {
|
|
test("fills subagent_type as sisyphus-junior when category is provided without subagent_type", async () => {
|
|
// given
|
|
const { createDelegateTask } = require("./tools")
|
|
|
|
const mockManager = {
|
|
launch: async () => ({
|
|
id: "task-123",
|
|
status: "pending",
|
|
description: "Test task",
|
|
agent: "sisyphus-junior",
|
|
sessionID: "test-session",
|
|
}),
|
|
}
|
|
const mockClient = {
|
|
app: { agents: async () => ({ data: [] }) },
|
|
config: { get: async () => ({}) },
|
|
provider: { list: async () => ({ data: { connected: ["openai"] } }) },
|
|
model: { list: async () => ({ data: [{ provider: "openai", id: "gpt-5.3-codex" }] }) },
|
|
session: {
|
|
create: async () => ({ data: { id: "test-session" } }),
|
|
prompt: async () => ({ data: {} }),
|
|
promptAsync: async () => ({ data: {} }),
|
|
messages: async () => ({ data: [] }),
|
|
status: async () => ({ data: {} }),
|
|
},
|
|
}
|
|
|
|
const tool = createDelegateTask({
|
|
manager: mockManager,
|
|
client: mockClient,
|
|
connectedProvidersOverride: TEST_CONNECTED_PROVIDERS,
|
|
availableModelsOverride: createTestAvailableModels(),
|
|
})
|
|
|
|
const toolContext = {
|
|
sessionID: "parent-session",
|
|
messageID: "parent-message",
|
|
agent: "sisyphus",
|
|
abort: new AbortController().signal,
|
|
}
|
|
|
|
const args: {
|
|
description: string
|
|
prompt: string
|
|
category: string
|
|
run_in_background: boolean
|
|
load_skills: string[]
|
|
subagent_type?: string
|
|
} = {
|
|
description: "Quick category test",
|
|
prompt: "Do something",
|
|
category: "quick",
|
|
run_in_background: true,
|
|
load_skills: [],
|
|
}
|
|
|
|
// when
|
|
await tool.execute(args, toolContext)
|
|
|
|
// then
|
|
expect(args.subagent_type).toBe("Sisyphus-Junior")
|
|
}, { timeout: 10000 })
|
|
|
|
test("prefers category over subagent_type when both are provided", async () => {
|
|
//#given
|
|
const { createDelegateTask } = require("./tools")
|
|
|
|
const mockManager = {
|
|
launch: async () => ({
|
|
id: "task-override",
|
|
status: "pending",
|
|
description: "Override test",
|
|
agent: "sisyphus-junior",
|
|
sessionID: "test-session",
|
|
}),
|
|
}
|
|
|
|
const mockClient = {
|
|
app: { agents: async () => ({ data: [] }) },
|
|
config: { get: async () => ({}) },
|
|
provider: { list: async () => ({ data: { connected: ["openai"] } }) },
|
|
model: { list: async () => ({ data: [{ provider: "openai", id: "gpt-5.3-codex" }] }) },
|
|
session: {
|
|
create: async () => ({ data: { id: "test-session" } }),
|
|
prompt: async () => ({ data: {} }),
|
|
promptAsync: async () => ({ data: {} }),
|
|
messages: async () => ({ data: [] }),
|
|
status: async () => ({ data: {} }),
|
|
},
|
|
}
|
|
|
|
const tool = createDelegateTask({
|
|
manager: mockManager,
|
|
client: mockClient,
|
|
connectedProvidersOverride: TEST_CONNECTED_PROVIDERS,
|
|
availableModelsOverride: createTestAvailableModels(),
|
|
})
|
|
|
|
const toolContext = {
|
|
sessionID: "parent-session",
|
|
messageID: "parent-message",
|
|
agent: "sisyphus",
|
|
abort: new AbortController().signal,
|
|
}
|
|
|
|
const args = {
|
|
description: "Override test",
|
|
prompt: "Do something",
|
|
category: "quick",
|
|
subagent_type: "oracle",
|
|
run_in_background: true,
|
|
load_skills: [],
|
|
}
|
|
|
|
//#when
|
|
await tool.execute(args, toolContext)
|
|
|
|
//#then - category takes precedence, subagent_type is overridden to sisyphus-junior
|
|
expect(args.subagent_type).toBe("Sisyphus-Junior")
|
|
}, { timeout: 10000 })
|
|
|
|
test("proceeds without error when systemDefaultModel is undefined", async () => {
|
|
// given a mock client with no model in config
|
|
const { createDelegateTask } = require("./tools")
|
|
|
|
const mockManager = { launch: async () => ({ id: "task-123", status: "pending", description: "Test task", agent: "sisyphus-junior", sessionID: "test-session" }) }
|
|
const mockClient = {
|
|
app: { agents: async () => ({ data: [] }) },
|
|
config: { get: async () => ({}) }, // No model configured
|
|
provider: { list: async () => ({ data: { connected: ["openai"] } }) },
|
|
model: { list: async () => ({ data: [{ provider: "openai", id: "gpt-5.3-codex" }] }) },
|
|
session: {
|
|
create: async () => ({ data: { id: "test-session" } }),
|
|
prompt: async () => ({ data: {} }),
|
|
promptAsync: async () => ({ data: {} }),
|
|
messages: async () => ({ data: [] }),
|
|
status: async () => ({ data: {} }),
|
|
},
|
|
}
|
|
|
|
const tool = createDelegateTask({
|
|
manager: mockManager,
|
|
client: mockClient,
|
|
connectedProvidersOverride: TEST_CONNECTED_PROVIDERS,
|
|
availableModelsOverride: createTestAvailableModels(),
|
|
})
|
|
|
|
const toolContext = {
|
|
sessionID: "parent-session",
|
|
messageID: "parent-message",
|
|
agent: "sisyphus",
|
|
abort: new AbortController().signal,
|
|
}
|
|
|
|
// when delegating with a category
|
|
const result = await tool.execute(
|
|
{
|
|
description: "Test task",
|
|
prompt: "Do something",
|
|
category: "ultrabrain",
|
|
run_in_background: true,
|
|
load_skills: [],
|
|
},
|
|
toolContext
|
|
)
|
|
|
|
// then proceeds without error - uses fallback chain
|
|
expect(result).not.toContain("oh-my-opencode requires a default model")
|
|
}, { timeout: 10000 })
|
|
|
|
test("returns clear error when no model can be resolved", async () => {
|
|
// given - custom category with no model, no systemDefaultModel, no available models
|
|
const { createDelegateTask } = require("./tools")
|
|
|
|
const mockManager = { launch: async () => ({ id: "task-123" }) }
|
|
const mockClient = {
|
|
app: { agents: async () => ({ data: [] }) },
|
|
config: { get: async () => ({}) }, // No model configured
|
|
model: { list: async () => [] }, // No available models
|
|
session: {
|
|
create: async () => ({ data: { id: "test-session" } }),
|
|
prompt: async () => ({ data: {} }),
|
|
promptAsync: async () => ({ data: {} }),
|
|
messages: async () => ({ data: [] }),
|
|
},
|
|
}
|
|
|
|
// Custom category with no model defined
|
|
const tool = createDelegateTask({
|
|
manager: mockManager,
|
|
client: mockClient,
|
|
userCategories: {
|
|
"custom-no-model": { temperature: 0.5 }, // No model field
|
|
},
|
|
})
|
|
|
|
const toolContext = {
|
|
sessionID: "parent-session",
|
|
messageID: "parent-message",
|
|
agent: "sisyphus",
|
|
abort: new AbortController().signal,
|
|
}
|
|
|
|
// when delegating with a custom category that has no model
|
|
const result = await tool.execute(
|
|
{
|
|
description: "Test task",
|
|
prompt: "Do something",
|
|
category: "custom-no-model",
|
|
run_in_background: true,
|
|
load_skills: [],
|
|
},
|
|
toolContext
|
|
)
|
|
|
|
// then returns clear error message with configuration guidance
|
|
expect(result).toContain("Model not configured")
|
|
expect(result).toContain("custom-no-model")
|
|
expect(result).toContain("Configure in one of")
|
|
})
|
|
})
|
|
|
|
describe("background metadata sessionId", () => {
|
|
test("should wait for background sessionId and set metadata for TUI toolcall counting", async () => {
|
|
//#given - manager.launch returns before sessionID is available
|
|
const { createDelegateTask } = require("./tools")
|
|
|
|
const tasks = new Map<string, { id: string; sessionID?: string; status: string; description: string; agent: string }>()
|
|
const mockManager = {
|
|
getTask: (id: string) => tasks.get(id),
|
|
launch: async () => {
|
|
const task = { id: "bg_1", status: "pending", description: "Test task", agent: "explore" }
|
|
tasks.set(task.id, task)
|
|
setTimeout(() => {
|
|
tasks.set(task.id, { ...task, status: "running", sessionID: "ses_child" })
|
|
}, 20)
|
|
return task
|
|
},
|
|
}
|
|
|
|
const mockClient = {
|
|
app: { agents: async () => ({ data: [{ name: "explore", mode: "subagent" }] }) },
|
|
config: { get: async () => ({}) },
|
|
provider: { list: async () => ({ data: { connected: ["openai"] } }) },
|
|
model: { list: async () => ({ data: [{ provider: "openai", id: "gpt-5.3-codex" }] }) },
|
|
session: {
|
|
create: async () => ({ data: { id: "test-session" } }),
|
|
prompt: async () => ({ data: {} }),
|
|
promptAsync: async () => ({ data: {} }),
|
|
messages: async () => ({ data: [] }),
|
|
status: async () => ({ data: {} }),
|
|
},
|
|
}
|
|
|
|
const tool = createDelegateTask({
|
|
manager: mockManager,
|
|
client: mockClient,
|
|
connectedProvidersOverride: TEST_CONNECTED_PROVIDERS,
|
|
availableModelsOverride: createTestAvailableModels(),
|
|
})
|
|
|
|
const metadataCalls: Array<{ title?: string; metadata?: Record<string, unknown> }> = []
|
|
const toolContext = {
|
|
sessionID: "parent-session",
|
|
messageID: "parent-message",
|
|
agent: "sisyphus",
|
|
abort: new AbortController().signal,
|
|
metadata: (input: { title?: string; metadata?: Record<string, unknown> }) => {
|
|
metadataCalls.push(input)
|
|
},
|
|
}
|
|
|
|
const args = {
|
|
description: "Explore task",
|
|
prompt: "Explore features directory deeply",
|
|
subagent_type: "explore",
|
|
run_in_background: true,
|
|
load_skills: [],
|
|
}
|
|
|
|
//#when
|
|
const result = await tool.execute(args, toolContext)
|
|
|
|
//#then - metadata should include sessionId (camelCase) once it's available
|
|
expect(String(result)).toContain("Background task launched")
|
|
const sessionIdCall = metadataCalls.find((c) => c.metadata?.sessionId === "ses_child")
|
|
expect(sessionIdCall).toBeDefined()
|
|
})
|
|
})
|
|
|
|
describe("resolveCategoryConfig", () => {
|
|
test("returns null for unknown category without user config", () => {
|
|
// given
|
|
const categoryName = "unknown-category"
|
|
|
|
// when
|
|
const result = resolveCategoryConfig(categoryName, { systemDefaultModel: SYSTEM_DEFAULT_MODEL })
|
|
|
|
// then
|
|
expect(result).toBeNull()
|
|
})
|
|
|
|
test("blocks requiresModel when availability is known and missing the required model", () => {
|
|
// given - artistry has requiresModel: gemini-3.1-pro
|
|
const categoryName = "artistry"
|
|
const availableModels = new Set<string>(["anthropic/claude-opus-4-6"])
|
|
|
|
// when
|
|
const result = resolveCategoryConfig(categoryName, {
|
|
systemDefaultModel: SYSTEM_DEFAULT_MODEL,
|
|
availableModels,
|
|
})
|
|
|
|
// then
|
|
expect(result).toBeNull()
|
|
})
|
|
|
|
test("blocks requiresModel when availability is empty", () => {
|
|
// given - artistry has requiresModel: gemini-3.1-pro
|
|
const categoryName = "artistry"
|
|
const availableModels = new Set<string>()
|
|
|
|
// when
|
|
const result = resolveCategoryConfig(categoryName, {
|
|
systemDefaultModel: SYSTEM_DEFAULT_MODEL,
|
|
availableModels,
|
|
})
|
|
|
|
// then
|
|
expect(result).toBeNull()
|
|
})
|
|
|
|
test("bypasses requiresModel when explicit user config provided", () => {
|
|
// #given
|
|
const categoryName = "deep"
|
|
const availableModels = new Set<string>(["anthropic/claude-opus-4-6"])
|
|
const userCategories = {
|
|
deep: { model: "anthropic/claude-opus-4-6" },
|
|
}
|
|
|
|
// #when
|
|
const result = resolveCategoryConfig(categoryName, {
|
|
systemDefaultModel: SYSTEM_DEFAULT_MODEL,
|
|
availableModels,
|
|
userCategories,
|
|
})
|
|
|
|
// #then
|
|
expect(result).not.toBeNull()
|
|
expect(result!.config.model).toBe("anthropic/claude-opus-4-6")
|
|
})
|
|
|
|
test("bypasses requiresModel when explicit user config provided even with empty availability", () => {
|
|
// #given
|
|
const categoryName = "deep"
|
|
const availableModels = new Set<string>()
|
|
const userCategories = {
|
|
deep: { model: "anthropic/claude-opus-4-6" },
|
|
}
|
|
|
|
// #when
|
|
const result = resolveCategoryConfig(categoryName, {
|
|
systemDefaultModel: SYSTEM_DEFAULT_MODEL,
|
|
availableModels,
|
|
userCategories,
|
|
})
|
|
|
|
// #then
|
|
expect(result).not.toBeNull()
|
|
expect(result!.config.model).toBe("anthropic/claude-opus-4-6")
|
|
})
|
|
|
|
test("returns default model from DEFAULT_CATEGORIES for builtin category", () => {
|
|
// given
|
|
const categoryName = "visual-engineering"
|
|
|
|
// when
|
|
const result = resolveCategoryConfig(categoryName, { systemDefaultModel: SYSTEM_DEFAULT_MODEL })
|
|
|
|
// then
|
|
expect(result).not.toBeNull()
|
|
expect(result!.config.model).toBe("google/gemini-3.1-pro")
|
|
expect(result!.promptAppend).toContain("VISUAL/UI")
|
|
})
|
|
|
|
test("user config overrides systemDefaultModel", () => {
|
|
// given
|
|
const categoryName = "visual-engineering"
|
|
const userCategories = {
|
|
"visual-engineering": { model: "anthropic/claude-opus-4-6" },
|
|
}
|
|
|
|
// when
|
|
const result = resolveCategoryConfig(categoryName, { userCategories, systemDefaultModel: SYSTEM_DEFAULT_MODEL })
|
|
|
|
// then
|
|
expect(result).not.toBeNull()
|
|
expect(result!.config.model).toBe("anthropic/claude-opus-4-6")
|
|
})
|
|
|
|
test("user prompt_append is appended to default", () => {
|
|
// given
|
|
const categoryName = "visual-engineering"
|
|
const userCategories = {
|
|
"visual-engineering": {
|
|
model: "google/gemini-3.1-pro",
|
|
prompt_append: "Custom instructions here",
|
|
},
|
|
}
|
|
|
|
// when
|
|
const result = resolveCategoryConfig(categoryName, { userCategories, systemDefaultModel: SYSTEM_DEFAULT_MODEL })
|
|
|
|
// then
|
|
expect(result).not.toBeNull()
|
|
expect(result!.promptAppend).toContain("VISUAL/UI")
|
|
expect(result!.promptAppend).toContain("Custom instructions here")
|
|
})
|
|
|
|
test("user can define custom category", () => {
|
|
// given
|
|
const categoryName = "my-custom"
|
|
const userCategories = {
|
|
"my-custom": {
|
|
model: "openai/gpt-5.4",
|
|
temperature: 0.5,
|
|
prompt_append: "You are a custom agent",
|
|
},
|
|
}
|
|
|
|
// when
|
|
const result = resolveCategoryConfig(categoryName, { userCategories, systemDefaultModel: SYSTEM_DEFAULT_MODEL })
|
|
|
|
// then
|
|
expect(result).not.toBeNull()
|
|
expect(result!.config.model).toBe("openai/gpt-5.4")
|
|
expect(result!.config.temperature).toBe(0.5)
|
|
expect(result!.promptAppend).toBe("You are a custom agent")
|
|
})
|
|
|
|
test("user category overrides temperature", () => {
|
|
// given
|
|
const categoryName = "visual-engineering"
|
|
const userCategories = {
|
|
"visual-engineering": {
|
|
model: "google/gemini-3.1-pro",
|
|
temperature: 0.3,
|
|
},
|
|
}
|
|
|
|
// when
|
|
const result = resolveCategoryConfig(categoryName, { userCategories, systemDefaultModel: SYSTEM_DEFAULT_MODEL })
|
|
|
|
// then
|
|
expect(result).not.toBeNull()
|
|
expect(result!.config.temperature).toBe(0.3)
|
|
})
|
|
|
|
test("category built-in model takes precedence over inheritedModel", () => {
|
|
// given - builtin category with its own model, parent model also provided
|
|
const categoryName = "visual-engineering"
|
|
const inheritedModel = "cliproxy/claude-opus-4-6"
|
|
|
|
// when
|
|
const result = resolveCategoryConfig(categoryName, { inheritedModel, systemDefaultModel: SYSTEM_DEFAULT_MODEL })
|
|
|
|
// then - category's built-in model wins over inheritedModel
|
|
expect(result).not.toBeNull()
|
|
expect(result!.config.model).toBe("google/gemini-3.1-pro")
|
|
})
|
|
|
|
test("systemDefaultModel is used as fallback when custom category has no model", () => {
|
|
// given - custom category with no model defined
|
|
const categoryName = "my-custom-no-model"
|
|
const userCategories = { "my-custom-no-model": { temperature: 0.5 } } as unknown as Record<string, CategoryConfig>
|
|
const inheritedModel = "cliproxy/claude-opus-4-6"
|
|
|
|
// when
|
|
const result = resolveCategoryConfig(categoryName, { userCategories, inheritedModel, systemDefaultModel: SYSTEM_DEFAULT_MODEL })
|
|
|
|
// then - systemDefaultModel is used since custom category has no built-in model
|
|
expect(result).not.toBeNull()
|
|
expect(result!.config.model).toBe(SYSTEM_DEFAULT_MODEL)
|
|
})
|
|
|
|
test("user model takes precedence over inheritedModel", () => {
|
|
// given
|
|
const categoryName = "visual-engineering"
|
|
const userCategories = {
|
|
"visual-engineering": { model: "my-provider/my-model" },
|
|
}
|
|
const inheritedModel = "cliproxy/claude-opus-4-6"
|
|
|
|
// when
|
|
const result = resolveCategoryConfig(categoryName, { userCategories, inheritedModel, systemDefaultModel: SYSTEM_DEFAULT_MODEL })
|
|
|
|
// then
|
|
expect(result).not.toBeNull()
|
|
expect(result!.config.model).toBe("my-provider/my-model")
|
|
})
|
|
|
|
test("default model from category config is used when no user model and no inheritedModel", () => {
|
|
// given
|
|
const categoryName = "visual-engineering"
|
|
|
|
// when
|
|
const result = resolveCategoryConfig(categoryName, { systemDefaultModel: SYSTEM_DEFAULT_MODEL })
|
|
|
|
// then
|
|
expect(result).not.toBeNull()
|
|
expect(result!.config.model).toBe("google/gemini-3.1-pro")
|
|
})
|
|
})
|
|
|
|
describe("category variant", () => {
|
|
test("passes variant to background model payload", async () => {
|
|
// given
|
|
const { createDelegateTask } = require("./tools")
|
|
let launchInput: any
|
|
|
|
const mockManager = {
|
|
launch: async (input: any) => {
|
|
launchInput = input
|
|
return {
|
|
id: "task-variant",
|
|
sessionID: "session-variant",
|
|
description: "Variant task",
|
|
agent: "sisyphus-junior",
|
|
status: "running",
|
|
}
|
|
},
|
|
}
|
|
|
|
const mockClient = {
|
|
app: { agents: async () => ({ data: [] }) },
|
|
config: { get: async () => ({ 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,
|
|
userCategories: {
|
|
ultrabrain: { model: "openai/gpt-5.4", variant: "xhigh" },
|
|
},
|
|
connectedProvidersOverride: TEST_CONNECTED_PROVIDERS,
|
|
availableModelsOverride: createTestAvailableModels(),
|
|
})
|
|
|
|
const toolContext = {
|
|
sessionID: "parent-session",
|
|
messageID: "parent-message",
|
|
agent: "sisyphus",
|
|
abort: new AbortController().signal,
|
|
}
|
|
|
|
// when
|
|
await tool.execute(
|
|
{
|
|
description: "Variant task",
|
|
prompt: "Do something",
|
|
category: "ultrabrain",
|
|
run_in_background: true,
|
|
load_skills: ["git-master"],
|
|
},
|
|
toolContext
|
|
)
|
|
|
|
// then
|
|
expect(launchInput.model).toEqual({
|
|
providerID: "openai",
|
|
modelID: "gpt-5.4",
|
|
variant: "xhigh",
|
|
})
|
|
})
|
|
|
|
test("DEFAULT_CATEGORIES explicit high model passes to background WITHOUT userCategories", async () => {
|
|
// given - NO userCategories, testing DEFAULT_CATEGORIES only
|
|
const { createDelegateTask } = require("./tools")
|
|
let launchInput: any
|
|
|
|
const mockManager = {
|
|
launch: async (input: any) => {
|
|
launchInput = input
|
|
return {
|
|
id: "task-default-variant",
|
|
sessionID: "session-default-variant",
|
|
description: "Default variant task",
|
|
agent: "sisyphus-junior",
|
|
status: "running",
|
|
}
|
|
},
|
|
}
|
|
|
|
const mockClient = {
|
|
app: { agents: async () => ({ data: [] }) },
|
|
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
|
model: { list: async () => [{ provider: "anthropic", id: "claude-opus-4-6" }] },
|
|
session: {
|
|
create: async () => ({ data: { id: "test-session" } }),
|
|
prompt: async () => ({ data: {} }),
|
|
promptAsync: async () => ({ data: {} }),
|
|
messages: async () => ({ data: [] }),
|
|
},
|
|
}
|
|
|
|
// NO userCategories - must use DEFAULT_CATEGORIES
|
|
const tool = createDelegateTask({
|
|
manager: mockManager,
|
|
client: mockClient,
|
|
connectedProvidersOverride: TEST_CONNECTED_PROVIDERS,
|
|
availableModelsOverride: createTestAvailableModels(),
|
|
})
|
|
|
|
const toolContext = {
|
|
sessionID: "parent-session",
|
|
messageID: "parent-message",
|
|
agent: "sisyphus",
|
|
abort: new AbortController().signal,
|
|
}
|
|
|
|
// when - unspecified-high uses claude-opus-4-6 max in DEFAULT_CATEGORIES
|
|
await tool.execute(
|
|
{
|
|
description: "Test unspecified-high default variant",
|
|
prompt: "Do something",
|
|
category: "unspecified-high",
|
|
run_in_background: true,
|
|
load_skills: ["git-master"],
|
|
},
|
|
toolContext
|
|
)
|
|
|
|
// then - claude-opus-4-6 should be passed with max variant
|
|
expect(launchInput.model).toEqual({
|
|
providerID: "anthropic",
|
|
modelID: "claude-opus-4-6",
|
|
variant: "max",
|
|
})
|
|
}, { timeout: 20000 })
|
|
|
|
test("DEFAULT_CATEGORIES explicit high model passes to sync session.prompt WITHOUT userCategories", async () => {
|
|
// given - NO userCategories, testing DEFAULT_CATEGORIES for sync mode
|
|
const { createDelegateTask } = require("./tools")
|
|
let promptBody: any
|
|
|
|
const mockManager = { launch: async () => ({}) }
|
|
|
|
const promptMock = async (input: any) => {
|
|
promptBody = input.body
|
|
return { data: {} }
|
|
}
|
|
|
|
const mockClient = {
|
|
app: { agents: async () => ({ data: [] }) },
|
|
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
|
model: { list: async () => [{ provider: "anthropic", id: "claude-opus-4-6" }] },
|
|
session: {
|
|
get: async () => ({ data: { directory: "/project" } }),
|
|
create: async () => ({ data: { id: "ses_sync_default_variant" } }),
|
|
prompt: promptMock,
|
|
promptAsync: promptMock,
|
|
messages: async () => ({
|
|
data: [{ info: { role: "assistant" }, parts: [{ type: "text", text: "done" }] }]
|
|
}),
|
|
status: async () => ({ data: { "ses_sync_default_variant": { type: "idle" } } }),
|
|
},
|
|
}
|
|
|
|
// NO userCategories - must use DEFAULT_CATEGORIES
|
|
const tool = createDelegateTask({
|
|
manager: mockManager,
|
|
client: mockClient,
|
|
})
|
|
|
|
const toolContext = {
|
|
sessionID: "parent-session",
|
|
messageID: "parent-message",
|
|
agent: "sisyphus",
|
|
abort: new AbortController().signal,
|
|
}
|
|
|
|
// when - unspecified-high uses claude-opus-4-6 max in DEFAULT_CATEGORIES
|
|
await tool.execute(
|
|
{
|
|
description: "Test unspecified-high sync variant",
|
|
prompt: "Do something",
|
|
category: "unspecified-high",
|
|
run_in_background: false,
|
|
load_skills: ["git-master"],
|
|
},
|
|
toolContext
|
|
)
|
|
|
|
// then - claude-opus-4-6 should be passed with max variant
|
|
expect(promptBody.model).toEqual({
|
|
providerID: "anthropic",
|
|
modelID: "claude-opus-4-6",
|
|
})
|
|
expect(promptBody.variant).toBe("max")
|
|
}, { timeout: 20000 })
|
|
})
|
|
|
|
describe("skills parameter", () => {
|
|
test("skills parameter is required - throws error when not provided", async () => {
|
|
// given
|
|
const { createDelegateTask } = require("./tools")
|
|
|
|
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,
|
|
})
|
|
|
|
const toolContext = {
|
|
sessionID: "parent-session",
|
|
messageID: "parent-message",
|
|
agent: "sisyphus",
|
|
abort: new AbortController().signal,
|
|
}
|
|
|
|
// when - skills not provided (undefined)
|
|
// then - should throw error about missing skills
|
|
await expect(tool.execute(
|
|
{
|
|
description: "Test task",
|
|
prompt: "Do something",
|
|
category: "ultrabrain",
|
|
run_in_background: false,
|
|
},
|
|
toolContext
|
|
)).rejects.toThrow("Invalid arguments: 'load_skills' parameter is REQUIRED")
|
|
})
|
|
|
|
test("null skills throws error", async () => {
|
|
// given
|
|
const { createDelegateTask } = require("./tools")
|
|
|
|
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,
|
|
})
|
|
|
|
const toolContext = {
|
|
sessionID: "parent-session",
|
|
messageID: "parent-message",
|
|
agent: "sisyphus",
|
|
abort: new AbortController().signal,
|
|
}
|
|
|
|
// when - null passed
|
|
// then - should throw error about null
|
|
await expect(tool.execute(
|
|
{
|
|
description: "Test task",
|
|
prompt: "Do something",
|
|
category: "ultrabrain",
|
|
run_in_background: false,
|
|
load_skills: null,
|
|
},
|
|
toolContext
|
|
)).rejects.toThrow("Invalid arguments: load_skills=null is not allowed")
|
|
})
|
|
|
|
test("empty array [] is allowed and proceeds without skill content", async () => {
|
|
// given
|
|
const { createDelegateTask } = require("./tools")
|
|
let promptBody: any
|
|
|
|
const mockManager = { launch: async () => ({}) }
|
|
|
|
const promptMock = async (input: any) => {
|
|
promptBody = input.body
|
|
return { data: {} }
|
|
}
|
|
|
|
const mockClient = {
|
|
app: { agents: async () => ({ data: [] }) },
|
|
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
|
session: {
|
|
get: async () => ({ data: { directory: "/project" } }),
|
|
create: async () => ({ data: { id: "test-session" } }),
|
|
prompt: promptMock,
|
|
promptAsync: promptMock,
|
|
messages: async () => ({
|
|
data: [{ info: { role: "assistant" }, parts: [{ type: "text", text: "Done" }] }]
|
|
}),
|
|
status: async () => ({ data: {} }),
|
|
},
|
|
}
|
|
|
|
const tool = createDelegateTask({
|
|
manager: mockManager,
|
|
client: mockClient,
|
|
})
|
|
|
|
const toolContext = {
|
|
sessionID: "parent-session",
|
|
messageID: "parent-message",
|
|
agent: "sisyphus",
|
|
abort: new AbortController().signal,
|
|
}
|
|
|
|
// when - empty array passed
|
|
await tool.execute(
|
|
{
|
|
description: "Test task",
|
|
prompt: "Do something",
|
|
category: "ultrabrain",
|
|
run_in_background: false,
|
|
load_skills: [],
|
|
},
|
|
toolContext
|
|
)
|
|
|
|
// then - should proceed without system content from skills
|
|
expect(promptBody).toBeDefined()
|
|
}, { timeout: 20000 })
|
|
})
|
|
|
|
describe("run_in_background parameter", () => {
|
|
test("#given category without run_in_background #when executing #then throws required parameter error", async () => {
|
|
// given
|
|
const { createDelegateTask } = require("./tools")
|
|
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
|
|
// then
|
|
await expect(tool.execute(
|
|
{
|
|
description: "Category without run flag",
|
|
prompt: "Do something",
|
|
category: "quick",
|
|
load_skills: [],
|
|
},
|
|
{ sessionID: "parent-session", messageID: "parent-message", agent: "sisyphus", abort: new AbortController().signal }
|
|
)).rejects.toThrow("Invalid arguments: 'run_in_background' parameter is REQUIRED")
|
|
})
|
|
|
|
test("#given subagent_type without run_in_background #when executing #then throws required parameter error", async () => {
|
|
// given
|
|
const { createDelegateTask } = require("./tools")
|
|
const mockManager = { launch: async () => ({}) }
|
|
const mockClient = {
|
|
app: { agents: async () => ({ data: [{ name: "explore", mode: "subagent" }] }) },
|
|
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
|
|
// then
|
|
await expect(tool.execute(
|
|
{
|
|
description: "Subagent without run flag",
|
|
prompt: "Find patterns",
|
|
subagent_type: "explore",
|
|
load_skills: [],
|
|
},
|
|
{ sessionID: "parent-session", messageID: "parent-message", agent: "sisyphus", abort: new AbortController().signal }
|
|
)).rejects.toThrow("Invalid arguments: 'run_in_background' parameter is REQUIRED")
|
|
})
|
|
|
|
test("#given session_id without run_in_background #when executing #then throws required parameter error", async () => {
|
|
// given
|
|
const { createDelegateTask } = require("./tools")
|
|
const mockManager = { resume: async () => ({ id: "task-1", sessionID: "ses_1", status: "running" }) }
|
|
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
|
|
// then
|
|
await expect(tool.execute(
|
|
{
|
|
description: "Continue without run flag",
|
|
prompt: "Continue",
|
|
session_id: "ses_existing",
|
|
load_skills: [],
|
|
},
|
|
{ sessionID: "parent-session", messageID: "parent-message", agent: "sisyphus", abort: new AbortController().signal }
|
|
)).rejects.toThrow("Invalid arguments: 'run_in_background' parameter is REQUIRED")
|
|
})
|
|
|
|
test("#given no category no subagent_type no session_id and no run_in_background #when executing #then throws required parameter error", async () => {
|
|
// given
|
|
const { createDelegateTask } = require("./tools")
|
|
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
|
|
// then
|
|
await expect(tool.execute(
|
|
{
|
|
description: "Missing required args",
|
|
prompt: "Do something",
|
|
load_skills: [],
|
|
},
|
|
{ sessionID: "parent-session", messageID: "parent-message", agent: "sisyphus", abort: new AbortController().signal }
|
|
)).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")
|
|
let promptCalled = false
|
|
const mockManager = { launch: async () => ({}) }
|
|
const mockClient = {
|
|
app: { agents: async () => ({ data: [{ name: "oracle", mode: "subagent", model: { providerID: "anthropic", modelID: "claude-opus-4-6" } }] }) },
|
|
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
|
session: {
|
|
get: async () => ({ data: { directory: "/project" } }),
|
|
create: async () => ({ data: { id: "ses_explicit_false" } }),
|
|
prompt: async () => {
|
|
promptCalled = true
|
|
return { data: {} }
|
|
},
|
|
promptAsync: async () => {
|
|
promptCalled = true
|
|
return { data: {} }
|
|
},
|
|
messages: async () => ({ data: [{ info: { role: "assistant" }, parts: [{ type: "text", text: "Done" }] }] }),
|
|
status: async () => ({ data: { ses_explicit_false: { type: "idle" } } }),
|
|
},
|
|
}
|
|
const tool = createDelegateTask({ manager: mockManager, client: mockClient })
|
|
|
|
// when
|
|
const result = await tool.execute(
|
|
{
|
|
description: "Explicit false",
|
|
prompt: "Run sync",
|
|
subagent_type: "oracle",
|
|
run_in_background: false,
|
|
load_skills: [],
|
|
},
|
|
{ sessionID: "parent-session", messageID: "parent-message", agent: "sisyphus", abort: new AbortController().signal }
|
|
)
|
|
|
|
// then
|
|
expect(promptCalled).toBe(true)
|
|
expect(result).toContain("Done")
|
|
}, { timeout: 10000 })
|
|
|
|
test("#given explicit run_in_background=true #when executing #then background execution succeeds", async () => {
|
|
// given
|
|
const { createDelegateTask } = require("./tools")
|
|
let launchCalled = false
|
|
const mockManager = {
|
|
launch: async () => {
|
|
launchCalled = true
|
|
return {
|
|
id: "bg_explicit_true",
|
|
sessionID: "ses_bg_explicit_true",
|
|
description: "Explicit true",
|
|
agent: "Sisyphus-Junior",
|
|
status: "running",
|
|
}
|
|
},
|
|
}
|
|
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 result = await tool.execute(
|
|
{
|
|
description: "Explicit true",
|
|
prompt: "Run background",
|
|
category: "quick",
|
|
run_in_background: true,
|
|
load_skills: [],
|
|
},
|
|
{ sessionID: "parent-session", messageID: "parent-message", agent: "sisyphus", abort: new AbortController().signal }
|
|
)
|
|
|
|
// then
|
|
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", () => {
|
|
test("session_id with background=false should wait for result and return content", async () => {
|
|
// Note: This test needs extended timeout because the implementation has MIN_STABILITY_TIME_MS = 5000
|
|
// given
|
|
const { createDelegateTask } = require("./tools")
|
|
|
|
const mockTask = {
|
|
id: "task-123",
|
|
sessionID: "ses_continue_test",
|
|
description: "Continued task",
|
|
agent: "explore",
|
|
status: "running",
|
|
}
|
|
|
|
const mockManager = {
|
|
resume: async () => mockTask,
|
|
launch: async () => mockTask,
|
|
}
|
|
|
|
let messagesCallCount = 0
|
|
|
|
const mockClient = {
|
|
session: {
|
|
prompt: async () => ({ data: {} }),
|
|
promptAsync: async () => ({ data: {} }),
|
|
messages: async (args?: { path?: { id?: string } }) => {
|
|
const sessionID = args?.path?.id
|
|
// Only track calls for the target session (ses_continue_test),
|
|
// not for parent-session calls from resolveParentContext
|
|
if (sessionID !== "ses_continue_test") {
|
|
return { data: [] }
|
|
}
|
|
messagesCallCount++
|
|
const now = Date.now()
|
|
|
|
const beforeContinuation = [
|
|
{
|
|
info: { id: "msg_001", role: "user", time: { created: now } },
|
|
parts: [{ type: "text", text: "Previous context" }],
|
|
},
|
|
{
|
|
info: { id: "msg_002", role: "assistant", time: { created: now + 1 }, finish: "end_turn" },
|
|
parts: [{ type: "text", text: "Previous result" }],
|
|
},
|
|
]
|
|
|
|
if (messagesCallCount === 1) {
|
|
return { data: beforeContinuation }
|
|
}
|
|
|
|
return {
|
|
data: [
|
|
...beforeContinuation,
|
|
{
|
|
info: { id: "msg_003", role: "user", time: { created: now + 2 } },
|
|
parts: [{ type: "text", text: "Continue the task" }],
|
|
},
|
|
{
|
|
info: { id: "msg_004", role: "assistant", time: { created: now + 3 }, finish: "end_turn" },
|
|
parts: [{ type: "text", text: "This is the continued task result" }],
|
|
},
|
|
],
|
|
}
|
|
},
|
|
status: async () => ({ data: { "ses_continue_test": { type: "idle" } } }),
|
|
},
|
|
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
|
app: {
|
|
agents: async () => ({ data: [] }),
|
|
},
|
|
}
|
|
|
|
const tool = createDelegateTask({
|
|
manager: mockManager,
|
|
client: mockClient,
|
|
})
|
|
|
|
const toolContext = {
|
|
sessionID: "parent-session",
|
|
messageID: "parent-message",
|
|
agent: "sisyphus",
|
|
abort: new AbortController().signal,
|
|
}
|
|
|
|
// when
|
|
const result = await tool.execute(
|
|
{
|
|
description: "Continue test",
|
|
prompt: "Continue the task",
|
|
session_id: "ses_continue_test",
|
|
run_in_background: false,
|
|
load_skills: ["git-master"],
|
|
},
|
|
toolContext
|
|
)
|
|
|
|
// then - should contain actual result, not just "Background task continued"
|
|
expect(result).toContain("This is the continued task result")
|
|
expect(result).not.toContain("Background task continued")
|
|
}, { timeout: 10000 })
|
|
|
|
test("sync continuation preserves variant from previous session message", async () => {
|
|
//#given a session with a previous message that has variant "max"
|
|
const { createDelegateTask } = require("./tools")
|
|
|
|
const promptMock = mock(async (input: any) => {
|
|
return { data: {} }
|
|
})
|
|
|
|
const baseTime = Date.now()
|
|
const initialMessages = [
|
|
{
|
|
info: {
|
|
id: "msg_001",
|
|
role: "user",
|
|
agent: "sisyphus-junior",
|
|
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
|
|
variant: "max",
|
|
time: { created: baseTime },
|
|
},
|
|
parts: [{ type: "text", text: "previous message" }],
|
|
},
|
|
{
|
|
info: { id: "msg_002", role: "assistant", time: { created: baseTime + 1 }, finish: "end_turn" },
|
|
parts: [{ type: "text", text: "Completed." }],
|
|
},
|
|
]
|
|
|
|
const messagesCallCounts: Record<string, number> = {}
|
|
|
|
const mockClient = {
|
|
session: {
|
|
prompt: promptMock,
|
|
promptAsync: promptMock,
|
|
messages: async (input: any) => {
|
|
const sessionID = input?.path?.id
|
|
if (typeof sessionID !== "string") {
|
|
return { data: [] }
|
|
}
|
|
|
|
const callCount = (messagesCallCounts[sessionID] ?? 0) + 1
|
|
messagesCallCounts[sessionID] = callCount
|
|
|
|
if (sessionID !== "ses_var_test") {
|
|
return { data: [] }
|
|
}
|
|
|
|
if (callCount === 1) {
|
|
return { data: initialMessages }
|
|
}
|
|
|
|
return {
|
|
data: [
|
|
...initialMessages,
|
|
{
|
|
info: { id: "msg_003", role: "assistant", time: { created: baseTime + 2 }, finish: "end_turn" },
|
|
parts: [{ type: "text", text: "Continued." }],
|
|
},
|
|
],
|
|
}
|
|
},
|
|
status: async () => ({ data: { "ses_var_test": { type: "idle" } } }),
|
|
},
|
|
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
|
app: {
|
|
agents: async () => ({ data: [] }),
|
|
},
|
|
}
|
|
|
|
const tool = createDelegateTask({
|
|
manager: { resume: async () => ({ id: "task-var", sessionID: "ses_var_test", description: "Variant test", agent: "sisyphus-junior", status: "running" }) },
|
|
client: mockClient,
|
|
})
|
|
|
|
const toolContext = {
|
|
sessionID: "parent-session",
|
|
messageID: "parent-message",
|
|
agent: "sisyphus",
|
|
abort: new AbortController().signal,
|
|
}
|
|
|
|
//#when continuing the session
|
|
await tool.execute(
|
|
{
|
|
description: "Continue with variant",
|
|
prompt: "Continue the task",
|
|
session_id: "ses_var_test",
|
|
run_in_background: false,
|
|
load_skills: [],
|
|
},
|
|
toolContext
|
|
)
|
|
|
|
//#then prompt should include variant from previous message
|
|
expect(promptMock).toHaveBeenCalled()
|
|
const callArgs = promptMock.mock.calls[0][0]
|
|
expect(callArgs.body.variant).toBe("max")
|
|
expect(callArgs.body.agent).toBe("sisyphus-junior")
|
|
expect(callArgs.body.model).toEqual({ providerID: "anthropic", modelID: "claude-opus-4-6" })
|
|
}, { timeout: 10000 })
|
|
|
|
test("session_id with background=true should return immediately without waiting", async () => {
|
|
// given
|
|
const { createDelegateTask } = require("./tools")
|
|
|
|
const mockTask = {
|
|
id: "task-456",
|
|
sessionID: "ses_bg_continue",
|
|
description: "Background continued task",
|
|
agent: "explore",
|
|
status: "running",
|
|
}
|
|
|
|
const mockManager = {
|
|
resume: async () => mockTask,
|
|
}
|
|
|
|
const mockClient = {
|
|
session: {
|
|
prompt: async () => ({ data: {} }),
|
|
promptAsync: async () => ({ data: {} }),
|
|
messages: async () => ({
|
|
data: [],
|
|
}),
|
|
},
|
|
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
|
}
|
|
|
|
const tool = createDelegateTask({
|
|
manager: mockManager,
|
|
client: mockClient,
|
|
})
|
|
|
|
const toolContext = {
|
|
sessionID: "parent-session",
|
|
messageID: "parent-message",
|
|
agent: "sisyphus",
|
|
abort: new AbortController().signal,
|
|
}
|
|
|
|
// when
|
|
const result = await tool.execute(
|
|
{
|
|
description: "Continue bg test",
|
|
prompt: "Continue in background",
|
|
session_id: "ses_bg_continue",
|
|
run_in_background: true,
|
|
load_skills: ["git-master"],
|
|
},
|
|
toolContext
|
|
)
|
|
|
|
// then - should return background message
|
|
expect(result).toContain("Background task continued")
|
|
expect(result).toContain("task-456")
|
|
})
|
|
})
|
|
|
|
describe("sync mode new task (run_in_background=false)", () => {
|
|
test("sync mode prompt error returns error message immediately", async () => {
|
|
// given
|
|
const { createDelegateTask } = require("./tools")
|
|
|
|
const mockManager = {
|
|
launch: async () => ({}),
|
|
}
|
|
|
|
const promptMock = async () => {
|
|
throw new Error("JSON Parse error: Unexpected EOF")
|
|
}
|
|
|
|
const mockClient = {
|
|
session: {
|
|
get: async () => ({ data: { directory: "/project" } }),
|
|
create: async () => ({ data: { id: "ses_sync_error_test" } }),
|
|
prompt: promptMock,
|
|
promptAsync: promptMock,
|
|
messages: async () => ({ data: [] }),
|
|
status: async () => ({ data: {} }),
|
|
},
|
|
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
|
app: {
|
|
agents: async () => ({ data: [{ name: "ultrabrain", mode: "subagent" }] }),
|
|
},
|
|
}
|
|
|
|
const tool = createDelegateTask({
|
|
manager: mockManager,
|
|
client: mockClient,
|
|
})
|
|
|
|
const toolContext = {
|
|
sessionID: "parent-session",
|
|
messageID: "parent-message",
|
|
agent: "sisyphus",
|
|
abort: new AbortController().signal,
|
|
}
|
|
|
|
// when
|
|
const result = await tool.execute(
|
|
{
|
|
description: "Sync error test",
|
|
prompt: "Do something",
|
|
category: "ultrabrain",
|
|
run_in_background: false,
|
|
load_skills: ["git-master"],
|
|
},
|
|
toolContext
|
|
)
|
|
|
|
// then - should return detailed error message with args and stack trace
|
|
expect(result).toContain("Send prompt failed")
|
|
expect(result).toContain("JSON Parse error")
|
|
expect(result).toContain("**Arguments**:")
|
|
expect(result).toContain("**Stack Trace**:")
|
|
})
|
|
|
|
test("sync mode success returns task result with content", async () => {
|
|
// given
|
|
const { createDelegateTask } = require("./tools")
|
|
|
|
const mockManager = {
|
|
launch: async () => ({}),
|
|
}
|
|
|
|
const mockClient = {
|
|
session: {
|
|
get: async () => ({ data: { directory: "/project" } }),
|
|
create: async () => ({ data: { id: "ses_sync_success" } }),
|
|
prompt: async () => ({ data: {} }),
|
|
promptAsync: async () => ({ data: {} }),
|
|
messages: async () => ({
|
|
data: [
|
|
{
|
|
info: { id: "msg_001", role: "user", time: { created: Date.now() } },
|
|
parts: [{ type: "text", text: "Do something" }],
|
|
},
|
|
{
|
|
info: { id: "msg_002", role: "assistant", time: { created: Date.now() + 1 }, finish: "end_turn" },
|
|
parts: [{ type: "text", text: "Sync task completed successfully" }],
|
|
},
|
|
],
|
|
}),
|
|
status: async () => ({ data: { "ses_sync_success": { type: "idle" } } }),
|
|
},
|
|
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
|
app: {
|
|
agents: async () => ({ data: [{ name: "ultrabrain", mode: "subagent" }] }),
|
|
},
|
|
}
|
|
|
|
const tool = createDelegateTask({
|
|
manager: mockManager,
|
|
client: mockClient,
|
|
})
|
|
|
|
const toolContext = {
|
|
sessionID: "parent-session",
|
|
messageID: "parent-message",
|
|
agent: "sisyphus",
|
|
abort: new AbortController().signal,
|
|
}
|
|
|
|
// when
|
|
const result = await tool.execute(
|
|
{
|
|
description: "Sync success test",
|
|
prompt: "Do something",
|
|
category: "ultrabrain",
|
|
run_in_background: false,
|
|
load_skills: ["git-master"],
|
|
},
|
|
toolContext
|
|
)
|
|
|
|
// then - should return the task result content
|
|
expect(result).toContain("Sync task completed successfully")
|
|
expect(result).toContain("Task completed")
|
|
}, { timeout: 20000 })
|
|
|
|
test("sync mode agent not found returns helpful error", async () => {
|
|
// given
|
|
const { createDelegateTask } = require("./tools")
|
|
|
|
const mockManager = {
|
|
launch: async () => ({}),
|
|
}
|
|
|
|
const promptMock = async () => {
|
|
throw new Error("Cannot read property 'name' of undefined agent.name")
|
|
}
|
|
|
|
const mockClient = {
|
|
session: {
|
|
get: async () => ({ data: { directory: "/project" } }),
|
|
create: async () => ({ data: { id: "ses_agent_notfound" } }),
|
|
prompt: promptMock,
|
|
promptAsync: promptMock,
|
|
messages: async () => ({ data: [] }),
|
|
status: async () => ({ data: {} }),
|
|
},
|
|
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
|
app: {
|
|
agents: async () => ({ data: [{ name: "ultrabrain", mode: "subagent" }] }),
|
|
},
|
|
}
|
|
|
|
const tool = createDelegateTask({
|
|
manager: mockManager,
|
|
client: mockClient,
|
|
})
|
|
|
|
const toolContext = {
|
|
sessionID: "parent-session",
|
|
messageID: "parent-message",
|
|
agent: "sisyphus",
|
|
abort: new AbortController().signal,
|
|
}
|
|
|
|
// when
|
|
const result = await tool.execute(
|
|
{
|
|
description: "Agent not found test",
|
|
prompt: "Do something",
|
|
category: "ultrabrain",
|
|
run_in_background: false,
|
|
load_skills: ["git-master"],
|
|
},
|
|
toolContext
|
|
)
|
|
|
|
// then - should return agent not found error
|
|
expect(result).toContain("not found")
|
|
expect(result).toContain("registered")
|
|
})
|
|
|
|
test("sync mode passes category model to prompt", async () => {
|
|
// given
|
|
const { createDelegateTask } = require("./tools")
|
|
let promptBody: any
|
|
|
|
const mockManager = { launch: async () => ({}) }
|
|
|
|
const promptMock = async (input: any) => {
|
|
promptBody = input.body
|
|
return { data: {} }
|
|
}
|
|
|
|
const mockClient = {
|
|
session: {
|
|
get: async () => ({ data: { directory: "/project" } }),
|
|
create: async () => ({ data: { id: "ses_sync_model" } }),
|
|
prompt: promptMock,
|
|
promptAsync: promptMock,
|
|
messages: async () => ({
|
|
data: [{ info: { role: "assistant" }, parts: [{ type: "text", text: "Done" }] }]
|
|
}),
|
|
status: async () => ({ data: {} }),
|
|
},
|
|
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
|
app: { agents: async () => ({ data: [] }) },
|
|
}
|
|
|
|
const tool = createDelegateTask({
|
|
manager: mockManager,
|
|
client: mockClient,
|
|
userCategories: {
|
|
"custom-cat": { model: "provider/custom-model" }
|
|
}
|
|
})
|
|
|
|
const toolContext = {
|
|
sessionID: "parent",
|
|
messageID: "msg",
|
|
agent: "sisyphus",
|
|
abort: new AbortController().signal
|
|
}
|
|
|
|
// when
|
|
await tool.execute({
|
|
description: "Sync model test",
|
|
prompt: "test",
|
|
category: "custom-cat",
|
|
run_in_background: false,
|
|
load_skills: ["git-master"]
|
|
}, toolContext)
|
|
|
|
// then
|
|
expect(promptBody.model).toEqual({
|
|
providerID: "provider",
|
|
modelID: "custom-model"
|
|
})
|
|
}, { timeout: 20000 })
|
|
})
|
|
|
|
describe("unstable agent forced background mode", () => {
|
|
test("gemini model with run_in_background=false should force background but wait for result", async () => {
|
|
// given - category using gemini model with run_in_background=false
|
|
const { createDelegateTask } = require("./tools")
|
|
let launchCalled = false
|
|
|
|
const launchedTask = {
|
|
id: "task-unstable",
|
|
sessionID: "ses_unstable_gemini",
|
|
description: "Unstable gemini task",
|
|
agent: "sisyphus-junior",
|
|
status: "running",
|
|
}
|
|
const mockManager = {
|
|
launch: async () => {
|
|
launchCalled = true
|
|
return launchedTask
|
|
},
|
|
getTask: () => launchedTask,
|
|
}
|
|
|
|
const mockClient = {
|
|
app: { agents: async () => ({ data: [] }) },
|
|
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
|
model: { list: async () => [{ provider: "google", id: "gemini-3.1-pro" }] },
|
|
session: {
|
|
get: async () => ({ data: { directory: "/project" } }),
|
|
create: async () => ({ data: { id: "ses_unstable_gemini" } }),
|
|
prompt: async () => ({ data: {} }),
|
|
promptAsync: async () => ({ data: {} }),
|
|
messages: async () => ({
|
|
data: [
|
|
{ info: { role: "assistant", time: { created: Date.now() } }, parts: [{ type: "text", text: "Gemini task completed successfully" }] }
|
|
]
|
|
}),
|
|
status: async () => ({ data: { "ses_unstable_gemini": { type: "idle" } } }),
|
|
},
|
|
}
|
|
|
|
const tool = createDelegateTask({
|
|
manager: mockManager,
|
|
client: mockClient,
|
|
})
|
|
|
|
const toolContext = {
|
|
sessionID: "parent-session",
|
|
messageID: "parent-message",
|
|
agent: "sisyphus",
|
|
abort: new AbortController().signal,
|
|
}
|
|
|
|
// when - using visual-engineering (gemini model) with run_in_background=false
|
|
const result = await tool.execute(
|
|
{
|
|
description: "Test gemini forced background",
|
|
prompt: "Do something visual",
|
|
category: "visual-engineering",
|
|
run_in_background: false,
|
|
load_skills: ["git-master"],
|
|
},
|
|
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("Gemini task completed successfully")
|
|
}, { timeout: 20000 })
|
|
|
|
test("gemini model with run_in_background=true should not show unstable message (normal background)", async () => {
|
|
// given - category using gemini model with run_in_background=true (normal background flow)
|
|
const { createDelegateTask } = require("./tools")
|
|
let launchCalled = false
|
|
|
|
const mockManager = {
|
|
launch: async () => {
|
|
launchCalled = true
|
|
return {
|
|
id: "task-normal-bg",
|
|
sessionID: "ses_normal_bg",
|
|
description: "Normal background task",
|
|
agent: "sisyphus-junior",
|
|
status: "running",
|
|
}
|
|
},
|
|
}
|
|
|
|
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,
|
|
})
|
|
|
|
const toolContext = {
|
|
sessionID: "parent-session",
|
|
messageID: "parent-message",
|
|
agent: "sisyphus",
|
|
abort: new AbortController().signal,
|
|
}
|
|
|
|
// when - using visual-engineering with run_in_background=true (normal background)
|
|
const result = await tool.execute(
|
|
{
|
|
description: "Test normal background",
|
|
prompt: "Do something visual",
|
|
category: "visual-engineering",
|
|
run_in_background: true, // User explicitly says true - normal background
|
|
load_skills: ["git-master"],
|
|
},
|
|
toolContext
|
|
)
|
|
|
|
// then - should NOT show unstable message (it's normal background flow)
|
|
expect(launchCalled).toBe(true)
|
|
expect(result).not.toContain("UNSTABLE AGENT MODE")
|
|
expect(result).toContain("task-normal-bg")
|
|
})
|
|
|
|
test("minimax model with run_in_background=false should force background but wait for result", async () => {
|
|
// given - custom category using minimax model with run_in_background=false
|
|
const { createDelegateTask } = require("./tools")
|
|
let launchCalled = false
|
|
|
|
const launchedTask = {
|
|
id: "task-unstable-minimax",
|
|
sessionID: "ses_unstable_minimax",
|
|
description: "Unstable minimax task",
|
|
agent: "sisyphus-junior",
|
|
status: "running",
|
|
}
|
|
const mockManager = {
|
|
launch: async () => {
|
|
launchCalled = true
|
|
return launchedTask
|
|
},
|
|
getTask: () => launchedTask,
|
|
}
|
|
|
|
const mockClient = {
|
|
app: { agents: async () => ({ data: [] }) },
|
|
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
|
session: {
|
|
get: async () => ({ data: { directory: "/project" } }),
|
|
create: async () => ({ data: { id: "ses_unstable_minimax" } }),
|
|
prompt: async () => ({ data: {} }),
|
|
promptAsync: async () => ({ data: {} }),
|
|
messages: async () => ({
|
|
data: [
|
|
{ info: { role: "assistant", time: { created: Date.now() } }, parts: [{ type: "text", text: "Minimax task completed successfully" }] }
|
|
]
|
|
}),
|
|
status: async () => ({ data: { "ses_unstable_minimax": { type: "idle" } } }),
|
|
},
|
|
}
|
|
|
|
const tool = createDelegateTask({
|
|
manager: mockManager,
|
|
client: mockClient,
|
|
userCategories: {
|
|
"minimax-cat": {
|
|
model: "minimax/abab-5",
|
|
},
|
|
},
|
|
})
|
|
|
|
const toolContext = {
|
|
sessionID: "parent-session",
|
|
messageID: "parent-message",
|
|
agent: "sisyphus",
|
|
abort: new AbortController().signal,
|
|
}
|
|
|
|
// when - using minimax category with run_in_background=false
|
|
const result = await tool.execute(
|
|
{
|
|
description: "Test minimax forced background",
|
|
prompt: "Do something with minimax",
|
|
category: "minimax-cat",
|
|
run_in_background: false,
|
|
load_skills: ["git-master"],
|
|
},
|
|
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("Minimax task completed successfully")
|
|
}, { timeout: 20000 })
|
|
|
|
test("non-gemini model with run_in_background=false should run sync (not forced to background)", async () => {
|
|
// given - category using non-gemini model with run_in_background=false
|
|
const { createDelegateTask } = require("./tools")
|
|
let launchCalled = false
|
|
let promptCalled = false
|
|
|
|
const mockManager = {
|
|
launch: async () => {
|
|
launchCalled = true
|
|
return { id: "should-not-be-called", sessionID: "x", description: "x", agent: "x", status: "running" }
|
|
},
|
|
}
|
|
|
|
const promptMock = async () => {
|
|
promptCalled = true
|
|
return { data: {} }
|
|
}
|
|
|
|
const mockClient = {
|
|
app: { agents: async () => ({ data: [] }) },
|
|
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
|
session: {
|
|
get: async () => ({ data: { directory: "/project" } }),
|
|
create: async () => ({ data: { id: "ses_sync_non_gemini" } }),
|
|
prompt: promptMock,
|
|
promptAsync: promptMock,
|
|
messages: async () => ({
|
|
data: [{ info: { role: "assistant" }, parts: [{ type: "text", text: "Done sync" }] }]
|
|
}),
|
|
status: async () => ({ data: { "ses_sync_non_gemini": { type: "idle" } } }),
|
|
},
|
|
}
|
|
|
|
// Use ultrabrain which uses gpt-5.4 (non-gemini)
|
|
const tool = createDelegateTask({
|
|
manager: mockManager,
|
|
client: mockClient,
|
|
})
|
|
|
|
const toolContext = {
|
|
sessionID: "parent-session",
|
|
messageID: "parent-message",
|
|
agent: "sisyphus",
|
|
abort: new AbortController().signal,
|
|
}
|
|
|
|
// when - using ultrabrain (gpt model) with run_in_background=false
|
|
const result = await tool.execute(
|
|
{
|
|
description: "Test non-gemini sync",
|
|
prompt: "Do something smart",
|
|
category: "ultrabrain",
|
|
run_in_background: false,
|
|
load_skills: ["git-master"],
|
|
},
|
|
toolContext
|
|
)
|
|
|
|
// then - should run sync, NOT forced to background
|
|
expect(launchCalled).toBe(false) // manager.launch should NOT be called
|
|
expect(promptCalled).toBe(true) // sync mode uses session.prompt
|
|
expect(result).not.toContain("UNSTABLE AGENT MODE")
|
|
}, { timeout: 20000 })
|
|
|
|
test("artistry category (gemini) with run_in_background=false should force background but wait for result", async () => {
|
|
// given - artistry also uses gemini model
|
|
const { createDelegateTask } = require("./tools")
|
|
let launchCalled = false
|
|
|
|
const launchedTask = {
|
|
id: "task-artistry",
|
|
sessionID: "ses_artistry_gemini",
|
|
description: "Artistry gemini task",
|
|
agent: "sisyphus-junior",
|
|
status: "running",
|
|
}
|
|
const mockManager = {
|
|
launch: async () => {
|
|
launchCalled = true
|
|
return launchedTask
|
|
},
|
|
getTask: () => launchedTask,
|
|
}
|
|
|
|
const mockClient = {
|
|
app: { agents: async () => ({ data: [] }) },
|
|
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
|
model: { list: async () => [{ provider: "google", id: "gemini-3.1-pro" }] },
|
|
session: {
|
|
get: async () => ({ data: { directory: "/project" } }),
|
|
create: async () => ({ data: { id: "ses_artistry_gemini" } }),
|
|
prompt: async () => ({ data: {} }),
|
|
promptAsync: async () => ({ data: {} }),
|
|
messages: async () => ({
|
|
data: [
|
|
{ info: { role: "assistant", time: { created: Date.now() } }, parts: [{ type: "text", text: "Artistry result here" }] }
|
|
]
|
|
}),
|
|
status: async () => ({ data: { "ses_artistry_gemini": { type: "idle" } } }),
|
|
},
|
|
}
|
|
|
|
const tool = createDelegateTask({
|
|
manager: mockManager,
|
|
client: mockClient,
|
|
})
|
|
|
|
const toolContext = {
|
|
sessionID: "parent-session",
|
|
messageID: "parent-message",
|
|
agent: "sisyphus",
|
|
abort: new AbortController().signal,
|
|
}
|
|
|
|
// when - artistry category (gemini-3.1-pro with high variant)
|
|
const result = await tool.execute(
|
|
{
|
|
description: "Test artistry forced background",
|
|
prompt: "Do something artistic",
|
|
category: "artistry",
|
|
run_in_background: false,
|
|
load_skills: ["git-master"],
|
|
},
|
|
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("Artistry result here")
|
|
}, { timeout: 20000 })
|
|
|
|
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
|
|
let promptCalled = false
|
|
|
|
const mockManager = {
|
|
launch: async () => {
|
|
launchCalled = true
|
|
return { id: "should-not-be-called", sessionID: "x", description: "x", agent: "x", status: "running" }
|
|
},
|
|
}
|
|
|
|
const promptMock = async () => {
|
|
promptCalled = true
|
|
return { data: {} }
|
|
}
|
|
|
|
const mockClient = {
|
|
app: { agents: async () => ({ data: [] }) },
|
|
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
|
session: {
|
|
get: async () => ({ data: { directory: "/project" } }),
|
|
create: async () => ({ data: { id: "ses_writing_kimi" } }),
|
|
prompt: promptMock,
|
|
promptAsync: promptMock,
|
|
messages: async () => ({
|
|
data: [{ info: { role: "assistant" }, parts: [{ type: "text", text: "Writing result here" }] }]
|
|
}),
|
|
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 (kimi) with run_in_background=false
|
|
const result = await tool.execute(
|
|
{
|
|
description: "Test writing sync",
|
|
prompt: "Write something",
|
|
category: "writing",
|
|
run_in_background: false,
|
|
load_skills: ["git-master"],
|
|
},
|
|
toolContext
|
|
)
|
|
|
|
// 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 () => {
|
|
// given - custom category with is_unstable_agent=true but non-gemini model
|
|
const { createDelegateTask } = require("./tools")
|
|
let launchCalled = false
|
|
|
|
const launchedTask = {
|
|
id: "task-custom-unstable",
|
|
sessionID: "ses_custom_unstable",
|
|
description: "Custom unstable task",
|
|
agent: "sisyphus-junior",
|
|
status: "running",
|
|
}
|
|
const mockManager = {
|
|
launch: async () => {
|
|
launchCalled = true
|
|
return launchedTask
|
|
},
|
|
getTask: () => launchedTask,
|
|
}
|
|
|
|
const mockClient = {
|
|
app: { agents: async () => ({ data: [] }) },
|
|
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
|
session: {
|
|
get: async () => ({ data: { directory: "/project" } }),
|
|
create: async () => ({ data: { id: "ses_custom_unstable" } }),
|
|
prompt: async () => ({ data: {} }),
|
|
promptAsync: async () => ({ data: {} }),
|
|
messages: async () => ({
|
|
data: [
|
|
{ info: { role: "assistant", time: { created: Date.now() } }, parts: [{ type: "text", text: "Custom unstable result" }] }
|
|
]
|
|
}),
|
|
status: async () => ({ data: { "ses_custom_unstable": { type: "idle" } } }),
|
|
},
|
|
}
|
|
|
|
const tool = createDelegateTask({
|
|
manager: mockManager,
|
|
client: mockClient,
|
|
userCategories: {
|
|
"my-unstable-cat": {
|
|
model: "openai/gpt-5.4",
|
|
is_unstable_agent: true,
|
|
},
|
|
},
|
|
})
|
|
|
|
const toolContext = {
|
|
sessionID: "parent-session",
|
|
messageID: "parent-message",
|
|
agent: "sisyphus",
|
|
abort: new AbortController().signal,
|
|
}
|
|
|
|
// when - using custom unstable category with run_in_background=false
|
|
const result = await tool.execute(
|
|
{
|
|
description: "Test custom unstable",
|
|
prompt: "Do something",
|
|
category: "my-unstable-cat",
|
|
run_in_background: false,
|
|
load_skills: ["git-master"],
|
|
},
|
|
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("Custom unstable result")
|
|
}, { timeout: 20000 })
|
|
})
|
|
|
|
describe("category model resolution fallback", () => {
|
|
test("category uses resolved.model when connectedProvidersCache is null and availableModels is empty", async () => {
|
|
// given - connectedProvidersCache returns null (simulates missing cache file)
|
|
// This is a regression test for PR #1227 which removed resolved.model from userModel chain
|
|
cacheSpy.mockReturnValue(null)
|
|
|
|
const { createDelegateTask } = require("./tools")
|
|
let launchInput: any
|
|
|
|
const mockManager = {
|
|
launch: async (input: any) => {
|
|
launchInput = input
|
|
return {
|
|
id: "task-fallback",
|
|
sessionID: "ses_fallback_test",
|
|
description: "Fallback test task",
|
|
agent: "sisyphus-junior",
|
|
status: "running",
|
|
}
|
|
},
|
|
}
|
|
|
|
const mockClient = {
|
|
app: { agents: async () => ({ data: [] }) },
|
|
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
|
model: { list: async () => [] },
|
|
session: {
|
|
create: async () => ({ data: { id: "test-session" } }),
|
|
prompt: async () => ({ data: {} }),
|
|
messages: async () => ({ data: [] }),
|
|
},
|
|
}
|
|
|
|
// NO userCategories override, NO sisyphusJuniorModel
|
|
const tool = createDelegateTask({
|
|
manager: mockManager,
|
|
client: mockClient,
|
|
// userCategories: undefined - use DEFAULT_CATEGORIES only
|
|
// sisyphusJuniorModel: undefined
|
|
connectedProvidersOverride: null,
|
|
availableModelsOverride: new Set(),
|
|
})
|
|
|
|
const toolContext = {
|
|
sessionID: "parent-session",
|
|
messageID: "parent-message",
|
|
agent: "sisyphus",
|
|
abort: new AbortController().signal,
|
|
}
|
|
|
|
// when - using "quick" category which should use "anthropic/claude-haiku-4-5"
|
|
await tool.execute(
|
|
{
|
|
description: "Test category fallback",
|
|
prompt: "Do something quick",
|
|
category: "quick",
|
|
run_in_background: true,
|
|
load_skills: [],
|
|
},
|
|
toolContext
|
|
)
|
|
|
|
// then - model should be anthropic/claude-haiku-4-5 from DEFAULT_CATEGORIES
|
|
// NOT anthropic/claude-sonnet-4-6 (system default)
|
|
expect(launchInput.model.providerID).toBe("anthropic")
|
|
expect(launchInput.model.modelID).toBe("claude-haiku-4-5")
|
|
})
|
|
|
|
test("category delegation ignores UI-selected (Kimi) system default model", async () => {
|
|
// given - OpenCode system default model is Kimi (selected from UI)
|
|
const { createDelegateTask } = require("./tools")
|
|
let launchInput: any
|
|
|
|
const mockManager = {
|
|
launch: async (input: any) => {
|
|
launchInput = input
|
|
return {
|
|
id: "task-ui-model",
|
|
sessionID: "ses_ui_model_test",
|
|
description: "UI model inheritance test",
|
|
agent: "sisyphus-junior",
|
|
status: "running",
|
|
}
|
|
},
|
|
}
|
|
|
|
const mockClient = {
|
|
app: { agents: async () => ({ data: [] }) },
|
|
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
|
model: { list: async () => [] },
|
|
session: {
|
|
create: async () => ({ data: { id: "test-session" } }),
|
|
prompt: async () => ({ data: {} }),
|
|
promptAsync: async () => ({ data: {} }),
|
|
messages: async () => ({ data: [] }),
|
|
},
|
|
}
|
|
|
|
const tool = createDelegateTask({
|
|
manager: mockManager,
|
|
client: mockClient,
|
|
userCategories: {
|
|
"fallback-test": { model: "anthropic/claude-opus-4-6" },
|
|
},
|
|
connectedProvidersOverride: TEST_CONNECTED_PROVIDERS,
|
|
availableModelsOverride: createTestAvailableModels(),
|
|
})
|
|
|
|
const toolContext = {
|
|
sessionID: "parent-session",
|
|
messageID: "parent-message",
|
|
agent: "sisyphus",
|
|
abort: new AbortController().signal,
|
|
}
|
|
|
|
// when - using "quick" category which should use "anthropic/claude-haiku-4-5"
|
|
await tool.execute(
|
|
{
|
|
description: "UI model inheritance test",
|
|
prompt: "Do something quick",
|
|
category: "quick",
|
|
run_in_background: true,
|
|
load_skills: [],
|
|
},
|
|
toolContext
|
|
)
|
|
|
|
// then - category model must win (not Kimi)
|
|
expect(launchInput.model.providerID).toBe("anthropic")
|
|
expect(launchInput.model.modelID).toBe("claude-haiku-4-5")
|
|
})
|
|
|
|
test("sisyphus-junior model override takes precedence over category model", async () => {
|
|
// given - sisyphus-junior override model differs from category default
|
|
const { createDelegateTask } = require("./tools")
|
|
let launchInput: any
|
|
|
|
const mockManager = {
|
|
launch: async (input: any) => {
|
|
launchInput = input
|
|
return {
|
|
id: "task-override",
|
|
sessionID: "ses_override_test",
|
|
description: "Override precedence test",
|
|
agent: "sisyphus-junior",
|
|
status: "running",
|
|
}
|
|
},
|
|
}
|
|
|
|
const mockClient = {
|
|
app: { agents: async () => ({ data: [] }) },
|
|
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
|
model: { list: async () => [] },
|
|
session: {
|
|
create: async () => ({ data: { id: "test-session" } }),
|
|
prompt: async () => ({ data: {} }),
|
|
messages: async () => ({ data: [] }),
|
|
},
|
|
}
|
|
|
|
const tool = createDelegateTask({
|
|
manager: mockManager,
|
|
client: mockClient,
|
|
sisyphusJuniorModel: "anthropic/claude-sonnet-4-6",
|
|
connectedProvidersOverride: TEST_CONNECTED_PROVIDERS,
|
|
availableModelsOverride: createTestAvailableModels(),
|
|
})
|
|
|
|
const toolContext = {
|
|
sessionID: "parent-session",
|
|
messageID: "parent-message",
|
|
agent: "sisyphus",
|
|
abort: new AbortController().signal,
|
|
}
|
|
|
|
// when - using ultrabrain category (default model is openai/gpt-5.4)
|
|
await tool.execute(
|
|
{
|
|
description: "Override precedence test",
|
|
prompt: "Do something",
|
|
category: "ultrabrain",
|
|
run_in_background: true,
|
|
load_skills: [],
|
|
},
|
|
toolContext
|
|
)
|
|
|
|
// then - override model should be used instead of category model
|
|
expect(launchInput.model.providerID).toBe("anthropic")
|
|
expect(launchInput.model.modelID).toBe("claude-sonnet-4-6")
|
|
})
|
|
|
|
test("explicit category model takes precedence over sisyphus-junior model", async () => {
|
|
// given - explicit category model differs from sisyphus-junior override
|
|
const { createDelegateTask } = require("./tools")
|
|
let launchInput: any
|
|
|
|
const mockManager = {
|
|
launch: async (input: any) => {
|
|
launchInput = input
|
|
return {
|
|
id: "task-category-precedence",
|
|
sessionID: "ses_category_precedence_test",
|
|
description: "Category precedence test",
|
|
agent: "sisyphus-junior",
|
|
status: "running",
|
|
}
|
|
},
|
|
}
|
|
|
|
const mockClient = {
|
|
app: { agents: async () => ({ data: [] }) },
|
|
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
|
model: { list: async () => [] },
|
|
session: {
|
|
create: async () => ({ data: { id: "test-session" } }),
|
|
prompt: async () => ({ data: {} }),
|
|
promptAsync: async () => ({ data: {} }),
|
|
messages: async () => ({ data: [] }),
|
|
},
|
|
}
|
|
|
|
const tool = createDelegateTask({
|
|
manager: mockManager,
|
|
client: mockClient,
|
|
sisyphusJuniorModel: "anthropic/claude-sonnet-4-6",
|
|
userCategories: {
|
|
ultrabrain: { model: "openai/gpt-5.4" },
|
|
},
|
|
connectedProvidersOverride: TEST_CONNECTED_PROVIDERS,
|
|
availableModelsOverride: createTestAvailableModels(),
|
|
})
|
|
|
|
const toolContext = {
|
|
sessionID: "parent-session",
|
|
messageID: "parent-message",
|
|
agent: "sisyphus",
|
|
abort: new AbortController().signal,
|
|
}
|
|
|
|
// when - using ultrabrain category with explicit model override
|
|
await tool.execute(
|
|
{
|
|
description: "Category precedence test",
|
|
prompt: "Do something",
|
|
category: "ultrabrain",
|
|
run_in_background: true,
|
|
load_skills: [],
|
|
},
|
|
toolContext
|
|
)
|
|
|
|
// then - explicit category model should win
|
|
expect(launchInput.model.providerID).toBe("openai")
|
|
expect(launchInput.model.modelID).toBe("gpt-5.4")
|
|
})
|
|
|
|
test("sisyphus-junior model override works with quick category (#1295)", async () => {
|
|
// given - user configures agents.sisyphus-junior.model but uses quick category
|
|
const { createDelegateTask } = require("./tools")
|
|
let launchInput: any
|
|
|
|
const mockManager = {
|
|
launch: async (input: any) => {
|
|
launchInput = input
|
|
return {
|
|
id: "task-1295-quick",
|
|
sessionID: "ses_1295_quick",
|
|
description: "Issue 1295 regression",
|
|
agent: "sisyphus-junior",
|
|
status: "running",
|
|
}
|
|
},
|
|
}
|
|
|
|
const mockClient = {
|
|
app: { agents: async () => ({ data: [] }) },
|
|
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
|
model: { list: async () => [] },
|
|
session: {
|
|
create: async () => ({ data: { id: "test-session" } }),
|
|
prompt: async () => ({ data: {} }),
|
|
messages: async () => ({ data: [] }),
|
|
},
|
|
}
|
|
|
|
const tool = createDelegateTask({
|
|
manager: mockManager,
|
|
client: mockClient,
|
|
sisyphusJuniorModel: "anthropic/claude-sonnet-4-6",
|
|
connectedProvidersOverride: TEST_CONNECTED_PROVIDERS,
|
|
availableModelsOverride: createTestAvailableModels(),
|
|
})
|
|
|
|
const toolContext = {
|
|
sessionID: "parent-session",
|
|
messageID: "parent-message",
|
|
agent: "sisyphus",
|
|
abort: new AbortController().signal,
|
|
}
|
|
|
|
// when - using quick category (default: anthropic/claude-haiku-4-5)
|
|
await tool.execute(
|
|
{
|
|
description: "Issue 1295 quick category test",
|
|
prompt: "Quick task",
|
|
category: "quick",
|
|
run_in_background: true,
|
|
load_skills: [],
|
|
},
|
|
toolContext
|
|
)
|
|
|
|
// 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 () => {
|
|
// given - user has a custom category with no model requirement
|
|
const { createDelegateTask } = require("./tools")
|
|
let launchInput: any
|
|
|
|
const mockManager = {
|
|
launch: async (input: any) => {
|
|
launchInput = input
|
|
return {
|
|
id: "task-1295-custom",
|
|
sessionID: "ses_1295_custom",
|
|
description: "Issue 1295 custom category",
|
|
agent: "sisyphus-junior",
|
|
status: "running",
|
|
}
|
|
},
|
|
}
|
|
|
|
const mockClient = {
|
|
app: { agents: async () => ({ data: [] }) },
|
|
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
|
model: { list: async () => [] },
|
|
session: {
|
|
create: async () => ({ data: { id: "test-session" } }),
|
|
prompt: async () => ({ data: {} }),
|
|
messages: async () => ({ data: [] }),
|
|
},
|
|
}
|
|
|
|
const tool = createDelegateTask({
|
|
manager: mockManager,
|
|
client: mockClient,
|
|
sisyphusJuniorModel: "openai/gpt-5.4",
|
|
userCategories: {
|
|
"my-custom": { temperature: 0.5 },
|
|
},
|
|
})
|
|
|
|
const toolContext = {
|
|
sessionID: "parent-session",
|
|
messageID: "parent-message",
|
|
agent: "sisyphus",
|
|
abort: new AbortController().signal,
|
|
}
|
|
|
|
// when - using custom category with no explicit model
|
|
await tool.execute(
|
|
{
|
|
description: "Custom category with agent model",
|
|
prompt: "Do something custom",
|
|
category: "my-custom",
|
|
run_in_background: true,
|
|
load_skills: [],
|
|
},
|
|
toolContext
|
|
)
|
|
|
|
// then - sisyphus-junior override model should be used as fallback
|
|
expect(launchInput.model.providerID).toBe("openai")
|
|
expect(launchInput.model.modelID).toBe("gpt-5.4")
|
|
})
|
|
})
|
|
|
|
describe("browserProvider propagation", () => {
|
|
test("should resolve agent-browser skill when browserProvider is passed", async () => {
|
|
// given - task configured with browserProvider: "agent-browser"
|
|
const { createDelegateTask } = require("./tools")
|
|
let promptBody: any
|
|
|
|
const mockManager = { launch: async () => ({}) }
|
|
|
|
const promptMock = async (input: any) => {
|
|
promptBody = input.body
|
|
return { data: {} }
|
|
}
|
|
|
|
const mockClient = {
|
|
app: { agents: async () => ({ data: [] }) },
|
|
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
|
session: {
|
|
get: async () => ({ data: { directory: "/project" } }),
|
|
create: async () => ({ data: { id: "ses_browser_provider" } }),
|
|
prompt: promptMock,
|
|
promptAsync: promptMock,
|
|
messages: async () => ({
|
|
data: [{ info: { role: "assistant" }, parts: [{ type: "text", text: "Done" }] }]
|
|
}),
|
|
status: async () => ({ data: {} }),
|
|
},
|
|
}
|
|
|
|
// Pass browserProvider to createDelegateTask
|
|
const tool = createDelegateTask({
|
|
manager: mockManager,
|
|
client: mockClient,
|
|
browserProvider: "agent-browser",
|
|
})
|
|
|
|
const toolContext = {
|
|
sessionID: "parent-session",
|
|
messageID: "parent-message",
|
|
agent: "sisyphus",
|
|
abort: new AbortController().signal,
|
|
}
|
|
|
|
// when - request agent-browser skill
|
|
await tool.execute(
|
|
{
|
|
description: "Test browserProvider propagation",
|
|
prompt: "Do something",
|
|
category: "ultrabrain",
|
|
run_in_background: false,
|
|
load_skills: ["agent-browser"],
|
|
},
|
|
toolContext
|
|
)
|
|
|
|
// then - agent-browser skill should be resolved
|
|
expect(promptBody).toBeDefined()
|
|
expect(promptBody.system).toBeDefined()
|
|
expect(promptBody.system).toContain("<Category_Context>")
|
|
expect(String(promptBody.system).startsWith("<Category_Context>")).toBe(false)
|
|
}, { timeout: 20000 })
|
|
|
|
test("should resolve agent-browser skill even when browserProvider is not set", async () => {
|
|
// given - delegate_task without browserProvider
|
|
const { createDelegateTask } = require("./tools")
|
|
let promptBody: any
|
|
|
|
const mockManager = { launch: async () => ({}) }
|
|
const mockClient = {
|
|
app: { agents: async () => ({ data: [] }) },
|
|
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
|
session: {
|
|
get: async () => ({ data: { directory: "/project" } }),
|
|
create: async () => ({ data: { id: "ses_no_browser_provider" } }),
|
|
prompt: async (input: any) => {
|
|
promptBody = input.body
|
|
return { data: {} }
|
|
},
|
|
messages: async () => ({
|
|
data: [{ info: { role: "assistant" }, parts: [{ type: "text", text: "Done" }] }]
|
|
}),
|
|
status: async () => ({ data: {} }),
|
|
},
|
|
}
|
|
|
|
// No browserProvider passed
|
|
const tool = createDelegateTask({
|
|
manager: mockManager,
|
|
client: mockClient,
|
|
})
|
|
|
|
const toolContext = {
|
|
sessionID: "parent-session",
|
|
messageID: "parent-message",
|
|
agent: "sisyphus",
|
|
abort: new AbortController().signal,
|
|
}
|
|
|
|
// when - request agent-browser skill without browserProvider
|
|
const result = await tool.execute(
|
|
{
|
|
description: "Test missing browserProvider",
|
|
prompt: "Do something",
|
|
category: "ultrabrain",
|
|
run_in_background: false,
|
|
load_skills: ["agent-browser"],
|
|
},
|
|
toolContext
|
|
)
|
|
|
|
// then - agent-browser skill should NOT resolve without browserProvider
|
|
expect(result).toContain("Skills not found")
|
|
expect(result).toContain("agent-browser")
|
|
})
|
|
})
|
|
|
|
describe("buildSystemContent", () => {
|
|
test("returns undefined when no skills and no category promptAppend", () => {
|
|
// given
|
|
const { buildSystemContent } = require("./tools")
|
|
|
|
// when
|
|
const result = buildSystemContent({ skillContent: undefined, categoryPromptAppend: undefined })
|
|
|
|
// then
|
|
expect(result).toBeUndefined()
|
|
})
|
|
|
|
test("returns skill content only when skills provided without category", () => {
|
|
// given
|
|
const { buildSystemContent } = require("./tools")
|
|
const skillContent = "You are a playwright expert"
|
|
|
|
// when
|
|
const result = buildSystemContent({ skillContent, categoryPromptAppend: undefined })
|
|
|
|
// then
|
|
expect(result).toBe(skillContent)
|
|
})
|
|
|
|
test("returns category promptAppend only when no skills", () => {
|
|
// given
|
|
const { buildSystemContent } = require("./tools")
|
|
const categoryPromptAppend = "Focus on visual design"
|
|
|
|
// when
|
|
const result = buildSystemContent({ skillContent: undefined, categoryPromptAppend })
|
|
|
|
// then
|
|
expect(result).toBe(categoryPromptAppend)
|
|
})
|
|
|
|
test("combines skill content and category promptAppend with separator", () => {
|
|
// given
|
|
const { buildSystemContent } = require("./tools")
|
|
const skillContent = "You are a playwright expert"
|
|
const categoryPromptAppend = "Focus on visual design"
|
|
|
|
// when
|
|
const result = buildSystemContent({ skillContent, categoryPromptAppend })
|
|
|
|
// then
|
|
expect(result).toContain(skillContent)
|
|
expect(result).toContain(categoryPromptAppend)
|
|
expect(result).toContain("\n\n")
|
|
})
|
|
|
|
test("prepends plan agent system prompt when agentName is 'plan'", () => {
|
|
// given
|
|
const { buildSystemContent } = require("./tools")
|
|
const { buildPlanAgentSystemPrepend } = require("./constants")
|
|
|
|
const availableCategories = [
|
|
{
|
|
name: "deep",
|
|
description: "Goal-oriented autonomous problem-solving",
|
|
model: "openai/gpt-5.3-codex",
|
|
},
|
|
]
|
|
const availableSkills = [
|
|
{
|
|
name: "typescript-programmer",
|
|
description: "Production TypeScript code.",
|
|
location: "plugin",
|
|
},
|
|
]
|
|
|
|
// when
|
|
const result = buildSystemContent({
|
|
agentName: "plan",
|
|
availableCategories,
|
|
availableSkills,
|
|
})
|
|
|
|
// then
|
|
expect(result).toContain("<system>")
|
|
expect(result).toContain("MANDATORY CONTEXT GATHERING PROTOCOL")
|
|
expect(result).toContain("### AVAILABLE CATEGORIES")
|
|
expect(result).toContain("`deep`")
|
|
expect(result).not.toContain("prompt-engineer")
|
|
expect(result).toBe(buildPlanAgentSystemPrepend(availableCategories, availableSkills))
|
|
})
|
|
|
|
test("does not prepend plan agent prompt for prometheus agent", () => {
|
|
//#given - prometheus is NOT a plan agent (decoupled)
|
|
const { buildSystemContent } = require("./tools")
|
|
const skillContent = "You are a strategic planner"
|
|
|
|
//#when
|
|
const result = buildSystemContent({
|
|
skillContent,
|
|
agentName: "prometheus",
|
|
})
|
|
|
|
//#then - prometheus should NOT get plan agent system prepend
|
|
expect(result).toBe(skillContent)
|
|
expect(result).not.toContain("MANDATORY CONTEXT GATHERING PROTOCOL")
|
|
})
|
|
|
|
test("does not prepend plan agent prompt for Prometheus (case insensitive)", () => {
|
|
//#given - Prometheus (capitalized) is NOT a plan agent
|
|
const { buildSystemContent } = require("./tools")
|
|
const skillContent = "You are a strategic planner"
|
|
|
|
//#when
|
|
const result = buildSystemContent({
|
|
skillContent,
|
|
agentName: "Prometheus",
|
|
})
|
|
|
|
//#then
|
|
expect(result).toBe(skillContent)
|
|
expect(result).not.toContain("MANDATORY CONTEXT GATHERING PROTOCOL")
|
|
})
|
|
|
|
test("combines plan agent prepend with skill content", () => {
|
|
// given
|
|
const { buildSystemContent } = require("./tools")
|
|
const { buildPlanAgentSystemPrepend } = require("./constants")
|
|
const skillContent = "You are a planning expert"
|
|
|
|
const availableCategories = [
|
|
{
|
|
name: "writing",
|
|
description: "Documentation, prose, technical writing",
|
|
model: "kimi-for-coding/k2p5",
|
|
},
|
|
]
|
|
const availableSkills = [
|
|
{
|
|
name: "python-programmer",
|
|
description: "Production Python code.",
|
|
location: "plugin",
|
|
},
|
|
]
|
|
const planPrepend = buildPlanAgentSystemPrepend(availableCategories, availableSkills)
|
|
|
|
// when
|
|
const result = buildSystemContent({
|
|
skillContent,
|
|
agentName: "plan",
|
|
availableCategories,
|
|
availableSkills,
|
|
})
|
|
|
|
// then
|
|
expect(result).toContain(planPrepend)
|
|
expect(result).toContain(skillContent)
|
|
expect(result!.indexOf(planPrepend)).toBeLessThan(result!.indexOf(skillContent))
|
|
})
|
|
|
|
test("does not prepend plan agent prompt for non-plan agents", () => {
|
|
// given
|
|
const { buildSystemContent } = require("./tools")
|
|
const skillContent = "You are an expert"
|
|
|
|
// when
|
|
const result = buildSystemContent({ skillContent, agentName: "oracle" })
|
|
|
|
// then
|
|
expect(result).toBe(skillContent)
|
|
expect(result).not.toContain("<system>")
|
|
})
|
|
|
|
test("does not prepend plan agent prompt when agentName is undefined", () => {
|
|
// given
|
|
const { buildSystemContent } = require("./tools")
|
|
const skillContent = "You are an expert"
|
|
|
|
// when
|
|
const result = buildSystemContent({ skillContent, agentName: undefined })
|
|
|
|
// then
|
|
expect(result).toBe(skillContent)
|
|
expect(result).not.toContain("<system>")
|
|
})
|
|
})
|
|
|
|
describe("buildTaskPrompt", () => {
|
|
test("appends English ULW TDD and commit guidance for plan agent", () => {
|
|
// given
|
|
const { buildTaskPrompt } = require("./tools")
|
|
const prompt = "Create a work plan for this feature"
|
|
|
|
// when
|
|
const result = buildTaskPrompt(prompt, "plan")
|
|
|
|
// then
|
|
expect(result).toContain(prompt)
|
|
expect(result).toContain("Answer in English.")
|
|
expect(result).toContain("Write the plan in English.")
|
|
expect(result).toContain("Plan well for ultrawork execution.")
|
|
expect(result).toContain("Use TDD-oriented planning.")
|
|
expect(result).toContain("Include a clear atomic commit strategy.")
|
|
})
|
|
|
|
test("does not append plan guidance for non-plan agents", () => {
|
|
// given
|
|
const { buildTaskPrompt } = require("./tools")
|
|
const prompt = "Investigate this module"
|
|
|
|
// when
|
|
const result = buildTaskPrompt(prompt, "explore")
|
|
|
|
// then
|
|
expect(result).toBe(prompt)
|
|
})
|
|
|
|
test("excludes TDD line when tddEnabled is false", () => {
|
|
// given
|
|
const { buildTaskPrompt } = require("./tools")
|
|
const prompt = "Create a work plan for this feature"
|
|
|
|
// when
|
|
const result = buildTaskPrompt(prompt, "plan", false)
|
|
|
|
// then
|
|
expect(result).toContain(prompt)
|
|
expect(result).toContain("Answer in English.")
|
|
expect(result).toContain("Write the plan in English.")
|
|
expect(result).toContain("Plan well for ultrawork execution.")
|
|
expect(result).toContain("Include a clear atomic commit strategy.")
|
|
expect(result).not.toContain("Use TDD-oriented planning.")
|
|
})
|
|
|
|
test("includes TDD line when tddEnabled is true", () => {
|
|
// given
|
|
const { buildTaskPrompt } = require("./tools")
|
|
const prompt = "Create a work plan for this feature"
|
|
|
|
// when
|
|
const result = buildTaskPrompt(prompt, "plan", true)
|
|
|
|
// then
|
|
expect(result).toContain("Use TDD-oriented planning.")
|
|
})
|
|
})
|
|
|
|
describe("modelInfo detection via resolveCategoryConfig", () => {
|
|
test("catalog model is used for category with catalog entry", () => {
|
|
// given - ultrabrain has catalog entry
|
|
const categoryName = "ultrabrain"
|
|
|
|
// when
|
|
const resolved = resolveCategoryConfig(categoryName, { systemDefaultModel: SYSTEM_DEFAULT_MODEL })
|
|
|
|
// then - catalog model is used
|
|
expect(resolved).not.toBeNull()
|
|
expect(resolved!.config.model).toBe("openai/gpt-5.4")
|
|
expect(resolved!.config.variant).toBe("xhigh")
|
|
})
|
|
|
|
test("default model is used for category with default entry", () => {
|
|
// given - unspecified-low has default model
|
|
const categoryName = "unspecified-low"
|
|
|
|
// when
|
|
const resolved = resolveCategoryConfig(categoryName, { systemDefaultModel: SYSTEM_DEFAULT_MODEL })
|
|
|
|
// then - default model from DEFAULT_CATEGORIES is used
|
|
expect(resolved).not.toBeNull()
|
|
expect(resolved!.config.model).toBe("anthropic/claude-sonnet-4-6")
|
|
})
|
|
|
|
test("category built-in model takes precedence over inheritedModel for builtin category", () => {
|
|
// given - builtin ultrabrain category with its own model, inherited model also provided
|
|
const categoryName = "ultrabrain"
|
|
const inheritedModel = "cliproxy/claude-opus-4-6"
|
|
|
|
// when
|
|
const resolved = resolveCategoryConfig(categoryName, { inheritedModel, systemDefaultModel: SYSTEM_DEFAULT_MODEL })
|
|
|
|
// then - category's built-in model wins (ultrabrain uses gpt-5.4)
|
|
expect(resolved).not.toBeNull()
|
|
const actualModel = resolved!.config.model
|
|
expect(actualModel).toBe("openai/gpt-5.4")
|
|
})
|
|
|
|
test("when user defines model - modelInfo should report user-defined regardless of inheritedModel", () => {
|
|
// given
|
|
const categoryName = "ultrabrain"
|
|
const userCategories = { "ultrabrain": { model: "my-provider/custom-model" } }
|
|
const inheritedModel = "cliproxy/claude-opus-4-6"
|
|
|
|
// when
|
|
const resolved = resolveCategoryConfig(categoryName, { userCategories, inheritedModel, systemDefaultModel: SYSTEM_DEFAULT_MODEL })
|
|
|
|
// then - actualModel should be userModel, type should be "user-defined"
|
|
expect(resolved).not.toBeNull()
|
|
const actualModel = resolved!.config.model
|
|
const userDefinedModel = userCategories[categoryName]?.model
|
|
expect(actualModel).toBe(userDefinedModel)
|
|
expect(actualModel).toBe("my-provider/custom-model")
|
|
})
|
|
|
|
test("detection logic: actualModel comparison correctly identifies source", () => {
|
|
// given - This test verifies the fix for PR #770 bug
|
|
// The bug was: checking `if (inheritedModel)` instead of `if (actualModel === inheritedModel)`
|
|
const categoryName = "ultrabrain"
|
|
const inheritedModel = "cliproxy/claude-opus-4-6"
|
|
const userCategories = { "ultrabrain": { model: "user/model" } }
|
|
|
|
// when - user model wins
|
|
const resolved = resolveCategoryConfig(categoryName, { userCategories, inheritedModel, systemDefaultModel: SYSTEM_DEFAULT_MODEL })
|
|
const actualModel = resolved!.config.model
|
|
const userDefinedModel = userCategories[categoryName]?.model
|
|
|
|
// then - detection should compare against actual resolved model
|
|
const detectedType = actualModel === userDefinedModel
|
|
? "user-defined"
|
|
: actualModel === inheritedModel
|
|
? "inherited"
|
|
: actualModel === SYSTEM_DEFAULT_MODEL
|
|
? "system-default"
|
|
: undefined
|
|
|
|
expect(detectedType).toBe("user-defined")
|
|
expect(actualModel).not.toBe(inheritedModel)
|
|
})
|
|
|
|
// ===== TESTS FOR resolveModel() INTEGRATION (TDD GREEN) =====
|
|
// These tests verify the NEW behavior where categories do NOT have default models
|
|
|
|
test("FIXED: category built-in model takes precedence over inheritedModel", () => {
|
|
// given a builtin category with its own model, and an inherited model from parent
|
|
// The CORRECT chain: userConfig?.model ?? categoryBuiltIn ?? systemDefaultModel
|
|
const categoryName = "ultrabrain"
|
|
const inheritedModel = "anthropic/claude-opus-4-6"
|
|
|
|
// when category has a built-in model (gpt-5.4 for ultrabrain)
|
|
const resolved = resolveCategoryConfig(categoryName, { inheritedModel, systemDefaultModel: SYSTEM_DEFAULT_MODEL })
|
|
|
|
// then category's built-in model should be used, NOT inheritedModel
|
|
expect(resolved).not.toBeNull()
|
|
expect(resolved!.model).toBe("openai/gpt-5.4")
|
|
})
|
|
|
|
test("FIXED: systemDefaultModel is used when no userConfig.model and no inheritedModel", () => {
|
|
// given a custom category with no default model
|
|
const categoryName = "custom-no-default"
|
|
const userCategories = { "custom-no-default": { temperature: 0.5 } } as unknown as Record<string, CategoryConfig>
|
|
const systemDefaultModel = "anthropic/claude-sonnet-4-6"
|
|
|
|
// when no inheritedModel is provided, only systemDefaultModel
|
|
const resolved = resolveCategoryConfig(categoryName, {
|
|
userCategories,
|
|
systemDefaultModel
|
|
})
|
|
|
|
// then systemDefaultModel should be returned
|
|
expect(resolved).not.toBeNull()
|
|
expect(resolved!.model).toBe("anthropic/claude-sonnet-4-6")
|
|
})
|
|
|
|
test("FIXED: userConfig.model always takes priority over everything", () => {
|
|
// given userConfig.model is explicitly set
|
|
const categoryName = "ultrabrain"
|
|
const userCategories = { "ultrabrain": { model: "custom/user-model" } }
|
|
const inheritedModel = "anthropic/claude-opus-4-6"
|
|
const systemDefaultModel = "anthropic/claude-sonnet-4-6"
|
|
|
|
// when resolveCategoryConfig is called with all sources
|
|
const resolved = resolveCategoryConfig(categoryName, {
|
|
userCategories,
|
|
inheritedModel,
|
|
systemDefaultModel
|
|
})
|
|
|
|
// then userConfig.model should win
|
|
expect(resolved).not.toBeNull()
|
|
expect(resolved!.model).toBe("custom/user-model")
|
|
})
|
|
|
|
test("FIXED: empty string in userConfig.model is treated as unset and falls back to systemDefault", () => {
|
|
// given userConfig.model is empty string "" for a custom category (no built-in model)
|
|
const categoryName = "custom-empty-model"
|
|
const userCategories = { "custom-empty-model": { model: "", temperature: 0.3 } }
|
|
const inheritedModel = "anthropic/claude-opus-4-6"
|
|
|
|
// when resolveCategoryConfig is called
|
|
const resolved = resolveCategoryConfig(categoryName, { userCategories, inheritedModel, systemDefaultModel: SYSTEM_DEFAULT_MODEL })
|
|
|
|
// then should fall back to systemDefaultModel since custom category has no built-in model
|
|
expect(resolved).not.toBeNull()
|
|
expect(resolved!.model).toBe(SYSTEM_DEFAULT_MODEL)
|
|
})
|
|
|
|
test("FIXED: undefined userConfig.model falls back to category built-in model", () => {
|
|
// given user sets a builtin category but leaves model undefined
|
|
const categoryName = "visual-engineering"
|
|
// Using type assertion since we're testing fallback behavior for categories without model
|
|
const userCategories = { "visual-engineering": { temperature: 0.2 } } as unknown as Record<string, CategoryConfig>
|
|
const inheritedModel = "anthropic/claude-opus-4-6"
|
|
|
|
// when resolveCategoryConfig is called
|
|
const resolved = resolveCategoryConfig(categoryName, { userCategories, inheritedModel, systemDefaultModel: SYSTEM_DEFAULT_MODEL })
|
|
|
|
// then should use category's built-in model (gemini-3.1-pro for visual-engineering)
|
|
expect(resolved).not.toBeNull()
|
|
expect(resolved!.model).toBe("google/gemini-3.1-pro")
|
|
})
|
|
|
|
test("systemDefaultModel is used when no other model is available", () => {
|
|
// given - custom category with no model, but systemDefaultModel is set
|
|
const categoryName = "my-custom"
|
|
// Using type assertion since we're testing fallback behavior for categories without model
|
|
const userCategories = { "my-custom": { temperature: 0.5 } } as unknown as Record<string, CategoryConfig>
|
|
const systemDefaultModel = "anthropic/claude-sonnet-4-6"
|
|
|
|
// when
|
|
const resolved = resolveCategoryConfig(categoryName, { userCategories, systemDefaultModel })
|
|
|
|
// then - actualModel should be systemDefaultModel
|
|
expect(resolved).not.toBeNull()
|
|
expect(resolved!.model).toBe(systemDefaultModel)
|
|
})
|
|
})
|
|
|
|
describe("plan family mutual delegation block", () => {
|
|
test("plan cannot delegate to plan (self-delegation)", 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: "plan", abort: new AbortController().signal }
|
|
)
|
|
|
|
//#then
|
|
expect(result).toContain("plan-family")
|
|
expect(result).toContain("directly")
|
|
})
|
|
|
|
test("prometheus 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: "prometheus", abort: new AbortController().signal }
|
|
)
|
|
|
|
//#then
|
|
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")
|
|
const mockClient = {
|
|
app: { agents: async () => ({ data: [{ name: "prometheus", 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: "Execute", subagent_type: "prometheus", run_in_background: false, load_skills: [] },
|
|
{ sessionID: "p", messageID: "m", agent: "plan", abort: new AbortController().signal }
|
|
)
|
|
|
|
//#then
|
|
expect(result).toContain("plan-family")
|
|
})
|
|
|
|
test("sisyphus CAN delegate to plan (not in plan family)", 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: "ses_ok" } }),
|
|
prompt: async () => ({ data: {} }),
|
|
promptAsync: async () => ({ data: {} }),
|
|
messages: async () => ({ data: [{ info: { role: "assistant" }, parts: [{ type: "text", text: "Plan created" }] }] }),
|
|
status: async () => ({ data: { "ses_ok": { type: "idle" } } }),
|
|
},
|
|
}
|
|
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: "sisyphus", abort: new AbortController().signal }
|
|
)
|
|
|
|
//#then
|
|
expect(result).not.toContain("plan-family")
|
|
expect(result).toContain("Plan created")
|
|
}, { timeout: 20000 })
|
|
})
|
|
|
|
describe("subagent_type model extraction (issue #1225)", () => {
|
|
test("background mode passes matched agent model to manager.launch", async () => {
|
|
// given - agent with model registered, using subagent_type with run_in_background=true
|
|
const { createDelegateTask } = require("./tools")
|
|
let launchInput: any
|
|
|
|
const mockManager = {
|
|
launch: async (input: any) => {
|
|
launchInput = input
|
|
return {
|
|
id: "task-explore",
|
|
sessionID: "ses_explore_model",
|
|
description: "Explore task",
|
|
agent: "explore",
|
|
status: "running",
|
|
}
|
|
},
|
|
}
|
|
|
|
const mockClient = {
|
|
app: {
|
|
agents: async () => ({
|
|
data: [
|
|
{ name: "explore", mode: "subagent", model: { providerID: "anthropic", modelID: "claude-haiku-4-5" } },
|
|
],
|
|
}),
|
|
},
|
|
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
|
session: {
|
|
create: async () => ({ data: { id: "ses_explore_model" } }),
|
|
prompt: async () => ({ data: {} }),
|
|
promptAsync: async () => ({ data: {} }),
|
|
messages: async () => ({ data: [] }),
|
|
},
|
|
}
|
|
|
|
const tool = createDelegateTask({
|
|
manager: mockManager,
|
|
client: mockClient,
|
|
})
|
|
|
|
const toolContext = {
|
|
sessionID: "parent-session",
|
|
messageID: "parent-message",
|
|
agent: "sisyphus",
|
|
abort: new AbortController().signal,
|
|
}
|
|
|
|
// when - delegating to explore agent via subagent_type
|
|
await tool.execute(
|
|
{
|
|
description: "Explore codebase",
|
|
prompt: "Find auth patterns",
|
|
subagent_type: "explore",
|
|
run_in_background: true,
|
|
load_skills: [],
|
|
},
|
|
toolContext
|
|
)
|
|
|
|
// then - matched agent's model should be passed to manager.launch
|
|
expect(launchInput.model).toEqual({
|
|
providerID: "anthropic",
|
|
modelID: "claude-haiku-4-5",
|
|
})
|
|
})
|
|
|
|
test("sync mode passes matched agent model to session.prompt", async () => {
|
|
// given - agent with model registered, using subagent_type with run_in_background=false
|
|
const { createDelegateTask } = require("./tools")
|
|
let promptBody: any
|
|
|
|
const mockManager = { launch: async () => ({}) }
|
|
|
|
const promptMock = async (input: any) => {
|
|
promptBody = input.body
|
|
return { data: {} }
|
|
}
|
|
|
|
const mockClient = {
|
|
app: {
|
|
agents: async () => ({
|
|
data: [
|
|
{ name: "oracle", mode: "subagent", model: { providerID: "anthropic", modelID: "claude-opus-4-6" } },
|
|
],
|
|
}),
|
|
},
|
|
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
|
session: {
|
|
get: async () => ({ data: { directory: "/project" } }),
|
|
create: async () => ({ data: { id: "ses_oracle_model" } }),
|
|
prompt: promptMock,
|
|
promptAsync: promptMock,
|
|
messages: async () => ({
|
|
data: [{ info: { role: "assistant" }, parts: [{ type: "text", text: "Consultation done" }] }],
|
|
}),
|
|
status: async () => ({ data: { "ses_oracle_model": { type: "idle" } } }),
|
|
},
|
|
}
|
|
|
|
const tool = createDelegateTask({
|
|
manager: mockManager,
|
|
client: mockClient,
|
|
})
|
|
|
|
const toolContext = {
|
|
sessionID: "parent-session",
|
|
messageID: "parent-message",
|
|
agent: "sisyphus",
|
|
abort: new AbortController().signal,
|
|
}
|
|
|
|
// when - delegating to oracle agent via subagent_type in sync mode
|
|
await tool.execute(
|
|
{
|
|
description: "Consult oracle",
|
|
prompt: "Review architecture",
|
|
subagent_type: "oracle",
|
|
run_in_background: false,
|
|
load_skills: [],
|
|
},
|
|
toolContext
|
|
)
|
|
|
|
// then - matched agent's model should be passed to session.prompt
|
|
expect(promptBody.model).toEqual({
|
|
providerID: "anthropic",
|
|
modelID: "claude-opus-4-6",
|
|
})
|
|
}, { timeout: 20000 })
|
|
|
|
test("agent without model resolves via fallback chain", async () => {
|
|
// given - agent registered without model field, fallback chain should resolve
|
|
const { createDelegateTask } = require("./tools")
|
|
let promptBody: any
|
|
|
|
const mockManager = { launch: async () => ({}) }
|
|
|
|
const promptMock = async (input: any) => {
|
|
promptBody = input.body
|
|
return { data: {} }
|
|
}
|
|
|
|
const mockClient = {
|
|
app: {
|
|
agents: async () => ({
|
|
data: [
|
|
{ name: "explore", mode: "subagent" },
|
|
],
|
|
}),
|
|
},
|
|
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
|
session: {
|
|
get: async () => ({ data: { directory: "/project" } }),
|
|
create: async () => ({ data: { id: "ses_no_model_agent" } }),
|
|
prompt: promptMock,
|
|
promptAsync: promptMock,
|
|
messages: async () => ({
|
|
data: [{ info: { role: "assistant" }, parts: [{ type: "text", text: "Done" }] }],
|
|
}),
|
|
status: async () => ({ data: { "ses_no_model_agent": { type: "idle" } } }),
|
|
},
|
|
}
|
|
|
|
const tool = createDelegateTask({
|
|
manager: mockManager,
|
|
client: mockClient,
|
|
})
|
|
|
|
const toolContext = {
|
|
sessionID: "parent-session",
|
|
messageID: "parent-message",
|
|
agent: "sisyphus",
|
|
abort: new AbortController().signal,
|
|
}
|
|
|
|
// when - delegating to agent without model
|
|
await tool.execute(
|
|
{
|
|
description: "Explore without model",
|
|
prompt: "Find something",
|
|
subagent_type: "explore",
|
|
run_in_background: false,
|
|
load_skills: [],
|
|
},
|
|
toolContext
|
|
)
|
|
|
|
// then - model should be resolved via AGENT_MODEL_REQUIREMENTS fallback chain
|
|
expect(promptBody.model).toBeDefined()
|
|
}, { timeout: 20000 })
|
|
|
|
test("agentOverrides model takes priority over matchedAgent.model (#1357)", async () => {
|
|
// given - user configured oracle to use a specific model in oh-my-opencode.json
|
|
const { createDelegateTask } = require("./tools")
|
|
let promptBody: any
|
|
|
|
const mockManager = { launch: async () => ({}) }
|
|
|
|
const promptMock = async (input: any) => {
|
|
promptBody = input.body
|
|
return { data: {} }
|
|
}
|
|
|
|
const mockClient = {
|
|
app: {
|
|
agents: async () => ({
|
|
data: [
|
|
{ name: "oracle", mode: "subagent", model: { providerID: "openai", modelID: "gpt-5.4" } },
|
|
],
|
|
}),
|
|
},
|
|
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
|
session: {
|
|
get: async () => ({ data: { directory: "/project" } }),
|
|
create: async () => ({ data: { id: "ses_override_model" } }),
|
|
prompt: promptMock,
|
|
promptAsync: promptMock,
|
|
messages: async () => ({
|
|
data: [{ info: { role: "assistant" }, parts: [{ type: "text", text: "Done" }] }],
|
|
}),
|
|
status: async () => ({ data: { "ses_override_model": { type: "idle" } } }),
|
|
},
|
|
}
|
|
|
|
const tool = createDelegateTask({
|
|
manager: mockManager,
|
|
client: mockClient,
|
|
agentOverrides: {
|
|
oracle: { model: "anthropic/claude-opus-4-6" },
|
|
},
|
|
})
|
|
|
|
const toolContext = {
|
|
sessionID: "parent-session",
|
|
messageID: "parent-message",
|
|
agent: "sisyphus",
|
|
abort: new AbortController().signal,
|
|
}
|
|
|
|
// when - delegating to oracle via subagent_type with user override
|
|
await tool.execute(
|
|
{
|
|
description: "Consult oracle with override",
|
|
prompt: "Review architecture",
|
|
subagent_type: "oracle",
|
|
run_in_background: false,
|
|
load_skills: [],
|
|
},
|
|
toolContext
|
|
)
|
|
|
|
// then - user-configured model should take priority over matchedAgent.model
|
|
expect(promptBody.model).toEqual({
|
|
providerID: "anthropic",
|
|
modelID: "claude-opus-4-6",
|
|
})
|
|
}, { timeout: 20000 })
|
|
|
|
test("agentOverrides variant is applied when model is overridden (#1357)", async () => {
|
|
// given - user configured oracle with model and variant
|
|
const { createDelegateTask } = require("./tools")
|
|
let promptBody: any
|
|
|
|
const mockManager = { launch: async () => ({}) }
|
|
|
|
const promptMock = async (input: any) => {
|
|
promptBody = input.body
|
|
return { data: {} }
|
|
}
|
|
|
|
const mockClient = {
|
|
app: {
|
|
agents: async () => ({
|
|
data: [
|
|
{ name: "oracle", mode: "subagent", model: { providerID: "openai", modelID: "gpt-5.4" } },
|
|
],
|
|
}),
|
|
},
|
|
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
|
session: {
|
|
get: async () => ({ data: { directory: "/project" } }),
|
|
create: async () => ({ data: { id: "ses_variant_test" } }),
|
|
prompt: promptMock,
|
|
promptAsync: promptMock,
|
|
messages: async () => ({
|
|
data: [{ info: { role: "assistant" }, parts: [{ type: "text", text: "Done" }] }],
|
|
}),
|
|
status: async () => ({ data: { "ses_variant_test": { type: "idle" } } }),
|
|
},
|
|
}
|
|
|
|
const tool = createDelegateTask({
|
|
manager: mockManager,
|
|
client: mockClient,
|
|
agentOverrides: {
|
|
oracle: { model: "anthropic/claude-opus-4-6", variant: "max" },
|
|
},
|
|
})
|
|
|
|
const toolContext = {
|
|
sessionID: "parent-session",
|
|
messageID: "parent-message",
|
|
agent: "sisyphus",
|
|
abort: new AbortController().signal,
|
|
}
|
|
|
|
// when - delegating to oracle via subagent_type with variant override
|
|
await tool.execute(
|
|
{
|
|
description: "Consult oracle with variant",
|
|
prompt: "Review architecture",
|
|
subagent_type: "oracle",
|
|
run_in_background: false,
|
|
load_skills: [],
|
|
},
|
|
toolContext
|
|
)
|
|
|
|
// then - user-configured variant should be applied
|
|
expect(promptBody.variant).toBe("max")
|
|
}, { timeout: 20000 })
|
|
|
|
test("fallback chain resolves model when no override and no matchedAgent.model (#1357)", async () => {
|
|
// given - agent registered without model, no override, but AGENT_MODEL_REQUIREMENTS has fallback
|
|
const { createDelegateTask } = require("./tools")
|
|
let promptBody: any
|
|
|
|
const mockManager = { launch: async () => ({}) }
|
|
|
|
const promptMock = async (input: any) => {
|
|
promptBody = input.body
|
|
return { data: {} }
|
|
}
|
|
|
|
const mockClient = {
|
|
app: {
|
|
agents: async () => ({
|
|
data: [
|
|
{ name: "oracle", mode: "subagent" }, // no model field
|
|
],
|
|
}),
|
|
},
|
|
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
|
session: {
|
|
get: async () => ({ data: { directory: "/project" } }),
|
|
create: async () => ({ data: { id: "ses_fallback_test" } }),
|
|
prompt: promptMock,
|
|
promptAsync: promptMock,
|
|
messages: async () => ({
|
|
data: [{ info: { role: "assistant" }, parts: [{ type: "text", text: "Done" }] }],
|
|
}),
|
|
status: async () => ({ data: { "ses_fallback_test": { type: "idle" } } }),
|
|
},
|
|
}
|
|
|
|
const tool = createDelegateTask({
|
|
manager: mockManager,
|
|
client: mockClient,
|
|
// no agentOverrides
|
|
connectedProvidersOverride: TEST_CONNECTED_PROVIDERS,
|
|
availableModelsOverride: createTestAvailableModels(),
|
|
})
|
|
|
|
const toolContext = {
|
|
sessionID: "parent-session",
|
|
messageID: "parent-message",
|
|
agent: "sisyphus",
|
|
abort: new AbortController().signal,
|
|
}
|
|
|
|
// when - delegating to oracle with no override and no matchedAgent model
|
|
await tool.execute(
|
|
{
|
|
description: "Consult oracle with fallback",
|
|
prompt: "Review architecture",
|
|
subagent_type: "oracle",
|
|
run_in_background: false,
|
|
load_skills: [],
|
|
},
|
|
toolContext
|
|
)
|
|
|
|
// then - should resolve via AGENT_MODEL_REQUIREMENTS fallback chain for oracle
|
|
// oracle fallback chain: gpt-5.4 (openai) > gemini-3.1-pro (google) > claude-opus-4-6 (anthropic)
|
|
// Since openai is in connectedProviders, should resolve to openai/gpt-5.4
|
|
expect(promptBody.model).toBeDefined()
|
|
expect(promptBody.model.providerID).toBe("openai")
|
|
expect(promptBody.model.modelID).toContain("gpt-5.4")
|
|
}, { timeout: 20000 })
|
|
})
|
|
|
|
describe("subagent task permission", () => {
|
|
test("plan subagent should have task permission enabled", async () => {
|
|
//#given - sisyphus delegates to plan agent
|
|
const { createDelegateTask } = require("./tools")
|
|
let promptBody: any
|
|
|
|
const mockManager = { launch: async () => ({}) }
|
|
|
|
const promptMock = async (input: any) => {
|
|
promptBody = input.body
|
|
return { data: {} }
|
|
}
|
|
|
|
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: "ses_plan_delegate" } }),
|
|
prompt: promptMock,
|
|
promptAsync: promptMock,
|
|
messages: async () => ({
|
|
data: [{ info: { role: "assistant" }, parts: [{ type: "text", text: "Plan created" }] }]
|
|
}),
|
|
status: async () => ({ data: { "ses_plan_delegate": { type: "idle" } } }),
|
|
},
|
|
}
|
|
|
|
const tool = createDelegateTask({
|
|
manager: mockManager,
|
|
client: mockClient,
|
|
})
|
|
|
|
const toolContext = {
|
|
sessionID: "parent-session",
|
|
messageID: "parent-message",
|
|
agent: "sisyphus",
|
|
abort: new AbortController().signal,
|
|
}
|
|
|
|
//#when - sisyphus delegates to plan
|
|
await tool.execute(
|
|
{
|
|
description: "Test plan task permission",
|
|
prompt: "Create a plan",
|
|
subagent_type: "plan",
|
|
run_in_background: false,
|
|
load_skills: [],
|
|
},
|
|
toolContext
|
|
)
|
|
|
|
//#then - plan agent should have task permission
|
|
expect(promptBody.tools.task).toBe(true)
|
|
}, { timeout: 20000 })
|
|
|
|
test("prometheus subagent should have task permission as part of the plan family", async () => {
|
|
//#given
|
|
const { createDelegateTask } = require("./tools")
|
|
let promptBody: any
|
|
const promptMock = async (input: any) => { promptBody = input.body; return { data: {} } }
|
|
const mockClient = {
|
|
app: { agents: async () => ({ data: [{ name: "prometheus", mode: "subagent" }] }) },
|
|
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
|
session: {
|
|
get: async () => ({ data: { directory: "/project" } }),
|
|
create: async () => ({ data: { id: "ses_prometheus_task" } }),
|
|
prompt: promptMock,
|
|
promptAsync: promptMock,
|
|
messages: async () => ({ data: [{ info: { role: "assistant" }, parts: [{ type: "text", text: "Plan created" }] }] }),
|
|
status: async () => ({ data: { "ses_prometheus_task": { type: "idle" } } }),
|
|
},
|
|
}
|
|
const tool = createDelegateTask({ manager: { launch: async () => ({}) }, client: mockClient })
|
|
|
|
//#when
|
|
await tool.execute(
|
|
{ description: "Test prometheus task permission", prompt: "Create a plan", subagent_type: "prometheus", run_in_background: false, load_skills: [] },
|
|
{ sessionID: "p", messageID: "m", agent: "sisyphus", abort: new AbortController().signal }
|
|
)
|
|
|
|
//#then - prometheus shares task permission with the plan family
|
|
expect(promptBody.tools.task).toBe(true)
|
|
}, { timeout: 20000 })
|
|
|
|
test("non-plan subagent should NOT have task permission", async () => {
|
|
//#given - sisyphus delegates to oracle (non-plan)
|
|
const { createDelegateTask } = require("./tools")
|
|
let promptBody: any
|
|
|
|
const mockManager = { launch: async () => ({}) }
|
|
const mockClient = {
|
|
app: { agents: async () => ({ data: [{ name: "oracle", mode: "subagent" }] }) },
|
|
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
|
session: {
|
|
get: async () => ({ data: { directory: "/project" } }),
|
|
create: async () => ({ data: { id: "ses_oracle_no_delegate" } }),
|
|
prompt: async (input: any) => {
|
|
promptBody = input.body
|
|
return { data: {} }
|
|
},
|
|
promptAsync: async (input: any) => {
|
|
promptBody = input.body
|
|
return { data: {} }
|
|
},
|
|
messages: async () => ({
|
|
data: [{ info: { role: "assistant" }, parts: [{ type: "text", text: "Consultation done" }] }]
|
|
}),
|
|
status: async () => ({ data: { "ses_oracle_no_delegate": { type: "idle" } } }),
|
|
},
|
|
}
|
|
|
|
const tool = createDelegateTask({
|
|
manager: mockManager,
|
|
client: mockClient,
|
|
})
|
|
|
|
const toolContext = {
|
|
sessionID: "parent-session",
|
|
messageID: "parent-message",
|
|
agent: "sisyphus",
|
|
abort: new AbortController().signal,
|
|
}
|
|
|
|
// when - sisyphus delegates to oracle
|
|
await tool.execute(
|
|
{
|
|
description: "Test oracle no task permission",
|
|
prompt: "Consult on architecture",
|
|
subagent_type: "oracle",
|
|
run_in_background: false,
|
|
load_skills: [],
|
|
},
|
|
toolContext
|
|
)
|
|
|
|
// then - oracle should NOT have task permission
|
|
expect(promptBody.tools.task).toBe(false)
|
|
}, { timeout: 20000 })
|
|
})
|
|
|
|
describe("session title and metadata format (OpenCode compatibility)", () => {
|
|
test("sync session title follows OpenCode format: '{description} (@{agent} subagent)'", async () => {
|
|
// given
|
|
const { createDelegateTask } = require("./tools")
|
|
let createBody: any
|
|
|
|
const mockManager = { launch: async () => ({}) }
|
|
const mockClient = {
|
|
app: { agents: async () => ({ data: [] }) },
|
|
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
|
model: { list: async () => [{ id: SYSTEM_DEFAULT_MODEL }] },
|
|
session: {
|
|
get: async () => ({ data: { directory: "/project" } }),
|
|
create: async (input: any) => {
|
|
createBody = input.body
|
|
return { data: { id: "ses_title_test" } }
|
|
},
|
|
prompt: async () => ({ data: {} }),
|
|
promptAsync: async () => ({ data: {} }),
|
|
messages: async () => ({
|
|
data: [{ info: { role: "assistant" }, parts: [{ type: "text", text: "done" }] }]
|
|
}),
|
|
status: async () => ({ data: { "ses_title_test": { type: "idle" } } }),
|
|
},
|
|
}
|
|
|
|
const tool = createDelegateTask({
|
|
manager: mockManager,
|
|
client: mockClient,
|
|
})
|
|
|
|
const toolContext = {
|
|
sessionID: "parent-session",
|
|
messageID: "parent-message",
|
|
agent: "sisyphus",
|
|
abort: new AbortController().signal,
|
|
}
|
|
|
|
// when - sync task with category
|
|
await tool.execute(
|
|
{
|
|
description: "Implement feature X",
|
|
prompt: "Build the feature",
|
|
category: "quick",
|
|
run_in_background: false,
|
|
load_skills: [],
|
|
},
|
|
toolContext
|
|
)
|
|
|
|
// then - title should follow OpenCode format
|
|
expect(createBody.title).toBe("Implement feature X (@Sisyphus-Junior subagent)")
|
|
}, { timeout: 10000 })
|
|
|
|
test("sync task output includes <task_metadata> block with session_id", async () => {
|
|
// given
|
|
const { createDelegateTask } = require("./tools")
|
|
|
|
const mockManager = { launch: async () => ({}) }
|
|
const mockClient = {
|
|
app: { agents: async () => ({ data: [] }) },
|
|
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
|
model: { list: async () => [{ id: SYSTEM_DEFAULT_MODEL }] },
|
|
session: {
|
|
get: async () => ({ data: { directory: "/project" } }),
|
|
create: async () => ({ data: { id: "ses_metadata_test" } }),
|
|
prompt: async () => ({ data: {} }),
|
|
promptAsync: async () => ({ data: {} }),
|
|
messages: async () => ({
|
|
data: [{ info: { role: "assistant" }, parts: [{ type: "text", text: "Task completed" }] }]
|
|
}),
|
|
status: async () => ({ data: { "ses_metadata_test": { type: "idle" } } }),
|
|
},
|
|
}
|
|
|
|
const tool = createDelegateTask({
|
|
manager: mockManager,
|
|
client: mockClient,
|
|
})
|
|
|
|
const toolContext = {
|
|
sessionID: "parent-session",
|
|
messageID: "parent-message",
|
|
agent: "sisyphus",
|
|
abort: new AbortController().signal,
|
|
}
|
|
|
|
// when
|
|
const result = await tool.execute(
|
|
{
|
|
description: "Test metadata format",
|
|
prompt: "Do something",
|
|
category: "quick",
|
|
run_in_background: false,
|
|
load_skills: [],
|
|
},
|
|
toolContext
|
|
)
|
|
|
|
// then - output should contain <task_metadata> block
|
|
expect(result).toContain("<task_metadata>")
|
|
expect(result).toContain("session_id: ses_metadata_test")
|
|
expect(result).toContain("</task_metadata>")
|
|
}, { timeout: 10000 })
|
|
|
|
test("background task output includes <task_metadata> block with session_id", async () => {
|
|
// given
|
|
const { createDelegateTask } = require("./tools")
|
|
|
|
const mockManager = {
|
|
launch: async () => ({
|
|
id: "bg_meta_test",
|
|
sessionID: "ses_bg_metadata",
|
|
description: "Background metadata test",
|
|
agent: "sisyphus-junior",
|
|
status: "running",
|
|
}),
|
|
}
|
|
const mockClient = {
|
|
app: { agents: async () => ({ data: [] }) },
|
|
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
|
model: { list: async () => [] },
|
|
session: {
|
|
create: async () => ({ data: { id: "test-session" } }),
|
|
prompt: async () => ({ data: {} }),
|
|
promptAsync: async () => ({ data: {} }),
|
|
messages: async () => ({ data: [] }),
|
|
},
|
|
}
|
|
|
|
const tool = createDelegateTask({
|
|
manager: mockManager,
|
|
client: mockClient,
|
|
userCategories: {
|
|
"sisyphus-junior": { model: "anthropic/claude-sonnet-4-6" },
|
|
},
|
|
})
|
|
|
|
const toolContext = {
|
|
sessionID: "parent-session",
|
|
messageID: "parent-message",
|
|
agent: "sisyphus",
|
|
abort: new AbortController().signal,
|
|
}
|
|
|
|
// when
|
|
const result = await tool.execute(
|
|
{
|
|
description: "Background metadata test",
|
|
prompt: "Do something",
|
|
category: "quick",
|
|
run_in_background: true,
|
|
load_skills: [],
|
|
},
|
|
toolContext
|
|
)
|
|
|
|
// then - output should contain <task_metadata> block
|
|
expect(result).toContain("<task_metadata>")
|
|
expect(result).toContain("session_id: ses_bg_metadata")
|
|
expect(result).toContain("</task_metadata>")
|
|
}, { timeout: 10000 })
|
|
})
|
|
})
|