feat(kimi-code): specialize the WaitFor tool's transcript display (#3066)

* feat(kimi-code): specialize the WaitFor tool's transcript display

* feat(agent-core-v2): emit status progress while WaitFor is pending

* fix(kimi-code): route WaitFor dimming through the TUI theme

* feat(kimi-code): support replaceable status updates in tool progress

* fix(kimi-code): forward status progress to subagent activity surfaces

* fix(agent-core-v2): drop the redundant undefined from ToolUpdate.replace

* fix(kimi-code): honor replace semantics in the subagent live status path

* test(agent-core-v2): drive the WaitFor progress test through a manual tick

* fix(kap-server): mirror ToolUpdate.replace in the ws event schema

* refactor(agent-core-v2): expose the WaitFor progress scheduler as a public seam

* fix(kimi-code): pass child wait statuses without the trailing newline

* feat(agent-core-v2): tick the WaitFor progress status every second

* feat(agent-core-v2): format WaitFor progress durations as 1m 15s

* feat(agent-core-v2): omit zero seconds and minutes in WaitFor durations
This commit is contained in:
Luyu Cheng 2026-08-19 14:15:38 +08:00 committed by GitHub
parent c908a39e32
commit 01eeacb59b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 662 additions and 11 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Show a live status line with elapsed time and remaining task count while the WaitFor tool is waiting.

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Improve the WaitFor tool's transcript display: the header shows the waited task and its outcome, and the body summarizes the finished task, other tasks that completed during the wait, and tasks still running, instead of dumping raw fields.

View file

@ -36,6 +36,7 @@ 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 { buildWaitForHeader } from './tool-renderers/wait-for';
const MAX_ARG_LENGTH = 60;
const MAX_SUB_TOOL_CALLS_SHOWN = 4;
@ -620,6 +621,7 @@ export class ToolCallComponent extends Container {
// spinner). Cleared when the result lands — the result is the
// authoritative final state.
private progressLines: string[] = [];
private progressStatusRows = 0;
private static readonly MAX_PROGRESS_LINES = 24;
private liveOutput = '';
@ -731,6 +733,7 @@ export class ToolCallComponent extends Container {
// authoritative final state. Without this clear, a finished tool would
// show both the streamed status lines and the final output stacked.
this.progressLines = [];
this.progressStatusRows = 0;
this.liveOutput = '';
this.detachHintVisible = false;
this.stopDetachHintTimer();
@ -759,15 +762,26 @@ export class ToolCallComponent extends Container {
/**
* Append a live progress line emitted by the tool via
* `onUpdate({kind:'status', text})`. Splits on newlines so multi-line
* status payloads render row-by-row. Old lines are dropped once the
* status payloads render row-by-row. With `options.replace`, the previous
* replaceable status block is swapped out first periodic "still
* waiting" updates would otherwise pile up to the cap with stale rows.
* Old lines are dropped once the
* buffer fills past {@link ToolCallComponent.MAX_PROGRESS_LINES} so a
* misbehaving tool can't grow the box unboundedly.
*/
appendProgress(text: string): void {
appendProgress(text: string, options?: { readonly replace?: boolean }): void {
if (this.result !== undefined) return;
for (const line of text.split('\n')) {
if (options?.replace === true && this.progressStatusRows > 0) {
this.progressLines.splice(
Math.max(0, this.progressLines.length - this.progressStatusRows),
this.progressStatusRows,
);
}
const lines = text.split('\n');
for (const line of lines) {
this.progressLines.push(line);
}
this.progressStatusRows = options?.replace === true ? lines.length : 0;
while (this.progressLines.length > ToolCallComponent.MAX_PROGRESS_LINES) {
this.progressLines.shift();
}
@ -1379,14 +1393,14 @@ export class ToolCallComponent extends Container {
this.ui?.requestRender();
}
appendSubToolLiveOutput(id: string, text: string): void {
appendSubToolLiveOutput(id: string, text: string, options?: { readonly replace?: boolean }): void {
if (text.length === 0) return;
const activity = this.subToolActivities.get(id);
const ongoing = this.ongoingSubCalls.get(id);
if (activity === undefined && ongoing === undefined) return;
const name = activity?.name ?? ongoing?.name ?? 'Tool';
const args = activity?.args ?? ongoing?.args ?? {};
const existingOutput = activity?.output ?? '';
const existingOutput = options?.replace === true ? '' : (activity?.output ?? '');
let output = existingOutput + text;
if (output.length > MAX_LIVE_OUTPUT_CHARS) {
output = `[...truncated]\n${output.slice(output.length - MAX_LIVE_OUTPUT_CHARS)}`;
@ -1503,6 +1517,14 @@ export class ToolCallComponent extends Container {
});
if (goalHeader !== undefined) return goalHeader;
const waitForHeader = buildWaitForHeader({
toolCall,
result,
bullet,
chip: isFinished && result !== undefined ? this.buildHeaderChip(result) : '',
});
if (waitForHeader !== undefined) return waitForHeader;
if (this.isSingleSubagentView()) {
return this.buildSingleSubagentHeader();
}
@ -1880,7 +1902,7 @@ export class ToolCallComponent extends Container {
current?.phase === 'ongoing' &&
current.output !== undefined &&
current.output.trim().length > 0 &&
(current.name === 'Bash' || isGenericToolResult(current.name))
(current.name === 'Bash' || current.name === 'WaitFor' || isGenericToolResult(current.name))
) {
return { text: current.output, tone: 'text' };
}

View file

@ -14,6 +14,7 @@ import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types';
import { goalStatusChip } from './goal';
import { readMediaChip } from './media';
import { strArg } from './types';
import { waitForChip } from './wait-for';
export type ChipProvider = (toolCall: ToolCallBlockData, result: ToolResultBlockData) => string;
@ -125,6 +126,7 @@ const REGISTRY: Record<string, ChipProvider> = {
WebSearch: webSearchChip,
CreateGoal: goalStatusOutputChip,
GetGoal: goalStatusOutputChip,
WaitFor: waitForChip,
};
export function pickChip(toolName: string): ChipProvider | undefined {

View file

@ -13,6 +13,7 @@
import { readMediaSummary } from './media';
import { shellExecutionResultRenderer } from '../shell-execution';
import { goalSummary } from './goal';
import { waitForSummary } from './wait-for';
import {
editSummary,
fetchSummary,
@ -63,6 +64,8 @@ export function pickResultRenderer(toolName: string): ResultRenderer {
case 'SetGoalBudget':
case 'UpdateGoal':
return goalSummary;
case 'WaitFor':
return waitForSummary;
default:
return renderTruncated;
}

View file

@ -0,0 +1,179 @@
/**
* WaitFor renderer the wait result is a timeline (header fields, then
* `[finished]` / `[completed_during_wait]` / `[still_running]` sections),
* so the collapsed body shows what the wait came back with instead of the
* raw key-value dump: the finished task with its outcome, plus counts of
* tasks that finished alongside or are still running. A timeout is not an
* error (the tool says so itself), so it renders in the warning tone.
*/
import { Text, type Component } from '@moonshot-ai/pi-tui';
import { STATUS_BULLET } from '#/tui/constant/symbols';
import { currentTheme } from '#/tui/theme';
import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types';
import { formatGoalElapsed } from '../goal-format';
import { renderTruncated } from './truncated';
import type { ResultRenderer } from './types';
const DESCRIPTION_MAX = 72;
const RUNNING_SAMPLES = 3;
type WaitForStatus = 'completed' | 'timed_out' | 'no_tasks';
interface WaitForResultView {
readonly status: WaitForStatus;
readonly waitedMs: number;
readonly finishedTaskId?: string;
readonly finishedStatus?: string;
readonly finishedDescription?: string;
readonly extraCount: number;
readonly runningCount: number;
readonly runningSamples: readonly string[];
}
export const waitForSummary: ResultRenderer = (toolCall, result, ctx) => {
if (result.is_error) return renderTruncated(toolCall, result, ctx);
const view = parseWaitForOutput(result.output);
if (view === undefined) return renderTruncated(toolCall, result, ctx);
const out: Component[] = [];
for (const line of glanceLines(view)) {
out.push(new Text(` ${currentTheme.dim(line)}`, 0, 0));
}
if (ctx.expanded && result.output.length > 0) {
out.push(new Text(currentTheme.dim(result.output), 4, 0));
}
return out;
};
export function buildWaitForHeader(options: {
readonly toolCall: ToolCallBlockData;
readonly result: ToolResultBlockData | undefined;
readonly bullet: string;
readonly chip: string;
}): string | undefined {
const { toolCall, result, bullet, chip } = options;
if (toolCall.name !== 'WaitFor') return undefined;
const taskId = typeof toolCall.args['task_id'] === 'string' ? toolCall.args['task_id'] : undefined;
const argText =
taskId === undefined ? '' : currentTheme.dimFg('textDim', ` (${taskId})`);
if (result === undefined) {
const label =
taskId === undefined ? 'Waiting for any background task' : 'Waiting for background task';
return `${bullet}${currentTheme.boldFg('primary', label)}${argText}`;
}
if (result.is_error === true) {
return `${bullet}${currentTheme.boldFg('error', 'Could not wait for background task')}${argText}`;
}
const status = parseWaitForOutput(result.output)?.status;
if (status === 'timed_out') {
return `${currentTheme.fg('warning', STATUS_BULLET)}${currentTheme.boldFg('warning', 'Wait timed out')}${argText}${chip}`;
}
if (status === 'no_tasks') {
return `${bullet}${currentTheme.boldFg('primary', 'No background tasks running')}${chip}`;
}
const label = taskId === undefined ? 'Waited for a background task' : 'Waited for background task';
return `${bullet}${currentTheme.boldFg('primary', label)}${argText}${chip}`;
}
export const waitForChip = (_toolCall: ToolCallBlockData, result: ToolResultBlockData): string => {
if (result.is_error === true) return '';
const view = parseWaitForOutput(result.output);
if (view === undefined || view.status === 'no_tasks') return '';
return formatGoalElapsed(view.waitedMs);
};
function glanceLines(view: WaitForResultView): string[] {
switch (view.status) {
case 'no_tasks':
return [];
case 'timed_out': {
if (view.runningCount === 0) return [];
const summary = `${pluralizeTasks(view.runningCount)} still running`;
if (view.runningSamples.length === 0) return [summary];
const remaining = view.runningCount - view.runningSamples.length;
const tail = remaining > 0 ? `, +${String(remaining)} more` : '';
return [`${summary}: ${view.runningSamples.join(', ')}${tail}`];
}
case 'completed': {
const taskId = view.finishedTaskId ?? 'task';
const status = view.finishedStatus ?? 'completed';
const marker = status === 'completed' ? '✓' : '✗';
const description =
view.finishedDescription === undefined
? ''
: ` · ${truncateOneLine(view.finishedDescription, DESCRIPTION_MAX)}`;
const lines = [`${marker} ${taskId} ${status}${description}`];
const parts: string[] = [];
if (view.extraCount > 0) parts.push(`+${String(view.extraCount)} more finished during wait`);
if (view.runningCount > 0) parts.push(`${pluralizeTasks(view.runningCount)} still running`);
if (parts.length > 0) lines.push(parts.join(' · '));
return lines;
}
}
}
function pluralizeTasks(count: number): string {
return `${String(count)} background task${count === 1 ? '' : 's'}`;
}
function parseWaitForOutput(output: string): WaitForResultView | undefined {
const status = field(output, 'wait_status');
if (status !== 'completed' && status !== 'timed_out' && status !== 'no_tasks') return undefined;
const waitedMs = Number(field(output, 'waited_ms') ?? 0);
const finished = section(output, 'finished');
const duringWait = section(output, 'completed_during_wait');
const stillRunning = section(output, 'still_running');
const runningCount = stillRunning === undefined ? 0 : countField(stillRunning, 'active_background_tasks');
return {
status,
waitedMs: Number.isFinite(waitedMs) ? waitedMs : 0,
finishedTaskId: field(output, 'task_id'),
finishedStatus: finished === undefined ? undefined : field(finished, 'status'),
finishedDescription: finished === undefined ? undefined : field(finished, 'description'),
extraCount: duringWait === undefined ? 0 : countOccurrences(duringWait, /^task_id: /gm),
runningCount,
runningSamples:
stillRunning === undefined ? [] : sampleDescriptions(stillRunning, runningCount),
};
}
function field(text: string, name: string): string | undefined {
const match = new RegExp(`^${name}: (.+)$`, 'm').exec(text);
return match?.[1];
}
function countField(text: string, name: string): number {
const value = Number(field(text, name) ?? 0);
return Number.isFinite(value) ? value : 0;
}
function section(output: string, name: string): string | undefined {
const match = new RegExp(`^\\[${name}\\]$`, 'm').exec(output);
if (match === null) return undefined;
const rest = output.slice(match.index + match[0].length);
const next = /^\[/m.exec(rest);
return (next === null ? rest : rest.slice(0, next.index)).trim();
}
function countOccurrences(text: string, pattern: RegExp): number {
return text.match(pattern)?.length ?? 0;
}
function sampleDescriptions(stillRunning: string, runningCount: number): readonly string[] {
const descriptions = [...stillRunning.matchAll(/^description: (.+)$/gm)].map((match) =>
truncateOneLine(match[1] ?? '', 40),
);
return descriptions.slice(0, Math.min(RUNNING_SAMPLES, runningCount));
}
function truncateOneLine(text: string, max: number): string {
const firstLine = text.replaceAll(/\s+/g, ' ').trim();
if (firstLine.length <= max) return firstLine;
return `${firstLine.slice(0, Math.max(0, max - 1))}`;
}

View file

@ -663,7 +663,7 @@ export class SessionEventHandler {
const tc = this.host.streamingUI.getToolComponent(event.toolCallId);
if (tc === undefined) return;
if (event.update.kind === 'status') {
tc.appendProgress(text);
tc.appendProgress(text, { replace: event.update.replace === true });
return;
}
if (event.update.kind === 'stdout' || event.update.kind === 'stderr') {

View file

@ -220,7 +220,8 @@ export class SubagentActivityStore {
return;
}
case 'tool.progress': {
if (event.update.kind !== 'stdout' && event.update.kind !== 'stderr') return;
const kind = event.update.kind;
if (kind !== 'stdout' && kind !== 'stderr' && kind !== 'status') return;
const text = event.update.text;
if (text === undefined || text.trim().length === 0) return;
const record = this.records.get(event.agentId);

View file

@ -119,10 +119,16 @@ export class SubAgentEventHandler {
});
} else if (
event.type === 'tool.progress' &&
(event.update.kind === 'stdout' || event.update.kind === 'stderr') &&
(event.update.kind === 'stdout' ||
event.update.kind === 'stderr' ||
event.update.kind === 'status') &&
event.update.text !== undefined
) {
toolCall.appendSubToolLiveOutput(`${childAgentId}:${event.toolCallId}`, event.update.text);
toolCall.appendSubToolLiveOutput(
`${childAgentId}:${event.toolCallId}`,
event.update.text,
{ replace: event.update.replace === true },
);
} else if (event.type === 'tool.result') {
toolCall.finishSubToolCall({
tool_call_id: `${childAgentId}:${event.toolCallId}`,

View file

@ -1933,4 +1933,179 @@ describe('ToolCallComponent', () => {
stderr.restore();
}
});
describe('WaitFor header', () => {
const waitForCompletedOutput = [
'wait_status: completed',
'task_id: question-80w0h7nw',
'waited_ms: 9607',
'timeout_ms: 300000',
'',
'[finished]',
'task_id: question-80w0h7nw',
'description: demo question',
'status: completed',
'kind: question',
].join('\n');
it('shows the waiting tense with the task id while pending', () => {
const component = new ToolCallComponent(
{
id: 'call_wait_pending',
name: 'WaitFor',
args: { task_id: 'question-80w0h7nw', timeout: 300 },
},
undefined,
stubTui(30),
);
expect(strip(component.render(100).join('\n'))).toContain(
'Waiting for background task (question-80w0h7nw)',
);
component.dispose();
});
it('falls back to "any background task" when no task id is given', () => {
const component = new ToolCallComponent(
{ id: 'call_wait_any', name: 'WaitFor', args: { timeout: 300 } },
undefined,
stubTui(30),
);
expect(strip(component.render(100).join('\n'))).toContain('Waiting for any background task');
component.dispose();
});
it('shows the waited tense with the elapsed chip once completed', () => {
const component = new ToolCallComponent(
{
id: 'call_wait_done',
name: 'WaitFor',
args: { task_id: 'question-80w0h7nw', timeout: 300 },
},
{
tool_call_id: 'call_wait_done',
output: waitForCompletedOutput,
is_error: false,
},
);
const out = strip(component.render(100).join('\n'));
expect(out).toContain('Waited for background task (question-80w0h7nw)');
expect(out).toContain('10s');
});
it('renders a timeout as its own non-error header', () => {
const component = new ToolCallComponent(
{
id: 'call_wait_timeout',
name: 'WaitFor',
args: { task_id: 'question-80w0h7nw', timeout: 1 },
},
{
tool_call_id: 'call_wait_timeout',
output: 'wait_status: timed_out\ntask_id: question-80w0h7nw\nwaited_ms: 1000\ntimeout_ms: 1000',
is_error: false,
},
);
expect(strip(component.render(100).join('\n'))).toContain(
'Wait timed out (question-80w0h7nw)',
);
});
it('renders errors with the failure tense', () => {
const component = new ToolCallComponent(
{
id: 'call_wait_error',
name: 'WaitFor',
args: { task_id: 'bash-x', timeout: 300 },
},
{
tool_call_id: 'call_wait_error',
output: 'Task not found: bash-x',
is_error: true,
},
);
expect(strip(component.render(100).join('\n'))).toContain(
'Could not wait for background task (bash-x)',
);
});
it('replaces the previous status block when progress arrives with replace', () => {
const component = new ToolCallComponent(
{ id: 'call_wait_replace', name: 'WaitFor', args: { timeout: 600 } },
undefined,
stubTui(30),
);
component.appendProgress('Waiting 10s / 600s · 2 background tasks still running', {
replace: true,
});
component.appendProgress('Waiting 20s / 600s · 1 background task still running', {
replace: true,
});
const out = strip(component.render(100).join('\n'));
expect(out).toContain('Waiting 20s / 600s');
expect(out).not.toContain('Waiting 10s / 600s');
component.dispose();
});
it('keeps appending status rows when replace is not set', () => {
const component = new ToolCallComponent(
{ id: 'call_wait_append', name: 'WaitFor', args: { timeout: 600 } },
undefined,
stubTui(30),
);
component.appendProgress('first status');
component.appendProgress('second status');
const out = strip(component.render(100).join('\n'));
expect(out).toContain('first status');
expect(out).toContain('second status');
component.dispose();
});
it('replaces a sub-tool status row when child progress arrives with replace', () => {
const component = new ToolCallComponent(
{ id: 'call_agent_wait', name: 'Agent', args: { description: 'child wait' } },
undefined,
stubTui(30),
);
component.onSubagentSpawned({
agentId: 'sub_wait_1',
agentName: 'coder',
runInBackground: false,
});
component.appendSubToolCall({
id: 'sub_wait_1:wait',
name: 'WaitFor',
args: { timeout: 600 },
});
component.appendSubToolLiveOutput(
'sub_wait_1:wait',
'Waiting 10s / 600s · 2 background tasks still running\n',
{ replace: true },
);
component.appendSubToolLiveOutput(
'sub_wait_1:wait',
'Waiting 20s / 600s · 1 background task still running\n',
{ replace: true },
);
const out = strip(component.render(120).join('\n'));
expect(out).toContain('Waiting 20s / 600s');
expect(out).not.toContain('Waiting 10s / 600s');
component.dispose();
});
});
});

View file

@ -250,4 +250,125 @@ describe('tool-result registry', () => {
expect(out).not.toContain(longLine);
expect(out).toContain('... (');
});
const waitForCompletedOutput = [
'wait_status: completed',
'task_id: question-80w0h7nw',
'waited_ms: 9607',
'timeout_ms: 300000',
'',
'[finished]',
'task_id: question-80w0h7nw',
'description: Pick one so I can demonstrate WaitFor with background questions?',
'status: completed',
'kind: question',
'',
'[output]',
'{"answers":{"Pick one":"Beta"}}',
].join('\n');
it('WaitFor completed renders the finished task instead of raw fields', () => {
const renderer = pickResultRenderer('WaitFor');
const out = strip(
joinRender(
renderer(call('WaitFor', { task_id: 'question-80w0h7nw' }), result(waitForCompletedOutput), ctx),
),
);
expect(out).toContain('✓ question-80w0h7nw completed');
expect(out).toContain('Pick one so I can demonstrate');
expect(out).not.toContain('waited_ms');
expect(out).not.toContain('[finished]');
});
it('WaitFor completed expands to the raw timeline output', () => {
const renderer = pickResultRenderer('WaitFor');
const out = strip(
joinRender(
renderer(
call('WaitFor', { task_id: 'question-80w0h7nw' }),
result(waitForCompletedOutput),
expandedCtx,
),
),
);
expect(out).toContain('[finished]');
expect(out).toContain('waited_ms: 9607');
});
it('WaitFor completed mentions extras and still-running counts', () => {
const output = [
'wait_status: completed',
'task_id: bash-a1',
'waited_ms: 1200',
'timeout_ms: 30000',
'',
'[finished]',
'task_id: bash-a1',
'description: main wait',
'status: failed',
'',
'[completed_during_wait]',
'task_id: bash-b2',
'description: side task',
'status: completed',
'',
'[still_running]',
'active_background_tasks: 2',
'task_id: bash-c3',
'description: slow one',
'status: running',
'---',
'task_id: agent-d4',
'description: another slow one',
'status: running',
].join('\n');
const renderer = pickResultRenderer('WaitFor');
const out = strip(joinRender(renderer(call('WaitFor', { task_id: 'bash-a1' }), result(output), ctx)));
expect(out).toContain('✗ bash-a1 failed');
expect(out).toContain('+1 more finished during wait');
expect(out).toContain('2 background tasks still running');
});
it('WaitFor timed_out lists the still-running tasks without an error tone', () => {
const output = [
'wait_status: timed_out',
'task_id: bash-a1',
'waited_ms: 30000',
'timeout_ms: 30000',
'The wait ended before the task finished.',
'',
'[still_running]',
'active_background_tasks: 2',
'task_id: bash-a1',
'description: bg sleep',
'status: running',
'---',
'task_id: agent-b2',
'description: investigate flaky test',
'status: running',
].join('\n');
const renderer = pickResultRenderer('WaitFor');
const out = strip(joinRender(renderer(call('WaitFor', { task_id: 'bash-a1' }), result(output), ctx)));
expect(out).toContain('2 background tasks still running');
expect(out).toContain('bg sleep');
expect(out).toContain('investigate flaky test');
expect(out).not.toContain('waited_ms');
});
it('WaitFor no_tasks renders no body in collapsed state', () => {
const renderer = pickResultRenderer('WaitFor');
const output = 'wait_status: no_tasks\nwaited_ms: 0\ntimeout_ms: 30000';
const out = joinRender(renderer(call('WaitFor', { timeout: 30 }), result(output), ctx));
expect(out.trim()).toBe('');
});
it('WaitFor errors fall back to the truncated renderer', () => {
const renderer = pickResultRenderer('WaitFor');
const out = strip(
joinRender(
renderer(call('WaitFor', { task_id: 'bash-x' }), result('Task not found: bash-x', true), ctx),
),
);
expect(out).toContain('Task not found: bash-x');
});
});

View file

@ -62,6 +62,25 @@ describe('SubagentActivityStore', () => {
expect(record?.version).toBeGreaterThan(0);
});
it('shows a status progress update as the live output tail', () => {
const store = new SubagentActivityStore();
store.ensureRecord(spawn());
store.applyEvent(
ev({ type: 'tool.call.started', turnId: 1, toolCallId: 't1', name: 'WaitFor', args: { timeout: 600 } }),
);
store.applyEvent(
ev({
type: 'tool.progress',
turnId: 1,
toolCallId: 't1',
update: { kind: 'status', text: 'Waiting 10s / 600s · 1 background task still running', replace: true },
}),
);
const call = store.get('agent-1')?.steps[0]?.toolCalls[0];
expect(call?.liveOutputTail).toBe('Waiting 10s / 600s · 1 background task still running');
});
it('creates a call from streaming deltas and replaces args on start', () => {
const store = new SubagentActivityStore();
store.ensureRecord(spawn());

View file

@ -4,6 +4,7 @@ import {
type ExecutableToolContext,
type ExecutableToolResult,
type ToolExecution,
type ToolUpdate,
} from '#/tool/toolContract';
import { registerAgentToolService } from '#/agent/toolRegistry/toolContribution';
@ -23,6 +24,8 @@ const OUTPUT_PREVIEW_BYTES = 32 * 1024;
const PAGING_HINT_LINES = 300;
const PROGRESS_INTERVAL_MS = 1_000;
type WaitForOutcome = 'completed' | 'timed_out' | 'task_not_found' | 'aborted';
function terminalReason(info: AgentTaskInfo): 'timed_out' | 'stopped' | 'failed' | undefined {
@ -50,6 +53,64 @@ function fullOutputHint(output: AgentTaskOutputSnapshot): string | undefined {
);
}
export function waitForProgressUpdate(
args: WaitForInput,
runningCount: number,
startedAt: number,
now: number,
): ToolUpdate {
const elapsedS = Math.max(0, Math.round((now - startedAt) / 1000));
return {
kind: 'status',
text:
`Waiting ${formatWaitSeconds(elapsedS)} / ${formatWaitSeconds(args.timeout)} · ` +
`${String(runningCount)} background task${runningCount === 1 ? '' : 's'} still running`,
replace: true,
};
}
function formatWaitSeconds(totalSeconds: number): string {
if (totalSeconds < 60) return `${String(totalSeconds)}s`;
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
if (minutes < 60) {
return seconds === 0
? `${String(minutes)}m`
: `${String(minutes)}m ${seconds.toString().padStart(2, '0')}s`;
}
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
return remainingMinutes === 0
? `${String(hours)}h`
: `${String(hours)}h ${remainingMinutes.toString().padStart(2, '0')}m`;
}
export interface WaitForProgressHandle {
readonly stop: () => void;
readonly tick: () => void;
}
export function startWaitProgress(
args: WaitForInput,
tasks: Pick<IAgentTaskService, 'list'>,
onUpdate: ((update: ToolUpdate) => void) | undefined,
startedAt: number,
): WaitForProgressHandle {
if (onUpdate === undefined) return { stop: () => {}, tick: () => {} };
const tick = (): void => {
onUpdate(waitForProgressUpdate(args, tasks.list(true).length, startedAt, Date.now()));
};
tick();
const interval = setInterval(tick, PROGRESS_INTERVAL_MS);
interval.unref?.();
return {
stop: () => {
clearInterval(interval);
},
tick,
};
}
export class WaitForTool implements IWaitForTool {
declare readonly _serviceBrand: undefined;
readonly name = 'WaitFor' as const;
@ -105,6 +166,7 @@ export class WaitForTool implements IWaitForTool {
}
let waited: AgentTaskInfo | undefined;
const progress = startWaitProgress(args, this.tasks, ctx.onUpdate, startedAt);
try {
waited =
args.task_id === undefined
@ -113,6 +175,8 @@ export class WaitForTool implements IWaitForTool {
} catch (error) {
this.track(args, startedAt, timeoutMs, 'aborted', 0);
throw error;
} finally {
progress.stop();
}
if (waited === undefined) {

View file

@ -45,6 +45,7 @@ export interface ToolUpdate {
percent?: number | undefined;
customKind?: string | undefined;
customData?: unknown;
replace?: boolean;
}
export interface ExecutableToolContext {

View file

@ -22,7 +22,7 @@ import { TaskOutputTool } from '#/agent/tools/task/task-output/taskOutputTool';
import { TaskStopInputSchema } from '#/agent/tools/task/task-stop/task-stop';
import { TaskStopTool } from '#/agent/tools/task/task-stop/taskStopTool';
import { WaitForInputSchema } from '#/agent/tools/task/task-wait/task-wait';
import { WaitForTool } from '#/agent/tools/task/task-wait/taskWaitTool';
import { WaitForTool, startWaitProgress, waitForProgressUpdate } from '#/agent/tools/task/task-wait/taskWaitTool';
import { abortError } from '#/_base/utils/abort';
import type { ITaskHandle } from '#/app/task/task';
import type { IHostProcess } from '#/os/interface/hostProcess';
@ -1032,6 +1032,45 @@ describe('WaitForTool', () => {
expect(outputString(result)).toContain('wait_for experimental flag is off');
expect(tasks.waitCalls).toEqual([]);
});
it('emits status progress updates while the wait is pending', async () => {
const update = waitForProgressUpdate({ timeout: 600 }, 2, 1_000, 31_000);
expect(update).toMatchObject({
kind: 'status',
replace: true,
text: 'Waiting 30s / 10m · 2 background tasks still running',
});
expect(waitForProgressUpdate({ timeout: 600 }, 1, 1_000, 31_000).text).toContain(
'1 background task still running',
);
expect(waitForProgressUpdate({ timeout: 600 }, 0, 1_000, 31_000).text).toContain(
'0 background tasks still running',
);
expect(waitForProgressUpdate({ timeout: 600 }, 1, 1_000, 76_000).text).toContain(
'Waiting 1m 15s / 10m',
);
expect(waitForProgressUpdate({ timeout: 180 }, 1, 1_000, 61_000).text).toContain(
'Waiting 1m / 3m',
);
});
it('routes the composed progress update through onUpdate on a manual tick', () => {
const tasks = new FakeTaskService();
tasks.add(processTask({ taskId: 'bash-prog002' }));
const onUpdate = vi.fn();
const progress = startWaitProgress({ timeout: 600 }, tasks, onUpdate, Date.now() - 30_000);
progress.tick();
progress.stop();
expect(onUpdate).toHaveBeenCalledWith(
expect.objectContaining({
kind: 'status',
replace: true,
text: expect.stringMatching(/^Waiting 3\ds \/ 10m · 1 background task still running$/),
}),
);
});
});
describe('WaitForTool (harness)', () => {

View file

@ -469,6 +469,7 @@ export const toolUpdateSchema = z.object({
percent: z.number().optional(),
customKind: z.string().optional(),
customData: z.unknown().optional(),
replace: z.boolean().optional(),
}) satisfies z.ZodType<ToolUpdate>;
export const mcpOAuthAuthorizationUrlUpdateDataSchema = z.object({

View file

@ -100,6 +100,7 @@ export const toolProgressEventSchema = z.object({
percent: z.number().optional(),
customKind: z.string().optional(),
customData: z.unknown().optional(),
replace: z.boolean().optional(),
}),
});

View file

@ -444,6 +444,12 @@ export interface ToolUpdate {
readonly percent?: number;
readonly customKind?: string;
readonly customData?: unknown;
/**
* When true, hosts replace this tool call's previous live status block
* instead of appending a new row for periodic "still working" updates
* whose predecessors are stale the moment they are emitted.
*/
readonly replace?: boolean;
}
export const MCP_OAUTH_AUTHORIZATION_URL_TOOL_UPDATE = 'mcp.oauth.authorization_url';
@ -1444,6 +1450,7 @@ export const toolUpdateSchema = z.object({
percent: z.number().optional(),
customKind: z.string().optional(),
customData: z.unknown().optional(),
replace: z.boolean().optional(),
}) satisfies z.ZodType<ToolUpdate>;
export const mcpOAuthAuthorizationUrlUpdateDataSchema = z.object({