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:
程序员阿江(Relakkes) 2026-06-01 21:55:23 +08:00
parent 46ca911d1a
commit 4589ef67f3
16 changed files with 495 additions and 12 deletions

View File

@ -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()
})

View File

@ -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 ')
})
})

View File

@ -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(() => {

View File

@ -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()
})

View File

@ -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'

View File

@ -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',

View File

@ -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 命令',

View File

@ -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 />)

View File

@ -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) => {

View File

@ -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,

View 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
View 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

View 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')
})
})

View File

@ -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]

View 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