From 0abde8662a531293fc8faa7cf9089c43ad8d6d76 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 9 Jun 2026 19:21:46 +0800 Subject: [PATCH] fix(tui): clarify grouped subagent progress (#587) --- .changeset/active-agent-groups.md | 5 + .../tui/components/messages/agent-group.ts | 173 +++++++++++++---- .../src/tui/components/messages/tool-call.ts | 3 + .../components/messages/agent-group.test.ts | 176 ++++++++++++++++++ .../kimi-code/test/tui/message-replay.test.ts | 4 + .../kimi-code/test/tui/terminal-theme.test.ts | 2 - docs/en/reference/tools.md | 2 +- docs/zh/reference/tools.md | 2 +- 8 files changed, 326 insertions(+), 41 deletions(-) create mode 100644 .changeset/active-agent-groups.md create mode 100644 apps/kimi-code/test/tui/components/messages/agent-group.test.ts diff --git a/.changeset/active-agent-groups.md b/.changeset/active-agent-groups.md new file mode 100644 index 000000000..25c3e4d47 --- /dev/null +++ b/.changeset/active-agent-groups.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Clarify grouped subagent progress with active status breakdowns and elapsed time. diff --git a/apps/kimi-code/src/tui/components/messages/agent-group.ts b/apps/kimi-code/src/tui/components/messages/agent-group.ts index 47decf64e..ec8d32d2f 100644 --- a/apps/kimi-code/src/tui/components/messages/agent-group.ts +++ b/apps/kimi-code/src/tui/components/messages/agent-group.ts @@ -30,6 +30,16 @@ interface AgentEntry { readonly tc: ToolCallComponent; } +interface PhaseCounts { + readonly done: number; + readonly failed: number; + readonly backgrounded: number; + readonly running: number; + readonly waiting: number; + readonly starting: number; + readonly terminal: number; +} + export class AgentGroupComponent extends Container { private readonly entries: AgentEntry[] = []; private readonly headerText: Text; @@ -134,13 +144,12 @@ export class AgentGroupComponent extends Container { private buildHeader(snapshots: readonly ToolCallSubagentSnapshot[]): string { const total = snapshots.length; - const done = snapshots.filter((s) => s.phase === 'done').length; - const failed = snapshots.filter((s) => s.phase === 'failed').length; - const finished = done + failed; - const allDone = finished === total; + const counts = countPhases(snapshots); + const allDone = counts.terminal === total; const bullet = allDone ? currentTheme.fg('success', STATUS_BULLET) : currentTheme.fg('text', STATUS_BULLET); + const elapsedSeconds = maxElapsedSeconds(snapshots); if (allDone) { const types = new Set(snapshots.map((s) => s.agentName).filter((n) => n !== undefined)); @@ -150,21 +159,16 @@ export class AgentGroupComponent extends Container { : `${String(total)} agents finished`; const totalTools = snapshots.reduce((acc, s) => acc + s.toolCount, 0); const totalTokens = snapshots.reduce((acc, s) => acc + s.tokens, 0); - const tail = formatHeaderTail(totalTools, totalTokens); + const tail = formatHeaderTail({ toolCount: totalTools, tokens: totalTokens, elapsedSeconds }); return `${bullet}${currentTheme.boldFg('primary', headerLabel)}${tail}`; } - let headerText = `Running ${String(total)} agents`; - // Mixed status gets a breakdown so the current state is clear. - if (finished > 0) { - const running = total - finished; - const parts: string[] = []; - if (done > 0) parts.push(`${String(done)} done`); - if (failed > 0) parts.push(`${String(failed)} failed`); - if (running > 0) parts.push(`${String(running)} running`); - headerText = `Running ${String(total)} agents (${parts.join(', ')})`; - } - return `${bullet}${currentTheme.boldFg('primary', headerText)}`; + const parts = formatBreakdownParts(counts); + const headerText = parts.length > 0 + ? `Running ${String(total)} agents (${parts.join(', ')})` + : `Running ${String(total)} agents`; + const tail = formatHeaderTail({ toolCount: 0, tokens: 0, elapsedSeconds }); + return `${bullet}${currentTheme.boldFg('primary', headerText)}${tail}`; } private appendLines(snap: ToolCallSubagentSnapshot, isLast: boolean): void { @@ -195,8 +199,7 @@ export class AgentGroupComponent extends Container { return; } // Running or not-yet-started agents show latest activity, with a fallback. - const fallback = snap.phase === 'queued' ? 'Queued…' : 'Initializing…'; - const activity = snap.latestActivity ?? fallback; + const activity = snap.latestActivity ?? fallbackActivityForPhase(snap.phase); this.bodyContainer.addChild(new Text(` ${branch2} ${dim(activity)}`, 0, 0)); } @@ -222,33 +225,129 @@ export class AgentGroupComponent extends Container { } } +function countPhases(snapshots: readonly ToolCallSubagentSnapshot[]): PhaseCounts { + let done = 0; + let failed = 0; + let backgrounded = 0; + let running = 0; + let waiting = 0; + let starting = 0; + + for (const snap of snapshots) { + switch (snap.phase) { + case 'done': + done += 1; + break; + case 'failed': + failed += 1; + break; + case 'backgrounded': + backgrounded += 1; + break; + case 'queued': + waiting += 1; + break; + case 'running': + running += 1; + break; + case 'spawning': + case undefined: + starting += 1; + break; + } + } + + return { + done, + failed, + backgrounded, + running, + waiting, + starting, + terminal: done + failed + backgrounded, + }; +} + +function formatBreakdownParts(counts: PhaseCounts): string[] { + const parts: string[] = []; + if (counts.done > 0) parts.push(`${String(counts.done)} done`); + if (counts.failed > 0) parts.push(`${String(counts.failed)} failed`); + if (counts.backgrounded > 0) parts.push(`${String(counts.backgrounded)} backgrounded`); + if (counts.running > 0) parts.push(`${String(counts.running)} running`); + if (counts.waiting > 0) parts.push(`${String(counts.waiting)} waiting`); + if (counts.starting > 0) parts.push(`${String(counts.starting)} starting`); + return parts; +} + function formatStats(snap: ToolCallSubagentSnapshot): string { - const dim = (text: string) => currentTheme.dim(text); - const tools = ` · ${String(snap.toolCount)} tool${snap.toolCount === 1 ? '' : 's'}`; - const tokens = snap.tokens > 0 ? ` · ${formatTokens(snap.tokens)}` : ''; - return dim(`${tools}${tokens}`); + const parts = [`${String(snap.toolCount)} tool${snap.toolCount === 1 ? '' : 's'}`]; + if (snap.elapsedSeconds !== undefined) parts.push(formatElapsed(snap.elapsedSeconds)); + if (snap.tokens > 0) parts.push(formatTokens(snap.tokens)); + return currentTheme.dim(` · ${parts.join(' · ')}`); } function formatLineTail(snap: ToolCallSubagentSnapshot): string { - const dim = (text: string) => currentTheme.dim(text); - if (snap.phase === 'done') { - return dim(' · ') + currentTheme.fg('success', '✓ Completed'); + const separator = currentTheme.dim(' · '); + switch (snap.phase) { + case 'done': + return separator + currentTheme.fg('success', '✓ Completed'); + case 'failed': + return separator + currentTheme.fg('error', '✗ Failed'); + case 'backgrounded': + return separator + currentTheme.dim('◐ backgrounded'); + case 'queued': + return separator + currentTheme.fg('primary', 'Waiting'); + case 'running': + return separator + currentTheme.fg('primary', 'Running'); + case 'spawning': + case undefined: + return separator + currentTheme.fg('primary', 'Starting'); } - if (snap.phase === 'failed') { - return dim(' · ') + currentTheme.fg('error', '✗ Failed'); - } - if (snap.phase === 'backgrounded') { - return dim(' · ◐ backgrounded'); - } - return ''; } -function formatHeaderTail(toolCount: number, tokens: number): string { - const dim = (text: string) => currentTheme.dim(text); +function fallbackActivityForPhase(phase: ToolCallSubagentSnapshot['phase']): string { + switch (phase) { + case 'queued': + return 'Waiting to start…'; + case 'running': + return 'Still working…'; + case 'spawning': + case undefined: + return 'Starting…'; + case 'done': + case 'failed': + case 'backgrounded': + return ''; + } +} + +function formatHeaderTail(args: { + readonly toolCount: number; + readonly tokens: number; + readonly elapsedSeconds: number | undefined; +}): string { const parts: string[] = []; - if (toolCount > 0) parts.push(`${String(toolCount)} tool${toolCount === 1 ? '' : 's'}`); - if (tokens > 0) parts.push(formatTokens(tokens)); - return parts.length > 0 ? dim(` · ${parts.join(' · ')}`) : ''; + if (args.toolCount > 0) parts.push(`${String(args.toolCount)} tool${args.toolCount === 1 ? '' : 's'}`); + if (args.tokens > 0) parts.push(formatTokens(args.tokens)); + if (args.elapsedSeconds !== undefined) parts.push(formatElapsed(args.elapsedSeconds)); + return parts.length > 0 ? currentTheme.dim(` · ${parts.join(' · ')}`) : ''; +} + +function maxElapsedSeconds(snapshots: readonly ToolCallSubagentSnapshot[]): number | undefined { + let max: number | undefined; + for (const snap of snapshots) { + const elapsed = snap.elapsedSeconds; + if (elapsed === undefined) continue; + max = max === undefined ? elapsed : Math.max(max, elapsed); + } + return max; +} + +function formatElapsed(seconds: number): string { + if (seconds < 60) return `${String(seconds)}s`; + const minutes = Math.floor(seconds / 60); + const remainder = seconds % 60; + return `${String(minutes)}m ${String(remainder)}s`; } function formatTokens(n: number): string { diff --git a/apps/kimi-code/src/tui/components/messages/tool-call.ts b/apps/kimi-code/src/tui/components/messages/tool-call.ts index 3c6bfe080..f9f5db738 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-call.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-call.ts @@ -87,6 +87,7 @@ export interface ToolCallSubagentSnapshot { readonly agentName: string | undefined; readonly phase: SubagentPhase | undefined; readonly toolCount: number; + readonly elapsedSeconds: number | undefined; readonly tokens: number; readonly isError: boolean; readonly errorText: string | undefined; @@ -777,6 +778,7 @@ export class ToolCallComponent extends Container { agentName: this.subagentAgentName, phase: derivedPhase, toolCount: finished, + elapsedSeconds: this.getSubagentElapsedSeconds(), tokens, isError: derivedPhase === 'failed', errorText, @@ -899,6 +901,7 @@ export class ToolCallComponent extends Container { } this.headerText.setText(this.buildHeader()); this.invalidate(); + this.notifySnapshotChange(); this.ui?.requestRender(); }, SUBAGENT_ELAPSED_INTERVAL_MS); } diff --git a/apps/kimi-code/test/tui/components/messages/agent-group.test.ts b/apps/kimi-code/test/tui/components/messages/agent-group.test.ts new file mode 100644 index 000000000..5018275cc --- /dev/null +++ b/apps/kimi-code/test/tui/components/messages/agent-group.test.ts @@ -0,0 +1,176 @@ +import type { TUI } from '@earendil-works/pi-tui'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { AgentGroupComponent } from '#/tui/components/messages/agent-group'; +import { ToolCallComponent } from '#/tui/components/messages/tool-call'; + +const ESC = String.fromCodePoint(0x1b); +const BEL = String.fromCodePoint(0x07); + +function strip(text: string): string { + return text + .replaceAll(/\u001B\[[0-9;]*m/g, '') + .replaceAll(new RegExp(`${ESC}\\]8;;[^${BEL}]*${BEL}`, 'g'), ''); +} + +function stubTui(): TUI { + return { + terminal: { rows: 40 }, + requestRender: vi.fn(), + } as unknown as TUI; +} + +function renderText(component: AgentGroupComponent, width = 120): string { + return strip(component.render(width).join('\n')); +} + +function createAgent( + id: string, + description: string, + agentName: string, + ui: TUI, +): ToolCallComponent { + const component = new ToolCallComponent( + { + id, + name: 'Agent', + args: { description }, + }, + undefined, + ui, + ); + component.onSubagentSpawned({ + agentId: `sub_${id}`, + agentName, + runInBackground: false, + }); + return component; +} + +function startAgent(component: ToolCallComponent, id: string, agentName: string): void { + component.onSubagentStarted({ + agentId: `sub_${id}`, + agentName, + runInBackground: false, + }); +} + +describe('AgentGroupComponent', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('shows explicit active breakdown, row state, and waiting fallback', () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const ui = stubTui(); + const group = new AgentGroupComponent(ui); + const running = createAgent('call_agent_1', 'inspect project', 'explore', ui); + const waiting = createAgent('call_agent_2', 'write tests', 'coder', ui); + + startAgent(running, 'call_agent_1', 'explore'); + running.appendSubToolCall({ + id: 'sub_call_agent_1:read', + name: 'Read', + args: { path: 'src/a.ts' }, + }); + + group.attach('call_agent_1', running); + group.attach('call_agent_2', waiting); + + const output = renderText(group); + expect(output).toContain('Running 2 agents (1 running, 1 waiting) · 0s'); + expect(output).toContain('explore · inspect project · 0 tools · 0s · Running'); + expect(output).toContain('Using Read (src/a.ts)'); + expect(output).toContain('coder · write tests · 0 tools · 0s · Waiting'); + expect(output).toContain('Waiting to start…'); + expect(output).not.toContain('Initializing…'); + + group.dispose(); + running.dispose(); + waiting.dispose(); + }); + + it('uses still-working fallback for running agents without recent activity', () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const ui = stubTui(); + const group = new AgentGroupComponent(ui); + const running = createAgent('call_agent_1', 'inspect project', 'explore', ui); + const waiting = createAgent('call_agent_2', 'write tests', 'coder', ui); + + startAgent(running, 'call_agent_1', 'explore'); + group.attach('call_agent_1', running); + group.attach('call_agent_2', waiting); + + const output = renderText(group); + expect(output).toContain('Still working…'); + expect(output).toContain('Waiting to start…'); + expect(output).not.toContain('Initializing…'); + + group.dispose(); + running.dispose(); + waiting.dispose(); + }); + + it('refreshes grouped elapsed time from child subagent timers', () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const ui = stubTui(); + const group = new AgentGroupComponent(ui); + const running = createAgent('call_agent_1', 'inspect project', 'explore', ui); + const waiting = createAgent('call_agent_2', 'write tests', 'coder', ui); + + startAgent(running, 'call_agent_1', 'explore'); + group.attach('call_agent_1', running); + group.attach('call_agent_2', waiting); + + expect(renderText(group)).toContain('Running 2 agents (1 running, 1 waiting) · 0s'); + vi.mocked(ui.requestRender).mockClear(); + + vi.advanceTimersByTime(1_200); + + expect(ui.requestRender).toHaveBeenCalled(); + expect(renderText(group)).toContain('Running 2 agents (1 running, 1 waiting) · 1s'); + expect(renderText(group)).toContain('explore · inspect project · 0 tools · 1s · Running'); + + group.dispose(); + running.dispose(); + waiting.dispose(); + }); + + it('keeps terminal rows explicit while mixed agents are still running', () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const ui = stubTui(); + const group = new AgentGroupComponent(ui); + const done = createAgent('call_agent_1', 'inspect project', 'explore', ui); + const running = createAgent('call_agent_2', 'write tests', 'coder', ui); + + startAgent(done, 'call_agent_1', 'explore'); + startAgent(running, 'call_agent_2', 'coder'); + group.attach('call_agent_1', done); + group.attach('call_agent_2', running); + + vi.setSystemTime(12_000); + done.onSubagentCompleted({ resultSummary: 'done' }); + + const mixed = renderText(group); + expect(mixed).toContain('Running 2 agents (1 done, 1 running) · 12s'); + expect(mixed).toContain('explore · inspect project · 0 tools · 12s · ✓ Completed'); + expect(mixed).toContain('coder · write tests · 0 tools · 12s · Running'); + + vi.setSystemTime(15_000); + running.onSubagentFailed({ error: 'review failed' }); + + const terminal = renderText(group); + expect(terminal).toContain('2 agents finished · 15s'); + expect(terminal).toContain('✗ Failed'); + expect(terminal).toContain('Error: review failed'); + expect(terminal).not.toContain('Still working…'); + + group.dispose(); + done.dispose(); + running.dispose(); + }); +}); diff --git a/apps/kimi-code/test/tui/message-replay.test.ts b/apps/kimi-code/test/tui/message-replay.test.ts index ef5b4b717..85ce19db9 100644 --- a/apps/kimi-code/test/tui/message-replay.test.ts +++ b/apps/kimi-code/test/tui/message-replay.test.ts @@ -573,6 +573,10 @@ describe('KimiTUI resume message replay', () => { expect(group).toBeInstanceOf(AgentGroupComponent); expect((group as AgentGroupComponent).size()).toBe(2); + const output = stripAnsi((group as AgentGroupComponent).render(120).join('\n')); + expect(output).toContain('2 agents finished'); + expect(output).not.toContain('Still working…'); + expect(output).not.toContain('Waiting to start…'); expect(driver.streamingUI.hasPendingAgentGroup()).toBe(false); expect(driver.streamingUI.getToolComponent('call_agent_1')).toBeUndefined(); expect(driver.streamingUI.getToolComponent('call_agent_2')).toBeUndefined(); diff --git a/apps/kimi-code/test/tui/terminal-theme.test.ts b/apps/kimi-code/test/tui/terminal-theme.test.ts index 1f1888223..3159f8025 100644 --- a/apps/kimi-code/test/tui/terminal-theme.test.ts +++ b/apps/kimi-code/test/tui/terminal-theme.test.ts @@ -173,5 +173,3 @@ describe('ColorPalette warning token', () => { expect(getBuiltInPalette('light')).toBe(lightColors); }); }); - - diff --git a/docs/en/reference/tools.md b/docs/en/reference/tools.md index c354bc384..0856ff19e 100644 --- a/docs/en/reference/tools.md +++ b/docs/en/reference/tools.md @@ -89,7 +89,7 @@ Collaboration tools handle inter-Agent coordination, user interaction, and Skill | `AskUserQuestion` | Auto-allow | Ask the user a question to gather structured input | | `Skill` | Auto-allow | Invoke a registered inline Skill | -**`Agent`** delegates a subtask to a sub-Agent. Required parameters: `prompt` (complete task description) and `description` (a 3–5 word short summary). Optional parameters: `subagent_type` (defaults to `coder`), `resume` (ID of an existing Agent to resume; mutually exclusive with `subagent_type`), and `run_in_background` (defaults to false). Agent tasks have a fixed 30-minute timeout. In foreground mode the parent Agent waits for the sub-Agent to complete before continuing; in background mode a task ID is returned immediately and the result is automatically delivered back to the main Agent via a synthetic User message when done. See [Agent & Sub-Agents](../customization/agents.md) for details. +**`Agent`** delegates a subtask to a sub-Agent. Required parameters: `prompt` (complete task description) and `description` (a 3–5 word short summary). Optional parameters: `subagent_type` (defaults to `coder`), `resume` (ID of an existing Agent to resume; mutually exclusive with `subagent_type`), and `run_in_background` (defaults to false). Agent tasks have a fixed 30-minute timeout. In foreground mode the parent Agent waits for the sub-Agent to complete before continuing; in background mode a task ID is returned immediately and the result is automatically delivered back to the main Agent via a synthetic User message when done. When several foreground `Agent` calls run in the same step, the TUI groups them and shows each subagent's running, waiting, completed, or failed status with elapsed time. See [Agent & Sub-Agents](../customization/agents.md) for details. **`AgentSwarm`** launches subagents from a shared `prompt_template` and an `items` array, resumes existing subagents through `resume_agent_ids`, or combines both in one call. The template must contain the `{{item}}` placeholder; each item replaces that placeholder and launches one new subagent. Pass `subagent_type` to choose the profile used by every spawned subagent in the swarm, or omit it to use `coder`. Without `resume_agent_ids`, the tool requires at least 2 items; with `resume_agent_ids`, it can resume one or more existing subagents. The tool supports up to 128 total subagents, waits for all subagents to finish, and returns an aggregated report. In the TUI, foreground swarms show a live `Agent swarm` progress panel above the input box. In `manual` permission mode, `AgentSwarm` calls outside active swarm mode request approval unless a permission rule allows them; while swarm mode is active, `AgentSwarm` itself is auto-approved. Permission rules match `AgentSwarm` by tool name only — argument patterns such as `AgentSwarm(swarm)` are not supported. diff --git a/docs/zh/reference/tools.md b/docs/zh/reference/tools.md index 0747a5e1a..151ee273c 100644 --- a/docs/zh/reference/tools.md +++ b/docs/zh/reference/tools.md @@ -89,7 +89,7 @@ Plan 模式是一种受约束的工作状态:进入后 `Write` 与 `Edit` 只 | `AskUserQuestion` | 自动放行 | 向用户提问以获取结构化输入 | | `Skill` | 自动放行 | 调用已注册的 inline Skill | -**`Agent`** 将子任务委托给子 Agent 执行。必填参数:`prompt`(完整任务描述)和 `description`(3–5 个词的简短说明)。可选参数:`subagent_type`(默认 `coder`)、`resume`(恢复已有 Agent 的 ID,与 `subagent_type` 互斥)和 `run_in_background`(默认 false)。Agent 任务使用固定 30 分钟超时。前台模式下父 Agent 等待子 Agent 完成再继续;后台模式立即返回任务 ID,完成时通过合成 User 消息自动回到主 Agent。子 Agent 体系细节见 [Agent 与子 Agent](../customization/agents.md)。 +**`Agent`** 将子任务委托给子 Agent 执行。必填参数:`prompt`(完整任务描述)和 `description`(3–5 个词的简短说明)。可选参数:`subagent_type`(默认 `coder`)、`resume`(恢复已有 Agent 的 ID,与 `subagent_type` 互斥)和 `run_in_background`(默认 false)。Agent 任务使用固定 30 分钟超时。前台模式下父 Agent 等待子 Agent 完成再继续;后台模式立即返回任务 ID,完成时通过合成 User 消息自动回到主 Agent。多个前台 `Agent` 调用在同一步运行时,TUI 会合并展示,并为每个子 Agent 显示运行、等待、完成或失败状态以及已耗时长。子 Agent 体系细节见 [Agent 与子 Agent](../customization/agents.md)。 **`AgentSwarm`** 可以从共享的 `prompt_template` 和 `items` 数组启动子 Agent,也可以通过 `resume_agent_ids` 恢复已有子 Agent,或在一次调用中同时使用两者。模板必须包含 `{{item}}` 占位符;每个 item 会替换该占位符,并启动一个新的子 Agent。传入 `subagent_type` 可以指定整个 swarm 中所有新启动的子 Agent 使用的 profile;省略时默认使用 `coder`。不传 `resume_agent_ids` 时,本工具要求至少 2 个 item;传入 `resume_agent_ids` 时,可以恢复 1 个或多个已有子 Agent。本工具最多支持 128 个子 Agent,会等待全部子 Agent 完成,并返回聚合报告。在 TUI 中,前台 swarm 会在输入框上方显示实时 `Agent swarm` 进度面板。在 `manual` 权限模式下,未处于 swarm mode 时调用 `AgentSwarm` 会触发审批,除非已有权限规则允许;swarm mode 已开启时,`AgentSwarm` 本身会自动放行。权限规则只能按工具名 `AgentSwarm` 匹配,不支持 `AgentSwarm(swarm)` 这类参数模式。