diff --git a/.changeset/fresh-agents-status.md b/.changeset/fresh-agents-status.md new file mode 100644 index 000000000..246d106b9 --- /dev/null +++ b/.changeset/fresh-agents-status.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add the /agents slash command to show background subagent status. Run /agents to list background subagents. diff --git a/apps/kimi-code/src/tui/commands/agents.ts b/apps/kimi-code/src/tui/commands/agents.ts new file mode 100644 index 000000000..d8d07835e --- /dev/null +++ b/apps/kimi-code/src/tui/commands/agents.ts @@ -0,0 +1,53 @@ +import type { AgentBackgroundTaskInfo, BackgroundTaskInfo } from '@moonshot-ai/kimi-code-sdk'; + +import { UsagePanelComponent } from '../components/messages/usage-panel'; +import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui'; +import { formatErrorMessage } from '../utils/event-payload'; +import type { SlashCommandHost } from './dispatch'; + +const AGENTS_USAGE = 'Usage: /agents [status]'; + +export async function handleAgentsCommand(host: SlashCommandHost, args: string): Promise { + const command = args.trim().toLowerCase(); + if (command.length > 0 && command !== 'status') { + host.showError(AGENTS_USAGE); + return; + } + + if (host.session === undefined) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + + let tasks: readonly BackgroundTaskInfo[]; + try { + tasks = await host.requireSession().listBackgroundTasks({ activeOnly: false }); + } catch (error) { + host.showError(`Failed to load subagents: ${formatErrorMessage(error)}`); + return; + } + + const agents = tasks.filter((task): task is AgentBackgroundTaskInfo => task.kind === 'agent'); + const title = agents.length > 0 ? ` Agents (${agents.length}) ` : ' Agents '; + host.state.transcriptContainer.addChild( + new UsagePanelComponent(() => buildAgentStatusReportLines(agents), 'primary', title), + ); + host.state.ui.requestRender(); +} + +export function buildAgentStatusReportLines(tasks: readonly AgentBackgroundTaskInfo[]): string[] { + if (tasks.length === 0) return ['No background subagents.']; + + return tasks.flatMap((task) => { + const agentId = task.agentId ?? task.taskId; + const subagentType = task.subagentType ?? 'agent'; + const lines = [`${task.status} ${subagentType} ${agentId} ${task.description}`]; + if (task.status === 'failed' && task.agentId !== undefined) { + lines.push(` Resume: ask Kimi to call Agent(resume="${task.agentId}", prompt="...")`); + } + if (task.stopReason !== undefined && task.stopReason.length > 0) { + lines.push(` Reason: ${task.stopReason}`); + } + return lines; + }); +} diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index 02226507e..2f9f62cbc 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -19,6 +19,7 @@ import type { TranscriptEntry, } from '../types'; import { formatErrorMessage } from '../utils/event-payload'; +import { handleAgentsCommand } from './agents'; import { handleLoginCommand, handleLogoutCommand } from './auth'; import { handleBtwCommand } from './btw'; import { @@ -59,6 +60,7 @@ import { handleWebCommand } from './web'; // --------------------------------------------------------------------------- export { handleLoginCommand, handleLogoutCommand } from './auth'; +export { handleAgentsCommand } from './agents'; export { handleBtwCommand } from './btw'; export { handleAddDirCommand } from './add-dir'; export { @@ -243,6 +245,9 @@ async function handleBuiltInSlashCommand( case 'tasks': void host.tasksBrowserController.show(); return; + case 'agents': + await handleAgentsCommand(host, args); + return; case 'mcp': void showMcpServers(host); return; diff --git a/apps/kimi-code/src/tui/commands/index.ts b/apps/kimi-code/src/tui/commands/index.ts index 57d4e5866..b0e530aa1 100644 --- a/apps/kimi-code/src/tui/commands/index.ts +++ b/apps/kimi-code/src/tui/commands/index.ts @@ -4,6 +4,7 @@ export * from './registry'; export * from './resolve'; export * from './skills'; export * from './types'; +export * from './agents'; export { dispatchInput, type SlashCommandHost } from './dispatch'; export { handleLoginCommand, handleLogoutCommand } from './auth'; diff --git a/apps/kimi-code/src/tui/commands/registry.ts b/apps/kimi-code/src/tui/commands/registry.ts index 966e5ceb7..ac0d6242c 100644 --- a/apps/kimi-code/src/tui/commands/registry.ts +++ b/apps/kimi-code/src/tui/commands/registry.ts @@ -224,6 +224,14 @@ export const BUILTIN_SLASH_COMMANDS = [ priority: 80, availability: 'always', }, + { + name: 'agents', + aliases: ['agent'], + description: 'Show background subagent status', + priority: 80, + availability: 'always', + argumentHint: '[status]', + }, { name: 'mcp', aliases: [], diff --git a/apps/kimi-code/test/tui/commands/agents.test.ts b/apps/kimi-code/test/tui/commands/agents.test.ts new file mode 100644 index 000000000..d4f6f734b --- /dev/null +++ b/apps/kimi-code/test/tui/commands/agents.test.ts @@ -0,0 +1,145 @@ +import type { AgentBackgroundTaskInfo, BackgroundTaskInfo } from '@moonshot-ai/kimi-code-sdk'; +import { describe, expect, it, vi } from 'vitest'; + +import { UsagePanelComponent } from '#/tui/components/messages/usage-panel'; +import { NO_ACTIVE_SESSION_MESSAGE } from '#/tui/constant/kimi-tui'; +import { buildAgentStatusReportLines, handleAgentsCommand } from '#/tui/commands/agents'; +import type { SlashCommandHost } from '#/tui/commands/dispatch'; + +function agentTask(overrides: Partial): AgentBackgroundTaskInfo { + return { + kind: 'agent', + taskId: 'agent_task_1', + description: 'Explore auth flow', + status: 'running', + detached: true, + startedAt: 1000, + endedAt: null, + agentId: 'agent_123', + subagentType: 'explore', + ...overrides, + }; +} + +function makeHost(tasks: readonly BackgroundTaskInfo[] | Error): SlashCommandHost { + const session = { + listBackgroundTasks: vi.fn(async () => { + if (tasks instanceof Error) throw tasks; + return tasks; + }), + }; + return { + session, + requireSession: () => session, + showError: vi.fn(), + state: { + transcriptContainer: { addChild: vi.fn() }, + ui: { requestRender: vi.fn() }, + }, + } as unknown as SlashCommandHost; +} + +describe('buildAgentStatusReportLines', () => { + it('shows an empty state when no subagents exist', () => { + expect(buildAgentStatusReportLines([])).toEqual(['No background subagents.']); + }); + + it('formats agent tasks with status, type, id, and description', () => { + expect( + buildAgentStatusReportLines([ + agentTask({ taskId: 'agent_task_1', agentId: 'agent_a', subagentType: 'explore' }), + agentTask({ + taskId: 'agent_task_2', + agentId: 'agent_b', + subagentType: 'coder', + status: 'completed', + endedAt: 2000, + description: 'Implement fix', + }), + ]), + ).toEqual([ + 'running explore agent_a Explore auth flow', + 'completed coder agent_b Implement fix', + ]); + }); + + it('adds an Agent resume hint for failed agents with ids', () => { + expect( + buildAgentStatusReportLines([ + agentTask({ status: 'failed', stopReason: 'Tool failed', agentId: 'agent_failed' }), + ]), + ).toEqual([ + 'failed explore agent_failed Explore auth flow', + ' Resume: ask Kimi to call Agent(resume="agent_failed", prompt="...")', + ' Reason: Tool failed', + ]); + }); +}); + +describe('handleAgentsCommand', () => { + it('renders only agent background tasks', async () => { + const host = makeHost([ + agentTask({}), + { + kind: 'process', + taskId: 'bash_1', + description: 'pnpm test', + status: 'running', + detached: true, + startedAt: 1000, + endedAt: null, + command: 'pnpm test', + pid: 12345, + exitCode: null, + }, + ]); + + await handleAgentsCommand(host, 'status'); + + expect(host.requireSession().listBackgroundTasks).toHaveBeenCalledWith({ activeOnly: false }); + expect(host.state.transcriptContainer.addChild).toHaveBeenCalledTimes(1); + const component = vi.mocked(host.state.transcriptContainer.addChild).mock.calls[0]?.[0]; + expect(component).toBeInstanceOf(UsagePanelComponent); + const rendered = component?.render(120).join('\n') ?? ''; + expect(rendered).toContain('agent_123'); + expect(rendered).toContain('Explore auth flow'); + expect(rendered).not.toContain('bash_1'); + expect(rendered).not.toContain('pnpm test'); + expect(host.state.ui.requestRender).toHaveBeenCalledTimes(1); + }); + + it('uses status as the default subcommand', async () => { + const host = makeHost([agentTask({})]); + + await handleAgentsCommand(host, ''); + + expect(host.requireSession().listBackgroundTasks).toHaveBeenCalledWith({ activeOnly: false }); + expect(host.state.transcriptContainer.addChild).toHaveBeenCalledTimes(1); + }); + + it('shows the inactive session error without loading tasks', async () => { + const host = makeHost([]); + host.session = undefined; + + await handleAgentsCommand(host, 'status'); + + expect(host.showError).toHaveBeenCalledWith(NO_ACTIVE_SESSION_MESSAGE); + expect(host.requireSession().listBackgroundTasks).not.toHaveBeenCalled(); + }); + + it('rejects unsupported arguments', async () => { + const host = makeHost([]); + + await handleAgentsCommand(host, 'cancel agent_123'); + + expect(host.showError).toHaveBeenCalledWith('Usage: /agents [status]'); + }); + + it('shows load errors', async () => { + const host = makeHost(new Error('boom')); + + await handleAgentsCommand(host, ''); + + expect(host.showError).toHaveBeenCalledWith('Failed to load subagents: boom'); + }); +}); diff --git a/apps/kimi-code/test/tui/commands/registry.test.ts b/apps/kimi-code/test/tui/commands/registry.test.ts index bc4c5894f..5e796198a 100644 --- a/apps/kimi-code/test/tui/commands/registry.test.ts +++ b/apps/kimi-code/test/tui/commands/registry.test.ts @@ -149,6 +149,7 @@ describe('built-in slash command registry', () => { expect(names).toEqual( expect.arrayContaining([ 'add-dir', + 'agents', 'compact', 'btw', 'editor', diff --git a/apps/kimi-web/src/i18n/locales/en/commands.ts b/apps/kimi-web/src/i18n/locales/en/commands.ts index 1027cb50c..6d94a0557 100644 --- a/apps/kimi-web/src/i18n/locales/en/commands.ts +++ b/apps/kimi-web/src/i18n/locales/en/commands.ts @@ -9,6 +9,7 @@ export default { permission: { desc: 'Switch approval mode (manual / auto / yolo)' }, plan: { desc: 'Toggle plan mode on/off' }, swarm: { desc: 'Toggle swarm mode; /swarm runs a task in swarm' }, + agents: { desc: 'Show background subagent status' }, goal: { desc: 'Create/control a goal: /goal , /goal pause|resume|cancel' }, btw: { desc: 'Side chat: /btw asks a forked side session' }, yolo: { desc: 'Auto-approve everything (yolo mode)' }, diff --git a/apps/kimi-web/src/i18n/locales/zh/commands.ts b/apps/kimi-web/src/i18n/locales/zh/commands.ts index 9f0b6e323..0658745d5 100644 --- a/apps/kimi-web/src/i18n/locales/zh/commands.ts +++ b/apps/kimi-web/src/i18n/locales/zh/commands.ts @@ -9,6 +9,7 @@ export default { permission: { desc: '切换审批模式 (manual/auto/yolo)' }, plan: { desc: '切换计划模式 开/关' }, swarm: { desc: '切换 swarm 模式;/swarm <任务> 直接在 swarm 下执行' }, + agents: { desc: '查看后台子 Agent 状态' }, goal: { desc: '创建/控制目标:/goal <目标>、/goal pause|resume|cancel' }, btw: { desc: '侧边聊天:/btw <问题> 向 fork 的侧边会话提问' }, yolo: { desc: '自动批准一切 (yolo 模式)' }, diff --git a/apps/kimi-web/src/lib/slashCommands.ts b/apps/kimi-web/src/lib/slashCommands.ts index b9fa53d52..c9bb771c4 100644 --- a/apps/kimi-web/src/lib/slashCommands.ts +++ b/apps/kimi-web/src/lib/slashCommands.ts @@ -32,6 +32,7 @@ export const SLASH_COMMANDS: SlashCommand[] = [ { name: '/permission', desc: 'commands.permission.desc' }, { name: '/plan', desc: 'commands.plan.desc' }, { name: '/swarm', desc: 'commands.swarm.desc', acceptsInput: true }, + { name: '/agents', desc: 'commands.agents.desc' }, { name: '/goal', desc: 'commands.goal.desc', acceptsInput: true }, { name: '/btw', desc: 'commands.btw.desc', acceptsInput: true }, { name: '/auto', desc: 'commands.auto.desc' }, diff --git a/docs/en/reference/slash-commands.md b/docs/en/reference/slash-commands.md index f74812c0e..3f2fe2c1e 100644 --- a/docs/en/reference/slash-commands.md +++ b/docs/en/reference/slash-commands.md @@ -29,6 +29,7 @@ Some commands are only available in the idle state. Executing these commands whi | `/new` | `/clear` | Start a fresh session, discarding the current context | No | | `/sessions` | `/resume` | Browse historical sessions and switch to / restore one | No | | `/tasks` | `/task` | Browse the background task list | Yes | +| `/agents [status]` | `/agent` | Show background subagent status, including agent IDs, subagent type, terminal state, and resume hints for failed agents | Yes | | `/fork` | — | Fork a new session from the current one, preserving the full conversation history | No | | `/title []` | `/rename` | Without arguments, display the current session title; with an argument, set a new title (max 200 characters) | Yes | | `/compact []` | — | Compact the current conversation context to free up token usage; an optional custom instruction can hint to the model what to preserve | No | diff --git a/docs/zh/reference/slash-commands.md b/docs/zh/reference/slash-commands.md index 218010835..85dc2e89d 100644 --- a/docs/zh/reference/slash-commands.md +++ b/docs/zh/reference/slash-commands.md @@ -29,6 +29,7 @@ | `/new` | `/clear` | 开启全新会话,丢弃当前上下文 | 否 | | `/sessions` | `/resume` | 浏览历史会话并切换/恢复 | 否 | | `/tasks` | `/task` | 浏览后台任务列表 | 是 | +| `/agents [status]` | `/agent` | 查看后台子 Agent 状态,包括 Agent ID、子 Agent 类型、结束状态,以及失败 Agent 的恢复提示 | 是 | | `/fork` | — | 基于当前会话 fork 一份新会话,保留完整对话历史 | 否 | | `/title []` | `/rename` | 不带参数时显示当前会话标题;带参数时设置为新标题(最长 200 字符) | 是 | | `/compact []` | — | 压缩当前对话上下文,释放 token 占用;可附带自定义指令,提示模型压缩时保留哪些信息 | 否 |