Merge branch 'dev' into fix/user-agents-callable-v2
Resolves conflicts with ZWSP agent ordering, display name unification, and test file migration to zauc-mocks split.
This commit is contained in:
+6
-6
@@ -1,6 +1,6 @@
|
||||
# src/tools/ — 26 Tools Across 15 Directories
|
||||
# src/tools/ - 26 Tools Across 16 Directories
|
||||
|
||||
**Generated:** 2026-03-06
|
||||
**Generated:** 2026-04-11
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
| `background_output` | `createBackgroundOutput` | task_id, block, timeout, full_session, include_thinking, message_limit, since_message_id, thinking_max_chars |
|
||||
| `background_cancel` | `createBackgroundCancel` | taskId, all |
|
||||
|
||||
### LSP Refactoring (6) — Direct ToolDefinition
|
||||
### LSP Refactoring (6) - Direct ToolDefinition
|
||||
|
||||
| Tool | Parameters |
|
||||
|------|------------|
|
||||
@@ -81,7 +81,7 @@
|
||||
| `interactive_bash` | Direct | tmux_command |
|
||||
| `look_at` | `createLookAt` | file_path, image_data, goal |
|
||||
|
||||
### Editing (1) — Conditional
|
||||
### Editing (1) - Conditional
|
||||
|
||||
| Tool | Factory | Parameters |
|
||||
|------|---------|------------|
|
||||
@@ -93,12 +93,12 @@
|
||||
|----------|-------|--------|
|
||||
| visual-engineering | gemini-3.1-pro high | Frontend, UI/UX |
|
||||
| ultrabrain | gpt-5.4 xhigh | Hard logic |
|
||||
| deep | gpt-5.3-codex medium | Autonomous problem-solving |
|
||||
| deep | gpt-5.4 medium | Autonomous problem-solving |
|
||||
| artistry | gemini-3.1-pro high | Creative approaches |
|
||||
| quick | gpt-5.4-mini | Trivial tasks |
|
||||
| unspecified-low | claude-sonnet-4-6 | Moderate effort |
|
||||
| unspecified-high | claude-opus-4-6 max | High effort |
|
||||
| writing | kimi-k2p5 | Documentation |
|
||||
| writing | gemini-3-flash | Documentation |
|
||||
|
||||
## HOW TO ADD A TOOL
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
getCachedBinaryPath as getCachedBinaryPathShared,
|
||||
} from "../../shared/binary-downloader"
|
||||
import { log } from "../../shared/logger"
|
||||
import { CACHE_DIR_NAME, PUBLISHED_PACKAGE_NAME } from "../../shared/plugin-identity"
|
||||
|
||||
const REPO = "ast-grep/ast-grep"
|
||||
|
||||
@@ -47,12 +48,12 @@ export function getCacheDir(): string {
|
||||
if (process.platform === "win32") {
|
||||
const localAppData = process.env.LOCALAPPDATA || process.env.APPDATA
|
||||
const base = localAppData || join(homedir(), "AppData", "Local")
|
||||
return join(base, "oh-my-opencode", "bin")
|
||||
return join(base, CACHE_DIR_NAME, "bin")
|
||||
}
|
||||
|
||||
const xdgCache = process.env.XDG_CACHE_HOME
|
||||
const base = xdgCache || join(homedir(), ".cache")
|
||||
return join(base, "oh-my-opencode", "bin")
|
||||
return join(base, CACHE_DIR_NAME, "bin")
|
||||
}
|
||||
|
||||
export function getBinaryName(): string {
|
||||
@@ -70,7 +71,7 @@ export async function downloadAstGrep(version: string = DEFAULT_VERSION): Promis
|
||||
const platformInfo = PLATFORM_MAP[platformKey]
|
||||
|
||||
if (!platformInfo) {
|
||||
log(`[oh-my-opencode] Unsupported platform for ast-grep: ${platformKey}`)
|
||||
log(`[${PUBLISHED_PACKAGE_NAME}] Unsupported platform for ast-grep: ${platformKey}`)
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -86,7 +87,7 @@ export async function downloadAstGrep(version: string = DEFAULT_VERSION): Promis
|
||||
const assetName = `app-${arch}-${os}.zip`
|
||||
const downloadUrl = `https://github.com/${REPO}/releases/download/${version}/${assetName}`
|
||||
|
||||
log(`[oh-my-opencode] Downloading ast-grep binary...`)
|
||||
log(`[${PUBLISHED_PACKAGE_NAME}] Downloading ast-grep binary...`)
|
||||
|
||||
try {
|
||||
const archivePath = join(cacheDir, assetName)
|
||||
@@ -96,12 +97,12 @@ export async function downloadAstGrep(version: string = DEFAULT_VERSION): Promis
|
||||
cleanupArchive(archivePath)
|
||||
ensureExecutable(binaryPath)
|
||||
|
||||
log(`[oh-my-opencode] ast-grep binary ready.`)
|
||||
log(`[${PUBLISHED_PACKAGE_NAME}] ast-grep binary ready.`)
|
||||
|
||||
return binaryPath
|
||||
} catch (err) {
|
||||
log(
|
||||
`[oh-my-opencode] Failed to download ast-grep: ${err instanceof Error ? err.message : err}`
|
||||
`[${PUBLISHED_PACKAGE_NAME}] Failed to download ast-grep: ${err instanceof Error ? err.message : err}`
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# src/tools/background-task/ — Background Task Tool Wrappers
|
||||
|
||||
**Generated:** 2026-03-06
|
||||
**Generated:** 2026-04-11
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ export const BACKGROUND_TASK_DESCRIPTION = `Run agent task in background. Return
|
||||
|
||||
Use \`background_output\` to get results. Prompts MUST be in English.`
|
||||
|
||||
export const BACKGROUND_OUTPUT_DESCRIPTION = `Get output from background task. Use full_session=true to fetch session messages with filters. System notifies on completion, so block=true rarely needed. - Timeout values are in milliseconds (ms), NOT seconds.`
|
||||
export const BACKGROUND_OUTPUT_DESCRIPTION = `Get output from background task. Use full_session=true to fetch session messages with filters. System notifies on completion, so block=true rarely needed. - Timeout values are in milliseconds (ms), NOT seconds.
|
||||
|
||||
IMPORTANT: ONLY call this tool AFTER receiving a <system-reminder> notification for the task. Do NOT call immediately after launching a background task - wait for the notification first.`
|
||||
|
||||
export const BACKGROUND_CANCEL_DESCRIPTION = `Cancel running background task(s). Use all=true to cancel ALL before final answer.`
|
||||
|
||||
@@ -6,7 +6,13 @@ import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { createBackgroundTask } from "./create-background-task"
|
||||
|
||||
describe("createBackgroundTask", () => {
|
||||
const launchMock = mock(() => Promise.resolve({
|
||||
const launchMock = mock(async (): Promise<{
|
||||
id: string
|
||||
sessionID: string | null
|
||||
description: string
|
||||
agent: string
|
||||
status: string
|
||||
}> => ({
|
||||
id: "test-task-id",
|
||||
sessionID: null,
|
||||
description: "Test task",
|
||||
@@ -32,7 +38,11 @@ describe("createBackgroundTask", () => {
|
||||
sessionID: "test-session",
|
||||
messageID: "test-message",
|
||||
agent: "test-agent",
|
||||
directory: "/Users/yeongyu/local-workspaces/omo",
|
||||
worktree: "/Users/yeongyu/local-workspaces/omo",
|
||||
abort: new AbortController().signal,
|
||||
metadata: () => {},
|
||||
ask: async () => {},
|
||||
}
|
||||
|
||||
const testArgs = {
|
||||
@@ -65,4 +75,83 @@ describe("createBackgroundTask", () => {
|
||||
expect(result).toContain("Task entered error state")
|
||||
expect(result).toContain("test-task-id")
|
||||
})
|
||||
|
||||
test("keeps launched background task alive when parent aborts before session id resolves", async () => {
|
||||
//#given - background launch should survive parent abort during session-id wait
|
||||
const abortController = new AbortController()
|
||||
launchMock.mockResolvedValueOnce({
|
||||
id: "test-task-id",
|
||||
sessionID: null,
|
||||
description: "Test task",
|
||||
agent: "test-agent",
|
||||
status: "pending",
|
||||
})
|
||||
getTaskMock.mockImplementationOnce(() => {
|
||||
abortController.abort()
|
||||
return {
|
||||
id: "test-task-id",
|
||||
sessionID: null,
|
||||
description: "Test task",
|
||||
agent: "test-agent",
|
||||
status: "pending",
|
||||
}
|
||||
})
|
||||
|
||||
//#when
|
||||
const result = await tool.execute(testArgs, {
|
||||
...testContext,
|
||||
abort: abortController.signal,
|
||||
})
|
||||
|
||||
//#then - tool should still report successful launch instead of cancelling child task
|
||||
expect(result).toContain("Background task launched successfully.")
|
||||
expect(result).toContain("Task ID: test-task-id")
|
||||
expect(result).not.toContain("Task aborted and cancelled while waiting for session to start")
|
||||
})
|
||||
|
||||
test("keeps sibling background task alive when two tasks start concurrently", async () => {
|
||||
//#given - one aborted parent call should not interrupt a sibling launch from the same parent session
|
||||
const firstAbortController = new AbortController()
|
||||
const secondAbortController = new AbortController()
|
||||
const states = new Map([
|
||||
["task-1", { reads: 0, abortOnFirstRead: true, sessionID: "ses-1" }],
|
||||
["task-2", { reads: 0, abortOnFirstRead: false, sessionID: "ses-2" }],
|
||||
])
|
||||
let launchCount = 0
|
||||
launchMock.mockImplementation(async () => {
|
||||
launchCount += 1
|
||||
return launchCount === 1
|
||||
? { id: "task-1", sessionID: null, description: "Task 1", agent: "test-agent", status: "pending" }
|
||||
: { id: "task-2", sessionID: null, description: "Task 2", agent: "test-agent", status: "pending" }
|
||||
})
|
||||
getTaskMock.mockImplementation((taskID: string) => {
|
||||
const state = states.get(taskID)
|
||||
if (!state) return undefined
|
||||
state.reads += 1
|
||||
if (state.abortOnFirstRead && state.reads === 1) {
|
||||
firstAbortController.abort()
|
||||
}
|
||||
return state.reads >= 2
|
||||
? { id: taskID, sessionID: state.sessionID, description: "Task", agent: "test-agent", status: "pending" }
|
||||
: { id: taskID, sessionID: null, description: "Task", agent: "test-agent", status: "pending" }
|
||||
})
|
||||
|
||||
//#when
|
||||
const [firstResult, secondResult] = await Promise.all([
|
||||
tool.execute(testArgs, {
|
||||
...testContext,
|
||||
abort: firstAbortController.signal,
|
||||
}),
|
||||
tool.execute(testArgs, {
|
||||
...testContext,
|
||||
abort: secondAbortController.signal,
|
||||
}),
|
||||
])
|
||||
|
||||
//#then - both launches still succeed and the sibling is not marked interrupted
|
||||
expect(firstResult).toContain("Background task launched successfully.")
|
||||
expect(secondResult).toContain("Background task launched successfully.")
|
||||
expect(secondResult).toContain("Task ID: task-2")
|
||||
expect(secondResult).not.toContain("interrupt")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -80,16 +80,18 @@ export function createBackgroundTask(
|
||||
const waitStart = Date.now()
|
||||
let sessionId = task.sessionID
|
||||
while (!sessionId && Date.now() - waitStart < WAIT_FOR_SESSION_TIMEOUT_MS) {
|
||||
if (ctx.abort?.aborted) {
|
||||
await manager.cancelTask(task.id)
|
||||
return `Task aborted and cancelled while waiting for session to start.\n\nTask ID: ${task.id}`
|
||||
}
|
||||
await delay(WAIT_FOR_SESSION_INTERVAL_MS)
|
||||
const updated = manager.getTask(task.id)
|
||||
if (!updated || updated.status === "error" || updated.status === "cancelled" || updated.status === "interrupt") {
|
||||
return `Task ${!updated ? "was deleted" : `entered error state`}\.\n\nTask ID: ${task.id}`
|
||||
if (updated?.status === "error" || updated?.status === "cancelled" || updated?.status === "interrupt") {
|
||||
return `Task ${`entered error state`}\.\n\nTask ID: ${task.id}`
|
||||
}
|
||||
sessionId = updated?.sessionID
|
||||
if (sessionId) {
|
||||
break
|
||||
}
|
||||
if (ctx.abort?.aborted) {
|
||||
break
|
||||
}
|
||||
await delay(WAIT_FOR_SESSION_INTERVAL_MS)
|
||||
}
|
||||
|
||||
const bgMeta = {
|
||||
@@ -112,10 +114,9 @@ Description: ${task.description}
|
||||
Agent: ${task.agent}
|
||||
Status: ${task.status}
|
||||
|
||||
The system will notify you when the task completes.
|
||||
Use \`background_output\` tool with task_id="${task.id}" to check progress:
|
||||
- block=false (default): Check status immediately - returns full status info
|
||||
- block=true: Wait for completion (rarely needed since system notifies)`
|
||||
System notifies on completion. Use \`background_output\` with task_id="${task.id}" to check.
|
||||
|
||||
Do NOT call background_output now. Wait for <system-reminder> notification first.`
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return `[ERROR] Failed to launch background task: ${message}`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# src/tools/call-omo-agent/ — Direct Agent Invocation Tool
|
||||
|
||||
**Generated:** 2026-03-06
|
||||
**Generated:** 2026-04-11
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
|
||||
@@ -5,7 +5,13 @@ import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { executeBackgroundAgent } from "./background-agent-executor"
|
||||
|
||||
describe("executeBackgroundAgent", () => {
|
||||
const launchMock = mock(() => Promise.resolve({
|
||||
const launchMock = mock(async (): Promise<{
|
||||
id: string
|
||||
sessionID: string | null
|
||||
description: string
|
||||
agent: string
|
||||
status: string
|
||||
}> => ({
|
||||
id: "test-task-id",
|
||||
sessionID: null,
|
||||
description: "Test task",
|
||||
@@ -64,4 +70,86 @@ describe("executeBackgroundAgent", () => {
|
||||
expect(result).toContain("interrupt")
|
||||
expect(result).toContain("test-task-id")
|
||||
})
|
||||
|
||||
test("keeps launched background task alive when parent aborts before session id resolves", async () => {
|
||||
//#given - parent abort after launch should stop waiting, not fail the background task
|
||||
const abortController = new AbortController()
|
||||
launchMock.mockResolvedValueOnce({
|
||||
id: "test-task-id",
|
||||
sessionID: null,
|
||||
description: "Test task",
|
||||
agent: "test-agent",
|
||||
status: "pending",
|
||||
})
|
||||
getTaskMock.mockImplementationOnce(() => {
|
||||
abortController.abort()
|
||||
return { id: "test-task-id", sessionID: null, description: "Test task", agent: "test-agent", status: "pending" }
|
||||
})
|
||||
|
||||
//#when
|
||||
const result = await executeBackgroundAgent(
|
||||
testArgs,
|
||||
{
|
||||
...testContext,
|
||||
abort: abortController.signal,
|
||||
},
|
||||
mockManager,
|
||||
mockClient
|
||||
)
|
||||
|
||||
//#then - background launch should still be reported as launched
|
||||
expect(result).toContain("Background agent task launched successfully")
|
||||
expect(result).toContain("Task ID: test-task-id")
|
||||
expect(result).not.toContain("Task aborted while waiting for session to start")
|
||||
})
|
||||
|
||||
test("keeps sibling background agent launch alive when two tasks start concurrently", async () => {
|
||||
//#given - one aborted parent call should not interrupt a sibling launch from the same parent session
|
||||
const firstAbortController = new AbortController()
|
||||
const secondAbortController = new AbortController()
|
||||
const states = new Map([
|
||||
["task-1", { reads: 0, abortOnFirstRead: true, sessionID: "ses-1" }],
|
||||
["task-2", { reads: 0, abortOnFirstRead: false, sessionID: "ses-2" }],
|
||||
])
|
||||
let launchCount = 0
|
||||
launchMock.mockImplementation(async () => {
|
||||
launchCount += 1
|
||||
return launchCount === 1
|
||||
? { id: "task-1", sessionID: null, description: "Task 1", agent: "test-agent", status: "pending" }
|
||||
: { id: "task-2", sessionID: null, description: "Task 2", agent: "test-agent", status: "pending" }
|
||||
})
|
||||
getTaskMock.mockImplementation((taskID: string) => {
|
||||
const state = states.get(taskID)
|
||||
if (!state) return undefined
|
||||
state.reads += 1
|
||||
if (state.abortOnFirstRead && state.reads === 1) {
|
||||
firstAbortController.abort()
|
||||
}
|
||||
return state.reads >= 2
|
||||
? { id: taskID, sessionID: state.sessionID, description: "Task", agent: "test-agent", status: "pending" }
|
||||
: { id: taskID, sessionID: null, description: "Task", agent: "test-agent", status: "pending" }
|
||||
})
|
||||
|
||||
//#when
|
||||
const [firstResult, secondResult] = await Promise.all([
|
||||
executeBackgroundAgent(
|
||||
testArgs,
|
||||
{ ...testContext, abort: firstAbortController.signal },
|
||||
mockManager,
|
||||
mockClient,
|
||||
),
|
||||
executeBackgroundAgent(
|
||||
testArgs,
|
||||
{ ...testContext, abort: secondAbortController.signal },
|
||||
mockManager,
|
||||
mockClient,
|
||||
),
|
||||
])
|
||||
|
||||
//#then - both launches still succeed and the sibling is not marked interrupted
|
||||
expect(firstResult).toContain("Background agent task launched successfully")
|
||||
expect(secondResult).toContain("Background agent task launched successfully")
|
||||
expect(secondResult).toContain("Task ID: task-2")
|
||||
expect(secondResult).not.toContain("interrupt")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -52,17 +52,20 @@ export async function executeBackgroundAgent(
|
||||
|
||||
let sessionId = task.sessionID
|
||||
while (!sessionId && Date.now() - waitStart < waitTimeoutMs) {
|
||||
if (toolContext.abort?.aborted) {
|
||||
return `Task aborted while waiting for session to start.\n\nTask ID: ${task.id}`
|
||||
}
|
||||
const updated = manager.getTask(task.id)
|
||||
if (updated?.status === "error" || updated?.status === "cancelled" || updated?.status === "interrupt") {
|
||||
return `Task failed to start (status: ${updated.status}).\n\nTask ID: ${task.id}`
|
||||
}
|
||||
sessionId = updated?.sessionID
|
||||
if (sessionId) {
|
||||
break
|
||||
}
|
||||
if (toolContext.abort?.aborted) {
|
||||
break
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, waitIntervalMs)
|
||||
})
|
||||
sessionId = manager.getTask(task.id)?.sessionID
|
||||
}
|
||||
|
||||
await toolContext.metadata?.({
|
||||
@@ -78,10 +81,9 @@ Description: ${task.description}
|
||||
Agent: ${task.agent} (subagent)
|
||||
Status: ${task.status}
|
||||
|
||||
The system will notify you when the task completes.
|
||||
Use \`background_output\` tool with task_id="${task.id}" to check progress:
|
||||
- block=false (default): Check status immediately - returns full status info
|
||||
- block=true: Wait for completion (rarely needed since system notifies)`
|
||||
System notifies on completion. Use \`background_output\` with task_id="${task.id}" to check.
|
||||
|
||||
Do NOT call background_output now. Wait for <system-reminder> notification first.`
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return `Failed to launch background agent task: ${message}`
|
||||
|
||||
@@ -5,7 +5,13 @@ import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { executeBackground } from "./background-executor"
|
||||
|
||||
describe("executeBackground", () => {
|
||||
const launchMock = mock(() => Promise.resolve({
|
||||
const launchMock = mock(async (_input?: { fallbackChain?: unknown }): Promise<{
|
||||
id: string
|
||||
sessionID: string | null
|
||||
description: string
|
||||
agent: string
|
||||
status: string
|
||||
}> => ({
|
||||
id: "test-task-id",
|
||||
sessionID: null,
|
||||
description: "Test task",
|
||||
@@ -83,7 +89,96 @@ describe("executeBackground", () => {
|
||||
await executeBackground(testArgs, testContext, mockManager, mockClient, fallbackChain)
|
||||
|
||||
//#then
|
||||
const launchArgs = launchMock.mock.calls.at(-1)?.[0]
|
||||
const latestCall = [...launchMock.mock.calls].pop()
|
||||
if (!latestCall) {
|
||||
throw new Error("Expected background manager launch to be called")
|
||||
}
|
||||
const launchArgs = latestCall[0]
|
||||
if (!launchArgs) {
|
||||
throw new Error("Expected launch arguments")
|
||||
}
|
||||
expect(launchArgs.fallbackChain).toEqual(fallbackChain)
|
||||
})
|
||||
|
||||
test("keeps launched background task alive when parent aborts before session id resolves", async () => {
|
||||
//#given - parent abort after launch should stop waiting, not fail the background task
|
||||
const abortController = new AbortController()
|
||||
launchMock.mockResolvedValueOnce({
|
||||
id: "test-task-id",
|
||||
sessionID: null,
|
||||
description: "Test task",
|
||||
agent: "test-agent",
|
||||
status: "pending",
|
||||
})
|
||||
getTaskMock.mockImplementationOnce(() => {
|
||||
abortController.abort()
|
||||
return { id: "test-task-id", sessionID: null, description: "Test task", agent: "test-agent", status: "pending" }
|
||||
})
|
||||
|
||||
//#when
|
||||
const result = await executeBackground(
|
||||
testArgs,
|
||||
{
|
||||
...testContext,
|
||||
abort: abortController.signal,
|
||||
},
|
||||
mockManager,
|
||||
mockClient
|
||||
)
|
||||
|
||||
//#then - background launch should still be reported as launched
|
||||
expect(result).toContain("Background agent task launched successfully")
|
||||
expect(result).toContain("Task ID: test-task-id")
|
||||
expect(result).not.toContain("Task aborted while waiting for session to start")
|
||||
})
|
||||
|
||||
test("keeps sibling background launch alive when two tasks start concurrently", async () => {
|
||||
//#given - one aborted parent call should not interrupt a sibling launch from the same parent session
|
||||
const firstAbortController = new AbortController()
|
||||
const secondAbortController = new AbortController()
|
||||
const states = new Map([
|
||||
["task-1", { reads: 0, abortOnFirstRead: true, sessionID: "ses-1" }],
|
||||
["task-2", { reads: 0, abortOnFirstRead: false, sessionID: "ses-2" }],
|
||||
])
|
||||
let launchCount = 0
|
||||
launchMock.mockImplementation(async () => {
|
||||
launchCount += 1
|
||||
return launchCount === 1
|
||||
? { id: "task-1", sessionID: null, description: "Task 1", agent: "test-agent", status: "pending" }
|
||||
: { id: "task-2", sessionID: null, description: "Task 2", agent: "test-agent", status: "pending" }
|
||||
})
|
||||
getTaskMock.mockImplementation((taskID: string) => {
|
||||
const state = states.get(taskID)
|
||||
if (!state) return undefined
|
||||
state.reads += 1
|
||||
if (state.abortOnFirstRead && state.reads === 1) {
|
||||
firstAbortController.abort()
|
||||
}
|
||||
return state.reads >= 2
|
||||
? { id: taskID, sessionID: state.sessionID, description: "Task", agent: "test-agent", status: "pending" }
|
||||
: { id: taskID, sessionID: null, description: "Task", agent: "test-agent", status: "pending" }
|
||||
})
|
||||
|
||||
//#when
|
||||
const [firstResult, secondResult] = await Promise.all([
|
||||
executeBackground(
|
||||
testArgs,
|
||||
{ ...testContext, abort: firstAbortController.signal },
|
||||
mockManager,
|
||||
mockClient,
|
||||
),
|
||||
executeBackground(
|
||||
testArgs,
|
||||
{ ...testContext, abort: secondAbortController.signal },
|
||||
mockManager,
|
||||
mockClient,
|
||||
),
|
||||
])
|
||||
|
||||
//#then - both launches still succeed and the sibling is not marked interrupted
|
||||
expect(firstResult).toContain("Background agent task launched successfully")
|
||||
expect(secondResult).toContain("Background agent task launched successfully")
|
||||
expect(secondResult).toContain("Task ID: task-2")
|
||||
expect(secondResult).not.toContain("interrupt")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -61,15 +61,18 @@ export async function executeBackground(
|
||||
const waitStart = Date.now()
|
||||
let sessionId = task.sessionID
|
||||
while (!sessionId && Date.now() - waitStart < WAIT_FOR_SESSION_TIMEOUT_MS) {
|
||||
if (toolContext.abort?.aborted) {
|
||||
return `Task aborted while waiting for session to start.\n\nTask ID: ${task.id}`
|
||||
}
|
||||
const updated = manager.getTask(task.id)
|
||||
if (updated?.status === "error" || updated?.status === "cancelled" || updated?.status === "interrupt") {
|
||||
return `Task failed to start (status: ${updated.status}).\n\nTask ID: ${task.id}`
|
||||
}
|
||||
sessionId = updated?.sessionID
|
||||
if (sessionId) {
|
||||
break
|
||||
}
|
||||
if (toolContext.abort?.aborted) {
|
||||
break
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, WAIT_FOR_SESSION_INTERVAL_MS))
|
||||
sessionId = manager.getTask(task.id)?.sessionID
|
||||
}
|
||||
|
||||
await toolContext.metadata?.({
|
||||
@@ -85,10 +88,9 @@ Description: ${task.description}
|
||||
Agent: ${task.agent} (subagent)
|
||||
Status: ${task.status}
|
||||
|
||||
The system will notify you when the task completes.
|
||||
Use \`background_output\` tool with task_id="${task.id}" to check progress:
|
||||
- block=false (default): Check status immediately - returns full status info
|
||||
- block=true: Wait for completion (rarely needed since system notifies)`
|
||||
System notifies on completion. Use \`background_output\` with task_id="${task.id}" to check.
|
||||
|
||||
Do NOT call background_output now. Wait for <system-reminder> notification first.`
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return `Failed to launch background agent task: ${message}`
|
||||
|
||||
@@ -15,7 +15,6 @@ export async function waitForCompletion(
|
||||
): Promise<void> {
|
||||
log(`[call_omo_agent] Polling for completion...`)
|
||||
|
||||
// Poll for session completion
|
||||
const POLL_INTERVAL_MS = 500
|
||||
const MAX_POLL_TIME_MS = 5 * 60 * 1000 // 5 minutes max
|
||||
const pollStart = Date.now()
|
||||
@@ -24,7 +23,6 @@ export async function waitForCompletion(
|
||||
const STABILITY_REQUIRED = 3
|
||||
|
||||
while (Date.now() - pollStart < MAX_POLL_TIME_MS) {
|
||||
// Check if aborted
|
||||
if (toolContext.abort?.aborted) {
|
||||
log(`[call_omo_agent] Aborted by user`)
|
||||
throw new Error("Task aborted.")
|
||||
@@ -32,19 +30,16 @@ export async function waitForCompletion(
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS))
|
||||
|
||||
// Check session status
|
||||
const statusResult = await ctx.client.session.status()
|
||||
const allStatuses = normalizeSDKResponse(statusResult, {} as Record<string, { type: string }>)
|
||||
const sessionStatus = allStatuses[sessionID]
|
||||
|
||||
// If session is actively running, reset stability counter
|
||||
if (sessionStatus && sessionStatus.type !== "idle") {
|
||||
stablePolls = 0
|
||||
lastMsgCount = 0
|
||||
continue
|
||||
}
|
||||
|
||||
// Session is idle - check message stability
|
||||
const messagesCheck = await ctx.client.session.messages({ path: { id: sessionID } })
|
||||
const msgs = normalizeSDKResponse(messagesCheck, [] as Array<unknown>, {
|
||||
preferResponseOnMissingData: true,
|
||||
|
||||
@@ -8,6 +8,11 @@ type PromptAsyncInput = {
|
||||
agent: string
|
||||
tools: Record<string, boolean>
|
||||
parts: Array<{ type: string; text: string }>
|
||||
model?: { providerID: string; modelID: string }
|
||||
variant?: string
|
||||
temperature?: number
|
||||
topP?: number
|
||||
options?: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,6 +115,27 @@ describe("executeSync", () => {
|
||||
expect(promptInput?.body.parts).toEqual([{ type: "text", text: "find something" }])
|
||||
})
|
||||
|
||||
test("removes invisible agent characters before sending the sync prompt", async () => {
|
||||
//#given
|
||||
const executeSync = await importExecuteSync()
|
||||
const deps = createDependencies()
|
||||
const toolContext = createToolContext()
|
||||
const recorder = createPromptAsyncRecorder()
|
||||
const args = {
|
||||
subagent_type: "\u200BSisyphus\u200B - Ultraworker",
|
||||
description: "test task",
|
||||
prompt: "find something",
|
||||
run_in_background: false,
|
||||
}
|
||||
|
||||
//#when
|
||||
await executeSync(args, toolContext, createContext(recorder.promptAsync) as never, deps)
|
||||
|
||||
//#then
|
||||
const promptInput = recorder.getCapturedInput()
|
||||
expect(promptInput?.body.agent).toBe("Sisyphus - Ultraworker")
|
||||
})
|
||||
|
||||
test("returns processed response with task metadata footer", async () => {
|
||||
//#given
|
||||
const executeSync = await importExecuteSync()
|
||||
@@ -141,6 +167,56 @@ describe("executeSync", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("forwards delegated model tuning params in the sync prompt body", async () => {
|
||||
//#given
|
||||
const executeSync = await importExecuteSync()
|
||||
const deps = createDependencies()
|
||||
const toolContext = createToolContext()
|
||||
const recorder = createPromptAsyncRecorder()
|
||||
const args = {
|
||||
subagent_type: "explore",
|
||||
description: "test task",
|
||||
prompt: "find something",
|
||||
run_in_background: false,
|
||||
}
|
||||
const model = {
|
||||
providerID: "openai",
|
||||
modelID: "gpt-5.4",
|
||||
variant: "high",
|
||||
temperature: 0.12,
|
||||
top_p: 0.34,
|
||||
maxTokens: 5678,
|
||||
reasoningEffort: "medium",
|
||||
thinking: { type: "disabled" as const },
|
||||
}
|
||||
|
||||
//#when
|
||||
await executeSync(
|
||||
args,
|
||||
toolContext,
|
||||
createContext(recorder.promptAsync) as never,
|
||||
deps,
|
||||
undefined,
|
||||
undefined,
|
||||
model,
|
||||
)
|
||||
|
||||
//#then
|
||||
const promptInput = recorder.getCapturedInput()
|
||||
expect(promptInput?.body.model).toEqual({
|
||||
providerID: "openai",
|
||||
modelID: "gpt-5.4",
|
||||
})
|
||||
expect(promptInput?.body.variant).toBe("high")
|
||||
expect(promptInput?.body.temperature).toBe(0.12)
|
||||
expect(promptInput?.body.topP).toBe(0.34)
|
||||
expect(promptInput?.body.options).toEqual({
|
||||
reasoningEffort: "medium",
|
||||
thinking: { type: "disabled" },
|
||||
})
|
||||
expect(promptInput?.body.maxOutputTokens).toBe(5678)
|
||||
})
|
||||
|
||||
test("records metadata with description and created session id", async () => {
|
||||
//#given
|
||||
const executeSync = await importExecuteSync()
|
||||
@@ -225,6 +301,27 @@ describe("executeSync", () => {
|
||||
expect(deps.processMessages).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("strips invisible sort prefixes before sending sync prompts", async () => {
|
||||
//#given
|
||||
const executeSync = await importExecuteSync()
|
||||
const deps = createDependencies()
|
||||
const toolContext = createToolContext()
|
||||
const recorder = createPromptAsyncRecorder()
|
||||
const args = {
|
||||
subagent_type: "\u200BSisyphus - Ultraworker",
|
||||
description: "prefixed agent",
|
||||
prompt: "find something",
|
||||
run_in_background: false,
|
||||
}
|
||||
|
||||
//#when
|
||||
await executeSync(args, toolContext, createContext(recorder.promptAsync) as never, deps)
|
||||
|
||||
//#then
|
||||
const promptInput = recorder.getCapturedInput()
|
||||
expect(promptInput?.body.agent).toBe("Sisyphus - Ultraworker")
|
||||
})
|
||||
|
||||
test("returns generic prompt failure with task metadata", async () => {
|
||||
//#given
|
||||
const executeSync = await importExecuteSync()
|
||||
|
||||
@@ -3,8 +3,10 @@ import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { subagentSessions, syncSubagentSessions } from "../../features/claude-code-session-state"
|
||||
import { clearSessionFallbackChain, setSessionFallbackChain } from "../../hooks/model-fallback/hook"
|
||||
import { getAgentToolRestrictions, log } from "../../shared"
|
||||
import { applySessionPromptParams } from "../../shared/session-prompt-params-helpers"
|
||||
import type { DelegatedModelConfig } from "../../shared/model-resolution-types"
|
||||
import type { FallbackEntry } from "../../shared/model-requirements"
|
||||
import { stripAgentListSortPrefix } from "../../shared/agent-display-names"
|
||||
import { waitForCompletion } from "./completion-poller"
|
||||
import { processMessages } from "./message-processor"
|
||||
import { createOrGetSession } from "./session-creator"
|
||||
@@ -34,6 +36,24 @@ const defaultDeps: ExecuteSyncDeps = {
|
||||
clearSessionFallbackChain,
|
||||
}
|
||||
|
||||
function buildPromptGenerationParams(model: DelegatedModelConfig | undefined): Record<string, unknown> {
|
||||
if (!model) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const promptOptions: Record<string, unknown> = {
|
||||
...(model.reasoningEffort ? { reasoningEffort: model.reasoningEffort } : {}),
|
||||
...(model.thinking ? { thinking: model.thinking } : {}),
|
||||
}
|
||||
|
||||
return {
|
||||
...(model.temperature !== undefined ? { temperature: model.temperature } : {}),
|
||||
...(model.top_p !== undefined ? { topP: model.top_p } : {}),
|
||||
...(model.maxTokens !== undefined ? { maxOutputTokens: model.maxTokens } : {}),
|
||||
...(Object.keys(promptOptions).length > 0 ? { options: promptOptions } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeSync(
|
||||
args: CallOmoAgentArgs,
|
||||
toolContext: {
|
||||
@@ -69,6 +89,8 @@ export async function executeSync(
|
||||
appliedFallbackChain = true
|
||||
}
|
||||
|
||||
applySessionPromptParams(sessionID, model)
|
||||
|
||||
await Promise.resolve(
|
||||
toolContext.metadata?.({
|
||||
title: args.description,
|
||||
@@ -78,27 +100,29 @@ export async function executeSync(
|
||||
|
||||
log(`[call_omo_agent] Sending prompt to session ${sessionID}`)
|
||||
log(`[call_omo_agent] Prompt text:`, args.prompt.substring(0, 100))
|
||||
const normalizedSubagentType = stripAgentListSortPrefix(args.subagent_type)
|
||||
|
||||
try {
|
||||
await (ctx.client.session as unknown as SessionWithPromptAsync).promptAsync({
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: args.subagent_type,
|
||||
agent: normalizedSubagentType,
|
||||
tools: {
|
||||
...getAgentToolRestrictions(args.subagent_type),
|
||||
...getAgentToolRestrictions(normalizedSubagentType),
|
||||
task: false,
|
||||
question: false,
|
||||
},
|
||||
parts: [{ type: "text", text: args.prompt }],
|
||||
...(model ? { model: { providerID: model.providerID, modelID: model.modelID } } : {}),
|
||||
...(model?.variant ? { variant: model.variant } : {}),
|
||||
...buildPromptGenerationParams(model),
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
log(`[call_omo_agent] Prompt error:`, errorMessage)
|
||||
if (errorMessage.includes("agent.name") || errorMessage.includes("undefined")) {
|
||||
return `Error: Agent "${args.subagent_type}" not found. Make sure the agent is registered in your opencode.json or provided by a plugin.\n\n<task_metadata>\nsession_id: ${sessionID}\n</task_metadata>`
|
||||
return `Error: Agent "${normalizedSubagentType}" not found. Make sure the agent is registered in your opencode.json or provided by a plugin.\n\n<task_metadata>\nsession_id: ${sessionID}\n</task_metadata>`
|
||||
}
|
||||
return `Error: Failed to send prompt: ${errorMessage}\n\n<task_metadata>\nsession_id: ${sessionID}\n</task_metadata>`
|
||||
}
|
||||
|
||||
@@ -265,6 +265,110 @@ describe("createCallOmoAgent", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("parses inline model variant from agent config override", async () => {
|
||||
//#given
|
||||
const launch = mock((_input: { model?: { providerID: string; modelID: string; variant?: string } }) => Promise.resolve({
|
||||
id: "task-inline-variant",
|
||||
sessionID: "sub-session",
|
||||
description: "Test task",
|
||||
agent: "explore",
|
||||
status: "pending",
|
||||
}))
|
||||
const managerWithLaunch = {
|
||||
launch,
|
||||
getTask: mock(() => undefined),
|
||||
}
|
||||
const toolDef = createCallOmoAgent(
|
||||
mockCtx,
|
||||
managerWithLaunch,
|
||||
[],
|
||||
{
|
||||
explore: {
|
||||
model: "openai/gpt-5.4 high",
|
||||
},
|
||||
},
|
||||
)
|
||||
const executeFunc = toolDef.execute as Function
|
||||
|
||||
//#when
|
||||
await executeFunc(
|
||||
{
|
||||
description: "Test inline variant",
|
||||
prompt: "Test prompt",
|
||||
subagent_type: "explore",
|
||||
run_in_background: true,
|
||||
},
|
||||
{ sessionID: "test", messageID: "msg", agent: "test", abort: new AbortController().signal }
|
||||
)
|
||||
|
||||
//#then
|
||||
const firstLaunchCall = launch.mock.calls[0]
|
||||
if (firstLaunchCall === undefined) {
|
||||
throw new Error("Expected launch to be called")
|
||||
}
|
||||
|
||||
const [launchArgs] = firstLaunchCall
|
||||
expect(launchArgs.model).toEqual({
|
||||
providerID: "openai",
|
||||
modelID: "gpt-5.4",
|
||||
variant: "high",
|
||||
})
|
||||
})
|
||||
|
||||
test("forwards category-derived model override to background executor", async () => {
|
||||
//#given
|
||||
const launch = mock((_input: { model?: { providerID: string; modelID: string } }) => Promise.resolve({
|
||||
id: "task-category-model",
|
||||
sessionID: "sub-session",
|
||||
description: "Test task",
|
||||
agent: "explore",
|
||||
status: "pending",
|
||||
}))
|
||||
const managerWithLaunch = {
|
||||
launch,
|
||||
getTask: mock(() => undefined),
|
||||
}
|
||||
const toolDef = createCallOmoAgent(
|
||||
mockCtx,
|
||||
managerWithLaunch,
|
||||
[],
|
||||
{
|
||||
explore: {
|
||||
category: "research",
|
||||
},
|
||||
},
|
||||
{
|
||||
research: {
|
||||
model: "openai/gpt-5.4",
|
||||
},
|
||||
},
|
||||
)
|
||||
const executeFunc = toolDef.execute as Function
|
||||
|
||||
//#when
|
||||
await executeFunc(
|
||||
{
|
||||
description: "Test category model override",
|
||||
prompt: "Test prompt",
|
||||
subagent_type: "explore",
|
||||
run_in_background: true,
|
||||
},
|
||||
{ sessionID: "test", messageID: "msg", agent: "test", abort: new AbortController().signal }
|
||||
)
|
||||
|
||||
//#then
|
||||
const firstLaunchCall = launch.mock.calls[0]
|
||||
if (firstLaunchCall === undefined) {
|
||||
throw new Error("Expected launch to be called")
|
||||
}
|
||||
|
||||
const [launchArgs] = firstLaunchCall
|
||||
expect(launchArgs.model).toEqual({
|
||||
providerID: "openai",
|
||||
modelID: "gpt-5.4",
|
||||
})
|
||||
})
|
||||
|
||||
test("should return a tool error when sync spawn depth validation fails", async () => {
|
||||
//#given
|
||||
reserveSubagentSpawnMock.mockRejectedValueOnce(new Error("Subagent spawn blocked: child depth 4 exceeds background_task.maxDepth=3."))
|
||||
|
||||
@@ -7,10 +7,11 @@ import type { DelegatedModelConfig } from "../../shared/model-resolution-types"
|
||||
import type { FallbackEntry } from "../../shared/model-requirements"
|
||||
import { AGENT_MODEL_REQUIREMENTS } from "../../shared/model-requirements"
|
||||
import { getAgentConfigKey } from "../../shared/agent-display-names"
|
||||
import { normalizeModelFormat } from "../../shared/model-format-normalizer"
|
||||
import { normalizeFallbackModels } from "../../shared/model-resolver"
|
||||
import { buildFallbackChainFromModels } from "../../shared/fallback-chain-from-models"
|
||||
import { log } from "../../shared"
|
||||
import { CONFIG_BASENAME } from "../../shared/plugin-identity"
|
||||
import { parseModelString } from "../delegate-task/model-string-parser"
|
||||
import { executeBackground } from "./background-executor"
|
||||
import { executeSync } from "./sync-executor"
|
||||
|
||||
@@ -27,10 +28,16 @@ function resolveModelAndFallbackChain(args: {
|
||||
?? (agentOverrides
|
||||
? Object.entries(agentOverrides).find(([key]) => key.toLowerCase() === agentConfigKey)?.[1]
|
||||
: undefined)
|
||||
const agentCategoryModel = agentOverride?.category
|
||||
? userCategories?.[agentOverride.category]?.model
|
||||
: undefined
|
||||
const agentCategoryVariant = agentOverride?.category
|
||||
? userCategories?.[agentOverride.category]?.variant
|
||||
: undefined
|
||||
|
||||
let model: DelegatedModelConfig | undefined
|
||||
if (agentOverride?.model) {
|
||||
const normalized = normalizeModelFormat(agentOverride.model)
|
||||
const normalized = parseModelString(agentOverride.model)
|
||||
if (normalized) {
|
||||
model = agentOverride.variant ? { ...normalized, variant: agentOverride.variant } : normalized
|
||||
log("[call_omo_agent] Resolved model override from agent config", {
|
||||
@@ -39,6 +46,18 @@ function resolveModelAndFallbackChain(args: {
|
||||
variant: agentOverride.variant,
|
||||
})
|
||||
}
|
||||
} else if (agentCategoryModel) {
|
||||
const normalized = parseModelString(agentCategoryModel)
|
||||
if (normalized) {
|
||||
const variantToUse = agentOverride?.variant ?? agentCategoryVariant
|
||||
model = variantToUse ? { ...normalized, variant: variantToUse } : normalized
|
||||
log("[call_omo_agent] Resolved model override from agent category", {
|
||||
agent: subagentType,
|
||||
category: agentOverride?.category,
|
||||
model: agentCategoryModel,
|
||||
variant: variantToUse,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedFallbackModels = normalizeFallbackModels(
|
||||
@@ -99,7 +118,7 @@ export function createCallOmoAgent(
|
||||
|
||||
// Check if agent is disabled
|
||||
if (disabledAgents.some((disabled) => disabled.toLowerCase() === normalizedAgent)) {
|
||||
return `Error: Agent "${normalizedAgent}" is disabled via disabled_agents configuration. Remove it from disabled_agents in your oh-my-opencode.json to use it.`
|
||||
return `Error: Agent "${normalizedAgent}" is disabled via disabled_agents configuration. Remove it from disabled_agents in your ${CONFIG_BASENAME}.json to use it.`
|
||||
}
|
||||
|
||||
const { model: resolvedModel, fallbackChain } = resolveModelAndFallbackChain({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# src/tools/delegate-task/ — Task Delegation Engine
|
||||
|
||||
**Generated:** 2026-03-06
|
||||
**Generated:** 2026-04-11
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { BuiltinCategoryDefinition } from "./builtin-category-definition"
|
||||
|
||||
const UNSPECIFIED_LOW_CATEGORY_PROMPT_APPEND = `<Category_Context>
|
||||
You are working on tasks that don't fit specific categories but require moderate effort.
|
||||
|
||||
<Selection_Gate>
|
||||
BEFORE selecting this category, VERIFY ALL conditions:
|
||||
1. Task does NOT fit: quick (trivial), visual-engineering (UI), ultrabrain (deep logic), artistry (creative), writing (docs)
|
||||
2. Task requires more than trivial effort but is NOT system-wide
|
||||
3. Scope is contained within a few files/modules
|
||||
|
||||
If task fits ANY other category, DO NOT select unspecified-low.
|
||||
This is NOT a default choice - it's for genuinely unclassifiable moderate-effort work.
|
||||
</Selection_Gate>
|
||||
</Category_Context>
|
||||
|
||||
<Caller_Warning>
|
||||
THIS CATEGORY USES A MID-TIER MODEL (claude-sonnet-4-6).
|
||||
|
||||
**PROVIDE CLEAR STRUCTURE:**
|
||||
1. MUST DO: Enumerate required actions explicitly
|
||||
2. MUST NOT DO: State forbidden actions to prevent scope creep
|
||||
3. EXPECTED OUTPUT: Define concrete success criteria
|
||||
</Caller_Warning>`
|
||||
|
||||
const UNSPECIFIED_HIGH_CATEGORY_PROMPT_APPEND = `<Category_Context>
|
||||
You are working on tasks that don't fit specific categories but require substantial effort.
|
||||
|
||||
<Selection_Gate>
|
||||
BEFORE selecting this category, VERIFY ALL conditions:
|
||||
1. Task does NOT fit: quick (trivial), visual-engineering (UI), ultrabrain (deep logic), artistry (creative), writing (docs)
|
||||
2. Task requires substantial effort across multiple systems/modules
|
||||
3. Changes have broad impact or require careful coordination
|
||||
4. NOT just "complex" - must be genuinely unclassifiable AND high-effort
|
||||
|
||||
If task fits ANY other category, DO NOT select unspecified-high.
|
||||
If task is unclassifiable but moderate-effort, use unspecified-low instead.
|
||||
</Selection_Gate>
|
||||
</Category_Context>`
|
||||
|
||||
export const ANTHROPIC_CATEGORIES: BuiltinCategoryDefinition[] = [
|
||||
{
|
||||
name: "unspecified-low",
|
||||
config: { model: "anthropic/claude-sonnet-4-6" },
|
||||
description: "Tasks that don't fit other categories, low effort required",
|
||||
promptAppend: UNSPECIFIED_LOW_CATEGORY_PROMPT_APPEND,
|
||||
},
|
||||
{
|
||||
name: "unspecified-high",
|
||||
config: { model: "anthropic/claude-opus-4-6", variant: "max" },
|
||||
description: "Tasks that don't fit other categories, high effort required",
|
||||
promptAppend: UNSPECIFIED_HIGH_CATEGORY_PROMPT_APPEND,
|
||||
},
|
||||
]
|
||||
@@ -3,6 +3,7 @@ import type { ExecutorContext, ParentContext } from "./executor-types"
|
||||
import { storeToolMetadata } from "../../features/tool-metadata-store"
|
||||
import { formatDetailedError } from "./error-formatting"
|
||||
import { getSessionTools } from "../../shared/session-tools-store"
|
||||
import { resolveCallID } from "./resolve-call-id"
|
||||
|
||||
export async function executeBackgroundContinuation(
|
||||
args: DelegateTaskArgs,
|
||||
@@ -37,8 +38,9 @@ export async function executeBackgroundContinuation(
|
||||
},
|
||||
}
|
||||
await ctx.metadata?.(bgContMeta)
|
||||
if (ctx.callID) {
|
||||
storeToolMetadata(ctx.sessionID, ctx.callID, bgContMeta)
|
||||
const callID = resolveCallID(ctx)
|
||||
if (callID) {
|
||||
storeToolMetadata(ctx.sessionID, callID, bgContMeta)
|
||||
}
|
||||
|
||||
return `Background task continued.
|
||||
@@ -49,7 +51,9 @@ Agent: ${task.agent}
|
||||
Status: ${task.status}
|
||||
|
||||
Agent continues with full previous context preserved.
|
||||
Use \`background_output\` with task_id="${task.id}" to check progress.
|
||||
System notifies on completion. Use \`background_output\` with task_id="${task.id}" to check.
|
||||
|
||||
Do NOT call background_output now. Wait for <system-reminder> notification first.
|
||||
|
||||
<task_metadata>
|
||||
session_id: ${task.sessionID}
|
||||
|
||||
@@ -7,6 +7,7 @@ const afterEachFn = bunTest.afterEach
|
||||
|
||||
const { executeBackgroundTask } = require("./background-task")
|
||||
const { __setTimingConfig, __resetTimingConfig } = require("./timing")
|
||||
const { SessionCategoryRegistry } = require("../../shared/session-category-registry")
|
||||
|
||||
describeFn("executeBackgroundTask output/session metadata compatibility", () => {
|
||||
beforeEachFn(() => {
|
||||
@@ -19,6 +20,7 @@ describeFn("executeBackgroundTask output/session metadata compatibility", () =>
|
||||
|
||||
afterEachFn(() => {
|
||||
__resetTimingConfig()
|
||||
SessionCategoryRegistry.clear()
|
||||
})
|
||||
|
||||
testFn("does not emit synthetic pending session metadata when session id is unresolved", async () => {
|
||||
@@ -201,4 +203,318 @@ describeFn("executeBackgroundTask output/session metadata compatibility", () =>
|
||||
{ permission: "question", action: "deny", pattern: "*" },
|
||||
])
|
||||
})
|
||||
|
||||
testFn("strips leading zwsp from agent name before launching background task", async () => {
|
||||
//#given - display-sorted agent names should be normalized before manager launch
|
||||
const launchCalls: unknown[] = []
|
||||
const manager = {
|
||||
launch: async (input: unknown) => {
|
||||
launchCalls.push(input)
|
||||
return {
|
||||
id: "bg_clean_agent",
|
||||
sessionID: "ses_clean_agent",
|
||||
description: "Clean agent",
|
||||
agent: "sisyphus-junior",
|
||||
status: "running",
|
||||
}
|
||||
},
|
||||
getTask: () => ({ sessionID: "ses_clean_agent" }),
|
||||
}
|
||||
|
||||
//#when
|
||||
await executeBackgroundTask(
|
||||
{
|
||||
description: "Clean agent",
|
||||
prompt: "check",
|
||||
run_in_background: true,
|
||||
load_skills: [],
|
||||
},
|
||||
{
|
||||
sessionID: "ses_parent",
|
||||
callID: "call_clean_agent",
|
||||
metadata: async () => {},
|
||||
abort: new AbortController().signal,
|
||||
},
|
||||
{ manager },
|
||||
{ sessionID: "ses_parent", messageID: "msg_clean_agent" },
|
||||
"\u200Bsisyphus-junior",
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
)
|
||||
|
||||
//#then
|
||||
expectFn(launchCalls).toHaveLength(1)
|
||||
expectFn((launchCalls[0] as { agent: string }).agent).toBe("sisyphus-junior")
|
||||
})
|
||||
|
||||
testFn("keeps launched background task alive when parent aborts before session id resolves", async () => {
|
||||
//#given - parallel tool execution can abort the parent call after launch succeeds
|
||||
const metadataCalls: any[] = []
|
||||
const abortController = new AbortController()
|
||||
const manager = {
|
||||
launch: async () => ({
|
||||
id: "bg_abort_after_launch",
|
||||
sessionID: undefined,
|
||||
description: "Abort after launch",
|
||||
agent: "explore",
|
||||
status: "pending",
|
||||
}),
|
||||
getTask: () => {
|
||||
abortController.abort()
|
||||
return { sessionID: undefined, status: "pending" }
|
||||
},
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = await executeBackgroundTask(
|
||||
{
|
||||
description: "Abort after launch",
|
||||
prompt: "check",
|
||||
run_in_background: true,
|
||||
load_skills: [],
|
||||
},
|
||||
{
|
||||
sessionID: "ses_parent",
|
||||
callID: "call_abort_after_launch",
|
||||
metadata: async (value: any) => metadataCalls.push(value),
|
||||
abort: abortController.signal,
|
||||
},
|
||||
{ manager },
|
||||
{ sessionID: "ses_parent", messageID: "msg_abort_after_launch" },
|
||||
"explore",
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
)
|
||||
|
||||
//#then - background launch should still succeed without fake abort failure
|
||||
expectFn(result).toContain("Background task launched")
|
||||
expectFn(result).toContain("Background Task ID: bg_abort_after_launch")
|
||||
expectFn(result).not.toContain("Task aborted while waiting for session to start")
|
||||
expectFn(metadataCalls).toHaveLength(1)
|
||||
expectFn("sessionId" in metadataCalls[0].metadata).toBe(false)
|
||||
})
|
||||
|
||||
testFn("registers late session category even when parent aborts before session id resolves", async () => {
|
||||
//#given - session wiring should continue after returning early on parent abort
|
||||
const abortController = new AbortController()
|
||||
abortController.abort()
|
||||
let reads = 0
|
||||
const manager = {
|
||||
launch: async () => ({
|
||||
id: "bg_abort_category",
|
||||
sessionID: undefined,
|
||||
description: "Abort category",
|
||||
agent: "explore",
|
||||
status: "pending",
|
||||
}),
|
||||
getTask: () => {
|
||||
reads += 1
|
||||
return reads >= 2
|
||||
? { sessionID: "ses_abort_category", status: "running" }
|
||||
: { sessionID: undefined, status: "pending" }
|
||||
},
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = await executeBackgroundTask(
|
||||
{
|
||||
description: "Abort category",
|
||||
prompt: "check",
|
||||
run_in_background: true,
|
||||
load_skills: [],
|
||||
category: "quick",
|
||||
},
|
||||
{
|
||||
sessionID: "ses_parent",
|
||||
callID: "call_abort_category",
|
||||
metadata: async () => {},
|
||||
abort: abortController.signal,
|
||||
},
|
||||
{ manager },
|
||||
{ sessionID: "ses_parent", messageID: "msg_abort_category" },
|
||||
"explore",
|
||||
undefined,
|
||||
undefined,
|
||||
[{ providers: ["openai"], model: "gpt-5.4" }],
|
||||
)
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 5))
|
||||
|
||||
//#then - late session setup should still register category for runtime fallback
|
||||
expectFn(result).toContain("Background task launched")
|
||||
expectFn(SessionCategoryRegistry.get("ses_abort_category")).toBe("quick")
|
||||
})
|
||||
|
||||
testFn("prefers child terminal status over parent abort while waiting for session id", async () => {
|
||||
//#given - failed child launch should not be misreported as a successful background launch
|
||||
const abortController = new AbortController()
|
||||
abortController.abort()
|
||||
const manager = {
|
||||
launch: async () => ({
|
||||
id: "bg_abort_terminal",
|
||||
sessionID: undefined,
|
||||
description: "Abort terminal",
|
||||
agent: "explore",
|
||||
status: "pending",
|
||||
}),
|
||||
getTask: () => ({ sessionID: undefined, status: "interrupt" }),
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = await executeBackgroundTask(
|
||||
{
|
||||
description: "Abort terminal",
|
||||
prompt: "check",
|
||||
run_in_background: true,
|
||||
load_skills: [],
|
||||
},
|
||||
{
|
||||
sessionID: "ses_parent",
|
||||
callID: "call_abort_terminal",
|
||||
metadata: async () => {},
|
||||
abort: abortController.signal,
|
||||
},
|
||||
{ manager },
|
||||
{ sessionID: "ses_parent", messageID: "msg_abort_terminal" },
|
||||
"explore",
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
)
|
||||
|
||||
//#then - terminal child status should win over abort and surface the failure
|
||||
expectFn(result).toContain("Task failed to start")
|
||||
expectFn(result).toContain("interrupt")
|
||||
})
|
||||
|
||||
testFn("reports failure when manager marks task as error during session startup", async () => {
|
||||
//#given - session created but startTask throws before prompt is sent
|
||||
const metadataCalls: any[] = []
|
||||
let reads = 0
|
||||
const manager = {
|
||||
launch: async () => ({
|
||||
id: "bg_crash_before_prompt",
|
||||
sessionID: undefined,
|
||||
description: "Crash before prompt",
|
||||
agent: "explore",
|
||||
status: "pending",
|
||||
}),
|
||||
getTask: () => {
|
||||
reads += 1
|
||||
if (reads >= 2) {
|
||||
return { sessionID: "ses_orphan", status: "error", error: "crash between session creation and prompt send" }
|
||||
}
|
||||
return { sessionID: undefined, status: "pending" }
|
||||
},
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = await executeBackgroundTask(
|
||||
{
|
||||
description: "Crash before prompt",
|
||||
prompt: "check",
|
||||
run_in_background: true,
|
||||
load_skills: [],
|
||||
},
|
||||
{
|
||||
sessionID: "ses_parent",
|
||||
callID: "call_crash",
|
||||
metadata: async (value: any) => metadataCalls.push(value),
|
||||
abort: new AbortController().signal,
|
||||
},
|
||||
{ manager },
|
||||
{ sessionID: "ses_parent", messageID: "msg_crash" },
|
||||
"explore",
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
)
|
||||
|
||||
//#then - polling loop should detect terminal status and report failure
|
||||
expectFn(result).toContain("Task failed to start")
|
||||
expectFn(result).toContain("error")
|
||||
})
|
||||
|
||||
testFn("keeps sibling background launch alive when two tasks start concurrently", async () => {
|
||||
//#given - one aborted parent call should not interrupt a sibling launch from the same parent session
|
||||
const firstAbortController = new AbortController()
|
||||
const secondAbortController = new AbortController()
|
||||
const states = new Map([
|
||||
["bg_first", { reads: 0, abortOnFirstRead: true, sessionID: "ses_first" }],
|
||||
["bg_second", { reads: 0, abortOnFirstRead: false, sessionID: "ses_second" }],
|
||||
])
|
||||
let launchCount = 0
|
||||
const manager = {
|
||||
launch: async () => {
|
||||
launchCount += 1
|
||||
return launchCount === 1
|
||||
? { id: "bg_first", sessionID: undefined, description: "First", agent: "explore", status: "pending" }
|
||||
: { id: "bg_second", sessionID: undefined, description: "Second", agent: "explore", status: "pending" }
|
||||
},
|
||||
getTask: (taskID: string) => {
|
||||
const state = states.get(taskID)
|
||||
if (!state) return undefined
|
||||
state.reads += 1
|
||||
if (state.abortOnFirstRead && state.reads === 1) {
|
||||
firstAbortController.abort()
|
||||
}
|
||||
return state.reads >= 2
|
||||
? { sessionID: state.sessionID, status: "running" }
|
||||
: { sessionID: undefined, status: "pending" }
|
||||
},
|
||||
}
|
||||
|
||||
//#when
|
||||
const [firstResult, secondResult] = await Promise.all([
|
||||
executeBackgroundTask(
|
||||
{
|
||||
description: "First",
|
||||
prompt: "check",
|
||||
run_in_background: true,
|
||||
load_skills: [],
|
||||
},
|
||||
{
|
||||
sessionID: "ses_parent",
|
||||
callID: "call_first",
|
||||
metadata: async () => {},
|
||||
abort: firstAbortController.signal,
|
||||
},
|
||||
{ manager },
|
||||
{ sessionID: "ses_parent", messageID: "msg_first" },
|
||||
"explore",
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
),
|
||||
executeBackgroundTask(
|
||||
{
|
||||
description: "Second",
|
||||
prompt: "check",
|
||||
run_in_background: true,
|
||||
load_skills: [],
|
||||
},
|
||||
{
|
||||
sessionID: "ses_parent",
|
||||
callID: "call_second",
|
||||
metadata: async () => {},
|
||||
abort: secondAbortController.signal,
|
||||
},
|
||||
{ manager },
|
||||
{ sessionID: "ses_parent", messageID: "msg_second" },
|
||||
"explore",
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
),
|
||||
])
|
||||
|
||||
//#then - both tasks still launch and the sibling is not reported as interrupted
|
||||
expectFn(firstResult).toContain("Background task launched")
|
||||
expectFn(firstResult).not.toContain("Task failed to start")
|
||||
expectFn(secondResult).toContain("Background task launched")
|
||||
expectFn(secondResult).toContain("session_id: ses_second")
|
||||
expectFn(secondResult).not.toContain("interrupt")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,11 +4,50 @@ import type { FallbackEntry } from "../../shared/model-requirements"
|
||||
import { getTimingConfig } from "./timing"
|
||||
import { buildTaskPrompt } from "./prompt-builder"
|
||||
import { storeToolMetadata } from "../../features/tool-metadata-store"
|
||||
import { resolveCallID } from "./resolve-call-id"
|
||||
import { formatDetailedError } from "./error-formatting"
|
||||
import { getSessionTools } from "../../shared/session-tools-store"
|
||||
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
|
||||
import { QUESTION_DENIED_SESSION_PERMISSION } from "../../shared/question-denied-session-permission"
|
||||
import { setSessionFallbackChain } from "../../hooks/model-fallback/hook"
|
||||
import { stripAgentListSortPrefix } from "../../shared/agent-display-names"
|
||||
|
||||
function continueSessionSetup(args: {
|
||||
taskID: string
|
||||
manager: ExecutorContext["manager"]
|
||||
timing: ReturnType<typeof getTimingConfig>
|
||||
fallbackChain?: FallbackEntry[]
|
||||
category?: string
|
||||
}): void {
|
||||
if (!args.fallbackChain && !args.category) {
|
||||
return
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
const waitStart = Date.now()
|
||||
while (Date.now() - waitStart < args.timing.WAIT_FOR_SESSION_TIMEOUT_MS) {
|
||||
await new Promise(resolve => setTimeout(resolve, args.timing.WAIT_FOR_SESSION_INTERVAL_MS))
|
||||
const updated = args.manager.getTask(args.taskID)
|
||||
if (!updated) {
|
||||
return
|
||||
}
|
||||
if (updated.status === "error" || updated.status === "cancelled" || updated.status === "interrupt") {
|
||||
return
|
||||
}
|
||||
|
||||
const sessionId = updated.sessionID
|
||||
if (!sessionId) {
|
||||
continue
|
||||
}
|
||||
|
||||
setSessionFallbackChain(sessionId, args.fallbackChain)
|
||||
if (args.category) {
|
||||
SessionCategoryRegistry.register(sessionId, args.category)
|
||||
}
|
||||
return
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
export async function executeBackgroundTask(
|
||||
args: DelegateTaskArgs,
|
||||
@@ -24,11 +63,12 @@ export async function executeBackgroundTask(
|
||||
|
||||
try {
|
||||
const tddEnabled = executorCtx.sisyphusAgentConfig?.tdd
|
||||
const effectivePrompt = buildTaskPrompt(args.prompt, agentToUse, tddEnabled)
|
||||
const normalizedAgent = stripAgentListSortPrefix(agentToUse)
|
||||
const effectivePrompt = buildTaskPrompt(args.prompt, normalizedAgent, tddEnabled)
|
||||
const task = await manager.launch({
|
||||
description: args.description,
|
||||
prompt: effectivePrompt,
|
||||
agent: agentToUse,
|
||||
agent: normalizedAgent,
|
||||
parentSessionID: parentContext.sessionID,
|
||||
parentMessageID: parentContext.messageID,
|
||||
parentModel: parentContext.model,
|
||||
@@ -50,12 +90,25 @@ export async function executeBackgroundTask(
|
||||
const waitStart = Date.now()
|
||||
let sessionId = task.sessionID
|
||||
while (!sessionId && Date.now() - waitStart < timing.WAIT_FOR_SESSION_TIMEOUT_MS) {
|
||||
const updated = manager.getTask(task.id)
|
||||
if (updated?.status === "error" || updated?.status === "cancelled" || updated?.status === "interrupt") {
|
||||
return `Task failed to start (status: ${updated.status}).\n\nTask ID: ${task.id}`
|
||||
}
|
||||
sessionId = updated?.sessionID
|
||||
if (sessionId) {
|
||||
break
|
||||
}
|
||||
if (ctx.abort?.aborted) {
|
||||
return `Task aborted while waiting for session to start.\n\nTask ID: ${task.id}`
|
||||
continueSessionSetup({
|
||||
taskID: task.id,
|
||||
manager,
|
||||
timing,
|
||||
fallbackChain,
|
||||
category: args.category,
|
||||
})
|
||||
break
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, timing.WAIT_FOR_SESSION_INTERVAL_MS))
|
||||
const updated = manager.getTask(task.id)
|
||||
sessionId = updated?.sessionID
|
||||
}
|
||||
|
||||
if (sessionId) {
|
||||
@@ -82,8 +135,9 @@ export async function executeBackgroundTask(
|
||||
metadata,
|
||||
}
|
||||
await ctx.metadata?.(unstableMeta)
|
||||
if (ctx.callID) {
|
||||
storeToolMetadata(ctx.sessionID, ctx.callID, unstableMeta)
|
||||
const callID = resolveCallID(ctx)
|
||||
if (callID) {
|
||||
storeToolMetadata(ctx.sessionID, callID, unstableMeta)
|
||||
}
|
||||
|
||||
const taskMetadataBlock = sessionId
|
||||
@@ -97,12 +151,14 @@ Description: ${task.description}
|
||||
Agent: ${task.agent}${args.category ? ` (category: ${args.category})` : ""}
|
||||
Status: ${task.status}
|
||||
|
||||
System notifies on completion. Use \`background_output\` with task_id="${task.id}" to check.${taskMetadataBlock}`
|
||||
System notifies on completion. Use \`background_output\` with task_id="${task.id}" to check.
|
||||
|
||||
Do NOT call background_output now. Wait for <system-reminder> notification first.${taskMetadataBlock}`
|
||||
} catch (error) {
|
||||
return formatDetailedError(error, {
|
||||
operation: "Launch background task",
|
||||
args,
|
||||
agent: agentToUse,
|
||||
agent: stripAgentListSortPrefix(agentToUse),
|
||||
category: args.category,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { CategoryConfig } from "../../config/schema"
|
||||
import { ANTHROPIC_CATEGORIES } from "./anthropic-categories"
|
||||
import type { BuiltinCategoryDefinition } from "./builtin-category-definition"
|
||||
import { GOOGLE_CATEGORIES } from "./google-categories"
|
||||
import { KIMI_CATEGORIES } from "./kimi-categories"
|
||||
import { OPENAI_CATEGORIES } from "./openai-categories"
|
||||
|
||||
const BUILTIN_CATEGORIES: BuiltinCategoryDefinition[] = [
|
||||
...GOOGLE_CATEGORIES,
|
||||
...OPENAI_CATEGORIES,
|
||||
...ANTHROPIC_CATEGORIES,
|
||||
...KIMI_CATEGORIES,
|
||||
]
|
||||
|
||||
function buildCategoryRecord<TValue>(
|
||||
selector: (definition: BuiltinCategoryDefinition) => TValue
|
||||
): Record<string, TValue> {
|
||||
return Object.fromEntries(
|
||||
BUILTIN_CATEGORIES.map((definition) => [definition.name, selector(definition)])
|
||||
)
|
||||
}
|
||||
|
||||
export const DEFAULT_CATEGORIES: Record<string, CategoryConfig> = buildCategoryRecord(
|
||||
(definition) => definition.config
|
||||
)
|
||||
|
||||
export const CATEGORY_PROMPT_APPENDS: Record<string, string> = buildCategoryRecord(
|
||||
(definition) => definition.promptAppend
|
||||
)
|
||||
|
||||
export const CATEGORY_DESCRIPTIONS: Record<string, string> = buildCategoryRecord(
|
||||
(definition) => definition.description
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { CategoryConfig } from "../../config/schema"
|
||||
|
||||
export type BuiltinCategoryDefinition = {
|
||||
name: string
|
||||
config: CategoryConfig
|
||||
description: string
|
||||
promptAppend: string
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
declare const require: (name: string) => any
|
||||
const { afterEach, beforeEach, describe, expect, mock, spyOn, test } = require("bun:test")
|
||||
import { resolveCategoryExecution } from "./category-resolver"
|
||||
import type { ExecutorContext } from "./executor-types"
|
||||
import * as availableModels from "./available-models"
|
||||
|
||||
describe("resolveCategoryExecution unknown category handling", () => {
|
||||
beforeEach(() => {
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
test("#given unknown category #when resolving category execution #then it rejects before fetching available models", async () => {
|
||||
//#given
|
||||
const availableModelsSpy = spyOn(availableModels, "getAvailableModelsForDelegateTask")
|
||||
const executorContext: ExecutorContext = {
|
||||
client: {} as ExecutorContext["client"],
|
||||
manager: {} as ExecutorContext["manager"],
|
||||
directory: "/tmp/test",
|
||||
userCategories: {},
|
||||
sisyphusJuniorModel: undefined,
|
||||
}
|
||||
const args = {
|
||||
category: "backend-engineer",
|
||||
prompt: "test prompt",
|
||||
description: "Test task",
|
||||
run_in_background: false,
|
||||
load_skills: [],
|
||||
blockedBy: undefined,
|
||||
enableSkillTools: false,
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = await resolveCategoryExecution(args, executorContext, undefined, "anthropic/claude-sonnet-4-6")
|
||||
|
||||
//#then
|
||||
expect(result.error).toContain('Unknown category: "backend-engineer"')
|
||||
expect(availableModelsSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -124,7 +124,7 @@ describe("resolveCategoryExecution", () => {
|
||||
})
|
||||
const agentsSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
|
||||
const args = {
|
||||
category: "deep",
|
||||
category: "quick",
|
||||
prompt: "test prompt",
|
||||
description: "Test task",
|
||||
run_in_background: false,
|
||||
@@ -134,7 +134,7 @@ describe("resolveCategoryExecution", () => {
|
||||
}
|
||||
const executorCtx = createMockExecutorContext()
|
||||
executorCtx.userCategories = {
|
||||
deep: {
|
||||
quick: {
|
||||
fallback_models: [
|
||||
{
|
||||
model: "openai/gpt-5.4 high",
|
||||
@@ -169,16 +169,10 @@ describe("resolveCategoryExecution", () => {
|
||||
agentsSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("does not apply object-style fallback settings when the configured primary model matches directly", async () => {
|
||||
test("preserves inline variant from category model string when no explicit variant is configured", async () => {
|
||||
//#given
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
|
||||
models: { openai: ["gpt-5.4-preview"] },
|
||||
connected: ["openai"],
|
||||
updatedAt: "2026-03-03T00:00:00.000Z",
|
||||
})
|
||||
const agentsSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
|
||||
const args = {
|
||||
category: "deep",
|
||||
category: "quick",
|
||||
prompt: "test prompt",
|
||||
description: "Test task",
|
||||
run_in_background: false,
|
||||
@@ -188,7 +182,49 @@ describe("resolveCategoryExecution", () => {
|
||||
}
|
||||
const executorCtx = createMockExecutorContext()
|
||||
executorCtx.userCategories = {
|
||||
deep: {
|
||||
quick: {
|
||||
model: "openai/gpt-5.4 high",
|
||||
},
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = await resolveCategoryExecution(args, executorCtx, undefined, "anthropic/claude-sonnet-4-6")
|
||||
|
||||
//#then
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.actualModel).toBeDefined()
|
||||
expect(result.categoryModel).toBeDefined()
|
||||
if (!result.actualModel || !result.categoryModel) {
|
||||
throw new Error("Expected resolved model and category model")
|
||||
}
|
||||
expect(result.actualModel).toBe("openai/gpt-5.4")
|
||||
expect(result.categoryModel).toEqual({
|
||||
providerID: "openai",
|
||||
modelID: "gpt-5.4",
|
||||
variant: "high",
|
||||
})
|
||||
})
|
||||
|
||||
test("does not apply object-style fallback settings when the configured primary model matches directly", async () => {
|
||||
//#given
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
|
||||
models: { openai: ["gpt-5.4-preview"] },
|
||||
connected: ["openai"],
|
||||
updatedAt: "2026-03-03T00:00:00.000Z",
|
||||
})
|
||||
const agentsSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
|
||||
const args = {
|
||||
category: "quick",
|
||||
prompt: "test prompt",
|
||||
description: "Test task",
|
||||
run_in_background: false,
|
||||
load_skills: [],
|
||||
blockedBy: undefined,
|
||||
enableSkillTools: false,
|
||||
}
|
||||
const executorCtx = createMockExecutorContext()
|
||||
executorCtx.userCategories = {
|
||||
quick: {
|
||||
model: "openai/gpt-5.4-preview",
|
||||
fallback_models: [
|
||||
{
|
||||
@@ -209,7 +245,7 @@ describe("resolveCategoryExecution", () => {
|
||||
expect(result.categoryModel).toEqual({
|
||||
providerID: "openai",
|
||||
modelID: "gpt-5.4-preview",
|
||||
variant: "medium",
|
||||
variant: undefined,
|
||||
})
|
||||
cacheSpy.mockRestore()
|
||||
agentsSpy.mockRestore()
|
||||
@@ -224,7 +260,7 @@ describe("resolveCategoryExecution", () => {
|
||||
})
|
||||
const agentsSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
|
||||
const args = {
|
||||
category: "deep",
|
||||
category: "quick",
|
||||
prompt: "test prompt",
|
||||
description: "Test task",
|
||||
run_in_background: false,
|
||||
@@ -234,7 +270,7 @@ describe("resolveCategoryExecution", () => {
|
||||
}
|
||||
const executorCtx = createMockExecutorContext()
|
||||
executorCtx.userCategories = {
|
||||
deep: {
|
||||
quick: {
|
||||
fallback_models: [
|
||||
{
|
||||
model: "openai/gpt-5.4",
|
||||
@@ -278,7 +314,7 @@ describe("resolveCategoryExecution", () => {
|
||||
})
|
||||
const agentsSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
|
||||
const args = {
|
||||
category: "deep",
|
||||
category: "quick",
|
||||
prompt: "test prompt",
|
||||
description: "Test task",
|
||||
run_in_background: false,
|
||||
@@ -288,7 +324,7 @@ describe("resolveCategoryExecution", () => {
|
||||
}
|
||||
const executorCtx = createMockExecutorContext()
|
||||
executorCtx.userCategories = {
|
||||
deep: {
|
||||
quick: {
|
||||
fallback_models: [
|
||||
{
|
||||
model: "openai/gpt-5.4",
|
||||
@@ -329,7 +365,7 @@ describe("resolveCategoryExecution", () => {
|
||||
})
|
||||
const agentsSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
|
||||
const args = {
|
||||
category: "deep",
|
||||
category: "quick",
|
||||
prompt: "test prompt",
|
||||
description: "Test task",
|
||||
run_in_background: false,
|
||||
@@ -339,7 +375,7 @@ describe("resolveCategoryExecution", () => {
|
||||
}
|
||||
const executorCtx = createMockExecutorContext()
|
||||
executorCtx.userCategories = {
|
||||
deep: {
|
||||
quick: {
|
||||
fallback_models: [
|
||||
{
|
||||
model: "openai/gpt-5.4",
|
||||
@@ -416,4 +452,64 @@ describe("resolveCategoryExecution", () => {
|
||||
cacheSpy.mockRestore()
|
||||
agentsSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("does not inherit hardcoded fallbackChain when user configures a category model [regression #3040]", async () => {
|
||||
//#given
|
||||
const args = {
|
||||
category: "quick",
|
||||
prompt: "test prompt",
|
||||
description: "Test task",
|
||||
run_in_background: false,
|
||||
load_skills: [],
|
||||
blockedBy: undefined,
|
||||
enableSkillTools: false,
|
||||
}
|
||||
const executorCtx = createMockExecutorContext()
|
||||
executorCtx.userCategories = {
|
||||
quick: {
|
||||
model: "animal-gateway-xai/grok-4-fast-non-reasoning",
|
||||
},
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = await resolveCategoryExecution(args, executorCtx, undefined, "anthropic/claude-sonnet-4-6")
|
||||
|
||||
//#then
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.actualModel).toBe("animal-gateway-xai/grok-4-fast-non-reasoning")
|
||||
expect(result.categoryModel).toEqual({
|
||||
providerID: "animal-gateway-xai",
|
||||
modelID: "grok-4-fast-non-reasoning",
|
||||
variant: undefined,
|
||||
})
|
||||
expect(result.fallbackChain).toBeUndefined()
|
||||
})
|
||||
|
||||
test("does not inherit hardcoded fallbackChain when sisyphus-junior model override is set [regression #2941]", async () => {
|
||||
//#given
|
||||
const args = {
|
||||
category: "quick",
|
||||
prompt: "test prompt",
|
||||
description: "Test task",
|
||||
run_in_background: false,
|
||||
load_skills: [],
|
||||
blockedBy: undefined,
|
||||
enableSkillTools: false,
|
||||
}
|
||||
const executorCtx = createMockExecutorContext()
|
||||
executorCtx.sisyphusJuniorModel = "anthropic/claude-sonnet-4-6"
|
||||
|
||||
//#when
|
||||
const result = await resolveCategoryExecution(args, executorCtx, undefined, "anthropic/claude-sonnet-4-6")
|
||||
|
||||
//#then
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.actualModel).toBe("anthropic/claude-sonnet-4-6")
|
||||
expect(result.categoryModel).toEqual({
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-sonnet-4-6",
|
||||
variant: undefined,
|
||||
})
|
||||
expect(result.fallbackChain).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,6 +9,7 @@ import { parseModelString } from "./model-string-parser"
|
||||
import { CATEGORY_MODEL_REQUIREMENTS } from "../../shared/model-requirements"
|
||||
import { normalizeFallbackModels, flattenToFallbackModelStrings } from "../../shared/model-resolver"
|
||||
import { buildFallbackChainFromModels, findMostSpecificFallbackEntry } from "../../shared/fallback-chain-from-models"
|
||||
import { CONFIG_BASENAME } from "../../shared/plugin-identity"
|
||||
import { getAvailableModelsForDelegateTask } from "./available-models"
|
||||
import { resolveModelForDelegateTask } from "./model-selection"
|
||||
|
||||
@@ -45,12 +46,26 @@ export async function resolveCategoryExecution(
|
||||
): Promise<CategoryResolutionResult> {
|
||||
const { client, userCategories, sisyphusJuniorModel } = executorCtx
|
||||
|
||||
const availableModels = await getAvailableModelsForDelegateTask(client)
|
||||
|
||||
const categoryName = args.category!
|
||||
const enabledCategories = mergeCategories(userCategories)
|
||||
const categoryExists = enabledCategories[categoryName] !== undefined
|
||||
|
||||
if (!categoryExists) {
|
||||
const allCategoryNames = Object.keys(enabledCategories).join(", ")
|
||||
return {
|
||||
agentToUse: "",
|
||||
categoryModel: undefined,
|
||||
categoryPromptAppend: undefined,
|
||||
maxPromptTokens: undefined,
|
||||
modelInfo: undefined,
|
||||
actualModel: undefined,
|
||||
isUnstableAgent: false,
|
||||
error: `Unknown category: "${categoryName}". Available: ${allCategoryNames}`,
|
||||
}
|
||||
}
|
||||
|
||||
const availableModels = await getAvailableModelsForDelegateTask(client)
|
||||
|
||||
const resolved = resolveCategoryConfig(categoryName, {
|
||||
userCategories,
|
||||
inheritedModel,
|
||||
@@ -75,7 +90,7 @@ export async function resolveCategoryExecution(
|
||||
|
||||
To use this category:
|
||||
1. Connect a provider with this model: ${requirement.requiresModel}
|
||||
2. Or configure an alternative model in your oh-my-opencode.json for this category
|
||||
2. Or configure an alternative model in your ${CONFIG_BASENAME}.json for this category
|
||||
|
||||
Available categories: ${allCategoryNames}`,
|
||||
}
|
||||
@@ -117,7 +132,7 @@ Available categories: ${allCategoryNames}`,
|
||||
const parsedModel = parseModelString(actualModel)
|
||||
const variantToUse = userCategories?.[args.category!]?.variant ?? resolved.config.variant
|
||||
categoryModel = parsedModel
|
||||
? applyCategoryParams({ ...parsedModel, variant: variantToUse }, resolved.config)
|
||||
? applyCategoryParams({ ...parsedModel, variant: variantToUse ?? parsedModel.variant }, resolved.config)
|
||||
: undefined
|
||||
}
|
||||
} else {
|
||||
@@ -136,12 +151,12 @@ Available categories: ${allCategoryNames}`,
|
||||
const userModelOverride = explicitCategoryModel ?? overrideModel
|
||||
if (userModelOverride) {
|
||||
actualModel = userModelOverride
|
||||
const parsedModel = parseModelString(actualModel)
|
||||
const parsedModel = parseModelString(userModelOverride)
|
||||
const variantToUse = userCategories?.[args.category!]?.variant ?? resolved.config.variant
|
||||
categoryModel = parsedModel
|
||||
? applyCategoryParams({ ...parsedModel, variant: variantToUse }, resolved.config)
|
||||
? applyCategoryParams({ ...parsedModel, variant: variantToUse ?? parsedModel.variant }, resolved.config)
|
||||
: undefined
|
||||
modelInfo = { model: actualModel, type: "user-defined", source: "override" }
|
||||
modelInfo = { model: userModelOverride, type: "user-defined", source: "override" }
|
||||
}
|
||||
} else if (resolution) {
|
||||
const {
|
||||
@@ -186,7 +201,7 @@ Available categories: ${allCategoryNames}`,
|
||||
const parsedModel = parseModelString(actualModel)
|
||||
const variantToUse = userCategories?.[args.category!]?.variant ?? resolvedVariant ?? resolved.config.variant
|
||||
categoryModel = parsedModel
|
||||
? applyCategoryParams({ ...parsedModel, variant: variantToUse }, resolved.config)
|
||||
? applyCategoryParams({ ...parsedModel, variant: variantToUse ?? parsedModel.variant }, resolved.config)
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
@@ -211,7 +226,7 @@ Available categories: ${allCategoryNames}`,
|
||||
|
||||
Configure in one of:
|
||||
1. OpenCode: Set "model" in opencode.json
|
||||
2. Oh-My-OpenCode: Set category model in oh-my-opencode.json
|
||||
2. Oh-My-OpenCode: Set category model in ${CONFIG_BASENAME}.json
|
||||
3. Provider: Connect a provider with available models
|
||||
|
||||
Current category: ${args.category}
|
||||
@@ -220,7 +235,7 @@ Available categories: ${categoryNames.join(", ")}`,
|
||||
}
|
||||
|
||||
const resolvedModel = actualModel?.toLowerCase()
|
||||
const isUnstableAgent = resolved.config.is_unstable_agent ?? (resolvedModel ? resolvedModel.includes("gemini") || resolvedModel.includes("minimax") || resolvedModel.includes("kimi") : false)
|
||||
const isUnstableAgent = resolved.config.is_unstable_agent ?? (resolvedModel ? resolvedModel.includes("gemini") || resolvedModel.includes("minimax") : false)
|
||||
|
||||
const defaultProviderID = categoryModel?.providerID
|
||||
?? parseModelString(actualModel ?? "")?.providerID
|
||||
@@ -261,6 +276,6 @@ Available categories: ${categoryNames.join(", ")}`,
|
||||
actualModel,
|
||||
isUnstableAgent,
|
||||
// Don't use hardcoded fallback chain when resolution was skipped (cold cache)
|
||||
fallbackChain: configuredFallbackChain ?? (isModelResolutionSkipped ? undefined : requirement?.fallbackChain),
|
||||
fallbackChain: configuredFallbackChain ?? ((isModelResolutionSkipped || explicitCategoryModel || overrideModel) ? undefined : requirement?.fallbackChain),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,322 +1,14 @@
|
||||
import type { CategoryConfig } from "../../config/schema"
|
||||
import type {
|
||||
AvailableCategory,
|
||||
AvailableSkill,
|
||||
} from "../../agents/dynamic-agent-prompt-builder"
|
||||
import { getAgentConfigKey } from "../../shared/agent-display-names"
|
||||
import { truncateDescription } from "../../shared/truncate-description"
|
||||
|
||||
export const VISUAL_CATEGORY_PROMPT_APPEND = `<Category_Context>
|
||||
You are working on VISUAL/UI tasks.
|
||||
|
||||
<DESIGN_SYSTEM_WORKFLOW_MANDATE>
|
||||
## YOU ARE A VISUAL ENGINEER. FOLLOW THIS WORKFLOW OR YOUR OUTPUT IS REJECTED.
|
||||
|
||||
**YOUR FAILURE MODE**: You skip design system analysis and jump straight to writing components with hardcoded colors, arbitrary spacing, and ad-hoc font sizes. The result is INCONSISTENT GARBAGE that looks like 5 different people built it. THIS STOPS NOW.
|
||||
|
||||
**EVERY visual task follows this EXACT workflow. VIOLATION = BROKEN OUTPUT.**
|
||||
|
||||
### PHASE 1: ANALYZE THE DESIGN SYSTEM (MANDATORY FIRST ACTION)
|
||||
|
||||
**BEFORE writing a SINGLE line of CSS, HTML, JSX, Svelte, or component code — you MUST:**
|
||||
|
||||
1. **SEARCH for the design system.** Use Grep, Glob, Read — actually LOOK:
|
||||
- Design tokens: colors, spacing, typography, shadows, border-radii
|
||||
- Theme files: CSS variables, Tailwind config, \`theme.ts\`, styled-components theme, design tokens file
|
||||
- Shared/base components: Button, Card, Input, Layout primitives
|
||||
- Existing UI patterns: How are pages structured? What spacing grid? What color usage?
|
||||
|
||||
2. **READ at minimum 5-10 existing UI components.** Understand:
|
||||
- Naming conventions (BEM? Atomic? Utility-first? Component-scoped?)
|
||||
- Spacing system (4px grid? 8px? Tailwind scale? CSS variables?)
|
||||
- Color usage (semantic tokens? Direct hex? Theme references?)
|
||||
- Typography scale (heading levels, body, caption — how many? What font stack?)
|
||||
- Component composition patterns (slots? children? compound components?)
|
||||
|
||||
**DO NOT proceed to Phase 2 until you can answer ALL of these. If you cannot, you have not explored enough. EXPLORE MORE.**
|
||||
|
||||
### PHASE 2: NO DESIGN SYSTEM? BUILD ONE. NOW.
|
||||
|
||||
If Phase 1 reveals NO coherent design system (or scattered, inconsistent patterns):
|
||||
|
||||
1. **STOP. Do NOT build the requested UI yet.**
|
||||
2. **Extract what exists** — even inconsistent patterns have salvageable decisions.
|
||||
3. **Create a minimal design system FIRST:**
|
||||
- Color palette: primary, secondary, neutral, semantic (success/warning/error/info)
|
||||
- Typography scale: heading levels (h1-h4 minimum), body, small, caption
|
||||
- Spacing scale: consistent increments (4px or 8px base)
|
||||
- Border radii, shadows, transitions — systematic, not random
|
||||
- Component primitives: the reusable building blocks
|
||||
4. **Commit/save the design system, THEN proceed to Phase 3.**
|
||||
|
||||
A design system is NOT optional overhead. It is the FOUNDATION. Building UI without one is like building a house on sand. It WILL collapse into inconsistency.
|
||||
|
||||
### PHASE 3: BUILD WITH THE SYSTEM. NEVER AROUND IT.
|
||||
|
||||
**NOW and ONLY NOW** — implement the requested visual work:
|
||||
|
||||
| Element | CORRECT | WRONG (WILL BE REJECTED) |
|
||||
|---------|---------|--------------------------|
|
||||
| Color | Design token / CSS variable | Hardcoded \`#3b82f6\`, \`rgb(59,130,246)\` |
|
||||
| Spacing | System value (\`space-4\`, \`gap-md\`, \`var(--spacing-4)\`) | Arbitrary \`margin: 13px\`, \`padding: 7px\` |
|
||||
| Typography | Scale value (\`text-lg\`, \`heading-2\`, token) | Ad-hoc \`font-size: 17px\` |
|
||||
| Component | Extend/compose from existing primitives | One-off div soup with inline styles |
|
||||
| Border radius | System token | Random \`border-radius: 6px\` |
|
||||
|
||||
**IF the design requires something OUTSIDE the current system:**
|
||||
- **Extend the system FIRST** — add the new token/primitive
|
||||
- **THEN use the new token** in your component
|
||||
- **NEVER one-off override.** That is how design systems die.
|
||||
|
||||
### PHASE 4: VERIFY BEFORE CLAIMING DONE
|
||||
|
||||
BEFORE reporting visual work as complete, answer these:
|
||||
|
||||
- [ ] Does EVERY color reference a design token or CSS variable?
|
||||
- [ ] Does EVERY spacing use the system scale?
|
||||
- [ ] Does EVERY component follow the existing composition pattern?
|
||||
- [ ] Would a designer see CONSISTENCY across old and new components?
|
||||
- [ ] Are there ZERO hardcoded magic numbers for visual properties?
|
||||
|
||||
**If ANY answer is NO — FIX IT. You are NOT done.**
|
||||
|
||||
</DESIGN_SYSTEM_WORKFLOW_MANDATE>
|
||||
|
||||
<DESIGN_QUALITY>
|
||||
Design-first mindset (AFTER design system is established):
|
||||
- Bold aesthetic choices over safe defaults
|
||||
- Unexpected layouts, asymmetry, grid-breaking elements
|
||||
- Distinctive typography (avoid: Arial, Inter, Roboto, Space Grotesk)
|
||||
- Cohesive color palettes with sharp accents
|
||||
- High-impact animations with staggered reveals
|
||||
- Atmosphere: gradient meshes, noise textures, layered transparencies
|
||||
|
||||
AVOID: Generic fonts, purple gradients on white, predictable layouts, cookie-cutter patterns.
|
||||
</DESIGN_QUALITY>
|
||||
</Category_Context>`
|
||||
|
||||
export const ULTRABRAIN_CATEGORY_PROMPT_APPEND = `<Category_Context>
|
||||
You are working on DEEP LOGICAL REASONING / COMPLEX ARCHITECTURE tasks.
|
||||
|
||||
**CRITICAL - CODE STYLE REQUIREMENTS (NON-NEGOTIABLE)**:
|
||||
1. BEFORE writing ANY code, SEARCH the existing codebase to find similar patterns/styles
|
||||
2. Your code MUST match the project's existing conventions - blend in seamlessly
|
||||
3. Write READABLE code that humans can easily understand - no clever tricks
|
||||
4. If unsure about style, explore more files until you find the pattern
|
||||
|
||||
Strategic advisor mindset:
|
||||
- Bias toward simplicity: least complex solution that fulfills requirements
|
||||
- Leverage existing code/patterns over new components
|
||||
- Prioritize developer experience and maintainability
|
||||
- One clear recommendation with effort estimate (Quick/Short/Medium/Large)
|
||||
- Signal when advanced approach warranted
|
||||
|
||||
Response format:
|
||||
- Bottom line (2-3 sentences)
|
||||
- Action plan (numbered steps)
|
||||
- Risks and mitigations (if relevant)
|
||||
</Category_Context>`
|
||||
|
||||
export const ARTISTRY_CATEGORY_PROMPT_APPEND = `<Category_Context>
|
||||
You are working on HIGHLY CREATIVE / ARTISTIC tasks.
|
||||
|
||||
Artistic genius mindset:
|
||||
- Push far beyond conventional boundaries
|
||||
- Explore radical, unconventional directions
|
||||
- Surprise and delight: unexpected twists, novel combinations
|
||||
- Rich detail and vivid expression
|
||||
- Break patterns deliberately when it serves the creative vision
|
||||
|
||||
Approach:
|
||||
- Generate diverse, bold options first
|
||||
- Embrace ambiguity and wild experimentation
|
||||
- Balance novelty with coherence
|
||||
- This is for tasks requiring exceptional creativity
|
||||
</Category_Context>`
|
||||
|
||||
export const QUICK_CATEGORY_PROMPT_APPEND = `<Category_Context>
|
||||
You are working on SMALL / QUICK tasks.
|
||||
|
||||
Efficient execution mindset:
|
||||
- Fast, focused, minimal overhead
|
||||
- Get to the point immediately
|
||||
- No over-engineering
|
||||
- Simple solutions for simple problems
|
||||
|
||||
Approach:
|
||||
- Minimal viable implementation
|
||||
- Skip unnecessary abstractions
|
||||
- Direct and concise
|
||||
</Category_Context>
|
||||
|
||||
<Caller_Warning>
|
||||
THIS CATEGORY USES A SMALLER/FASTER MODEL (gpt-5.4-mini).
|
||||
|
||||
The model executing this task is optimized for speed over depth. Your prompt MUST be:
|
||||
|
||||
**EXHAUSTIVELY EXPLICIT** - Leave NOTHING to interpretation:
|
||||
1. MUST DO: List every required action as atomic, numbered steps
|
||||
2. MUST NOT DO: Explicitly forbid likely mistakes and deviations
|
||||
3. EXPECTED OUTPUT: Describe exact success criteria with concrete examples
|
||||
|
||||
**WHY THIS MATTERS:**
|
||||
- Smaller models benefit from explicit guardrails
|
||||
- Vague instructions may lead to unpredictable results
|
||||
- Implicit expectations may be missed
|
||||
**PROMPT STRUCTURE (MANDATORY):**
|
||||
\`\`\`
|
||||
TASK: [One-sentence goal]
|
||||
|
||||
MUST DO:
|
||||
1. [Specific action with exact details]
|
||||
2. [Another specific action]
|
||||
...
|
||||
|
||||
MUST NOT DO:
|
||||
- [Forbidden action + why]
|
||||
- [Another forbidden action]
|
||||
...
|
||||
|
||||
EXPECTED OUTPUT:
|
||||
- [Exact deliverable description]
|
||||
- [Success criteria / verification method]
|
||||
\`\`\`
|
||||
|
||||
If your prompt lacks this structure, REWRITE IT before delegating.
|
||||
</Caller_Warning>`
|
||||
|
||||
export const UNSPECIFIED_LOW_CATEGORY_PROMPT_APPEND = `<Category_Context>
|
||||
You are working on tasks that don't fit specific categories but require moderate effort.
|
||||
|
||||
<Selection_Gate>
|
||||
BEFORE selecting this category, VERIFY ALL conditions:
|
||||
1. Task does NOT fit: quick (trivial), visual-engineering (UI), ultrabrain (deep logic), artistry (creative), writing (docs)
|
||||
2. Task requires more than trivial effort but is NOT system-wide
|
||||
3. Scope is contained within a few files/modules
|
||||
|
||||
If task fits ANY other category, DO NOT select unspecified-low.
|
||||
This is NOT a default choice - it's for genuinely unclassifiable moderate-effort work.
|
||||
</Selection_Gate>
|
||||
</Category_Context>
|
||||
|
||||
<Caller_Warning>
|
||||
THIS CATEGORY USES A MID-TIER MODEL (claude-sonnet-4-6).
|
||||
|
||||
**PROVIDE CLEAR STRUCTURE:**
|
||||
1. MUST DO: Enumerate required actions explicitly
|
||||
2. MUST NOT DO: State forbidden actions to prevent scope creep
|
||||
3. EXPECTED OUTPUT: Define concrete success criteria
|
||||
</Caller_Warning>`
|
||||
|
||||
export const UNSPECIFIED_HIGH_CATEGORY_PROMPT_APPEND = `<Category_Context>
|
||||
You are working on tasks that don't fit specific categories but require substantial effort.
|
||||
|
||||
<Selection_Gate>
|
||||
BEFORE selecting this category, VERIFY ALL conditions:
|
||||
1. Task does NOT fit: quick (trivial), visual-engineering (UI), ultrabrain (deep logic), artistry (creative), writing (docs)
|
||||
2. Task requires substantial effort across multiple systems/modules
|
||||
3. Changes have broad impact or require careful coordination
|
||||
4. NOT just "complex" - must be genuinely unclassifiable AND high-effort
|
||||
|
||||
If task fits ANY other category, DO NOT select unspecified-high.
|
||||
If task is unclassifiable but moderate-effort, use unspecified-low instead.
|
||||
</Selection_Gate>
|
||||
</Category_Context>`
|
||||
|
||||
export const WRITING_CATEGORY_PROMPT_APPEND = `<Category_Context>
|
||||
You are working on WRITING / PROSE tasks.
|
||||
|
||||
Wordsmith mindset:
|
||||
- Clear, flowing prose
|
||||
- Appropriate tone and voice
|
||||
- Engaging and readable
|
||||
- Proper structure and organization
|
||||
|
||||
Approach:
|
||||
- Understand the audience
|
||||
- Draft with care
|
||||
- Polish for clarity and impact
|
||||
- Documentation, READMEs, articles, technical writing
|
||||
|
||||
ANTI-AI-SLOP RULES (NON-NEGOTIABLE):
|
||||
- NEVER use em dashes (—) or en dashes (–). Use commas, periods, ellipses, or line breaks instead. Zero tolerance.
|
||||
- Remove AI-sounding phrases: "delve", "it's important to note", "I'd be happy to", "certainly", "please don't hesitate", "leverage", "utilize", "in order to", "moving forward", "circle back", "at the end of the day", "robust", "streamline", "facilitate"
|
||||
- Pick plain words. "Use" not "utilize". "Start" not "commence". "Help" not "facilitate".
|
||||
- Use contractions naturally: "don't" not "do not", "it's" not "it is".
|
||||
- Vary sentence length. Don't make every sentence the same length.
|
||||
- NEVER start consecutive sentences with the same word.
|
||||
- No filler openings: skip "In today's world...", "As we all know...", "It goes without saying..."
|
||||
- Write like a human, not a corporate template.
|
||||
</Category_Context>`
|
||||
|
||||
export const DEEP_CATEGORY_PROMPT_APPEND = `<Category_Context>
|
||||
You are working on GOAL-ORIENTED AUTONOMOUS tasks.
|
||||
|
||||
**CRITICAL - AUTONOMOUS EXECUTION MINDSET (NON-NEGOTIABLE)**:
|
||||
You are NOT an interactive assistant. You are an autonomous problem-solver.
|
||||
|
||||
**BEFORE making ANY changes**:
|
||||
1. SILENTLY explore the codebase extensively (5-15 minutes of reading is normal)
|
||||
2. Read related files, trace dependencies, understand the full context
|
||||
3. Build a complete mental model of the problem space
|
||||
4. DO NOT ask clarifying questions - the goal is already defined
|
||||
|
||||
**Autonomous executor mindset**:
|
||||
- You receive a GOAL. When the goal includes numbered steps or phases, treat them as one atomic task broken into sub-steps - NOT as separate independent tasks.
|
||||
- Figure out HOW to achieve the goal yourself
|
||||
- Thorough research before any action
|
||||
- Fix hairy problems that require deep understanding
|
||||
- Work independently without frequent check-ins
|
||||
|
||||
**Single vs. multi-step context**:
|
||||
- Sub-steps of ONE goal (e.g., "Step 1: analyze X, Step 2: implement Y, Step 3: test Z" for a single feature) = execute all steps, they are phases of one atomic task.
|
||||
- Genuinely independent tasks (e.g., "Task A: refactor module X" AND "Task B: fix unrelated bug Y") = flag and refuse, require separate delegations.
|
||||
|
||||
**Approach**:
|
||||
- Explore extensively, understand deeply, then act decisively
|
||||
- Prefer comprehensive solutions over quick patches
|
||||
- If the goal is unclear, make reasonable assumptions and proceed
|
||||
- Document your reasoning in code comments only when non-obvious
|
||||
|
||||
**Response format**:
|
||||
- Minimal status updates (user trusts your autonomy)
|
||||
- Focus on results, not play-by-play progress
|
||||
- Report completion with summary of changes made
|
||||
</Category_Context>`
|
||||
|
||||
|
||||
|
||||
export const DEFAULT_CATEGORIES: Record<string, CategoryConfig> = {
|
||||
"visual-engineering": { model: "google/gemini-3.1-pro", variant: "high" },
|
||||
ultrabrain: { model: "openai/gpt-5.4", variant: "xhigh" },
|
||||
deep: { model: "openai/gpt-5.3-codex", variant: "medium" },
|
||||
artistry: { model: "google/gemini-3.1-pro", variant: "high" },
|
||||
quick: { model: "openai/gpt-5.4-mini" },
|
||||
"unspecified-low": { model: "anthropic/claude-sonnet-4-6" },
|
||||
"unspecified-high": { model: "anthropic/claude-opus-4-6", variant: "max" },
|
||||
writing: { model: "kimi-for-coding/k2p5" },
|
||||
}
|
||||
|
||||
export const CATEGORY_PROMPT_APPENDS: Record<string, string> = {
|
||||
"visual-engineering": VISUAL_CATEGORY_PROMPT_APPEND,
|
||||
ultrabrain: ULTRABRAIN_CATEGORY_PROMPT_APPEND,
|
||||
deep: DEEP_CATEGORY_PROMPT_APPEND,
|
||||
artistry: ARTISTRY_CATEGORY_PROMPT_APPEND,
|
||||
quick: QUICK_CATEGORY_PROMPT_APPEND,
|
||||
"unspecified-low": UNSPECIFIED_LOW_CATEGORY_PROMPT_APPEND,
|
||||
"unspecified-high": UNSPECIFIED_HIGH_CATEGORY_PROMPT_APPEND,
|
||||
writing: WRITING_CATEGORY_PROMPT_APPEND,
|
||||
}
|
||||
|
||||
export const CATEGORY_DESCRIPTIONS: Record<string, string> = {
|
||||
"visual-engineering": "Frontend, UI/UX, design, styling, animation",
|
||||
ultrabrain: "Use ONLY for genuinely hard, logic-heavy tasks. Give clear goals only, not step-by-step instructions.",
|
||||
deep: "Goal-oriented autonomous problem-solving. Thorough research before action. For hairy problems requiring deep understanding.",
|
||||
artistry: "Complex problem-solving with unconventional, creative approaches - beyond standard patterns",
|
||||
quick: "Trivial tasks - single file changes, typo fixes, simple modifications",
|
||||
"unspecified-low": "Tasks that don't fit other categories, low effort required",
|
||||
"unspecified-high": "Tasks that don't fit other categories, high effort required",
|
||||
writing: "Documentation, prose, technical writing",
|
||||
}
|
||||
export {
|
||||
CATEGORY_DESCRIPTIONS,
|
||||
CATEGORY_PROMPT_APPENDS,
|
||||
DEFAULT_CATEGORIES,
|
||||
} from "./builtin-categories"
|
||||
|
||||
/**
|
||||
* System prompt prepended to plan agent invocations.
|
||||
@@ -634,7 +326,7 @@ export const PLAN_AGENT_NAMES = ["plan"]
|
||||
export function isPlanAgent(agentName: string | undefined): boolean {
|
||||
if (!agentName) return false
|
||||
const lowerName = agentName.toLowerCase().trim()
|
||||
return PLAN_AGENT_NAMES.some(name => lowerName === name || lowerName.includes(name))
|
||||
return PLAN_AGENT_NAMES.some(name => lowerName === name)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -650,8 +342,6 @@ export function isPlanFamily(category: string): boolean
|
||||
export function isPlanFamily(category: string | undefined): boolean
|
||||
export function isPlanFamily(category: string | undefined): boolean {
|
||||
if (!category) return false
|
||||
const lowerCategory = category.toLowerCase().trim()
|
||||
return PLAN_FAMILY_NAMES.some(
|
||||
(name) => lowerCategory === name || lowerCategory.includes(name)
|
||||
)
|
||||
const lowerCategory = getAgentConfigKey(category).toLowerCase().trim()
|
||||
return PLAN_FAMILY_NAMES.some((name) => lowerCategory === name)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { BuiltinCategoryDefinition } from "./builtin-category-definition"
|
||||
|
||||
const VISUAL_CATEGORY_PROMPT_APPEND = `<Category_Context>
|
||||
You are working on VISUAL/UI tasks.
|
||||
|
||||
<DESIGN_SYSTEM_WORKFLOW_MANDATE>
|
||||
## YOU ARE A VISUAL ENGINEER. FOLLOW THIS WORKFLOW OR YOUR OUTPUT IS REJECTED.
|
||||
|
||||
**YOUR FAILURE MODE**: You skip design system analysis and jump straight to writing components with hardcoded colors, arbitrary spacing, and ad-hoc font sizes. The result is INCONSISTENT GARBAGE that looks like 5 different people built it. THIS STOPS NOW.
|
||||
|
||||
**EVERY visual task follows this EXACT workflow. VIOLATION = BROKEN OUTPUT.**
|
||||
|
||||
### PHASE 1: ANALYZE THE DESIGN SYSTEM (MANDATORY FIRST ACTION)
|
||||
|
||||
**BEFORE writing a SINGLE line of CSS, HTML, JSX, Svelte, or component code - you MUST:**
|
||||
|
||||
1. **SEARCH for the design system.** Use Grep, Glob, Read - actually LOOK:
|
||||
- Design tokens: colors, spacing, typography, shadows, border-radii
|
||||
- Theme files: CSS variables, Tailwind config, \`theme.ts\`, styled-components theme, design tokens file
|
||||
- Shared/base components: Button, Card, Input, Layout primitives
|
||||
- Existing UI patterns: How are pages structured? What spacing grid? What color usage?
|
||||
|
||||
2. **READ at minimum 5-10 existing UI components.** Understand:
|
||||
- Naming conventions (BEM? Atomic? Utility-first? Component-scoped?)
|
||||
- Spacing system (4px grid? 8px? Tailwind scale? CSS variables?)
|
||||
- Color usage (semantic tokens? Direct hex? Theme references?)
|
||||
- Typography scale (heading levels, body, caption - how many? What font stack?)
|
||||
- Component composition patterns (slots? children? compound components?)
|
||||
|
||||
**DO NOT proceed to Phase 2 until you can answer ALL of these. If you cannot, you have not explored enough. EXPLORE MORE.**
|
||||
|
||||
### PHASE 2: NO DESIGN SYSTEM? BUILD ONE. NOW.
|
||||
|
||||
If Phase 1 reveals NO coherent design system (or scattered, inconsistent patterns):
|
||||
|
||||
1. **STOP. Do NOT build the requested UI yet.**
|
||||
2. **Extract what exists** - even inconsistent patterns have salvageable decisions.
|
||||
3. **Create a minimal design system FIRST:**
|
||||
- Color palette: primary, secondary, neutral, semantic (success/warning/error/info)
|
||||
- Typography scale: heading levels (h1-h4 minimum), body, small, caption
|
||||
- Spacing scale: consistent increments (4px or 8px base)
|
||||
- Border radii, shadows, transitions - systematic, not random
|
||||
- Component primitives: the reusable building blocks
|
||||
4. **Commit/save the design system, THEN proceed to Phase 3.**
|
||||
|
||||
A design system is NOT optional overhead. It is the FOUNDATION. Building UI without one is like building a house on sand. It WILL collapse into inconsistency.
|
||||
|
||||
### PHASE 3: BUILD WITH THE SYSTEM. NEVER AROUND IT.
|
||||
|
||||
**NOW and ONLY NOW** - implement the requested visual work:
|
||||
|
||||
| Element | CORRECT | WRONG (WILL BE REJECTED) |
|
||||
|---------|---------|--------------------------|
|
||||
| Color | Design token / CSS variable | Hardcoded \`#3b82f6\`, \`rgb(59,130,246)\` |
|
||||
| Spacing | System value (\`space-4\`, \`gap-md\`, \`var(--spacing-4)\`) | Arbitrary \`margin: 13px\`, \`padding: 7px\` |
|
||||
| Typography | Scale value (\`text-lg\`, \`heading-2\`, token) | Ad-hoc \`font-size: 17px\` |
|
||||
| Component | Extend/compose from existing primitives | One-off div soup with inline styles |
|
||||
| Border radius | System token | Random \`border-radius: 6px\` |
|
||||
|
||||
**IF the design requires something OUTSIDE the current system:**
|
||||
- **Extend the system FIRST** - add the new token/primitive
|
||||
- **THEN use the new token** in your component
|
||||
- **NEVER one-off override.** That is how design systems die.
|
||||
|
||||
### PHASE 4: VERIFY BEFORE CLAIMING DONE
|
||||
|
||||
BEFORE reporting visual work as complete, answer these:
|
||||
|
||||
- [ ] Does EVERY color reference a design token or CSS variable?
|
||||
- [ ] Does EVERY spacing use the system scale?
|
||||
- [ ] Does EVERY component follow the existing composition pattern?
|
||||
- [ ] Would a designer see CONSISTENCY across old and new components?
|
||||
- [ ] Are there ZERO hardcoded magic numbers for visual properties?
|
||||
|
||||
**If ANY answer is NO - FIX IT. You are NOT done.**
|
||||
|
||||
</DESIGN_SYSTEM_WORKFLOW_MANDATE>
|
||||
|
||||
<DESIGN_QUALITY>
|
||||
Design-first mindset (AFTER design system is established):
|
||||
- Bold aesthetic choices over safe defaults
|
||||
- Unexpected layouts, asymmetry, grid-breaking elements
|
||||
- Distinctive typography (avoid: Arial, Inter, Roboto, Space Grotesk)
|
||||
- Cohesive color palettes with sharp accents
|
||||
- High-impact animations with staggered reveals
|
||||
- Atmosphere: gradient meshes, noise textures, layered transparencies
|
||||
|
||||
AVOID: Generic fonts, purple gradients on white, predictable layouts, cookie-cutter patterns.
|
||||
</DESIGN_QUALITY>
|
||||
</Category_Context>`
|
||||
|
||||
const ARTISTRY_CATEGORY_PROMPT_APPEND = `<Category_Context>
|
||||
You are working on HIGHLY CREATIVE / ARTISTIC tasks.
|
||||
|
||||
Artistic genius mindset:
|
||||
- Push far beyond conventional boundaries
|
||||
- Explore radical, unconventional directions
|
||||
- Surprise and delight: unexpected twists, novel combinations
|
||||
- Rich detail and vivid expression
|
||||
- Break patterns deliberately when it serves the creative vision
|
||||
|
||||
Approach:
|
||||
- Generate diverse, bold options first
|
||||
- Embrace ambiguity and wild experimentation
|
||||
- Balance novelty with coherence
|
||||
- This is for tasks requiring exceptional creativity
|
||||
</Category_Context>`
|
||||
|
||||
export const GOOGLE_CATEGORIES: BuiltinCategoryDefinition[] = [
|
||||
{
|
||||
name: "visual-engineering",
|
||||
config: { model: "google/gemini-3.1-pro", variant: "high" },
|
||||
description: "Frontend, UI/UX, design, styling, animation",
|
||||
promptAppend: VISUAL_CATEGORY_PROMPT_APPEND,
|
||||
},
|
||||
{
|
||||
name: "artistry",
|
||||
config: { model: "google/gemini-3.1-pro", variant: "high" },
|
||||
description: "Complex problem-solving with unconventional, creative approaches - beyond standard patterns",
|
||||
promptAppend: ARTISTRY_CATEGORY_PROMPT_APPEND,
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { BuiltinCategoryDefinition } from "./builtin-category-definition"
|
||||
|
||||
const WRITING_CATEGORY_PROMPT_APPEND = `<Category_Context>
|
||||
You are working on WRITING / PROSE tasks.
|
||||
|
||||
Wordsmith mindset:
|
||||
- Clear, flowing prose
|
||||
- Appropriate tone and voice
|
||||
- Engaging and readable
|
||||
- Proper structure and organization
|
||||
|
||||
Approach:
|
||||
- Understand the audience
|
||||
- Draft with care
|
||||
- Polish for clarity and impact
|
||||
- Documentation, READMEs, articles, technical writing
|
||||
|
||||
ANTI-AI-SLOP RULES (NON-NEGOTIABLE):
|
||||
- NEVER use em dashes (-) or en dashes (-). Use commas, periods, ellipses, or line breaks instead. Zero tolerance.
|
||||
- Remove AI-sounding phrases: "delve", "it's important to note", "I'd be happy to", "certainly", "please don't hesitate", "leverage", "utilize", "in order to", "moving forward", "circle back", "at the end of the day", "robust", "streamline", "facilitate"
|
||||
- Pick plain words. "Use" not "utilize". "Start" not "commence". "Help" not "facilitate".
|
||||
- Use contractions naturally: "don't" not "do not", "it's" not "it is".
|
||||
- Vary sentence length. Don't make every sentence the same length.
|
||||
- NEVER start consecutive sentences with the same word.
|
||||
- No filler openings: skip "In today's world...", "As we all know...", "It goes without saying..."
|
||||
- Write like a human, not a corporate template.
|
||||
</Category_Context>`
|
||||
|
||||
export const KIMI_CATEGORIES: BuiltinCategoryDefinition[] = [
|
||||
{
|
||||
name: "writing",
|
||||
config: { model: "kimi-for-coding/k2p5" },
|
||||
description: "Documentation, prose, technical writing",
|
||||
promptAppend: WRITING_CATEGORY_PROMPT_APPEND,
|
||||
},
|
||||
]
|
||||
@@ -1,5 +1,6 @@
|
||||
declare const require: (name: string) => any
|
||||
const { afterEach, beforeEach, describe, expect, mock, spyOn, test } = require("bun:test")
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"
|
||||
import { resolveModelForDelegateTask } from "./model-selection"
|
||||
import * as connectedProvidersCache from "../../shared/connected-providers-cache"
|
||||
|
||||
@@ -25,12 +26,12 @@ describe("resolveModelForDelegateTask", () => {
|
||||
describe("#when availableModels is empty and no user model override", () => {
|
||||
test("#then returns skipped sentinel to leave model unpinned", () => {
|
||||
const result = resolveModelForDelegateTask({
|
||||
categoryDefaultModel: "anthropic/claude-sonnet-4-6",
|
||||
categoryDefaultModel: "anthropic/claude-sonnet-4.6",
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic"], model: "claude-sonnet-4-6" },
|
||||
],
|
||||
availableModels: new Set(),
|
||||
systemDefaultModel: "anthropic/claude-sonnet-4-6",
|
||||
systemDefaultModel: "anthropic/claude-sonnet-4.6",
|
||||
})
|
||||
|
||||
expect(result).toEqual({ skipped: true })
|
||||
@@ -41,12 +42,12 @@ describe("resolveModelForDelegateTask", () => {
|
||||
test("#then returns the user model regardless of cache state", () => {
|
||||
const result = resolveModelForDelegateTask({
|
||||
userModel: "openai/gpt-5.4",
|
||||
categoryDefaultModel: "anthropic/claude-sonnet-4-6",
|
||||
categoryDefaultModel: "anthropic/claude-sonnet-4.6",
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic"], model: "claude-sonnet-4-6" },
|
||||
],
|
||||
availableModels: new Set(),
|
||||
systemDefaultModel: "anthropic/claude-sonnet-4-6",
|
||||
systemDefaultModel: "anthropic/claude-sonnet-4.6",
|
||||
})
|
||||
|
||||
expect(result).toEqual({ model: "openai/gpt-5.4" })
|
||||
@@ -57,7 +58,7 @@ describe("resolveModelForDelegateTask", () => {
|
||||
test("#then returns skipped sentinel (skip fallback resolution without cache)", () => {
|
||||
const result = resolveModelForDelegateTask({
|
||||
userFallbackModels: ["openai/gpt-5.4", "google/gemini-3.1-pro"],
|
||||
categoryDefaultModel: "anthropic/claude-sonnet-4-6",
|
||||
categoryDefaultModel: "anthropic/claude-sonnet-4.6",
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic"], model: "claude-sonnet-4-6" },
|
||||
],
|
||||
@@ -80,15 +81,15 @@ describe("resolveModelForDelegateTask", () => {
|
||||
const readConnectedProvidersSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["anthropic"])
|
||||
|
||||
const result = resolveModelForDelegateTask({
|
||||
categoryDefaultModel: "anthropic/claude-sonnet-4-6",
|
||||
categoryDefaultModel: "anthropic/claude-sonnet-4.6",
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic"], model: "claude-sonnet-4-6" },
|
||||
],
|
||||
availableModels: new Set(),
|
||||
systemDefaultModel: "anthropic/claude-sonnet-4-6",
|
||||
systemDefaultModel: "anthropic/claude-sonnet-4.6",
|
||||
})
|
||||
|
||||
expect(result).toEqual({ model: "anthropic/claude-sonnet-4-6" })
|
||||
expect(result).toEqual({ model: "anthropic/claude-sonnet-4.6" })
|
||||
readConnectedProvidersSpy.mockRestore()
|
||||
})
|
||||
|
||||
@@ -96,12 +97,12 @@ describe("resolveModelForDelegateTask", () => {
|
||||
const readConnectedProvidersSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
|
||||
|
||||
const result = resolveModelForDelegateTask({
|
||||
categoryDefaultModel: "anthropic/claude-sonnet-4-6",
|
||||
categoryDefaultModel: "anthropic/claude-sonnet-4.6",
|
||||
fallbackChain: [
|
||||
{ providers: ["openai"], model: "gpt-5.4", variant: "high" },
|
||||
],
|
||||
availableModels: new Set(),
|
||||
systemDefaultModel: "anthropic/claude-sonnet-4-6",
|
||||
systemDefaultModel: "anthropic/claude-sonnet-4.6",
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
@@ -117,7 +118,7 @@ describe("resolveModelForDelegateTask", () => {
|
||||
const readConnectedProvidersSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
|
||||
|
||||
const result = resolveModelForDelegateTask({
|
||||
userFallbackModels: ["anthropic/claude-sonnet-4-6", "openai/gpt-5.4"],
|
||||
userFallbackModels: ["anthropic/claude-sonnet-4.6", "openai/gpt-5.4"],
|
||||
availableModels: new Set(),
|
||||
})
|
||||
|
||||
@@ -129,14 +130,14 @@ describe("resolveModelForDelegateTask", () => {
|
||||
describe("#when availableModels has entries and category default matches", () => {
|
||||
test("#then resolves via fuzzy match (existing behavior)", () => {
|
||||
const result = resolveModelForDelegateTask({
|
||||
categoryDefaultModel: "anthropic/claude-sonnet-4-6",
|
||||
categoryDefaultModel: "anthropic/claude-sonnet-4.6",
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic"], model: "claude-sonnet-4-6" },
|
||||
],
|
||||
availableModels: new Set(["anthropic/claude-sonnet-4-6"]),
|
||||
availableModels: new Set(["anthropic/claude-sonnet-4.6"]),
|
||||
})
|
||||
|
||||
expect(result).toEqual({ model: "anthropic/claude-sonnet-4-6" })
|
||||
expect(result).toEqual({ model: "anthropic/claude-sonnet-4.6" })
|
||||
})
|
||||
|
||||
test("#then trusts user-configured category model without fuzzy validation", () => {
|
||||
@@ -200,7 +201,7 @@ describe("resolveModelForDelegateTask", () => {
|
||||
expect(result).toBeDefined()
|
||||
expect(result).not.toHaveProperty("skipped")
|
||||
const resolved = result as { model: string; variant?: string }
|
||||
expect(resolved.model).toBe("anthropic/claude-haiku-4-5")
|
||||
expect(resolved.model).toBe("anthropic/claude-haiku-4.5")
|
||||
})
|
||||
|
||||
test("#then resolves first provider in entry that is connected", () => {
|
||||
@@ -229,10 +230,10 @@ describe("resolveModelForDelegateTask", () => {
|
||||
{ providers: ["opencode-go"], model: "minimax-m2.7" },
|
||||
],
|
||||
availableModels: new Set(),
|
||||
systemDefaultModel: "anthropic/claude-sonnet-4-6",
|
||||
systemDefaultModel: "anthropic/claude-sonnet-4.6",
|
||||
})
|
||||
|
||||
expect(result).toEqual({ model: "anthropic/claude-sonnet-4-6" })
|
||||
expect(result).toEqual({ model: "anthropic/claude-sonnet-4.6" })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -254,6 +255,100 @@ describe("resolveModelForDelegateTask", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given user model override includes variant syntax", () => {
|
||||
describe("#when userModel contains space-separated variant", () => {
|
||||
test("#then extracts the variant and returns the base model separately", () => {
|
||||
const result = resolveModelForDelegateTask({
|
||||
userModel: "openai/gpt-5.4 high",
|
||||
categoryDefaultModel: "anthropic/claude-sonnet-4.6",
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic"], model: "claude-sonnet-4-6" },
|
||||
],
|
||||
availableModels: new Set(["openai/gpt-5.4"]),
|
||||
})
|
||||
|
||||
expect(result).toEqual({ model: "openai/gpt-5.4", variant: "high" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("#when userModel contains parenthesized variant", () => {
|
||||
test("#then extracts the variant and returns the base model separately", () => {
|
||||
const result = resolveModelForDelegateTask({
|
||||
userModel: "openai/gpt-5.4(max)",
|
||||
categoryDefaultModel: "anthropic/claude-sonnet-4.6",
|
||||
availableModels: new Set(),
|
||||
})
|
||||
|
||||
expect(result).toEqual({ model: "openai/gpt-5.4", variant: "max" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("#when userModel has no variant syntax", () => {
|
||||
test("#then returns the model without a variant (backward compat)", () => {
|
||||
const result = resolveModelForDelegateTask({
|
||||
userModel: "openai/gpt-5.4",
|
||||
availableModels: new Set(),
|
||||
})
|
||||
|
||||
expect(result).toEqual({ model: "openai/gpt-5.4" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("#when userModel has a non-variant suffix (e.g. -high in model name)", () => {
|
||||
test("#then preserves the full model name without extracting a variant", () => {
|
||||
const result = resolveModelForDelegateTask({
|
||||
userModel: "new-api-openai/gpt-5.4-high",
|
||||
availableModels: new Set(),
|
||||
})
|
||||
|
||||
expect(result).toEqual({ model: "new-api-openai/gpt-5.4-high" })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given user-configured category model includes variant syntax", () => {
|
||||
beforeEach(() => {
|
||||
hasConnectedProvidersSpy = spyOn(connectedProvidersCache, "hasConnectedProvidersCache").mockReturnValue(true)
|
||||
hasProviderModelsSpy = spyOn(connectedProvidersCache, "hasProviderModelsCache").mockReturnValue(true)
|
||||
})
|
||||
|
||||
describe("#when categoryDefaultModel with isUserConfiguredCategoryModel contains a space-separated variant", () => {
|
||||
test("#then extracts the variant and returns the base model separately", () => {
|
||||
const result = resolveModelForDelegateTask({
|
||||
categoryDefaultModel: "openai/gpt-5.4 medium",
|
||||
isUserConfiguredCategoryModel: true,
|
||||
availableModels: new Set(["openai/gpt-5.4"]),
|
||||
})
|
||||
|
||||
expect(result).toEqual({ model: "openai/gpt-5.4", variant: "medium" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("#when categoryDefaultModel with isUserConfiguredCategoryModel contains a parenthesized variant", () => {
|
||||
test("#then extracts the variant and returns the base model separately", () => {
|
||||
const result = resolveModelForDelegateTask({
|
||||
categoryDefaultModel: "openai/gpt-5.4(xhigh)",
|
||||
isUserConfiguredCategoryModel: true,
|
||||
availableModels: new Set(),
|
||||
})
|
||||
|
||||
expect(result).toEqual({ model: "openai/gpt-5.4", variant: "xhigh" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("#when categoryDefaultModel with isUserConfiguredCategoryModel has no variant", () => {
|
||||
test("#then returns the model without a variant (backward compat)", () => {
|
||||
const result = resolveModelForDelegateTask({
|
||||
categoryDefaultModel: "new-api-openai/gpt-5.4-high",
|
||||
isUserConfiguredCategoryModel: true,
|
||||
availableModels: new Set(["openai/gpt-5.4"]),
|
||||
})
|
||||
|
||||
expect(result).toEqual({ model: "new-api-openai/gpt-5.4-high" })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given only connected providers cache exists (no provider-models cache)", () => {
|
||||
beforeEach(() => {
|
||||
hasConnectedProvidersSpy = spyOn(connectedProvidersCache, "hasConnectedProvidersCache").mockReturnValue(true)
|
||||
@@ -265,7 +360,7 @@ describe("resolveModelForDelegateTask", () => {
|
||||
const readConnectedProvidersSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
|
||||
|
||||
const result = resolveModelForDelegateTask({
|
||||
categoryDefaultModel: "anthropic/claude-sonnet-4-6",
|
||||
categoryDefaultModel: "anthropic/claude-sonnet-4.6",
|
||||
fallbackChain: [
|
||||
{ providers: ["openai"], model: "gpt-5.4" },
|
||||
],
|
||||
|
||||
@@ -56,6 +56,10 @@ export function resolveModelForDelegateTask(input: {
|
||||
}): { model: string; variant?: string; fallbackEntry?: FallbackEntry; matchedFallback?: boolean } | { skipped: true } | undefined {
|
||||
const userModel = normalizeModel(input.userModel)
|
||||
if (userModel) {
|
||||
const parsed = parseUserFallbackModel(userModel)
|
||||
if (parsed?.variant) {
|
||||
return { model: parsed.baseModel, variant: parsed.variant }
|
||||
}
|
||||
return { model: userModel }
|
||||
}
|
||||
|
||||
@@ -75,6 +79,10 @@ export function resolveModelForDelegateTask(input: {
|
||||
log("[resolveModelForDelegateTask] using user-configured category model (bypass validation)", {
|
||||
categoryDefaultModel: categoryDefault,
|
||||
})
|
||||
const parsed = parseUserFallbackModel(categoryDefault)
|
||||
if (parsed?.variant) {
|
||||
return { model: parsed.baseModel, variant: parsed.variant }
|
||||
}
|
||||
return { model: categoryDefault }
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { BuiltinCategoryDefinition } from "./builtin-category-definition"
|
||||
|
||||
const ULTRABRAIN_CATEGORY_PROMPT_APPEND = `<Category_Context>
|
||||
You are working on DEEP LOGICAL REASONING / COMPLEX ARCHITECTURE tasks.
|
||||
|
||||
**CRITICAL - CODE STYLE REQUIREMENTS (NON-NEGOTIABLE)**:
|
||||
1. BEFORE writing ANY code, SEARCH the existing codebase to find similar patterns/styles
|
||||
2. Your code MUST match the project's existing conventions - blend in seamlessly
|
||||
3. Write READABLE code that humans can easily understand - no clever tricks
|
||||
4. If unsure about style, explore more files until you find the pattern
|
||||
|
||||
Strategic advisor mindset:
|
||||
- Bias toward simplicity: least complex solution that fulfills requirements
|
||||
- Leverage existing code/patterns over new components
|
||||
- Prioritize developer experience and maintainability
|
||||
- One clear recommendation with effort estimate (Quick/Short/Medium/Large)
|
||||
- Signal when advanced approach warranted
|
||||
|
||||
Response format:
|
||||
- Bottom line (2-3 sentences)
|
||||
- Action plan (numbered steps)
|
||||
- Risks and mitigations (if relevant)
|
||||
</Category_Context>`
|
||||
|
||||
const DEEP_CATEGORY_PROMPT_APPEND = `<Category_Context>
|
||||
You are working on GOAL-ORIENTED AUTONOMOUS tasks.
|
||||
|
||||
You are NOT an interactive assistant. You are an autonomous problem-solver.
|
||||
|
||||
BEFORE making ANY changes:
|
||||
1. Silently explore the codebase extensively (5-15 minutes of reading is normal)
|
||||
2. Read related files, trace dependencies, understand the full context
|
||||
3. Build a complete mental model of the problem space
|
||||
4. Do not ask clarifying questions - the goal is already defined
|
||||
|
||||
You receive a GOAL. When the goal includes numbered steps or phases, treat them as one atomic task broken into sub-steps, not as separate independent tasks. Figure out HOW to achieve it yourself. Thorough research before any action.
|
||||
|
||||
Sub-steps of ONE goal = execute all steps as phases of one atomic task.
|
||||
Genuinely independent tasks = flag and refuse, require separate delegations.
|
||||
|
||||
Approach: explore extensively, understand deeply, then act decisively. Prefer comprehensive solutions over quick patches. If the goal is unclear, make reasonable assumptions and proceed.
|
||||
|
||||
Minimal status updates. Focus on results, not play-by-play. Report completion with summary of changes.
|
||||
</Category_Context>`
|
||||
|
||||
const QUICK_CATEGORY_PROMPT_APPEND = `<Category_Context>
|
||||
You are working on SMALL / QUICK tasks.
|
||||
|
||||
Efficient execution mindset:
|
||||
- Fast, focused, minimal overhead
|
||||
- Get to the point immediately
|
||||
- No over-engineering
|
||||
- Simple solutions for simple problems
|
||||
|
||||
Approach:
|
||||
- Minimal viable implementation
|
||||
- Skip unnecessary abstractions
|
||||
- Direct and concise
|
||||
</Category_Context>
|
||||
|
||||
<Caller_Warning>
|
||||
THIS CATEGORY USES A SMALLER/FASTER MODEL (gpt-5.4-mini).
|
||||
|
||||
The model executing this task is optimized for speed over depth. Your prompt MUST be:
|
||||
|
||||
**EXHAUSTIVELY EXPLICIT** - Leave NOTHING to interpretation:
|
||||
1. MUST DO: List every required action as atomic, numbered steps
|
||||
2. MUST NOT DO: Explicitly forbid likely mistakes and deviations
|
||||
3. EXPECTED OUTPUT: Describe exact success criteria with concrete examples
|
||||
|
||||
**WHY THIS MATTERS:**
|
||||
- Smaller models benefit from explicit guardrails
|
||||
- Vague instructions may lead to unpredictable results
|
||||
- Implicit expectations may be missed
|
||||
**PROMPT STRUCTURE (MANDATORY):**
|
||||
\`\`\`
|
||||
TASK: [One-sentence goal]
|
||||
|
||||
MUST DO:
|
||||
1. [Specific action with exact details]
|
||||
2. [Another specific action]
|
||||
...
|
||||
|
||||
MUST NOT DO:
|
||||
- [Forbidden action + why]
|
||||
- [Another forbidden action]
|
||||
...
|
||||
|
||||
EXPECTED OUTPUT:
|
||||
- [Exact deliverable description]
|
||||
- [Success criteria / verification method]
|
||||
\`\`\`
|
||||
|
||||
If your prompt lacks this structure, REWRITE IT before delegating.
|
||||
</Caller_Warning>`
|
||||
|
||||
export const OPENAI_CATEGORIES: BuiltinCategoryDefinition[] = [
|
||||
{
|
||||
name: "ultrabrain",
|
||||
config: { model: "openai/gpt-5.4", variant: "xhigh" },
|
||||
description: "Use ONLY for genuinely hard, logic-heavy tasks. Give clear goals only, not step-by-step instructions.",
|
||||
promptAppend: ULTRABRAIN_CATEGORY_PROMPT_APPEND,
|
||||
},
|
||||
{
|
||||
name: "deep",
|
||||
config: { model: "openai/gpt-5.4", variant: "medium" },
|
||||
description: "Goal-oriented autonomous problem-solving. Thorough research before action. For hairy problems requiring deep understanding.",
|
||||
promptAppend: DEEP_CATEGORY_PROMPT_APPEND,
|
||||
},
|
||||
{
|
||||
name: "quick",
|
||||
config: { model: "openai/gpt-5.4-mini" },
|
||||
description: "Trivial tasks - single file changes, typo fixes, simple modifications",
|
||||
promptAppend: QUICK_CATEGORY_PROMPT_APPEND,
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,125 @@
|
||||
declare const require: (name: string) => unknown
|
||||
const { describe, test, expect } = require("bun:test") as {
|
||||
describe: (name: string, fn: () => void) => void
|
||||
test: (name: string, fn: () => void) => void
|
||||
expect: (value: unknown) => {
|
||||
toBe: (expected: unknown) => void
|
||||
toContain: (expected: string) => void
|
||||
toBeUndefined: () => void
|
||||
toBeDefined: () => void
|
||||
not: {
|
||||
toContain: (expected: string) => void
|
||||
toBeUndefined: () => void
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
import { buildSystemContent } from "./prompt-builder"
|
||||
import type { AvailableSkill, AvailableCategory } from "../../agents/dynamic-agent-prompt-builder"
|
||||
|
||||
describe("prompt-builder", () => {
|
||||
describe("buildSystemContent", () => {
|
||||
describe("#given non-plan agent with availableSkills", () => {
|
||||
test("#when availableSkills contains project-level skills #then system content includes available_skills section", () => {
|
||||
// given
|
||||
const availableSkills: AvailableSkill[] = [
|
||||
{ name: "git-master", description: "Git workflow automation", location: "plugin" },
|
||||
{ name: "my-project-skill", description: "Project-specific deployment", location: "project" },
|
||||
]
|
||||
const availableCategories: AvailableCategory[] = [
|
||||
{ name: "quick", description: "Trivial tasks", model: "openai/gpt-5.4-mini" },
|
||||
]
|
||||
|
||||
// when
|
||||
const result = buildSystemContent({
|
||||
agentName: "sisyphus-junior",
|
||||
availableSkills,
|
||||
availableCategories,
|
||||
})
|
||||
|
||||
// then
|
||||
expect(result).toBeDefined()
|
||||
expect(result).toContain("my-project-skill")
|
||||
expect(result).toContain("git-master")
|
||||
})
|
||||
|
||||
test("#when agent is explore #then system content includes available_skills section", () => {
|
||||
// given
|
||||
const availableSkills: AvailableSkill[] = [
|
||||
{ name: "code-review", description: "Review code quality", location: "project" },
|
||||
]
|
||||
|
||||
// when
|
||||
const result = buildSystemContent({
|
||||
agentName: "explore",
|
||||
availableSkills,
|
||||
})
|
||||
|
||||
// then
|
||||
expect(result).toBeDefined()
|
||||
expect(result).toContain("code-review")
|
||||
})
|
||||
|
||||
test("#when availableSkills is empty #then system content does not include available_skills section", () => {
|
||||
// given
|
||||
const availableSkills: AvailableSkill[] = []
|
||||
|
||||
// when
|
||||
const result = buildSystemContent({
|
||||
agentName: "sisyphus-junior",
|
||||
availableSkills,
|
||||
categoryPromptAppend: "some category context",
|
||||
})
|
||||
|
||||
// then
|
||||
expect(result).toBeDefined()
|
||||
expect(result).not.toContain("available_skills")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given plan agent with availableSkills", () => {
|
||||
test("#when availableSkills provided #then system content includes plan agent prepend with skills", () => {
|
||||
// given
|
||||
const availableSkills: AvailableSkill[] = [
|
||||
{ name: "git-master", description: "Git workflow automation", location: "plugin" },
|
||||
]
|
||||
const availableCategories: AvailableCategory[] = [
|
||||
{ name: "quick", description: "Trivial tasks", model: "openai/gpt-5.4-mini" },
|
||||
]
|
||||
|
||||
// when
|
||||
const result = buildSystemContent({
|
||||
agentName: "plan",
|
||||
availableSkills,
|
||||
availableCategories,
|
||||
})
|
||||
|
||||
// then
|
||||
expect(result).toBeDefined()
|
||||
expect(result).toContain("git-master")
|
||||
expect(result).toContain("AVAILABLE SKILLS")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given non-plan agent with agentsContext override", () => {
|
||||
test("#when agentsContext is provided #then it takes precedence and skills section is appended", () => {
|
||||
// given
|
||||
const availableSkills: AvailableSkill[] = [
|
||||
{ name: "deploy-skill", description: "Deployment automation", location: "project" },
|
||||
]
|
||||
|
||||
// when
|
||||
const result = buildSystemContent({
|
||||
agentName: "sisyphus-junior",
|
||||
agentsContext: "Custom agent context here",
|
||||
availableSkills,
|
||||
})
|
||||
|
||||
// then
|
||||
expect(result).toBeDefined()
|
||||
expect(result).toContain("Custom agent context here")
|
||||
expect(result).toContain("deploy-skill")
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { BuildSystemContentInput } from "./types"
|
||||
import type { AvailableSkill } from "../../agents/dynamic-agent-prompt-builder"
|
||||
import { buildPlanAgentSystemPrepend, isPlanAgent } from "./constants"
|
||||
import { buildSystemContentWithTokenLimit } from "./token-limiter"
|
||||
|
||||
@@ -21,6 +22,22 @@ ${TDD_LINE}`
|
||||
return PLAN_AGENT_PROMPT_BASE
|
||||
}
|
||||
|
||||
function buildAvailableSkillsSection(skills: AvailableSkill[]): string {
|
||||
if (skills.length === 0) {
|
||||
return ""
|
||||
}
|
||||
|
||||
const rows = skills
|
||||
.map((s) => `- \`${s.name}\`: ${s.description || s.name}`)
|
||||
.join("\n")
|
||||
|
||||
return `<available_skills>
|
||||
Skills provide specialized instructions. Load via load_skills parameter when delegating tasks.
|
||||
|
||||
${rows}
|
||||
</available_skills>`
|
||||
}
|
||||
|
||||
function usesFreeOrLocalModel(model: { providerID: string; modelID: string; variant?: string } | undefined): boolean {
|
||||
if (!model) {
|
||||
return false
|
||||
@@ -51,10 +68,20 @@ export function buildSystemContent(input: BuildSystemContentInput): string | und
|
||||
availableSkills,
|
||||
} = input
|
||||
|
||||
const planAgentPrepend = isPlanAgent(agentName)
|
||||
const isPlan = isPlanAgent(agentName)
|
||||
const planAgentPrepend = isPlan
|
||||
? buildPlanAgentSystemPrepend(availableCategories, availableSkills)
|
||||
: ""
|
||||
|
||||
const skillsSection = !isPlan
|
||||
? buildAvailableSkillsSection(availableSkills ?? [])
|
||||
: ""
|
||||
|
||||
const baseAgentsContext = agentsContext ?? planAgentPrepend
|
||||
const effectiveAgentsContext = !isPlan && skillsSection
|
||||
? [baseAgentsContext, skillsSection].filter(Boolean).join("\n\n")
|
||||
: baseAgentsContext
|
||||
|
||||
const effectiveMaxPromptTokens = maxPromptTokens
|
||||
?? (usesFreeOrLocalModel(model) ? FREE_OR_LOCAL_PROMPT_TOKEN_LIMIT : undefined)
|
||||
|
||||
@@ -63,7 +90,7 @@ export function buildSystemContent(input: BuildSystemContentInput): string | und
|
||||
skillContent,
|
||||
skillContents,
|
||||
categoryPromptAppend,
|
||||
agentsContext: agentsContext ?? planAgentPrepend,
|
||||
agentsContext: effectiveAgentsContext,
|
||||
planAgentPrepend,
|
||||
},
|
||||
effectiveMaxPromptTokens
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import { resolveCallID } from "./resolve-call-id"
|
||||
import type { ToolContextWithMetadata } from "./types"
|
||||
|
||||
describe("resolveCallID", () => {
|
||||
function makeCtx(overrides: Partial<ToolContextWithMetadata> = {}): ToolContextWithMetadata {
|
||||
return {
|
||||
sessionID: "ses_test",
|
||||
messageID: "msg_test",
|
||||
agent: "sisyphus",
|
||||
abort: new AbortController().signal,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
test("#given callID is set #then returns callID", () => {
|
||||
const ctx = makeCtx({ callID: "call_abc" })
|
||||
expect(resolveCallID(ctx)).toBe("call_abc")
|
||||
})
|
||||
|
||||
test("#given only callId is set #then returns callId", () => {
|
||||
const ctx = makeCtx({ callId: "call_def" })
|
||||
expect(resolveCallID(ctx)).toBe("call_def")
|
||||
})
|
||||
|
||||
test("#given only call_id is set #then returns call_id", () => {
|
||||
const ctx = makeCtx({ call_id: "call_ghi" })
|
||||
expect(resolveCallID(ctx)).toBe("call_ghi")
|
||||
})
|
||||
|
||||
test("#given callID and callId are both set #then prefers callID", () => {
|
||||
const ctx = makeCtx({ callID: "preferred", callId: "fallback" })
|
||||
expect(resolveCallID(ctx)).toBe("preferred")
|
||||
})
|
||||
|
||||
test("#given no call ID variants are set #then returns undefined", () => {
|
||||
const ctx = makeCtx()
|
||||
expect(resolveCallID(ctx)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { ToolContextWithMetadata } from "./types"
|
||||
|
||||
export function resolveCallID(ctx: ToolContextWithMetadata): string | undefined {
|
||||
return ctx.callID ?? ctx.callId ?? ctx.call_id
|
||||
}
|
||||
@@ -7,15 +7,80 @@ import { normalizeModelFormat } from "../../shared/model-format-normalizer"
|
||||
import { AGENT_MODEL_REQUIREMENTS } from "../../shared/model-requirements"
|
||||
import { normalizeFallbackModels, flattenToFallbackModelStrings } from "../../shared/model-resolver"
|
||||
import { buildFallbackChainFromModels, findMostSpecificFallbackEntry } from "../../shared/fallback-chain-from-models"
|
||||
import { getAgentDisplayName, getAgentConfigKey } from "../../shared/agent-display-names"
|
||||
import { getAgentDisplayName, getAgentConfigKey, stripAgentListSortPrefix } from "../../shared/agent-display-names"
|
||||
import { normalizeSDKResponse } from "../../shared"
|
||||
import { log } from "../../shared/logger"
|
||||
import { getAvailableModelsForDelegateTask } from "./available-models"
|
||||
import type { FallbackEntry } from "../../shared/model-requirements"
|
||||
import { resolveModelForDelegateTask } from "./model-selection"
|
||||
import { fuzzyMatchModel } from "../../shared/model-availability"
|
||||
import type { CategoryConfig } from "../../config/schema"
|
||||
import { loadUserAgents, loadProjectAgents } from "../../features/claude-code-agent-loader"
|
||||
|
||||
type AgentMode = "subagent" | "primary" | "all" | undefined
|
||||
|
||||
type AgentInfo = {
|
||||
name: string
|
||||
mode?: "subagent" | "primary" | "all"
|
||||
model?: string | { providerID: string; modelID: string }
|
||||
}
|
||||
|
||||
function applyCategoryParams(
|
||||
base: DelegatedModelConfig,
|
||||
config: CategoryConfig | undefined,
|
||||
): DelegatedModelConfig {
|
||||
if (!config) {
|
||||
return base
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
...(config.reasoningEffort !== undefined ? { reasoningEffort: config.reasoningEffort } : {}),
|
||||
...(config.temperature !== undefined ? { temperature: config.temperature } : {}),
|
||||
...(config.top_p !== undefined ? { top_p: config.top_p } : {}),
|
||||
...(config.maxTokens !== undefined ? { maxTokens: config.maxTokens } : {}),
|
||||
...(config.thinking !== undefined ? { thinking: config.thinking } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function mergeWithClaudeCodeAgents(
|
||||
serverAgents: AgentInfo[],
|
||||
directory: string | undefined,
|
||||
): AgentInfo[] {
|
||||
const userAgentsRecord = loadUserAgents()
|
||||
const projectAgentsRecord = loadProjectAgents(directory)
|
||||
|
||||
const toAgentInfoList = (record: Record<string, { mode?: string; model?: AgentInfo["model"] }>): AgentInfo[] =>
|
||||
Object.entries(record).map(([name, config]) => ({
|
||||
name,
|
||||
mode: config.mode as AgentInfo["mode"],
|
||||
model: config.model,
|
||||
}))
|
||||
|
||||
const projectAgentsList = toAgentInfoList(projectAgentsRecord)
|
||||
const userAgentsList = toAgentInfoList(userAgentsRecord)
|
||||
|
||||
const mergedAgentMap = new Map<string, AgentInfo>()
|
||||
const addIfAbsent = (agent: AgentInfo): void => {
|
||||
const key = agent.name.toLowerCase()
|
||||
if (!mergedAgentMap.has(key)) {
|
||||
mergedAgentMap.set(key, agent)
|
||||
}
|
||||
}
|
||||
|
||||
for (const agent of serverAgents) {
|
||||
addIfAbsent(agent)
|
||||
}
|
||||
for (const agent of projectAgentsList) {
|
||||
addIfAbsent(agent)
|
||||
}
|
||||
for (const agent of userAgentsList) {
|
||||
addIfAbsent(agent)
|
||||
}
|
||||
|
||||
return Array.from(mergedAgentMap.values())
|
||||
}
|
||||
|
||||
export async function resolveSubagentExecution(
|
||||
args: DelegateTaskArgs,
|
||||
executorCtx: ExecutorContext,
|
||||
@@ -28,7 +93,9 @@ export async function resolveSubagentExecution(
|
||||
return { agentToUse: "", categoryModel: undefined, error: `Agent name cannot be empty.` }
|
||||
}
|
||||
|
||||
const agentName = args.subagent_type.trim()
|
||||
// Strip wrapping characters (backslashes, quotes) that LLMs sometimes add around agent names
|
||||
// e.g. \hephaestus\ -> hephaestus, "oracle" -> oracle, 'explore' -> explore
|
||||
const agentName = args.subagent_type.trim().replace(/^[\\\/"']+|[\\\/"']+$/g, "").trim()
|
||||
|
||||
if (agentName.toLowerCase() === SISYPHUS_JUNIOR_AGENT.toLowerCase()) {
|
||||
return {
|
||||
@@ -54,82 +121,27 @@ Create the work plan directly - that's your job as the planning agent.`,
|
||||
let categoryModel: DelegatedModelConfig | undefined
|
||||
let fallbackChain: FallbackEntry[] | undefined = undefined
|
||||
|
||||
type AgentInfo = {
|
||||
name: string
|
||||
mode?: "subagent" | "primary" | "all"
|
||||
model?: string | { providerID: string; modelID: string }
|
||||
}
|
||||
|
||||
try {
|
||||
const agentsResult = await client.app.agents()
|
||||
const agents = normalizeSDKResponse(agentsResult, [] as AgentInfo[], {
|
||||
preferResponseOnMissingData: true,
|
||||
})
|
||||
|
||||
// Load user and project agents
|
||||
const userAgentsRecord = loadUserAgents()
|
||||
const projectAgentsRecord = loadProjectAgents(executorCtx.directory)
|
||||
const mergedAgents = mergeWithClaudeCodeAgents(agents, executorCtx.directory)
|
||||
const callableAgents = mergedAgents.filter((agent) => isTaskCallableAgentMode(agent.mode))
|
||||
|
||||
// Convert user/project agent configs to AgentInfo format
|
||||
const userAgentsList: AgentInfo[] = Object.entries(userAgentsRecord).map(([name, config]) => ({
|
||||
name,
|
||||
mode: config.mode as "subagent" | "primary" | "all",
|
||||
model: config.model,
|
||||
}))
|
||||
|
||||
const projectAgentsList: AgentInfo[] = Object.entries(projectAgentsRecord).map(([name, config]) => ({
|
||||
name,
|
||||
mode: config.mode as "subagent" | "primary" | "all",
|
||||
model: config.model,
|
||||
}))
|
||||
|
||||
// Merge user and project agents into the server's agent list
|
||||
// Server agents take precedence; project agents override user agents
|
||||
const mergedAgentMap = new Map<string, AgentInfo>()
|
||||
|
||||
// First add server agents (they take precedence)
|
||||
for (const agent of agents) {
|
||||
mergedAgentMap.set(agent.name.toLowerCase(), agent)
|
||||
}
|
||||
|
||||
// Then add project agents (overrides user agents, server wins on collision)
|
||||
for (const agent of projectAgentsList) {
|
||||
if (!mergedAgentMap.has(agent.name.toLowerCase())) {
|
||||
mergedAgentMap.set(agent.name.toLowerCase(), agent)
|
||||
}
|
||||
}
|
||||
|
||||
// Then add user agents (only if not already added by server or project)
|
||||
for (const agent of userAgentsList) {
|
||||
if (!mergedAgentMap.has(agent.name.toLowerCase())) {
|
||||
mergedAgentMap.set(agent.name.toLowerCase(), agent)
|
||||
}
|
||||
}
|
||||
|
||||
const mergedAgents = Array.from(mergedAgentMap.values())
|
||||
const callableAgents = mergedAgents.filter((a) => a.mode !== "primary")
|
||||
|
||||
const resolvedDisplayName = getAgentDisplayName(agentToUse)
|
||||
const resolvedDisplayName = stripAgentListSortPrefix(getAgentDisplayName(agentToUse))
|
||||
const normalizedAgentToUse = stripAgentListSortPrefix(agentToUse)
|
||||
const matchedAgent = callableAgents.find(
|
||||
(agent) => agent.name.toLowerCase() === agentToUse.toLowerCase()
|
||||
|| agent.name.toLowerCase() === resolvedDisplayName.toLowerCase()
|
||||
(agent) => {
|
||||
const normalizedListedAgentName = stripAgentListSortPrefix(agent.name)
|
||||
return normalizedListedAgentName.toLowerCase() === normalizedAgentToUse.toLowerCase()
|
||||
|| normalizedListedAgentName.toLowerCase() === resolvedDisplayName.toLowerCase()
|
||||
}
|
||||
)
|
||||
if (!matchedAgent) {
|
||||
const isPrimaryAgent = agents
|
||||
.filter((a) => a.mode === "primary")
|
||||
.find((agent) => agent.name.toLowerCase() === agentToUse.toLowerCase()
|
||||
|| agent.name.toLowerCase() === resolvedDisplayName.toLowerCase())
|
||||
|
||||
if (isPrimaryAgent) {
|
||||
return {
|
||||
agentToUse: "",
|
||||
categoryModel: undefined,
|
||||
error: `Cannot call primary agent "${isPrimaryAgent.name}" via task. Primary agents are top-level orchestrators.`,
|
||||
}
|
||||
}
|
||||
|
||||
const availableAgents = callableAgents
|
||||
.map((a) => a.name)
|
||||
.map((a) => stripAgentListSortPrefix(a.name))
|
||||
.sort()
|
||||
.join(", ")
|
||||
return {
|
||||
@@ -139,18 +151,19 @@ Create the work plan directly - that's your job as the planning agent.`,
|
||||
}
|
||||
}
|
||||
|
||||
agentToUse = matchedAgent.name
|
||||
agentToUse = stripAgentListSortPrefix(matchedAgent.name)
|
||||
|
||||
const agentConfigKey = getAgentConfigKey(agentToUse)
|
||||
const agentOverride = agentOverrides?.[agentConfigKey as keyof typeof agentOverrides]
|
||||
?? (agentOverrides ? Object.entries(agentOverrides).find(([key]) => key.toLowerCase() === agentConfigKey)?.[1] : undefined)
|
||||
const agentRequirement = AGENT_MODEL_REQUIREMENTS[agentConfigKey]
|
||||
const agentCategoryModel = agentOverride?.category
|
||||
? userCategories?.[agentOverride.category]?.model
|
||||
const agentCategoryConfig = agentOverride?.category
|
||||
? userCategories?.[agentOverride.category]
|
||||
: undefined
|
||||
const agentCategoryModel = agentCategoryConfig?.model
|
||||
const normalizedAgentFallbackModels = normalizeFallbackModels(
|
||||
agentOverride?.fallback_models
|
||||
?? (agentOverride?.category ? userCategories?.[agentOverride.category]?.fallback_models : undefined)
|
||||
?? agentCategoryConfig?.fallback_models
|
||||
)
|
||||
|
||||
const availableModels = await getAvailableModelsForDelegateTask(client)
|
||||
@@ -178,19 +191,16 @@ Create the work plan directly - that's your job as the planning agent.`,
|
||||
if (resolution && !resolutionSkipped) {
|
||||
const normalized = normalizeModelFormat(resolution.model)
|
||||
if (normalized) {
|
||||
const variantToUse = agentOverride?.variant ?? resolution.variant
|
||||
categoryModel = variantToUse ? { ...normalized, variant: variantToUse } : normalized
|
||||
const variantToUse = agentOverride?.variant ?? resolution.variant ?? agentCategoryConfig?.variant
|
||||
const resolvedModel = variantToUse ? { ...normalized, variant: variantToUse } : normalized
|
||||
categoryModel = applyCategoryParams(resolvedModel, agentCategoryConfig)
|
||||
}
|
||||
} else if (resolutionSkipped && (agentOverride?.model ?? agentCategoryModel)) {
|
||||
// Cold cache: resolution was skipped but user explicitly configured a model.
|
||||
// Honor the user override directly — don't fall through to hardcoded fallback chain.
|
||||
const normalized = normalizeModelFormat((agentOverride?.model ?? agentCategoryModel)!)
|
||||
if (normalized) {
|
||||
const agentCategoryVariant = agentOverride?.category
|
||||
? userCategories?.[agentOverride.category]?.variant
|
||||
: undefined
|
||||
const variantToUse = agentOverride?.variant ?? agentCategoryVariant
|
||||
categoryModel = variantToUse ? { ...normalized, variant: variantToUse } : normalized
|
||||
const variantToUse = agentOverride?.variant ?? agentCategoryConfig?.variant
|
||||
const resolvedModel = variantToUse ? { ...normalized, variant: variantToUse } : normalized
|
||||
categoryModel = applyCategoryParams(resolvedModel, agentCategoryConfig)
|
||||
log("[delegate-task] Cold cache: using explicit user override for subagent", {
|
||||
agent: agentToUse,
|
||||
model: agentOverride?.model ?? agentCategoryModel,
|
||||
@@ -205,8 +215,6 @@ Create the work plan directly - that's your job as the planning agent.`,
|
||||
normalizedAgentFallbackModels,
|
||||
defaultProviderID,
|
||||
)
|
||||
// Don't assign hardcoded fallback chain when resolution was skipped (cold cache)
|
||||
// — the chain may contain model IDs that don't exist in the provider yet.
|
||||
fallbackChain = configuredFallbackChain ?? (resolutionSkipped ? undefined : agentRequirement?.fallbackChain)
|
||||
|
||||
// Only promote fallback-only settings when resolution actually selected a fallback model.
|
||||
@@ -225,11 +233,11 @@ Create the work plan directly - that's your job as the planning agent.`,
|
||||
categoryModel = {
|
||||
...categoryModel,
|
||||
variant: agentOverride?.variant ?? effectiveEntry.variant ?? categoryModel.variant,
|
||||
reasoningEffort: effectiveEntry.reasoningEffort,
|
||||
temperature: effectiveEntry.temperature,
|
||||
top_p: effectiveEntry.top_p,
|
||||
maxTokens: effectiveEntry.maxTokens,
|
||||
thinking: effectiveEntry.thinking,
|
||||
reasoningEffort: effectiveEntry.reasoningEffort ?? categoryModel.reasoningEffort,
|
||||
temperature: effectiveEntry.temperature ?? categoryModel.temperature,
|
||||
top_p: effectiveEntry.top_p ?? categoryModel.top_p,
|
||||
maxTokens: effectiveEntry.maxTokens ?? categoryModel.maxTokens,
|
||||
thinking: effectiveEntry.thinking ?? categoryModel.thinking,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -265,3 +273,7 @@ Create the work plan directly - that's your job as the planning agent.`,
|
||||
|
||||
return { agentToUse, categoryModel, fallbackChain }
|
||||
}
|
||||
|
||||
function isTaskCallableAgentMode(mode: AgentMode): boolean {
|
||||
return mode === "all" || mode === "subagent"
|
||||
}
|
||||
|
||||
@@ -605,8 +605,8 @@ describe("executeSyncContinuation - toast cleanup error paths", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("keeps plan-family task delegation available during sync continuation", async () => {
|
||||
//#given - a resumed plan-family session should keep its intended task capability
|
||||
test("keeps task delegation enabled during prometheus sync continuation", async () => {
|
||||
//#given - a resumed prometheus session should keep plan-family task permission
|
||||
const promptAsyncCalls: Array<{ path: { id: string }; body: Record<string, unknown> }> = []
|
||||
const mockClient = {
|
||||
session: {
|
||||
@@ -656,7 +656,7 @@ describe("executeSyncContinuation - toast cleanup error paths", () => {
|
||||
const args = {
|
||||
session_id: "ses_test_12345678",
|
||||
prompt: "continue planning",
|
||||
description: "resume plan task",
|
||||
description: "resume prometheus task",
|
||||
load_skills: [],
|
||||
run_in_background: false,
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { DelegateTaskArgs, ToolContextWithMetadata } from "./types"
|
||||
import type { ExecutorContext, SessionMessage } from "./executor-types"
|
||||
import { isPlanFamily } from "./constants"
|
||||
import { storeToolMetadata } from "../../features/tool-metadata-store"
|
||||
import { resolveCallID } from "./resolve-call-id"
|
||||
import { getTaskToastManager } from "../../features/task-toast-manager"
|
||||
import { getAgentToolRestrictions } from "../../shared/agent-tool-restrictions"
|
||||
import { getMessageDir } from "../../shared"
|
||||
@@ -78,8 +79,9 @@ export async function executeSyncContinuation(
|
||||
},
|
||||
}
|
||||
await ctx.metadata?.(syncContMeta)
|
||||
if (ctx.callID) {
|
||||
storeToolMetadata(ctx.sessionID, ctx.callID, syncContMeta)
|
||||
const callID = resolveCallID(ctx)
|
||||
if (callID) {
|
||||
storeToolMetadata(ctx.sessionID, callID, syncContMeta)
|
||||
}
|
||||
|
||||
const allowTask = isPlanFamily(resumeAgent)
|
||||
|
||||
@@ -274,17 +274,65 @@ bunDescribe("sendSyncPrompt", () => {
|
||||
modelID: "gpt-5.4",
|
||||
})
|
||||
bunExpect(promptArgs.body.variant).toBe("low")
|
||||
bunExpect(promptArgs.body.options).toBeUndefined()
|
||||
bunExpect(promptArgs.body.options).toEqual({
|
||||
reasoningEffort: "high",
|
||||
thinking: { type: "disabled" },
|
||||
})
|
||||
bunExpect(promptArgs.body.maxOutputTokens).toBe(4096)
|
||||
bunExpect(getSessionPromptParams("test-session")).toEqual({
|
||||
temperature: 0.4,
|
||||
topP: 0.7,
|
||||
maxOutputTokens: 4096,
|
||||
options: {
|
||||
reasoningEffort: "high",
|
||||
thinking: { type: "disabled" },
|
||||
maxTokens: 4096,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
bunTest("forwards category temperature through the sync prompt body", async () => {
|
||||
//#given
|
||||
const { sendSyncPrompt } = require("./sync-prompt-sender")
|
||||
|
||||
let promptArgs: any
|
||||
const promptWithModelSuggestionRetry = bunMock(async (_client: any, input: any) => {
|
||||
promptArgs = input
|
||||
})
|
||||
|
||||
const input = {
|
||||
sessionID: "test-session",
|
||||
agentToUse: "sisyphus-junior",
|
||||
args: {
|
||||
description: "test task",
|
||||
prompt: "test prompt",
|
||||
category: "quick",
|
||||
run_in_background: false,
|
||||
load_skills: [],
|
||||
},
|
||||
systemContent: undefined,
|
||||
categoryModel: {
|
||||
providerID: "openai",
|
||||
modelID: "gpt-5.4",
|
||||
temperature: 0.25,
|
||||
},
|
||||
toastManager: null,
|
||||
taskId: undefined,
|
||||
}
|
||||
|
||||
//#when
|
||||
await sendSyncPrompt(
|
||||
{ session: { promptAsync: bunMock(async () => ({ data: {} })) } },
|
||||
input,
|
||||
{
|
||||
promptWithModelSuggestionRetry,
|
||||
promptSyncWithModelSuggestionRetry: bunMock(async () => {}),
|
||||
},
|
||||
)
|
||||
|
||||
//#then
|
||||
bunExpect(promptWithModelSuggestionRetry).toHaveBeenCalledTimes(1)
|
||||
bunExpect(promptArgs.body.temperature).toBe(0.25)
|
||||
})
|
||||
bunTest("retries with promptSync for oracle when promptAsync fails with unexpected EOF", async () => {
|
||||
//#given
|
||||
const { sendSyncPrompt } = require("./sync-prompt-sender")
|
||||
|
||||
@@ -22,6 +22,24 @@ const sendSyncPromptDeps: SendSyncPromptDeps = {
|
||||
promptSyncWithModelSuggestionRetry,
|
||||
}
|
||||
|
||||
function buildPromptGenerationParams(model: DelegatedModelConfig | undefined): Record<string, unknown> {
|
||||
if (!model) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const promptOptions: Record<string, unknown> = {
|
||||
...(model.reasoningEffort ? { reasoningEffort: model.reasoningEffort } : {}),
|
||||
...(model.thinking ? { thinking: model.thinking } : {}),
|
||||
}
|
||||
|
||||
return {
|
||||
...(model.temperature !== undefined ? { temperature: model.temperature } : {}),
|
||||
...(model.top_p !== undefined ? { topP: model.top_p } : {}),
|
||||
...(model.maxTokens !== undefined ? { maxOutputTokens: model.maxTokens } : {}),
|
||||
...(Object.keys(promptOptions).length > 0 ? { options: promptOptions } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function isOracleAgent(agentToUse: string): boolean {
|
||||
return agentToUse.toLowerCase() === "oracle"
|
||||
}
|
||||
@@ -62,7 +80,7 @@ export async function sendSyncPrompt(
|
||||
const promptArgs = {
|
||||
path: { id: input.sessionID },
|
||||
body: {
|
||||
agent: input.agentToUse,
|
||||
agent: input.agentToUse.replace(/^\u200B+/, ""),
|
||||
system: input.systemContent,
|
||||
tools,
|
||||
parts: [createInternalAgentTextPart(effectivePrompt)],
|
||||
@@ -75,6 +93,7 @@ export async function sendSyncPrompt(
|
||||
}
|
||||
: {}),
|
||||
...(input.categoryModel?.variant ? { variant: input.categoryModel.variant } : {}),
|
||||
...buildPromptGenerationParams(input.categoryModel),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
||||
declare const require: (name: string) => any
|
||||
const { describe, test, expect, beforeEach, afterEach } = require("bun:test")
|
||||
import { __setTimingConfig, __resetTimingConfig } from "./timing"
|
||||
|
||||
function createMockCtx(aborted = false) {
|
||||
@@ -33,7 +33,6 @@ describe("pollSyncSession", () => {
|
||||
// and the assistant id > user id (native opencode condition)
|
||||
const { pollSyncSession } = require("./sync-session-poller")
|
||||
|
||||
let pollCount = 0
|
||||
const mockClient = {
|
||||
session: {
|
||||
messages: async () => ({
|
||||
@@ -165,6 +164,58 @@ describe("pollSyncSession", () => {
|
||||
expect(callCount).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
test("keeps polling when finish is 'stop' but assistant still has tool-call parts", async () => {
|
||||
//#given
|
||||
const { pollSyncSession } = require("./sync-session-poller")
|
||||
|
||||
let callCount = 0
|
||||
const mockClient = {
|
||||
session: {
|
||||
messages: async () => {
|
||||
callCount++
|
||||
if (callCount <= 1) {
|
||||
return {
|
||||
data: [
|
||||
{ info: { id: "msg_001", role: "user", time: { created: 1000 } } },
|
||||
{
|
||||
info: { id: "msg_002", role: "assistant", time: { created: 2000 }, finish: "stop" },
|
||||
parts: [{ type: "tool-call", text: "calling tool" }],
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
return {
|
||||
data: [
|
||||
{ info: { id: "msg_001", role: "user", time: { created: 1000 } } },
|
||||
{
|
||||
info: { id: "msg_002", role: "assistant", time: { created: 2000 }, finish: "stop" },
|
||||
parts: [{ type: "tool-call", text: "calling tool" }],
|
||||
},
|
||||
{ info: { id: "msg_003", role: "user", time: { created: 3000 } } },
|
||||
{
|
||||
info: { id: "msg_004", role: "assistant", time: { created: 4000 }, finish: "stop" },
|
||||
parts: [{ type: "text", text: "Done" }],
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
status: async () => ({ data: { "ses_test": { type: "idle" } } }),
|
||||
},
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = await pollSyncSession(createMockCtx(), mockClient, {
|
||||
sessionID: "ses_test",
|
||||
agentToUse: "test-agent",
|
||||
toastManager: null,
|
||||
taskId: undefined,
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(result).toBeNull()
|
||||
expect(callCount).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
test("does not complete when assistant id < user id (user sent after assistant)", async () => {
|
||||
//#given - assistant finished but user message came after it (agent still processing)
|
||||
const { pollSyncSession } = require("./sync-session-poller")
|
||||
@@ -220,6 +271,55 @@ describe("pollSyncSession", () => {
|
||||
})
|
||||
|
||||
describe("abort handling", () => {
|
||||
test("#given session completed AND abort fires #then returns completion result not abort", async () => {
|
||||
//#given
|
||||
const { pollSyncSession } = require("./sync-session-poller")
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
|
||||
let abortCount = 0
|
||||
let messageCallCount = 0
|
||||
const mockClient = {
|
||||
session: {
|
||||
abort: async () => {
|
||||
abortCount++
|
||||
},
|
||||
messages: async () => {
|
||||
messageCallCount++
|
||||
return {
|
||||
data: [
|
||||
{ info: { id: "msg_001", role: "user", time: { created: 1000 } } },
|
||||
{
|
||||
info: { id: "msg_002", role: "assistant", time: { created: 2000 }, finish: "stop" },
|
||||
parts: [{ type: "text", text: "Done" }],
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
status: async () => ({ data: {} }),
|
||||
},
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = await pollSyncSession({
|
||||
sessionID: "parent-session",
|
||||
messageID: "parent-message",
|
||||
agent: "test-agent",
|
||||
abort: controller.signal,
|
||||
}, mockClient, {
|
||||
sessionID: "ses_abort_complete",
|
||||
agentToUse: "test-agent",
|
||||
toastManager: { removeTask: () => {} },
|
||||
taskId: "task_123",
|
||||
anchorMessageCount: 1,
|
||||
})
|
||||
|
||||
//#then
|
||||
expect(result).toBeNull()
|
||||
expect(messageCallCount).toBe(1)
|
||||
expect(abortCount).toBe(0)
|
||||
})
|
||||
|
||||
test("returns abort message when signal is aborted", async () => {
|
||||
//#given
|
||||
const { pollSyncSession } = require("./sync-session-poller")
|
||||
@@ -295,7 +395,7 @@ describe("pollSyncSession", () => {
|
||||
//#given
|
||||
const { pollSyncSession } = require("./sync-session-poller")
|
||||
|
||||
let statusCallCount = 0
|
||||
let statusCallCount = 0
|
||||
let messageCallCount = 0
|
||||
const mockClient = {
|
||||
session: {
|
||||
@@ -421,6 +521,44 @@ describe("pollSyncSession", () => {
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false when finish is stop but assistant has tool-call parts", () => {
|
||||
const { isSessionComplete } = require("./sync-session-poller")
|
||||
|
||||
//#given - provider marks stop even though tool execution is still pending
|
||||
const messages = [
|
||||
{ info: { id: "msg_001", role: "user", time: { created: 1000 } } },
|
||||
{
|
||||
info: { id: "msg_002", role: "assistant", time: { created: 2000 }, finish: "stop" },
|
||||
parts: [{ type: "tool-call", text: "calling tool" }],
|
||||
},
|
||||
]
|
||||
|
||||
//#when
|
||||
const result = isSessionComplete(messages)
|
||||
|
||||
//#then - should return false because tool execution is still pending
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false when finish is end_turn but assistant has tool-call parts", () => {
|
||||
const { isSessionComplete } = require("./sync-session-poller")
|
||||
|
||||
//#given - assistant emitted a terminal finish but still contains pending tool calls
|
||||
const messages = [
|
||||
{ info: { id: "msg_001", role: "user", time: { created: 1000 } } },
|
||||
{
|
||||
info: { id: "msg_002", role: "assistant", time: { created: 2000 }, finish: "end_turn" },
|
||||
parts: [{ type: "tool-call", text: "calling tool" }],
|
||||
},
|
||||
]
|
||||
|
||||
//#when
|
||||
const result = isSessionComplete(messages)
|
||||
|
||||
//#then - should return false because tool execution is still pending
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false when user message has missing info.id field", () => {
|
||||
const { isSessionComplete } = require("./sync-session-poller")
|
||||
|
||||
@@ -438,7 +576,7 @@ describe("pollSyncSession", () => {
|
||||
|
||||
//#then - should return false (missing user id)
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import { log } from "../../shared/logger"
|
||||
import { normalizeSDKResponse } from "../../shared"
|
||||
|
||||
const NON_TERMINAL_FINISH_REASONS = new Set(["tool-calls", "unknown"])
|
||||
const PENDING_TOOL_PART_TYPES = new Set(["tool", "tool_use", "tool-call"])
|
||||
|
||||
function wait(milliseconds: number): Promise<void> {
|
||||
const sharedBuffer = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT)
|
||||
@@ -22,6 +23,15 @@ function abortSyncSession(client: OpencodeClient, sessionID: string, reason: str
|
||||
})
|
||||
}
|
||||
|
||||
async function fetchSessionMessages(
|
||||
client: OpencodeClient,
|
||||
sessionID: string
|
||||
): Promise<SessionMessage[]> {
|
||||
const messagesResult = await client.session.messages({ path: { id: sessionID } })
|
||||
const rawData = (messagesResult as { data?: unknown })?.data ?? messagesResult
|
||||
return Array.isArray(rawData) ? (rawData as SessionMessage[]) : []
|
||||
}
|
||||
|
||||
export function isSessionComplete(messages: SessionMessage[]): boolean {
|
||||
let lastUser: SessionMessage | undefined
|
||||
let lastAssistant: SessionMessage | undefined
|
||||
@@ -35,6 +45,7 @@ export function isSessionComplete(messages: SessionMessage[]): boolean {
|
||||
|
||||
if (!lastAssistant?.info?.finish) return false
|
||||
if (NON_TERMINAL_FINISH_REASONS.has(lastAssistant.info.finish)) return false
|
||||
if (lastAssistant.parts?.some((part) => part.type && PENDING_TOOL_PART_TYPES.has(part.type))) return false
|
||||
if (!lastUser?.info?.id || !lastAssistant?.info?.id) return false
|
||||
return lastUser.info.id < lastAssistant.info.id
|
||||
}
|
||||
@@ -67,6 +78,21 @@ export async function pollSyncSession(
|
||||
|
||||
while (Date.now() - pollStart < maxPollTimeMs) {
|
||||
if (ctx.abort?.aborted) {
|
||||
try {
|
||||
const messages = await fetchSessionMessages(client, input.sessionID)
|
||||
const hasNewMessages =
|
||||
input.anchorMessageCount === undefined || messages.length > input.anchorMessageCount
|
||||
if (hasNewMessages && isSessionComplete(messages)) {
|
||||
log("[task] Abort detected after session already completed", { sessionID: input.sessionID })
|
||||
return null
|
||||
}
|
||||
} catch (error) {
|
||||
log("[task] Final messages fetch failed after abort, continuing with abort", {
|
||||
sessionID: input.sessionID,
|
||||
error: String(error),
|
||||
})
|
||||
}
|
||||
|
||||
log("[task] Aborted by user", { sessionID: input.sessionID })
|
||||
abortSyncSession(client, input.sessionID, "parent_abort")
|
||||
if (input.toastManager && input.taskId) input.toastManager.removeTask(input.taskId)
|
||||
@@ -99,27 +125,25 @@ export async function pollSyncSession(
|
||||
continue
|
||||
}
|
||||
|
||||
let messagesResult: { data?: unknown } | SessionMessage[]
|
||||
let messages: SessionMessage[]
|
||||
try {
|
||||
messagesResult = await client.session.messages({ path: { id: input.sessionID } })
|
||||
messages = await fetchSessionMessages(client, input.sessionID)
|
||||
} catch (error) {
|
||||
log("[task] Poll messages fetch failed, retrying", { sessionID: input.sessionID, error: String(error) })
|
||||
continue
|
||||
}
|
||||
const rawData = (messagesResult as { data?: unknown })?.data ?? messagesResult
|
||||
const msgs = Array.isArray(rawData) ? (rawData as SessionMessage[]) : []
|
||||
|
||||
if (input.anchorMessageCount !== undefined && msgs.length <= input.anchorMessageCount) {
|
||||
if (input.anchorMessageCount !== undefined && messages.length <= input.anchorMessageCount) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (isSessionComplete(msgs)) {
|
||||
if (isSessionComplete(messages)) {
|
||||
log("[task] Poll complete - terminal finish detected", { sessionID: input.sessionID, pollCount })
|
||||
break
|
||||
}
|
||||
|
||||
// 计数新出现的 assistant 轮次,用于熔断无限循环
|
||||
const lastAssistant = [...msgs].reverse().find((m) => m.info?.role === "assistant")
|
||||
const lastAssistant = [...messages].reverse().find((m) => m.info?.role === "assistant")
|
||||
if (lastAssistant?.info?.id && lastAssistant.info.id !== lastSeenAssistantId) {
|
||||
lastSeenAssistantId = lastAssistant.info.id
|
||||
assistantTurnCount++
|
||||
@@ -135,7 +159,7 @@ export async function pollSyncSession(
|
||||
}
|
||||
}
|
||||
|
||||
const hasAssistantText = msgs.some((m) => {
|
||||
const hasAssistantText = messages.some((m) => {
|
||||
if (m.info?.role !== "assistant") return false
|
||||
const parts = m.parts ?? []
|
||||
return parts.some((p) => {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { FallbackEntry } from "../../shared/model-requirements"
|
||||
import type { DelegatedModelConfig } from "./types"
|
||||
import type { ModelFallbackState } from "../../hooks/model-fallback/hook"
|
||||
import { getNextReachableFallback } from "../../hooks/model-fallback/next-fallback"
|
||||
|
||||
function toDelegatedModelConfig(fallback: NonNullable<ReturnType<typeof getNextReachableFallback>>): DelegatedModelConfig {
|
||||
return {
|
||||
providerID: fallback.providerID,
|
||||
modelID: fallback.modelID,
|
||||
variant: fallback.variant,
|
||||
reasoningEffort: fallback.reasoningEffort,
|
||||
temperature: fallback.temperature,
|
||||
top_p: fallback.top_p,
|
||||
maxTokens: fallback.maxTokens,
|
||||
thinking: fallback.thinking,
|
||||
}
|
||||
}
|
||||
|
||||
export async function retrySyncPromptWithFallbacks(input: {
|
||||
sessionID: string
|
||||
initialError: string
|
||||
categoryModel: DelegatedModelConfig | undefined
|
||||
fallbackChain: FallbackEntry[] | undefined
|
||||
sendPrompt: (categoryModel: DelegatedModelConfig) => Promise<string | null>
|
||||
}): Promise<{ promptError: string | null; categoryModel: DelegatedModelConfig | undefined }> {
|
||||
const { sessionID, initialError, categoryModel, fallbackChain, sendPrompt } = input
|
||||
|
||||
if (!categoryModel || !fallbackChain || fallbackChain.length === 0) {
|
||||
return {
|
||||
promptError: initialError,
|
||||
categoryModel,
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackState: ModelFallbackState = {
|
||||
providerID: categoryModel.providerID,
|
||||
modelID: categoryModel.modelID,
|
||||
fallbackChain,
|
||||
attemptCount: 0,
|
||||
pending: true,
|
||||
}
|
||||
|
||||
let finalError = initialError
|
||||
|
||||
while (true) {
|
||||
const nextFallback = getNextReachableFallback(sessionID, fallbackState)
|
||||
if (!nextFallback) {
|
||||
return {
|
||||
promptError: finalError,
|
||||
categoryModel,
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackModel = toDelegatedModelConfig(nextFallback)
|
||||
const promptError = await sendPrompt(fallbackModel)
|
||||
if (!promptError) {
|
||||
return {
|
||||
promptError: null,
|
||||
categoryModel: fallbackModel,
|
||||
}
|
||||
}
|
||||
|
||||
finalError = promptError
|
||||
fallbackState.providerID = fallbackModel.providerID
|
||||
fallbackState.modelID = fallbackModel.modelID
|
||||
fallbackState.pending = true
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,12 @@
|
||||
const { describe, test, expect, beforeEach, afterEach, mock, spyOn } = require("bun:test")
|
||||
|
||||
function clearRequireCache(modulePath: string): void {
|
||||
const resolvedPath = require.resolve(modulePath)
|
||||
if (require.cache?.[resolvedPath]) {
|
||||
delete require.cache[resolvedPath]
|
||||
}
|
||||
}
|
||||
|
||||
describe("executeSyncTask - cleanup on error paths", () => {
|
||||
let removeTaskCalls: string[] = []
|
||||
let addTaskCalls: any[] = []
|
||||
@@ -23,6 +30,8 @@ describe("executeSyncTask - cleanup on error paths", () => {
|
||||
deleteCalls = []
|
||||
addCalls = []
|
||||
|
||||
clearRequireCache("./sync-task")
|
||||
|
||||
//#given - initialize real task toast manager (avoid global module mocks)
|
||||
const { initTaskToastManager, _resetTaskToastManagerForTesting } = require("../../features/task-toast-manager/manager")
|
||||
_resetTaskToastManagerForTesting()
|
||||
@@ -219,6 +228,140 @@ describe("executeSyncTask - cleanup on error paths", () => {
|
||||
expect(deleteCalls[0]).toBe("ses_test_12345678")
|
||||
})
|
||||
|
||||
test("#given fallback chain set #when sendSyncPrompt fails #then retries with next model", async () => {
|
||||
//#given
|
||||
const mockClient = {
|
||||
session: {
|
||||
create: async () => ({ data: { id: "ses_test_12345678" } }),
|
||||
},
|
||||
}
|
||||
|
||||
const { executeSyncTask } = require("./sync-task")
|
||||
const attemptedModels: Array<{ providerID: string; modelID: string; variant?: string } | undefined> = []
|
||||
|
||||
const deps = {
|
||||
createSyncSession: async () => ({ ok: true, sessionID: "ses_test_12345678" }),
|
||||
sendSyncPrompt: async (_client: unknown, input: { categoryModel?: { providerID: string; modelID: string; variant?: string } }) => {
|
||||
attemptedModels.push(input.categoryModel)
|
||||
return attemptedModels.length === 1 ? "Initial failure" : null
|
||||
},
|
||||
pollSyncSession: async () => null,
|
||||
fetchSyncResult: async () => ({ ok: true as const, textContent: "Result" }),
|
||||
}
|
||||
|
||||
const mockCtx = {
|
||||
sessionID: "parent-session",
|
||||
callID: "call-123",
|
||||
metadata: () => {},
|
||||
}
|
||||
|
||||
const mockExecutorCtx = {
|
||||
client: mockClient,
|
||||
directory: "/tmp",
|
||||
onSyncSessionCreated: null,
|
||||
}
|
||||
|
||||
const args = {
|
||||
prompt: "test prompt",
|
||||
description: "test task",
|
||||
category: "test",
|
||||
load_skills: [],
|
||||
run_in_background: false,
|
||||
command: null,
|
||||
}
|
||||
|
||||
const initialModel = {
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-opus-4-6",
|
||||
variant: "max",
|
||||
}
|
||||
const fallbackChain = [
|
||||
{ providers: ["anthropic"], model: "claude-opus-4-6", variant: "max" },
|
||||
{ providers: ["opencode-go"], model: "kimi-k2.5" },
|
||||
]
|
||||
|
||||
//#when
|
||||
const result = await executeSyncTask(args, mockCtx, mockExecutorCtx, {
|
||||
sessionID: "parent-session",
|
||||
}, "test-agent", initialModel, undefined, undefined, fallbackChain, deps)
|
||||
|
||||
//#then
|
||||
expect(result).toContain("Task completed")
|
||||
expect(result).toContain("Model: opencode-go/kimi-k2.5")
|
||||
expect(attemptedModels).toEqual([
|
||||
{ providerID: "anthropic", modelID: "claude-opus-4-6", variant: "max" },
|
||||
{ providerID: "opencode-go", modelID: "kimi-k2.5", variant: undefined },
|
||||
])
|
||||
})
|
||||
|
||||
test("#given fallback chain exhausted #when all retries fail #then returns final error", async () => {
|
||||
//#given
|
||||
const mockClient = {
|
||||
session: {
|
||||
create: async () => ({ data: { id: "ses_test_12345678" } }),
|
||||
},
|
||||
}
|
||||
|
||||
const { executeSyncTask } = require("./sync-task")
|
||||
const attemptedModels: Array<{ providerID: string; modelID: string; variant?: string } | undefined> = []
|
||||
const promptErrors = ["Initial failure", "Second failure", "Final failure"]
|
||||
|
||||
const deps = {
|
||||
createSyncSession: async () => ({ ok: true, sessionID: "ses_test_12345678" }),
|
||||
sendSyncPrompt: async (_client: unknown, input: { categoryModel?: { providerID: string; modelID: string; variant?: string } }) => {
|
||||
attemptedModels.push(input.categoryModel)
|
||||
return promptErrors[attemptedModels.length - 1] ?? "Unexpected extra retry"
|
||||
},
|
||||
pollSyncSession: async () => null,
|
||||
fetchSyncResult: async () => ({ ok: true as const, textContent: "Result" }),
|
||||
}
|
||||
|
||||
const mockCtx = {
|
||||
sessionID: "parent-session",
|
||||
callID: "call-123",
|
||||
metadata: () => {},
|
||||
}
|
||||
|
||||
const mockExecutorCtx = {
|
||||
client: mockClient,
|
||||
directory: "/tmp",
|
||||
onSyncSessionCreated: null,
|
||||
}
|
||||
|
||||
const args = {
|
||||
prompt: "test prompt",
|
||||
description: "test task",
|
||||
category: "test",
|
||||
load_skills: [],
|
||||
run_in_background: false,
|
||||
command: null,
|
||||
}
|
||||
|
||||
const initialModel = {
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-opus-4-6",
|
||||
variant: "max",
|
||||
}
|
||||
const fallbackChain = [
|
||||
{ providers: ["anthropic"], model: "claude-opus-4-6", variant: "max" },
|
||||
{ providers: ["opencode-go"], model: "kimi-k2.5" },
|
||||
{ providers: ["openai"], model: "gpt-5.4", variant: "medium" },
|
||||
]
|
||||
|
||||
//#when
|
||||
const result = await executeSyncTask(args, mockCtx, mockExecutorCtx, {
|
||||
sessionID: "parent-session",
|
||||
}, "test-agent", initialModel, undefined, undefined, fallbackChain, deps)
|
||||
|
||||
//#then
|
||||
expect(result).toBe("Final failure")
|
||||
expect(attemptedModels).toEqual([
|
||||
{ providerID: "anthropic", modelID: "claude-opus-4-6", variant: "max" },
|
||||
{ providerID: "opencode-go", modelID: "kimi-k2.5", variant: undefined },
|
||||
{ providerID: "openai", modelID: "gpt-5.4", variant: "medium" },
|
||||
])
|
||||
})
|
||||
|
||||
test("cleans up toast and subagentSessions on successful completion", async () => {
|
||||
const mockClient = {
|
||||
session: {
|
||||
@@ -282,6 +425,139 @@ describe("executeSyncTask - cleanup on error paths", () => {
|
||||
expect(deleteCalls.length).toBe(1)
|
||||
expect(deleteCalls[0]).toBe("ses_test_12345678")
|
||||
})
|
||||
|
||||
test("depth regression: blocks spawn when reserveSubagentSpawn throws depth limit error", async () => {
|
||||
// This is a smoke test guarding against regressions where the depth limit
|
||||
// would be silently bypassed (e.g. via a fallback path that hardcodes
|
||||
// childDepth: 1).
|
||||
|
||||
const mockClient = {
|
||||
session: {
|
||||
create: async () => ({ data: { id: "ses_test_12345678" } }),
|
||||
},
|
||||
}
|
||||
|
||||
const { executeSyncTask } = require("./sync-task")
|
||||
|
||||
const reserveSubagentSpawn = mock(async () => {
|
||||
throw new Error(
|
||||
"Subagent spawn blocked: child depth 4 exceeds background_task.maxDepth=3. Parent session: parent. Root session: root. Continue in an existing subagent session instead of spawning another."
|
||||
)
|
||||
})
|
||||
|
||||
const deps = {
|
||||
createSyncSession: async () => ({ ok: true, sessionID: "ses_test_12345678" }),
|
||||
sendSyncPrompt: async () => null,
|
||||
pollSyncSession: async () => null,
|
||||
fetchSyncResult: async () => ({ ok: true as const, textContent: "Result" }),
|
||||
}
|
||||
|
||||
const mockCtx = {
|
||||
sessionID: "parent-session",
|
||||
callID: "call-123",
|
||||
metadata: () => {},
|
||||
}
|
||||
|
||||
const mockExecutorCtx = {
|
||||
manager: { reserveSubagentSpawn },
|
||||
client: mockClient,
|
||||
directory: "/tmp",
|
||||
onSyncSessionCreated: null,
|
||||
}
|
||||
|
||||
const args = {
|
||||
prompt: "test prompt",
|
||||
description: "test task",
|
||||
category: "test",
|
||||
load_skills: [],
|
||||
run_in_background: false,
|
||||
command: null,
|
||||
}
|
||||
|
||||
//#when - executeSyncTask is called from a session at max depth
|
||||
const result = await executeSyncTask(args, mockCtx, mockExecutorCtx, {
|
||||
sessionID: "parent-session",
|
||||
}, "test-agent", undefined, undefined, undefined, undefined, deps)
|
||||
|
||||
//#then - should propagate the depth limit error and NOT create the session
|
||||
expect(result).toContain("Subagent spawn blocked")
|
||||
expect(result).toContain("child depth 4")
|
||||
expect(result).toContain("maxDepth=3")
|
||||
expect(reserveSubagentSpawn).toHaveBeenCalledWith("parent-session")
|
||||
// critical: createSyncSession must NOT have been called -- if it was,
|
||||
// the depth guard was bypassed.
|
||||
expect(addCalls.length).toBe(0)
|
||||
})
|
||||
|
||||
test("depth regression: does not silently fall back to childDepth: 1 when manager methods are present", async () => {
|
||||
// Guards against the dangerous fallback path in sync-task.ts that
|
||||
// hardcodes childDepth: 1 if reserveSubagentSpawn / assertCanSpawn are
|
||||
// not functions. With a real manager present, the fallback must NOT be
|
||||
// taken.
|
||||
|
||||
const mockClient = {
|
||||
session: {
|
||||
create: async () => ({ data: { id: "ses_test_12345678" } }),
|
||||
},
|
||||
}
|
||||
|
||||
const { executeSyncTask } = require("./sync-task")
|
||||
|
||||
let reservedDepth: number | undefined
|
||||
const commit = mock(() => 1)
|
||||
const rollback = mock(() => {})
|
||||
const reserveSubagentSpawn = mock(async () => {
|
||||
// Return a depth that proves the real manager was consulted
|
||||
reservedDepth = 3
|
||||
return {
|
||||
spawnContext: { rootSessionID: "root", parentDepth: 2, childDepth: 3 },
|
||||
descendantCount: 5,
|
||||
commit,
|
||||
rollback,
|
||||
}
|
||||
})
|
||||
|
||||
const deps = {
|
||||
createSyncSession: async () => ({ ok: true, sessionID: "ses_test_12345678" }),
|
||||
sendSyncPrompt: async () => null,
|
||||
pollSyncSession: async () => null,
|
||||
fetchSyncResult: async () => ({ ok: true as const, textContent: "Result" }),
|
||||
}
|
||||
|
||||
const metadataCalls: any[] = []
|
||||
const mockCtx = {
|
||||
sessionID: "parent-session",
|
||||
callID: "call-123",
|
||||
metadata: (input: any) => { metadataCalls.push(input) },
|
||||
}
|
||||
|
||||
const mockExecutorCtx = {
|
||||
manager: { reserveSubagentSpawn },
|
||||
client: mockClient,
|
||||
directory: "/tmp",
|
||||
onSyncSessionCreated: null,
|
||||
}
|
||||
|
||||
const args = {
|
||||
prompt: "test prompt",
|
||||
description: "test task",
|
||||
category: "test",
|
||||
load_skills: [],
|
||||
run_in_background: false,
|
||||
command: null,
|
||||
}
|
||||
|
||||
//#when
|
||||
await executeSyncTask(args, mockCtx, mockExecutorCtx, {
|
||||
sessionID: "parent-session",
|
||||
}, "test-agent", undefined, undefined, undefined, undefined, deps)
|
||||
|
||||
//#then - the spawnDepth recorded in metadata MUST match what reserveSubagentSpawn returned
|
||||
expect(reservedDepth).toBe(3)
|
||||
const taskMeta = metadataCalls.find((c) => c.metadata?.spawnDepth !== undefined)
|
||||
expect(taskMeta).toBeDefined()
|
||||
expect(taskMeta.metadata.spawnDepth).toBe(3) // NOT 1 (the fallback value)
|
||||
})
|
||||
})
|
||||
|
||||
export {}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { DelegateTaskArgs, ToolContextWithMetadata, DelegatedModelConfig }
|
||||
import type { ExecutorContext, ParentContext } from "./executor-types"
|
||||
import { getTaskToastManager } from "../../features/task-toast-manager"
|
||||
import { storeToolMetadata } from "../../features/tool-metadata-store"
|
||||
import { resolveCallID } from "./resolve-call-id"
|
||||
import { subagentSessions, syncSubagentSessions, setSessionAgent } from "../../features/claude-code-session-state"
|
||||
import { log } from "../../shared/logger"
|
||||
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
|
||||
@@ -10,6 +11,7 @@ import { formatDuration } from "./time-formatter"
|
||||
import { formatDetailedError } from "./error-formatting"
|
||||
import { syncTaskDeps, type SyncTaskDeps } from "./sync-task-deps"
|
||||
import { setSessionFallbackChain, clearSessionFallbackChain } from "../../hooks/model-fallback/hook"
|
||||
import { retrySyncPromptWithFallbacks } from "./sync-task-fallback"
|
||||
|
||||
export async function executeSyncTask(
|
||||
args: DelegateTaskArgs,
|
||||
@@ -36,14 +38,29 @@ export async function executeSyncTask(
|
||||
spawnReservation = await manager.reserveSubagentSpawn(parentContext.sessionID)
|
||||
}
|
||||
|
||||
const spawnContext = spawnReservation?.spawnContext
|
||||
?? (typeof manager?.assertCanSpawn === "function"
|
||||
? await manager.assertCanSpawn(parentContext.sessionID)
|
||||
: {
|
||||
rootSessionID: parentContext.sessionID,
|
||||
parentDepth: 0,
|
||||
childDepth: 1,
|
||||
})
|
||||
// Depth/descendant guard. We must NOT silently fall back to childDepth: 1
|
||||
// when the manager is unavailable or lacks the spawn methods, because that
|
||||
// would let subagents recurse without bound. The only safe fallback is
|
||||
// when the manager genuinely cannot enforce limits (legacy SDK), in which
|
||||
// case we still record childDepth: 1 but log a warning so regressions are
|
||||
// visible.
|
||||
let spawnContext: { rootSessionID: string; parentDepth: number; childDepth: number }
|
||||
if (spawnReservation?.spawnContext) {
|
||||
spawnContext = spawnReservation.spawnContext
|
||||
} else if (typeof manager?.assertCanSpawn === "function") {
|
||||
spawnContext = await manager.assertCanSpawn(parentContext.sessionID)
|
||||
} else {
|
||||
log(
|
||||
"[task] WARNING: BackgroundManager has no spawn enforcement methods (reserveSubagentSpawn / assertCanSpawn). " +
|
||||
"Depth and descendant limits cannot be enforced for this task. This indicates an old SDK or a misconfiguration.",
|
||||
{ parentSessionID: parentContext.sessionID }
|
||||
)
|
||||
spawnContext = {
|
||||
rootSessionID: parentContext.sessionID,
|
||||
parentDepth: 0,
|
||||
childDepth: 1,
|
||||
}
|
||||
}
|
||||
|
||||
const createSessionResult = await deps.createSyncSession(client, {
|
||||
parentSessionID: parentContext.sessionID,
|
||||
@@ -114,22 +131,48 @@ export async function executeSyncTask(
|
||||
},
|
||||
}
|
||||
await ctx.metadata?.(syncTaskMeta)
|
||||
if (ctx.callID) {
|
||||
storeToolMetadata(ctx.sessionID, ctx.callID, syncTaskMeta)
|
||||
const callID = resolveCallID(ctx)
|
||||
if (callID) {
|
||||
storeToolMetadata(ctx.sessionID, callID, syncTaskMeta)
|
||||
}
|
||||
|
||||
const promptError = await deps.sendSyncPrompt(client, {
|
||||
let effectiveCategoryModel = categoryModel
|
||||
let promptError = await deps.sendSyncPrompt(client, {
|
||||
sessionID,
|
||||
agentToUse,
|
||||
args,
|
||||
systemContent,
|
||||
categoryModel,
|
||||
categoryModel: effectiveCategoryModel,
|
||||
toastManager,
|
||||
taskId,
|
||||
sisyphusAgentConfig: executorCtx.sisyphusAgentConfig,
|
||||
})
|
||||
if (promptError) {
|
||||
return promptError
|
||||
const promptResult = await retrySyncPromptWithFallbacks({
|
||||
sessionID,
|
||||
initialError: promptError,
|
||||
categoryModel: effectiveCategoryModel,
|
||||
fallbackChain,
|
||||
sendPrompt: async (fallbackModel) => {
|
||||
return deps.sendSyncPrompt(client, {
|
||||
sessionID,
|
||||
agentToUse,
|
||||
args,
|
||||
systemContent,
|
||||
categoryModel: fallbackModel,
|
||||
toastManager,
|
||||
taskId,
|
||||
sisyphusAgentConfig: executorCtx.sisyphusAgentConfig,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
promptError = promptResult.promptError
|
||||
effectiveCategoryModel = promptResult.categoryModel
|
||||
|
||||
if (promptError) {
|
||||
return promptError
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -151,8 +194,8 @@ export async function executeSyncTask(
|
||||
const duration = formatDuration(startTime)
|
||||
|
||||
// 检测模型路由是否与父 session 不同,给用户可见的提示
|
||||
const actualModelStr = categoryModel
|
||||
? `${categoryModel.providerID}/${categoryModel.modelID}`
|
||||
const actualModelStr = effectiveCategoryModel
|
||||
? `${effectiveCategoryModel.providerID}/${effectiveCategoryModel.modelID}`
|
||||
: undefined
|
||||
const parentModelStr = parentContext.model
|
||||
? `${parentContext.model.providerID}/${parentContext.model.modelID}`
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
const { describe, expect, test } = require("bun:test")
|
||||
|
||||
function requireFresh<T>(modulePath: string): T {
|
||||
const resolvedPath = require.resolve(modulePath)
|
||||
if (require.cache?.[resolvedPath]) {
|
||||
delete require.cache[resolvedPath]
|
||||
}
|
||||
return require(modulePath) as T
|
||||
}
|
||||
|
||||
function createDelegateTask(...args: Parameters<typeof import("./tools").createDelegateTask>): ReturnType<typeof import("./tools").createDelegateTask> {
|
||||
return requireFresh<typeof import("./tools")>("./tools").createDelegateTask(...args)
|
||||
}
|
||||
|
||||
describe("createDelegateTask schema", () => {
|
||||
test("#given category arg #when tool is created #then category accepts any string", () => {
|
||||
//#given
|
||||
const toolDefinition = createDelegateTask({ manager: {} as never, client: {} as never, directory: "/tmp/test" })
|
||||
|
||||
//#when
|
||||
const categorySchema = toolDefinition.args.category as unknown as {
|
||||
def: {
|
||||
type: string
|
||||
innerType: {
|
||||
def: { type: string }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//#then
|
||||
expect(categorySchema.def.type).toBe("optional")
|
||||
expect(categorySchema.def.innerType.def.type).toBe("string")
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
declare const require: (name: string) => any
|
||||
declare const require: NodeJS.Require
|
||||
const { describe, test, expect, beforeEach, afterEach, spyOn, mock } = require("bun:test")
|
||||
import { DEFAULT_CATEGORIES, CATEGORY_PROMPT_APPENDS, CATEGORY_DESCRIPTIONS, isPlanAgent, PLAN_AGENT_NAMES, isPlanFamily, PLAN_FAMILY_NAMES } from "./constants"
|
||||
import { resolveCategoryConfig } from "./tools"
|
||||
import { getAgentDisplayName, getAgentListDisplayName } from "../../shared/agent-display-names"
|
||||
import type { CategoryConfig } from "../../config/schema"
|
||||
import type { DelegateTaskArgs } from "./types"
|
||||
import { __resetModelCache } from "../../shared/model-availability"
|
||||
@@ -10,6 +10,20 @@ import { __setTimingConfig, __resetTimingConfig } from "./timing"
|
||||
import * as connectedProvidersCache from "../../shared/connected-providers-cache"
|
||||
import * as executor from "./executor"
|
||||
|
||||
const runtimeRequire = require as NodeJS.Require & { cache?: Record<string, unknown> }
|
||||
|
||||
function clearRequireCache(modulePath: string): void {
|
||||
const resolvedPath = runtimeRequire.resolve(modulePath)
|
||||
if (runtimeRequire.cache?.[resolvedPath]) {
|
||||
delete runtimeRequire.cache[resolvedPath]
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCategoryConfig(...args: Parameters<typeof import("./tools").resolveCategoryConfig>): ReturnType<typeof import("./tools").resolveCategoryConfig> {
|
||||
clearRequireCache("./tools")
|
||||
return require("./tools").resolveCategoryConfig(...args)
|
||||
}
|
||||
|
||||
const SYSTEM_DEFAULT_MODEL = "anthropic/claude-sonnet-4-6"
|
||||
|
||||
const TEST_CONNECTED_PROVIDERS = ["anthropic", "google", "openai"]
|
||||
@@ -37,6 +51,7 @@ describe("sisyphus-task", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
mock.restore()
|
||||
clearRequireCache("./tools")
|
||||
__resetModelCache()
|
||||
clearSkillCache()
|
||||
__setTimingConfig({
|
||||
@@ -93,7 +108,7 @@ describe("sisyphus-task", () => {
|
||||
|
||||
// when / #then
|
||||
expect(category).toBeDefined()
|
||||
expect(category.model).toBe("openai/gpt-5.3-codex")
|
||||
expect(category.model).toBe("openai/gpt-5.4")
|
||||
expect(category.variant).toBe("medium")
|
||||
})
|
||||
|
||||
@@ -180,8 +195,8 @@ describe("sisyphus-task", () => {
|
||||
//#given / #when
|
||||
const result = isPlanAgent("planner")
|
||||
|
||||
//#then - "planner" contains "plan" so it matches via includes
|
||||
expect(result).toBe(true)
|
||||
//#then - "planner" is NOT an exact match for "plan" (T37 exact match fix)
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
test("returns true for case-insensitive match 'PLAN'", () => {
|
||||
@@ -253,6 +268,20 @@ describe("sisyphus-task", () => {
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test("returns true for prometheus display name", () => {
|
||||
//#given / #when
|
||||
const result = isPlanFamily(getAgentDisplayName("prometheus"))
|
||||
//#then
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test("returns true for prometheus list display name with zwsp prefix", () => {
|
||||
//#given / #when
|
||||
const result = isPlanFamily(getAgentListDisplayName("prometheus"))
|
||||
//#then
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test("returns false for 'oracle'", () => {
|
||||
//#given / #when
|
||||
const result = isPlanFamily("oracle")
|
||||
@@ -705,8 +734,8 @@ describe("sisyphus-task", () => {
|
||||
})
|
||||
|
||||
test("blocks requiresModel when availability is known and missing the required model", () => {
|
||||
// given
|
||||
const categoryName = "deep"
|
||||
// given - artistry has requiresModel: gemini-3.1-pro
|
||||
const categoryName = "artistry"
|
||||
const availableModels = new Set<string>(["anthropic/claude-opus-4-6"])
|
||||
|
||||
// when
|
||||
@@ -720,8 +749,8 @@ describe("sisyphus-task", () => {
|
||||
})
|
||||
|
||||
test("blocks requiresModel when availability is empty", () => {
|
||||
// given
|
||||
const categoryName = "deep"
|
||||
// given - artistry has requiresModel: gemini-3.1-pro
|
||||
const categoryName = "artistry"
|
||||
const availableModels = new Set<string>()
|
||||
|
||||
// when
|
||||
@@ -1366,6 +1395,134 @@ describe("sisyphus-task", () => {
|
||||
)).rejects.toThrow("Invalid arguments: 'run_in_background' parameter is REQUIRED")
|
||||
})
|
||||
|
||||
test("#given category without description #when executing #then auto-generates description from prompt", async () => {
|
||||
// given
|
||||
const { createDelegateTask } = require("./tools")
|
||||
let capturedTitle: string | undefined
|
||||
const mockManager = { launch: async () => ({}) }
|
||||
const mockClient = {
|
||||
app: { agents: async () => ({ data: [] }) },
|
||||
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
||||
session: {
|
||||
create: async () => ({ data: { id: "test-session" } }),
|
||||
prompt: async () => ({ data: {} }),
|
||||
promptAsync: async () => ({ data: {} }),
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
}
|
||||
const tool = createDelegateTask({ manager: mockManager, client: mockClient })
|
||||
|
||||
// when
|
||||
try {
|
||||
await tool.execute(
|
||||
{
|
||||
prompt: "Fix the broken unit tests in parser module",
|
||||
category: "quick",
|
||||
run_in_background: false,
|
||||
load_skills: [],
|
||||
},
|
||||
{
|
||||
sessionID: "parent-session",
|
||||
messageID: "parent-message",
|
||||
agent: "sisyphus",
|
||||
abort: new AbortController().signal,
|
||||
metadata: async (meta: { title?: string }) => { capturedTitle = meta.title },
|
||||
}
|
||||
)
|
||||
} catch {
|
||||
// execution may fail due to incomplete mocks — we only care about the title
|
||||
}
|
||||
|
||||
// then — description auto-generated from first 4 words of prompt
|
||||
expect(capturedTitle).toBe("Fix the broken unit")
|
||||
})
|
||||
|
||||
test("#given empty description #when executing #then auto-generates description from prompt", async () => {
|
||||
// given
|
||||
const { createDelegateTask } = require("./tools")
|
||||
let capturedTitle: string | undefined
|
||||
const mockManager = { launch: async () => ({}) }
|
||||
const mockClient = {
|
||||
app: { agents: async () => ({ data: [] }) },
|
||||
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
||||
session: {
|
||||
create: async () => ({ data: { id: "test-session" } }),
|
||||
prompt: async () => ({ data: {} }),
|
||||
promptAsync: async () => ({ data: {} }),
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
}
|
||||
const tool = createDelegateTask({ manager: mockManager, client: mockClient })
|
||||
|
||||
// when
|
||||
try {
|
||||
await tool.execute(
|
||||
{
|
||||
description: " ",
|
||||
prompt: "Refactor authentication module completely",
|
||||
category: "quick",
|
||||
run_in_background: false,
|
||||
load_skills: [],
|
||||
},
|
||||
{
|
||||
sessionID: "parent-session",
|
||||
messageID: "parent-message",
|
||||
agent: "sisyphus",
|
||||
abort: new AbortController().signal,
|
||||
metadata: async (meta: { title?: string }) => { capturedTitle = meta.title },
|
||||
}
|
||||
)
|
||||
} catch {
|
||||
// execution may fail due to incomplete mocks
|
||||
}
|
||||
|
||||
// then
|
||||
expect(capturedTitle).toBe("Refactor authentication module completely")
|
||||
})
|
||||
|
||||
test("#given explicit description #when executing #then preserves provided description", async () => {
|
||||
// given
|
||||
const { createDelegateTask } = require("./tools")
|
||||
let capturedTitle: string | undefined
|
||||
const mockManager = { launch: async () => ({}) }
|
||||
const mockClient = {
|
||||
app: { agents: async () => ({ data: [] }) },
|
||||
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
||||
session: {
|
||||
create: async () => ({ data: { id: "test-session" } }),
|
||||
prompt: async () => ({ data: {} }),
|
||||
promptAsync: async () => ({ data: {} }),
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
}
|
||||
const tool = createDelegateTask({ manager: mockManager, client: mockClient })
|
||||
|
||||
// when
|
||||
try {
|
||||
await tool.execute(
|
||||
{
|
||||
description: "My custom task name",
|
||||
prompt: "Do something else entirely",
|
||||
category: "quick",
|
||||
run_in_background: false,
|
||||
load_skills: [],
|
||||
},
|
||||
{
|
||||
sessionID: "parent-session",
|
||||
messageID: "parent-message",
|
||||
agent: "sisyphus",
|
||||
abort: new AbortController().signal,
|
||||
metadata: async (meta: { title?: string }) => { capturedTitle = meta.title },
|
||||
}
|
||||
)
|
||||
} catch {
|
||||
// execution may fail due to incomplete mocks
|
||||
}
|
||||
|
||||
// then — explicit description preserved
|
||||
expect(capturedTitle).toBe("My custom task name")
|
||||
})
|
||||
|
||||
test("#given explicit run_in_background=false #when executing #then sync execution succeeds", async () => {
|
||||
// given
|
||||
const { createDelegateTask } = require("./tools")
|
||||
@@ -1453,6 +1610,92 @@ describe("sisyphus-task", () => {
|
||||
expect(launchCalled).toBe(true)
|
||||
expect(result).toContain("Background task launched")
|
||||
}, { timeout: 10000 })
|
||||
|
||||
test("#given concurrent background launches from the same parent #when one parent call aborts during session wait #then sibling launch is not interrupted", async () => {
|
||||
// given
|
||||
const { createDelegateTask } = require("./tools")
|
||||
const firstAbortController = new AbortController()
|
||||
const secondAbortController = new AbortController()
|
||||
const taskStates = new Map([
|
||||
["bg_tool_first", { reads: 0, abortOnFirstRead: true, sessionID: "ses_tool_first" }],
|
||||
["bg_tool_second", { reads: 0, abortOnFirstRead: false, sessionID: "ses_tool_second" }],
|
||||
])
|
||||
let launchCount = 0
|
||||
const mockManager = {
|
||||
launch: async () => {
|
||||
launchCount += 1
|
||||
return launchCount === 1
|
||||
? {
|
||||
id: "bg_tool_first",
|
||||
sessionID: undefined,
|
||||
description: "Tool first",
|
||||
agent: "Sisyphus-Junior",
|
||||
status: "running",
|
||||
}
|
||||
: {
|
||||
id: "bg_tool_second",
|
||||
sessionID: undefined,
|
||||
description: "Tool second",
|
||||
agent: "Sisyphus-Junior",
|
||||
status: "running",
|
||||
}
|
||||
},
|
||||
getTask: (taskID: string) => {
|
||||
const state = taskStates.get(taskID)
|
||||
if (!state) return undefined
|
||||
state.reads += 1
|
||||
if (state.abortOnFirstRead && state.reads === 1) {
|
||||
firstAbortController.abort()
|
||||
}
|
||||
return state.reads >= 2
|
||||
? { sessionID: state.sessionID, status: "running" }
|
||||
: { sessionID: undefined, status: "pending" }
|
||||
},
|
||||
}
|
||||
const mockClient = {
|
||||
app: { agents: async () => ({ data: [] }) },
|
||||
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
||||
model: { list: async () => [] },
|
||||
session: {
|
||||
create: async () => ({ data: { id: "ses_bg_explicit_true" } }),
|
||||
prompt: async () => ({ data: {} }),
|
||||
promptAsync: async () => ({ data: {} }),
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
}
|
||||
const tool = createDelegateTask({ manager: mockManager, client: mockClient })
|
||||
|
||||
// when
|
||||
const [firstResult, secondResult] = await Promise.all([
|
||||
tool.execute(
|
||||
{
|
||||
description: "Tool first",
|
||||
prompt: "Run background",
|
||||
category: "quick",
|
||||
run_in_background: true,
|
||||
load_skills: [],
|
||||
},
|
||||
{ sessionID: "parent-session", messageID: "parent-message-1", agent: "sisyphus", abort: firstAbortController.signal }
|
||||
),
|
||||
tool.execute(
|
||||
{
|
||||
description: "Tool second",
|
||||
prompt: "Run background",
|
||||
category: "quick",
|
||||
run_in_background: true,
|
||||
load_skills: [],
|
||||
},
|
||||
{ sessionID: "parent-session", messageID: "parent-message-2", agent: "sisyphus", abort: secondAbortController.signal }
|
||||
),
|
||||
])
|
||||
|
||||
// then
|
||||
expect(firstResult).toContain("Background task launched")
|
||||
expect(firstResult).not.toContain("Task failed to start")
|
||||
expect(secondResult).toContain("Background task launched")
|
||||
expect(secondResult).toContain("session_id: ses_tool_second")
|
||||
expect(secondResult).not.toContain("interrupt")
|
||||
}, { timeout: 10000 })
|
||||
})
|
||||
|
||||
describe("session_id with background parameter", () => {
|
||||
@@ -2282,60 +2525,68 @@ describe("sisyphus-task", () => {
|
||||
expect(result).toContain("Artistry result here")
|
||||
}, { timeout: 20000 })
|
||||
|
||||
test("writing category (kimi) with run_in_background=false should force background but wait for result", async () => {
|
||||
// given - writing uses kimi-for-coding/k2p5
|
||||
test("writing category (kimi) with run_in_background=false should run sync when kimi provider is available", async () => {
|
||||
// given - writing uses kimi model which is no longer considered unstable
|
||||
// Override provider cache to include kimi-for-coding provider
|
||||
providerModelsSpy.mockReturnValue({
|
||||
models: {
|
||||
anthropic: ["claude-opus-4-6", "claude-sonnet-4-6", "claude-haiku-4-5"],
|
||||
google: ["gemini-3.1-pro", "gemini-3-flash"],
|
||||
openai: ["gpt-5.4", "gpt-5.3-codex"],
|
||||
"kimi-for-coding": ["k2p5"],
|
||||
},
|
||||
connected: ["anthropic", "google", "openai", "kimi-for-coding"],
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
})
|
||||
cacheSpy.mockReturnValue(["anthropic", "google", "openai", "kimi-for-coding"])
|
||||
|
||||
const { createDelegateTask } = require("./tools")
|
||||
let launchCalled = false
|
||||
|
||||
const launchedTask = {
|
||||
id: "task-writing",
|
||||
sessionID: "ses_writing_gemini",
|
||||
description: "Writing gemini task",
|
||||
agent: "sisyphus-junior",
|
||||
status: "running",
|
||||
}
|
||||
let promptCalled = false
|
||||
|
||||
const mockManager = {
|
||||
launch: async () => {
|
||||
launchCalled = true
|
||||
return launchedTask
|
||||
return { id: "should-not-be-called", sessionID: "x", description: "x", agent: "x", status: "running" }
|
||||
},
|
||||
getTask: () => launchedTask,
|
||||
}
|
||||
|
||||
|
||||
const promptMock = async () => {
|
||||
promptCalled = true
|
||||
return { data: {} }
|
||||
}
|
||||
|
||||
const mockClient = {
|
||||
app: { agents: async () => ({ data: [] }) },
|
||||
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
||||
model: { list: async () => [{ provider: "google", id: "gemini-3-flash" }] },
|
||||
session: {
|
||||
get: async () => ({ data: { directory: "/project" } }),
|
||||
create: async () => ({ data: { id: "ses_writing_gemini" } }),
|
||||
prompt: async () => ({ data: {} }),
|
||||
promptAsync: async () => ({ data: {} }),
|
||||
create: async () => ({ data: { id: "ses_writing_kimi" } }),
|
||||
prompt: promptMock,
|
||||
promptAsync: promptMock,
|
||||
messages: async () => ({
|
||||
data: [
|
||||
{ info: { role: "assistant", time: { created: Date.now() } }, parts: [{ type: "text", text: "Writing result here" }] }
|
||||
]
|
||||
data: [{ info: { role: "assistant" }, parts: [{ type: "text", text: "Writing result here" }] }]
|
||||
}),
|
||||
status: async () => ({ data: { "ses_writing_gemini": { type: "idle" } } }),
|
||||
status: async () => ({ data: { "ses_writing_kimi": { type: "idle" } } }),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
const tool = createDelegateTask({
|
||||
manager: mockManager,
|
||||
client: mockClient,
|
||||
})
|
||||
|
||||
|
||||
const toolContext = {
|
||||
sessionID: "parent-session",
|
||||
messageID: "parent-message",
|
||||
agent: "sisyphus",
|
||||
abort: new AbortController().signal,
|
||||
}
|
||||
|
||||
// when - writing category (gemini-3-flash)
|
||||
|
||||
// when - writing category (kimi) with run_in_background=false
|
||||
const result = await tool.execute(
|
||||
{
|
||||
description: "Test writing forced background",
|
||||
description: "Test writing sync",
|
||||
prompt: "Write something",
|
||||
category: "writing",
|
||||
run_in_background: false,
|
||||
@@ -2343,11 +2594,11 @@ describe("sisyphus-task", () => {
|
||||
},
|
||||
toolContext
|
||||
)
|
||||
|
||||
// then - should launch as background BUT wait for and return actual result
|
||||
expect(launchCalled).toBe(true)
|
||||
expect(result).toContain("SUPERVISED TASK COMPLETED")
|
||||
expect(result).toContain("Writing result here")
|
||||
|
||||
// then - should run sync, NOT forced to background (kimi is not unstable)
|
||||
expect(launchCalled).toBe(false)
|
||||
expect(promptCalled).toBe(true)
|
||||
expect(result).not.toContain("SUPERVISED TASK COMPLETED")
|
||||
}, { timeout: 20000 })
|
||||
|
||||
test("is_unstable_agent=true should force background but wait for result", async () => {
|
||||
@@ -2741,6 +2992,7 @@ describe("sisyphus-task", () => {
|
||||
// then - sisyphus-junior override model should be used, not category default
|
||||
expect(launchInput.model.providerID).toBe("anthropic")
|
||||
expect(launchInput.model.modelID).toBe("claude-sonnet-4-6")
|
||||
expect(launchInput.fallbackChain).toBeUndefined()
|
||||
})
|
||||
|
||||
test("sisyphus-junior model override works with user-defined category (#1295)", async () => {
|
||||
@@ -3385,6 +3637,26 @@ describe("sisyphus-task", () => {
|
||||
expect(result).toContain("plan-family")
|
||||
})
|
||||
|
||||
test("prometheus display name cannot delegate to plan (cross-blocking)", async () => {
|
||||
//#given
|
||||
const { createDelegateTask } = require("./tools")
|
||||
const mockClient = {
|
||||
app: { agents: async () => ({ data: [{ name: "plan", mode: "subagent" }] }) },
|
||||
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
||||
session: { get: async () => ({ data: { directory: "/project" } }), create: async () => ({ data: { id: "s" } }), prompt: async () => ({ data: {} }), promptAsync: async () => ({ data: {} }), messages: async () => ({ data: [] }), status: async () => ({ data: {} }) },
|
||||
}
|
||||
const tool = createDelegateTask({ manager: { launch: async () => ({}) }, client: mockClient })
|
||||
|
||||
//#when
|
||||
const result = await tool.execute(
|
||||
{ description: "test", prompt: "Create a plan", subagent_type: "plan", run_in_background: false, load_skills: [] },
|
||||
{ sessionID: "p", messageID: "m", agent: getAgentDisplayName("prometheus"), abort: new AbortController().signal }
|
||||
)
|
||||
|
||||
//#then
|
||||
expect(result).toContain("plan-family")
|
||||
})
|
||||
|
||||
test("plan cannot delegate to prometheus (cross-blocking)", async () => {
|
||||
//#given
|
||||
const { createDelegateTask } = require("./tools")
|
||||
@@ -3882,7 +4154,7 @@ describe("sisyphus-task", () => {
|
||||
expect(promptBody.tools.task).toBe(true)
|
||||
}, { timeout: 20000 })
|
||||
|
||||
test("prometheus subagent should have task permission (plan family)", async () => {
|
||||
test("prometheus subagent should have task permission as part of the plan family", async () => {
|
||||
//#given
|
||||
const { createDelegateTask } = require("./tools")
|
||||
let promptBody: any
|
||||
@@ -3907,7 +4179,7 @@ describe("sisyphus-task", () => {
|
||||
{ sessionID: "p", messageID: "m", agent: "sisyphus", abort: new AbortController().signal }
|
||||
)
|
||||
|
||||
//#then
|
||||
//#then - prometheus shares task permission with the plan family
|
||||
expect(promptBody.tools.task).toBe(true)
|
||||
}, { timeout: 20000 })
|
||||
|
||||
|
||||
@@ -76,13 +76,13 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini
|
||||
- category: For task delegation (uses Sisyphus-Junior with category-optimized model)
|
||||
- subagent_type: For direct agent invocation (explore, librarian, oracle, etc.)
|
||||
|
||||
**DO NOT provide both.** category and subagent_type are mutually exclusive.
|
||||
**DO NOT provide both.** If category is provided, subagent_type is ignored.
|
||||
|
||||
- load_skills: ALWAYS REQUIRED. Pass [] if no skills needed, or ["skill-1", "skill-2"] for category tasks.
|
||||
- category: Use predefined category → Spawns Sisyphus-Junior with category config
|
||||
Available categories:
|
||||
${categoryList}
|
||||
- subagent_type: Use a specific callable non-primary agent directly (for example: explore, librarian, oracle, metis, momus)
|
||||
- subagent_type: Use specific agent directly (explore, librarian, oracle, metis, momus)
|
||||
- run_in_background: REQUIRED. true=async (returns task_id), false=sync (waits). Use background=true ONLY for parallel exploration with 5+ independent queries.
|
||||
- session_id: Existing Task session to continue (from previous task output). Continues agent with FULL CONTEXT PRESERVED - saves tokens, maintains continuity.
|
||||
- command: The command that triggered this task (optional, for slash command tracking).
|
||||
@@ -98,28 +98,34 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini
|
||||
description,
|
||||
args: {
|
||||
load_skills: tool.schema.array(tool.schema.string()).describe("Skill names to inject. REQUIRED - pass [] if no skills needed."),
|
||||
description: tool.schema.string().describe("Short task description (3-5 words)"),
|
||||
description: tool.schema.string().optional().describe("Short task description (3-5 words). Auto-generated from prompt if omitted."),
|
||||
prompt: tool.schema.string().describe("Full detailed prompt for the agent"),
|
||||
run_in_background: tool.schema.boolean().describe("REQUIRED. true=async (returns task_id), false=sync (waits). Use false for task delegation, true ONLY for parallel exploration."),
|
||||
category: tool.schema.string().optional().describe(`REQUIRED if subagent_type not provided. Do NOT provide both category and subagent_type.`),
|
||||
subagent_type: tool.schema.string().optional().describe("REQUIRED if category not provided. Do NOT provide both category and subagent_type. Must be a callable non-primary agent name returned by app.agents()."),
|
||||
subagent_type: tool.schema.string().optional().describe("REQUIRED if category not provided. Do NOT provide both category and subagent_type."),
|
||||
session_id: tool.schema.string().optional().describe("Existing Task session to continue"),
|
||||
command: tool.schema.string().optional().describe("The command that triggered this task"),
|
||||
},
|
||||
async execute(args: DelegateTaskArgs, toolContext) {
|
||||
const ctx = toolContext as ToolContextWithMetadata
|
||||
|
||||
let categoryOverrideNote: string | undefined
|
||||
if (args.category && args.subagent_type) {
|
||||
categoryOverrideNote = `[Note: You provided both category="${args.category}" and subagent_type="${args.subagent_type}". category takes precedence \u2014 subagent_type was ignored. Next time, provide ONLY category.]`
|
||||
}
|
||||
if (args.category) {
|
||||
if (args.subagent_type && args.subagent_type !== SISYPHUS_JUNIOR_AGENT) {
|
||||
log("[task] category provided - overriding subagent_type to sisyphus-junior", {
|
||||
category: args.category,
|
||||
subagent_type: args.subagent_type,
|
||||
})
|
||||
}
|
||||
args.subagent_type = SISYPHUS_JUNIOR_AGENT
|
||||
}
|
||||
// Auto-generate description from prompt when missing or empty
|
||||
if (!args.description || typeof args.description !== "string" || args.description.trim() === "") {
|
||||
const words = (args.prompt || "").trim().split(/\s+/)
|
||||
args.description = words.slice(0, 4).join(" ") || "Delegated task"
|
||||
}
|
||||
await ctx.metadata?.({
|
||||
title: args.description,
|
||||
})
|
||||
|
||||
if (args.run_in_background === undefined) {
|
||||
throw new Error(`Invalid arguments: 'run_in_background' parameter is REQUIRED. Specify run_in_background=false for task delegation, or run_in_background=true for parallel exploration.`)
|
||||
}
|
||||
@@ -221,8 +227,7 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini
|
||||
availableCategories,
|
||||
availableSkills,
|
||||
})
|
||||
const result = await executeUnstableAgentTask(args, ctx, options, parentContext, agentToUse, categoryModel, systemContent, actualModel)
|
||||
return categoryOverrideNote ? `${categoryOverrideNote}\n\n${result}` : result
|
||||
return executeUnstableAgentTask(args, ctx, options, parentContext, agentToUse, categoryModel, systemContent, actualModel)
|
||||
}
|
||||
} else {
|
||||
const resolution = await resolveSubagentExecution(args, options, parentContext.agent, categoryExamples)
|
||||
@@ -245,13 +250,11 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini
|
||||
availableSkills,
|
||||
})
|
||||
|
||||
const prependNote = (result: string) => categoryOverrideNote ? `${categoryOverrideNote}\n\n${result}` : result
|
||||
|
||||
if (runInBackground) {
|
||||
return prependNote(await executeBackgroundTask(args, ctx, options, parentContext, agentToUse, categoryModel, systemContent, fallbackChain))
|
||||
return executeBackgroundTask(args, ctx, options, parentContext, agentToUse, categoryModel, systemContent, fallbackChain)
|
||||
}
|
||||
|
||||
return prependNote(await executeSyncTask(args, ctx, options, parentContext, agentToUse, categoryModel, systemContent, modelInfo, fallbackChain))
|
||||
return executeSyncTask(args, ctx, options, parentContext, agentToUse, categoryModel, systemContent, modelInfo, fallbackChain)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { DEFAULT_SYNC_POLL_TIMEOUT_MS, getTimingConfig } from "./timing"
|
||||
import { buildTaskPrompt } from "./prompt-builder"
|
||||
import { cancelUnstableAgentTask } from "./cancel-unstable-agent-task"
|
||||
import { storeToolMetadata } from "../../features/tool-metadata-store"
|
||||
import { resolveCallID } from "./resolve-call-id"
|
||||
import { formatDuration } from "./time-formatter"
|
||||
import { formatDetailedError } from "./error-formatting"
|
||||
import { getSessionTools } from "../../shared/session-tools-store"
|
||||
@@ -81,8 +82,9 @@ export async function executeUnstableAgentTask(
|
||||
},
|
||||
}
|
||||
await ctx.metadata?.(bgTaskMeta)
|
||||
if (ctx.callID) {
|
||||
storeToolMetadata(ctx.sessionID, ctx.callID, bgTaskMeta)
|
||||
const callID = resolveCallID(ctx)
|
||||
if (callID) {
|
||||
storeToolMetadata(ctx.sessionID, callID, bgTaskMeta)
|
||||
}
|
||||
|
||||
const startTime = new Date()
|
||||
|
||||
+377
-114
@@ -1,11 +1,36 @@
|
||||
declare const require: (name: string) => any
|
||||
const { describe, test, expect, beforeEach, afterEach, spyOn, mock, vi } = require("bun:test")
|
||||
import { resolveSubagentExecution } from "./subagent-resolver"
|
||||
import type { DelegateTaskArgs } from "./types"
|
||||
import type { ExecutorContext } from "./executor-types"
|
||||
import * as logger from "../../shared/logger"
|
||||
import * as connectedProvidersCache from "../../shared/connected-providers-cache"
|
||||
import * as agentLoader from "../../features/claude-code-agent-loader"
|
||||
import { describe, test, expect, beforeEach, afterEach, mock } from "bun:test"
|
||||
import type { DelegateTaskArgs } from "../types"
|
||||
import type { ExecutorContext } from "../executor-types"
|
||||
|
||||
type SubagentResolverModule = typeof import("../subagent-resolver")
|
||||
|
||||
const logMock = mock((..._args: unknown[]) => {})
|
||||
|
||||
const readConnectedProvidersCacheMock = mock(() => null as string[] | null)
|
||||
const readProviderModelsCacheMock = mock(
|
||||
() => null as {
|
||||
models: Record<string, string[]>
|
||||
connected: string[]
|
||||
updatedAt: string
|
||||
} | null,
|
||||
)
|
||||
|
||||
type ClaudeCodeAgentRecord = Record<
|
||||
string,
|
||||
{
|
||||
description?: string
|
||||
mode?: string
|
||||
prompt?: string
|
||||
model?: string | { providerID: string; modelID: string }
|
||||
}
|
||||
>
|
||||
|
||||
const loadUserAgentsMock = mock((): ClaudeCodeAgentRecord => ({}))
|
||||
const loadProjectAgentsMock = mock((_directory?: string): ClaudeCodeAgentRecord => ({}))
|
||||
|
||||
async function importFreshSubagentResolverModule(): Promise<SubagentResolverModule> {
|
||||
return await import(`../subagent-resolver?test=${Date.now()}-${Math.random()}`)
|
||||
}
|
||||
|
||||
function createBaseArgs(overrides?: Partial<DelegateTaskArgs>): DelegateTaskArgs {
|
||||
return {
|
||||
@@ -37,21 +62,42 @@ function createExecutorContext(
|
||||
}
|
||||
|
||||
describe("resolveSubagentExecution", () => {
|
||||
let logSpy: ReturnType<typeof spyOn> | undefined
|
||||
let mockLoadUserAgents: ReturnType<typeof spyOn>
|
||||
let mockLoadProjectAgents: ReturnType<typeof spyOn>
|
||||
let resolveSubagentExecution: SubagentResolverModule["resolveSubagentExecution"]
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
mock.restore()
|
||||
logSpy = spyOn(logger, "log").mockImplementation(() => {})
|
||||
mockLoadUserAgents = spyOn(agentLoader, "loadUserAgents").mockReturnValue({})
|
||||
mockLoadProjectAgents = spyOn(agentLoader, "loadProjectAgents").mockReturnValue({})
|
||||
logMock.mockClear()
|
||||
readConnectedProvidersCacheMock.mockReset()
|
||||
readProviderModelsCacheMock.mockReset()
|
||||
readConnectedProvidersCacheMock.mockReturnValue(null)
|
||||
readProviderModelsCacheMock.mockReturnValue(null)
|
||||
loadUserAgentsMock.mockReset()
|
||||
loadProjectAgentsMock.mockReset()
|
||||
loadUserAgentsMock.mockImplementation(() => ({}))
|
||||
loadProjectAgentsMock.mockImplementation(() => ({}))
|
||||
mock.module("../../../shared/logger", () => ({
|
||||
log: logMock,
|
||||
}))
|
||||
mock.module("../../../shared/connected-providers-cache", () => ({
|
||||
readConnectedProvidersCache: readConnectedProvidersCacheMock,
|
||||
readProviderModelsCache: readProviderModelsCacheMock,
|
||||
hasConnectedProvidersCache: () => readConnectedProvidersCacheMock() !== null,
|
||||
hasProviderModelsCache: () => readProviderModelsCacheMock() !== null,
|
||||
_resetMemCacheForTesting: () => {},
|
||||
}))
|
||||
mock.module("../../../features/claude-code-agent-loader/loader", () => ({
|
||||
loadUserAgents: loadUserAgentsMock,
|
||||
loadProjectAgents: loadProjectAgentsMock,
|
||||
}))
|
||||
mock.module("../../../features/claude-code-agent-loader", () => ({
|
||||
loadUserAgents: loadUserAgentsMock,
|
||||
loadProjectAgents: loadProjectAgentsMock,
|
||||
}))
|
||||
;({ resolveSubagentExecution } = await importFreshSubagentResolverModule())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
logSpy?.mockRestore()
|
||||
mockLoadUserAgents?.mockRestore()
|
||||
mockLoadProjectAgents?.mockRestore()
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
test("returns delegation error when agent discovery fails instead of silently proceeding", async () => {
|
||||
@@ -71,7 +117,7 @@ describe("resolveSubagentExecution", () => {
|
||||
expect(result.error).toBe("Failed to delegate to agent \"oracle\": agents API unavailable")
|
||||
})
|
||||
|
||||
test("logs failure details when subagent resolution throws", async () => {
|
||||
test("returns delegation error when subagent resolution throws", async () => {
|
||||
//#given
|
||||
const args = createBaseArgs({ subagent_type: "review" })
|
||||
const executorCtx = createExecutorContext(async () => {
|
||||
@@ -79,22 +125,52 @@ describe("resolveSubagentExecution", () => {
|
||||
})
|
||||
|
||||
//#when
|
||||
await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep")
|
||||
const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep")
|
||||
|
||||
//#then
|
||||
expect(logSpy).toHaveBeenCalledTimes(1)
|
||||
const callArgs = logSpy?.mock.calls[0]
|
||||
expect(callArgs?.[0]).toBe("[delegate-task] Failed to resolve subagent execution")
|
||||
expect(callArgs?.[1]).toEqual({
|
||||
requestedAgent: "review",
|
||||
parentAgent: "sisyphus",
|
||||
error: "network timeout",
|
||||
})
|
||||
expect(result.agentToUse).toBe("")
|
||||
expect(result.categoryModel).toBeUndefined()
|
||||
expect(result.error).toBe('Failed to delegate to agent "review": network timeout')
|
||||
})
|
||||
|
||||
test("hides primary agents from task delegation lookups", async () => {
|
||||
//#given
|
||||
const args = createBaseArgs({ subagent_type: "sisyphus" })
|
||||
const executorCtx = createExecutorContext(async () => ([
|
||||
{ name: "sisyphus", mode: "primary" },
|
||||
{ name: "oracle", mode: "subagent" },
|
||||
{ name: "metis", mode: "all" },
|
||||
]))
|
||||
|
||||
//#when
|
||||
const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep")
|
||||
|
||||
//#then
|
||||
expect(result.agentToUse).toBe("")
|
||||
expect(result.categoryModel).toBeUndefined()
|
||||
expect(result.error).toBe('Unknown agent: "sisyphus". Available agents: metis, oracle')
|
||||
})
|
||||
|
||||
test("requires explicit all or subagent mode for task-callable agents", async () => {
|
||||
//#given
|
||||
const args = createBaseArgs({ subagent_type: "custom-worker" })
|
||||
const executorCtx = createExecutorContext(async () => ([
|
||||
{ name: "custom-worker" },
|
||||
{ name: "oracle", mode: "subagent" },
|
||||
]))
|
||||
|
||||
//#when
|
||||
const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep")
|
||||
|
||||
//#then
|
||||
expect(result.agentToUse).toBe("")
|
||||
expect(result.categoryModel).toBeUndefined()
|
||||
expect(result.error).toBe('Unknown agent: "custom-worker". Available agents: oracle')
|
||||
})
|
||||
|
||||
test("normalizes matched agent model string before returning categoryModel", async () => {
|
||||
//#given
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
|
||||
readProviderModelsCacheMock.mockReturnValue({
|
||||
models: { openai: ["grok-3", "gpt-5.3-codex"] },
|
||||
connected: ["openai"],
|
||||
updatedAt: "2026-03-03T00:00:00.000Z",
|
||||
@@ -110,12 +186,26 @@ describe("resolveSubagentExecution", () => {
|
||||
//#then
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.categoryModel).toEqual({ providerID: "openai", modelID: "gpt-5.3-codex" })
|
||||
cacheSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("matches agents even when zero-width characters are present in the requested name", async () => {
|
||||
//#given
|
||||
const args = createBaseArgs({ subagent_type: "\uFEFFSisyphus - Ultraworker" })
|
||||
const executorCtx = createExecutorContext(async () => ([
|
||||
{ name: "\u200BSisyphus - Ultraworker", mode: "subagent", model: "openai/gpt-5.3-codex" },
|
||||
]))
|
||||
|
||||
//#when
|
||||
const result = await resolveSubagentExecution(args, executorCtx, "oracle", "deep")
|
||||
|
||||
//#then
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.agentToUse).toBe("Sisyphus - Ultraworker")
|
||||
})
|
||||
|
||||
test("uses agent override fallback_models for subagent runtime fallback chain", async () => {
|
||||
//#given
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
|
||||
readProviderModelsCacheMock.mockReturnValue({
|
||||
models: { quotio: ["claude-haiku-4-5"] },
|
||||
connected: ["quotio"],
|
||||
updatedAt: "2026-03-03T00:00:00.000Z",
|
||||
@@ -143,12 +233,11 @@ describe("resolveSubagentExecution", () => {
|
||||
{ providers: ["quotio"], model: "gpt-5.2", variant: undefined },
|
||||
{ providers: ["quotio"], model: "glm-5", variant: "max" },
|
||||
])
|
||||
cacheSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("uses category fallback_models when agent override points at category", async () => {
|
||||
//#given
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
|
||||
readProviderModelsCacheMock.mockReturnValue({
|
||||
models: { anthropic: ["claude-haiku-4-5"] },
|
||||
connected: ["anthropic"],
|
||||
updatedAt: "2026-03-03T00:00:00.000Z",
|
||||
@@ -180,17 +269,16 @@ describe("resolveSubagentExecution", () => {
|
||||
expect(result.fallbackChain).toEqual([
|
||||
{ providers: ["anthropic"], model: "claude-haiku-4-5", variant: undefined },
|
||||
])
|
||||
cacheSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("promotes object-style fallback model settings to categoryModel when subagent fallback becomes initial model", async () => {
|
||||
//#given
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
|
||||
readProviderModelsCacheMock.mockReturnValue({
|
||||
models: { openai: ["gpt-5.4"] },
|
||||
connected: ["openai"],
|
||||
updatedAt: "2026-03-03T00:00:00.000Z",
|
||||
})
|
||||
const connectedSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
|
||||
readConnectedProvidersCacheMock.mockReturnValue(["openai"])
|
||||
const args = createBaseArgs({ subagent_type: "explore" })
|
||||
const executorCtx = createExecutorContext(
|
||||
async () => ([
|
||||
@@ -230,18 +318,16 @@ describe("resolveSubagentExecution", () => {
|
||||
maxTokens: 2048,
|
||||
thinking: { type: "disabled" },
|
||||
})
|
||||
cacheSpy.mockRestore()
|
||||
connectedSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("does not apply object-style fallback settings when the subagent primary model matches directly", async () => {
|
||||
//#given
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
|
||||
readProviderModelsCacheMock.mockReturnValue({
|
||||
models: { openai: ["gpt-5.4-preview"] },
|
||||
connected: ["openai"],
|
||||
updatedAt: "2026-03-03T00:00:00.000Z",
|
||||
})
|
||||
const connectedSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
|
||||
readConnectedProvidersCacheMock.mockReturnValue(["openai"])
|
||||
const args = createBaseArgs({ subagent_type: "explore" })
|
||||
const executorCtx = createExecutorContext(
|
||||
async () => ([
|
||||
@@ -271,18 +357,16 @@ describe("resolveSubagentExecution", () => {
|
||||
providerID: "openai",
|
||||
modelID: "gpt-5.4-preview",
|
||||
})
|
||||
cacheSpy.mockRestore()
|
||||
connectedSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("matches promoted fallback settings after fuzzy model resolution", async () => {
|
||||
//#given
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
|
||||
readProviderModelsCacheMock.mockReturnValue({
|
||||
models: { openai: ["gpt-5.4-preview"] },
|
||||
connected: ["openai"],
|
||||
updatedAt: "2026-03-03T00:00:00.000Z",
|
||||
})
|
||||
const connectedSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
|
||||
readConnectedProvidersCacheMock.mockReturnValue(["openai"])
|
||||
const args = createBaseArgs({ subagent_type: "explore" })
|
||||
const executorCtx = createExecutorContext(
|
||||
async () => ([
|
||||
@@ -322,18 +406,16 @@ describe("resolveSubagentExecution", () => {
|
||||
maxTokens: 2222,
|
||||
thinking: { type: "disabled" },
|
||||
})
|
||||
cacheSpy.mockRestore()
|
||||
connectedSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("prefers exact promoted fallback match over earlier fuzzy prefix match", async () => {
|
||||
//#given
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
|
||||
readProviderModelsCacheMock.mockReturnValue({
|
||||
models: { openai: ["gpt-5.4-preview"] },
|
||||
connected: ["openai"],
|
||||
updatedAt: "2026-03-03T00:00:00.000Z",
|
||||
})
|
||||
const connectedSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
|
||||
readConnectedProvidersCacheMock.mockReturnValue(["openai"])
|
||||
const args = createBaseArgs({ subagent_type: "explore" })
|
||||
const executorCtx = createExecutorContext(
|
||||
async () => ([
|
||||
@@ -370,18 +452,16 @@ describe("resolveSubagentExecution", () => {
|
||||
variant: "max",
|
||||
reasoningEffort: "high",
|
||||
})
|
||||
cacheSpy.mockRestore()
|
||||
connectedSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("matches promoted fallback settings when fuzzy resolution extends configured model without hyphen", async () => {
|
||||
//#given
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
|
||||
readProviderModelsCacheMock.mockReturnValue({
|
||||
models: { openai: ["gpt-5.4o"] },
|
||||
connected: ["openai"],
|
||||
updatedAt: "2026-03-03T00:00:00.000Z",
|
||||
})
|
||||
const connectedSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
|
||||
readConnectedProvidersCacheMock.mockReturnValue(["openai"])
|
||||
const args = createBaseArgs({ subagent_type: "explore" })
|
||||
const executorCtx = createExecutorContext(
|
||||
async () => ([
|
||||
@@ -413,18 +493,16 @@ describe("resolveSubagentExecution", () => {
|
||||
variant: "low",
|
||||
reasoningEffort: "high",
|
||||
})
|
||||
cacheSpy.mockRestore()
|
||||
connectedSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("does not use unavailable matchedAgent.model as fallback for custom subagent", async () => {
|
||||
//#given
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
|
||||
readProviderModelsCacheMock.mockReturnValue({
|
||||
models: { minimaxi: ["MiniMax-M2.7"] },
|
||||
connected: ["minimaxi"],
|
||||
updatedAt: "2026-03-03T00:00:00.000Z",
|
||||
})
|
||||
const connectedSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["minimaxi"])
|
||||
readConnectedProvidersCacheMock.mockReturnValue(["minimaxi"])
|
||||
const args = createBaseArgs({ subagent_type: "my-custom-agent" })
|
||||
const executorCtx = createExecutorContext(
|
||||
async () => ([
|
||||
@@ -438,18 +516,16 @@ describe("resolveSubagentExecution", () => {
|
||||
//#then
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.categoryModel?.modelID).not.toBe("MiniMax-M2.7-highspeed")
|
||||
cacheSpy.mockRestore()
|
||||
connectedSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("uses matchedAgent.model as fallback when model is available", async () => {
|
||||
//#given
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
|
||||
readProviderModelsCacheMock.mockReturnValue({
|
||||
models: { minimaxi: ["MiniMax-M2.7-highspeed"] },
|
||||
connected: ["minimaxi"],
|
||||
updatedAt: "2026-03-03T00:00:00.000Z",
|
||||
})
|
||||
const connectedSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["minimaxi"])
|
||||
readConnectedProvidersCacheMock.mockReturnValue(["minimaxi"])
|
||||
const args = createBaseArgs({ subagent_type: "my-custom-agent" })
|
||||
const executorCtx = createExecutorContext(
|
||||
async () => ([
|
||||
@@ -463,18 +539,16 @@ describe("resolveSubagentExecution", () => {
|
||||
//#then
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.categoryModel).toEqual({ providerID: "minimaxi", modelID: "MiniMax-M2.7-highspeed" })
|
||||
cacheSpy.mockRestore()
|
||||
connectedSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("prefers the most specific prefix match when fallback entries share a prefix", async () => {
|
||||
//#given
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
|
||||
readProviderModelsCacheMock.mockReturnValue({
|
||||
models: { openai: ["gpt-4o-preview"] },
|
||||
connected: ["openai"],
|
||||
updatedAt: "2026-03-03T00:00:00.000Z",
|
||||
})
|
||||
const connectedSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
|
||||
readConnectedProvidersCacheMock.mockReturnValue(["openai"])
|
||||
const args = createBaseArgs({ subagent_type: "explore" })
|
||||
const executorCtx = createExecutorContext(
|
||||
async () => ([
|
||||
@@ -511,29 +585,122 @@ describe("resolveSubagentExecution", () => {
|
||||
variant: "max",
|
||||
reasoningEffort: "high",
|
||||
})
|
||||
cacheSpy.mockRestore()
|
||||
connectedSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("resolves user agent from loadUserAgents when calling task(subagent_type=...)", async () => {
|
||||
test("preserves category temperature when fallback entry leaves temperature undefined", async () => {
|
||||
//#given
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
|
||||
readProviderModelsCacheMock.mockReturnValue({
|
||||
models: { openai: ["gpt-5.4"] },
|
||||
connected: ["openai"],
|
||||
updatedAt: "2026-03-03T00:00:00.000Z",
|
||||
})
|
||||
const connectedSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
|
||||
readConnectedProvidersCacheMock.mockReturnValue(["openai"])
|
||||
const args = createBaseArgs({ subagent_type: "explore" })
|
||||
const executorCtx = createExecutorContext(
|
||||
async () => ([
|
||||
{ name: "explore", mode: "subagent", model: "quotio/claude-haiku-4-5-unavailable" },
|
||||
]),
|
||||
{
|
||||
agentOverrides: {
|
||||
explore: {
|
||||
category: "research",
|
||||
},
|
||||
} as ExecutorContext["agentOverrides"],
|
||||
userCategories: {
|
||||
research: {
|
||||
fallback_models: [
|
||||
{
|
||||
model: "openai/gpt-5.4",
|
||||
variant: "max",
|
||||
},
|
||||
],
|
||||
temperature: 0.55,
|
||||
top_p: 0.45,
|
||||
},
|
||||
} as ExecutorContext["userCategories"],
|
||||
}
|
||||
)
|
||||
|
||||
mockLoadUserAgents.mockReturnValue({
|
||||
//#when
|
||||
const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep")
|
||||
|
||||
//#then
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.categoryModel).toEqual({
|
||||
providerID: "openai",
|
||||
modelID: "gpt-5.4",
|
||||
variant: "max",
|
||||
temperature: 0.55,
|
||||
top_p: 0.45,
|
||||
})
|
||||
})
|
||||
|
||||
test("applies category tuning params in the cold-cache override path", async () => {
|
||||
//#given
|
||||
readProviderModelsCacheMock.mockReturnValue({
|
||||
models: {},
|
||||
connected: [],
|
||||
updatedAt: "2026-03-03T00:00:00.000Z",
|
||||
})
|
||||
readConnectedProvidersCacheMock.mockReturnValue([])
|
||||
const args = createBaseArgs({ subagent_type: "explore" })
|
||||
const executorCtx = createExecutorContext(
|
||||
async () => ([
|
||||
{ name: "explore", mode: "subagent", model: "openai/gpt-5.4" },
|
||||
]),
|
||||
{
|
||||
agentOverrides: {
|
||||
explore: {
|
||||
category: "research",
|
||||
},
|
||||
} as ExecutorContext["agentOverrides"],
|
||||
userCategories: {
|
||||
research: {
|
||||
model: "openai/gpt-5.4",
|
||||
variant: "high",
|
||||
temperature: 0.61,
|
||||
top_p: 0.62,
|
||||
maxTokens: 3200,
|
||||
reasoningEffort: "medium",
|
||||
thinking: { type: "disabled" },
|
||||
},
|
||||
} as ExecutorContext["userCategories"],
|
||||
}
|
||||
)
|
||||
|
||||
//#when
|
||||
const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep")
|
||||
|
||||
//#then
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.categoryModel).toEqual({
|
||||
providerID: "openai",
|
||||
modelID: "gpt-5.4",
|
||||
variant: "high",
|
||||
temperature: 0.61,
|
||||
top_p: 0.62,
|
||||
maxTokens: 3200,
|
||||
reasoningEffort: "medium",
|
||||
thinking: { type: "disabled" },
|
||||
})
|
||||
})
|
||||
|
||||
test("resolves user agent from loadUserAgents when calling task(subagent_type=...)", async () => {
|
||||
//#given
|
||||
readProviderModelsCacheMock.mockReturnValue({
|
||||
models: { openai: ["gpt-5.4"] },
|
||||
connected: ["openai"],
|
||||
updatedAt: "2026-03-03T00:00:00.000Z",
|
||||
})
|
||||
readConnectedProvidersCacheMock.mockReturnValue(["openai"])
|
||||
loadUserAgentsMock.mockImplementation(() => ({
|
||||
"my-user-agent": {
|
||||
description: "A user agent",
|
||||
mode: "subagent",
|
||||
prompt: "Do something",
|
||||
model: "openai/gpt-5.4",
|
||||
},
|
||||
})
|
||||
mockLoadProjectAgents.mockReturnValue({})
|
||||
|
||||
}))
|
||||
const args = createBaseArgs({ subagent_type: "my-user-agent" })
|
||||
const executorCtx = createExecutorContext(async () => [])
|
||||
|
||||
@@ -544,30 +711,24 @@ describe("resolveSubagentExecution", () => {
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.agentToUse).toBe("my-user-agent")
|
||||
expect(result.categoryModel?.modelID).toBe("gpt-5.4")
|
||||
|
||||
cacheSpy.mockRestore()
|
||||
connectedSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("resolves project agent from loadProjectAgents when calling task(subagent_type=...)", async () => {
|
||||
//#given
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
|
||||
readProviderModelsCacheMock.mockReturnValue({
|
||||
models: { anthropic: ["claude-sonnet-4"] },
|
||||
connected: ["anthropic"],
|
||||
updatedAt: "2026-03-03T00:00:00.000Z",
|
||||
})
|
||||
const connectedSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["anthropic"])
|
||||
|
||||
mockLoadUserAgents.mockReturnValue({})
|
||||
mockLoadProjectAgents.mockReturnValue({
|
||||
readConnectedProvidersCacheMock.mockReturnValue(["anthropic"])
|
||||
loadProjectAgentsMock.mockImplementation(() => ({
|
||||
"my-project-agent": {
|
||||
description: "A project agent",
|
||||
mode: "subagent",
|
||||
prompt: "Do project work",
|
||||
model: "anthropic/claude-sonnet-4",
|
||||
},
|
||||
})
|
||||
|
||||
}))
|
||||
const args = createBaseArgs({ subagent_type: "my-project-agent" })
|
||||
const executorCtx = createExecutorContext(async () => [])
|
||||
|
||||
@@ -578,31 +739,24 @@ describe("resolveSubagentExecution", () => {
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.agentToUse).toBe("my-project-agent")
|
||||
expect(result.categoryModel?.modelID).toBe("claude-sonnet-4")
|
||||
|
||||
cacheSpy.mockRestore()
|
||||
connectedSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("server agent takes precedence over user agent with same name", async () => {
|
||||
//#given
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
|
||||
models: { openai: ["gpt-5.4"] },
|
||||
readProviderModelsCacheMock.mockReturnValue({
|
||||
models: { openai: ["gpt-5.4", "gpt-3.5"] },
|
||||
connected: ["openai"],
|
||||
updatedAt: "2026-03-03T00:00:00.000Z",
|
||||
})
|
||||
const connectedSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
|
||||
|
||||
mockLoadUserAgents.mockReturnValue({
|
||||
readConnectedProvidersCacheMock.mockReturnValue(["openai"])
|
||||
loadUserAgentsMock.mockImplementation(() => ({
|
||||
"explore": {
|
||||
description: "User explore agent",
|
||||
mode: "subagent",
|
||||
prompt: "User prompt",
|
||||
model: "openai/gpt-3.5",
|
||||
},
|
||||
})
|
||||
mockLoadProjectAgents.mockReturnValue({})
|
||||
|
||||
// Server has "explore" agent
|
||||
}))
|
||||
const args = createBaseArgs({ subagent_type: "explore" })
|
||||
const executorCtx = createExecutorContext(async () => ([
|
||||
{ name: "explore", mode: "subagent", model: "openai/gpt-5.4" },
|
||||
@@ -614,39 +768,33 @@ describe("resolveSubagentExecution", () => {
|
||||
//#then
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.agentToUse).toBe("explore")
|
||||
// Should use server's model, not user's
|
||||
expect(result.categoryModel?.modelID).toBe("gpt-5.4")
|
||||
|
||||
cacheSpy.mockRestore()
|
||||
connectedSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("project agent takes precedence over user agent with same name", async () => {
|
||||
//#given
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({
|
||||
readProviderModelsCacheMock.mockReturnValue({
|
||||
models: { minimaxi: ["MiniMax-M2.7-highspeed", "claude-3-haiku"] },
|
||||
connected: ["minimaxi"],
|
||||
updatedAt: "2026-03-03T00:00:00.000Z",
|
||||
})
|
||||
const connectedSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["minimaxi"])
|
||||
|
||||
mockLoadUserAgents.mockReturnValue({
|
||||
readConnectedProvidersCacheMock.mockReturnValue(["minimaxi"])
|
||||
loadUserAgentsMock.mockImplementation(() => ({
|
||||
"my-custom-agent": {
|
||||
description: "User agent",
|
||||
mode: "subagent",
|
||||
prompt: "User prompt",
|
||||
model: "minimaxi/claude-3-haiku",
|
||||
},
|
||||
})
|
||||
mockLoadProjectAgents.mockReturnValue({
|
||||
}))
|
||||
loadProjectAgentsMock.mockImplementation(() => ({
|
||||
"my-custom-agent": {
|
||||
description: "Project agent",
|
||||
mode: "subagent",
|
||||
prompt: "Project prompt",
|
||||
model: "minimaxi/MiniMax-M2.7-highspeed",
|
||||
},
|
||||
})
|
||||
|
||||
}))
|
||||
const args = createBaseArgs({ subagent_type: "my-custom-agent" })
|
||||
const executorCtx = createExecutorContext(async () => [])
|
||||
|
||||
@@ -657,22 +805,17 @@ describe("resolveSubagentExecution", () => {
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.agentToUse).toBe("my-custom-agent")
|
||||
expect(result.categoryModel?.modelID).toBe("MiniMax-M2.7-highspeed")
|
||||
|
||||
cacheSpy.mockRestore()
|
||||
connectedSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("filters out primary agents from user/project when resolving", async () => {
|
||||
//#given
|
||||
mockLoadUserAgents.mockReturnValue({
|
||||
loadUserAgentsMock.mockImplementation(() => ({
|
||||
"my-primary-agent": {
|
||||
description: "A primary agent",
|
||||
mode: "primary",
|
||||
prompt: "I am primary",
|
||||
},
|
||||
})
|
||||
mockLoadProjectAgents.mockReturnValue({})
|
||||
|
||||
}))
|
||||
const args = createBaseArgs({ subagent_type: "my-primary-agent" })
|
||||
const executorCtx = createExecutorContext(async () => [])
|
||||
|
||||
@@ -684,3 +827,123 @@ describe("resolveSubagentExecution", () => {
|
||||
expect(result.agentToUse).toBe("")
|
||||
})
|
||||
})
|
||||
|
||||
describe("resolveSubagentExecution - agent name sanitization", () => {
|
||||
let resolveSubagentExecution: SubagentResolverModule["resolveSubagentExecution"]
|
||||
|
||||
beforeEach(async () => {
|
||||
mock.restore()
|
||||
logMock.mockClear()
|
||||
readConnectedProvidersCacheMock.mockReset()
|
||||
readProviderModelsCacheMock.mockReset()
|
||||
readConnectedProvidersCacheMock.mockReturnValue(null)
|
||||
readProviderModelsCacheMock.mockReturnValue(null)
|
||||
loadUserAgentsMock.mockReset()
|
||||
loadProjectAgentsMock.mockReset()
|
||||
loadUserAgentsMock.mockImplementation(() => ({}))
|
||||
loadProjectAgentsMock.mockImplementation(() => ({}))
|
||||
mock.module("../../../shared/logger", () => ({
|
||||
log: logMock,
|
||||
}))
|
||||
mock.module("../../../shared/connected-providers-cache", () => ({
|
||||
readConnectedProvidersCache: readConnectedProvidersCacheMock,
|
||||
readProviderModelsCache: readProviderModelsCacheMock,
|
||||
hasConnectedProvidersCache: () => readConnectedProvidersCacheMock() !== null,
|
||||
hasProviderModelsCache: () => readProviderModelsCacheMock() !== null,
|
||||
_resetMemCacheForTesting: () => {},
|
||||
}))
|
||||
mock.module("../../../features/claude-code-agent-loader/loader", () => ({
|
||||
loadUserAgents: loadUserAgentsMock,
|
||||
loadProjectAgents: loadProjectAgentsMock,
|
||||
}))
|
||||
mock.module("../../../features/claude-code-agent-loader", () => ({
|
||||
loadUserAgents: loadUserAgentsMock,
|
||||
loadProjectAgents: loadProjectAgentsMock,
|
||||
}))
|
||||
;({ resolveSubagentExecution } = await importFreshSubagentResolverModule())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
test("strips backslash-wrapped agent names like \\hephaestus\\", async () => {
|
||||
//#given
|
||||
readProviderModelsCacheMock.mockReturnValue({
|
||||
models: {},
|
||||
connected: [],
|
||||
updatedAt: "2026-03-03T00:00:00.000Z",
|
||||
})
|
||||
const args = createBaseArgs({ subagent_type: "\\hephaestus\\" })
|
||||
const executorCtx = createExecutorContext(async () => ([
|
||||
{ name: "Hephaestus - Deep Agent", mode: "subagent", model: "openai/gpt-5.3-codex" },
|
||||
]))
|
||||
|
||||
//#when
|
||||
const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep")
|
||||
|
||||
//#then
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.agentToUse).toBe("Hephaestus - Deep Agent")
|
||||
})
|
||||
|
||||
test("strips double-quoted agent names", async () => {
|
||||
//#given
|
||||
readProviderModelsCacheMock.mockReturnValue({
|
||||
models: {},
|
||||
connected: [],
|
||||
updatedAt: "2026-03-03T00:00:00.000Z",
|
||||
})
|
||||
const args = createBaseArgs({ subagent_type: '"oracle"' })
|
||||
const executorCtx = createExecutorContext(async () => ([
|
||||
{ name: "oracle", mode: "subagent" },
|
||||
]))
|
||||
|
||||
//#when
|
||||
const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep")
|
||||
|
||||
//#then
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.agentToUse).toBe("oracle")
|
||||
})
|
||||
|
||||
test("strips single-quoted agent names", async () => {
|
||||
//#given
|
||||
readProviderModelsCacheMock.mockReturnValue({
|
||||
models: {},
|
||||
connected: [],
|
||||
updatedAt: "2026-03-03T00:00:00.000Z",
|
||||
})
|
||||
const args = createBaseArgs({ subagent_type: "'explore'" })
|
||||
const executorCtx = createExecutorContext(async () => ([
|
||||
{ name: "explore", mode: "subagent" },
|
||||
]))
|
||||
|
||||
//#when
|
||||
const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep")
|
||||
|
||||
//#then
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.agentToUse).toBe("explore")
|
||||
})
|
||||
|
||||
test("matches runtime agent names that include invisible sort prefixes", async () => {
|
||||
//#given
|
||||
readProviderModelsCacheMock.mockReturnValue({
|
||||
models: {},
|
||||
connected: [],
|
||||
updatedAt: "2026-03-03T00:00:00.000Z",
|
||||
})
|
||||
const args = createBaseArgs({ subagent_type: "Sisyphus - Ultraworker" })
|
||||
const executorCtx = createExecutorContext(async () => ([
|
||||
{ name: "\u200BSisyphus - Ultraworker", mode: "subagent", model: "openai/gpt-5.3-codex" },
|
||||
]))
|
||||
|
||||
//#when
|
||||
const result = await resolveSubagentExecution(args, executorCtx, "oracle", "deep")
|
||||
|
||||
//#then
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.agentToUse).toBe("Sisyphus - Ultraworker")
|
||||
})
|
||||
})
|
||||
+15
-8
@@ -1,6 +1,7 @@
|
||||
import { spawn } from "bun"
|
||||
import {
|
||||
resolveGrepCli,
|
||||
type ResolvedCli,
|
||||
type GrepBackend,
|
||||
DEFAULT_MAX_DEPTH,
|
||||
DEFAULT_MAX_FILESIZE,
|
||||
@@ -148,17 +149,17 @@ function parseCountOutput(output: string): CountResult[] {
|
||||
return results
|
||||
}
|
||||
|
||||
export async function runRg(options: GrepOptions): Promise<GrepResult> {
|
||||
export async function runRg(options: GrepOptions, resolvedCli?: ResolvedCli): Promise<GrepResult> {
|
||||
await rgSemaphore.acquire()
|
||||
try {
|
||||
return await runRgInternal(options)
|
||||
return await runRgInternal(options, resolvedCli)
|
||||
} finally {
|
||||
rgSemaphore.release()
|
||||
}
|
||||
}
|
||||
|
||||
async function runRgInternal(options: GrepOptions): Promise<GrepResult> {
|
||||
const cli = resolveGrepCli()
|
||||
async function runRgInternal(options: GrepOptions, resolvedCli?: ResolvedCli): Promise<GrepResult> {
|
||||
const cli = resolvedCli ?? resolveGrepCli()
|
||||
const args = buildArgs(options, cli.backend)
|
||||
const timeout = Math.min(options.timeout ?? DEFAULT_TIMEOUT_MS, DEFAULT_TIMEOUT_MS)
|
||||
|
||||
@@ -224,17 +225,23 @@ async function runRgInternal(options: GrepOptions): Promise<GrepResult> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function runRgCount(options: Omit<GrepOptions, "context">): Promise<CountResult[]> {
|
||||
export async function runRgCount(
|
||||
options: Omit<GrepOptions, "context">,
|
||||
resolvedCli?: ResolvedCli
|
||||
): Promise<CountResult[]> {
|
||||
await rgSemaphore.acquire()
|
||||
try {
|
||||
return await runRgCountInternal(options)
|
||||
return await runRgCountInternal(options, resolvedCli)
|
||||
} finally {
|
||||
rgSemaphore.release()
|
||||
}
|
||||
}
|
||||
|
||||
async function runRgCountInternal(options: Omit<GrepOptions, "context">): Promise<CountResult[]> {
|
||||
const cli = resolveGrepCli()
|
||||
async function runRgCountInternal(
|
||||
options: Omit<GrepOptions, "context">,
|
||||
resolvedCli?: ResolvedCli
|
||||
): Promise<CountResult[]> {
|
||||
const cli = resolvedCli ?? resolveGrepCli()
|
||||
const args = buildArgs({ ...options, context: 0 }, cli.backend)
|
||||
|
||||
if (cli.backend === "rg") {
|
||||
|
||||
@@ -3,10 +3,12 @@ import { join, dirname } from "node:path"
|
||||
import { spawnSync } from "node:child_process"
|
||||
import { getInstalledRipgrepPath, downloadAndInstallRipgrep } from "./downloader"
|
||||
import { getDataDir } from "../../shared/data-path"
|
||||
import { log } from "../../shared/logger"
|
||||
import { PUBLISHED_PACKAGE_NAME } from "../../shared/plugin-identity"
|
||||
|
||||
export type GrepBackend = "rg" | "grep"
|
||||
|
||||
interface ResolvedCli {
|
||||
export interface ResolvedCli {
|
||||
path: string
|
||||
backend: GrepBackend
|
||||
}
|
||||
@@ -89,7 +91,7 @@ export function resolveGrepCli(): ResolvedCli {
|
||||
export async function resolveGrepCliWithAutoInstall(): Promise<ResolvedCli> {
|
||||
const current = resolveGrepCli()
|
||||
|
||||
if (current.backend === "rg") {
|
||||
if (current.backend === "rg" && current.path !== "rg") {
|
||||
return current
|
||||
}
|
||||
|
||||
@@ -103,7 +105,18 @@ export async function resolveGrepCliWithAutoInstall(): Promise<ResolvedCli> {
|
||||
const rgPath = await downloadAndInstallRipgrep()
|
||||
cachedCli = { path: rgPath, backend: "rg" }
|
||||
return cachedCli
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (current.backend === "grep") {
|
||||
log(`[${PUBLISHED_PACKAGE_NAME}] Failed to auto-install ripgrep. Falling back to GNU grep.`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
grep_path: current.path,
|
||||
})
|
||||
} else {
|
||||
log(`[${PUBLISHED_PACKAGE_NAME}] Failed to auto-install ripgrep and GNU grep was not found.`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
|
||||
return current
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { existsSync, readdirSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { extractZip as extractZipBase } from "../../shared"
|
||||
import { CACHE_DIR_NAME } from "../../shared/plugin-identity"
|
||||
import {
|
||||
cleanupArchive,
|
||||
downloadArchive,
|
||||
@@ -39,7 +40,7 @@ function getPlatformKey(): string {
|
||||
|
||||
function getInstallDir(): string {
|
||||
const homeDir = process.env.HOME || process.env.USERPROFILE || "."
|
||||
return join(homeDir, ".cache", "oh-my-opencode", "bin")
|
||||
return join(homeDir, ".cache", CACHE_DIR_NAME, "bin")
|
||||
}
|
||||
|
||||
function getRgPath(): string {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { resolve } from "node:path"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
|
||||
import { runRg, runRgCount } from "./cli"
|
||||
import { resolveGrepCliWithAutoInstall } from "./constants"
|
||||
import { formatGrepResult, formatCountResult } from "./result-formatter"
|
||||
|
||||
export function createGrepTools(ctx: PluginInput): Record<string, ToolDefinition> {
|
||||
@@ -42,13 +43,14 @@ export function createGrepTools(ctx: PluginInput): Record<string, ToolDefinition
|
||||
const paths = [searchPath]
|
||||
const outputMode = args.output_mode ?? "files_with_matches"
|
||||
const headLimit = args.head_limit ?? 0
|
||||
const cli = await resolveGrepCliWithAutoInstall()
|
||||
|
||||
if (outputMode === "count") {
|
||||
const results = await runRgCount({
|
||||
pattern: args.pattern,
|
||||
paths,
|
||||
globs,
|
||||
})
|
||||
}, cli)
|
||||
const limited = headLimit > 0 ? results.slice(0, headLimit) : results
|
||||
return formatCountResult(limited)
|
||||
}
|
||||
@@ -60,7 +62,7 @@ export function createGrepTools(ctx: PluginInput): Record<string, ToolDefinition
|
||||
context: 0,
|
||||
outputMode,
|
||||
headLimit,
|
||||
})
|
||||
}, cli)
|
||||
|
||||
return formatGrepResult(result)
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# src/tools/hashline-edit/ — Hash-Anchored File Edit Tool
|
||||
|
||||
**Generated:** 2026-03-06
|
||||
**Generated:** 2026-04-11
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
|
||||
@@ -227,10 +227,10 @@ describe("hashline edit operations", () => {
|
||||
})
|
||||
|
||||
it("preserves blank lines and indentation in range replace (no false unwrap)", () => {
|
||||
//#given — reproduces the 애국가 bug where blank+indented lines collapse
|
||||
//#given, reproduces the 애국가 bug where blank+indented lines collapse
|
||||
const lines = ["", "동해물과 백두산이 마르고 닳도록", "하느님이 보우하사 우리나라 만세", "", "무궁화 삼천리 화려강산", "대한사람 대한으로 길이 보전하세", ""]
|
||||
|
||||
//#when — replace the range with indented version (blank lines preserved)
|
||||
//#when, replace the range with indented version (blank lines preserved)
|
||||
const result = applyReplaceLines(
|
||||
lines,
|
||||
anchorFor(lines, 1),
|
||||
@@ -238,7 +238,7 @@ describe("hashline edit operations", () => {
|
||||
["", " 동해물과 백두산이 마르고 닳도록", " 하느님이 보우하사 우리나라 만세", "", " 무궁화 삼천리 화려강산", " 대한사람 대한으로 길이 보전하세", ""]
|
||||
)
|
||||
|
||||
//#then — all 7 lines preserved with indentation, not collapsed to 3
|
||||
//#then, all 7 lines preserved with indentation, not collapsed to 3
|
||||
expect(result).toEqual(["", " 동해물과 백두산이 마르고 닳도록", " 하느님이 보우하사 우리나라 만세", "", " 무궁화 삼천리 화려강산", " 대한사람 대한으로 길이 보전하세", ""])
|
||||
})
|
||||
|
||||
|
||||
@@ -350,10 +350,10 @@ describe("runFormattersForFile", () => {
|
||||
},
|
||||
})
|
||||
|
||||
//#when — run for a .go file, but only .ts formatters registered
|
||||
//#when, run for a .go file, but only .ts formatters registered
|
||||
await runFormattersForFile(client, "/project", "/src/main.go")
|
||||
|
||||
//#then — no error thrown
|
||||
//#then, no error thrown
|
||||
})
|
||||
|
||||
it("runs formatter for matching extension", async () => {
|
||||
@@ -367,10 +367,10 @@ describe("runFormattersForFile", () => {
|
||||
},
|
||||
})
|
||||
|
||||
//#when — echo is a safe no-op command
|
||||
//#when, echo is a safe no-op command
|
||||
await runFormattersForFile(client, "/tmp", "/tmp/test.ts")
|
||||
|
||||
//#then — should complete without error
|
||||
//#then, should complete without error
|
||||
expect(client.config.get).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,8 +8,8 @@ WORKFLOW:
|
||||
5. Use anchors as "LINE#ID" only (never include trailing "|content").
|
||||
|
||||
<must>
|
||||
- SNAPSHOT: All edits in one call reference the ORIGINAL file state. Do NOT adjust line numbers for prior edits in the same call — the system applies them bottom-up automatically.
|
||||
- replace removes lines pos..end (inclusive) and inserts lines in their place. Lines BEFORE pos and AFTER end are UNTOUCHED — do NOT include them in lines. If you do, they will appear twice.
|
||||
- SNAPSHOT: All edits in one call reference the ORIGINAL file state. Do NOT adjust line numbers for prior edits in the same call - the system applies them bottom-up automatically.
|
||||
- replace removes lines pos..end (inclusive) and inserts lines in their place. Lines BEFORE pos and AFTER end are UNTOUCHED - do NOT include them in lines. If you do, they will appear twice.
|
||||
- lines must contain ONLY the content that belongs inside the consumed range. Content after end survives unchanged.
|
||||
- Tags MUST be copied exactly from read output or >>> mismatch output. NEVER guess tags.
|
||||
- Batch = multiple operations in edits[], NOT one big replace covering everything. Each operation targets the smallest possible change.
|
||||
@@ -75,7 +75,7 @@ Insert after line 13 (between functions):
|
||||
{ op: "append", pos: "13#QR", lines: ["", "function added() {", " return true;", "}"] }
|
||||
Result: 4 new lines inserted after line 13. All existing lines unchanged.
|
||||
|
||||
BAD — lines extend past end (DUPLICATES line 13):
|
||||
BAD - lines extend past end (DUPLICATES line 13):
|
||||
{ op: "replace", pos: "11#XJ", end: "12#MB", lines: [" return \\"hi\\";", "}"] }
|
||||
Line 13 is "}" which already exists after end. Including "}" in lines duplicates it.
|
||||
CORRECT: { op: "replace", pos: "11#XJ", end: "12#MB", lines: [" return \\"hi\\";"] }
|
||||
|
||||
@@ -23,10 +23,10 @@ describe("parseLineRef", () => {
|
||||
})
|
||||
|
||||
it("gives specific hint when literal text is used instead of line number", () => {
|
||||
//#given — model sends "LINE#HK" instead of "1#HK"
|
||||
//#given, model sends "LINE#HK" instead of "1#HK"
|
||||
const ref = "LINE#HK"
|
||||
|
||||
//#when / #then — error should mention that LINE is not a valid number
|
||||
//#when / #then, error should mention that LINE is not a valid number
|
||||
expect(() => parseLineRef(ref)).toThrow(/not a line number/i)
|
||||
})
|
||||
|
||||
@@ -39,10 +39,10 @@ describe("parseLineRef", () => {
|
||||
})
|
||||
|
||||
it("extracts valid line number from mixed prefix like LINE42 without throwing", () => {
|
||||
//#given — normalizeLineRef extracts 42#VK from LINE42#VK
|
||||
//#given, normalizeLineRef extracts 42#VK from LINE42#VK
|
||||
const ref = "LINE42#VK"
|
||||
|
||||
//#when / #then — should parse successfully as line 42
|
||||
//#when / #then, should parse successfully as line 42
|
||||
const result = parseLineRef(ref)
|
||||
expect(result.line).toBe(42)
|
||||
expect(result.hash).toBe("VK")
|
||||
@@ -144,11 +144,11 @@ describe("validateLineRef", () => {
|
||||
})
|
||||
|
||||
it("suggests correct line number when hash matches a file line", () => {
|
||||
//#given — model sends LINE#XX where XX is the actual hash for line 1
|
||||
//#given, model sends LINE#XX where XX is the actual hash for line 1
|
||||
const lines = ["function hello() {", " return 42", "}"]
|
||||
const hash = computeLineHash(1, lines[0])
|
||||
|
||||
//#when / #then — error should suggest the correct reference
|
||||
//#when / #then, error should suggest the correct reference
|
||||
expect(() => validateLineRefs(lines, [`LINE#${hash}`])).toThrow(new RegExp(`1#${hash}`))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -48,7 +48,6 @@ export function parseLineRef(ref: string): LineRef {
|
||||
hash: match[2],
|
||||
}
|
||||
}
|
||||
// normalized equals ref.trim() in all error paths — extraction only succeeds for valid refs
|
||||
const hashIdx = normalized.indexOf('#')
|
||||
if (hashIdx > 0) {
|
||||
const prefix = normalized.slice(0, hashIdx)
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export const MULTIMODAL_LOOKER_AGENT = "multimodal-looker" as const
|
||||
|
||||
export const LOOK_AT_DESCRIPTION = `Extract basic information from media files (PDFs, images, diagrams) when a quick summary suffices over precise reading. Good for simple text-based content extraction without using the Read tool. NEVER use for visual precision, aesthetic evaluation, or exact accuracy — use Read tool instead for those cases.`
|
||||
export const LOOK_AT_DESCRIPTION = `Extract basic information from media files (PDFs, images, diagrams) when a quick summary suffices over precise reading. Good for simple text-based content extraction without using the Read tool. NEVER use for visual precision, aesthetic evaluation, or exact accuracy - use Read tool instead for those cases.`
|
||||
|
||||
@@ -659,4 +659,112 @@ describe("look-at tool", () => {
|
||||
expect(filePart.url).toContain("base64")
|
||||
})
|
||||
})
|
||||
|
||||
describe("createLookAt prompt conditional on Read availability", () => {
|
||||
const captureLastPromptBody = () => {
|
||||
const captured: { body: any } = { body: undefined }
|
||||
const mockClient = {
|
||||
app: {
|
||||
agents: async () => ({ data: [] }),
|
||||
},
|
||||
session: {
|
||||
get: async () => ({ data: { directory: "/project" } }),
|
||||
create: async () => ({ data: { id: "ses_prompt_conditional" } }),
|
||||
prompt: async (input: any) => {
|
||||
captured.body = input.body
|
||||
return { data: {} }
|
||||
},
|
||||
messages: async () => ({
|
||||
data: [
|
||||
{ info: { role: "assistant", time: { created: 1 } }, parts: [{ type: "text", text: "ok" }] },
|
||||
],
|
||||
}),
|
||||
},
|
||||
}
|
||||
return { mockClient, captured }
|
||||
}
|
||||
|
||||
const buildToolContext = (): ToolContext => ({
|
||||
sessionID: "parent-session",
|
||||
messageID: "parent-message",
|
||||
agent: "sisyphus",
|
||||
directory: "/project",
|
||||
worktree: "/project",
|
||||
abort: new AbortController().signal,
|
||||
metadata: () => {},
|
||||
ask: async () => {},
|
||||
})
|
||||
|
||||
// given file_path mode where Read tool is disabled in invocation
|
||||
// when LookAt tool sends prompt to multimodal-looker
|
||||
// then prompt instructs agent to analyze the attached file directly without using Read
|
||||
test("instructs agent to analyze attached file when Read is disabled (file_path mode)", async () => {
|
||||
const { mockClient, captured } = captureLastPromptBody()
|
||||
|
||||
const tool = createLookAt({
|
||||
client: mockClient,
|
||||
directory: "/project",
|
||||
} as any)
|
||||
|
||||
await tool.execute(
|
||||
{ file_path: "/test/file.png", goal: "describe contents" },
|
||||
buildToolContext(),
|
||||
)
|
||||
|
||||
expect(captured.body.tools.read).toBe(false)
|
||||
const promptPart = captured.body.parts.find((p: any) => p.type === "text")
|
||||
expect(promptPart).toBeDefined()
|
||||
const promptText: string = promptPart.text
|
||||
expect(promptText).toContain("attached")
|
||||
expect(promptText).not.toMatch(/\bRead\s+(?:the\s+)?file\b/i)
|
||||
expect(promptText).not.toMatch(/\buse\s+Read\b/i)
|
||||
})
|
||||
|
||||
// given image_data mode where no file path exists and Read is disabled
|
||||
// when LookAt tool sends prompt to multimodal-looker
|
||||
// then prompt instructs agent to analyze the attached image directly without referencing Read or file path
|
||||
test("instructs agent to analyze attached image when image_data is provided", async () => {
|
||||
const { mockClient, captured } = captureLastPromptBody()
|
||||
|
||||
const tool = createLookAt({
|
||||
client: mockClient,
|
||||
directory: "/project",
|
||||
} as any)
|
||||
|
||||
await tool.execute(
|
||||
{ image_data: "data:image/png;base64,iVBORw0KGgo=", goal: "describe image" },
|
||||
buildToolContext(),
|
||||
)
|
||||
|
||||
expect(captured.body.tools.read).toBe(false)
|
||||
const promptPart = captured.body.parts.find((p: any) => p.type === "text")
|
||||
expect(promptPart).toBeDefined()
|
||||
const promptText: string = promptPart.text
|
||||
expect(promptText).toContain("attached")
|
||||
expect(promptText).not.toMatch(/\bRead\s+(?:the\s+)?file\b/i)
|
||||
expect(promptText).not.toMatch(/\buse\s+Read\b/i)
|
||||
})
|
||||
|
||||
// given prompt is generated for any invocation where Read is denied
|
||||
// when LookAt tool sends prompt to multimodal-looker
|
||||
// then prompt explicitly tells the agent NOT to attempt Read tool
|
||||
test("explicitly warns the agent not to attempt Read when Read is disabled", async () => {
|
||||
const { mockClient, captured } = captureLastPromptBody()
|
||||
|
||||
const tool = createLookAt({
|
||||
client: mockClient,
|
||||
directory: "/project",
|
||||
} as any)
|
||||
|
||||
await tool.execute(
|
||||
{ file_path: "/test/file.pdf", goal: "extract text" },
|
||||
buildToolContext(),
|
||||
)
|
||||
|
||||
const promptPart = captured.body.parts.find((p: any) => p.type === "text")
|
||||
const promptText: string = promptPart.text
|
||||
// The prompt must mention the agent cannot use Read so the agent does not hallucinate
|
||||
expect(promptText.toLowerCase()).toContain("read tool")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -129,7 +129,15 @@ export function createLookAt(ctx: PluginInput): ToolDefinition {
|
||||
return "Error: Must provide either 'file_path' or 'image_data'."
|
||||
}
|
||||
|
||||
const prompt = `Analyze this ${isBase64Input ? "image" : "file"} and extract the requested information.
|
||||
const readEnabled = false
|
||||
const subjectNoun = isBase64Input ? "image" : "file"
|
||||
const sourceClause = readEnabled
|
||||
? `Use the Read tool on the provided file path to load its contents, then analyze it.`
|
||||
: `The ${subjectNoun} is already attached to this message. Analyze it directly from the attachment. Do NOT attempt to use the Read tool. The Read tool is disabled for this invocation and the ${subjectNoun} cannot be loaded by path.`
|
||||
|
||||
const prompt = `Analyze the attached ${subjectNoun} and extract the requested information.
|
||||
|
||||
${sourceClause}
|
||||
|
||||
Goal: ${args.goal}
|
||||
|
||||
@@ -182,7 +190,7 @@ Original error: ${createResult.error}`
|
||||
task: false,
|
||||
call_omo_agent: false,
|
||||
look_at: false,
|
||||
read: false,
|
||||
read: readEnabled,
|
||||
},
|
||||
parts: [
|
||||
{ type: "text", text: prompt },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# src/tools/lsp/ — LSP Tool Implementations
|
||||
|
||||
**Generated:** 2026-03-06
|
||||
**Generated:** 2026-04-11
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { tmpdir } from "node:os"
|
||||
|
||||
import { describe, it, expect, spyOn, mock, beforeEach, afterEach } from "bun:test"
|
||||
import { describe, it, expect, spyOn, mock, beforeEach, afterEach, afterAll } from "bun:test"
|
||||
|
||||
mock.module("vscode-jsonrpc/node", () => ({
|
||||
createMessageConnection: () => {
|
||||
@@ -12,6 +12,8 @@ mock.module("vscode-jsonrpc/node", () => ({
|
||||
StreamMessageWriter: function StreamMessageWriter() {},
|
||||
}))
|
||||
|
||||
afterAll(() => { mock.restore() })
|
||||
|
||||
import { LSPClient, lspManager, validateCwd } from "./client"
|
||||
import type { ResolvedServer } from "./types"
|
||||
|
||||
|
||||
@@ -20,8 +20,7 @@ describe("isServerInstalled", () => {
|
||||
afterEach(() => {
|
||||
try {
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
} catch (e) {
|
||||
// cleanup failed — ignored
|
||||
} catch {
|
||||
}
|
||||
|
||||
if (process.platform === "win32") {
|
||||
|
||||
@@ -4,57 +4,42 @@ import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
|
||||
|
||||
import { DEFAULT_MAX_DIAGNOSTICS } from "./constants"
|
||||
import { aggregateDiagnosticsForDirectory } from "./directory-diagnostics"
|
||||
import { inferExtensionFromDirectory } from "./infer-extension"
|
||||
import { filterDiagnosticsBySeverity, formatDiagnostic } from "./lsp-formatters"
|
||||
import { isDirectoryPath, withLspClient } from "./lsp-client-wrapper"
|
||||
import type { Diagnostic } from "./types"
|
||||
|
||||
export const lsp_diagnostics: ToolDefinition = tool({
|
||||
description:
|
||||
'Get errors, warnings, hints from language server BEFORE running build. Use filePath for a single file, or filePath with extension for a directory. Do NOT pass both filePath and directory — use filePath for everything.',
|
||||
'Get errors, warnings, hints from language server BEFORE running build. Works for both single files and directories - file extension is auto-detected for directories.',
|
||||
args: {
|
||||
filePath: tool.schema
|
||||
.string()
|
||||
.optional()
|
||||
.describe("File or directory path to check diagnostics for"),
|
||||
directory: tool.schema
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Alias for filePath when checking a directory. Do NOT provide both filePath and directory."),
|
||||
severity: tool.schema
|
||||
.enum(["error", "warning", "information", "hint", "all"])
|
||||
.optional()
|
||||
.describe("Filter by severity level"),
|
||||
extension: tool.schema
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Required if target is a directory. E.g., '.ts', '.py', '.go', '.java'"),
|
||||
},
|
||||
execute: async (args, _context) => {
|
||||
try {
|
||||
// Accept either filePath or directory (treat directory as alias for filePath)
|
||||
const targetPath = args.filePath || args.directory
|
||||
if (!targetPath) {
|
||||
throw new Error("Provide either 'filePath' or 'directory' parameter.")
|
||||
if (!args.filePath) {
|
||||
throw new Error("'filePath' parameter is required.")
|
||||
}
|
||||
if (args.filePath && args.directory) {
|
||||
// Instead of erroring, just use filePath and ignore directory
|
||||
// This prevents model confusion from causing hard failures
|
||||
}
|
||||
const absPath = resolve(targetPath)
|
||||
const absPath = resolve(args.filePath)
|
||||
|
||||
if (isDirectoryPath(absPath)) {
|
||||
if (!args.extension) {
|
||||
const extension = inferExtensionFromDirectory(absPath)
|
||||
if (!extension) {
|
||||
throw new Error(
|
||||
`Directory path requires 'extension' parameter.\n\n` +
|
||||
`Example: lsp_diagnostics(filePath="src", extension=".ts")\n\n` +
|
||||
`Supported extensions: .ts, .tsx, .js, .py, .go, etc.`
|
||||
`No supported source files found in directory: ${absPath}`
|
||||
)
|
||||
}
|
||||
return await aggregateDiagnosticsForDirectory(absPath, args.extension, args.severity)
|
||||
return await aggregateDiagnosticsForDirectory(absPath, extension, args.severity)
|
||||
}
|
||||
|
||||
const result = await withLspClient(targetPath, async (client) => {
|
||||
return (await client.diagnostics(targetPath)) as { items?: Diagnostic[] } | Diagnostic[] | null
|
||||
const result = await withLspClient(args.filePath, async (client) => {
|
||||
return (await client.diagnostics(args.filePath)) as { items?: Diagnostic[] } | Diagnostic[] | null
|
||||
})
|
||||
|
||||
let diagnostics: Diagnostic[] = []
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test"
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs"
|
||||
import { join } from "path"
|
||||
import os from "os"
|
||||
|
||||
import { inferExtensionFromDirectory } from "./infer-extension"
|
||||
|
||||
describe("inferExtensionFromDirectory", () => {
|
||||
let tmpDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(os.tmpdir(), "omo-infer-ext-"))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe("#given a directory with TypeScript files", () => {
|
||||
beforeEach(() => {
|
||||
writeFileSync(join(tmpDir, "index.ts"), "export const a = 1")
|
||||
writeFileSync(join(tmpDir, "utils.ts"), "export const b = 2")
|
||||
writeFileSync(join(tmpDir, "app.tsx"), "export const c = 3")
|
||||
})
|
||||
|
||||
describe("#when inferring extension", () => {
|
||||
it("#then returns .ts as the most common extension", () => {
|
||||
const result = inferExtensionFromDirectory(tmpDir)
|
||||
expect(result).toBe(".ts")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given a directory with mixed file types where Python dominates", () => {
|
||||
beforeEach(() => {
|
||||
writeFileSync(join(tmpDir, "main.py"), "x = 1")
|
||||
writeFileSync(join(tmpDir, "utils.py"), "y = 2")
|
||||
writeFileSync(join(tmpDir, "helper.py"), "z = 3")
|
||||
writeFileSync(join(tmpDir, "config.ts"), "export default {}")
|
||||
})
|
||||
|
||||
describe("#when inferring extension", () => {
|
||||
it("#then returns .py as the most common extension", () => {
|
||||
const result = inferExtensionFromDirectory(tmpDir)
|
||||
expect(result).toBe(".py")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given an empty directory", () => {
|
||||
describe("#when inferring extension", () => {
|
||||
it("#then returns null", () => {
|
||||
const result = inferExtensionFromDirectory(tmpDir)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given a directory with only unsupported files", () => {
|
||||
beforeEach(() => {
|
||||
writeFileSync(join(tmpDir, "data.csv"), "a,b,c")
|
||||
writeFileSync(join(tmpDir, "image.png"), "fake")
|
||||
})
|
||||
|
||||
describe("#when inferring extension", () => {
|
||||
it("#then returns null", () => {
|
||||
const result = inferExtensionFromDirectory(tmpDir)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given a directory with nested subdirectories", () => {
|
||||
beforeEach(() => {
|
||||
writeFileSync(join(tmpDir, "root.go"), "package main")
|
||||
const sub = join(tmpDir, "pkg")
|
||||
mkdirSync(sub)
|
||||
writeFileSync(join(sub, "handler.go"), "package pkg")
|
||||
writeFileSync(join(sub, "model.go"), "package pkg")
|
||||
})
|
||||
|
||||
describe("#when inferring extension", () => {
|
||||
it("#then counts files recursively", () => {
|
||||
const result = inferExtensionFromDirectory(tmpDir)
|
||||
expect(result).toBe(".go")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given a directory with node_modules", () => {
|
||||
beforeEach(() => {
|
||||
writeFileSync(join(tmpDir, "index.ts"), "export {}")
|
||||
const nm = join(tmpDir, "node_modules", "pkg")
|
||||
mkdirSync(nm, { recursive: true })
|
||||
writeFileSync(join(nm, "a.js"), "module.exports = {}")
|
||||
writeFileSync(join(nm, "b.js"), "module.exports = {}")
|
||||
writeFileSync(join(nm, "c.js"), "module.exports = {}")
|
||||
})
|
||||
|
||||
describe("#when inferring extension", () => {
|
||||
it("#then skips node_modules and returns .ts", () => {
|
||||
const result = inferExtensionFromDirectory(tmpDir)
|
||||
expect(result).toBe(".ts")
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
import { readdirSync, lstatSync } from "fs"
|
||||
import { extname, join } from "path"
|
||||
|
||||
import { EXT_TO_LANG } from "./language-mappings"
|
||||
|
||||
const SKIP_DIRECTORIES = new Set(["node_modules", ".git", "dist", "build", ".next", "out"])
|
||||
const MAX_SCAN_ENTRIES = 500
|
||||
|
||||
export function inferExtensionFromDirectory(directory: string): string | null {
|
||||
const extensionCounts = new Map<string, number>()
|
||||
let scanned = 0
|
||||
|
||||
function walk(dir: string): void {
|
||||
if (scanned >= MAX_SCAN_ENTRIES) return
|
||||
|
||||
let entries: string[]
|
||||
try {
|
||||
entries = readdirSync(dir)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (scanned >= MAX_SCAN_ENTRIES) return
|
||||
|
||||
const fullPath = join(dir, entry)
|
||||
|
||||
let stat: ReturnType<typeof lstatSync> | undefined
|
||||
try {
|
||||
stat = lstatSync(fullPath)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
if (stat.isSymbolicLink()) continue
|
||||
scanned++
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
if (!SKIP_DIRECTORIES.has(entry)) {
|
||||
walk(fullPath)
|
||||
}
|
||||
} else if (stat.isFile()) {
|
||||
const ext = extname(fullPath)
|
||||
if (ext && ext in EXT_TO_LANG) {
|
||||
extensionCounts.set(ext, (extensionCounts.get(ext) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(directory)
|
||||
|
||||
if (extensionCounts.size === 0) return null
|
||||
|
||||
let maxExt = ""
|
||||
let maxCount = 0
|
||||
for (const [ext, count] of extensionCounts) {
|
||||
if (count > maxCount) {
|
||||
maxCount = count
|
||||
maxExt = ext
|
||||
}
|
||||
}
|
||||
|
||||
return maxExt || null
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { existsSync, statSync } from "fs"
|
||||
import { LSPClient, lspManager } from "./client"
|
||||
import { findServerForExtension } from "./config"
|
||||
import type { ServerLookupResult } from "./types"
|
||||
import { CONFIG_BASENAME } from "../../shared/plugin-identity"
|
||||
|
||||
export function isDirectoryPath(filePath: string): boolean {
|
||||
if (!existsSync(filePath)) {
|
||||
@@ -63,7 +64,7 @@ export function formatServerLookupError(result: Exclude<ServerLookupResult, { st
|
||||
``,
|
||||
`Available servers: ${result.availableServers.slice(0, 10).join(", ")}${result.availableServers.length > 10 ? "..." : ""}`,
|
||||
``,
|
||||
`To add a custom server, configure 'lsp' in oh-my-opencode.json:`,
|
||||
`To add a custom server, configure 'lsp' in ${CONFIG_BASENAME}.json:`,
|
||||
` {`,
|
||||
` "lsp": {`,
|
||||
` "my-server": {`,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
type ManagedClientForCleanup = {
|
||||
client: {
|
||||
stop: () => Promise<void>;
|
||||
@@ -22,23 +24,32 @@ export type LspProcessCleanupHandle = {
|
||||
export function registerLspManagerProcessCleanup(options: ProcessCleanupOptions): LspProcessCleanupHandle {
|
||||
const handlers: RegisteredHandler[] = [];
|
||||
|
||||
// Synchronous cleanup for 'exit' event (cannot await)
|
||||
const logCleanupError = (phase: string, error: unknown): void => {
|
||||
log(`[lsp-manager-process-cleanup] ${phase}`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
};
|
||||
|
||||
const syncCleanup = () => {
|
||||
for (const [, managed] of options.getClients()) {
|
||||
try {
|
||||
// Fire-and-forget during sync exit - process is terminating
|
||||
void managed.client.stop().catch(() => {});
|
||||
} catch {}
|
||||
void managed.client.stop().catch((error) => {
|
||||
logCleanupError("stop failed during exit cleanup", error);
|
||||
});
|
||||
} catch (error) {
|
||||
logCleanupError("failed to schedule exit cleanup", error);
|
||||
}
|
||||
}
|
||||
options.clearClients();
|
||||
options.clearCleanupInterval();
|
||||
};
|
||||
|
||||
// Async cleanup for signal handlers - properly await all stops
|
||||
const asyncCleanup = async () => {
|
||||
const stopPromises: Promise<void>[] = [];
|
||||
for (const [, managed] of options.getClients()) {
|
||||
stopPromises.push(managed.client.stop().catch(() => {}));
|
||||
stopPromises.push(managed.client.stop().catch((error) => {
|
||||
logCleanupError("stop failed during signal cleanup", error);
|
||||
}));
|
||||
}
|
||||
await Promise.allSettled(stopPromises);
|
||||
options.clearClients();
|
||||
@@ -52,8 +63,9 @@ export function registerLspManagerProcessCleanup(options: ProcessCleanupOptions)
|
||||
|
||||
registerHandler("exit", syncCleanup);
|
||||
|
||||
// Don't call process.exit() here; other handlers (background-agent manager) handle final exit.
|
||||
const signalCleanup = () => void asyncCleanup().catch(() => {});
|
||||
const signalCleanup = () => void asyncCleanup().catch((error) => {
|
||||
logCleanupError("signal cleanup failed", error);
|
||||
});
|
||||
registerHandler("SIGINT", signalCleanup);
|
||||
registerHandler("SIGTERM", signalCleanup);
|
||||
if (process.platform === "win32") {
|
||||
|
||||
@@ -2,11 +2,9 @@ import { spawn as bunSpawn } from "bun"
|
||||
import { spawn as nodeSpawn, type ChildProcess } from "node:child_process"
|
||||
import { existsSync, statSync } from "fs"
|
||||
import { log } from "../../shared/logger"
|
||||
// Bun spawn segfaults on Windows (oven-sh/bun#25798) — unfixed as of v1.3.8+
|
||||
function shouldUseNodeSpawn(): boolean {
|
||||
return process.platform === "win32"
|
||||
}
|
||||
// Prevents segfaults when libuv gets a non-existent cwd (oven-sh/bun#25798)
|
||||
export function validateCwd(cwd: string): { valid: boolean; error?: string } {
|
||||
try {
|
||||
if (!existsSync(cwd)) {
|
||||
@@ -24,7 +22,6 @@ export function validateCwd(cwd: string): { valid: boolean; error?: string } {
|
||||
interface StreamReader {
|
||||
read(): Promise<{ done: boolean; value: Uint8Array | undefined }>
|
||||
}
|
||||
// Bridges Bun Subprocess and Node.js ChildProcess under a common API
|
||||
export interface UnifiedProcess {
|
||||
stdin: { write(chunk: Uint8Array | string): void }
|
||||
stdout: { getReader(): StreamReader }
|
||||
|
||||
@@ -52,6 +52,9 @@ class LSPServerManager {
|
||||
this.cleanupInterval = setInterval(() => {
|
||||
this.cleanupIdleClients();
|
||||
}, 60000);
|
||||
if (typeof this.cleanupInterval === "object" && "unref" in this.cleanupInterval) {
|
||||
this.cleanupInterval.unref();
|
||||
}
|
||||
}
|
||||
|
||||
private cleanupIdleClients(): void {
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { existsSync } from "node:fs"
|
||||
import { readdir, readFile } from "node:fs/promises"
|
||||
import { join } from "node:path"
|
||||
import { MESSAGE_STORAGE, PART_STORAGE, SESSION_STORAGE, TODO_DIR, TRANSCRIPT_DIR } from "./constants"
|
||||
import { getMessageDir } from "../../shared/opencode-message-dir"
|
||||
import type { SessionInfo, SessionMessage, SessionMetadata, TodoItem } from "./types"
|
||||
|
||||
export async function getFileMainSessions(directory?: string): Promise<SessionMetadata[]> {
|
||||
if (!existsSync(SESSION_STORAGE)) return []
|
||||
|
||||
const sessions: SessionMetadata[] = []
|
||||
|
||||
try {
|
||||
const projectDirs = await readdir(SESSION_STORAGE, { withFileTypes: true })
|
||||
for (const projectDir of projectDirs) {
|
||||
if (!projectDir.isDirectory()) continue
|
||||
const projectPath = join(SESSION_STORAGE, projectDir.name)
|
||||
const sessionFiles = await readdir(projectPath)
|
||||
|
||||
for (const file of sessionFiles) {
|
||||
if (!file.endsWith(".json")) continue
|
||||
|
||||
try {
|
||||
const content = await readFile(join(projectPath, file), "utf-8")
|
||||
const meta = JSON.parse(content) as SessionMetadata
|
||||
if (meta.parentID) continue
|
||||
if (directory && meta.directory !== directory) continue
|
||||
sessions.push(meta)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
return sessions.sort((a, b) => b.time.updated - a.time.updated)
|
||||
}
|
||||
|
||||
export async function getFileAllSessions(): Promise<string[]> {
|
||||
if (!existsSync(MESSAGE_STORAGE)) return []
|
||||
|
||||
const sessions: string[] = []
|
||||
|
||||
async function scanDirectory(dir: string): Promise<void> {
|
||||
try {
|
||||
const entries = await readdir(dir, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue
|
||||
const sessionPath = join(dir, entry.name)
|
||||
const files = await readdir(sessionPath)
|
||||
if (files.some((file) => file.endsWith(".json"))) {
|
||||
sessions.push(entry.name)
|
||||
continue
|
||||
}
|
||||
await scanDirectory(sessionPath)
|
||||
}
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
await scanDirectory(MESSAGE_STORAGE)
|
||||
return [...new Set(sessions)]
|
||||
}
|
||||
|
||||
export async function fileSessionExists(sessionID: string): Promise<boolean> {
|
||||
return getMessageDir(sessionID) !== null
|
||||
}
|
||||
|
||||
export async function getFileSessionMessages(sessionID: string): Promise<SessionMessage[]> {
|
||||
const messageDir = getMessageDir(sessionID)
|
||||
if (!messageDir || !existsSync(messageDir)) return []
|
||||
|
||||
const messages: SessionMessage[] = []
|
||||
try {
|
||||
const files = await readdir(messageDir)
|
||||
for (const file of files) {
|
||||
if (!file.endsWith(".json")) continue
|
||||
try {
|
||||
const content = await readFile(join(messageDir, file), "utf-8")
|
||||
const meta = JSON.parse(content)
|
||||
const parts = await readParts(meta.id)
|
||||
messages.push({
|
||||
id: meta.id,
|
||||
role: meta.role,
|
||||
agent: meta.agent,
|
||||
time: meta.time,
|
||||
parts,
|
||||
})
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
return messages.sort((a, b) => {
|
||||
const aTime = a.time?.created ?? 0
|
||||
const bTime = b.time?.created ?? 0
|
||||
if (aTime !== bTime) return aTime - bTime
|
||||
return a.id.localeCompare(b.id)
|
||||
})
|
||||
}
|
||||
|
||||
async function readParts(messageID: string): Promise<Array<{ id: string; type: string; [key: string]: unknown }>> {
|
||||
const partDir = join(PART_STORAGE, messageID)
|
||||
if (!existsSync(partDir)) return []
|
||||
|
||||
const parts: Array<{ id: string; type: string; [key: string]: unknown }> = []
|
||||
try {
|
||||
const files = await readdir(partDir)
|
||||
for (const file of files) {
|
||||
if (!file.endsWith(".json")) continue
|
||||
try {
|
||||
const content = await readFile(join(partDir, file), "utf-8")
|
||||
parts.push(JSON.parse(content))
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
return parts.sort((a, b) => a.id.localeCompare(b.id))
|
||||
}
|
||||
|
||||
export async function getFileSessionTodos(sessionID: string): Promise<TodoItem[]> {
|
||||
if (!existsSync(TODO_DIR)) return []
|
||||
|
||||
try {
|
||||
const allFiles = await readdir(TODO_DIR)
|
||||
const todoFiles = allFiles.filter((file) => file === `${sessionID}.json`)
|
||||
|
||||
for (const file of todoFiles) {
|
||||
try {
|
||||
const content = await readFile(join(TODO_DIR, file), "utf-8")
|
||||
const data = JSON.parse(content)
|
||||
if (!Array.isArray(data)) continue
|
||||
return data.map((item) => ({
|
||||
id: item.id || "",
|
||||
content: item.content || "",
|
||||
status: item.status || "pending",
|
||||
priority: item.priority,
|
||||
}))
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
export async function getFileSessionTranscript(sessionID: string): Promise<number> {
|
||||
if (!existsSync(TRANSCRIPT_DIR)) return 0
|
||||
const transcriptFile = join(TRANSCRIPT_DIR, `${sessionID}.jsonl`)
|
||||
if (!existsSync(transcriptFile)) return 0
|
||||
|
||||
try {
|
||||
const content = await readFile(transcriptFile, "utf-8")
|
||||
return content.trim().split("\n").filter(Boolean).length
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
export async function getFileSessionInfo(sessionID: string): Promise<SessionInfo | null> {
|
||||
const messages = await getFileSessionMessages(sessionID)
|
||||
if (messages.length === 0) return null
|
||||
|
||||
const agentsUsed = new Set<string>()
|
||||
let firstMessage: Date | undefined
|
||||
let lastMessage: Date | undefined
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.agent) agentsUsed.add(msg.agent)
|
||||
if (!msg.time?.created) continue
|
||||
const date = new Date(msg.time.created)
|
||||
if (!firstMessage || date < firstMessage) firstMessage = date
|
||||
if (!lastMessage || date > lastMessage) lastMessage = date
|
||||
}
|
||||
|
||||
const todos = await getFileSessionTodos(sessionID)
|
||||
const transcriptEntries = await getFileSessionTranscript(sessionID)
|
||||
|
||||
return {
|
||||
id: sessionID,
|
||||
message_count: messages.length,
|
||||
first_message: firstMessage,
|
||||
last_message: lastMessage,
|
||||
agents_used: Array.from(agentsUsed),
|
||||
has_todos: todos.length > 0,
|
||||
has_transcript: transcriptEntries > 0,
|
||||
todos,
|
||||
transcript_entries: transcriptEntries,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { normalizeSDKResponse } from "../../shared"
|
||||
import type { SessionMessage, SessionMetadata, TodoItem } from "./types"
|
||||
import { isSessionSdkUnavailableError } from "./sdk-unavailable"
|
||||
|
||||
function unwrapSdkResponseError(response: unknown): unknown {
|
||||
if (!response || typeof response !== "object" || !("error" in response)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (response as { error?: unknown }).error ?? null
|
||||
}
|
||||
|
||||
function throwOnNonFallbackableSdkError(response: unknown): void {
|
||||
const error = unwrapSdkResponseError(response)
|
||||
if (!error) return
|
||||
throw error
|
||||
}
|
||||
|
||||
export async function getSdkMainSessions(
|
||||
client: PluginInput["client"],
|
||||
directory?: string,
|
||||
): Promise<SessionMetadata[]> {
|
||||
const response = await client.session.list()
|
||||
const error = unwrapSdkResponseError(response)
|
||||
if (error) throw error
|
||||
|
||||
const sessions = normalizeSDKResponse(response, [] as SessionMetadata[])
|
||||
const mainSessions = sessions.filter((session) => !session.parentID)
|
||||
if (directory) {
|
||||
return mainSessions
|
||||
.filter((session) => session.directory === directory)
|
||||
.sort((a, b) => b.time.updated - a.time.updated)
|
||||
}
|
||||
|
||||
return mainSessions.sort((a, b) => b.time.updated - a.time.updated)
|
||||
}
|
||||
|
||||
export async function getSdkAllSessions(client: PluginInput["client"]): Promise<string[]> {
|
||||
const response = await client.session.list()
|
||||
throwOnNonFallbackableSdkError(response)
|
||||
const sessions = normalizeSDKResponse(response, [] as SessionMetadata[])
|
||||
return sessions.map((session) => session.id)
|
||||
}
|
||||
|
||||
export async function sdkSessionExists(client: PluginInput["client"], sessionID: string): Promise<boolean> {
|
||||
const response = await client.session.list()
|
||||
throwOnNonFallbackableSdkError(response)
|
||||
const sessions = normalizeSDKResponse(response, [] as Array<{ id?: string }>)
|
||||
return sessions.some((session) => session.id === sessionID)
|
||||
}
|
||||
|
||||
export async function getSdkSessionMessages(
|
||||
client: PluginInput["client"],
|
||||
sessionID: string,
|
||||
): Promise<SessionMessage[]> {
|
||||
const response = await client.session.messages({ path: { id: sessionID } })
|
||||
throwOnNonFallbackableSdkError(response)
|
||||
|
||||
const rawMessages = normalizeSDKResponse(response, [] as Array<{
|
||||
info?: {
|
||||
id?: string
|
||||
role?: string
|
||||
agent?: string
|
||||
time?: { created?: number; updated?: number }
|
||||
}
|
||||
parts?: Array<{
|
||||
id?: string
|
||||
type?: string
|
||||
text?: string
|
||||
thinking?: string
|
||||
tool?: string
|
||||
callID?: string
|
||||
input?: Record<string, unknown>
|
||||
output?: string
|
||||
error?: string
|
||||
}>
|
||||
}>)
|
||||
|
||||
const messages: SessionMessage[] = rawMessages
|
||||
.filter((message) => message.info?.id)
|
||||
.map((message) => ({
|
||||
id: message.info!.id!,
|
||||
role: (message.info!.role as "user" | "assistant") || "user",
|
||||
agent: message.info!.agent,
|
||||
time: message.info!.time?.created
|
||||
? {
|
||||
created: message.info!.time.created,
|
||||
updated: message.info!.time.updated,
|
||||
}
|
||||
: undefined,
|
||||
parts:
|
||||
message.parts?.map((part) => ({
|
||||
id: part.id || "",
|
||||
type: part.type || "text",
|
||||
text: part.text,
|
||||
thinking: part.thinking,
|
||||
tool: part.tool,
|
||||
callID: part.callID,
|
||||
input: part.input,
|
||||
output: part.output,
|
||||
error: part.error,
|
||||
})) || [],
|
||||
}))
|
||||
|
||||
return messages.sort((a, b) => {
|
||||
const aTime = a.time?.created ?? 0
|
||||
const bTime = b.time?.created ?? 0
|
||||
if (aTime !== bTime) return aTime - bTime
|
||||
return a.id.localeCompare(b.id)
|
||||
})
|
||||
}
|
||||
|
||||
export async function getSdkSessionTodos(client: PluginInput["client"], sessionID: string): Promise<TodoItem[]> {
|
||||
const response = await client.session.todo({ path: { id: sessionID } })
|
||||
throwOnNonFallbackableSdkError(response)
|
||||
|
||||
const data = normalizeSDKResponse(response, [] as Array<{
|
||||
id?: string
|
||||
content?: string
|
||||
status?: string
|
||||
priority?: string
|
||||
}>)
|
||||
|
||||
return data.map((item) => ({
|
||||
id: item.id || "",
|
||||
content: item.content || "",
|
||||
status: (item.status as TodoItem["status"]) || "pending",
|
||||
priority: item.priority,
|
||||
}))
|
||||
}
|
||||
|
||||
export function shouldFallbackFromSdkError(error: unknown): boolean {
|
||||
return isSessionSdkUnavailableError(error)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
const SDK_UNAVAILABLE_PATTERNS = [
|
||||
"unable to connect",
|
||||
"econnrefused",
|
||||
"fetch failed",
|
||||
"network error",
|
||||
"network request failed",
|
||||
"server unreachable",
|
||||
"etimedout",
|
||||
"timed out",
|
||||
"timeout",
|
||||
"socket hang up",
|
||||
] as const
|
||||
|
||||
function collectErrorTexts(value: unknown): string[] {
|
||||
if (value instanceof Error) {
|
||||
return [value.message, value.name, ...collectErrorTexts(value.cause)]
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
return [value]
|
||||
}
|
||||
|
||||
if (!value || typeof value !== "object") {
|
||||
return []
|
||||
}
|
||||
|
||||
const record = value as Record<string, unknown>
|
||||
return [
|
||||
typeof record.message === "string" ? record.message : "",
|
||||
typeof record.code === "string" ? record.code : "",
|
||||
typeof record.name === "string" ? record.name : "",
|
||||
...collectErrorTexts(record.cause),
|
||||
...collectErrorTexts(record.error),
|
||||
].filter(Boolean)
|
||||
}
|
||||
|
||||
export function isSessionSdkUnavailableError(value: unknown): boolean {
|
||||
const haystack = collectErrorTexts(value)
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
|
||||
return SDK_UNAVAILABLE_PATTERNS.some((pattern) => haystack.includes(pattern))
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
|
||||
import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { randomUUID } from "node:crypto"
|
||||
|
||||
const TEST_DIR = join(tmpdir(), `omo-test-session-manager-fallback-${randomUUID()}`)
|
||||
const TEST_MESSAGE_STORAGE = join(TEST_DIR, "message")
|
||||
const TEST_PART_STORAGE = join(TEST_DIR, "part")
|
||||
const TEST_SESSION_STORAGE = join(TEST_DIR, "session")
|
||||
const TEST_TODO_DIR = join(TEST_DIR, "todos")
|
||||
const TEST_TRANSCRIPT_DIR = join(TEST_DIR, "transcripts")
|
||||
|
||||
let sqliteBackend = false
|
||||
|
||||
mock.module("./constants", () => ({
|
||||
OPENCODE_STORAGE: TEST_DIR,
|
||||
MESSAGE_STORAGE: TEST_MESSAGE_STORAGE,
|
||||
PART_STORAGE: TEST_PART_STORAGE,
|
||||
SESSION_STORAGE: TEST_SESSION_STORAGE,
|
||||
TODO_DIR: TEST_TODO_DIR,
|
||||
TRANSCRIPT_DIR: TEST_TRANSCRIPT_DIR,
|
||||
SESSION_LIST_DESCRIPTION: "test",
|
||||
SESSION_READ_DESCRIPTION: "test",
|
||||
SESSION_SEARCH_DESCRIPTION: "test",
|
||||
SESSION_INFO_DESCRIPTION: "test",
|
||||
SESSION_DELETE_DESCRIPTION: "test",
|
||||
TOOL_NAME_PREFIX: "session_",
|
||||
}))
|
||||
|
||||
mock.module("../../shared/opencode-storage-detection", () => ({
|
||||
isSqliteBackend: () => sqliteBackend,
|
||||
resetSqliteBackendCache: () => {},
|
||||
}))
|
||||
|
||||
mock.module("../../shared/opencode-message-dir", () => ({
|
||||
getMessageDir: (sessionID: string) => {
|
||||
if (!sessionID.startsWith("ses_")) return null
|
||||
if (/[/\\]|\.\./.test(sessionID)) return null
|
||||
if (!existsSync(TEST_MESSAGE_STORAGE)) return null
|
||||
|
||||
const directPath = join(TEST_MESSAGE_STORAGE, sessionID)
|
||||
if (existsSync(directPath)) return directPath
|
||||
|
||||
for (const dir of readdirSync(TEST_MESSAGE_STORAGE)) {
|
||||
const nestedPath = join(TEST_MESSAGE_STORAGE, dir, sessionID)
|
||||
if (existsSync(nestedPath)) return nestedPath
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
}))
|
||||
|
||||
afterAll(() => {
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
const storage = await import("./storage")
|
||||
|
||||
function createSdkUnavailableError(message: string): Error {
|
||||
return new Error(message)
|
||||
}
|
||||
|
||||
function createSessionMetadata(projectID: string, sessionID: string, directory: string, updated: number): void {
|
||||
const projectDir = join(TEST_SESSION_STORAGE, projectID)
|
||||
mkdirSync(projectDir, { recursive: true })
|
||||
writeFileSync(
|
||||
join(projectDir, `${sessionID}.json`),
|
||||
JSON.stringify({
|
||||
id: sessionID,
|
||||
projectID,
|
||||
directory,
|
||||
time: { created: updated - 1_000, updated },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function createSessionMessage(sessionID: string, messageID: string, created: number, role = "user"): void {
|
||||
const sessionPath = join(TEST_MESSAGE_STORAGE, sessionID)
|
||||
mkdirSync(sessionPath, { recursive: true })
|
||||
writeFileSync(
|
||||
join(sessionPath, `${messageID}.json`),
|
||||
JSON.stringify({ id: messageID, role, time: { created } }),
|
||||
)
|
||||
}
|
||||
|
||||
function createSessionTodo(sessionID: string, items: Array<Record<string, unknown>>): void {
|
||||
mkdirSync(TEST_TODO_DIR, { recursive: true })
|
||||
writeFileSync(join(TEST_TODO_DIR, `${sessionID}.json`), JSON.stringify(items))
|
||||
}
|
||||
|
||||
describe("session-manager storage fallback", () => {
|
||||
const mockClient = {
|
||||
session: {
|
||||
list: mock((): Promise<unknown> => Promise.resolve({ data: [] })),
|
||||
messages: mock((): Promise<unknown> => Promise.resolve({ data: [] })),
|
||||
todo: mock((): Promise<unknown> => Promise.resolve({ data: [] })),
|
||||
},
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
sqliteBackend = true
|
||||
if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true, force: true })
|
||||
mkdirSync(TEST_DIR, { recursive: true })
|
||||
mkdirSync(TEST_MESSAGE_STORAGE, { recursive: true })
|
||||
mkdirSync(TEST_PART_STORAGE, { recursive: true })
|
||||
mkdirSync(TEST_SESSION_STORAGE, { recursive: true })
|
||||
mkdirSync(TEST_TODO_DIR, { recursive: true })
|
||||
mkdirSync(TEST_TRANSCRIPT_DIR, { recursive: true })
|
||||
mockClient.session.list.mockReset()
|
||||
mockClient.session.messages.mockReset()
|
||||
mockClient.session.todo.mockReset()
|
||||
storage.setStorageClient(mockClient as never)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
storage.resetStorageClient()
|
||||
if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test("#given unreachable SDK list response #when getMainSessions runs #then falls back to file sessions", async () => {
|
||||
createSessionMetadata("proj_test", "ses_file", "/workspace/project", 2_000)
|
||||
mockClient.session.list.mockImplementation(() => Promise.resolve({ error: createSdkUnavailableError("fetch failed ECONNREFUSED") }))
|
||||
|
||||
const sessions = await storage.getMainSessions({ directory: "/workspace/project" })
|
||||
|
||||
expect(sessions).toHaveLength(1)
|
||||
expect(sessions[0].id).toBe("ses_file")
|
||||
})
|
||||
|
||||
test("#given empty SDK list response #when getMainSessions runs #then returns file-backed pre-migration sessions", async () => {
|
||||
createSessionMetadata("proj_test", "ses_file", "/workspace/project", 2_000)
|
||||
mockClient.session.list.mockImplementation(() => Promise.resolve({ data: [] }))
|
||||
|
||||
const sessions = await storage.getMainSessions({ directory: "/workspace/project" })
|
||||
|
||||
expect(sessions).toHaveLength(1)
|
||||
expect(sessions[0].id).toBe("ses_file")
|
||||
})
|
||||
|
||||
test("#given SDK and file sessions overlap #when getMainSessions runs #then dedupes by id and keeps SDK metadata", async () => {
|
||||
createSessionMetadata("proj_test", "ses_file", "/workspace/project", 2_000)
|
||||
createSessionMetadata("proj_test", "ses_sdk", "/workspace/project", 1_500)
|
||||
mockClient.session.list.mockImplementation(() => Promise.resolve({
|
||||
data: [
|
||||
{
|
||||
id: "ses_sdk",
|
||||
projectID: "sdk_project",
|
||||
directory: "/workspace/project",
|
||||
time: { created: 3_000, updated: 4_000 },
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
const sessions = await storage.getMainSessions({ directory: "/workspace/project" })
|
||||
|
||||
expect(sessions).toHaveLength(2)
|
||||
expect(sessions.map((session) => session.id)).toEqual(["ses_sdk", "ses_file"])
|
||||
expect(sessions[0].projectID).toBe("sdk_project")
|
||||
})
|
||||
|
||||
test("#given empty SDK session list #when getAllSessions runs #then returns file-backed session ids", async () => {
|
||||
createSessionMessage("ses_file", "msg_001", 1_000)
|
||||
mockClient.session.list.mockImplementation(() => Promise.resolve({ data: [] }))
|
||||
|
||||
const sessionIds = await storage.getAllSessions()
|
||||
|
||||
expect(sessionIds).toEqual(["ses_file"])
|
||||
})
|
||||
|
||||
test("#given SDK and file session ids overlap #when getAllSessions runs #then returns deduped union", async () => {
|
||||
createSessionMessage("ses_file", "msg_001", 1_000)
|
||||
createSessionMessage("ses_sdk", "msg_002", 2_000)
|
||||
mockClient.session.list.mockImplementation(() => Promise.resolve({
|
||||
data: [
|
||||
{ id: "ses_sdk" },
|
||||
],
|
||||
}))
|
||||
|
||||
const sessionIds = await storage.getAllSessions()
|
||||
|
||||
expect(sessionIds).toEqual(["ses_sdk", "ses_file"])
|
||||
})
|
||||
|
||||
test("#given unreachable SDK messages error #when readSessionMessages runs #then falls back to file messages", async () => {
|
||||
createSessionMessage("ses_file", "msg_001", 1_000)
|
||||
mockClient.session.messages.mockImplementation(() => Promise.reject(createSdkUnavailableError("Unable to connect to http://localhost:4096")))
|
||||
|
||||
const messages = await storage.readSessionMessages("ses_file")
|
||||
|
||||
expect(messages).toHaveLength(1)
|
||||
expect(messages[0].id).toBe("msg_001")
|
||||
})
|
||||
|
||||
test("#given empty SDK messages response #when readSessionMessages runs #then falls back to file messages", async () => {
|
||||
createSessionMessage("ses_file", "msg_001", 1_000)
|
||||
mockClient.session.messages.mockImplementation(() => Promise.resolve({ data: [] }))
|
||||
|
||||
const messages = await storage.readSessionMessages("ses_file")
|
||||
|
||||
expect(messages).toHaveLength(1)
|
||||
expect(messages[0].id).toBe("msg_001")
|
||||
})
|
||||
|
||||
test("#given unreachable SDK todo response #when readSessionTodos runs #then falls back to file todos", async () => {
|
||||
createSessionTodo("ses_file", [{ id: "todo_1", content: "Fallback todo", status: "pending" }])
|
||||
mockClient.session.todo.mockImplementation(() => Promise.resolve({ error: createSdkUnavailableError("network error: server unreachable") }))
|
||||
|
||||
const todos = await storage.readSessionTodos("ses_file")
|
||||
|
||||
expect(todos).toHaveLength(1)
|
||||
expect(todos[0].content).toBe("Fallback todo")
|
||||
})
|
||||
|
||||
test("#given empty SDK todo response #when readSessionTodos runs #then falls back to file todos", async () => {
|
||||
createSessionTodo("ses_file", [{ id: "todo_1", content: "Fallback todo", status: "pending" }])
|
||||
mockClient.session.todo.mockImplementation(() => Promise.resolve({ data: [] }))
|
||||
|
||||
const todos = await storage.readSessionTodos("ses_file")
|
||||
|
||||
expect(todos).toHaveLength(1)
|
||||
expect(todos[0].content).toBe("Fallback todo")
|
||||
})
|
||||
|
||||
test("#given unreachable SDK list error #when sessionExists runs #then falls back to file existence", async () => {
|
||||
createSessionMessage("ses_file", "msg_001", 1_000)
|
||||
mockClient.session.list.mockImplementation(() => Promise.reject(createSdkUnavailableError("ETIMEDOUT while connecting")))
|
||||
|
||||
const exists = await storage.sessionExists("ses_file")
|
||||
|
||||
expect(exists).toBe(true)
|
||||
})
|
||||
|
||||
test("#given empty SDK session list #when sessionExists runs #then falls back to file existence", async () => {
|
||||
createSessionMessage("ses_file", "msg_001", 1_000)
|
||||
mockClient.session.list.mockImplementation(() => Promise.resolve({ data: [] }))
|
||||
|
||||
const exists = await storage.sessionExists("ses_file")
|
||||
|
||||
expect(exists).toBe(true)
|
||||
})
|
||||
|
||||
test("#given semantic SDK error #when readSessionMessages runs #then rethrows instead of hiding bug", async () => {
|
||||
mockClient.session.messages.mockImplementation(() => Promise.resolve({ error: new Error("session not found") }))
|
||||
|
||||
await expect(storage.readSessionMessages("ses_missing")).rejects.toThrow("session not found")
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, test, expect, beforeEach, afterEach, mock } from "bun:test"
|
||||
import { describe, test, expect, beforeEach, afterEach, afterAll, mock } from "bun:test"
|
||||
import { mkdirSync, writeFileSync, rmSync, existsSync, readdirSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { tmpdir } from "node:os"
|
||||
@@ -10,6 +10,7 @@ const TEST_PART_STORAGE = join(TEST_DIR, "part")
|
||||
const TEST_SESSION_STORAGE = join(TEST_DIR, "session")
|
||||
const TEST_TODO_DIR = join(TEST_DIR, "todos")
|
||||
const TEST_TRANSCRIPT_DIR = join(TEST_DIR, "transcripts")
|
||||
let sqliteBackend = false
|
||||
|
||||
mock.module("./constants", () => ({
|
||||
OPENCODE_STORAGE: TEST_DIR,
|
||||
@@ -27,7 +28,7 @@ mock.module("./constants", () => ({
|
||||
}))
|
||||
|
||||
mock.module("../../shared/opencode-storage-detection", () => ({
|
||||
isSqliteBackend: () => false,
|
||||
isSqliteBackend: () => sqliteBackend,
|
||||
resetSqliteBackendCache: () => {},
|
||||
}))
|
||||
|
||||
@@ -59,6 +60,9 @@ mock.module("../../shared/opencode-message-dir", () => ({
|
||||
return null
|
||||
},
|
||||
}))
|
||||
|
||||
afterAll(() => { mock.restore() })
|
||||
|
||||
const { getAllSessions, getMessageDir, sessionExists, readSessionMessages, readSessionTodos, getSessionInfo } =
|
||||
await import("./storage")
|
||||
|
||||
@@ -66,6 +70,7 @@ const storage = await import("./storage")
|
||||
|
||||
describe("session-manager storage", () => {
|
||||
beforeEach(() => {
|
||||
sqliteBackend = false
|
||||
if (existsSync(TEST_DIR)) {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true })
|
||||
}
|
||||
@@ -78,6 +83,8 @@ describe("session-manager storage", () => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sqliteBackend = false
|
||||
storage.resetStorageClient()
|
||||
if (existsSync(TEST_DIR)) {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true })
|
||||
}
|
||||
@@ -232,6 +239,47 @@ describe("session-manager storage", () => {
|
||||
expect(info?.agents_used).toContain("build")
|
||||
expect(info?.agents_used).toContain("oracle")
|
||||
})
|
||||
|
||||
test("getSessionInfo uses SDK session messages on sqlite backend", async () => {
|
||||
sqliteBackend = true
|
||||
const now = Date.now()
|
||||
|
||||
storage.setStorageClient({
|
||||
session: {
|
||||
messages: async () => ({
|
||||
data: [
|
||||
{
|
||||
info: {
|
||||
id: "msg_sqlite_1",
|
||||
role: "user",
|
||||
agent: "atlas",
|
||||
time: { created: now - 5000, updated: now - 5000 },
|
||||
},
|
||||
parts: [],
|
||||
},
|
||||
{
|
||||
info: {
|
||||
id: "msg_sqlite_2",
|
||||
role: "assistant",
|
||||
agent: "prometheus",
|
||||
time: { created: now, updated: now },
|
||||
},
|
||||
parts: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
todo: async () => ({ data: [] }),
|
||||
},
|
||||
} as never)
|
||||
|
||||
const info = await getSessionInfo("ses_sqlite")
|
||||
|
||||
expect(info).not.toBeNull()
|
||||
expect(info?.id).toBe("ses_sqlite")
|
||||
expect(info?.message_count).toBe(2)
|
||||
expect(info?.agents_used).toContain("atlas")
|
||||
expect(info?.agents_used).toContain("prometheus")
|
||||
})
|
||||
})
|
||||
|
||||
describe("session-manager storage - getMainSessions", () => {
|
||||
@@ -371,9 +419,9 @@ describe("session-manager storage - getMainSessions", () => {
|
||||
describe("session-manager storage - SDK path (beta mode)", () => {
|
||||
const mockClient = {
|
||||
session: {
|
||||
list: mock(() => Promise.resolve({ data: [] })),
|
||||
messages: mock(() => Promise.resolve({ data: [] })),
|
||||
todo: mock(() => Promise.resolve({ data: [] })),
|
||||
list: mock((): Promise<unknown> => Promise.resolve({ data: [] })),
|
||||
messages: mock((): Promise<unknown> => Promise.resolve({ data: [] })),
|
||||
todo: mock((): Promise<unknown> => Promise.resolve({ data: [] })),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -497,7 +545,7 @@ describe("session-manager storage - SDK path (beta mode)", () => {
|
||||
expect(todos[1].status).toBe("completed")
|
||||
})
|
||||
|
||||
test("SDK path returns empty array on error", async () => {
|
||||
test("SDK path rethrows non-transport errors", async () => {
|
||||
// given
|
||||
mockClient.session.messages.mockImplementation(() => Promise.reject(new Error("API error")))
|
||||
|
||||
@@ -509,11 +557,7 @@ describe("session-manager storage - SDK path (beta mode)", () => {
|
||||
const { setStorageClient, readSessionMessages } = await import("./storage")
|
||||
setStorageClient(mockClient as unknown as Parameters<typeof setStorageClient>[0])
|
||||
|
||||
// when
|
||||
const messages = await readSessionMessages("ses_test")
|
||||
|
||||
// then
|
||||
expect(messages).toEqual([])
|
||||
await expect(readSessionMessages("ses_test")).rejects.toThrow("API error")
|
||||
})
|
||||
|
||||
test("SDK path returns empty array when client is not set", async () => {
|
||||
|
||||
@@ -1,17 +1,35 @@
|
||||
import { existsSync } from "node:fs"
|
||||
import { readdir, readFile } from "node:fs/promises"
|
||||
import { join } from "node:path"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { MESSAGE_STORAGE, PART_STORAGE, SESSION_STORAGE, TODO_DIR, TRANSCRIPT_DIR } from "./constants"
|
||||
import { isSqliteBackend } from "../../shared/opencode-storage-detection"
|
||||
import { getMessageDir } from "../../shared/opencode-message-dir"
|
||||
import type { SessionMessage, SessionInfo, TodoItem, SessionMetadata } from "./types"
|
||||
import { normalizeSDKResponse } from "../../shared"
|
||||
import { log } from "../../shared"
|
||||
import { getFileAllSessions, getFileMainSessions, fileSessionExists, getFileSessionInfo, getFileSessionMessages, getFileSessionTodos, getFileSessionTranscript } from "./file-storage"
|
||||
import { getSdkAllSessions, getSdkMainSessions, getSdkSessionMessages, getSdkSessionTodos, sdkSessionExists, shouldFallbackFromSdkError } from "./sdk-storage"
|
||||
import type { SessionInfo, SessionMessage, SessionMetadata, TodoItem } from "./types"
|
||||
|
||||
export interface GetMainSessionsOptions {
|
||||
directory?: string
|
||||
}
|
||||
|
||||
function mergeSessionMetadataLists(
|
||||
sdkSessions: SessionMetadata[],
|
||||
fileSessions: SessionMetadata[],
|
||||
): SessionMetadata[] {
|
||||
const merged = new Map<string, SessionMetadata>()
|
||||
|
||||
for (const session of fileSessions) {
|
||||
merged.set(session.id, session)
|
||||
}
|
||||
|
||||
for (const session of sdkSessions) {
|
||||
merged.set(session.id, session)
|
||||
}
|
||||
|
||||
return [...merged.values()].sort((a, b) => b.time.updated - a.time.updated)
|
||||
}
|
||||
|
||||
function mergeSessionIds(sdkSessionIds: string[], fileSessionIds: string[]): string[] {
|
||||
return [...new Set([...sdkSessionIds, ...fileSessionIds])]
|
||||
}
|
||||
|
||||
// SDK client reference for beta mode
|
||||
let sdkClient: PluginInput["client"] | null = null
|
||||
|
||||
@@ -24,327 +42,120 @@ export function resetStorageClient(): void {
|
||||
}
|
||||
|
||||
export async function getMainSessions(options: GetMainSessionsOptions): Promise<SessionMetadata[]> {
|
||||
// Beta mode: use SDK
|
||||
if (isSqliteBackend() && sdkClient) {
|
||||
try {
|
||||
const response = await sdkClient.session.list()
|
||||
const sessions = normalizeSDKResponse(response, [] as SessionMetadata[])
|
||||
const mainSessions = sessions.filter((s) => !s.parentID)
|
||||
if (options.directory) {
|
||||
return mainSessions
|
||||
.filter((s) => s.directory === options.directory)
|
||||
.sort((a, b) => b.time.updated - a.time.updated)
|
||||
}
|
||||
return mainSessions.sort((a, b) => b.time.updated - a.time.updated)
|
||||
} catch {
|
||||
return []
|
||||
const sdkSessions = await getSdkMainSessions(sdkClient, options.directory)
|
||||
const fileSessions = await getFileMainSessions(options.directory)
|
||||
return mergeSessionMetadataLists(sdkSessions, fileSessions)
|
||||
} catch (error) {
|
||||
if (!shouldFallbackFromSdkError(error)) throw error
|
||||
log("[session-manager] falling back to file session list after SDK unavailable error", { error: String(error) })
|
||||
}
|
||||
}
|
||||
|
||||
// Stable mode: use JSON files
|
||||
if (!existsSync(SESSION_STORAGE)) return []
|
||||
|
||||
const sessions: SessionMetadata[] = []
|
||||
|
||||
try {
|
||||
const projectDirs = await readdir(SESSION_STORAGE, { withFileTypes: true })
|
||||
for (const projectDir of projectDirs) {
|
||||
if (!projectDir.isDirectory()) continue
|
||||
|
||||
const projectPath = join(SESSION_STORAGE, projectDir.name)
|
||||
const sessionFiles = await readdir(projectPath)
|
||||
|
||||
for (const file of sessionFiles) {
|
||||
if (!file.endsWith(".json")) continue
|
||||
|
||||
try {
|
||||
const content = await readFile(join(projectPath, file), "utf-8")
|
||||
const meta = JSON.parse(content) as SessionMetadata
|
||||
|
||||
if (meta.parentID) continue
|
||||
|
||||
if (options.directory && meta.directory !== options.directory) continue
|
||||
|
||||
sessions.push(meta)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
return sessions.sort((a, b) => b.time.updated - a.time.updated)
|
||||
return getFileMainSessions(options.directory)
|
||||
}
|
||||
|
||||
export async function getAllSessions(): Promise<string[]> {
|
||||
// Beta mode: use SDK
|
||||
if (isSqliteBackend() && sdkClient) {
|
||||
try {
|
||||
const response = await sdkClient.session.list()
|
||||
const sessions = normalizeSDKResponse(response, [] as SessionMetadata[])
|
||||
return sessions.map((s) => s.id)
|
||||
} catch {
|
||||
return []
|
||||
const sdkSessionIds = await getSdkAllSessions(sdkClient)
|
||||
const fileSessionIds = await getFileAllSessions()
|
||||
return mergeSessionIds(sdkSessionIds, fileSessionIds)
|
||||
} catch (error) {
|
||||
if (!shouldFallbackFromSdkError(error)) throw error
|
||||
log("[session-manager] falling back to file session ids after SDK unavailable error", { error: String(error) })
|
||||
}
|
||||
}
|
||||
|
||||
// Stable mode: use JSON files
|
||||
if (!existsSync(MESSAGE_STORAGE)) return []
|
||||
|
||||
const sessions: string[] = []
|
||||
|
||||
async function scanDirectory(dir: string): Promise<void> {
|
||||
try {
|
||||
const entries = await readdir(dir, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
const sessionPath = join(dir, entry.name)
|
||||
const files = await readdir(sessionPath)
|
||||
if (files.some((f) => f.endsWith(".json"))) {
|
||||
sessions.push(entry.name)
|
||||
} else {
|
||||
await scanDirectory(sessionPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
await scanDirectory(MESSAGE_STORAGE)
|
||||
return [...new Set(sessions)]
|
||||
return getFileAllSessions()
|
||||
}
|
||||
|
||||
export { getMessageDir } from "../../shared/opencode-message-dir"
|
||||
|
||||
export async function sessionExists(sessionID: string): Promise<boolean> {
|
||||
if (isSqliteBackend() && sdkClient) {
|
||||
const response = await sdkClient.session.list()
|
||||
const sessions = normalizeSDKResponse(response, [] as Array<{ id?: string }>)
|
||||
return sessions.some((s) => s.id === sessionID)
|
||||
try {
|
||||
const existsInSdk = await sdkSessionExists(sdkClient, sessionID)
|
||||
if (existsInSdk) return true
|
||||
} catch (error) {
|
||||
if (!shouldFallbackFromSdkError(error)) throw error
|
||||
log("[session-manager] falling back to file sessionExists after SDK unavailable error", { error: String(error), sessionID })
|
||||
}
|
||||
}
|
||||
return getMessageDir(sessionID) !== null
|
||||
return fileSessionExists(sessionID)
|
||||
}
|
||||
|
||||
export async function readSessionMessages(sessionID: string): Promise<SessionMessage[]> {
|
||||
// Beta mode: use SDK
|
||||
if (isSqliteBackend() && sdkClient) {
|
||||
try {
|
||||
const response = await sdkClient.session.messages({ path: { id: sessionID } })
|
||||
const rawMessages = normalizeSDKResponse(response, [] as Array<{
|
||||
info?: {
|
||||
id?: string
|
||||
role?: string
|
||||
agent?: string
|
||||
time?: { created?: number; updated?: number }
|
||||
}
|
||||
parts?: Array<{
|
||||
id?: string
|
||||
type?: string
|
||||
text?: string
|
||||
thinking?: string
|
||||
tool?: string
|
||||
callID?: string
|
||||
input?: Record<string, unknown>
|
||||
output?: string
|
||||
error?: string
|
||||
}>
|
||||
}>)
|
||||
const messages: SessionMessage[] = rawMessages
|
||||
.filter((m) => m.info?.id)
|
||||
.map((m) => ({
|
||||
id: m.info!.id!,
|
||||
role: (m.info!.role as "user" | "assistant") || "user",
|
||||
agent: m.info!.agent,
|
||||
time: m.info!.time?.created
|
||||
? {
|
||||
created: m.info!.time.created,
|
||||
updated: m.info!.time.updated,
|
||||
}
|
||||
: undefined,
|
||||
parts:
|
||||
m.parts?.map((p) => ({
|
||||
id: p.id || "",
|
||||
type: p.type || "text",
|
||||
text: p.text,
|
||||
thinking: p.thinking,
|
||||
tool: p.tool,
|
||||
callID: p.callID,
|
||||
input: p.input,
|
||||
output: p.output,
|
||||
error: p.error,
|
||||
})) || [],
|
||||
}))
|
||||
return messages.sort((a, b) => {
|
||||
const aTime = a.time?.created ?? 0
|
||||
const bTime = b.time?.created ?? 0
|
||||
if (aTime !== bTime) return aTime - bTime
|
||||
return a.id.localeCompare(b.id)
|
||||
})
|
||||
} catch {
|
||||
return []
|
||||
const sdkMessages = await getSdkSessionMessages(sdkClient, sessionID)
|
||||
if (sdkMessages.length > 0) return sdkMessages
|
||||
} catch (error) {
|
||||
if (!shouldFallbackFromSdkError(error)) throw error
|
||||
log("[session-manager] falling back to file session messages after SDK unavailable error", { error: String(error), sessionID })
|
||||
}
|
||||
}
|
||||
|
||||
// Stable mode: use JSON files
|
||||
const messageDir = getMessageDir(sessionID)
|
||||
if (!messageDir || !existsSync(messageDir)) return []
|
||||
|
||||
const messages: SessionMessage[] = []
|
||||
try {
|
||||
const files = await readdir(messageDir)
|
||||
for (const file of files) {
|
||||
if (!file.endsWith(".json")) continue
|
||||
try {
|
||||
const content = await readFile(join(messageDir, file), "utf-8")
|
||||
const meta = JSON.parse(content)
|
||||
|
||||
const parts = await readParts(meta.id)
|
||||
|
||||
messages.push({
|
||||
id: meta.id,
|
||||
role: meta.role,
|
||||
agent: meta.agent,
|
||||
time: meta.time,
|
||||
parts,
|
||||
})
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
return messages.sort((a, b) => {
|
||||
const aTime = a.time?.created ?? 0
|
||||
const bTime = b.time?.created ?? 0
|
||||
if (aTime !== bTime) return aTime - bTime
|
||||
return a.id.localeCompare(b.id)
|
||||
})
|
||||
}
|
||||
|
||||
async function readParts(messageID: string): Promise<Array<{ id: string; type: string; [key: string]: unknown }>> {
|
||||
const partDir = join(PART_STORAGE, messageID)
|
||||
if (!existsSync(partDir)) return []
|
||||
|
||||
const parts: Array<{ id: string; type: string; [key: string]: unknown }> = []
|
||||
try {
|
||||
const files = await readdir(partDir)
|
||||
for (const file of files) {
|
||||
if (!file.endsWith(".json")) continue
|
||||
try {
|
||||
const content = await readFile(join(partDir, file), "utf-8")
|
||||
parts.push(JSON.parse(content))
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
return parts.sort((a, b) => a.id.localeCompare(b.id))
|
||||
return getFileSessionMessages(sessionID)
|
||||
}
|
||||
|
||||
export async function readSessionTodos(sessionID: string): Promise<TodoItem[]> {
|
||||
// Beta mode: use SDK
|
||||
if (isSqliteBackend() && sdkClient) {
|
||||
try {
|
||||
const response = await sdkClient.session.todo({ path: { id: sessionID } })
|
||||
const data = normalizeSDKResponse(response, [] as Array<{
|
||||
id?: string
|
||||
content?: string
|
||||
status?: string
|
||||
priority?: string
|
||||
}>)
|
||||
return data.map((item) => ({
|
||||
id: item.id || "",
|
||||
content: item.content || "",
|
||||
status: (item.status as TodoItem["status"]) || "pending",
|
||||
priority: item.priority,
|
||||
}))
|
||||
} catch {
|
||||
return []
|
||||
const sdkTodos = await getSdkSessionTodos(sdkClient, sessionID)
|
||||
if (sdkTodos.length > 0) return sdkTodos
|
||||
} catch (error) {
|
||||
if (!shouldFallbackFromSdkError(error)) throw error
|
||||
log("[session-manager] falling back to file session todos after SDK unavailable error", { error: String(error), sessionID })
|
||||
}
|
||||
}
|
||||
|
||||
// Stable mode: use JSON files
|
||||
if (!existsSync(TODO_DIR)) return []
|
||||
|
||||
try {
|
||||
const allFiles = await readdir(TODO_DIR)
|
||||
const todoFiles = allFiles.filter((f) => f === `${sessionID}.json`)
|
||||
|
||||
for (const file of todoFiles) {
|
||||
try {
|
||||
const content = await readFile(join(TODO_DIR, file), "utf-8")
|
||||
const data = JSON.parse(content)
|
||||
if (Array.isArray(data)) {
|
||||
return data.map((item) => ({
|
||||
id: item.id || "",
|
||||
content: item.content || "",
|
||||
status: item.status || "pending",
|
||||
priority: item.priority,
|
||||
}))
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
return []
|
||||
return getFileSessionTodos(sessionID)
|
||||
}
|
||||
|
||||
export async function readSessionTranscript(sessionID: string): Promise<number> {
|
||||
if (!existsSync(TRANSCRIPT_DIR)) return 0
|
||||
|
||||
const transcriptFile = join(TRANSCRIPT_DIR, `${sessionID}.jsonl`)
|
||||
if (!existsSync(transcriptFile)) return 0
|
||||
|
||||
try {
|
||||
const content = await readFile(transcriptFile, "utf-8")
|
||||
return content.trim().split("\n").filter(Boolean).length
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
return getFileSessionTranscript(sessionID)
|
||||
}
|
||||
|
||||
export async function getSessionInfo(sessionID: string): Promise<SessionInfo | null> {
|
||||
const messages = await readSessionMessages(sessionID)
|
||||
if (messages.length === 0) return null
|
||||
if (isSqliteBackend() && sdkClient) {
|
||||
try {
|
||||
const sdkMessages = await getSdkSessionMessages(sdkClient, sessionID)
|
||||
if (sdkMessages.length > 0) {
|
||||
const agentsUsed = new Set<string>()
|
||||
let firstMessage: Date | undefined
|
||||
let lastMessage: Date | undefined
|
||||
|
||||
const agentsUsed = new Set<string>()
|
||||
let firstMessage: Date | undefined
|
||||
let lastMessage: Date | undefined
|
||||
for (const msg of sdkMessages) {
|
||||
if (msg.agent) agentsUsed.add(msg.agent)
|
||||
if (msg.time?.created) {
|
||||
const date = new Date(msg.time.created)
|
||||
if (!firstMessage || date < firstMessage) firstMessage = date
|
||||
if (!lastMessage || date > lastMessage) lastMessage = date
|
||||
}
|
||||
}
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.agent) agentsUsed.add(msg.agent)
|
||||
if (msg.time?.created) {
|
||||
const date = new Date(msg.time.created)
|
||||
if (!firstMessage || date < firstMessage) firstMessage = date
|
||||
if (!lastMessage || date > lastMessage) lastMessage = date
|
||||
const todos = await readSessionTodos(sessionID)
|
||||
const transcriptEntries = await readSessionTranscript(sessionID)
|
||||
|
||||
return {
|
||||
id: sessionID,
|
||||
message_count: sdkMessages.length,
|
||||
first_message: firstMessage,
|
||||
last_message: lastMessage,
|
||||
agents_used: Array.from(agentsUsed),
|
||||
has_todos: todos.length > 0,
|
||||
has_transcript: transcriptEntries > 0,
|
||||
todos,
|
||||
transcript_entries: transcriptEntries,
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!shouldFallbackFromSdkError(error)) throw error
|
||||
log("[session-manager] falling back to file session info after SDK unavailable error", { error: String(error), sessionID })
|
||||
}
|
||||
}
|
||||
|
||||
const todos = await readSessionTodos(sessionID)
|
||||
const transcriptEntries = await readSessionTranscript(sessionID)
|
||||
|
||||
return {
|
||||
id: sessionID,
|
||||
message_count: messages.length,
|
||||
first_message: firstMessage,
|
||||
last_message: lastMessage,
|
||||
agents_used: Array.from(agentsUsed),
|
||||
has_todos: todos.length > 0,
|
||||
has_transcript: transcriptEntries > 0,
|
||||
todos,
|
||||
transcript_entries: transcriptEntries,
|
||||
}
|
||||
return getFileSessionInfo(sessionID)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, test, expect } from "bun:test"
|
||||
import { createSessionManagerTools } from "./tools"
|
||||
import type { ToolContext } from "@opencode-ai/plugin/tool"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { SessionInfo, SessionMessage, SearchResult, SessionMetadata, TodoItem } from "./types"
|
||||
|
||||
const projectDir = "/Users/yeongyu/local-workspaces/oh-my-opencode"
|
||||
|
||||
@@ -18,23 +19,83 @@ const mockContext: ToolContext = {
|
||||
ask: async () => {},
|
||||
}
|
||||
|
||||
const tools = createSessionManagerTools(mockCtx)
|
||||
const { session_list, session_read, session_search, session_info } = tools
|
||||
function createTestTools() {
|
||||
return createSessionManagerTools(mockCtx, {
|
||||
setStorageClient: () => {},
|
||||
getMainSessions: async (): Promise<SessionMetadata[]> => [
|
||||
{
|
||||
id: "ses_test123",
|
||||
projectID: "project-1",
|
||||
directory: projectDir,
|
||||
time: { created: Date.now(), updated: Date.now() },
|
||||
},
|
||||
{
|
||||
id: "ses_test456",
|
||||
projectID: "project-1",
|
||||
directory: projectDir,
|
||||
time: { created: Date.now(), updated: Date.now() },
|
||||
},
|
||||
],
|
||||
filterSessionsByDate: async (sessionIDs) => sessionIDs,
|
||||
formatSessionList: async (sessionIDs) => `sessions:${sessionIDs.join(",")}`,
|
||||
sessionExists: async (sessionID) => sessionID === "ses_test123",
|
||||
readSessionMessages: async (sessionID): Promise<SessionMessage[]> =>
|
||||
sessionID === "ses_test123"
|
||||
? [{
|
||||
id: `${sessionID}-msg`,
|
||||
role: "user",
|
||||
time: { created: Date.now() },
|
||||
parts: [{ id: `${sessionID}-part`, type: "text", text: "hello" }],
|
||||
}]
|
||||
: [],
|
||||
readSessionTodos: async (): Promise<TodoItem[]> => [],
|
||||
formatSessionMessages: (messages) => `messages:${messages.length}`,
|
||||
getAllSessions: async () => ["ses_test123", "ses_test456"],
|
||||
searchInSession: async (sessionID): Promise<SearchResult[]> => [
|
||||
{
|
||||
session_id: sessionID,
|
||||
message_id: `${sessionID}-msg`,
|
||||
excerpt: "test snippet",
|
||||
role: "user",
|
||||
match_count: 1,
|
||||
},
|
||||
],
|
||||
formatSearchResults: (results) => `results:${results.length}`,
|
||||
getSessionInfo: async (sessionID): Promise<SessionInfo | null> =>
|
||||
sessionID === "ses_test123"
|
||||
? {
|
||||
id: sessionID,
|
||||
message_count: 1,
|
||||
first_message: new Date(),
|
||||
last_message: new Date(),
|
||||
agents_used: ["test-agent"],
|
||||
has_todos: false,
|
||||
has_transcript: false,
|
||||
todos: [],
|
||||
transcript_entries: 0,
|
||||
}
|
||||
: null,
|
||||
formatSessionInfo: (info) => `info:${info.id}`,
|
||||
})
|
||||
}
|
||||
|
||||
describe("session-manager tools", () => {
|
||||
test("session_list executes without error", async () => {
|
||||
const { session_list } = createTestTools()
|
||||
const result = await session_list.execute({}, mockContext)
|
||||
|
||||
expect(typeof result).toBe("string")
|
||||
})
|
||||
|
||||
test("session_list respects limit parameter", async () => {
|
||||
const { session_list } = createTestTools()
|
||||
const result = await session_list.execute({ limit: 5 }, mockContext)
|
||||
|
||||
expect(typeof result).toBe("string")
|
||||
})
|
||||
|
||||
test("session_list filters by date range", async () => {
|
||||
const { session_list } = createTestTools()
|
||||
const result = await session_list.execute({
|
||||
from_date: "2025-12-01T00:00:00Z",
|
||||
to_date: "2025-12-31T23:59:59Z",
|
||||
@@ -44,6 +105,7 @@ describe("session-manager tools", () => {
|
||||
})
|
||||
|
||||
test("session_list filters by project_path", async () => {
|
||||
const { session_list } = createTestTools()
|
||||
//#given
|
||||
const projectPath = "/Users/yeongyu/local-workspaces/oh-my-opencode"
|
||||
|
||||
@@ -55,6 +117,7 @@ describe("session-manager tools", () => {
|
||||
})
|
||||
|
||||
test("session_list uses ctx.directory as default project_path", async () => {
|
||||
const { session_list } = createTestTools()
|
||||
//#given - no project_path provided
|
||||
|
||||
//#when
|
||||
@@ -65,12 +128,14 @@ describe("session-manager tools", () => {
|
||||
})
|
||||
|
||||
test("session_read handles non-existent session", async () => {
|
||||
const { session_read } = createTestTools()
|
||||
const result = await session_read.execute({ session_id: "ses_nonexistent" }, mockContext)
|
||||
|
||||
expect(result).toContain("not found")
|
||||
})
|
||||
|
||||
test("session_read executes with valid parameters", async () => {
|
||||
const { session_read } = createTestTools()
|
||||
const result = await session_read.execute({
|
||||
session_id: "ses_test123",
|
||||
include_todos: true,
|
||||
@@ -81,6 +146,7 @@ describe("session-manager tools", () => {
|
||||
})
|
||||
|
||||
test("session_read respects limit parameter", async () => {
|
||||
const { session_read } = createTestTools()
|
||||
const result = await session_read.execute({
|
||||
session_id: "ses_test123",
|
||||
limit: 10,
|
||||
@@ -90,12 +156,14 @@ describe("session-manager tools", () => {
|
||||
})
|
||||
|
||||
test("session_search executes without error", async () => {
|
||||
const { session_search } = createTestTools()
|
||||
const result = await session_search.execute({ query: "test" }, mockContext)
|
||||
|
||||
expect(typeof result).toBe("string")
|
||||
})
|
||||
|
||||
test("session_search filters by session_id", async () => {
|
||||
const { session_search } = createTestTools()
|
||||
const result = await session_search.execute({
|
||||
query: "test",
|
||||
session_id: "ses_test123",
|
||||
@@ -105,6 +173,7 @@ describe("session-manager tools", () => {
|
||||
})
|
||||
|
||||
test("session_search respects case_sensitive parameter", async () => {
|
||||
const { session_search } = createTestTools()
|
||||
const result = await session_search.execute({
|
||||
query: "TEST",
|
||||
case_sensitive: true,
|
||||
@@ -114,6 +183,7 @@ describe("session-manager tools", () => {
|
||||
})
|
||||
|
||||
test("session_search respects limit parameter", async () => {
|
||||
const { session_search } = createTestTools()
|
||||
const result = await session_search.execute({
|
||||
query: "test",
|
||||
limit: 5,
|
||||
@@ -123,12 +193,14 @@ describe("session-manager tools", () => {
|
||||
})
|
||||
|
||||
test("session_info handles non-existent session", async () => {
|
||||
const { session_info } = createTestTools()
|
||||
const result = await session_info.execute({ session_id: "ses_nonexistent" }, mockContext)
|
||||
|
||||
expect(result).toContain("not found")
|
||||
})
|
||||
|
||||
test("session_info executes with valid session", async () => {
|
||||
const { session_info } = createTestTools()
|
||||
const result = await session_info.execute({ session_id: "ses_test123" }, mockContext)
|
||||
|
||||
expect(typeof result).toBe("string")
|
||||
|
||||
@@ -27,9 +27,48 @@ function withTimeout<T>(promise: Promise<T>, ms: number, operation: string): Pro
|
||||
])
|
||||
}
|
||||
|
||||
export function createSessionManagerTools(ctx: PluginInput): Record<string, ToolDefinition> {
|
||||
type SessionManagerToolDeps = {
|
||||
getAllSessions: typeof getAllSessions
|
||||
getMainSessions: typeof getMainSessions
|
||||
getSessionInfo: typeof getSessionInfo
|
||||
readSessionMessages: typeof readSessionMessages
|
||||
readSessionTodos: typeof readSessionTodos
|
||||
sessionExists: typeof sessionExists
|
||||
setStorageClient: typeof setStorageClient
|
||||
filterSessionsByDate: typeof filterSessionsByDate
|
||||
formatSessionInfo: typeof formatSessionInfo
|
||||
formatSessionList: typeof formatSessionList
|
||||
formatSessionMessages: typeof formatSessionMessages
|
||||
formatSearchResults: typeof formatSearchResults
|
||||
searchInSession: typeof searchInSession
|
||||
}
|
||||
|
||||
const defaultSessionManagerToolDeps: SessionManagerToolDeps = {
|
||||
getAllSessions,
|
||||
getMainSessions,
|
||||
getSessionInfo,
|
||||
readSessionMessages,
|
||||
readSessionTodos,
|
||||
sessionExists,
|
||||
setStorageClient,
|
||||
filterSessionsByDate,
|
||||
formatSessionInfo,
|
||||
formatSessionList,
|
||||
formatSessionMessages,
|
||||
formatSearchResults,
|
||||
searchInSession,
|
||||
}
|
||||
|
||||
export function createSessionManagerTools(
|
||||
ctx: PluginInput,
|
||||
deps: Partial<SessionManagerToolDeps> = {},
|
||||
): Record<string, ToolDefinition> {
|
||||
const resolvedDeps: SessionManagerToolDeps = {
|
||||
...defaultSessionManagerToolDeps,
|
||||
...deps,
|
||||
}
|
||||
// Initialize storage client for SDK-based operations (beta mode)
|
||||
setStorageClient(ctx.client)
|
||||
resolvedDeps.setStorageClient(ctx.client)
|
||||
|
||||
const session_list: ToolDefinition = tool({
|
||||
description: SESSION_LIST_DESCRIPTION,
|
||||
@@ -42,18 +81,18 @@ export function createSessionManagerTools(ctx: PluginInput): Record<string, Tool
|
||||
execute: async (args: SessionListArgs, _context) => {
|
||||
try {
|
||||
const directory = args.project_path ?? ctx.directory
|
||||
let sessions = await getMainSessions({ directory })
|
||||
let sessions = await resolvedDeps.getMainSessions({ directory })
|
||||
let sessionIDs = sessions.map((s) => s.id)
|
||||
|
||||
if (args.from_date || args.to_date) {
|
||||
sessionIDs = await filterSessionsByDate(sessionIDs, args.from_date, args.to_date)
|
||||
sessionIDs = await resolvedDeps.filterSessionsByDate(sessionIDs, args.from_date, args.to_date)
|
||||
}
|
||||
|
||||
if (args.limit && args.limit > 0) {
|
||||
sessionIDs = sessionIDs.slice(0, args.limit)
|
||||
}
|
||||
|
||||
return await formatSessionList(sessionIDs)
|
||||
return await resolvedDeps.formatSessionList(sessionIDs)
|
||||
} catch (e) {
|
||||
return `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||
}
|
||||
@@ -70,11 +109,11 @@ export function createSessionManagerTools(ctx: PluginInput): Record<string, Tool
|
||||
},
|
||||
execute: async (args: SessionReadArgs, _context) => {
|
||||
try {
|
||||
if (!(await sessionExists(args.session_id))) {
|
||||
if (!(await resolvedDeps.sessionExists(args.session_id))) {
|
||||
return `Session not found: ${args.session_id}`
|
||||
}
|
||||
|
||||
let messages = await readSessionMessages(args.session_id)
|
||||
let messages = await resolvedDeps.readSessionMessages(args.session_id)
|
||||
|
||||
if (messages.length === 0) {
|
||||
return `Session not found: ${args.session_id}`
|
||||
@@ -84,9 +123,9 @@ export function createSessionManagerTools(ctx: PluginInput): Record<string, Tool
|
||||
messages = messages.slice(0, args.limit)
|
||||
}
|
||||
|
||||
const todos = args.include_todos ? await readSessionTodos(args.session_id) : undefined
|
||||
const todos = args.include_todos ? await resolvedDeps.readSessionTodos(args.session_id) : undefined
|
||||
|
||||
return formatSessionMessages(messages, args.include_todos, todos)
|
||||
return resolvedDeps.formatSessionMessages(messages, args.include_todos, todos)
|
||||
} catch (e) {
|
||||
return `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||
}
|
||||
@@ -107,10 +146,10 @@ export function createSessionManagerTools(ctx: PluginInput): Record<string, Tool
|
||||
|
||||
const searchOperation = async (): Promise<SearchResult[]> => {
|
||||
if (args.session_id) {
|
||||
return searchInSession(args.session_id, args.query, args.case_sensitive, resultLimit)
|
||||
return resolvedDeps.searchInSession(args.session_id, args.query, args.case_sensitive, resultLimit)
|
||||
}
|
||||
|
||||
const allSessions = await getAllSessions()
|
||||
const allSessions = await resolvedDeps.getAllSessions()
|
||||
const sessionsToScan = allSessions.slice(0, MAX_SESSIONS_TO_SCAN)
|
||||
|
||||
const allResults: SearchResult[] = []
|
||||
@@ -118,7 +157,7 @@ export function createSessionManagerTools(ctx: PluginInput): Record<string, Tool
|
||||
if (allResults.length >= resultLimit) break
|
||||
|
||||
const remaining = resultLimit - allResults.length
|
||||
const sessionResults = await searchInSession(sid, args.query, args.case_sensitive, remaining)
|
||||
const sessionResults = await resolvedDeps.searchInSession(sid, args.query, args.case_sensitive, remaining)
|
||||
allResults.push(...sessionResults)
|
||||
}
|
||||
|
||||
@@ -127,7 +166,7 @@ export function createSessionManagerTools(ctx: PluginInput): Record<string, Tool
|
||||
|
||||
const results = await withTimeout(searchOperation(), SEARCH_TIMEOUT_MS, "Search")
|
||||
|
||||
return formatSearchResults(results)
|
||||
return resolvedDeps.formatSearchResults(results)
|
||||
} catch (e) {
|
||||
return `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||
}
|
||||
@@ -141,13 +180,13 @@ export function createSessionManagerTools(ctx: PluginInput): Record<string, Tool
|
||||
},
|
||||
execute: async (args: SessionInfoArgs, _context) => {
|
||||
try {
|
||||
const info = await getSessionInfo(args.session_id)
|
||||
const info = await resolvedDeps.getSessionInfo(args.session_id)
|
||||
|
||||
if (!info) {
|
||||
return `Session not found: ${args.session_id}`
|
||||
}
|
||||
|
||||
return formatSessionInfo(info)
|
||||
return resolvedDeps.formatSessionInfo(info)
|
||||
} catch (e) {
|
||||
return `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, beforeEach, mock } from "bun:test"
|
||||
import { describe, it, expect, beforeEach, mock, spyOn } from "bun:test"
|
||||
import type { ToolContext } from "@opencode-ai/plugin/tool"
|
||||
import { createSkillMcpTool, applyGrepFilter } from "./tools"
|
||||
import { SkillMcpManager } from "../../features/skill-mcp-manager"
|
||||
@@ -165,6 +165,34 @@ describe("skill_mcp tool", () => {
|
||||
expect(tool.description).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("session resolution", () => {
|
||||
it("uses the tool context sessionID when the fallback getter is empty", async () => {
|
||||
// given
|
||||
loadedSkills = [
|
||||
createMockSkillWithMcp("test-skill", {
|
||||
"test-server": { command: "echo", args: ["test"] },
|
||||
}),
|
||||
]
|
||||
const callToolSpy = spyOn(manager, "callTool").mockResolvedValue({ content: [] } as never)
|
||||
const tool = createSkillMcpTool({
|
||||
manager,
|
||||
getLoadedSkills: () => loadedSkills,
|
||||
getSessionID: () => "",
|
||||
})
|
||||
|
||||
// when
|
||||
await tool.execute({ mcp_name: "test-server", tool_name: "some-tool" }, mockContext)
|
||||
|
||||
// then
|
||||
expect(callToolSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sessionID: mockContext.sessionID }),
|
||||
expect.any(Object),
|
||||
"some-tool",
|
||||
{},
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("applyGrepFilter", () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
|
||||
import type { ToolContext } from "@opencode-ai/plugin/tool"
|
||||
import { BUILTIN_MCP_TOOL_HINTS, SKILL_MCP_DESCRIPTION } from "./constants"
|
||||
import type { SkillMcpArgs } from "./types"
|
||||
import type { SkillMcpManager, SkillMcpClientInfo, SkillMcpServerContext } from "../../features/skill-mcp-manager"
|
||||
@@ -7,7 +8,7 @@ import type { LoadedSkill } from "../../features/opencode-skill-loader/types"
|
||||
interface SkillMcpToolOptions {
|
||||
manager: SkillMcpManager
|
||||
getLoadedSkills: () => LoadedSkill[]
|
||||
getSessionID: () => string
|
||||
getSessionID?: () => string | undefined
|
||||
}
|
||||
|
||||
type OperationType = { type: "tool" | "resource" | "prompt"; name: string }
|
||||
@@ -136,7 +137,7 @@ export function createSkillMcpTool(options: SkillMcpToolOptions): ToolDefinition
|
||||
.optional()
|
||||
.describe("Regex pattern to filter output lines (only matching lines returned)"),
|
||||
},
|
||||
async execute(args: SkillMcpArgs) {
|
||||
async execute(args: SkillMcpArgs, toolContext: ToolContext) {
|
||||
const operation = validateOperationParams(args)
|
||||
const skills = getLoadedSkills()
|
||||
const found = findMcpServer(args.mcp_name, skills)
|
||||
@@ -156,10 +157,16 @@ export function createSkillMcpTool(options: SkillMcpToolOptions): ToolDefinition
|
||||
)
|
||||
}
|
||||
|
||||
const sessionID = toolContext.sessionID || getSessionID?.()
|
||||
if (!sessionID) {
|
||||
throw new Error("No active session available for skill MCP call.")
|
||||
}
|
||||
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: args.mcp_name,
|
||||
skillName: found.skill.name,
|
||||
sessionID: getSessionID(),
|
||||
sessionID,
|
||||
scope: found.skill.scope,
|
||||
}
|
||||
|
||||
const context: SkillMcpServerContext = {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import type { LoadedSkill } from "../../features/opencode-skill-loader/types"
|
||||
|
||||
function requireFresh<T>(modulePath: string): T {
|
||||
const resolvedPath = require.resolve(modulePath)
|
||||
if (require.cache?.[resolvedPath]) {
|
||||
delete require.cache[resolvedPath]
|
||||
}
|
||||
return require(modulePath) as T
|
||||
}
|
||||
|
||||
function createSkillTool(...args: Parameters<typeof import("./tools").createSkillTool>): ReturnType<typeof import("./tools").createSkillTool> {
|
||||
return requireFresh<typeof import("./tools")>("./tools").createSkillTool(...args)
|
||||
}
|
||||
|
||||
function createMockSkill(name: string): LoadedSkill {
|
||||
return {
|
||||
name,
|
||||
path: `/test/skills/${name}/SKILL.md`,
|
||||
resolvedPath: `/test/skills/${name}`,
|
||||
definition: {
|
||||
name,
|
||||
description: `Test skill ${name}`,
|
||||
template: `Test skill template for ${name}`,
|
||||
},
|
||||
scope: "opencode-project",
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForRefresh(predicate: () => boolean): Promise<void> {
|
||||
for (let attempt = 0; attempt < 200; attempt += 1) {
|
||||
if (predicate()) {
|
||||
return
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 10))
|
||||
}
|
||||
|
||||
throw new Error("Timed out waiting for async skill description refresh")
|
||||
}
|
||||
|
||||
describe("skill tool - async native skill description refresh", () => {
|
||||
it("updates description after async native skills resolve", async () => {
|
||||
//#given
|
||||
let allCallCount = 0
|
||||
const tool = createSkillTool({
|
||||
skills: [createMockSkill("seeded-skill")],
|
||||
commands: [],
|
||||
nativeSkills: {
|
||||
async all() {
|
||||
allCallCount += 1
|
||||
|
||||
return [{
|
||||
name: "async-native-skill",
|
||||
description: "Async native skill from plugin input",
|
||||
location: "/external/skills/async-native-skill/SKILL.md",
|
||||
content: "Async native skill body",
|
||||
}]
|
||||
},
|
||||
async get() {
|
||||
return undefined
|
||||
},
|
||||
async dirs() {
|
||||
return []
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(tool.description).toContain("seeded-skill")
|
||||
expect(tool.description).not.toContain("async-native-skill")
|
||||
|
||||
//#when
|
||||
await waitForRefresh(() => tool.description.includes("async-native-skill"))
|
||||
|
||||
//#then
|
||||
expect(allCallCount).toBeGreaterThanOrEqual(1)
|
||||
expect(tool.description).toContain("seeded-skill")
|
||||
expect(tool.description).toContain("async-native-skill")
|
||||
})
|
||||
})
|
||||
@@ -8,7 +8,7 @@ Skills and commands provide specialized knowledge and step-by-step guidance.
|
||||
Use this when a task matches an available skill's or command's description.
|
||||
|
||||
**How to use:**
|
||||
- Call with a skill name: name='code-review'
|
||||
- Call with a skill name: name='review-work'
|
||||
- Call with a command name (without leading slash): name='publish'
|
||||
- The tool will return detailed instructions with your context applied.
|
||||
`
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { TOOL_DESCRIPTION_NO_SKILLS, TOOL_DESCRIPTION_PREFIX } from "./constants"
|
||||
import { sortByScopePriority } from "./scope-priority"
|
||||
import type { SkillInfo } from "./types"
|
||||
import type { CommandInfo } from "../slashcommand/types"
|
||||
|
||||
function formatSkillCommand(skill: SkillInfo): string {
|
||||
const lines = [
|
||||
" <command>",
|
||||
` <name>/${skill.name}</name>`,
|
||||
` <description>${skill.description}</description>`,
|
||||
` <scope>${skill.scope}</scope>`,
|
||||
]
|
||||
|
||||
if (skill.compatibility) {
|
||||
lines.push(` <compatibility>${skill.compatibility}</compatibility>`)
|
||||
}
|
||||
|
||||
lines.push(" </command>")
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
function formatSlashCommand(command: CommandInfo): string {
|
||||
const argumentHint = typeof command.metadata.argumentHint === "string"
|
||||
? command.metadata.argumentHint.trim()
|
||||
: undefined
|
||||
const lines = [
|
||||
" <command>",
|
||||
` <name>/${command.name}</name>`,
|
||||
` <description>${command.metadata.description || "(no description)"}</description>`,
|
||||
` <scope>${command.scope}</scope>`,
|
||||
]
|
||||
|
||||
if (argumentHint) {
|
||||
lines.push(` <argument>${argumentHint}</argument>`)
|
||||
}
|
||||
|
||||
lines.push(" </command>")
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
export function formatCombinedDescription(skills: SkillInfo[], commands: CommandInfo[]): string {
|
||||
if (skills.length === 0 && commands.length === 0) {
|
||||
return TOOL_DESCRIPTION_NO_SKILLS
|
||||
}
|
||||
|
||||
const availableItems = [
|
||||
...sortByScopePriority(skills).map(formatSkillCommand),
|
||||
...sortByScopePriority(commands).map(formatSlashCommand),
|
||||
]
|
||||
|
||||
if (availableItems.length === 0) {
|
||||
return TOOL_DESCRIPTION_PREFIX
|
||||
}
|
||||
|
||||
return `${TOOL_DESCRIPTION_PREFIX}
|
||||
<available_items>
|
||||
Priority: project > user > opencode > builtin/plugin | Skills listed before commands
|
||||
Invoke via: skill(name="item-name") - omit leading slash for commands.
|
||||
${availableItems.join("\n")}
|
||||
</available_items>`
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { Prompt, Resource, Tool } from "@modelcontextprotocol/sdk/types.js"
|
||||
import { sanitizeJsonSchema } from "../../plugin/normalize-tool-arg-schemas"
|
||||
import type {
|
||||
SkillMcpClientInfo,
|
||||
SkillMcpManager,
|
||||
SkillMcpServerContext,
|
||||
} from "../../features/skill-mcp-manager"
|
||||
import type { LoadedSkill } from "../../features/opencode-skill-loader"
|
||||
|
||||
export async function formatMcpCapabilities(
|
||||
skill: LoadedSkill,
|
||||
manager: SkillMcpManager,
|
||||
sessionID: string
|
||||
): Promise<string | null> {
|
||||
if (!skill.mcpConfig || Object.keys(skill.mcpConfig).length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const sections: string[] = ["", "## Available MCP Servers", ""]
|
||||
|
||||
for (const [serverName, config] of Object.entries(skill.mcpConfig)) {
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName,
|
||||
skillName: skill.name,
|
||||
sessionID,
|
||||
scope: skill.scope,
|
||||
}
|
||||
const context: SkillMcpServerContext = {
|
||||
config,
|
||||
skillName: skill.name,
|
||||
}
|
||||
|
||||
sections.push(`### ${serverName}`, "")
|
||||
|
||||
try {
|
||||
const [tools, resources, prompts] = await Promise.all([
|
||||
manager.listTools(info, context).catch(() => []),
|
||||
manager.listResources(info, context).catch(() => []),
|
||||
manager.listPrompts(info, context).catch(() => []),
|
||||
])
|
||||
|
||||
appendToolSections(sections, tools as Tool[])
|
||||
appendResourceSection(sections, resources as Resource[])
|
||||
appendPromptSection(sections, prompts as Prompt[])
|
||||
|
||||
if (tools.length === 0 && resources.length === 0 && prompts.length === 0) {
|
||||
sections.push("*No capabilities discovered*")
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
sections.push(`*Failed to connect: ${errorMessage.split("\n")[0]}*`)
|
||||
}
|
||||
|
||||
sections.push("", `Use \`skill_mcp\` tool with \`mcp_name=\"${serverName}\"\` to invoke.`, "")
|
||||
}
|
||||
|
||||
return sections.join("\n")
|
||||
}
|
||||
|
||||
function appendToolSections(sections: string[], tools: Tool[]): void {
|
||||
if (tools.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
sections.push("**Tools:**", "")
|
||||
|
||||
for (const toolDefinition of tools) {
|
||||
sections.push(`#### \`${toolDefinition.name}\``)
|
||||
if (toolDefinition.description) {
|
||||
sections.push(toolDefinition.description)
|
||||
}
|
||||
sections.push(
|
||||
"",
|
||||
"**inputSchema:**",
|
||||
"```json",
|
||||
JSON.stringify(sanitizeJsonSchema(toolDefinition.inputSchema), null, 2),
|
||||
"```",
|
||||
""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function appendResourceSection(sections: string[], resources: Resource[]): void {
|
||||
if (resources.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
sections.push(`**Resources**: ${resources.map((resource) => resource.uri).join(", ")}`)
|
||||
}
|
||||
|
||||
function appendPromptSection(sections: string[], prompts: Prompt[]): void {
|
||||
if (prompts.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
sections.push(`**Prompts**: ${prompts.map((prompt) => prompt.name).join(", ")}`)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { SkillInfo } from "./types"
|
||||
import type { LoadedSkill } from "../../features/opencode-skill-loader"
|
||||
|
||||
export type NativeSkillEntry = {
|
||||
name: string
|
||||
description: string
|
||||
location: string
|
||||
content: string
|
||||
}
|
||||
|
||||
export function loadedSkillToInfo(skill: LoadedSkill): SkillInfo {
|
||||
return {
|
||||
name: skill.name,
|
||||
description: skill.definition.description || "",
|
||||
location: skill.path,
|
||||
scope: skill.scope,
|
||||
license: skill.license,
|
||||
compatibility: skill.compatibility,
|
||||
metadata: skill.metadata,
|
||||
allowedTools: skill.allowedTools,
|
||||
}
|
||||
}
|
||||
|
||||
function nativeSkillToLoadedSkill(native: NativeSkillEntry): LoadedSkill {
|
||||
return {
|
||||
name: native.name,
|
||||
path: native.location,
|
||||
definition: {
|
||||
name: native.name,
|
||||
description: native.description,
|
||||
template: native.content,
|
||||
},
|
||||
scope: "config",
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeNativeSkills(skills: LoadedSkill[], nativeSkills: NativeSkillEntry[]): void {
|
||||
const knownNames = new Set(skills.map((skill) => skill.name))
|
||||
for (const native of nativeSkills) {
|
||||
if (knownNames.has(native.name)) continue
|
||||
skills.push(nativeSkillToLoadedSkill(native))
|
||||
knownNames.add(native.name)
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeNativeSkillInfos(skillInfos: SkillInfo[], nativeSkills: NativeSkillEntry[]): void {
|
||||
const knownNames = new Set(skillInfos.map((skill) => skill.name))
|
||||
for (const native of nativeSkills) {
|
||||
if (knownNames.has(native.name)) continue
|
||||
skillInfos.push({
|
||||
name: native.name,
|
||||
description: native.description,
|
||||
location: native.location,
|
||||
scope: "config",
|
||||
})
|
||||
knownNames.add(native.name)
|
||||
}
|
||||
}
|
||||
|
||||
export function isPromiseLike<TValue>(value: TValue | Promise<TValue>): value is Promise<TValue> {
|
||||
return typeof value === "object" && value !== null && "then" in value
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export const SCOPE_PRIORITY: Record<string, number> = {
|
||||
project: 4,
|
||||
user: 3,
|
||||
opencode: 2,
|
||||
"opencode-project": 2,
|
||||
plugin: 1,
|
||||
config: 1,
|
||||
builtin: 1,
|
||||
}
|
||||
|
||||
export function sortByScopePriority<TItem extends { scope: string }>(items: TItem[]): TItem[] {
|
||||
return [...items].sort((left, right) => {
|
||||
const leftPriority = SCOPE_PRIORITY[left.scope] || 0
|
||||
const rightPriority = SCOPE_PRIORITY[right.scope] || 0
|
||||
return rightPriority - leftPriority
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { LoadedSkill } from "../../features/opencode-skill-loader"
|
||||
import { extractSkillTemplate } from "../../features/opencode-skill-loader/skill-content"
|
||||
|
||||
const SKILL_INSTRUCTION_PATTERN = /<skill-instruction>([\s\S]*?)<\/skill-instruction>/
|
||||
|
||||
function trimSkillInstruction(template: string): string {
|
||||
const templateMatch = template.match(SKILL_INSTRUCTION_PATTERN)
|
||||
return templateMatch ? templateMatch[1].trim() : template
|
||||
}
|
||||
|
||||
export async function extractSkillBody(skill: LoadedSkill): Promise<string> {
|
||||
if (skill.lazyContent) {
|
||||
const fullTemplate = await skill.lazyContent.load()
|
||||
return trimSkillInstruction(fullTemplate)
|
||||
}
|
||||
|
||||
if (skill.scope === "config" && skill.definition.template) {
|
||||
return trimSkillInstruction(skill.definition.template)
|
||||
}
|
||||
|
||||
if (skill.path) {
|
||||
return extractSkillTemplate(skill)
|
||||
}
|
||||
|
||||
return trimSkillInstruction(skill.definition.template || "")
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { sortByScopePriority } from "./scope-priority"
|
||||
import type { CommandInfo } from "../slashcommand/types"
|
||||
import type { LoadedSkill } from "../../features/opencode-skill-loader"
|
||||
|
||||
export function matchSkillByName(skills: LoadedSkill[], requestedName: string): LoadedSkill | undefined {
|
||||
const normalizedName = requestedName.toLowerCase()
|
||||
const exactMatch = skills.find((skill) => skill.name.toLowerCase() === normalizedName)
|
||||
if (exactMatch) {
|
||||
return exactMatch
|
||||
}
|
||||
|
||||
const shortNameMatches = skills.filter((skill) => {
|
||||
const parts = skill.name.split("/")
|
||||
const shortName = parts[parts.length - 1]
|
||||
return parts.length > 1 && shortName?.toLowerCase() === normalizedName
|
||||
})
|
||||
|
||||
if (shortNameMatches.length === 1) {
|
||||
return shortNameMatches[0]
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function matchCommandByName(commands: CommandInfo[], requestedName: string): CommandInfo | undefined {
|
||||
const normalizedName = requestedName.toLowerCase()
|
||||
return sortByScopePriority(commands).find((command) => command.name.toLowerCase() === normalizedName)
|
||||
}
|
||||
|
||||
export function findPartialMatches(
|
||||
skills: LoadedSkill[],
|
||||
commands: CommandInfo[],
|
||||
requestedName: string
|
||||
): string[] {
|
||||
const normalizedName = requestedName.toLowerCase()
|
||||
return [
|
||||
...skills.map((skill) => skill.name),
|
||||
...commands.map((command) => `/${command.name}`),
|
||||
].filter((name) => name.toLowerCase().includes(normalizedName))
|
||||
}
|
||||
+57
-261
@@ -1,257 +1,52 @@
|
||||
import { dirname } from "node:path"
|
||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
|
||||
import { TOOL_DESCRIPTION_NO_SKILLS, TOOL_DESCRIPTION_PREFIX } from "./constants"
|
||||
import type { SkillArgs, SkillInfo, SkillLoadOptions } from "./types"
|
||||
import type { ToolContext } from "@opencode-ai/plugin/tool"
|
||||
import { TOOL_DESCRIPTION_PREFIX } from "./constants"
|
||||
import type { SkillArgs, SkillLoadOptions } from "./types"
|
||||
import type { LoadedSkill } from "../../features/opencode-skill-loader"
|
||||
import { getAllSkills, extractSkillTemplate, clearSkillCache } from "../../features/opencode-skill-loader/skill-content"
|
||||
import { getAllSkills, clearSkillCache } from "../../features/opencode-skill-loader/skill-content"
|
||||
import { injectGitMasterConfig } from "../../features/opencode-skill-loader/skill-content"
|
||||
import type { SkillMcpManager, SkillMcpClientInfo, SkillMcpServerContext } from "../../features/skill-mcp-manager"
|
||||
import type { Tool, Resource, Prompt } from "@modelcontextprotocol/sdk/types.js"
|
||||
import { sanitizeJsonSchema } from "../../plugin/normalize-tool-arg-schemas"
|
||||
import { discoverCommandsSync } from "../slashcommand/command-discovery"
|
||||
import type { CommandInfo } from "../slashcommand/types"
|
||||
import { formatLoadedCommand } from "../slashcommand/command-output-formatter"
|
||||
|
||||
type NativeSkillEntry = {
|
||||
name: string
|
||||
description: string
|
||||
location: string
|
||||
content: string
|
||||
}
|
||||
// Priority: project > user > opencode/opencode-project > builtin/config
|
||||
const scopePriority: Record<string, number> = {
|
||||
project: 4,
|
||||
user: 3,
|
||||
opencode: 2,
|
||||
"opencode-project": 2,
|
||||
plugin: 1,
|
||||
config: 1,
|
||||
builtin: 1,
|
||||
}
|
||||
|
||||
function loadedSkillToInfo(skill: LoadedSkill): SkillInfo {
|
||||
return {
|
||||
name: skill.name,
|
||||
description: skill.definition.description || "",
|
||||
location: skill.path,
|
||||
scope: skill.scope,
|
||||
license: skill.license,
|
||||
compatibility: skill.compatibility,
|
||||
metadata: skill.metadata,
|
||||
allowedTools: skill.allowedTools,
|
||||
}
|
||||
}
|
||||
|
||||
function nativeSkillToLoadedSkill(native: NativeSkillEntry): LoadedSkill {
|
||||
return {
|
||||
name: native.name,
|
||||
path: native.location,
|
||||
definition: {
|
||||
name: native.name,
|
||||
description: native.description,
|
||||
template: native.content,
|
||||
},
|
||||
scope: "config",
|
||||
}
|
||||
}
|
||||
|
||||
function mergeNativeSkills(skills: LoadedSkill[], nativeSkills: NativeSkillEntry[]): void {
|
||||
const knownNames = new Set(skills.map(skill => skill.name))
|
||||
for (const native of nativeSkills) {
|
||||
if (knownNames.has(native.name)) continue
|
||||
skills.push(nativeSkillToLoadedSkill(native))
|
||||
knownNames.add(native.name)
|
||||
}
|
||||
}
|
||||
|
||||
function mergeNativeSkillInfos(skillInfos: SkillInfo[], nativeSkills: NativeSkillEntry[]): void {
|
||||
const knownNames = new Set(skillInfos.map(skill => skill.name))
|
||||
for (const native of nativeSkills) {
|
||||
if (knownNames.has(native.name)) continue
|
||||
skillInfos.push({
|
||||
name: native.name,
|
||||
description: native.description,
|
||||
location: native.location,
|
||||
scope: "config",
|
||||
})
|
||||
knownNames.add(native.name)
|
||||
}
|
||||
}
|
||||
|
||||
function isPromiseLike<T>(value: T | Promise<T>): value is Promise<T> {
|
||||
return typeof value === "object" && value !== null && "then" in value
|
||||
}
|
||||
|
||||
function formatCombinedDescription(skills: SkillInfo[], commands: CommandInfo[]): string {
|
||||
const lines: string[] = []
|
||||
|
||||
if (skills.length === 0 && commands.length === 0) {
|
||||
return TOOL_DESCRIPTION_NO_SKILLS
|
||||
}
|
||||
|
||||
// Uses module-level scopePriority for consistent priority ordering
|
||||
|
||||
const allItems: string[] = []
|
||||
|
||||
// Skills rendered as command items (skills are also slash-invocable)
|
||||
if (skills.length > 0) {
|
||||
const sortedSkills = [...skills].sort((a, b) => {
|
||||
const priorityA = scopePriority[a.scope] || 0
|
||||
const priorityB = scopePriority[b.scope] || 0
|
||||
return priorityB - priorityA
|
||||
})
|
||||
sortedSkills.forEach(skill => {
|
||||
const parts = [
|
||||
" <command>",
|
||||
` <name>/${skill.name}</name>`,
|
||||
` <description>${skill.description}</description>`,
|
||||
` <scope>${skill.scope}</scope>`,
|
||||
]
|
||||
if (skill.compatibility) {
|
||||
parts.push(` <compatibility>${skill.compatibility}</compatibility>`)
|
||||
}
|
||||
parts.push(" </command>")
|
||||
allItems.push(parts.join("\n"))
|
||||
})
|
||||
}
|
||||
|
||||
// Sort and add commands second (commands after skills)
|
||||
if (commands.length > 0) {
|
||||
const sortedCommands = [...commands].sort((a, b) => {
|
||||
const priorityA = scopePriority[a.scope] || 0
|
||||
const priorityB = scopePriority[b.scope] || 0
|
||||
return priorityB - priorityA // Higher priority first
|
||||
})
|
||||
sortedCommands.forEach(cmd => {
|
||||
const hint = cmd.metadata.argumentHint ? ` ${cmd.metadata.argumentHint}` : ""
|
||||
const parts = [
|
||||
" <command>",
|
||||
` <name>/${cmd.name}</name>`,
|
||||
` <description>${cmd.metadata.description || "(no description)"}</description>`,
|
||||
` <scope>${cmd.scope}</scope>`,
|
||||
]
|
||||
if (hint) {
|
||||
parts.push(` <argument>${hint.trim()}</argument>`)
|
||||
}
|
||||
parts.push(" </command>")
|
||||
allItems.push(parts.join("\n"))
|
||||
})
|
||||
}
|
||||
|
||||
if (allItems.length > 0) {
|
||||
lines.push(`\n<available_items>\nPriority: project > user > opencode > builtin/plugin | Skills listed before commands\nInvoke via: skill(name="item-name") — omit leading slash for commands.\n${allItems.join("\n")}\n</available_items>`)
|
||||
}
|
||||
|
||||
return TOOL_DESCRIPTION_PREFIX + lines.join("")
|
||||
}
|
||||
|
||||
async function extractSkillBody(skill: LoadedSkill): Promise<string> {
|
||||
if (skill.lazyContent) {
|
||||
const fullTemplate = await skill.lazyContent.load()
|
||||
const templateMatch = fullTemplate.match(/<skill-instruction>([\s\S]*?)<\/skill-instruction>/)
|
||||
return templateMatch ? templateMatch[1].trim() : fullTemplate
|
||||
}
|
||||
|
||||
if (skill.scope === "config" && skill.definition.template) {
|
||||
const templateMatch = skill.definition.template.match(/<skill-instruction>([\s\S]*?)<\/skill-instruction>/)
|
||||
return templateMatch ? templateMatch[1].trim() : skill.definition.template
|
||||
}
|
||||
|
||||
if (skill.path) {
|
||||
return extractSkillTemplate(skill)
|
||||
}
|
||||
|
||||
const templateMatch = skill.definition.template?.match(/<skill-instruction>([\s\S]*?)<\/skill-instruction>/)
|
||||
return templateMatch ? templateMatch[1].trim() : skill.definition.template || ""
|
||||
}
|
||||
|
||||
async function formatMcpCapabilities(
|
||||
skill: LoadedSkill,
|
||||
manager: SkillMcpManager,
|
||||
sessionID: string
|
||||
): Promise<string | null> {
|
||||
if (!skill.mcpConfig || Object.keys(skill.mcpConfig).length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const sections: string[] = ["", "## Available MCP Servers", ""]
|
||||
|
||||
for (const [serverName, config] of Object.entries(skill.mcpConfig)) {
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName,
|
||||
skillName: skill.name,
|
||||
sessionID,
|
||||
}
|
||||
const context: SkillMcpServerContext = {
|
||||
config,
|
||||
skillName: skill.name,
|
||||
}
|
||||
|
||||
sections.push(`### ${serverName}`)
|
||||
sections.push("")
|
||||
|
||||
try {
|
||||
const [tools, resources, prompts] = await Promise.all([
|
||||
manager.listTools(info, context).catch(() => []),
|
||||
manager.listResources(info, context).catch(() => []),
|
||||
manager.listPrompts(info, context).catch(() => []),
|
||||
])
|
||||
|
||||
if (tools.length > 0) {
|
||||
sections.push("**Tools:**")
|
||||
sections.push("")
|
||||
for (const t of tools as Tool[]) {
|
||||
sections.push(`#### \`${t.name}\``)
|
||||
if (t.description) {
|
||||
sections.push(t.description)
|
||||
}
|
||||
sections.push("")
|
||||
sections.push("**inputSchema:**")
|
||||
sections.push("```json")
|
||||
sections.push(JSON.stringify(sanitizeJsonSchema(t.inputSchema), null, 2))
|
||||
sections.push("```")
|
||||
sections.push("")
|
||||
}
|
||||
}
|
||||
if (resources.length > 0) {
|
||||
sections.push(`**Resources**: ${resources.map((r: Resource) => r.uri).join(", ")}`)
|
||||
}
|
||||
if (prompts.length > 0) {
|
||||
sections.push(`**Prompts**: ${prompts.map((p: Prompt) => p.name).join(", ")}`)
|
||||
}
|
||||
|
||||
if (tools.length === 0 && resources.length === 0 && prompts.length === 0) {
|
||||
sections.push("*No capabilities discovered*")
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
sections.push(`*Failed to connect: ${errorMessage.split("\n")[0]}*`)
|
||||
}
|
||||
|
||||
sections.push("")
|
||||
sections.push(`Use \`skill_mcp\` tool with \`mcp_name="${serverName}"\` to invoke.`)
|
||||
sections.push("")
|
||||
}
|
||||
|
||||
return sections.join("\n")
|
||||
}
|
||||
import { formatCombinedDescription } from "./description-formatter"
|
||||
import { formatMcpCapabilities } from "./mcp-capability-formatter"
|
||||
import {
|
||||
findPartialMatches,
|
||||
matchCommandByName,
|
||||
matchSkillByName,
|
||||
} from "./skill-matcher"
|
||||
import { extractSkillBody } from "./skill-body"
|
||||
import {
|
||||
isPromiseLike,
|
||||
loadedSkillToInfo,
|
||||
mergeNativeSkillInfos,
|
||||
mergeNativeSkills,
|
||||
} from "./native-skills"
|
||||
|
||||
export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition {
|
||||
let cachedDescription: string | null = null
|
||||
|
||||
const getSkills = async (): Promise<LoadedSkill[]> => {
|
||||
clearSkillCache()
|
||||
const discovered = await getAllSkills({disabledSkills: options?.disabledSkills, browserProvider: options?.browserProvider})
|
||||
const discovered = await getAllSkills({
|
||||
disabledSkills: options?.disabledSkills,
|
||||
browserProvider: options?.browserProvider,
|
||||
})
|
||||
const allSkills = !options.skills
|
||||
? discovered
|
||||
: [...discovered, ...options.skills.filter(s => !new Set(discovered.map(d => d.name)).has(s.name))]
|
||||
: [
|
||||
...discovered,
|
||||
...options.skills.filter(
|
||||
(skill) => !new Set(discovered.map((discoveredSkill) => discoveredSkill.name)).has(skill.name)
|
||||
),
|
||||
]
|
||||
|
||||
if (options.nativeSkills) {
|
||||
try {
|
||||
const nativeAll = await options.nativeSkills.all()
|
||||
mergeNativeSkills(allSkills, nativeAll)
|
||||
} catch {
|
||||
// Native skill discovery may not be available
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,8 +60,8 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
|
||||
})
|
||||
}
|
||||
|
||||
const buildDescription = async (): Promise<string> => {
|
||||
if (cachedDescription) return cachedDescription
|
||||
const buildDescription = async (force = false): Promise<string> => {
|
||||
if (!force && cachedDescription) return cachedDescription
|
||||
const skills = await getSkills()
|
||||
const commands = getCommands()
|
||||
const skillInfos = skills.map(loadedSkillToInfo)
|
||||
@@ -288,13 +83,12 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
|
||||
mergeNativeSkillInfos(skillInfos, nativeAll)
|
||||
}
|
||||
} catch {
|
||||
// Native skill discovery may not be available
|
||||
}
|
||||
}
|
||||
|
||||
cachedDescription = formatCombinedDescription(skillInfos, commandsForDescription)
|
||||
if (needsAsyncRefresh) {
|
||||
void buildDescription()
|
||||
void buildDescription(true)
|
||||
}
|
||||
} else if (options.commands !== undefined) {
|
||||
cachedDescription = formatCombinedDescription([], options.commands)
|
||||
@@ -310,23 +104,30 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
|
||||
return cachedDescription ?? TOOL_DESCRIPTION_PREFIX
|
||||
},
|
||||
args: {
|
||||
name: tool.schema.string().describe("The skill or command name (e.g., 'code-review' or 'publish'). Use without leading slash for commands."),
|
||||
name: tool.schema.string().describe("The skill or command name (e.g., 'review-work' or 'publish'). Use without leading slash for commands."),
|
||||
user_message: tool.schema
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Optional arguments or context for command invocation. Example: name='publish', user_message='patch'"),
|
||||
},
|
||||
async execute(args: SkillArgs, ctx?: { agent?: string }) {
|
||||
async execute(args: SkillArgs, ctx?: ToolContext) {
|
||||
const skills = await getSkills()
|
||||
const commands = getCommands()
|
||||
cachedDescription = formatCombinedDescription(skills.map(loadedSkillToInfo), commands)
|
||||
|
||||
const requestedName = args.name.replace(/^\//, "")
|
||||
|
||||
// Check skills first (exact match, case-insensitive)
|
||||
const matchedSkill = skills.find(s => s.name.toLowerCase() === requestedName.toLowerCase())
|
||||
const matchedSkill = matchSkillByName(skills, requestedName)
|
||||
|
||||
if (matchedSkill) {
|
||||
await ctx?.ask({
|
||||
permission: "skill",
|
||||
patterns: [matchedSkill.name],
|
||||
always: [matchedSkill.name],
|
||||
metadata: {
|
||||
skill: matchedSkill.name,
|
||||
},
|
||||
})
|
||||
|
||||
if (matchedSkill.definition.agent && (!ctx?.agent || matchedSkill.definition.agent !== ctx.agent)) {
|
||||
throw new Error(`Skill "${matchedSkill.name}" is restricted to agent "${matchedSkill.definition.agent}"`)
|
||||
}
|
||||
@@ -347,11 +148,17 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
|
||||
body,
|
||||
]
|
||||
|
||||
if (options.mcpManager && options.getSessionID && matchedSkill.mcpConfig) {
|
||||
if (options.mcpManager && matchedSkill.mcpConfig) {
|
||||
const sessionID = ctx?.sessionID || options.getSessionID?.()
|
||||
|
||||
if (!sessionID) {
|
||||
return output.join("\n")
|
||||
}
|
||||
|
||||
const mcpInfo = await formatMcpCapabilities(
|
||||
matchedSkill,
|
||||
options.mcpManager,
|
||||
options.getSessionID()
|
||||
sessionID
|
||||
)
|
||||
if (mcpInfo) {
|
||||
output.push(mcpInfo)
|
||||
@@ -361,27 +168,13 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
|
||||
return output.join("\n")
|
||||
}
|
||||
|
||||
// Check commands (exact match, case-insensitive) - sort by priority first
|
||||
const sortedCommands = [...commands].sort((a, b) => {
|
||||
const priorityA = scopePriority[a.scope] || 0
|
||||
const priorityB = scopePriority[b.scope] || 0
|
||||
return priorityB - priorityA // Higher priority first
|
||||
})
|
||||
const matchedCommand = sortedCommands.find(c => c.name.toLowerCase() === requestedName.toLowerCase())
|
||||
const matchedCommand = matchCommandByName(commands, requestedName)
|
||||
|
||||
if (matchedCommand) {
|
||||
return await formatLoadedCommand(matchedCommand, args.user_message)
|
||||
}
|
||||
|
||||
// No match found — provide helpful error with partial matches
|
||||
const allNames = [
|
||||
...skills.map(s => s.name),
|
||||
...commands.map(c => `/${c.name}`),
|
||||
]
|
||||
|
||||
const partialMatches = allNames.filter(n =>
|
||||
n.toLowerCase().includes(requestedName.toLowerCase())
|
||||
)
|
||||
const partialMatches = findPartialMatches(skills, commands, requestedName)
|
||||
|
||||
if (partialMatches.length > 0) {
|
||||
throw new Error(
|
||||
@@ -389,7 +182,10 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
|
||||
)
|
||||
}
|
||||
|
||||
const available = allNames.join(", ")
|
||||
const available = [
|
||||
...skills.map((skill) => skill.name),
|
||||
...commands.map((command) => `/${command.name}`),
|
||||
].join(", ")
|
||||
throw new Error(
|
||||
`Skill or command "${args.name}" not found. Available: ${available || "none"}`
|
||||
)
|
||||
|
||||
@@ -29,7 +29,7 @@ export interface SkillLoadOptions {
|
||||
/** MCP manager for querying skill-embedded MCP servers */
|
||||
mcpManager?: SkillMcpManager
|
||||
/** Session ID getter for MCP client identification */
|
||||
getSessionID?: () => string
|
||||
getSessionID?: () => string | undefined
|
||||
/** Git master configuration for watermark/co-author settings */
|
||||
gitMasterConfig?: GitMasterConfig
|
||||
disabledSkills?: Set<string>
|
||||
|
||||
+131
-15
@@ -1,26 +1,32 @@
|
||||
import { afterAll, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"
|
||||
import type { ToolContext } from "@opencode-ai/plugin/tool"
|
||||
import * as fs from "node:fs"
|
||||
import { createSkillTool } from "./tools"
|
||||
import { SkillMcpManager } from "../../features/skill-mcp-manager"
|
||||
import type { LoadedSkill } from "../../features/opencode-skill-loader/types"
|
||||
import type { CommandInfo } from "../slashcommand/types"
|
||||
import { SkillMcpManager } from "../../../features/skill-mcp-manager"
|
||||
import type { LoadedSkill } from "../../../features/opencode-skill-loader/types"
|
||||
import type { CommandInfo } from "../../slashcommand/types"
|
||||
import type { Tool as McpTool } from "@modelcontextprotocol/sdk/types.js"
|
||||
|
||||
const originalReadFileSync = fs.readFileSync.bind(fs)
|
||||
|
||||
mock.module("node:fs", () => ({
|
||||
...fs,
|
||||
readFileSync: (path: string, encoding?: string) => {
|
||||
if (typeof path === "string" && path.includes("/skills/")) {
|
||||
return `---
|
||||
let createSkillTool: typeof import("../tools").createSkillTool
|
||||
|
||||
beforeEach(async () => {
|
||||
mock.module("node:fs", () => ({
|
||||
...fs,
|
||||
readFileSync: (path: string, encoding?: string) => {
|
||||
if (typeof path === "string" && path.includes("/skills/")) {
|
||||
return `---
|
||||
description: Test skill description
|
||||
---
|
||||
Test skill body content`
|
||||
}
|
||||
return originalReadFileSync(path, encoding as BufferEncoding)
|
||||
},
|
||||
}))
|
||||
}
|
||||
return originalReadFileSync(path, encoding as BufferEncoding)
|
||||
},
|
||||
}))
|
||||
|
||||
const module = await import("../tools")
|
||||
createSkillTool = module.createSkillTool
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
mock.restore()
|
||||
@@ -121,6 +127,32 @@ describe("skill tool - agent restriction", () => {
|
||||
expect(result).toContain("public-skill")
|
||||
})
|
||||
|
||||
it("requests host skill permission before loading the skill", async () => {
|
||||
// given
|
||||
const loadedSkills = [createMockSkill("review-work")]
|
||||
const askCalls: Array<Parameters<ToolContext["ask"]>[0]> = []
|
||||
const tool = createSkillTool({ skills: loadedSkills })
|
||||
const context: ToolContext = {
|
||||
...mockContext,
|
||||
ask: async (input) => {
|
||||
askCalls.push(input)
|
||||
},
|
||||
}
|
||||
|
||||
// when
|
||||
await tool.execute({ name: "review-work" }, context)
|
||||
|
||||
// then
|
||||
expect(askCalls).toEqual([
|
||||
{
|
||||
permission: "skill",
|
||||
patterns: ["review-work"],
|
||||
always: ["review-work"],
|
||||
metadata: { skill: "review-work" },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("allows skill when agent matches restriction", async () => {
|
||||
// given
|
||||
const loadedSkills = [createMockSkill("restricted-skill", { agent: "sisyphus" })]
|
||||
@@ -141,7 +173,7 @@ describe("skill tool - agent restriction", () => {
|
||||
const context = { ...mockContext, agent: "oracle" }
|
||||
|
||||
// when / #then
|
||||
await expect(tool.execute({ name: "sisyphus-only-skill" }, context)).rejects.toThrow(
|
||||
return expect(tool.execute({ name: "sisyphus-only-skill" }, context)).rejects.toThrow(
|
||||
'Skill "sisyphus-only-skill" is restricted to agent "sisyphus"'
|
||||
)
|
||||
})
|
||||
@@ -153,7 +185,7 @@ describe("skill tool - agent restriction", () => {
|
||||
const contextWithoutAgent = { ...mockContext, agent: undefined as unknown as string }
|
||||
|
||||
// when / #then
|
||||
await expect(tool.execute({ name: "sisyphus-only-skill" }, contextWithoutAgent)).rejects.toThrow(
|
||||
return expect(tool.execute({ name: "sisyphus-only-skill" }, contextWithoutAgent)).rejects.toThrow(
|
||||
'Skill "sisyphus-only-skill" is restricted to agent "sisyphus"'
|
||||
)
|
||||
})
|
||||
@@ -172,6 +204,34 @@ describe("skill tool - MCP schema display", () => {
|
||||
})
|
||||
|
||||
describe("formatMcpCapabilities with inputSchema", () => {
|
||||
it("uses the tool context sessionID when the fallback getter is empty", async () => {
|
||||
// given
|
||||
loadedSkills = [
|
||||
createMockSkillWithMcp("test-skill", {
|
||||
playwright: { command: "npx", args: ["-y", "@anthropic-ai/mcp-playwright"] },
|
||||
}),
|
||||
]
|
||||
|
||||
const listToolsSpy = spyOn(manager, "listTools").mockResolvedValue([])
|
||||
spyOn(manager, "listResources").mockResolvedValue([])
|
||||
spyOn(manager, "listPrompts").mockResolvedValue([])
|
||||
|
||||
const tool = createSkillTool({
|
||||
skills: loadedSkills,
|
||||
mcpManager: manager,
|
||||
getSessionID: () => "",
|
||||
})
|
||||
|
||||
// when
|
||||
await tool.execute({ name: "test-skill" }, mockContext)
|
||||
|
||||
// then
|
||||
expect(listToolsSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sessionID: mockContext.sessionID }),
|
||||
expect.any(Object),
|
||||
)
|
||||
})
|
||||
|
||||
it("displays tool inputSchema when available", async () => {
|
||||
// given
|
||||
const mockToolsWithSchema: McpTool[] = [
|
||||
@@ -533,6 +593,7 @@ describe("skill tool - dynamic description cache invalidation", () => {
|
||||
|
||||
// Get initial description - it will build from empty or disk skills
|
||||
const initialDescription = tool.description
|
||||
expect(initialDescription).toBeString()
|
||||
|
||||
// when: execute() is called, which clears cache AND gets fresh skills
|
||||
// Note: In real scenario, execute() would discover new skills from disk
|
||||
@@ -670,3 +731,58 @@ describe("skill tool - nativeSkills integration", () => {
|
||||
expect(result).toContain("External plugin skill body")
|
||||
})
|
||||
})
|
||||
|
||||
describe("skill tool - short name resolution", () => {
|
||||
it("resolves namespaced skill by short name when unambiguous", async () => {
|
||||
// given
|
||||
const loadedSkills = [createMockSkill("superpowers/systematic-debugging")]
|
||||
const tool = createSkillTool({ skills: loadedSkills })
|
||||
|
||||
// when
|
||||
const result = await tool.execute({ name: "systematic-debugging" }, mockContext)
|
||||
|
||||
// then
|
||||
expect(result).toContain("superpowers/systematic-debugging")
|
||||
})
|
||||
|
||||
it("still resolves by exact full name", async () => {
|
||||
// given
|
||||
const loadedSkills = [createMockSkill("superpowers/systematic-debugging")]
|
||||
const tool = createSkillTool({ skills: loadedSkills })
|
||||
|
||||
// when
|
||||
const result = await tool.execute({ name: "superpowers/systematic-debugging" }, mockContext)
|
||||
|
||||
// then
|
||||
expect(result).toContain("superpowers/systematic-debugging")
|
||||
})
|
||||
|
||||
it("does not resolve short name when ambiguous (multiple matches)", async () => {
|
||||
// given
|
||||
const loadedSkills = [
|
||||
createMockSkill("superpowers/debugging"),
|
||||
createMockSkill("utils/debugging"),
|
||||
]
|
||||
const tool = createSkillTool({ skills: loadedSkills })
|
||||
|
||||
// when / then, should not resolve (ambiguous), should suggest both
|
||||
return expect(tool.execute({ name: "debugging" }, mockContext)).rejects.toThrow(
|
||||
"not found"
|
||||
)
|
||||
})
|
||||
|
||||
it("prefers exact match over short name match", async () => {
|
||||
// given, "debugging" exists as both exact and as part of a namespace
|
||||
const loadedSkills = [
|
||||
createMockSkill("debugging"),
|
||||
createMockSkill("superpowers/debugging"),
|
||||
]
|
||||
const tool = createSkillTool({ skills: loadedSkills })
|
||||
|
||||
// when
|
||||
const result = await tool.execute({ name: "debugging" }, mockContext)
|
||||
|
||||
// then, should match "debugging" exactly, not "superpowers/debugging"
|
||||
expect(result).toContain("## Skill: debugging")
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,18 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { discoverCommandsSync } from "./command-discovery"
|
||||
|
||||
function requireFresh<T>(modulePath: string): T {
|
||||
const resolvedPath = require.resolve(modulePath)
|
||||
if (require.cache?.[resolvedPath]) {
|
||||
delete require.cache[resolvedPath]
|
||||
}
|
||||
return require(modulePath) as T
|
||||
}
|
||||
|
||||
function discoverCommandsSync(...args: Parameters<typeof import("./command-discovery").discoverCommandsSync>): ReturnType<typeof import("./command-discovery").discoverCommandsSync> {
|
||||
return requireFresh<typeof import("./command-discovery")>("./command-discovery").discoverCommandsSync(...args)
|
||||
}
|
||||
|
||||
const ENV_KEYS = [
|
||||
"CLAUDE_CONFIG_DIR",
|
||||
@@ -255,4 +266,64 @@ Use nested command.
|
||||
expect(nestedCommand?.content).toContain("Use nested command.")
|
||||
expect(nestedCommand?.scope).toBe("opencode-project")
|
||||
})
|
||||
|
||||
it("keeps builtin start-work routed to Atlas during static discovery", () => {
|
||||
// given
|
||||
|
||||
// when
|
||||
const commands = discoverCommandsSync(projectDir)
|
||||
const startWorkCommand = commands.find((command) => command.name === "start-work")
|
||||
|
||||
// then
|
||||
expect(startWorkCommand?.metadata.agent).toBe("atlas")
|
||||
})
|
||||
})
|
||||
|
||||
describe("non-directory commands path", () => {
|
||||
let testDir: string
|
||||
let savedEnv: Record<string, string | undefined>
|
||||
|
||||
beforeEach(() => {
|
||||
testDir = mkdtempSync(join(tmpdir(), "omo-cmd-file-"))
|
||||
savedEnv = {
|
||||
CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR,
|
||||
OPENCODE_CONFIG_DIR: process.env.OPENCODE_CONFIG_DIR,
|
||||
}
|
||||
process.env.CLAUDE_CONFIG_DIR = join(testDir, "claude-config")
|
||||
process.env.OPENCODE_CONFIG_DIR = join(testDir, "opencode-config")
|
||||
mkdirSync(join(testDir, "claude-config"), { recursive: true })
|
||||
mkdirSync(join(testDir, "opencode-config"), { recursive: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Object.entries(savedEnv).forEach(([k, v]) => {
|
||||
if (v === undefined) delete process.env[k]
|
||||
else process.env[k] = v
|
||||
})
|
||||
rmSync(testDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it("#given .claude/commands is a file #when discoverCommandsSync runs #then returns without crashing", () => {
|
||||
const projectDir = join(testDir, "project")
|
||||
mkdirSync(join(projectDir, ".claude"), { recursive: true })
|
||||
writeFileSync(join(projectDir, ".claude", "commands"), "") // file, not directory
|
||||
|
||||
// Should not throw
|
||||
const commands = discoverCommandsSync(projectDir)
|
||||
expect(commands).toBeInstanceOf(Array)
|
||||
})
|
||||
|
||||
it("#given .claude/commands is a directory #when discoverCommandsSync runs #then discovers commands normally", () => {
|
||||
const projectDir = join(testDir, "project")
|
||||
mkdirSync(join(projectDir, ".claude", "commands"), { recursive: true })
|
||||
writeFileSync(
|
||||
join(projectDir, ".claude", "commands", "test-cmd.md"),
|
||||
"---\ndescription: Test\n---\nTest command content.\n",
|
||||
)
|
||||
|
||||
const commands = discoverCommandsSync(projectDir)
|
||||
const testCmd = commands.find((c) => c.name === "test-cmd")
|
||||
expect(testCmd).toBeDefined()
|
||||
expect(testCmd?.content).toContain("Test command content.")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "fs"
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from "fs"
|
||||
import { basename, join } from "path"
|
||||
import {
|
||||
parseFrontmatter,
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from "../../shared"
|
||||
import type { CommandFrontmatter } from "../../features/claude-code-command-loader/types"
|
||||
import { isMarkdownFile } from "../../shared/file-utils"
|
||||
import { getClaudeConfigDir } from "../../shared"
|
||||
import { getClaudeConfigDir, log } from "../../shared"
|
||||
import { loadBuiltinCommands } from "../../features/builtin-commands"
|
||||
import type { CommandInfo, CommandMetadata, CommandScope } from "./types"
|
||||
|
||||
@@ -26,6 +26,10 @@ function discoverCommandsFromDir(
|
||||
prefix = "",
|
||||
): CommandInfo[] {
|
||||
if (!existsSync(commandsDir)) return []
|
||||
if (!statSync(commandsDir).isDirectory()) {
|
||||
log(`[command-discovery] Skipping non-directory path: ${commandsDir}`)
|
||||
return []
|
||||
}
|
||||
|
||||
const entries = readdirSync(commandsDir, { withFileTypes: true })
|
||||
const commands: CommandInfo[] = []
|
||||
|
||||
@@ -2,8 +2,22 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { executeSlashCommand } from "../../hooks/auto-slash-command/executor"
|
||||
import { discoverCommandsSync } from "./command-discovery"
|
||||
|
||||
function requireFresh<T>(modulePath: string): T {
|
||||
const resolvedPath = require.resolve(modulePath)
|
||||
if (require.cache?.[resolvedPath]) {
|
||||
delete require.cache[resolvedPath]
|
||||
}
|
||||
return require(modulePath) as T
|
||||
}
|
||||
|
||||
function executeSlashCommand(...args: Parameters<typeof import("../../hooks/auto-slash-command/executor").executeSlashCommand>): ReturnType<typeof import("../../hooks/auto-slash-command/executor").executeSlashCommand> {
|
||||
return requireFresh<typeof import("../../hooks/auto-slash-command/executor")>("../../hooks/auto-slash-command/executor").executeSlashCommand(...args)
|
||||
}
|
||||
|
||||
function discoverCommandsSync(...args: Parameters<typeof import("./command-discovery").discoverCommandsSync>): ReturnType<typeof import("./command-discovery").discoverCommandsSync> {
|
||||
return requireFresh<typeof import("./command-discovery")>("./command-discovery").discoverCommandsSync(...args)
|
||||
}
|
||||
|
||||
describe("slashcommand discovery and execution compatibility", () => {
|
||||
let tempDir = ""
|
||||
@@ -60,4 +74,32 @@ describe("slashcommand discovery and execution compatibility", () => {
|
||||
expect(result.replacementText).toContain("Execute from parent config.")
|
||||
expect(result.replacementText).toContain("**Scope**: opencode")
|
||||
})
|
||||
|
||||
it("executes project commands using the provided directory even when cwd differs", async () => {
|
||||
// given
|
||||
const projectDir = join(tempDir, "project")
|
||||
const commandDir = join(projectDir, ".claude", "commands")
|
||||
const commandName = "project-only-command"
|
||||
|
||||
mkdirSync(commandDir, { recursive: true })
|
||||
writeFileSync(
|
||||
join(commandDir, `${commandName}.md`),
|
||||
`---\ndescription: Project command\n---\nExecute from project directory.\n`,
|
||||
)
|
||||
process.chdir("/tmp")
|
||||
|
||||
expect(discoverCommandsSync(projectDir).some(command => command.name === commandName)).toBe(true)
|
||||
|
||||
// when
|
||||
const result = await executeSlashCommand({
|
||||
command: commandName,
|
||||
args: "",
|
||||
raw: `/${commandName}`,
|
||||
}, { skills: [], directory: projectDir })
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.replacementText).toContain("Execute from project directory.")
|
||||
expect(result.replacementText).toContain("**Scope**: project")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,18 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { discoverCommandsSync } from "./command-discovery"
|
||||
|
||||
function requireFresh<T>(modulePath: string): T {
|
||||
const resolvedPath = require.resolve(modulePath)
|
||||
if (require.cache?.[resolvedPath]) {
|
||||
delete require.cache[resolvedPath]
|
||||
}
|
||||
return require(modulePath) as T
|
||||
}
|
||||
|
||||
function discoverCommandsSync(...args: Parameters<typeof import("./command-discovery").discoverCommandsSync>): ReturnType<typeof import("./command-discovery").discoverCommandsSync> {
|
||||
return requireFresh<typeof import("./command-discovery")>("./command-discovery").discoverCommandsSync(...args)
|
||||
}
|
||||
|
||||
function writeCommand(path: string, description: string, body: string): void {
|
||||
mkdirSync(join(path, ".."), { recursive: true })
|
||||
|
||||
@@ -55,7 +55,7 @@ Returns summary format: id, subject, status, owner, blockedBy (not full descript
|
||||
// Build summary with filtered blockedBy
|
||||
const summaries: TaskSummary[] = activeTasks.map((task) => {
|
||||
// Filter blockedBy to only include unresolved (non-completed) blockers
|
||||
const unresolvedBlockers = task.blockedBy.filter((blockerId) => {
|
||||
const unresolvedBlockers = task.blockedBy.filter((blockerId: string) => {
|
||||
const blockerTask = taskMap.get(blockerId)
|
||||
// Include if blocker doesn't exist (missing) or if it's not completed
|
||||
return !blockerTask || blockerTask.status !== "completed"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user