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:
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
+66
-1033
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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(" ")
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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'")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user