mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
feat: surface agent selection without slash namespace collisions (#653)
Desktop users need a direct way to pick an installed Agent from slash completion while preserving the existing command and skill namespace. Add /agent <agent> <prompt> as the single execution surface and render active agents as namespaced completion rows. Constraint: Existing slash command names and ordering must remain authoritative. Rejected: Register each Agent as a top-level /<agent> command | would collide with built-in commands, skills, and future custom commands. Confidence: high Scope-risk: moderate Tested: bun run verify
This commit is contained in:
parent
46ca911d1a
commit
4589ef67f3
@ -625,7 +625,7 @@ describe('Content-only pages render without errors', () => {
|
||||
expect(screen.getByText('Slash commands')).toBeInTheDocument()
|
||||
expect(screen.getByText('/clear')).toBeInTheDocument()
|
||||
expect(screen.getByText('/cost')).toBeInTheDocument()
|
||||
expect(screen.getByText('14 more commands available. Type / to search the full command list.')).toBeInTheDocument()
|
||||
expect(screen.getByText('15 more commands available. Type / to search the full command list.')).toBeInTheDocument()
|
||||
|
||||
resetPageStores()
|
||||
})
|
||||
|
||||
@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => ({
|
||||
getMessages: vi.fn(),
|
||||
getGitInfo: vi.fn(),
|
||||
getSlashCommands: vi.fn(),
|
||||
listAgents: vi.fn(),
|
||||
getRepositoryContext: vi.fn(),
|
||||
getRecentProjects: vi.fn(),
|
||||
search: vi.fn(),
|
||||
@ -37,6 +38,12 @@ vi.mock('../../api/sessions', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../api/agents', () => ({
|
||||
agentsApi: {
|
||||
list: mocks.listAgents,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../api/filesystem', () => ({
|
||||
filesystemApi: {
|
||||
search: mocks.search,
|
||||
@ -185,6 +192,7 @@ describe('ChatInput file mentions', () => {
|
||||
mocks.list.mockResolvedValue({ sessions: [], total: 0 })
|
||||
mocks.getMessages.mockResolvedValue({ messages: [] })
|
||||
mocks.getSlashCommands.mockResolvedValue({ commands: [] })
|
||||
mocks.listAgents.mockResolvedValue({ activeAgents: [], allAgents: [] })
|
||||
})
|
||||
|
||||
it('keeps unsent composer drafts isolated when switching between session tabs', async () => {
|
||||
@ -1056,4 +1064,35 @@ describe('ChatInput file mentions', () => {
|
||||
expect(commandButtons[0]).toHaveTextContent('/superpowers:brainstorming')
|
||||
})
|
||||
})
|
||||
|
||||
it('offers active agents as slash entries that insert /agent with the selected type', async () => {
|
||||
mocks.listAgents.mockResolvedValue({
|
||||
activeAgents: [
|
||||
{
|
||||
agentType: 'debugger',
|
||||
description: 'Debug failures',
|
||||
modelDisplay: 'OPUS',
|
||||
source: 'userSettings',
|
||||
isActive: true,
|
||||
},
|
||||
],
|
||||
allAgents: [],
|
||||
})
|
||||
|
||||
render(<ChatInput />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.listAgents).toHaveBeenCalledWith('/repo')
|
||||
})
|
||||
|
||||
const input = screen.getByRole('textbox') as HTMLTextAreaElement
|
||||
fireEvent.change(input, {
|
||||
target: { value: '/debug', selectionStart: 6 },
|
||||
})
|
||||
|
||||
const agentOption = await screen.findByText('/agent debugger')
|
||||
fireEvent.click(agentOption)
|
||||
|
||||
expect(input).toHaveValue('/agent debugger ')
|
||||
})
|
||||
})
|
||||
|
||||
@ -13,6 +13,7 @@ import {
|
||||
type WorkspaceChatReference,
|
||||
} from '../../stores/workspaceChatContextStore'
|
||||
import { sessionsApi, type SessionGitInfo } from '../../api/sessions'
|
||||
import { agentsApi } from '../../api/agents'
|
||||
import { PermissionModeSelector } from '../controls/PermissionModeSelector'
|
||||
import { ModelSelector } from '../controls/ModelSelector'
|
||||
import type { AttachmentRef } from '../../types/chat'
|
||||
@ -24,6 +25,8 @@ import { FileSearchMenu, type FileSearchMenuHandle } from './FileSearchMenu'
|
||||
import { LocalSlashCommandPanel, type LocalSlashCommandName } from './LocalSlashCommandPanel'
|
||||
import { ContextUsageIndicator } from './ContextUsageIndicator'
|
||||
import {
|
||||
appendAgentSlashCommands,
|
||||
buildAgentSlashCommands,
|
||||
getLocalizedFallbackCommands,
|
||||
filterSlashCommands,
|
||||
findSlashTrigger,
|
||||
@ -94,6 +97,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
||||
const [atCursorPos, setAtCursorPos] = useState(-1)
|
||||
const [slashFilter, setSlashFilter] = useState('')
|
||||
const [slashSelectedIndex, setSlashSelectedIndex] = useState(0)
|
||||
const [agentSlashCommands, setAgentSlashCommands] = useState<ReturnType<typeof buildAgentSlashCommands>>([])
|
||||
const [launchWorkDir, setLaunchWorkDir] = useState('')
|
||||
const [launchBranch, setLaunchBranch] = useState<string | null>(null)
|
||||
const [launchUseWorktree, setLaunchUseWorktree] = useState(false)
|
||||
@ -335,6 +339,27 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
||||
setFileSearchOpen(false)
|
||||
}, [isMemberSession, activeTabId])
|
||||
|
||||
useEffect(() => {
|
||||
if (isMemberSession) {
|
||||
setAgentSlashCommands([])
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
agentsApi.list(resolvedWorkDir)
|
||||
.then(({ activeAgents }) => {
|
||||
if (cancelled) return
|
||||
setAgentSlashCommands(buildAgentSlashCommands(activeAgents))
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setAgentSlashCommands([])
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [isMemberSession, resolvedWorkDir])
|
||||
|
||||
useEffect(() => {
|
||||
if (!showLaunchControls) return
|
||||
const nextWorkDir = activeSession?.workDir || gitInfo?.workDir || ''
|
||||
@ -415,8 +440,11 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
||||
}, [fileSearchOpen])
|
||||
|
||||
const allSlashCommands = useMemo(
|
||||
() => mergeSlashCommands(slashCommands, getLocalizedFallbackCommands(t)),
|
||||
[slashCommands, t],
|
||||
() => appendAgentSlashCommands(
|
||||
mergeSlashCommands(slashCommands, getLocalizedFallbackCommands(t)),
|
||||
agentSlashCommands,
|
||||
),
|
||||
[agentSlashCommands, slashCommands, t],
|
||||
)
|
||||
|
||||
const filteredCommands = useMemo(() => {
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
appendAgentSlashCommands,
|
||||
buildAgentSlashCommands,
|
||||
filterSlashCommands,
|
||||
findSlashToken,
|
||||
getLocalizedFallbackCommands,
|
||||
@ -110,6 +112,35 @@ describe('composerUtils', () => {
|
||||
expect(mergeSlashCommands([]).map((command) => command.name)).not.toContain('goal --tokens')
|
||||
})
|
||||
|
||||
it('builds agent slash entries under the /agent namespace', () => {
|
||||
expect(
|
||||
buildAgentSlashCommands([
|
||||
{
|
||||
agentType: 'debugger',
|
||||
description: 'Debug failures',
|
||||
modelDisplay: 'OPUS',
|
||||
source: 'userSettings',
|
||||
},
|
||||
]),
|
||||
).toEqual([
|
||||
{
|
||||
name: 'agent debugger',
|
||||
description: 'Debug failures (OPUS - userSettings)',
|
||||
argumentHint: '<prompt>',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('appends agent entries after normal slash commands without replacing them', () => {
|
||||
const base = mergeSlashCommands([{ name: 'agent', description: 'CLI /agent' }])
|
||||
const withAgents = appendAgentSlashCommands(base, [
|
||||
{ name: 'agent debugger', description: 'Debug failures', argumentHint: '<prompt>' },
|
||||
])
|
||||
|
||||
expect(withAgents.map((command) => command.name).slice(0, 2)).toEqual(['agent', 'mcp'])
|
||||
expect(withAgents.map((command) => command.name)).toContain('agent debugger')
|
||||
})
|
||||
|
||||
it('does not replace /goal arguments as slash command fragments', () => {
|
||||
expect(replaceSlashCommand('/goal sta', 9, 'goal status')).toBeNull()
|
||||
})
|
||||
|
||||
@ -3,6 +3,7 @@ import type { TranslationKey } from '../../i18n'
|
||||
|
||||
/** Map from slash command name to its i18n description key */
|
||||
const SLASH_CMD_DESCRIPTION_KEYS: Record<string, TranslationKey> = {
|
||||
agent: 'slashCmd.agent.description',
|
||||
mcp: 'slashCmd.mcp.description',
|
||||
skills: 'slashCmd.skills.description',
|
||||
help: 'slashCmd.help.description',
|
||||
@ -53,6 +54,7 @@ export const SLASH_COMMAND_ALIASES = [
|
||||
|
||||
/** Static fallback with English descriptions (for non-React contexts) */
|
||||
export const FALLBACK_SLASH_COMMANDS: SlashCommandOption[] = [
|
||||
{ name: 'agent', description: 'Run a prompt with a selected Agent', argumentHint: '<agent> <prompt>' },
|
||||
{ name: 'mcp', description: 'Open available MCP tools for the current chat context' },
|
||||
{ name: 'skills', description: 'Browse user-invocable skills for the current chat context' },
|
||||
{ name: 'help', description: 'Show available desktop and agent commands' },
|
||||
@ -115,6 +117,51 @@ export type SlashCommandOption = {
|
||||
argumentHint?: string
|
||||
}
|
||||
|
||||
export type AgentSlashCommandSource = {
|
||||
agentType: string
|
||||
description?: string
|
||||
modelDisplay?: string
|
||||
source?: string
|
||||
}
|
||||
|
||||
export function buildAgentSlashCommands(
|
||||
agents: ReadonlyArray<AgentSlashCommandSource>,
|
||||
): SlashCommandOption[] {
|
||||
const seen = new Set<string>()
|
||||
const commands: SlashCommandOption[] = []
|
||||
|
||||
for (const agent of agents) {
|
||||
const agentType = agent.agentType.trim()
|
||||
if (!agentType || seen.has(agentType)) continue
|
||||
seen.add(agentType)
|
||||
|
||||
const details = [agent.modelDisplay, agent.source].filter(Boolean).join(' - ')
|
||||
const description = [
|
||||
agent.description?.trim() || `Run with the ${agentType} Agent`,
|
||||
details ? `(${details})` : '',
|
||||
].filter(Boolean).join(' ')
|
||||
|
||||
commands.push({
|
||||
name: `agent ${agentType}`,
|
||||
description,
|
||||
argumentHint: '<prompt>',
|
||||
})
|
||||
}
|
||||
|
||||
return commands
|
||||
}
|
||||
|
||||
export function appendAgentSlashCommands(
|
||||
commands: ReadonlyArray<SlashCommandOption>,
|
||||
agentCommands: ReadonlyArray<SlashCommandOption>,
|
||||
): SlashCommandOption[] {
|
||||
const names = new Set(commands.map((command) => command.name))
|
||||
return [
|
||||
...commands,
|
||||
...agentCommands.filter((command) => !names.has(command.name)),
|
||||
]
|
||||
}
|
||||
|
||||
export type SlashUiAction =
|
||||
| {
|
||||
type: 'panel'
|
||||
|
||||
@ -1660,6 +1660,7 @@ export const en = {
|
||||
'tabs.closeConfirmMessage': 'This session is still running. What would you like to do?',
|
||||
'tabs.closeConfirmKeep': 'Keep Running',
|
||||
// ─── Slash Command Descriptions ──────────────────────────────────────
|
||||
'slashCmd.agent.description': 'Run a prompt with a selected Agent',
|
||||
'slashCmd.mcp.description': 'Open available MCP tools for the current chat context',
|
||||
'slashCmd.skills.description': 'Browse user-invocable skills for the current chat context',
|
||||
'slashCmd.help.description': 'Show available desktop and agent commands',
|
||||
|
||||
@ -1664,6 +1664,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'tabs.closeConfirmStop': '停止并关闭',
|
||||
'tabs.closeAllConfirmTitle': '多个会话运行中',
|
||||
// ─── Slash Command Descriptions ──────────────────────────────────────
|
||||
'slashCmd.agent.description': '使用指定 Agent 执行提示',
|
||||
'slashCmd.mcp.description': '打开当前聊天上下文中可用的 MCP 工具',
|
||||
'slashCmd.skills.description': '浏览当前上下文中可直接调用的技能',
|
||||
'slashCmd.help.description': '查看可用的桌面端与 Agent 命令',
|
||||
|
||||
@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({
|
||||
getMessages: vi.fn(),
|
||||
getSlashCommands: vi.fn(),
|
||||
listSkills: vi.fn(),
|
||||
listAgents: vi.fn(),
|
||||
search: vi.fn(),
|
||||
browse: vi.fn(),
|
||||
getTasksForList: vi.fn(),
|
||||
@ -41,6 +42,12 @@ vi.mock('../api/skills', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../api/agents', () => ({
|
||||
agentsApi: {
|
||||
list: mocks.listAgents,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../api/filesystem', () => ({
|
||||
filesystemApi: {
|
||||
search: mocks.search,
|
||||
@ -200,6 +207,7 @@ describe('EmptySession', () => {
|
||||
mocks.getMessages.mockResolvedValue({ messages: [] })
|
||||
mocks.getSlashCommands.mockResolvedValue({ commands: [] })
|
||||
mocks.listSkills.mockResolvedValue({ skills: [] })
|
||||
mocks.listAgents.mockResolvedValue({ activeAgents: [], allAgents: [] })
|
||||
mocks.search.mockResolvedValue({
|
||||
currentPath: '/workspace/project',
|
||||
parentPath: null,
|
||||
@ -312,6 +320,37 @@ describe('EmptySession', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('offers active agents as slash entries that insert /agent with the selected type', async () => {
|
||||
mocks.listAgents.mockResolvedValue({
|
||||
activeAgents: [
|
||||
{
|
||||
agentType: 'debugger',
|
||||
description: 'Debug failures',
|
||||
modelDisplay: 'OPUS',
|
||||
source: 'userSettings',
|
||||
isActive: true,
|
||||
},
|
||||
],
|
||||
allAgents: [],
|
||||
})
|
||||
|
||||
render(<EmptySession />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.listAgents).toHaveBeenCalledWith(undefined)
|
||||
})
|
||||
|
||||
const input = screen.getByRole('textbox') as HTMLTextAreaElement
|
||||
fireEvent.change(input, {
|
||||
target: { value: '/debug', selectionStart: 6 },
|
||||
})
|
||||
|
||||
const agentOption = await screen.findByText('/agent debugger')
|
||||
fireEvent.click(agentOption)
|
||||
|
||||
expect(input).toHaveValue('/agent debugger ')
|
||||
})
|
||||
|
||||
it('integrates repository launch controls into the desktop composer panel', async () => {
|
||||
render(<EmptySession />)
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ApiError } from '../api/client'
|
||||
import { agentsApi } from '../api/agents'
|
||||
import { skillsApi } from '../api/skills'
|
||||
import { useTranslation } from '../i18n'
|
||||
import { useSessionStore } from '../stores/sessionStore'
|
||||
@ -27,6 +28,8 @@ import {
|
||||
import { useComposerFileDrop } from '../components/chat/useComposerFileDrop'
|
||||
import { shouldSubmitOnEnter } from '../components/chat/sendShortcut'
|
||||
import {
|
||||
appendAgentSlashCommands,
|
||||
buildAgentSlashCommands,
|
||||
getLocalizedFallbackCommands,
|
||||
filterSlashCommands,
|
||||
findSlashToken,
|
||||
@ -97,6 +100,7 @@ export function EmptySession() {
|
||||
const [slashFilter, setSlashFilter] = useState('')
|
||||
const [slashSelectedIndex, setSlashSelectedIndex] = useState(0)
|
||||
const [slashCommands, setSlashCommands] = useState<SlashCommandOption[]>([])
|
||||
const [agentSlashCommands, setAgentSlashCommands] = useState<SlashCommandOption[]>([])
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
@ -188,7 +192,9 @@ export function EmptySession() {
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
skillsApi.list(workDir || undefined)
|
||||
const cwd = workDir || undefined
|
||||
|
||||
skillsApi.list(cwd)
|
||||
.then(({ skills }) => {
|
||||
if (cancelled) return
|
||||
setSlashCommands(
|
||||
@ -211,9 +217,32 @@ export function EmptySession() {
|
||||
}
|
||||
}, [workDir, lastPluginReloadSummary])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const cwd = workDir || undefined
|
||||
|
||||
agentsApi.list(cwd)
|
||||
.then(({ activeAgents }) => {
|
||||
if (cancelled) return
|
||||
setAgentSlashCommands(buildAgentSlashCommands(activeAgents))
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setAgentSlashCommands([])
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [workDir, lastPluginReloadSummary])
|
||||
|
||||
const allSlashCommands = useMemo(
|
||||
() => mergeSlashCommands(slashCommands, getLocalizedFallbackCommands(t)),
|
||||
[slashCommands, t],
|
||||
() => appendAgentSlashCommands(
|
||||
mergeSlashCommands(slashCommands, getLocalizedFallbackCommands(t)),
|
||||
agentSlashCommands,
|
||||
),
|
||||
[agentSlashCommands, slashCommands, t],
|
||||
)
|
||||
|
||||
const handleWorkDirChange = (newWorkDir: string) => {
|
||||
|
||||
@ -130,6 +130,7 @@ import privacySettings from './commands/privacy-settings/index.js'
|
||||
import hooks from './commands/hooks/index.js'
|
||||
import files from './commands/files/index.js'
|
||||
import branch from './commands/branch/index.js'
|
||||
import agent from './commands/agent.js'
|
||||
import agents from './commands/agents/index.js'
|
||||
import plugin from './commands/plugin/index.js'
|
||||
import reloadPlugins from './commands/reload-plugins/index.js'
|
||||
@ -257,6 +258,7 @@ export const INTERNAL_ONLY_COMMANDS = [
|
||||
const COMMANDS = memoize((): Command[] => [
|
||||
addDir,
|
||||
advisor,
|
||||
agent,
|
||||
agents,
|
||||
branch,
|
||||
btw,
|
||||
|
||||
22
src/commands/agent.test.ts
Normal file
22
src/commands/agent.test.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import agentCommand, { parseAgentCommandArgs } from './agent.js'
|
||||
|
||||
describe('/agent command', () => {
|
||||
test('parses an agent type and prompt', () => {
|
||||
expect(parseAgentCommandArgs('debugger fix the failing tests')).toEqual({
|
||||
agentType: 'debugger',
|
||||
prompt: 'fix the failing tests',
|
||||
})
|
||||
})
|
||||
|
||||
test('requires both an agent type and prompt', () => {
|
||||
expect(parseAgentCommandArgs('')).toBeNull()
|
||||
expect(parseAgentCommandArgs('debugger')).toBeNull()
|
||||
})
|
||||
|
||||
test('passes only the prompt body to forked execution', async () => {
|
||||
await expect(agentCommand.getPromptForCommand('debugger inspect auth', {} as never)).resolves.toEqual([
|
||||
{ type: 'text', text: 'inspect auth' },
|
||||
])
|
||||
})
|
||||
})
|
||||
41
src/commands/agent.ts
Normal file
41
src/commands/agent.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.js'
|
||||
import type { Command } from '../commands.js'
|
||||
|
||||
export type ParsedAgentCommandArgs = {
|
||||
agentType: string
|
||||
prompt: string
|
||||
}
|
||||
|
||||
export function parseAgentCommandArgs(args: string): ParsedAgentCommandArgs | null {
|
||||
const trimmed = args.trim()
|
||||
if (!trimmed) return null
|
||||
|
||||
const match = /^(\S+)(?:\s+([\s\S]+))?$/.exec(trimmed)
|
||||
const agentType = match?.[1]?.trim()
|
||||
const prompt = match?.[2]?.trim()
|
||||
if (!agentType || !prompt) return null
|
||||
|
||||
return { agentType, prompt }
|
||||
}
|
||||
|
||||
const agentCommand: Command = {
|
||||
type: 'prompt',
|
||||
name: 'agent',
|
||||
description: 'Run a prompt with a selected Agent',
|
||||
argumentHint: '<agent> <prompt>',
|
||||
progressMessage: 'running agent',
|
||||
contentLength: 0,
|
||||
source: 'builtin',
|
||||
context: 'fork',
|
||||
async getPromptForCommand(args): Promise<ContentBlockParam[]> {
|
||||
const parsed = parseAgentCommandArgs(args)
|
||||
return [
|
||||
{
|
||||
type: 'text',
|
||||
text: parsed?.prompt ?? args.trim(),
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
export default agentCommand
|
||||
67
src/utils/forkedAgent.test.ts
Normal file
67
src/utils/forkedAgent.test.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import type { PromptCommand } from '../commands.js'
|
||||
import type { ToolUseContext } from '../Tool.js'
|
||||
import type { AgentDefinition } from '../tools/AgentTool/loadAgentsDir.js'
|
||||
import { prepareForkedCommandContext } from './forkedAgent.js'
|
||||
|
||||
const makeAgent = (agentType: string): AgentDefinition => ({
|
||||
agentType,
|
||||
whenToUse: `Use ${agentType}`,
|
||||
source: 'built-in',
|
||||
baseDir: 'built-in',
|
||||
getSystemPrompt: () => `${agentType} prompt`,
|
||||
})
|
||||
|
||||
function makeContext(activeAgents: AgentDefinition[]): ToolUseContext {
|
||||
return {
|
||||
getAppState: () => ({
|
||||
toolPermissionContext: {
|
||||
alwaysAllowRules: { command: [] },
|
||||
},
|
||||
}),
|
||||
options: {
|
||||
agentDefinitions: { activeAgents },
|
||||
},
|
||||
} as unknown as ToolUseContext
|
||||
}
|
||||
|
||||
describe('prepareForkedCommandContext', () => {
|
||||
const command: PromptCommand = {
|
||||
type: 'prompt',
|
||||
progressMessage: 'running',
|
||||
contentLength: 0,
|
||||
source: 'builtin',
|
||||
getPromptForCommand: async (args) => [{ type: 'text', text: args }],
|
||||
}
|
||||
|
||||
test('uses explicit agent and prompt overrides for dynamic forked commands', async () => {
|
||||
const prepared = await prepareForkedCommandContext(
|
||||
command,
|
||||
'debugger fix tests',
|
||||
makeContext([makeAgent('general-purpose'), makeAgent('debugger')]),
|
||||
{
|
||||
agentType: 'debugger',
|
||||
promptArgs: 'fix tests',
|
||||
requireAgentType: true,
|
||||
},
|
||||
)
|
||||
|
||||
expect(prepared.baseAgent.agentType).toBe('debugger')
|
||||
expect(prepared.skillContent).toBe('fix tests')
|
||||
})
|
||||
|
||||
test('throws when a required explicit agent is unavailable', async () => {
|
||||
await expect(
|
||||
prepareForkedCommandContext(
|
||||
command,
|
||||
'debugger fix tests',
|
||||
makeContext([makeAgent('general-purpose')]),
|
||||
{
|
||||
agentType: 'debugger',
|
||||
promptArgs: 'fix tests',
|
||||
requireAgentType: true,
|
||||
},
|
||||
),
|
||||
).rejects.toThrow('Agent not available: debugger')
|
||||
})
|
||||
})
|
||||
@ -184,6 +184,15 @@ export type PreparedForkedContext = {
|
||||
promptMessages: Message[]
|
||||
}
|
||||
|
||||
export type ForkedCommandContextOptions = {
|
||||
/** Override which agent type executes this forked command. */
|
||||
agentType?: string
|
||||
/** When true, a missing explicit agent is an error instead of falling back. */
|
||||
requireAgentType?: boolean
|
||||
/** Override the args passed to getPromptForCommand. */
|
||||
promptArgs?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares the context for executing a forked command/skill.
|
||||
* This handles the common setup that both SkillTool and slash commands need.
|
||||
@ -192,9 +201,11 @@ export async function prepareForkedCommandContext(
|
||||
command: PromptCommand,
|
||||
args: string,
|
||||
context: ToolUseContext,
|
||||
options: ForkedCommandContextOptions = {},
|
||||
): Promise<PreparedForkedContext> {
|
||||
// Get skill content with $ARGUMENTS replaced
|
||||
const skillPrompt = await command.getPromptForCommand(args, context)
|
||||
const promptArgs = options.promptArgs ?? args
|
||||
const skillPrompt = await command.getPromptForCommand(promptArgs, context)
|
||||
const skillContent = skillPrompt
|
||||
.map(block => (block.type === 'text' ? block.text : ''))
|
||||
.join('\n')
|
||||
@ -208,11 +219,15 @@ export async function prepareForkedCommandContext(
|
||||
allowedTools,
|
||||
)
|
||||
|
||||
// Use command.agent if specified, otherwise 'general-purpose'
|
||||
const agentTypeName = command.agent ?? 'general-purpose'
|
||||
// Use an explicit override first, then command.agent, then general-purpose.
|
||||
const agentTypeName = options.agentType ?? command.agent ?? 'general-purpose'
|
||||
const agents = context.options.agentDefinitions.activeAgents
|
||||
const requestedAgent = agents.find(a => a.agentType === agentTypeName)
|
||||
if (options.requireAgentType && !requestedAgent) {
|
||||
throw new Error(`Agent not available: ${agentTypeName}`)
|
||||
}
|
||||
const baseAgent =
|
||||
agents.find(a => a.agentType === agentTypeName) ??
|
||||
requestedAgent ??
|
||||
agents.find(a => a.agentType === 'general-purpose') ??
|
||||
agents[0]
|
||||
|
||||
|
||||
101
src/utils/processUserInput/processSlashCommand.test.ts
Normal file
101
src/utils/processUserInput/processSlashCommand.test.ts
Normal file
@ -0,0 +1,101 @@
|
||||
import { beforeEach, describe, expect, mock, test } from 'bun:test'
|
||||
import agentCommand from '../../commands/agent.js'
|
||||
import type { ToolUseContext } from '../../Tool.js'
|
||||
import type { AgentDefinition } from '../../tools/AgentTool/loadAgentsDir.js'
|
||||
import { createAssistantMessage } from '../messages.js'
|
||||
|
||||
process.env.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY ?? 'test-key'
|
||||
|
||||
const runAgentMock = mock(() =>
|
||||
(async function* () {
|
||||
yield createAssistantMessage({ content: 'debugger result' })
|
||||
})(),
|
||||
)
|
||||
|
||||
mock.module('../../tools/AgentTool/runAgent.js', () => ({
|
||||
runAgent: runAgentMock,
|
||||
}))
|
||||
|
||||
const { processSlashCommand } = await import('./processSlashCommand.js')
|
||||
|
||||
const makeAgent = (agentType: string): AgentDefinition => ({
|
||||
agentType,
|
||||
whenToUse: `Use ${agentType}`,
|
||||
source: 'built-in',
|
||||
baseDir: 'built-in',
|
||||
getSystemPrompt: () => `${agentType} prompt`,
|
||||
})
|
||||
|
||||
function makeContext(activeAgents: AgentDefinition[]): ToolUseContext {
|
||||
return {
|
||||
abortController: new AbortController(),
|
||||
messages: [],
|
||||
getAppState: () => ({
|
||||
kairosEnabled: false,
|
||||
mcp: { clients: [] },
|
||||
toolPermissionContext: {
|
||||
alwaysAllowRules: { command: [] },
|
||||
},
|
||||
}),
|
||||
setResponseLength: () => {},
|
||||
options: {
|
||||
commands: [agentCommand],
|
||||
tools: [],
|
||||
agentDefinitions: { activeAgents },
|
||||
},
|
||||
} as unknown as ToolUseContext
|
||||
}
|
||||
|
||||
describe('/agent slash command processing', () => {
|
||||
beforeEach(() => {
|
||||
runAgentMock.mockClear()
|
||||
})
|
||||
|
||||
test('runs the selected agent with only the prompt body', async () => {
|
||||
const result = await processSlashCommand(
|
||||
'/agent debugger fix failing tests',
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
makeContext([makeAgent('general-purpose'), makeAgent('debugger')]),
|
||||
() => {},
|
||||
)
|
||||
|
||||
expect(result.shouldQuery).toBe(false)
|
||||
expect(runAgentMock.mock.calls.length).toBe(1)
|
||||
|
||||
const params = runAgentMock.mock.calls[0]?.[0] as {
|
||||
agentDefinition: AgentDefinition
|
||||
promptMessages: Array<{ message: { content: string } }>
|
||||
}
|
||||
expect(params.agentDefinition.agentType).toBe('debugger')
|
||||
expect(params.promptMessages[0]?.message.content).toBe('fix failing tests')
|
||||
|
||||
const stdout = result.messages.find(
|
||||
message =>
|
||||
message.type === 'user' &&
|
||||
typeof message.message.content === 'string' &&
|
||||
message.message.content.includes('<local-command-stdout>'),
|
||||
)
|
||||
expect(stdout?.message.content).toContain('debugger result')
|
||||
})
|
||||
|
||||
test('shows usage when the agent prompt is missing', async () => {
|
||||
const result = await processSlashCommand(
|
||||
'/agent debugger',
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
makeContext([makeAgent('general-purpose'), makeAgent('debugger')]),
|
||||
() => {},
|
||||
)
|
||||
|
||||
expect(result.shouldQuery).toBe(false)
|
||||
expect(runAgentMock.mock.calls.length).toBe(0)
|
||||
expect(
|
||||
result.messages.some(
|
||||
message => message.message.content === 'Usage: /agent <agent> <prompt>',
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
File diff suppressed because one or more lines are too long
Loading…
x
Reference in New Issue
Block a user