Skip to content
Merged
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/web-subagent-full-progress.md
Original file line number Diff line number Diff line change
@@ -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.
56 changes: 35 additions & 21 deletions apps/kimi-web/src/api/daemon/agentEventProjector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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, unknown>): string | null {
if (rawType === 'turn.step.started') return 'Started a step';
export function subagentProgressText(rawType: string, payload: Record<string, unknown>): 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<string, unknown>, 'text');
if (text) return text;
if (text) return capProgressText(text);
const message = stringField(update as Record<string, unknown>, '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,
Expand Down
15 changes: 13 additions & 2 deletions apps/kimi-web/src/api/daemon/eventReducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down
119 changes: 115 additions & 4 deletions apps/kimi-web/src/components/chat/AgentDetailPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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<Set<string>>(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';
Expand Down Expand Up @@ -66,10 +115,27 @@ watch(
<span class="ap-field-label">Task</span>
<div class="ap-field-body">{{ member.prompt }}</div>
</div>
<div v-if="progressLines.length > 0" class="ap-field">
<div v-if="progressGroups.length > 0" class="ap-field">
<span class="ap-field-label">Progress</span>
<div class="ap-field-body ap-progress">
<span v-for="(line, index) in progressLines" :key="index">{{ line }}</span>
<div v-for="group in progressGroups" :key="group.key" class="ap-group">
<div v-if="group.call" class="ap-call">
<span class="ap-glyph" aria-hidden="true">▶</span>
{{ group.call }}
</div>
<div v-if="group.output.length > 0" class="ap-output">
<template v-if="group.output.length <= OUTPUT_FOLD_THRESHOLD || isExpanded(group.key)">
<div v-for="(line, li) in group.output" :key="li" class="ap-out-line">{{ line }}</div>
</template>
<template v-else>
<div v-for="(line, li) in group.output.slice(0, OUTPUT_HEAD)" :key="li" class="ap-out-line">{{ line }}</div>
<button type="button" class="ap-fold" @click="toggleGroup(group.key)">
… ({{ foldCount(group) }} more)
</button>
<div v-for="(line, li) in group.output.slice(-OUTPUT_TAIL)" :key="'t' + li" class="ap-out-line">{{ line }}</div>
</template>
</div>
</div>
</div>
</div>
<div v-if="member.summary" class="ap-field">
Expand Down Expand Up @@ -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;
}
</style>
41 changes: 41 additions & 0 deletions apps/kimi-web/test/agent-event-projector.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading
Loading