refactor: major codebase cleanup - BDD comments, file splitting, bug fixes (#1350)

* style(tests): normalize BDD comments from '// #given' to '// given'

- Replace 4,668 Python-style BDD comments across 107 test files
- Patterns changed: // #given -> // given, // #when -> // when, // #then -> // then
- Also handles no-space variants: //#given -> // given

* fix(rules-injector): prefer output.metadata.filePath over output.title

- Extract file path resolution to dedicated output-path.ts module
- Prefer metadata.filePath which contains actual file path
- Fall back to output.title only when metadata unavailable
- Fixes issue where rules weren't injected when tool output title was a label

* feat(slashcommand): add optional user_message parameter

- Add user_message optional parameter for command arguments
- Model can now call: command='publish' user_message='patch'
- Improves error messages with clearer format guidance
- Helps LLMs understand correct parameter usage

* feat(hooks): restore compaction-context-injector hook

- Restore hook deleted in cbbc7bd0 for session compaction context
- Injects 7 mandatory sections: User Requests, Final Goal, Work Completed,
  Remaining Tasks, Active Working Context, MUST NOT Do, Agent Verification State
- Re-register in hooks/index.ts and main plugin entry

* refactor(background-agent): split manager.ts into focused modules

- Extract constants.ts for TTL values and internal types (52 lines)
- Extract state.ts for TaskStateManager class (204 lines)
- Extract spawner.ts for task creation logic (244 lines)
- Extract result-handler.ts for completion handling (265 lines)
- Reduce manager.ts from 1377 to 755 lines (45% reduction)
- Maintain backward compatible exports

* refactor(agents): split prometheus-prompt.ts into subdirectory

- Move 1196-line prometheus-prompt.ts to prometheus/ subdirectory
- Organize prompt sections into separate files for maintainability
- Update agents/index.ts exports

* refactor(delegate-task): split tools.ts into focused modules

- Extract categories.ts for category definitions and routing
- Extract executor.ts for task execution logic
- Extract helpers.ts for utility functions
- Extract prompt-builder.ts for prompt construction
- Reduce tools.ts complexity with cleaner separation of concerns

* refactor(builtin-skills): split skills.ts into individual skill files

- Move each skill to dedicated file in skills/ subdirectory
- Create barrel export for backward compatibility
- Improve maintainability with focused skill modules

* chore: update import paths and lockfile

- Update prometheus import path after refactor
- Update bun.lock

* fix(tests): complete BDD comment normalization

- Fix remaining #when/#then patterns missed by initial sed
- Affected: state.test.ts, events.test.ts

---------

Co-authored-by: justsisyphus <justsisyphus@users.noreply.github.com>
This commit is contained in:
YeonGyu-Kim
2026-02-01 16:47:50 +09:00
committed by GitHub
parent c83150d9ea
commit f146aeff0f
145 changed files with 10307 additions and 9562 deletions
+70
View File
@@ -0,0 +1,70 @@
import type { CategoryConfig, CategoriesConfig } from "../../config/schema"
import { DEFAULT_CATEGORIES, CATEGORY_PROMPT_APPENDS } from "./constants"
import { resolveModel } from "../../shared"
import { isModelAvailable } from "../../shared/model-availability"
import { CATEGORY_MODEL_REQUIREMENTS } from "../../shared/model-requirements"
import { log } from "../../shared"
export interface ResolveCategoryConfigOptions {
userCategories?: CategoriesConfig
inheritedModel?: string
systemDefaultModel?: string
availableModels?: Set<string>
}
export interface ResolveCategoryConfigResult {
config: CategoryConfig
promptAppend: string
model: string | undefined
}
/**
* Resolve the configuration for a given category name.
* Merges default and user configurations, handles model resolution.
*/
export function resolveCategoryConfig(
categoryName: string,
options: ResolveCategoryConfigOptions
): ResolveCategoryConfigResult | null {
const { userCategories, inheritedModel, systemDefaultModel, availableModels } = options
// Check if category requires a specific model
const categoryReq = CATEGORY_MODEL_REQUIREMENTS[categoryName]
if (categoryReq?.requiresModel && availableModels) {
if (!isModelAvailable(categoryReq.requiresModel, availableModels)) {
log(`[resolveCategoryConfig] Category ${categoryName} requires ${categoryReq.requiresModel} but not available`)
return null
}
}
const defaultConfig = DEFAULT_CATEGORIES[categoryName]
const userConfig = userCategories?.[categoryName]
const defaultPromptAppend = CATEGORY_PROMPT_APPENDS[categoryName] ?? ""
if (!defaultConfig && !userConfig) {
return null
}
// Model priority for categories: user override > category default > system default
// Categories have explicit models - no inheritance from parent session
const model = resolveModel({
userModel: userConfig?.model,
inheritedModel: defaultConfig?.model, // Category's built-in model takes precedence over system default
systemDefault: systemDefaultModel,
})
const config: CategoryConfig = {
...defaultConfig,
...userConfig,
model,
variant: userConfig?.variant ?? defaultConfig?.variant,
}
let promptAppend = defaultPromptAppend
if (userConfig?.prompt_append) {
promptAppend = defaultPromptAppend
? defaultPromptAppend + "\n\n" + userConfig.prompt_append
: userConfig.prompt_append
}
return { config, promptAppend, model }
}
+968
View File
@@ -0,0 +1,968 @@
import type { BackgroundManager } from "../../features/background-agent"
import type { CategoriesConfig, GitMasterConfig, BrowserAutomationProvider } from "../../config/schema"
import type { ModelFallbackInfo } from "../../features/task-toast-manager/types"
import type { DelegateTaskArgs, ToolContextWithMetadata, OpencodeClient } from "./types"
import { DEFAULT_CATEGORIES, CATEGORY_DESCRIPTIONS, isPlanAgent } from "./constants"
import { getTimingConfig } from "./timing"
import { parseModelString, getMessageDir, formatDuration, formatDetailedError } from "./helpers"
import { resolveCategoryConfig } from "./categories"
import { buildSystemContent } from "./prompt-builder"
import { findNearestMessageWithFields, findFirstMessageWithAgent } from "../../features/hook-message-injector"
import { resolveMultipleSkillsAsync } from "../../features/opencode-skill-loader/skill-content"
import { discoverSkills } from "../../features/opencode-skill-loader"
import { getTaskToastManager } from "../../features/task-toast-manager"
import { subagentSessions, getSessionAgent } from "../../features/claude-code-session-state"
import { log, getAgentToolRestrictions, resolveModelPipeline, promptWithModelSuggestionRetry } from "../../shared"
import { fetchAvailableModels, isModelAvailable } from "../../shared/model-availability"
import { readConnectedProvidersCache } from "../../shared/connected-providers-cache"
import { CATEGORY_MODEL_REQUIREMENTS } from "../../shared/model-requirements"
const SISYPHUS_JUNIOR_AGENT = "sisyphus-junior"
export interface ExecutorContext {
manager: BackgroundManager
client: OpencodeClient
directory: string
userCategories?: CategoriesConfig
gitMasterConfig?: GitMasterConfig
sisyphusJuniorModel?: string
browserProvider?: BrowserAutomationProvider
onSyncSessionCreated?: (event: { sessionID: string; parentID: string; title: string }) => Promise<void>
}
export interface ParentContext {
sessionID: string
messageID: string
agent?: string
model?: { providerID: string; modelID: string; variant?: string }
}
interface SessionMessage {
info?: { role?: string; time?: { created?: number }; agent?: string; model?: { providerID: string; modelID: string }; modelID?: string; providerID?: string }
parts?: Array<{ type?: string; text?: string }>
}
export async function resolveSkillContent(
skills: string[],
options: { gitMasterConfig?: GitMasterConfig; browserProvider?: BrowserAutomationProvider }
): Promise<{ content: string | undefined; error: string | null }> {
if (skills.length === 0) {
return { content: undefined, error: null }
}
const { resolved, notFound } = await resolveMultipleSkillsAsync(skills, options)
if (notFound.length > 0) {
const allSkills = await discoverSkills({ includeClaudeCodePaths: true })
const available = allSkills.map(s => s.name).join(", ")
return { content: undefined, error: `Skills not found: ${notFound.join(", ")}. Available: ${available}` }
}
return { content: Array.from(resolved.values()).join("\n\n"), error: null }
}
export function resolveParentContext(ctx: ToolContextWithMetadata): ParentContext {
const messageDir = getMessageDir(ctx.sessionID)
const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
const firstMessageAgent = messageDir ? findFirstMessageWithAgent(messageDir) : null
const sessionAgent = getSessionAgent(ctx.sessionID)
const parentAgent = ctx.agent ?? sessionAgent ?? firstMessageAgent ?? prevMessage?.agent
log("[delegate_task] parentAgent resolution", {
sessionID: ctx.sessionID,
messageDir,
ctxAgent: ctx.agent,
sessionAgent,
firstMessageAgent,
prevMessageAgent: prevMessage?.agent,
resolvedParentAgent: parentAgent,
})
const parentModel = prevMessage?.model?.providerID && prevMessage?.model?.modelID
? {
providerID: prevMessage.model.providerID,
modelID: prevMessage.model.modelID,
...(prevMessage.model.variant ? { variant: prevMessage.model.variant } : {}),
}
: undefined
return {
sessionID: ctx.sessionID,
messageID: ctx.messageID,
agent: parentAgent,
model: parentModel,
}
}
export async function executeBackgroundContinuation(
args: DelegateTaskArgs,
ctx: ToolContextWithMetadata,
executorCtx: ExecutorContext,
parentContext: ParentContext
): Promise<string> {
const { manager } = executorCtx
try {
const task = await manager.resume({
sessionId: args.session_id!,
prompt: args.prompt,
parentSessionID: parentContext.sessionID,
parentMessageID: parentContext.messageID,
parentModel: parentContext.model,
parentAgent: parentContext.agent,
})
ctx.metadata?.({
title: `Continue: ${task.description}`,
metadata: {
prompt: args.prompt,
agent: task.agent,
load_skills: args.load_skills,
description: args.description,
run_in_background: args.run_in_background,
sessionId: task.sessionID,
command: args.command,
},
})
return `Background task continued.
Task ID: ${task.id}
Session ID: ${task.sessionID}
Description: ${task.description}
Agent: ${task.agent}
Status: ${task.status}
Agent continues with full previous context preserved.
Use \`background_output\` with task_id="${task.id}" to check progress.`
} catch (error) {
return formatDetailedError(error, {
operation: "Continue background task",
args,
sessionID: args.session_id,
})
}
}
export async function executeSyncContinuation(
args: DelegateTaskArgs,
ctx: ToolContextWithMetadata,
executorCtx: ExecutorContext
): Promise<string> {
const { client } = executorCtx
const toastManager = getTaskToastManager()
const taskId = `resume_sync_${args.session_id!.slice(0, 8)}`
const startTime = new Date()
if (toastManager) {
toastManager.addTask({
id: taskId,
description: args.description,
agent: "continue",
isBackground: false,
})
}
ctx.metadata?.({
title: `Continue: ${args.description}`,
metadata: {
prompt: args.prompt,
load_skills: args.load_skills,
description: args.description,
run_in_background: args.run_in_background,
sessionId: args.session_id,
sync: true,
command: args.command,
},
})
try {
let resumeAgent: string | undefined
let resumeModel: { providerID: string; modelID: string } | undefined
try {
const messagesResp = await client.session.messages({ path: { id: args.session_id! } })
const messages = (messagesResp.data ?? []) as SessionMessage[]
for (let i = messages.length - 1; i >= 0; i--) {
const info = messages[i].info
if (info?.agent || info?.model || (info?.modelID && info?.providerID)) {
resumeAgent = info.agent
resumeModel = info.model ?? (info.providerID && info.modelID ? { providerID: info.providerID, modelID: info.modelID } : undefined)
break
}
}
} catch {
const resumeMessageDir = getMessageDir(args.session_id!)
const resumeMessage = resumeMessageDir ? findNearestMessageWithFields(resumeMessageDir) : null
resumeAgent = resumeMessage?.agent
resumeModel = resumeMessage?.model?.providerID && resumeMessage?.model?.modelID
? { providerID: resumeMessage.model.providerID, modelID: resumeMessage.model.modelID }
: undefined
}
await client.session.prompt({
path: { id: args.session_id! },
body: {
...(resumeAgent !== undefined ? { agent: resumeAgent } : {}),
...(resumeModel !== undefined ? { model: resumeModel } : {}),
tools: {
...(resumeAgent ? getAgentToolRestrictions(resumeAgent) : {}),
task: false,
delegate_task: false,
call_omo_agent: true,
question: false,
},
parts: [{ type: "text", text: args.prompt }],
},
})
} catch (promptError) {
if (toastManager) {
toastManager.removeTask(taskId)
}
const errorMessage = promptError instanceof Error ? promptError.message : String(promptError)
return `Failed to send continuation prompt: ${errorMessage}\n\nSession ID: ${args.session_id}`
}
const timing = getTimingConfig()
const pollStart = Date.now()
let lastMsgCount = 0
let stablePolls = 0
while (Date.now() - pollStart < 60000) {
await new Promise(resolve => setTimeout(resolve, timing.POLL_INTERVAL_MS))
const elapsed = Date.now() - pollStart
if (elapsed < timing.SESSION_CONTINUATION_STABILITY_MS) continue
const messagesCheck = await client.session.messages({ path: { id: args.session_id! } })
const msgs = ((messagesCheck as { data?: unknown }).data ?? messagesCheck) as Array<unknown>
const currentMsgCount = msgs.length
if (currentMsgCount > 0 && currentMsgCount === lastMsgCount) {
stablePolls++
if (stablePolls >= timing.STABILITY_POLLS_REQUIRED) break
} else {
stablePolls = 0
lastMsgCount = currentMsgCount
}
}
const messagesResult = await client.session.messages({
path: { id: args.session_id! },
})
if (messagesResult.error) {
if (toastManager) {
toastManager.removeTask(taskId)
}
return `Error fetching result: ${messagesResult.error}\n\nSession ID: ${args.session_id}`
}
const messages = ((messagesResult as { data?: unknown }).data ?? messagesResult) as SessionMessage[]
const assistantMessages = messages
.filter((m) => m.info?.role === "assistant")
.sort((a, b) => (b.info?.time?.created ?? 0) - (a.info?.time?.created ?? 0))
const lastMessage = assistantMessages[0]
if (toastManager) {
toastManager.removeTask(taskId)
}
if (!lastMessage) {
return `No assistant response found.\n\nSession ID: ${args.session_id}`
}
const textParts = lastMessage?.parts?.filter((p) => p.type === "text" || p.type === "reasoning") ?? []
const textContent = textParts.map((p) => p.text ?? "").filter(Boolean).join("\n")
const duration = formatDuration(startTime)
return `Task continued and completed in ${duration}.
Session ID: ${args.session_id}
---
${textContent || "(No text output)"}
---
To continue this session: session_id="${args.session_id}"`
}
export async function executeUnstableAgentTask(
args: DelegateTaskArgs,
ctx: ToolContextWithMetadata,
executorCtx: ExecutorContext,
parentContext: ParentContext,
agentToUse: string,
categoryModel: { providerID: string; modelID: string; variant?: string } | undefined,
systemContent: string | undefined,
actualModel: string | undefined
): Promise<string> {
const { manager, client } = executorCtx
try {
const task = await manager.launch({
description: args.description,
prompt: args.prompt,
agent: agentToUse,
parentSessionID: parentContext.sessionID,
parentMessageID: parentContext.messageID,
parentModel: parentContext.model,
parentAgent: parentContext.agent,
model: categoryModel,
skills: args.load_skills.length > 0 ? args.load_skills : undefined,
skillContent: systemContent,
})
const WAIT_FOR_SESSION_INTERVAL_MS = 100
const WAIT_FOR_SESSION_TIMEOUT_MS = 30000
const waitStart = Date.now()
while (!task.sessionID && Date.now() - waitStart < WAIT_FOR_SESSION_TIMEOUT_MS) {
if (ctx.abort?.aborted) {
return `Task aborted while waiting for session to start.\n\nTask ID: ${task.id}`
}
await new Promise(resolve => setTimeout(resolve, WAIT_FOR_SESSION_INTERVAL_MS))
}
const sessionID = task.sessionID
if (!sessionID) {
return formatDetailedError(new Error(`Task failed to start within timeout (30s). Task ID: ${task.id}, Status: ${task.status}`), {
operation: "Launch monitored background task",
args,
agent: agentToUse,
category: args.category,
})
}
ctx.metadata?.({
title: args.description,
metadata: {
prompt: args.prompt,
agent: agentToUse,
category: args.category,
load_skills: args.load_skills,
description: args.description,
run_in_background: args.run_in_background,
sessionId: sessionID,
command: args.command,
},
})
const startTime = new Date()
const timingCfg = getTimingConfig()
const pollStart = Date.now()
let lastMsgCount = 0
let stablePolls = 0
while (Date.now() - pollStart < timingCfg.MAX_POLL_TIME_MS) {
if (ctx.abort?.aborted) {
return `Task aborted (was running in background mode).\n\nSession ID: ${sessionID}`
}
await new Promise(resolve => setTimeout(resolve, timingCfg.POLL_INTERVAL_MS))
const statusResult = await client.session.status()
const allStatuses = (statusResult.data ?? {}) as Record<string, { type: string }>
const sessionStatus = allStatuses[sessionID]
if (sessionStatus && sessionStatus.type !== "idle") {
stablePolls = 0
lastMsgCount = 0
continue
}
if (Date.now() - pollStart < timingCfg.MIN_STABILITY_TIME_MS) continue
const messagesCheck = await client.session.messages({ path: { id: sessionID } })
const msgs = ((messagesCheck as { data?: unknown }).data ?? messagesCheck) as Array<unknown>
const currentMsgCount = msgs.length
if (currentMsgCount === lastMsgCount) {
stablePolls++
if (stablePolls >= timingCfg.STABILITY_POLLS_REQUIRED) break
} else {
stablePolls = 0
lastMsgCount = currentMsgCount
}
}
const messagesResult = await client.session.messages({ path: { id: sessionID } })
const messages = ((messagesResult as { data?: unknown }).data ?? messagesResult) as SessionMessage[]
const assistantMessages = messages
.filter((m) => m.info?.role === "assistant")
.sort((a, b) => (b.info?.time?.created ?? 0) - (a.info?.time?.created ?? 0))
const lastMessage = assistantMessages[0]
if (!lastMessage) {
return `No assistant response found (task ran in background mode).\n\nSession ID: ${sessionID}`
}
const textParts = lastMessage?.parts?.filter((p) => p.type === "text" || p.type === "reasoning") ?? []
const textContent = textParts.map((p) => p.text ?? "").filter(Boolean).join("\n")
const duration = formatDuration(startTime)
return `SUPERVISED TASK COMPLETED SUCCESSFULLY
IMPORTANT: This model (${actualModel}) is marked as unstable/experimental.
Your run_in_background=false was automatically converted to background mode for reliability monitoring.
Duration: ${duration}
Agent: ${agentToUse}${args.category ? ` (category: ${args.category})` : ""}
Session ID: ${sessionID}
MONITORING INSTRUCTIONS:
- The task was monitored and completed successfully
- If you observe this agent behaving erratically in future calls, actively monitor its progress
- Use background_cancel(task_id="...") to abort if the agent seems stuck or producing garbage output
- Do NOT retry automatically if you see this message - the task already succeeded
---
RESULT:
${textContent || "(No text output)"}
---
To continue this session: session_id="${sessionID}"`
} catch (error) {
return formatDetailedError(error, {
operation: "Launch monitored background task",
args,
agent: agentToUse,
category: args.category,
})
}
}
export async function executeBackgroundTask(
args: DelegateTaskArgs,
ctx: ToolContextWithMetadata,
executorCtx: ExecutorContext,
parentContext: ParentContext,
agentToUse: string,
categoryModel: { providerID: string; modelID: string; variant?: string } | undefined,
systemContent: string | undefined
): Promise<string> {
const { manager } = executorCtx
try {
const task = await manager.launch({
description: args.description,
prompt: args.prompt,
agent: agentToUse,
parentSessionID: parentContext.sessionID,
parentMessageID: parentContext.messageID,
parentModel: parentContext.model,
parentAgent: parentContext.agent,
model: categoryModel,
skills: args.load_skills.length > 0 ? args.load_skills : undefined,
skillContent: systemContent,
})
ctx.metadata?.({
title: args.description,
metadata: {
prompt: args.prompt,
agent: task.agent,
category: args.category,
load_skills: args.load_skills,
description: args.description,
run_in_background: args.run_in_background,
sessionId: task.sessionID,
command: args.command,
},
})
return `Background task launched.
Task ID: ${task.id}
Session ID: ${task.sessionID}
Description: ${task.description}
Agent: ${task.agent}${args.category ? ` (category: ${args.category})` : ""}
Status: ${task.status}
System notifies on completion. Use \`background_output\` with task_id="${task.id}" to check.
To continue this session: session_id="${task.sessionID}"`
} catch (error) {
return formatDetailedError(error, {
operation: "Launch background task",
args,
agent: agentToUse,
category: args.category,
})
}
}
export async function executeSyncTask(
args: DelegateTaskArgs,
ctx: ToolContextWithMetadata,
executorCtx: ExecutorContext,
parentContext: ParentContext,
agentToUse: string,
categoryModel: { providerID: string; modelID: string; variant?: string } | undefined,
systemContent: string | undefined,
modelInfo?: ModelFallbackInfo
): Promise<string> {
const { client, directory, onSyncSessionCreated } = executorCtx
const toastManager = getTaskToastManager()
let taskId: string | undefined
let syncSessionID: string | undefined
try {
const parentSession = client.session.get
? await client.session.get({ path: { id: parentContext.sessionID } }).catch(() => null)
: null
const parentDirectory = parentSession?.data?.directory ?? directory
const createResult = await client.session.create({
body: {
parentID: parentContext.sessionID,
title: `Task: ${args.description}`,
permission: [
{ permission: "question", action: "deny" as const, pattern: "*" },
],
} as any,
query: {
directory: parentDirectory,
},
})
if (createResult.error) {
return `Failed to create session: ${createResult.error}`
}
const sessionID = createResult.data.id
syncSessionID = sessionID
subagentSessions.add(sessionID)
if (onSyncSessionCreated) {
log("[delegate_task] Invoking onSyncSessionCreated callback", { sessionID, parentID: parentContext.sessionID })
await onSyncSessionCreated({
sessionID,
parentID: parentContext.sessionID,
title: args.description,
}).catch((err) => {
log("[delegate_task] onSyncSessionCreated callback failed", { error: String(err) })
})
await new Promise(r => setTimeout(r, 200))
}
taskId = `sync_${sessionID.slice(0, 8)}`
const startTime = new Date()
if (toastManager) {
toastManager.addTask({
id: taskId,
description: args.description,
agent: agentToUse,
isBackground: false,
category: args.category,
skills: args.load_skills,
modelInfo,
})
}
ctx.metadata?.({
title: args.description,
metadata: {
prompt: args.prompt,
agent: agentToUse,
category: args.category,
load_skills: args.load_skills,
description: args.description,
run_in_background: args.run_in_background,
sessionId: sessionID,
sync: true,
command: args.command,
},
})
try {
const allowDelegateTask = isPlanAgent(agentToUse)
await promptWithModelSuggestionRetry(client, {
path: { id: sessionID },
body: {
agent: agentToUse,
system: systemContent,
tools: {
task: false,
delegate_task: allowDelegateTask,
call_omo_agent: true,
question: false,
},
parts: [{ type: "text", text: args.prompt }],
...(categoryModel ? { model: { providerID: categoryModel.providerID, modelID: categoryModel.modelID } } : {}),
...(categoryModel?.variant ? { variant: categoryModel.variant } : {}),
},
})
} catch (promptError) {
if (toastManager && taskId !== undefined) {
toastManager.removeTask(taskId)
}
const errorMessage = promptError instanceof Error ? promptError.message : String(promptError)
if (errorMessage.includes("agent.name") || errorMessage.includes("undefined")) {
return formatDetailedError(new Error(`Agent "${agentToUse}" not found. Make sure the agent is registered in your opencode.json or provided by a plugin.`), {
operation: "Send prompt to agent",
args,
sessionID,
agent: agentToUse,
category: args.category,
})
}
return formatDetailedError(promptError, {
operation: "Send prompt",
args,
sessionID,
agent: agentToUse,
category: args.category,
})
}
const syncTiming = getTimingConfig()
const pollStart = Date.now()
let lastMsgCount = 0
let stablePolls = 0
let pollCount = 0
log("[delegate_task] Starting poll loop", { sessionID, agentToUse })
while (Date.now() - pollStart < syncTiming.MAX_POLL_TIME_MS) {
if (ctx.abort?.aborted) {
log("[delegate_task] Aborted by user", { sessionID })
if (toastManager && taskId) toastManager.removeTask(taskId)
return `Task aborted.\n\nSession ID: ${sessionID}`
}
await new Promise(resolve => setTimeout(resolve, syncTiming.POLL_INTERVAL_MS))
pollCount++
const statusResult = await client.session.status()
const allStatuses = (statusResult.data ?? {}) as Record<string, { type: string }>
const sessionStatus = allStatuses[sessionID]
if (pollCount % 10 === 0) {
log("[delegate_task] Poll status", {
sessionID,
pollCount,
elapsed: Math.floor((Date.now() - pollStart) / 1000) + "s",
sessionStatus: sessionStatus?.type ?? "not_in_status",
stablePolls,
lastMsgCount,
})
}
if (sessionStatus && sessionStatus.type !== "idle") {
stablePolls = 0
lastMsgCount = 0
continue
}
const elapsed = Date.now() - pollStart
if (elapsed < syncTiming.MIN_STABILITY_TIME_MS) {
continue
}
const messagesCheck = await client.session.messages({ path: { id: sessionID } })
const msgs = ((messagesCheck as { data?: unknown }).data ?? messagesCheck) as Array<unknown>
const currentMsgCount = msgs.length
if (currentMsgCount === lastMsgCount) {
stablePolls++
if (stablePolls >= syncTiming.STABILITY_POLLS_REQUIRED) {
log("[delegate_task] Poll complete - messages stable", { sessionID, pollCount, currentMsgCount })
break
}
} else {
stablePolls = 0
lastMsgCount = currentMsgCount
}
}
if (Date.now() - pollStart >= syncTiming.MAX_POLL_TIME_MS) {
log("[delegate_task] Poll timeout reached", { sessionID, pollCount, lastMsgCount, stablePolls })
}
const messagesResult = await client.session.messages({
path: { id: sessionID },
})
if (messagesResult.error) {
return `Error fetching result: ${messagesResult.error}\n\nSession ID: ${sessionID}`
}
const messages = ((messagesResult as { data?: unknown }).data ?? messagesResult) as SessionMessage[]
const assistantMessages = messages
.filter((m) => m.info?.role === "assistant")
.sort((a, b) => (b.info?.time?.created ?? 0) - (a.info?.time?.created ?? 0))
const lastMessage = assistantMessages[0]
if (!lastMessage) {
return `No assistant response found.\n\nSession ID: ${sessionID}`
}
const textParts = lastMessage?.parts?.filter((p) => p.type === "text" || p.type === "reasoning") ?? []
const textContent = textParts.map((p) => p.text ?? "").filter(Boolean).join("\n")
const duration = formatDuration(startTime)
if (toastManager) {
toastManager.removeTask(taskId)
}
subagentSessions.delete(sessionID)
return `Task completed in ${duration}.
Agent: ${agentToUse}${args.category ? ` (category: ${args.category})` : ""}
Session ID: ${sessionID}
---
${textContent || "(No text output)"}
---
To continue this session: session_id="${sessionID}"`
} catch (error) {
if (toastManager && taskId !== undefined) {
toastManager.removeTask(taskId)
}
if (syncSessionID) {
subagentSessions.delete(syncSessionID)
}
return formatDetailedError(error, {
operation: "Execute task",
args,
sessionID: syncSessionID,
agent: agentToUse,
category: args.category,
})
}
}
export interface CategoryResolutionResult {
agentToUse: string
categoryModel: { providerID: string; modelID: string; variant?: string } | undefined
categoryPromptAppend: string | undefined
modelInfo: ModelFallbackInfo | undefined
actualModel: string | undefined
isUnstableAgent: boolean
error?: string
}
export async function resolveCategoryExecution(
args: DelegateTaskArgs,
executorCtx: ExecutorContext,
inheritedModel: string | undefined,
systemDefaultModel: string | undefined
): Promise<CategoryResolutionResult> {
const { client, userCategories, sisyphusJuniorModel } = executorCtx
const connectedProviders = readConnectedProvidersCache()
const availableModels = await fetchAvailableModels(client, {
connectedProviders: connectedProviders ?? undefined,
})
const resolved = resolveCategoryConfig(args.category!, {
userCategories,
inheritedModel,
systemDefaultModel,
availableModels,
})
if (!resolved) {
return {
agentToUse: "",
categoryModel: undefined,
categoryPromptAppend: undefined,
modelInfo: undefined,
actualModel: undefined,
isUnstableAgent: false,
error: `Unknown category: "${args.category}". Available: ${Object.keys({ ...DEFAULT_CATEGORIES, ...userCategories }).join(", ")}`,
}
}
const requirement = CATEGORY_MODEL_REQUIREMENTS[args.category!]
let actualModel: string | undefined
let modelInfo: ModelFallbackInfo | undefined
let categoryModel: { providerID: string; modelID: string; variant?: string } | undefined
if (!requirement) {
actualModel = resolved.model
if (actualModel) {
modelInfo = { model: actualModel, type: "system-default", source: "system-default" }
}
} else {
const resolution = resolveModelPipeline({
intent: {
userModel: userCategories?.[args.category!]?.model,
categoryDefaultModel: resolved.model ?? sisyphusJuniorModel,
},
constraints: { availableModels },
policy: {
fallbackChain: requirement.fallbackChain,
systemDefaultModel,
},
})
if (resolution) {
const { model: resolvedModel, provenance, variant: resolvedVariant } = resolution
actualModel = resolvedModel
if (!parseModelString(actualModel)) {
return {
agentToUse: "",
categoryModel: undefined,
categoryPromptAppend: undefined,
modelInfo: undefined,
actualModel: undefined,
isUnstableAgent: false,
error: `Invalid model format "${actualModel}". Expected "provider/model" format (e.g., "anthropic/claude-sonnet-4-5").`,
}
}
let type: "user-defined" | "inherited" | "category-default" | "system-default"
const source = provenance
switch (provenance) {
case "override":
type = "user-defined"
break
case "category-default":
case "provider-fallback":
type = "category-default"
break
case "system-default":
type = "system-default"
break
}
modelInfo = { model: actualModel, type, source }
const parsedModel = parseModelString(actualModel)
const variantToUse = userCategories?.[args.category!]?.variant ?? resolvedVariant ?? resolved.config.variant
categoryModel = parsedModel
? (variantToUse ? { ...parsedModel, variant: variantToUse } : parsedModel)
: undefined
}
}
if (!categoryModel && actualModel) {
const parsedModel = parseModelString(actualModel)
categoryModel = parsedModel ?? undefined
}
const categoryPromptAppend = resolved.promptAppend || undefined
if (!categoryModel && !actualModel) {
const categoryNames = Object.keys({ ...DEFAULT_CATEGORIES, ...userCategories })
return {
agentToUse: "",
categoryModel: undefined,
categoryPromptAppend: undefined,
modelInfo: undefined,
actualModel: undefined,
isUnstableAgent: false,
error: `Model not configured for category "${args.category}".
Configure in one of:
1. OpenCode: Set "model" in opencode.json
2. Oh-My-OpenCode: Set category model in oh-my-opencode.json
3. Provider: Connect a provider with available models
Current category: ${args.category}
Available categories: ${categoryNames.join(", ")}`,
}
}
const isUnstableAgent = resolved.config.is_unstable_agent === true || (actualModel?.toLowerCase().includes("gemini") ?? false)
return {
agentToUse: SISYPHUS_JUNIOR_AGENT,
categoryModel,
categoryPromptAppend,
modelInfo,
actualModel,
isUnstableAgent,
}
}
export async function resolveSubagentExecution(
args: DelegateTaskArgs,
executorCtx: ExecutorContext,
parentAgent: string | undefined,
categoryExamples: string
): Promise<{ agentToUse: string; categoryModel: { providerID: string; modelID: string } | undefined; error?: string }> {
const { client } = executorCtx
if (!args.subagent_type?.trim()) {
return { agentToUse: "", categoryModel: undefined, error: `Agent name cannot be empty.` }
}
const agentName = args.subagent_type.trim()
if (agentName.toLowerCase() === SISYPHUS_JUNIOR_AGENT.toLowerCase()) {
return {
agentToUse: "",
categoryModel: undefined,
error: `Cannot use subagent_type="${SISYPHUS_JUNIOR_AGENT}" directly. Use category parameter instead (e.g., ${categoryExamples}).
Sisyphus-Junior is spawned automatically when you specify a category. Pick the appropriate category for your task domain.`,
}
}
if (isPlanAgent(agentName) && isPlanAgent(parentAgent)) {
return {
agentToUse: "",
categoryModel: undefined,
error: `You are prometheus. You cannot delegate to prometheus via delegate_task.
Create the work plan directly - that's your job as the planning agent.`,
}
}
let agentToUse = agentName
let categoryModel: { providerID: string; modelID: string } | undefined
try {
const agentsResult = await client.app.agents()
type AgentInfo = { name: string; mode?: "subagent" | "primary" | "all"; model?: { providerID: string; modelID: string } }
const agents = (agentsResult as { data?: AgentInfo[] }).data ?? agentsResult as unknown as AgentInfo[]
const callableAgents = agents.filter((a) => a.mode !== "primary")
const matchedAgent = callableAgents.find(
(agent) => agent.name.toLowerCase() === agentToUse.toLowerCase()
)
if (!matchedAgent) {
const isPrimaryAgent = agents
.filter((a) => a.mode === "primary")
.find((agent) => agent.name.toLowerCase() === agentToUse.toLowerCase())
if (isPrimaryAgent) {
return {
agentToUse: "",
categoryModel: undefined,
error: `Cannot call primary agent "${isPrimaryAgent.name}" via delegate_task. Primary agents are top-level orchestrators.`,
}
}
const availableAgents = callableAgents
.map((a) => a.name)
.sort()
.join(", ")
return {
agentToUse: "",
categoryModel: undefined,
error: `Unknown agent: "${agentToUse}". Available agents: ${availableAgents}`,
}
}
agentToUse = matchedAgent.name
if (matchedAgent.model) {
categoryModel = matchedAgent.model
}
} catch {
// Proceed anyway - session.prompt will fail with clearer error if agent doesn't exist
}
return { agentToUse, categoryModel }
}
+100
View File
@@ -0,0 +1,100 @@
import { existsSync, readdirSync } from "node:fs"
import { join } from "node:path"
import { MESSAGE_STORAGE } from "../../features/hook-message-injector"
import type { DelegateTaskArgs } from "./types"
/**
* Parse a model string in "provider/model" format.
*/
export function parseModelString(model: string): { providerID: string; modelID: string } | undefined {
const parts = model.split("/")
if (parts.length >= 2) {
return { providerID: parts[0], modelID: parts.slice(1).join("/") }
}
return undefined
}
/**
* Get the message directory for a session, checking both direct and nested paths.
*/
export function getMessageDir(sessionID: string): string | null {
if (!existsSync(MESSAGE_STORAGE)) return null
const directPath = join(MESSAGE_STORAGE, sessionID)
if (existsSync(directPath)) return directPath
for (const dir of readdirSync(MESSAGE_STORAGE)) {
const sessionPath = join(MESSAGE_STORAGE, dir, sessionID)
if (existsSync(sessionPath)) return sessionPath
}
return null
}
/**
* Format a duration between two dates as a human-readable string.
*/
export function formatDuration(start: Date, end?: Date): string {
const duration = (end ?? new Date()).getTime() - start.getTime()
const seconds = Math.floor(duration / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
if (hours > 0) return `${hours}h ${minutes % 60}m ${seconds % 60}s`
if (minutes > 0) return `${minutes}m ${seconds % 60}s`
return `${seconds}s`
}
/**
* Context for error formatting.
*/
export interface ErrorContext {
operation: string
args?: DelegateTaskArgs
sessionID?: string
agent?: string
category?: string
}
/**
* Format an error with detailed context for debugging.
*/
export function formatDetailedError(error: unknown, ctx: ErrorContext): string {
const message = error instanceof Error ? error.message : String(error)
const stack = error instanceof Error ? error.stack : undefined
const lines: string[] = [
`${ctx.operation} failed`,
"",
`**Error**: ${message}`,
]
if (ctx.sessionID) {
lines.push(`**Session ID**: ${ctx.sessionID}`)
}
if (ctx.agent) {
lines.push(`**Agent**: ${ctx.agent}${ctx.category ? ` (category: ${ctx.category})` : ""}`)
}
if (ctx.args) {
lines.push("", "**Arguments**:")
lines.push(`- description: "${ctx.args.description}"`)
lines.push(`- category: ${ctx.args.category ?? "(none)"}`)
lines.push(`- subagent_type: ${ctx.args.subagent_type ?? "(none)"}`)
lines.push(`- run_in_background: ${ctx.args.run_in_background}`)
lines.push(`- load_skills: [${ctx.args.load_skills?.join(", ") ?? ""}]`)
if (ctx.args.session_id) {
lines.push(`- session_id: ${ctx.args.session_id}`)
}
}
if (stack) {
lines.push("", "**Stack Trace**:")
lines.push("```")
lines.push(stack.split("\n").slice(0, 10).join("\n"))
lines.push("```")
}
return lines.join("\n")
}
+2 -1
View File
@@ -1,3 +1,4 @@
export { createDelegateTask, type DelegateTaskToolOptions } from "./tools"
export { createDelegateTask, resolveCategoryConfig, buildSystemContent } from "./tools"
export type { DelegateTaskToolOptions, SyncSessionCreatedEvent, BuildSystemContentInput } from "./tools"
export type * from "./types"
export * from "./constants"
+32
View File
@@ -0,0 +1,32 @@
import { PLAN_AGENT_SYSTEM_PREPEND, isPlanAgent } from "./constants"
import type { BuildSystemContentInput } from "./types"
/**
* Build the system content to inject into the agent prompt.
* Combines skill content, category prompt append, and plan agent system prepend.
*/
export function buildSystemContent(input: BuildSystemContentInput): string | undefined {
const { skillContent, categoryPromptAppend, agentName } = input
const planAgentPrepend = isPlanAgent(agentName) ? PLAN_AGENT_SYSTEM_PREPEND : ""
if (!skillContent && !categoryPromptAppend && !planAgentPrepend) {
return undefined
}
const parts: string[] = []
if (planAgentPrepend) {
parts.push(planAgentPrepend)
}
if (skillContent) {
parts.push(skillContent)
}
if (categoryPromptAppend) {
parts.push(categoryPromptAppend)
}
return parts.join("\n\n") || undefined
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+37
View File
@@ -1,3 +1,9 @@
import type { PluginInput } from "@opencode-ai/plugin"
import type { BackgroundManager } from "../../features/background-agent"
import type { CategoriesConfig, GitMasterConfig, BrowserAutomationProvider } from "../../config/schema"
export type OpencodeClient = PluginInput["client"]
export interface DelegateTaskArgs {
description: string
prompt: string
@@ -8,3 +14,34 @@ export interface DelegateTaskArgs {
command?: string
load_skills: string[]
}
export interface ToolContextWithMetadata {
sessionID: string
messageID: string
agent: string
abort: AbortSignal
metadata?: (input: { title?: string; metadata?: Record<string, unknown> }) => void
}
export interface SyncSessionCreatedEvent {
sessionID: string
parentID: string
title: string
}
export interface DelegateTaskToolOptions {
manager: BackgroundManager
client: OpencodeClient
directory: string
userCategories?: CategoriesConfig
gitMasterConfig?: GitMasterConfig
sisyphusJuniorModel?: string
browserProvider?: BrowserAutomationProvider
onSyncSessionCreated?: (event: SyncSessionCreatedEvent) => Promise<void>
}
export interface BuildSystemContentInput {
skillContent?: string
categoryPromptAppend?: string
agentName?: string
}
+51 -51
View File
@@ -2,9 +2,9 @@ import { describe, it, expect } from "bun:test"
import { buildRgArgs, buildFindArgs, buildPowerShellCommand } from "./cli"
describe("buildRgArgs", () => {
// #given default options (no hidden/follow specified)
// #when building ripgrep args
// #then should include --hidden and --follow by default
// given default options (no hidden/follow specified)
// when building ripgrep args
// then should include --hidden and --follow by default
it("includes --hidden by default when not explicitly set", () => {
const args = buildRgArgs({ pattern: "*.ts" })
expect(args).toContain("--hidden")
@@ -15,41 +15,41 @@ describe("buildRgArgs", () => {
expect(args).toContain("--follow")
})
// #given hidden=false explicitly set
// #when building ripgrep args
// #then should NOT include --hidden
// given hidden=false explicitly set
// when building ripgrep args
// then should NOT include --hidden
it("excludes --hidden when explicitly set to false", () => {
const args = buildRgArgs({ pattern: "*.ts", hidden: false })
expect(args).not.toContain("--hidden")
})
// #given follow=false explicitly set
// #when building ripgrep args
// #then should NOT include --follow
// given follow=false explicitly set
// when building ripgrep args
// then should NOT include --follow
it("excludes --follow when explicitly set to false", () => {
const args = buildRgArgs({ pattern: "*.ts", follow: false })
expect(args).not.toContain("--follow")
})
// #given hidden=true explicitly set
// #when building ripgrep args
// #then should include --hidden
// given hidden=true explicitly set
// when building ripgrep args
// then should include --hidden
it("includes --hidden when explicitly set to true", () => {
const args = buildRgArgs({ pattern: "*.ts", hidden: true })
expect(args).toContain("--hidden")
})
// #given follow=true explicitly set
// #when building ripgrep args
// #then should include --follow
// given follow=true explicitly set
// when building ripgrep args
// then should include --follow
it("includes --follow when explicitly set to true", () => {
const args = buildRgArgs({ pattern: "*.ts", follow: true })
expect(args).toContain("--follow")
})
// #given pattern with special characters
// #when building ripgrep args
// #then should include glob pattern correctly
// given pattern with special characters
// when building ripgrep args
// then should include glob pattern correctly
it("includes the glob pattern", () => {
const args = buildRgArgs({ pattern: "**/*.tsx" })
expect(args).toContain("--glob=**/*.tsx")
@@ -57,9 +57,9 @@ describe("buildRgArgs", () => {
})
describe("buildFindArgs", () => {
// #given default options (no hidden/follow specified)
// #when building find args
// #then should include hidden files by default (no exclusion filter)
// given default options (no hidden/follow specified)
// when building find args
// then should include hidden files by default (no exclusion filter)
it("includes hidden files by default when not explicitly set", () => {
const args = buildFindArgs({ pattern: "*.ts" })
// When hidden is enabled (default), should NOT have the exclusion filter
@@ -67,43 +67,43 @@ describe("buildFindArgs", () => {
expect(args.join(" ")).not.toContain("*/.*")
})
// #given default options (no follow specified)
// #when building find args
// #then should include -L flag for symlink following by default
// given default options (no follow specified)
// when building find args
// then should include -L flag for symlink following by default
it("includes -L flag for symlink following by default", () => {
const args = buildFindArgs({ pattern: "*.ts" })
expect(args).toContain("-L")
})
// #given hidden=false explicitly set
// #when building find args
// #then should exclude hidden files
// given hidden=false explicitly set
// when building find args
// then should exclude hidden files
it("excludes hidden files when hidden is explicitly false", () => {
const args = buildFindArgs({ pattern: "*.ts", hidden: false })
expect(args).toContain("-not")
expect(args.join(" ")).toContain("*/.*")
})
// #given follow=false explicitly set
// #when building find args
// #then should NOT include -L flag
// given follow=false explicitly set
// when building find args
// then should NOT include -L flag
it("excludes -L flag when follow is explicitly false", () => {
const args = buildFindArgs({ pattern: "*.ts", follow: false })
expect(args).not.toContain("-L")
})
// #given hidden=true explicitly set
// #when building find args
// #then should include hidden files
// given hidden=true explicitly set
// when building find args
// then should include hidden files
it("includes hidden files when hidden is explicitly true", () => {
const args = buildFindArgs({ pattern: "*.ts", hidden: true })
expect(args).not.toContain("-not")
expect(args.join(" ")).not.toContain("*/.*")
})
// #given follow=true explicitly set
// #when building find args
// #then should include -L flag
// given follow=true explicitly set
// when building find args
// then should include -L flag
it("includes -L flag when follow is explicitly true", () => {
const args = buildFindArgs({ pattern: "*.ts", follow: true })
expect(args).toContain("-L")
@@ -111,45 +111,45 @@ describe("buildFindArgs", () => {
})
describe("buildPowerShellCommand", () => {
// #given default options (no hidden specified)
// #when building PowerShell command
// #then should include -Force by default
// given default options (no hidden specified)
// when building PowerShell command
// then should include -Force by default
it("includes -Force by default when not explicitly set", () => {
const args = buildPowerShellCommand({ pattern: "*.ts" })
const command = args.join(" ")
expect(command).toContain("-Force")
})
// #given hidden=false explicitly set
// #when building PowerShell command
// #then should NOT include -Force
// given hidden=false explicitly set
// when building PowerShell command
// then should NOT include -Force
it("excludes -Force when hidden is explicitly false", () => {
const args = buildPowerShellCommand({ pattern: "*.ts", hidden: false })
const command = args.join(" ")
expect(command).not.toContain("-Force")
})
// #given hidden=true explicitly set
// #when building PowerShell command
// #then should include -Force
// given hidden=true explicitly set
// when building PowerShell command
// then should include -Force
it("includes -Force when hidden is explicitly true", () => {
const args = buildPowerShellCommand({ pattern: "*.ts", hidden: true })
const command = args.join(" ")
expect(command).toContain("-Force")
})
// #given default options (no follow specified)
// #when building PowerShell command
// #then should NOT include -FollowSymlink (unsupported in Windows PowerShell 5.1)
// given default options (no follow specified)
// when building PowerShell command
// then should NOT include -FollowSymlink (unsupported in Windows PowerShell 5.1)
it("does NOT include -FollowSymlink (unsupported in Windows PowerShell 5.1)", () => {
const args = buildPowerShellCommand({ pattern: "*.ts" })
const command = args.join(" ")
expect(command).not.toContain("-FollowSymlink")
})
// #given pattern with special chars
// #when building PowerShell command
// #then should escape single quotes properly
// given pattern with special chars
// when building PowerShell command
// then should escape single quotes properly
it("escapes single quotes in pattern", () => {
const args = buildPowerShellCommand({ pattern: "test's.ts" })
const command = args.join(" ")
+19 -19
View File
@@ -10,7 +10,7 @@ describe("findFileRecursive", () => {
let testDir: string
beforeEach(() => {
// #given - create temp directory for testing
// given - create temp directory for testing
testDir = join(tmpdir(), `downloader-test-${Date.now()}`)
mkdirSync(testDir, { recursive: true })
})
@@ -23,57 +23,57 @@ describe("findFileRecursive", () => {
})
test("should find file in root directory", () => {
// #given
// given
const targetFile = join(testDir, "rg.exe")
writeFileSync(targetFile, "dummy content")
// #when
// when
const result = findFileRecursive(testDir, "rg.exe")
// #then
// then
expect(result).toBe(targetFile)
})
test("should find file in nested directory (ripgrep release structure)", () => {
// #given - simulate ripgrep release zip structure
// given - simulate ripgrep release zip structure
const nestedDir = join(testDir, "ripgrep-14.1.1-x86_64-pc-windows-msvc")
mkdirSync(nestedDir, { recursive: true })
const targetFile = join(nestedDir, "rg.exe")
writeFileSync(targetFile, "dummy content")
// #when
// when
const result = findFileRecursive(testDir, "rg.exe")
// #then
// then
expect(result).toBe(targetFile)
})
test("should find file in deeply nested directory", () => {
// #given
// given
const deepDir = join(testDir, "level1", "level2", "level3")
mkdirSync(deepDir, { recursive: true })
const targetFile = join(deepDir, "rg")
writeFileSync(targetFile, "dummy content")
// #when
// when
const result = findFileRecursive(testDir, "rg")
// #then
// then
expect(result).toBe(targetFile)
})
test("should return null when file not found", () => {
// #given - empty directory
// given - empty directory
// #when
// when
const result = findFileRecursive(testDir, "nonexistent.exe")
// #then
// then
expect(result).toBeNull()
})
test("should find first match when multiple files exist", () => {
// #given
// given
const dir1 = join(testDir, "dir1")
const dir2 = join(testDir, "dir2")
mkdirSync(dir1, { recursive: true })
@@ -81,23 +81,23 @@ describe("findFileRecursive", () => {
writeFileSync(join(dir1, "rg"), "first")
writeFileSync(join(dir2, "rg"), "second")
// #when
// when
const result = findFileRecursive(testDir, "rg")
// #then
// then
expect(result).not.toBeNull()
expect(result!.endsWith("rg")).toBe(true)
})
test("should match exact filename, not partial", () => {
// #given
// given
writeFileSync(join(testDir, "rg.exe.bak"), "backup file")
writeFileSync(join(testDir, "not-rg.exe"), "wrong file")
// #when
// when
const result = findFileRecursive(testDir, "rg.exe")
// #then
// then
expect(result).toBeNull()
})
})
+30 -30
View File
@@ -4,9 +4,9 @@ import { normalizeArgs, validateArgs, createLookAt } from "./tools"
describe("look-at tool", () => {
describe("normalizeArgs", () => {
// #given LLM이 file_path 대신 path를 사용할 수 있음
// #when path 파라미터로 호출
// #then file_path로 정규화되어야 함
// given LLM이 file_path 대신 path를 사용할 수 있음
// when path 파라미터로 호출
// then file_path로 정규화되어야 함
test("normalizes path to file_path for LLM compatibility", () => {
const args = { path: "/some/file.png", goal: "analyze" }
const normalized = normalizeArgs(args as any)
@@ -14,18 +14,18 @@ describe("look-at tool", () => {
expect(normalized.goal).toBe("analyze")
})
// #given 정상적인 file_path 사용
// #when file_path 파라미터로 호출
// #then 그대로 유지
// given 정상적인 file_path 사용
// when file_path 파라미터로 호출
// then 그대로 유지
test("keeps file_path when properly provided", () => {
const args = { file_path: "/correct/path.pdf", goal: "extract" }
const normalized = normalizeArgs(args)
expect(normalized.file_path).toBe("/correct/path.pdf")
})
// #given 둘 다 제공된 경우
// #when file_path와 path 모두 있음
// #then file_path 우선
// given 둘 다 제공된 경우
// when file_path와 path 모두 있음
// then file_path 우선
test("prefers file_path over path when both provided", () => {
const args = { file_path: "/preferred.png", path: "/fallback.png", goal: "test" }
const normalized = normalizeArgs(args as any)
@@ -34,17 +34,17 @@ describe("look-at tool", () => {
})
describe("validateArgs", () => {
// #given 유효한 인자
// #when 검증
// #then null 반환 (에러 없음)
// given 유효한 인자
// when 검증
// then null 반환 (에러 없음)
test("returns null for valid args", () => {
const args = { file_path: "/valid/path.png", goal: "analyze" }
expect(validateArgs(args)).toBeNull()
})
// #given file_path 누락
// #when 검증
// #then 명확한 에러 메시지
// given file_path 누락
// when 검증
// then 명확한 에러 메시지
test("returns error when file_path is missing", () => {
const args = { goal: "analyze" } as any
const error = validateArgs(args)
@@ -52,9 +52,9 @@ describe("look-at tool", () => {
expect(error).toContain("required")
})
// #given goal 누락
// #when 검증
// #then 명확한 에러 메시지
// given goal 누락
// when 검증
// then 명확한 에러 메시지
test("returns error when goal is missing", () => {
const args = { file_path: "/some/path.png" } as any
const error = validateArgs(args)
@@ -62,9 +62,9 @@ describe("look-at tool", () => {
expect(error).toContain("required")
})
// #given file_path가 빈 문자열
// #when 검증
// #then 에러 반환
// given file_path가 빈 문자열
// when 검증
// then 에러 반환
test("returns error when file_path is empty string", () => {
const args = { file_path: "", goal: "analyze" }
const error = validateArgs(args)
@@ -73,9 +73,9 @@ describe("look-at tool", () => {
})
describe("createLookAt error handling", () => {
// #given session.prompt에서 JSON parse 에러 발생
// #when LookAt 도구 실행
// #then 사용자 친화적 에러 메시지 반환
// given session.prompt에서 JSON parse 에러 발생
// when LookAt 도구 실행
// then 사용자 친화적 에러 메시지 반환
test("handles JSON parse error from session.prompt gracefully", async () => {
const mockClient = {
session: {
@@ -115,9 +115,9 @@ describe("look-at tool", () => {
expect(result).toContain("image/png")
})
// #given session.prompt에서 일반 에러 발생
// #when LookAt 도구 실행
// #then 원본 에러 메시지 포함한 에러 반환
// given session.prompt에서 일반 에러 발생
// when LookAt 도구 실행
// then 원본 에러 메시지 포함한 에러 반환
test("handles generic prompt error gracefully", async () => {
const mockClient = {
session: {
@@ -157,9 +157,9 @@ describe("look-at tool", () => {
})
describe("createLookAt model passthrough", () => {
// #given multimodal-looker agent has resolved model info
// #when LookAt 도구 실행
// #then session.prompt에 model 정보가 전달되어야 함
// given multimodal-looker agent has resolved model info
// when LookAt 도구 실행
// then session.prompt에 model 정보가 전달되어야 함
test("passes multimodal-looker model to session.prompt when available", async () => {
let promptBody: any
+34 -34
View File
@@ -50,60 +50,60 @@ describe("session-manager storage", () => {
})
test("getAllSessions returns empty array when no sessions exist", async () => {
// #when
// when
const sessions = await getAllSessions()
// #then
// then
expect(Array.isArray(sessions)).toBe(true)
expect(sessions).toEqual([])
})
test("getMessageDir finds session in direct path", () => {
// #given
// given
const sessionID = "ses_test123"
const sessionPath = join(TEST_MESSAGE_STORAGE, sessionID)
mkdirSync(sessionPath, { recursive: true })
writeFileSync(join(sessionPath, "msg_001.json"), JSON.stringify({ id: "msg_001", role: "user" }))
// #when
// when
const result = getMessageDir(sessionID)
// #then
// then
expect(result).toBe(sessionPath)
})
test("sessionExists returns false for non-existent session", () => {
// #when
// when
const exists = sessionExists("ses_nonexistent")
// #then
// then
expect(exists).toBe(false)
})
test("sessionExists returns true for existing session", () => {
// #given
// given
const sessionID = "ses_exists"
const sessionPath = join(TEST_MESSAGE_STORAGE, sessionID)
mkdirSync(sessionPath, { recursive: true })
writeFileSync(join(sessionPath, "msg_001.json"), JSON.stringify({ id: "msg_001" }))
// #when
// when
const exists = sessionExists(sessionID)
// #then
// then
expect(exists).toBe(true)
})
test("readSessionMessages returns empty array for non-existent session", async () => {
// #when
// when
const messages = await readSessionMessages("ses_nonexistent")
// #then
// then
expect(messages).toEqual([])
})
test("readSessionMessages sorts messages by timestamp", async () => {
// #given
// given
const sessionID = "ses_test123"
const sessionPath = join(TEST_MESSAGE_STORAGE, sessionID)
mkdirSync(sessionPath, { recursive: true })
@@ -117,33 +117,33 @@ describe("session-manager storage", () => {
JSON.stringify({ id: "msg_001", role: "user", time: { created: 1000 } })
)
// #when
// when
const messages = await readSessionMessages(sessionID)
// #then
// then
expect(messages.length).toBe(2)
expect(messages[0].id).toBe("msg_001")
expect(messages[1].id).toBe("msg_002")
})
test("readSessionTodos returns empty array when no todos exist", async () => {
// #when
// when
const todos = await readSessionTodos("ses_nonexistent")
// #then
// then
expect(todos).toEqual([])
})
test("getSessionInfo returns null for non-existent session", async () => {
// #when
// when
const info = await getSessionInfo("ses_nonexistent")
// #then
// then
expect(info).toBeNull()
})
test("getSessionInfo aggregates session metadata correctly", async () => {
// #given
// given
const sessionID = "ses_test123"
const sessionPath = join(TEST_MESSAGE_STORAGE, sessionID)
mkdirSync(sessionPath, { recursive: true })
@@ -168,10 +168,10 @@ describe("session-manager storage", () => {
})
)
// #when
// when
const info = await getSessionInfo(sessionID)
// #then
// then
expect(info).not.toBeNull()
expect(info?.id).toBe(sessionID)
expect(info?.message_count).toBe(2)
@@ -228,7 +228,7 @@ describe("session-manager storage - getMainSessions", () => {
}
test("getMainSessions returns only sessions without parentID", async () => {
// #given
// given
const projectID = "proj_abc123"
const now = Date.now()
@@ -240,16 +240,16 @@ describe("session-manager storage - getMainSessions", () => {
createMessageForSession("ses_main2", "msg_001", now - 1000)
createMessageForSession("ses_child1", "msg_001", now)
// #when
// when
const sessions = await storage.getMainSessions({ directory: "/test/path" })
// #then
// then
expect(sessions.length).toBe(2)
expect(sessions.map((s) => s.id)).not.toContain("ses_child1")
})
test("getMainSessions sorts by time.updated descending (most recent first)", async () => {
// #given
// given
const projectID = "proj_abc123"
const now = Date.now()
@@ -261,10 +261,10 @@ describe("session-manager storage - getMainSessions", () => {
createMessageForSession("ses_mid", "msg_001", now - 2000)
createMessageForSession("ses_new", "msg_001", now)
// #when
// when
const sessions = await storage.getMainSessions({ directory: "/test/path" })
// #then
// then
expect(sessions.length).toBe(3)
expect(sessions[0].id).toBe("ses_new")
expect(sessions[1].id).toBe("ses_mid")
@@ -272,7 +272,7 @@ describe("session-manager storage - getMainSessions", () => {
})
test("getMainSessions filters by directory (project path)", async () => {
// #given
// given
const projectA = "proj_aaa"
const projectB = "proj_bbb"
const now = Date.now()
@@ -283,11 +283,11 @@ describe("session-manager storage - getMainSessions", () => {
createMessageForSession("ses_projA", "msg_001", now)
createMessageForSession("ses_projB", "msg_001", now)
// #when
// when
const sessionsA = await storage.getMainSessions({ directory: "/path/to/projectA" })
const sessionsB = await storage.getMainSessions({ directory: "/path/to/projectB" })
// #then
// then
expect(sessionsA.length).toBe(1)
expect(sessionsA[0].id).toBe("ses_projA")
expect(sessionsB.length).toBe(1)
@@ -295,7 +295,7 @@ describe("session-manager storage - getMainSessions", () => {
})
test("getMainSessions returns all main sessions when directory is not specified", async () => {
// #given
// given
const projectA = "proj_aaa"
const projectB = "proj_bbb"
const now = Date.now()
@@ -306,10 +306,10 @@ describe("session-manager storage - getMainSessions", () => {
createMessageForSession("ses_projA", "msg_001", now)
createMessageForSession("ses_projB", "msg_001", now - 1000)
// #when
// when
const sessions = await storage.getMainSessions({})
// #then
// then
expect(sessions.length).toBe(2)
})
})
+6 -6
View File
@@ -38,23 +38,23 @@ describe("session-manager tools", () => {
})
test("session_list filters by project_path", async () => {
// #given
// given
const projectPath = "/Users/yeongyu/local-workspaces/oh-my-opencode"
// #when
// when
const result = await session_list.execute({ project_path: projectPath }, mockContext)
// #then
// then
expect(typeof result).toBe("string")
})
test("session_list uses process.cwd() as default project_path", async () => {
// #given - no project_path provided
// given - no project_path provided
// #when
// when
const result = await session_list.execute({}, mockContext)
// #then - should not throw and return string (uses process.cwd() internally)
// then - should not throw and return string (uses process.cwd() internally)
expect(typeof result).toBe("string")
})
+27 -27
View File
@@ -11,29 +11,29 @@ import type { SessionInfo, SessionMessage, SearchResult } from "./types"
describe("session-manager utils", () => {
test("formatSessionList handles empty array", async () => {
// #given
// given
const sessions: string[] = []
// #when
// when
const result = await formatSessionList(sessions)
// #then
// then
expect(result).toContain("No sessions found")
})
test("formatSessionMessages handles empty array", () => {
// #given
// given
const messages: SessionMessage[] = []
// #when
// when
const result = formatSessionMessages(messages)
// #then
// then
expect(result).toContain("No messages")
})
test("formatSessionMessages includes message content", () => {
// #given
// given
const messages: SessionMessage[] = [
{
id: "msg_001",
@@ -43,16 +43,16 @@ describe("session-manager utils", () => {
},
]
// #when
// when
const result = formatSessionMessages(messages)
// #then
// then
expect(result).toContain("user")
expect(result).toContain("Hello world")
})
test("formatSessionMessages includes todos when requested", () => {
// #given
// given
const messages: SessionMessage[] = [
{
id: "msg_001",
@@ -66,17 +66,17 @@ describe("session-manager utils", () => {
{ id: "2", content: "Task 2", status: "pending" as const },
]
// #when
// when
const result = formatSessionMessages(messages, true, todos)
// #then
// then
expect(result).toContain("Todos")
expect(result).toContain("Task 1")
expect(result).toContain("Task 2")
})
test("formatSessionInfo includes all metadata", () => {
// #given
// given
const info: SessionInfo = {
id: "ses_test123",
message_count: 42,
@@ -89,10 +89,10 @@ describe("session-manager utils", () => {
transcript_entries: 123,
}
// #when
// when
const result = formatSessionInfo(info)
// #then
// then
expect(result).toContain("ses_test123")
expect(result).toContain("42")
expect(result).toContain("build, oracle")
@@ -100,18 +100,18 @@ describe("session-manager utils", () => {
})
test("formatSearchResults handles empty array", () => {
// #given
// given
const results: SearchResult[] = []
// #when
// when
const result = formatSearchResults(results)
// #then
// then
expect(result).toContain("No matches")
})
test("formatSearchResults formats matches correctly", () => {
// #given
// given
const results: SearchResult[] = [
{
session_id: "ses_test123",
@@ -123,10 +123,10 @@ describe("session-manager utils", () => {
},
]
// #when
// when
const result = formatSearchResults(results)
// #then
// then
expect(result).toContain("Found 1 matches")
expect(result).toContain("ses_test123")
expect(result).toContain("msg_001")
@@ -135,25 +135,25 @@ describe("session-manager utils", () => {
})
test("filterSessionsByDate filters correctly", async () => {
// #given
// given
const sessionIDs = ["ses_001", "ses_002", "ses_003"]
// #when
// when
const result = await filterSessionsByDate(sessionIDs)
// #then
// then
expect(Array.isArray(result)).toBe(true)
})
test("searchInSession finds matches case-insensitively", async () => {
// #given
// given
const sessionID = "ses_nonexistent"
const query = "test"
// #when
// when
const results = await searchInSession(sessionID, query, false)
// #then
// then
expect(Array.isArray(results)).toBe(true)
expect(results.length).toBe(0)
})
+26 -26
View File
@@ -43,28 +43,28 @@ describe("skill_mcp tool", () => {
describe("parameter validation", () => {
it("throws when no operation specified", async () => {
// #given
// given
const tool = createSkillMcpTool({
manager,
getLoadedSkills: () => loadedSkills,
getSessionID: () => sessionID,
})
// #when / #then
// when / #then
await expect(
tool.execute({ mcp_name: "test-server" }, mockContext)
).rejects.toThrow(/Missing operation/)
})
it("throws when multiple operations specified", async () => {
// #given
// given
const tool = createSkillMcpTool({
manager,
getLoadedSkills: () => loadedSkills,
getSessionID: () => sessionID,
})
// #when / #then
// when / #then
await expect(
tool.execute({
mcp_name: "test-server",
@@ -75,7 +75,7 @@ describe("skill_mcp tool", () => {
})
it("throws when mcp_name not found in any skill", async () => {
// #given
// given
loadedSkills = [
createMockSkillWithMcp("test-skill", {
"known-server": { command: "echo", args: ["test"] },
@@ -87,14 +87,14 @@ describe("skill_mcp tool", () => {
getSessionID: () => sessionID,
})
// #when / #then
// when / #then
await expect(
tool.execute({ mcp_name: "unknown-server", tool_name: "some-tool" }, mockContext)
).rejects.toThrow(/not found/)
})
it("includes available MCP servers in error message", async () => {
// #given
// given
loadedSkills = [
createMockSkillWithMcp("db-skill", {
sqlite: { command: "uvx", args: ["mcp-server-sqlite"] },
@@ -109,14 +109,14 @@ describe("skill_mcp tool", () => {
getSessionID: () => sessionID,
})
// #when / #then
// when / #then
await expect(
tool.execute({ mcp_name: "missing", tool_name: "test" }, mockContext)
).rejects.toThrow(/sqlite.*db-skill|rest-api.*api-skill/s)
})
it("throws on invalid JSON arguments", async () => {
// #given
// given
loadedSkills = [
createMockSkillWithMcp("test-skill", {
"test-server": { command: "echo" },
@@ -128,7 +128,7 @@ describe("skill_mcp tool", () => {
getSessionID: () => sessionID,
})
// #when / #then
// when / #then
await expect(
tool.execute({
mcp_name: "test-server",
@@ -141,27 +141,27 @@ describe("skill_mcp tool", () => {
describe("tool description", () => {
it("has concise description", () => {
// #given / #when
// given / #when
const tool = createSkillMcpTool({
manager,
getLoadedSkills: () => [],
getSessionID: () => "session",
})
// #then
// then
expect(tool.description.length).toBeLessThan(200)
expect(tool.description).toContain("mcp_name")
})
it("includes grep parameter in schema", () => {
// #given / #when
// given / #when
const tool = createSkillMcpTool({
manager,
getLoadedSkills: () => [],
getSessionID: () => "session",
})
// #then
// then
expect(tool.description).toBeDefined()
})
})
@@ -169,16 +169,16 @@ describe("skill_mcp tool", () => {
describe("applyGrepFilter", () => {
it("filters lines matching pattern", () => {
// #given
// given
const output = `line1: hello world
line2: foo bar
line3: hello again
line4: baz qux`
// #when
// when
const result = applyGrepFilter(output, "hello")
// #then
// then
expect(result).toContain("line1: hello world")
expect(result).toContain("line3: hello again")
expect(result).not.toContain("foo bar")
@@ -186,35 +186,35 @@ line4: baz qux`
})
it("returns original output when pattern is undefined", () => {
// #given
// given
const output = "some output"
// #when
// when
const result = applyGrepFilter(output, undefined)
// #then
// then
expect(result).toBe(output)
})
it("returns message when no lines match", () => {
// #given
// given
const output = "line1\nline2\nline3"
// #when
// when
const result = applyGrepFilter(output, "xyz")
// #then
// then
expect(result).toContain("[grep] No lines matched pattern")
})
it("handles invalid regex gracefully", () => {
// #given
// given
const output = "some output"
// #when
// when
const result = applyGrepFilter(output, "[invalid")
// #then
// then
expect(result).toBe(output)
})
})
+30 -30
View File
@@ -64,89 +64,89 @@ const mockContext: ToolContext = {
describe("skill tool - synchronous description", () => {
it("includes available_skills immediately when skills are pre-provided", () => {
// #given
// given
const loadedSkills = [createMockSkill("test-skill")]
// #when
// when
const tool = createSkillTool({ skills: loadedSkills })
// #then
// then
expect(tool.description).toContain("<available_skills>")
expect(tool.description).toContain("test-skill")
})
it("includes all pre-provided skills in available_skills immediately", () => {
// #given
// given
const loadedSkills = [
createMockSkill("playwright"),
createMockSkill("frontend-ui-ux"),
createMockSkill("git-master"),
]
// #when
// when
const tool = createSkillTool({ skills: loadedSkills })
// #then
// then
expect(tool.description).toContain("playwright")
expect(tool.description).toContain("frontend-ui-ux")
expect(tool.description).toContain("git-master")
})
it("shows no-skills message immediately when empty skills are pre-provided", () => {
// #given / #when
// given / #when
const tool = createSkillTool({ skills: [] })
// #then
// then
expect(tool.description).toContain("No skills are currently available")
})
})
describe("skill tool - agent restriction", () => {
it("allows skill without agent restriction to any agent", async () => {
// #given
// given
const loadedSkills = [createMockSkill("public-skill")]
const tool = createSkillTool({ skills: loadedSkills })
const context = { ...mockContext, agent: "any-agent" }
// #when
// when
const result = await tool.execute({ name: "public-skill" }, context)
// #then
// then
expect(result).toContain("public-skill")
})
it("allows skill when agent matches restriction", async () => {
// #given
// given
const loadedSkills = [createMockSkill("restricted-skill", { agent: "sisyphus" })]
const tool = createSkillTool({ skills: loadedSkills })
const context = { ...mockContext, agent: "sisyphus" }
// #when
// when
const result = await tool.execute({ name: "restricted-skill" }, context)
// #then
// then
expect(result).toContain("restricted-skill")
})
it("throws error when agent does not match restriction", async () => {
// #given
// given
const loadedSkills = [createMockSkill("sisyphus-only-skill", { agent: "sisyphus" })]
const tool = createSkillTool({ skills: loadedSkills })
const context = { ...mockContext, agent: "oracle" }
// #when / #then
// when / #then
await expect(tool.execute({ name: "sisyphus-only-skill" }, context)).rejects.toThrow(
'Skill "sisyphus-only-skill" is restricted to agent "sisyphus"'
)
})
it("throws error when context agent is undefined for restricted skill", async () => {
// #given
// given
const loadedSkills = [createMockSkill("sisyphus-only-skill", { agent: "sisyphus" })]
const tool = createSkillTool({ skills: loadedSkills })
const contextWithoutAgent = { ...mockContext, agent: undefined as unknown as string }
// #when / #then
// when / #then
await expect(tool.execute({ name: "sisyphus-only-skill" }, contextWithoutAgent)).rejects.toThrow(
'Skill "sisyphus-only-skill" is restricted to agent "sisyphus"'
)
@@ -167,7 +167,7 @@ describe("skill tool - MCP schema display", () => {
describe("formatMcpCapabilities with inputSchema", () => {
it("displays tool inputSchema when available", async () => {
// #given
// given
const mockToolsWithSchema: McpTool[] = [
{
name: "browser_type",
@@ -202,10 +202,10 @@ describe("skill tool - MCP schema display", () => {
getSessionID: () => sessionID,
})
// #when
// when
const result = await tool.execute({ name: "test-skill" }, mockContext)
// #then
// then
// Should include inputSchema details
expect(result).toContain("browser_type")
expect(result).toContain("inputSchema")
@@ -217,7 +217,7 @@ describe("skill tool - MCP schema display", () => {
})
it("displays multiple tools with their schemas", async () => {
// #given
// given
const mockToolsWithSchema: McpTool[] = [
{
name: "browser_navigate",
@@ -260,10 +260,10 @@ describe("skill tool - MCP schema display", () => {
getSessionID: () => sessionID,
})
// #when
// when
const result = await tool.execute({ name: "playwright-skill" }, mockContext)
// #then
// then
expect(result).toContain("browser_navigate")
expect(result).toContain("browser_click")
expect(result).toContain("url")
@@ -271,7 +271,7 @@ describe("skill tool - MCP schema display", () => {
})
it("handles tools without inputSchema gracefully", async () => {
// #given
// given
const mockToolsMinimal: McpTool[] = [
{
name: "simple_tool",
@@ -295,16 +295,16 @@ describe("skill tool - MCP schema display", () => {
getSessionID: () => sessionID,
})
// #when
// when
const result = await tool.execute({ name: "simple-skill" }, mockContext)
// #then
// then
expect(result).toContain("simple_tool")
// Should not throw, should handle gracefully
})
it("formats schema in a way LLM can understand for skill_mcp calls", async () => {
// #given
// given
const mockTools: McpTool[] = [
{
name: "query",
@@ -336,10 +336,10 @@ describe("skill tool - MCP schema display", () => {
getSessionID: () => sessionID,
})
// #when
// when
const result = await tool.execute({ name: "db-skill" }, mockContext)
// #then
// then
// Should provide enough info for LLM to construct valid skill_mcp call
expect(result).toContain("sqlite")
expect(result).toContain("query")
+21 -8
View File
@@ -30,21 +30,21 @@ function createMockSkill(name: string, description = ""): LoadedSkill {
describe("slashcommand tool - synchronous description", () => {
it("includes available_skills immediately when commands and skills are pre-provided", () => {
// #given
// given
const commands = [createMockCommand("commit", "Create a git commit")]
const skills = [createMockSkill("playwright", "Browser automation via Playwright MCP")]
// #when
// when
const tool = createSlashcommandTool({ commands, skills })
// #then
// then
expect(tool.description).toContain("<available_skills>")
expect(tool.description).toContain("commit")
expect(tool.description).toContain("playwright")
})
it("includes all pre-provided commands and skills in description immediately", () => {
// #given
// given
const commands = [
createMockCommand("commit", "Git commit"),
createMockCommand("plan", "Create plan"),
@@ -55,10 +55,10 @@ describe("slashcommand tool - synchronous description", () => {
createMockSkill("git-master", "Git operations"),
]
// #when
// when
const tool = createSlashcommandTool({ commands, skills })
// #then
// then
expect(tool.description).toContain("commit")
expect(tool.description).toContain("plan")
expect(tool.description).toContain("playwright")
@@ -67,10 +67,23 @@ describe("slashcommand tool - synchronous description", () => {
})
it("shows prefix-only description when both commands and skills are empty", () => {
// #given / #when
// given / #when
const tool = createSlashcommandTool({ commands: [], skills: [] })
// #then - even with no items, description should be built synchronously (not just prefix)
// then - even with no items, description should be built synchronously (not just prefix)
expect(tool.description).toContain("Load a skill")
})
it("includes user_message parameter documentation in description", () => {
// given
const commands = [createMockCommand("publish", "Publish package")]
const skills: LoadedSkill[] = []
// when
const tool = createSlashcommandTool({ commands, skills })
// then
expect(tool.description).toContain("user_message")
expect(tool.description).toContain("command='publish' user_message='patch'")
})
})
+30 -8
View File
@@ -100,7 +100,7 @@ function skillToCommandInfo(skill: LoadedSkill): CommandInfo {
}
}
async function formatLoadedCommand(cmd: CommandInfo): Promise<string> {
async function formatLoadedCommand(cmd: CommandInfo, userMessage?: string): Promise<string> {
const sections: string[] = []
sections.push(`# /${cmd.name} Command\n`)
@@ -113,6 +113,10 @@ async function formatLoadedCommand(cmd: CommandInfo): Promise<string> {
sections.push(`**Usage**: /${cmd.name} ${cmd.metadata.argumentHint}\n`)
}
if (userMessage) {
sections.push(`**Arguments**: ${userMessage}\n`)
}
if (cmd.metadata.model) {
sections.push(`**Model**: ${cmd.metadata.model}\n`)
}
@@ -137,7 +141,14 @@ async function formatLoadedCommand(cmd: CommandInfo): Promise<string> {
const commandDir = cmd.path ? dirname(cmd.path) : process.cwd()
const withFileRefs = await resolveFileReferencesInText(content, commandDir)
const resolvedContent = await resolveCommandsInText(withFileRefs)
sections.push(resolvedContent.trim())
// Substitute user_message into content if provided
let finalContent = resolvedContent.trim()
if (userMessage) {
finalContent = finalContent.replace(/\$\{user_message\}/g, userMessage)
}
sections.push(finalContent)
return sections.join("\n")
}
@@ -160,10 +171,15 @@ function formatCommandList(items: CommandInfo[]): string {
return lines.join("\n")
}
const TOOL_DESCRIPTION_PREFIX = `Load a skill to get detailed instructions for a specific task.
const TOOL_DESCRIPTION_PREFIX = `Load a skill or execute a command to get detailed instructions for a specific task.
Skills provide specialized knowledge and step-by-step guidance.
Use this when a task matches an available skill's description.
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 command name only: command='publish'
- Call with command and arguments: command='publish' user_message='patch'
- The tool will return detailed instructions for the command with your arguments substituted.
`
function buildDescriptionFromItems(items: CommandInfo[]): string {
@@ -226,7 +242,13 @@ export function createSlashcommandTool(options: SlashcommandToolOptions = {}): T
command: tool.schema
.string()
.describe(
"The slash command to execute (without the leading slash). E.g., 'commit', 'plan', 'execute'."
"The slash command name (without leading slash). E.g., 'publish', 'commit', 'plan'"
),
user_message: tool.schema
.string()
.optional()
.describe(
"Optional arguments or context to pass to the command. E.g., for '/publish patch', command='publish' user_message='patch'"
),
},
@@ -244,7 +266,7 @@ export function createSlashcommandTool(options: SlashcommandToolOptions = {}): T
)
if (exactMatch) {
return await formatLoadedCommand(exactMatch)
return await formatLoadedCommand(exactMatch, args.user_message)
}
const partialMatches = allItems.filter((cmd) =>
@@ -254,7 +276,7 @@ export function createSlashcommandTool(options: SlashcommandToolOptions = {}): T
if (partialMatches.length > 0) {
const matchList = partialMatches.map((cmd) => `/${cmd.name}`).join(", ")
return (
`No exact match for "/${cmdName}\". Did you mean: ${matchList}?\n\n` +
`No exact match for "/${cmdName}". Did you mean: ${matchList}?\n\n` +
formatCommandList(allItems)
)
}