diff --git a/.changeset/web-subagent-full-progress.md b/.changeset/web-subagent-full-progress.md new file mode 100644 index 0000000000..72cb94470a --- /dev/null +++ b/.changeset/web-subagent-full-progress.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Show the full accumulated progress of a subagent in its detail panel, with concise tool-call summaries instead of raw JSON. diff --git a/apps/kimi-web/src/api/daemon/agentEventProjector.ts b/apps/kimi-web/src/api/daemon/agentEventProjector.ts index 31a01c1c6a..5a55282b4c 100644 --- a/apps/kimi-web/src/api/daemon/agentEventProjector.ts +++ b/apps/kimi-web/src/api/daemon/agentEventProjector.ts @@ -26,6 +26,7 @@ import type { AppTask, } from '../types'; import { i18n } from '../../i18n'; +import { toolLabel, toolSummary } from '../../lib/toolMeta'; import { toAppMessageContent } from './mappers'; import type { WireMessageContent } from './wire'; @@ -212,41 +213,54 @@ function patchSubagent( return next; } -function shortJson(value: unknown): string { - if (value === undefined || value === null) return ''; - try { - const text = typeof value === 'string' ? value : JSON.stringify(value); - return text.length > 120 ? `${text.slice(0, 117)}...` : text; - } catch { - return ''; - } -} - -function subagentProgressText(rawType: string, payload: Record): string | null { - if (rawType === 'turn.step.started') return 'Started a step'; +export function subagentProgressText(rawType: string, payload: Record): string | null { + // "Started a step" fires on every step and adds no information — the phase + // badge already shows the subagent is working, so skip it to cut the noise. + if (rawType === 'turn.step.started') return null; if (rawType === 'tool.use' || rawType === 'tool.call.started') { const name = stringField(payload, 'name') ?? stringField(payload, 'toolName') ?? 'tool'; - const args = shortJson(payload['args'] ?? payload['input']); - return args ? `Calling ${name}: ${args}` : `Calling ${name}`; + const label = toolLabel(cleanToolName(name)); + const summary = toolArgSummary(name, payload['args'] ?? payload['input']); + return summary ? `Calling ${label}: ${summary}` : `Calling ${label}`; } if (rawType === 'tool.progress') { const update = payload['update']; if (update && typeof update === 'object') { const text = stringField(update as Record, 'text'); - if (text) return text; + if (text) return capProgressText(text); const message = stringField(update as Record, 'message'); - if (message) return message; + if (message) return capProgressText(message); } const message = stringField(payload, 'message'); - if (message) return message; - } - if (rawType === 'tool.result') { - const name = stringField(payload, 'name') ?? stringField(payload, 'toolName') ?? stringField(payload, 'toolCallId') ?? 'tool'; - return `Finished ${name}`; + if (message) return capProgressText(message); } + // tool.result lines ("Finished X") add noise without much information — the + // next call or the final summary already implies completion — so skip them. + if (rawType === 'tool.result') return null; return null; } +/** Strip a trailing `_N` index that some subagents append to tool names in + * `tool.result` events (e.g. `Read_0` → `Read`) so the label resolves. */ +function cleanToolName(name: string): string { + return name.replace(/_\d+$/, ''); +} + +/** Cap a progress text chunk so a single huge tool output (e.g. a big command + * result) cannot dominate the panel. */ +const MAX_PROGRESS_TEXT = 2000; +function capProgressText(text: string): string { + return text.length > MAX_PROGRESS_TEXT ? `${text.slice(0, MAX_PROGRESS_TEXT)}…` : text; +} + +/** A concise, human-readable summary of a tool call's arguments for progress + * lines (e.g. a file path or shell command), instead of the full JSON blob. */ +function toolArgSummary(name: string, args: unknown): string { + if (args === undefined || args === null) return ''; + const arg = typeof args === 'string' ? args : JSON.stringify(args); + return toolSummary(name, arg); +} + function projectSubagentProgress( state: SessionState, sessionId: string, diff --git a/apps/kimi-web/src/api/daemon/eventReducer.ts b/apps/kimi-web/src/api/daemon/eventReducer.ts index f1f68cc143..23a94e002b 100644 --- a/apps/kimi-web/src/api/daemon/eventReducer.ts +++ b/apps/kimi-web/src/api/daemon/eventReducer.ts @@ -26,6 +26,11 @@ import { i18n } from '../../i18n'; const OPTIMISTIC_USER_MESSAGE_METADATA_KEY = 'kimiWeb.optimisticUserMessage'; +/** Tail cap for accumulated output of non-subagent (bash / background tool) + * tasks, whose stdout can be noisy and unbounded. Subagent progress is kept + * in full (small synthesized lines). */ +const MAX_BACKGROUND_OUTPUT_LINES = 40; + // --------------------------------------------------------------------------- // State // --------------------------------------------------------------------------- @@ -518,7 +523,9 @@ export function reduceAppEvent( next.tasksBySession[sid] = [...list, event.task]; } else { const patched = [...list]; - patched[idx] = event.task; + // The projected task does not carry reducer-owned accumulated progress; + // preserve it across the replacement so subagent output keeps growing. + patched[idx] = { ...event.task, outputLines: list[idx]!.outputLines }; next.tasksBySession[sid] = patched; } break; @@ -532,9 +539,13 @@ export function reduceAppEvent( if (t.id !== event.taskId) return t; const outputLines = t.outputLines ?? []; if (outputLines.at(-1) === event.outputChunk) return t; + const lines = [...outputLines, event.outputChunk]; return { ...t, - outputLines: [...outputLines, event.outputChunk].slice(-40), + // Keep subagent progress in full (small synthesized lines) so the + // panel shows the whole process; cap background bash/tool output, + // which can grow without bound. + outputLines: t.kind === 'subagent' ? lines : lines.slice(-MAX_BACKGROUND_OUTPUT_LINES), }; }); break; diff --git a/apps/kimi-web/src/components/chat/AgentDetailPanel.vue b/apps/kimi-web/src/components/chat/AgentDetailPanel.vue index 59ebc1f35e..be501b208b 100644 --- a/apps/kimi-web/src/components/chat/AgentDetailPanel.vue +++ b/apps/kimi-web/src/components/chat/AgentDetailPanel.vue @@ -23,6 +23,55 @@ const progressLines = computed(() => .filter((line) => line.length > 0), ); +interface ProgressGroup { + key: string; + /** The "Calling …" tool-call line, or '' for output with no preceding call. */ + call: string; + output: string[]; +} + +/** Group flat progress lines into tool-call groups: a "Calling …" line starts a + * group and subsequent non-call lines are its output. */ +function groupProgress(lines: string[]): ProgressGroup[] { + const groups: ProgressGroup[] = []; + let current: ProgressGroup | null = null; + let idx = 0; + for (const line of lines) { + if (line.startsWith('Calling ')) { + current = { key: `g${idx++}`, call: line, output: [] }; + groups.push(current); + } else if (current) { + current.output.push(line); + } else { + current = { key: `g${idx++}`, call: '', output: [line] }; + groups.push(current); + } + } + return groups; +} + +const progressGroups = computed(() => groupProgress(progressLines.value)); + +/** Group keys whose folded output is expanded. */ +const expandedGroups = ref>(new Set()); + +const OUTPUT_FOLD_THRESHOLD = 8; +const OUTPUT_HEAD = 5; +const OUTPUT_TAIL = 2; + +function isExpanded(key: string): boolean { + return expandedGroups.value.has(key); +} +function toggleGroup(key: string): void { + const next = new Set(expandedGroups.value); + if (next.has(key)) next.delete(key); + else next.add(key); + expandedGroups.value = next; +} +function foldCount(group: ProgressGroup): number { + return group.output.length - OUTPUT_HEAD - OUTPUT_TAIL; +} + function phaseLabel(phase: AgentMember['phase']): string { switch (phase) { case 'queued': return 'Queued'; @@ -66,10 +115,27 @@ watch( Task
{{ member.prompt }}
-
+
Progress
- {{ line }} +
+
+ + {{ group.call }} +
+
+ + +
+
@@ -189,14 +255,59 @@ watch( .ap-progress { display: flex; flex-direction: column; - gap: 3px; + gap: 6px; font-family: var(--mono); color: var(--text); min-width: 0; } -.ap-progress span { +.ap-group { + min-width: 0; +} +.ap-call { + display: flex; + align-items: baseline; + gap: 6px; + min-width: 0; + font-weight: 600; + color: var(--ink); + overflow-wrap: anywhere; + white-space: pre-wrap; +} +.ap-glyph { + flex: none; + color: var(--blue); + font-size: 0.85em; +} +.ap-output { + margin: 2px 0 0 16px; + padding-left: 8px; + color: var(--muted); + font-size: calc(var(--ui-font-size) - 1px); + line-height: 1.5; + border-left: 2px solid var(--line2, var(--line)); + min-width: 0; +} +.ap-out-line { min-width: 0; overflow-wrap: anywhere; white-space: pre-wrap; } +.ap-fold { + display: inline-block; + margin: 2px 0; + padding: 0; + background: none; + border: none; + color: var(--blue); + font-family: inherit; + font-size: inherit; + cursor: pointer; +} +.ap-fold:hover { + text-decoration: underline; +} +.ap-fold:focus-visible { + outline: 2px solid var(--blue); + outline-offset: 1px; +} diff --git a/apps/kimi-web/test/agent-event-projector.test.ts b/apps/kimi-web/test/agent-event-projector.test.ts new file mode 100644 index 0000000000..a01d6f83ce --- /dev/null +++ b/apps/kimi-web/test/agent-event-projector.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { subagentProgressText } from '../src/api/daemon/agentEventProjector'; + +describe('subagentProgressText', () => { + it('drops turn.step.started as noise', () => { + expect(subagentProgressText('turn.step.started', {})).toBeNull(); + }); + + it('summarizes a read tool call with its path', () => { + const text = subagentProgressText('tool.use', { name: 'read', args: { path: 'src/foo.ts' } }); + expect(text).toContain('src/foo.ts'); + expect(text).not.toContain('"path"'); + }); + + it('summarizes a bash tool call with its command', () => { + const text = subagentProgressText('tool.call.started', { name: 'bash', args: { command: 'pnpm test' } }); + expect(text).toContain('pnpm test'); + expect(text).not.toContain('"command"'); + }); + + it('drops tool.result lines as noise', () => { + expect(subagentProgressText('tool.result', { name: 'read' })).toBeNull(); + expect(subagentProgressText('tool.result', { name: 'Read_0' })).toBeNull(); + }); + + it('returns tool.progress update text', () => { + expect(subagentProgressText('tool.progress', { update: { text: 'working…' } })).toBe('working…'); + }); + + it('caps a long tool.progress text', () => { + const long = 'x'.repeat(3000); + const text = subagentProgressText('tool.progress', { update: { text: long } }); + expect(text).not.toBeNull(); + expect(text!.length).toBeLessThan(long.length); + expect(text!.endsWith('…')).toBe(true); + }); + + it('returns null for unknown event types', () => { + expect(subagentProgressText('turn.delta', {})).toBeNull(); + }); +}); diff --git a/apps/kimi-web/test/event-reducer.test.ts b/apps/kimi-web/test/event-reducer.test.ts index 8d0531aacc..4df40777e4 100644 --- a/apps/kimi-web/test/event-reducer.test.ts +++ b/apps/kimi-web/test/event-reducer.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { createInitialState, reduceAppEvent } from '../src/api/daemon/eventReducer'; -import type { AppMessage, AppSession } from '../src/api/types'; +import type { AppMessage, AppSession, AppTask } from '../src/api/types'; function makeSession(id: string, updatedAt: string): AppSession { return { @@ -37,6 +37,17 @@ function makeMessage(sessionId: string, createdAt: string): AppMessage { }; } +function makeSubagentTask(id: string, sessionId: string): AppTask { + return { + id, + sessionId, + kind: 'subagent', + description: 'subagent task', + status: 'running', + createdAt: '2026-01-01T00:00:00.000Z', + }; +} + describe('reduceAppEvent messageCreated', () => { it('bumps the session updatedAt so it floats to the top of the sidebar', () => { const state = { @@ -81,3 +92,60 @@ describe('reduceAppEvent messageCreated', () => { expect(next.sessions.find((s) => s.id === 's-b')?.updatedAt).toBe('2026-01-01T00:00:00.000Z'); }); }); + +describe('reduceAppEvent taskProgress', () => { + it('accumulates the full progress output without truncating to a fixed window', () => { + const state = { + ...createInitialState(), + tasksBySession: { 's1': [makeSubagentTask('t1', 's1')] }, + }; + let next = state; + for (let i = 0; i < 60; i++) { + // The real projector emits a taskCreated (without reducer-owned + // outputLines) right before every taskProgress; progress must survive + // that replacement. + next = reduceAppEvent( + next, + { type: 'taskCreated', sessionId: 's1', task: makeSubagentTask('t1', 's1') }, + { sessionId: 's1', seq: i * 2 + 1 }, + ); + next = reduceAppEvent( + next, + { type: 'taskProgress', sessionId: 's1', taskId: 't1', outputChunk: `line ${i}`, stream: 'stdout' }, + { sessionId: 's1', seq: i * 2 + 2 }, + ); + } + const lines = next.tasksBySession['s1']?.[0]?.outputLines; + expect(lines).toHaveLength(60); + expect(lines?.[0]).toBe('line 0'); + expect(lines?.at(-1)).toBe('line 59'); + }); + + it('deduplicates a repeated trailing chunk', () => { + const state = { + ...createInitialState(), + tasksBySession: { 's1': [makeSubagentTask('t1', 's1')] }, + }; + const event = { type: 'taskProgress', sessionId: 's1', taskId: 't1', outputChunk: 'same', stream: 'stdout' } as const; + const once = reduceAppEvent(state, event, { sessionId: 's1', seq: 1 }); + const twice = reduceAppEvent(once, event, { sessionId: 's1', seq: 2 }); + expect(twice.tasksBySession['s1']?.[0]?.outputLines).toEqual(['same']); + }); + + it('caps accumulated output for non-subagent (background) tasks', () => { + const bash: AppTask = { ...makeSubagentTask('b1', 's1'), kind: 'bash' }; + const state = { ...createInitialState(), tasksBySession: { 's1': [bash] } }; + let next = state; + for (let i = 0; i < 60; i++) { + next = reduceAppEvent( + next, + { type: 'taskProgress', sessionId: 's1', taskId: 'b1', outputChunk: `line ${i}`, stream: 'stdout' }, + { sessionId: 's1', seq: i + 1 }, + ); + } + const lines = next.tasksBySession['s1']?.[0]?.outputLines; + expect(lines).toHaveLength(40); + expect(lines?.[0]).toBe('line 20'); + expect(lines?.at(-1)).toBe('line 59'); + }); +});