Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fresh-agents-status.md
Original file line number Diff line number Diff line change
@@ -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.
53 changes: 53 additions & 0 deletions apps/kimi-code/src/tui/commands/agents.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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;
});
}
5 changes: 5 additions & 0 deletions apps/kimi-code/src/tui/commands/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions apps/kimi-code/src/tui/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
8 changes: 8 additions & 0 deletions apps/kimi-code/src/tui/commands/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
Expand Down
145 changes: 145 additions & 0 deletions apps/kimi-code/test/tui/commands/agents.test.ts
Original file line number Diff line number Diff line change
@@ -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>): 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');
});
});
1 change: 1 addition & 0 deletions apps/kimi-code/test/tui/commands/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ describe('built-in slash command registry', () => {
expect(names).toEqual(
expect.arrayContaining([
'add-dir',
'agents',
'compact',
'btw',
'editor',
Expand Down
1 change: 1 addition & 0 deletions apps/kimi-web/src/i18n/locales/en/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <task> runs a task in swarm' },
agents: { desc: 'Show background subagent status' },
goal: { desc: 'Create/control a goal: /goal <objective>, /goal pause|resume|cancel' },
btw: { desc: 'Side chat: /btw <question> asks a forked side session' },
yolo: { desc: 'Auto-approve everything (yolo mode)' },
Expand Down
1 change: 1 addition & 0 deletions apps/kimi-web/src/i18n/locales/zh/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 模式)' },
Expand Down
1 change: 1 addition & 0 deletions apps/kimi-web/src/lib/slashCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not advertise /agents before handling it

In the web UI, commands listed here are treated as known by Composer.vue and emitted to App.vue's handleCommand, but that switch has no /agents case; the default branch treats it as a skill and calls client.activateSkill('agents'). So selecting or typing /agents in the web slash menu will surface a skill-not-found warning instead of showing agent status. Add a web handler for /agents or keep it out of the web command list until supported.

Useful? React with 👍 / 👎.

{ name: '/goal', desc: 'commands.goal.desc', acceptsInput: true },
{ name: '/btw', desc: 'commands.btw.desc', acceptsInput: true },
{ name: '/auto', desc: 'commands.auto.desc' },
Expand Down
1 change: 1 addition & 0 deletions docs/en/reference/slash-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 [<text>]` | `/rename` | Without arguments, display the current session title; with an argument, set a new title (max 200 characters) | Yes |
| `/compact [<instruction>]` | — | Compact the current conversation context to free up token usage; an optional custom instruction can hint to the model what to preserve | No |
Expand Down
1 change: 1 addition & 0 deletions docs/zh/reference/slash-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
| `/new` | `/clear` | 开启全新会话,丢弃当前上下文 | 否 |
| `/sessions` | `/resume` | 浏览历史会话并切换/恢复 | 否 |
| `/tasks` | `/task` | 浏览后台任务列表 | 是 |
| `/agents [status]` | `/agent` | 查看后台子 Agent 状态,包括 Agent ID、子 Agent 类型、结束状态,以及失败 Agent 的恢复提示 | 是 |
| `/fork` | — | 基于当前会话 fork 一份新会话,保留完整对话历史 | 否 |
| `/title [<text>]` | `/rename` | 不带参数时显示当前会话标题;带参数时设置为新标题(最长 200 字符) | 是 |
| `/compact [<instruction>]` | — | 压缩当前对话上下文,释放 token 占用;可附带自定义指令,提示模型压缩时保留哪些信息 | 否 |
Expand Down