feat: redesign the subagent card (#1345)

Stable height across running, done, failed and backgrounded states: all share the same header + one-line tool summary + two-row content window, so the card no longer shrinks when a run finishes. Add a braille spinner in the header while active, collapse sub-tool calls into a one-line summary, and have the two-row window follow the live stream (tool output, text, or thinking) instead of showing thinking and text side by side. Mute the window tones so a brief text or tool-output segment no longer flashes white against dim thinking.
This commit is contained in:
liruifengv 2026-07-03 16:54:21 +08:00 committed by GitHub
parent 175b95f3af
commit 3ed22e35a4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 235 additions and 149 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Keep subagent cards at a stable height and show a live status spinner with a compact two-row activity window.

View file

@ -10,6 +10,8 @@ import type { Component, TUI } from '@moonshot-ai/pi-tui';
import { highlightLines, langFromPath } from '#/tui/components/media/code-highlight';
import { renderDiffLinesClustered } from '#/tui/components/media/diff-preview';
import {
BRAILLE_SPINNER_FRAMES,
BRAILLE_SPINNER_INTERVAL_MS,
COMMAND_PREVIEW_LINES,
RESULT_PREVIEW_LINES,
THINKING_PREVIEW_LINES,
@ -33,16 +35,14 @@ import { ShellExecutionComponent } from './shell-execution';
import { countNonEmptyLines, pickChip } from './tool-renderers/chip';
import { buildGoalToolHeader } from './tool-renderers/goal';
import { isGenericToolResult, pickResultRenderer } from './tool-renderers/registry';
import { TruncatedOutputComponent } from './tool-renderers/truncated';
const MAX_ARG_LENGTH = 60;
const MAX_SUB_TOOL_CALLS_SHOWN = 4;
const MAX_SINGLE_SUBAGENT_TOOL_ROWS = 4;
// Hanging indent for a sub-tool's previewed output, nested under its activity row.
const SUBAGENT_SUBTOOL_OUTPUT_INDENT = 6;
// Cap the Agent `description` in the single-subagent header so a long prompt
// cannot wrap the header onto a second row and break the card's stable height.
const MAX_SUBAGENT_DESCRIPTION_LENGTH = 60;
const APPROVED_PLAN_MARKER = '## Approved Plan:';
const STREAMING_PROGRESS_INTERVAL_MS = 1000;
const SUBAGENT_ELAPSED_INTERVAL_MS = 1000;
const PROGRESS_URL_RE = /https?:\/\/\S+/g;
const ABORTED_MARK = '⊘';
const MAX_LIVE_OUTPUT_CHARS = 50_000;
@ -474,6 +474,10 @@ class PrefixedWrappedLine implements Component {
// unwrapped paragraph scrolls within a fixed window instead of growing
// unbounded. The first kept row still gets `firstPrefix`.
private readonly tailLines?: number,
// When set, the output is padded with empty continuation rows until it
// reaches this many display rows, so a short paragraph still fills a
// fixed-height window. Applied after `tailLines`.
private readonly minLines?: number,
) { }
invalidate(): void {
@ -498,6 +502,9 @@ class PrefixedWrappedLine implements Component {
this.tailLines !== undefined && wrapped.length > this.tailLines
? wrapped.slice(wrapped.length - this.tailLines)
: wrapped;
if (this.minLines !== undefined) {
while (lines.length < this.minLines) lines.push('');
}
const rendered = lines
.map((line, index) =>
index === 0 ? `${this.firstPrefix}${line}` : `${this.continuationPrefix}${line}`,
@ -548,6 +555,9 @@ export class ToolCallComponent extends Container {
*/
private subagentText = '';
private subagentThinkingText = '';
/** Tracks whether the child agent's latest streamed delta was text or thinking,
* so the active window can follow whichever is currently live. */
private lastSubagentStreamKind: SubagentTextKind = 'text';
// ── Subagent lifecycle state from subagent.spawned/started/completed/failed ──
private subagentPhase: SubagentPhase | undefined;
/**
@ -577,6 +587,7 @@ export class ToolCallComponent extends Container {
private subagentElapsedTimer: ReturnType<typeof setInterval> | undefined;
private subagentStartedAtMs: number | undefined;
private subagentEndedAtMs: number | undefined;
private subagentSpinnerFrame = 0;
// ── Live progress lines ──────────────────────────────────────────
//
@ -1036,11 +1047,14 @@ export class ToolCallComponent extends Container {
this.stopSubagentElapsedTimer();
return;
}
// Drives both the braille spinner in the header and the elapsed-seconds
// refresh. Only the header text changes on a tick, so we avoid rebuilding
// the body (which would defeat the per-component render caches).
this.subagentSpinnerFrame = (this.subagentSpinnerFrame + 1) % BRAILLE_SPINNER_FRAMES.length;
this.headerText.setText(this.buildHeader());
this.invalidate();
this.notifySnapshotChange();
this.ui?.requestRender();
}, SUBAGENT_ELAPSED_INTERVAL_MS);
}, BRAILLE_SPINNER_INTERVAL_MS);
}
private stopSubagentElapsedTimer(): void {
@ -1260,6 +1274,7 @@ export class ToolCallComponent extends Container {
}
appendSubagentText(text: string, kind: SubagentTextKind = 'text'): void {
this.lastSubagentStreamKind = kind;
if (kind === 'thinking') {
this.subagentThinkingText += text;
} else {
@ -1697,25 +1712,24 @@ export class ToolCallComponent extends Container {
private buildSingleSubagentHeader(): string {
const phase = this.getDerivedSubagentPhase();
const isFailed = phase === 'failed';
const isDone = phase === 'done';
const bullet = isFailed
? currentTheme.fg('error', '✗ ')
: isDone
? currentTheme.fg('success', STATUS_BULLET)
: currentTheme.fg('text', STATUS_BULLET);
const marker = this.buildSingleSubagentMarker(phase);
const labelText = formatSubagentLabel(this.subagentAgentName);
const label = currentTheme.boldFg('primary', labelText);
const status = this.formatSingleSubagentStatus(phase);
const description = str(this.toolCall.args['description']);
const rawDescription = str(this.toolCall.args['description']);
const description =
rawDescription.length > MAX_SUBAGENT_DESCRIPTION_LENGTH
? `${rawDescription.slice(0, MAX_SUBAGENT_DESCRIPTION_LENGTH - 1)}`
: rawDescription;
const descriptionPlain = description.length > 0 ? ` (${description})` : '';
const descriptionText = descriptionPlain.length > 0 ? currentTheme.dim(descriptionPlain) : '';
const statsText = this.formatSingleSubagentStatsText();
if (isDone) {
return `${bullet}${currentTheme.boldFg('success', labelText)} ${currentTheme.fg('success', `Completed${descriptionPlain}${statsText}`)}`;
return `${marker}${currentTheme.boldFg('success', labelText)} ${currentTheme.fg('success', `Completed${descriptionPlain}${statsText}`)}`;
}
const stats = currentTheme.dim(statsText);
return `${bullet}${label} ${status}${descriptionText}${stats}`;
return `${marker}${label} ${status}${descriptionText}${stats}`;
}
private formatSingleSubagentStatus(phase: SubagentPhase | undefined): string {
@ -1758,92 +1772,133 @@ export class ToolCallComponent extends Container {
return Math.max(0, Math.floor((end - this.subagentStartedAtMs) / 1000));
}
private buildSingleSubagentBlock(): void {
for (const activity of this.getRecentSubToolActivities()) {
const mark =
activity.phase === 'failed'
? currentTheme.fg('error', '✗')
: activity.phase === 'done'
? currentTheme.fg('success', '•')
: currentTheme.fg('text', '•');
const verb = activity.phase === 'ongoing' ? 'Using' : 'Used';
this.addChild(new Text(` ${mark} ${this.formatSubToolActivity(verb, activity)}`, 0, 0));
this.addSubToolOutputPreview(activity);
}
if (this.getDerivedSubagentPhase() === 'failed' && this.subagentError !== undefined) {
const errorLine = tailNonEmptyLines(this.subagentError, 1).at(-1);
if (errorLine !== undefined) {
this.addChild(
new PrefixedWrappedLine(
` ${currentTheme.fg('error', '└')} `,
' ',
currentTheme.fg('error', errorLine),
),
);
}
return;
}
const outputLine = tailNonEmptyLines(this.subagentText, 1).at(-1);
if (
this.getDerivedSubagentPhase() !== 'done' &&
this.subagentThinkingText.trim().length > 0
) {
// Scroll thinking within a fixed two-row window (width-aware), matching
// the main agent's live thinking instead of growing without bound.
this.addChild(
new PrefixedWrappedLine(
` ${currentTheme.dim('◌')} `,
' ',
currentTheme.dim(this.subagentThinkingText.trimEnd()),
THINKING_PREVIEW_LINES,
),
);
}
if (outputLine !== undefined) {
this.addChild(
new PrefixedWrappedLine(
` ${currentTheme.fg('text', '└')} `,
' ',
currentTheme.fg('text', outputLine),
),
);
}
private buildSingleSubagentMarker(phase: SubagentPhase | undefined): string {
if (phase === 'failed') return currentTheme.fg('error', '✗ ');
if (phase === 'done') return currentTheme.fg('success', STATUS_BULLET);
if (phase === 'backgrounded') return currentTheme.dim('◐ ');
// Active (queued / spawning / running): a braille spinner reads as alive
// where a static bullet looked frozen.
const frame = BRAILLE_SPINNER_FRAMES[this.subagentSpinnerFrame] ?? BRAILLE_SPINNER_FRAMES[0];
return currentTheme.fg('primary', `${frame} `);
}
private addSubToolOutputPreview(activity: SubToolActivity): void {
const output = activity.output;
if (output === undefined || output.trim().length === 0) return;
// Mirror the main agent: Bash and any tool without a dedicated renderer
// (every MCP tool included) get a truncated output preview. Recognized
// tools keep their compact activity row only.
if (activity.name !== 'Bash' && !isGenericToolResult(activity.name)) return;
this.addChild(
new TruncatedOutputComponent(output, {
// Subagent output is always fixed-truncated; it does not take part in
// the ctrl+o expand toggle, so don't advertise it either.
expanded: false,
expandHint: false,
isError: activity.phase === 'failed',
maxLines: RESULT_PREVIEW_LINES,
indent: SUBAGENT_SUBTOOL_OUTPUT_INDENT,
tail: activity.phase === 'ongoing',
}),
private buildSingleSubagentBlock(): void {
const phase = this.getDerivedSubagentPhase();
// Every state shares the same skeleton — header, a one-line tool summary,
// and a fixed two-row content window — so the card height is identical
// while running and after it finishes (no end-of-run shrink).
this.addChild(new Text(this.buildSingleSubagentSummaryLine(), 0, 0));
if (phase === 'failed') {
this.addChild(this.buildSingleSubagentResultWindow('error'));
return;
}
if (phase === 'done' || phase === 'backgrounded') {
this.addChild(this.buildSingleSubagentResultWindow('output'));
return;
}
this.addChild(this.buildSingleSubagentActiveWindow());
}
/** Most-recently-started sub-tool, preferring one that is still running. */
private getCurrentSubToolActivity(): SubToolActivity | undefined {
let latestOngoing: SubToolActivity | undefined;
let latest: SubToolActivity | undefined;
for (const activity of this.subToolActivities.values()) {
if (latest === undefined || activity.orderSeq > latest.orderSeq) latest = activity;
if (
activity.phase === 'ongoing' &&
(latestOngoing === undefined || activity.orderSeq > latestOngoing.orderSeq)
) {
latestOngoing = activity;
}
}
return latestOngoing ?? latest;
}
/**
* The single live stream shown in the active window. A running sub-tool with
* previewable output (Bash or any tool without a dedicated renderer) wins;
* otherwise the most-recently-updated of the child agent's text / thinking.
*/
private getActiveSubagentContent(): { text: string; tone: 'text' | 'thinking' } | undefined {
const current = this.getCurrentSubToolActivity();
if (
current?.phase === 'ongoing' &&
current.output !== undefined &&
current.output.trim().length > 0 &&
(current.name === 'Bash' || isGenericToolResult(current.name))
) {
return { text: current.output, tone: 'text' };
}
if (this.lastSubagentStreamKind === 'thinking' && this.subagentThinkingText.trim().length > 0) {
return { text: this.subagentThinkingText.trimEnd(), tone: 'thinking' };
}
if (this.subagentText.trim().length > 0) {
return { text: this.subagentText, tone: 'text' };
}
if (this.subagentThinkingText.trim().length > 0) {
return { text: this.subagentThinkingText.trimEnd(), tone: 'thinking' };
}
return undefined;
}
private buildSingleSubagentSummaryLine(): string {
const toolCount = this.subToolActivities.size;
const countLabel = `${String(toolCount)} tool${toolCount === 1 ? '' : 's'}`;
const current = this.getCurrentSubToolActivity();
if (current === undefined) {
return currentTheme.dim(` · ${countLabel}`);
}
const verb = current.phase === 'ongoing' ? 'Using' : 'Used';
const keyArg = extractKeyArgument(current.name, current.args, this.workspaceDir);
const nameCol = currentTheme.fg('primary', current.name);
const argCol = keyArg ? currentTheme.dim(` (${keyArg})`) : '';
const mark =
current.phase === 'failed'
? currentTheme.fg('error', ' ✗')
: current.phase === 'done'
? currentTheme.fg('success', ' ✓')
: '';
return `${currentTheme.dim(` · ${countLabel} · `)}${verb} ${nameCol}${argCol}${mark}`;
}
private buildSingleSubagentActiveWindow(): Component {
const gutter = currentTheme.dim('│');
const content = this.getActiveSubagentContent();
// Keep both tones muted: a bright `fg('text')` here flashed white whenever
// the window flipped between thinking and a brief text/tool-output segment.
const styled =
content === undefined
? currentTheme.dim('…')
: content.tone === 'thinking'
? currentTheme.dim(content.text)
: currentTheme.fg('textDim', content.text);
// Always exactly two rows (padded when short) so the live window matches
// the finished card's height.
return new PrefixedWrappedLine(
` ${gutter} `,
` ${gutter} `,
styled,
THINKING_PREVIEW_LINES,
THINKING_PREVIEW_LINES,
);
}
private getRecentSubToolActivities(): SubToolActivity[] {
return [...this.subToolActivities.values()]
.toSorted((a, b) => a.orderSeq - b.orderSeq)
.slice(-MAX_SINGLE_SUBAGENT_TOOL_ROWS);
}
private formatSubToolActivity(verb: string, activity: SubToolActivity): string {
const keyArg = extractKeyArgument(activity.name, activity.args, this.workspaceDir);
const nameCol = currentTheme.fg('primary', activity.name);
const argCol = keyArg ? currentTheme.dim(` (${keyArg})`) : '';
return `${verb} ${nameCol}${argCol}`;
private buildSingleSubagentResultWindow(kind: 'output' | 'error'): Component {
const gutter = currentTheme.dim('│');
const source = kind === 'error' ? this.subagentError : this.subagentText;
const text = source === undefined ? '' : tailNonEmptyLines(source, 2).join('\n');
const styled =
kind === 'error' ? currentTheme.fg('error', text) : currentTheme.fg('text', text);
return new PrefixedWrappedLine(
` ${gutter} `,
` ${gutter} `,
styled,
THINKING_PREVIEW_LINES,
THINKING_PREVIEW_LINES,
);
}
private buildCallPreview(): void {

View file

@ -929,14 +929,15 @@ describe('ToolCallComponent', () => {
out = strip(component.render(120).join('\n'));
expect(out).toContain('Explore Agent Running (explore project xxx) · 1 tool · 10s');
expect(out).toContain('Using Read (apps/kimi-code/src/tui/utils/background-agent-status.ts)');
// Thinking and text are mutually exclusive in the active window: the most
// recently streamed (text) wins, so thinking is hidden entirely.
expect(out).not.toContain('think1');
expect(out).toContain('think2');
expect(out).toContain('think3');
expect(out).toContain('◌ think2');
expect(out).not.toContain('think2');
expect(out).not.toContain('think3');
expect(out).not.toContain('answer1');
expect(out).not.toContain('answer2');
expect(out).toContain('answer2');
expect(out).toContain('answer3');
expect(out).toContain(' answer3');
expect(out).toContain(' answer3');
vi.setSystemTime(22_000);
component.onSubagentCompleted({ resultSummary: 'summary fallback' });
@ -950,7 +951,7 @@ describe('ToolCallComponent', () => {
out = strip(component.render(120).join('\n'));
expect(out).toContain('Explore Agent Completed (explore project xxx) · 1 tool · 12s');
expect(out).not.toContain('think3');
expect(out).toContain(' answer3');
expect(out).toContain(' answer3');
expect(out).not.toContain('Used Agent');
expect(out).not.toContain('parent duplicate result');
expect(out).not.toContain('summary fallback');
@ -1000,7 +1001,7 @@ describe('ToolCallComponent', () => {
component.dispose();
});
it('keeps the single subagent tool area to the latest four activities', () => {
it('summarizes subagent tools as a count plus the current tool', () => {
vi.useFakeTimers();
vi.setSystemTime(0);
const component = new ToolCallComponent(
@ -1030,16 +1031,17 @@ describe('ToolCallComponent', () => {
const out = strip(component.render(120).join('\n'));
expect(out).toContain('Explore Agent Running (inspect tools) · 5 tools · 0s');
expect(out).not.toContain('file1.ts');
expect(out).toContain('Used Read (file2.ts)');
expect(out).toContain('Used Read (file3.ts)');
expect(out).toContain('Used Read (file4.ts)');
expect(out).not.toContain('… Using Grep (auth)');
expect(out).toContain('• Using Grep (auth)');
// Only the current (most recent ongoing) tool appears in the summary line.
expect(out).toContain('Using Grep (auth)');
// No per-tool activity rows are rendered.
expect(out).not.toContain('file1.ts');
expect(out).not.toContain('file2.ts');
expect(out).not.toContain('file3.ts');
expect(out).not.toContain('file4.ts');
expect(out).not.toContain('Used Read');
});
it('keeps the single subagent tool window stable when older tools update', () => {
it('keeps the subagent tool summary pinned to the most recent tool', () => {
vi.useFakeTimers();
vi.setSystemTime(0);
const component = new ToolCallComponent(
@ -1075,14 +1077,16 @@ describe('ToolCallComponent', () => {
});
const out = strip(component.render(120).join('\n'));
// The updated/finished older tool must not surface in the summary.
expect(out).not.toContain('file1-updated.ts');
expect(out).toContain('Using Read (file2.ts)');
expect(out).toContain('Using Read (file3.ts)');
expect(out).toContain('Using Read (file4.ts)');
expect(out).not.toContain('file2.ts');
expect(out).not.toContain('file3.ts');
expect(out).not.toContain('file4.ts');
// Only the most recent ongoing tool is shown.
expect(out).toContain('Using Read (file5.ts)');
});
it('wraps single subagent thinking and output with hanging indentation', () => {
it('wraps the single subagent active window with a hanging gutter', () => {
vi.useFakeTimers();
vi.setSystemTime(0);
const component = new ToolCallComponent(
@ -1098,24 +1102,17 @@ describe('ToolCallComponent', () => {
agentName: 'explore',
runInBackground: false,
});
component.appendSubagentText(
'thinking words that should wrap with a clean hanging indent',
'thinking',
);
component.appendSubagentText(
'output words that should also wrap with a clean hanging indent',
'text',
);
const lines = strip(component.render(34).join('\n')).split('\n');
// Thinking is scrolled to its last two display rows, so the head of the
// wrapped paragraph drops and the ◌ marker hangs on the first kept row.
expect(lines.some((l) => l.includes('◌ wrap with a clean hanging'))).toBe(true);
expect(lines.join('\n')).not.toContain('thinking words that should');
expect(lines).toContain(' indent ');
// Output keeps its full hanging-indent wrap (unchanged behavior).
expect(lines).toContain(' └ output words that should also ');
expect(lines).toContain(' wrap with a clean hanging ');
const joined = strip(component.render(34).join('\n'));
// The two-row window drops the head of the wrapped paragraph.
expect(joined).not.toContain('output words that should');
// Every kept row carries the `│` gutter as a hanging indent.
expect(joined).toContain('│ wrap with a clean hanging');
expect(joined).toContain('│ indent');
});
it('scrolls single subagent thinking to the last two display rows', () => {
@ -1146,7 +1143,7 @@ describe('ToolCallComponent', () => {
expect(lines.join('\n')).not.toContain('seg00');
});
it('shows and truncates a single subagent Bash tool output', () => {
it('shows a two-row tail of an ongoing subagent Bash output', () => {
vi.useFakeTimers();
vi.setSystemTime(0);
const component = new ToolCallComponent(
@ -1168,25 +1165,25 @@ describe('ToolCallComponent', () => {
args: { command: 'ls -la' },
});
const output = Array.from({ length: 10 }, (_, i) => `bash-line-${String(i)}`).join('\n');
component.finishSubToolCall({ tool_call_id: 'sub_bash:cmd', output, is_error: false });
component.appendSubToolLiveOutput('sub_bash:cmd', output);
let out = strip(component.render(120).join('\n'));
expect(out).toContain('Used Bash (ls -la)');
expect(out).toContain('bash-line-0');
expect(out).toContain('bash-line-2');
expect(out).not.toContain('bash-line-3');
expect(out).toContain('... (7 more lines)');
// Subagent output is fixed-truncated: no ctrl+o promise.
expect(out).toContain('Using Bash (ls -la)');
// The active window keeps only the last two rows of live output.
expect(out).toContain('bash-line-8');
expect(out).toContain('bash-line-9');
expect(out).not.toContain('bash-line-7');
// No ctrl+o promise for the subagent window.
expect(out).not.toContain('ctrl+o');
// The global ctrl+o expand toggle must NOT expand subagent output.
// The global ctrl+o expand toggle must NOT expand the window.
component.setExpanded(true);
out = strip(component.render(120).join('\n'));
expect(out).not.toContain('bash-line-9');
expect(out).toContain('... (7 more lines)');
expect(out).toContain('bash-line-9');
expect(out).not.toContain('bash-line-7');
});
it('truncates unknown subagent tool output but leaves recognized tools as rows', () => {
it('shows live output for generic subagent tools but not for recognized ones', () => {
vi.useFakeTimers();
vi.setSystemTime(0);
const component = new ToolCallComponent(
@ -1202,6 +1199,7 @@ describe('ToolCallComponent', () => {
agentName: 'explore',
runInBackground: false,
});
// A finished recognized tool: its output body never reaches the window.
component.appendSubToolCall({
id: 'sub_mixed:read',
name: 'Read',
@ -1212,23 +1210,22 @@ describe('ToolCallComponent', () => {
output: 'recognized-read-body\nhidden-read-line',
is_error: false,
});
// An ongoing generic (MCP) tool: its live output is the active stream.
component.appendSubToolCall({
id: 'sub_mixed:mcp',
name: 'mcp__server__do',
args: {},
});
const mcpOut = Array.from({ length: 5 }, (_, i) => `mcp-line-${String(i)}`).join('\n');
component.finishSubToolCall({ tool_call_id: 'sub_mixed:mcp', output: mcpOut, is_error: false });
component.appendSubToolLiveOutput('sub_mixed:mcp', mcpOut);
const out = strip(component.render(120).join('\n'));
// Recognized tool: activity row only, no output body.
expect(out).toContain('Used Read (foo.ts)');
// Recognized tool output never appears.
expect(out).not.toContain('recognized-read-body');
// Unknown/MCP tool: truncated output body, no ctrl+o promise.
expect(out).toContain('mcp-line-0');
expect(out).toContain('mcp-line-2');
expect(out).not.toContain('mcp-line-3');
expect(out).toContain('... (2 more lines)');
// Generic tool output shows as the two-row active window tail.
expect(out).toContain('mcp-line-3');
expect(out).toContain('mcp-line-4');
expect(out).not.toContain('mcp-line-2');
expect(out).not.toContain('ctrl+o');
});
@ -1254,11 +1251,40 @@ describe('ToolCallComponent', () => {
const out = strip(component.render(120).join('\n'));
expect(out).toContain('Explore Agent Failed (check failure) · 0 tools · 3s');
expect(out).toContain(' subagent exceeded max_steps');
expect(out).toContain(' subagent exceeded max_steps');
expect(out).not.toContain('Using Agent');
expect(out).not.toContain('Used Agent');
});
it('keeps the same card height between running and done', () => {
vi.useFakeTimers();
vi.setSystemTime(0);
const component = new ToolCallComponent(
{
id: 'call_agent_height',
name: 'Agent',
args: { description: 'height stable' },
},
undefined,
);
component.onSubagentSpawned({
agentId: 'sub_height',
agentName: 'explore',
runInBackground: false,
});
component.appendSubToolCall({ id: 'sub_height:read', name: 'Read', args: { path: 'a.ts' } });
component.appendSubagentText('short answer', 'text');
const runningLines = strip(component.render(120).join('\n')).split('\n').length;
component.onSubagentCompleted({ resultSummary: 'short answer' });
component.setResult({ tool_call_id: 'call_agent_height', output: 'done', is_error: false });
const doneLines = strip(component.render(120).join('\n')).split('\n').length;
expect(doneLines).toBe(runningLines);
});
describe('background agent terminal state vs spawn-success ToolResult', () => {
// The Agent tool returns a "task spawned" result the moment a
// run_in_background=true call lands. That result is not an error and its