From 0080046602d743a36721b382916db37edf105b8d Mon Sep 17 00:00:00 2001 From: chengluyu <2239547+chengluyu@users.noreply.github.com> Date: Wed, 19 Aug 2026 09:44:26 +0800 Subject: [PATCH 01/14] feat(kimi-code): specialize the WaitFor tool's transcript display --- .changeset/wait-for-tui-display.md | 5 + .../src/tui/components/messages/tool-call.ts | 9 + .../messages/tool-renderers/chip.ts | 2 + .../messages/tool-renderers/registry.ts | 3 + .../messages/tool-renderers/wait-for.ts | 180 ++++++++++++++++++ .../tui/components/messages/tool-call.test.ts | 102 ++++++++++ .../messages/tool-renderers/registry.test.ts | 121 ++++++++++++ 7 files changed, 422 insertions(+) create mode 100644 .changeset/wait-for-tui-display.md create mode 100644 apps/kimi-code/src/tui/components/messages/tool-renderers/wait-for.ts diff --git a/.changeset/wait-for-tui-display.md b/.changeset/wait-for-tui-display.md new file mode 100644 index 00000000000..c9dcea034de --- /dev/null +++ b/.changeset/wait-for-tui-display.md @@ -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. 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 050a9a2456f..e482f3aefe1 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-call.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-call.ts @@ -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; @@ -1503,6 +1504,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(); } diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/chip.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/chip.ts index c7c8120f2df..37536c14005 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/chip.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/chip.ts @@ -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 = { WebSearch: webSearchChip, CreateGoal: goalStatusOutputChip, GetGoal: goalStatusOutputChip, + WaitFor: waitForChip, }; export function pickChip(toolName: string): ChipProvider | undefined { diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/registry.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/registry.ts index 2a7b395397f..eedc4316a38 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/registry.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/registry.ts @@ -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; } diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/wait-for.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/wait-for.ts new file mode 100644 index 00000000000..8701e979a31 --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/wait-for.ts @@ -0,0 +1,180 @@ +/** + * 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 chalk from 'chalk'; + +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(` ${chalk.dim(line)}`, 0, 0)); + } + if (ctx.expanded && result.output.length > 0) { + out.push(new Text(chalk.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))}…`; +} diff --git a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts index 4426e0e5a44..2b217cbef20 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts @@ -1933,4 +1933,106 @@ 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)', + ); + }); + }); }); diff --git a/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts b/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts index 6570aac4643..cff95e628e4 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts @@ -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'); + }); }); From dd6c99a4b59e0db3fb1d951084c3dd7bb16a3233 Mon Sep 17 00:00:00 2001 From: chengluyu <2239547+chengluyu@users.noreply.github.com> Date: Wed, 19 Aug 2026 09:44:38 +0800 Subject: [PATCH 02/14] feat(agent-core-v2): emit status progress while WaitFor is pending --- .changeset/wait-for-progress.md | 5 +++ .../tools/task/task-wait/taskWaitTool.ts | 28 +++++++++++++++ .../test/agent/task/tools/task-tools.test.ts | 36 +++++++++++++++++++ 3 files changed, 69 insertions(+) create mode 100644 .changeset/wait-for-progress.md diff --git a/.changeset/wait-for-progress.md b/.changeset/wait-for-progress.md new file mode 100644 index 00000000000..b11419bf6fd --- /dev/null +++ b/.changeset/wait-for-progress.md @@ -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. diff --git a/packages/agent-core-v2/src/agent/tools/task/task-wait/taskWaitTool.ts b/packages/agent-core-v2/src/agent/tools/task/task-wait/taskWaitTool.ts index 4c2733b75a6..d15e42e4214 100644 --- a/packages/agent-core-v2/src/agent/tools/task/task-wait/taskWaitTool.ts +++ b/packages/agent-core-v2/src/agent/tools/task/task-wait/taskWaitTool.ts @@ -23,6 +23,8 @@ const OUTPUT_PREVIEW_BYTES = 32 * 1024; const PAGING_HINT_LINES = 300; +const PROGRESS_INTERVAL_MS = 10_000; + type WaitForOutcome = 'completed' | 'timed_out' | 'task_not_found' | 'aborted'; function terminalReason(info: AgentTaskInfo): 'timed_out' | 'stopped' | 'failed' | undefined { @@ -105,6 +107,7 @@ export class WaitForTool implements IWaitForTool { } let waited: AgentTaskInfo | undefined; + const stopProgress = this.startProgress(args, ctx, startedAt); try { waited = args.task_id === undefined @@ -113,6 +116,8 @@ export class WaitForTool implements IWaitForTool { } catch (error) { this.track(args, startedAt, timeoutMs, 'aborted', 0); throw error; + } finally { + stopProgress(); } if (waited === undefined) { @@ -160,6 +165,29 @@ export class WaitForTool implements IWaitForTool { } } + private startProgress( + args: WaitForInput, + ctx: ExecutableToolContext, + startedAt: number, + ): () => void { + const onUpdate = ctx.onUpdate; + if (onUpdate === undefined) return () => {}; + const interval = setInterval(() => { + const elapsedS = Math.round((Date.now() - startedAt) / 1000); + const running = this.tasks.list(true).length; + onUpdate({ + kind: 'status', + text: + `Waiting ${String(elapsedS)}s / ${String(args.timeout)}s · ` + + `${String(running)} background task${running === 1 ? '' : 's'} still running`, + }); + }, PROGRESS_INTERVAL_MS); + interval.unref?.(); + return () => { + clearInterval(interval); + }; + } + private collectExtras( runningAtStart: readonly AgentTaskInfo[], finishedTaskId: string, diff --git a/packages/agent-core-v2/test/agent/task/tools/task-tools.test.ts b/packages/agent-core-v2/test/agent/task/tools/task-tools.test.ts index 8430b012b06..b2b5a1c5eb1 100644 --- a/packages/agent-core-v2/test/agent/task/tools/task-tools.test.ts +++ b/packages/agent-core-v2/test/agent/task/tools/task-tools.test.ts @@ -1032,6 +1032,42 @@ 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 () => { + vi.useFakeTimers(); + try { + const tasks = new FakeTaskService(); + tasks.add(processTask({ taskId: 'bash-prog001' })); + tasks.waitDelegate = (_taskId, _timeoutMs, waitSignal) => + new Promise((_resolve, reject) => { + waitSignal?.addEventListener('abort', () => reject(abortError()), { once: true }); + }); + + const onUpdate = vi.fn(); + const controller = new AbortController(); + const pending = executeTool( + new WaitForTool(tasks, recordingTelemetry([]), stubFlag(true)), + { ...context('wait_progress', { timeout: 600, task_id: 'bash-prog001' }, controller.signal), onUpdate }, + ); + + await vi.advanceTimersByTimeAsync(10_500); + + expect(onUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'status', + text: expect.stringContaining('1 background task still running'), + }), + ); + + controller.abort(); + await expect(pending).rejects.toThrow('Aborted'); + + await vi.advanceTimersByTimeAsync(30_000); + expect(onUpdate.mock.calls).toHaveLength(1); + } finally { + vi.useRealTimers(); + } + }); }); describe('WaitForTool (harness)', () => { From 536cc27bba55e74c8b40aa6c1babecc7b642a078 Mon Sep 17 00:00:00 2001 From: chengluyu <2239547+chengluyu@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:12:55 +0800 Subject: [PATCH 03/14] fix(kimi-code): route WaitFor dimming through the TUI theme --- .../src/tui/components/messages/tool-renderers/wait-for.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/wait-for.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/wait-for.ts index 8701e979a31..8d95a8a66a2 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/wait-for.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/wait-for.ts @@ -8,7 +8,6 @@ */ import { Text, type Component } from '@moonshot-ai/pi-tui'; -import chalk from 'chalk'; import { STATUS_BULLET } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; @@ -41,10 +40,10 @@ export const waitForSummary: ResultRenderer = (toolCall, result, ctx) => { const out: Component[] = []; for (const line of glanceLines(view)) { - out.push(new Text(` ${chalk.dim(line)}`, 0, 0)); + out.push(new Text(` ${currentTheme.dim(line)}`, 0, 0)); } if (ctx.expanded && result.output.length > 0) { - out.push(new Text(chalk.dim(result.output), 4, 0)); + out.push(new Text(currentTheme.dim(result.output), 4, 0)); } return out; }; From 1e20145debf3092b4af88e18c0f07471c303dcab Mon Sep 17 00:00:00 2001 From: chengluyu <2239547+chengluyu@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:12:55 +0800 Subject: [PATCH 04/14] feat(kimi-code): support replaceable status updates in tool progress --- .../src/tui/components/messages/tool-call.ts | 19 ++++++++-- .../tui/controllers/session-event-handler.ts | 2 +- .../tui/components/messages/tool-call.test.ts | 38 +++++++++++++++++++ .../tools/task/task-wait/taskWaitTool.ts | 1 + .../agent-core-v2/src/tool/toolContract.ts | 1 + .../test/agent/task/tools/task-tools.test.ts | 1 + packages/klient/src/contract/agent/events.ts | 1 + packages/protocol/src/events.ts | 7 ++++ 8 files changed, 66 insertions(+), 4 deletions(-) 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 e482f3aefe1..ec6b1b27161 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-call.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-call.ts @@ -621,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 = ''; @@ -732,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(); @@ -760,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(); } diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index e0fa8cf107d..6f876877ae0 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -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') { diff --git a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts index 2b217cbef20..a2a88809335 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts @@ -2034,5 +2034,43 @@ describe('ToolCallComponent', () => { '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(); + }); }); }); diff --git a/packages/agent-core-v2/src/agent/tools/task/task-wait/taskWaitTool.ts b/packages/agent-core-v2/src/agent/tools/task/task-wait/taskWaitTool.ts index d15e42e4214..2982461271f 100644 --- a/packages/agent-core-v2/src/agent/tools/task/task-wait/taskWaitTool.ts +++ b/packages/agent-core-v2/src/agent/tools/task/task-wait/taskWaitTool.ts @@ -180,6 +180,7 @@ export class WaitForTool implements IWaitForTool { text: `Waiting ${String(elapsedS)}s / ${String(args.timeout)}s · ` + `${String(running)} background task${running === 1 ? '' : 's'} still running`, + replace: true, }); }, PROGRESS_INTERVAL_MS); interval.unref?.(); diff --git a/packages/agent-core-v2/src/tool/toolContract.ts b/packages/agent-core-v2/src/tool/toolContract.ts index e98490d30a9..ec2bdb130e5 100644 --- a/packages/agent-core-v2/src/tool/toolContract.ts +++ b/packages/agent-core-v2/src/tool/toolContract.ts @@ -45,6 +45,7 @@ export interface ToolUpdate { percent?: number | undefined; customKind?: string | undefined; customData?: unknown; + replace?: boolean | undefined; } export interface ExecutableToolContext { diff --git a/packages/agent-core-v2/test/agent/task/tools/task-tools.test.ts b/packages/agent-core-v2/test/agent/task/tools/task-tools.test.ts index b2b5a1c5eb1..ade1b8a5900 100644 --- a/packages/agent-core-v2/test/agent/task/tools/task-tools.test.ts +++ b/packages/agent-core-v2/test/agent/task/tools/task-tools.test.ts @@ -1056,6 +1056,7 @@ describe('WaitForTool', () => { expect.objectContaining({ kind: 'status', text: expect.stringContaining('1 background task still running'), + replace: true, }), ); diff --git a/packages/klient/src/contract/agent/events.ts b/packages/klient/src/contract/agent/events.ts index c102b180847..e657d6550a7 100644 --- a/packages/klient/src/contract/agent/events.ts +++ b/packages/klient/src/contract/agent/events.ts @@ -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(), }), }); diff --git a/packages/protocol/src/events.ts b/packages/protocol/src/events.ts index b0c61884771..d319066fa8a 100644 --- a/packages/protocol/src/events.ts +++ b/packages/protocol/src/events.ts @@ -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; export const mcpOAuthAuthorizationUrlUpdateDataSchema = z.object({ From 369f3990053adc82fcfb822bb7824de39966ae66 Mon Sep 17 00:00:00 2001 From: chengluyu <2239547+chengluyu@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:12:55 +0800 Subject: [PATCH 05/14] fix(kimi-code): forward status progress to subagent activity surfaces --- .../controllers/subagent-activity-store.ts | 3 ++- .../tui/controllers/subagent-event-handler.ts | 9 +++++++-- .../subagent-activity-store.test.ts | 19 +++++++++++++++++++ 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/apps/kimi-code/src/tui/controllers/subagent-activity-store.ts b/apps/kimi-code/src/tui/controllers/subagent-activity-store.ts index a612ece5b74..2f768513f99 100644 --- a/apps/kimi-code/src/tui/controllers/subagent-activity-store.ts +++ b/apps/kimi-code/src/tui/controllers/subagent-activity-store.ts @@ -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); diff --git a/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts b/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts index 80a62510250..ec59cb0e9da 100644 --- a/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts @@ -119,10 +119,15 @@ 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.kind === 'status' ? `${event.update.text}\n` : event.update.text, + ); } else if (event.type === 'tool.result') { toolCall.finishSubToolCall({ tool_call_id: `${childAgentId}:${event.toolCallId}`, diff --git a/apps/kimi-code/test/tui/controllers/subagent-activity-store.test.ts b/apps/kimi-code/test/tui/controllers/subagent-activity-store.test.ts index d7c73f2a78a..06b90dd6941 100644 --- a/apps/kimi-code/test/tui/controllers/subagent-activity-store.test.ts +++ b/apps/kimi-code/test/tui/controllers/subagent-activity-store.test.ts @@ -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()); From 4f6e7bd45ff9897e364602e4717764a70c55ab59 Mon Sep 17 00:00:00 2001 From: chengluyu <2239547+chengluyu@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:42:13 +0800 Subject: [PATCH 06/14] fix(agent-core-v2): drop the redundant undefined from ToolUpdate.replace --- packages/agent-core-v2/src/tool/toolContract.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/agent-core-v2/src/tool/toolContract.ts b/packages/agent-core-v2/src/tool/toolContract.ts index ec2bdb130e5..552c708c264 100644 --- a/packages/agent-core-v2/src/tool/toolContract.ts +++ b/packages/agent-core-v2/src/tool/toolContract.ts @@ -45,7 +45,7 @@ export interface ToolUpdate { percent?: number | undefined; customKind?: string | undefined; customData?: unknown; - replace?: boolean | undefined; + replace?: boolean; } export interface ExecutableToolContext { From 1f48298bba944200d862ed14605e20244489e433 Mon Sep 17 00:00:00 2001 From: chengluyu <2239547+chengluyu@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:42:13 +0800 Subject: [PATCH 07/14] fix(kimi-code): honor replace semantics in the subagent live status path --- .../src/tui/components/messages/tool-call.ts | 6 ++-- .../tui/controllers/subagent-event-handler.ts | 1 + .../tui/components/messages/tool-call.test.ts | 35 +++++++++++++++++++ 3 files changed, 39 insertions(+), 3 deletions(-) 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 ec6b1b27161..4ca5541bbbc 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-call.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-call.ts @@ -1393,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)}`; @@ -1902,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' }; } diff --git a/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts b/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts index ec59cb0e9da..95bfef8b3bb 100644 --- a/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts @@ -127,6 +127,7 @@ export class SubAgentEventHandler { toolCall.appendSubToolLiveOutput( `${childAgentId}:${event.toolCallId}`, event.update.kind === 'status' ? `${event.update.text}\n` : event.update.text, + { replace: event.update.replace === true }, ); } else if (event.type === 'tool.result') { toolCall.finishSubToolCall({ diff --git a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts index a2a88809335..367e62a56c9 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts @@ -2072,5 +2072,40 @@ describe('ToolCallComponent', () => { 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(); + }); }); }); From f38e4cc93f54ae1a58e9ad5232d42f5270ea1c34 Mon Sep 17 00:00:00 2001 From: chengluyu <2239547+chengluyu@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:42:14 +0800 Subject: [PATCH 08/14] test(agent-core-v2): drive the WaitFor progress test through a manual tick --- .../tools/task/task-wait/taskWaitTool.ts | 47 ++++++++----- .../test/agent/task/tools/task-tools.test.ts | 69 ++++++++++--------- 2 files changed, 67 insertions(+), 49 deletions(-) diff --git a/packages/agent-core-v2/src/agent/tools/task/task-wait/taskWaitTool.ts b/packages/agent-core-v2/src/agent/tools/task/task-wait/taskWaitTool.ts index 2982461271f..5a357a8d166 100644 --- a/packages/agent-core-v2/src/agent/tools/task/task-wait/taskWaitTool.ts +++ b/packages/agent-core-v2/src/agent/tools/task/task-wait/taskWaitTool.ts @@ -4,6 +4,7 @@ import { type ExecutableToolContext, type ExecutableToolResult, type ToolExecution, + type ToolUpdate, } from '#/tool/toolContract'; import { registerAgentToolService } from '#/agent/toolRegistry/toolContribution'; @@ -52,6 +53,22 @@ 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 ${String(elapsedS)}s / ${String(args.timeout)}s · ` + + `${String(runningCount)} background task${runningCount === 1 ? '' : 's'} still running`, + replace: true, + }; +} + export class WaitForTool implements IWaitForTool { declare readonly _serviceBrand: undefined; readonly name = 'WaitFor' as const; @@ -107,7 +124,7 @@ export class WaitForTool implements IWaitForTool { } let waited: AgentTaskInfo | undefined; - const stopProgress = this.startProgress(args, ctx, startedAt); + const progress = this.startProgress(args, ctx, startedAt); try { waited = args.task_id === undefined @@ -117,7 +134,7 @@ export class WaitForTool implements IWaitForTool { this.track(args, startedAt, timeoutMs, 'aborted', 0); throw error; } finally { - stopProgress(); + progress.stop(); } if (waited === undefined) { @@ -169,23 +186,19 @@ export class WaitForTool implements IWaitForTool { args: WaitForInput, ctx: ExecutableToolContext, startedAt: number, - ): () => void { + ): { readonly stop: () => void; readonly tick: () => void } { const onUpdate = ctx.onUpdate; - if (onUpdate === undefined) return () => {}; - const interval = setInterval(() => { - const elapsedS = Math.round((Date.now() - startedAt) / 1000); - const running = this.tasks.list(true).length; - onUpdate({ - kind: 'status', - text: - `Waiting ${String(elapsedS)}s / ${String(args.timeout)}s · ` + - `${String(running)} background task${running === 1 ? '' : 's'} still running`, - replace: true, - }); - }, PROGRESS_INTERVAL_MS); + if (onUpdate === undefined) return { stop: () => {}, tick: () => {} }; + const tick = (): void => { + onUpdate(waitForProgressUpdate(args, this.tasks.list(true).length, startedAt, Date.now())); + }; + const interval = setInterval(tick, PROGRESS_INTERVAL_MS); interval.unref?.(); - return () => { - clearInterval(interval); + return { + stop: () => { + clearInterval(interval); + }, + tick, }; } diff --git a/packages/agent-core-v2/test/agent/task/tools/task-tools.test.ts b/packages/agent-core-v2/test/agent/task/tools/task-tools.test.ts index ade1b8a5900..37952681bf8 100644 --- a/packages/agent-core-v2/test/agent/task/tools/task-tools.test.ts +++ b/packages/agent-core-v2/test/agent/task/tools/task-tools.test.ts @@ -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, 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'; @@ -1034,40 +1034,45 @@ describe('WaitForTool', () => { }); it('emits status progress updates while the wait is pending', async () => { - vi.useFakeTimers(); - try { - const tasks = new FakeTaskService(); - tasks.add(processTask({ taskId: 'bash-prog001' })); - tasks.waitDelegate = (_taskId, _timeoutMs, waitSignal) => - new Promise((_resolve, reject) => { - waitSignal?.addEventListener('abort', () => reject(abortError()), { once: true }); - }); - - const onUpdate = vi.fn(); - const controller = new AbortController(); - const pending = executeTool( - new WaitForTool(tasks, recordingTelemetry([]), stubFlag(true)), - { ...context('wait_progress', { timeout: 600, task_id: 'bash-prog001' }, controller.signal), onUpdate }, - ); - - await vi.advanceTimersByTimeAsync(10_500); + const update = waitForProgressUpdate({ timeout: 600 }, 2, 1_000, 31_000); + expect(update).toMatchObject({ + kind: 'status', + replace: true, + text: 'Waiting 30s / 600s · 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(onUpdate).toHaveBeenCalledWith( - expect.objectContaining({ - kind: 'status', - text: expect.stringContaining('1 background task still running'), - replace: true, - }), - ); + 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 tool = new WaitForTool(tasks, recordingTelemetry([]), stubFlag(true)); + const progress = ( + tool as unknown as { + startProgress( + args: { timeout: number }, + ctx: { onUpdate?: (update: unknown) => void }, + startedAt: number, + ): { stop(): void; tick(): void }; + } + ).startProgress({ timeout: 600 }, { onUpdate }, Date.now() - 30_000); - controller.abort(); - await expect(pending).rejects.toThrow('Aborted'); + progress.tick(); + progress.stop(); - await vi.advanceTimersByTimeAsync(30_000); - expect(onUpdate.mock.calls).toHaveLength(1); - } finally { - vi.useRealTimers(); - } + expect(onUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'status', + replace: true, + text: 'Waiting 30s / 600s · 1 background task still running', + }), + ); }); }); From 255aeaf6e90579b0119042239fbabe093def57fc Mon Sep 17 00:00:00 2001 From: chengluyu <2239547+chengluyu@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:57:54 +0800 Subject: [PATCH 09/14] fix(kap-server): mirror ToolUpdate.replace in the ws event schema --- packages/kap-server/src/protocol/events-zod.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/kap-server/src/protocol/events-zod.ts b/packages/kap-server/src/protocol/events-zod.ts index 87738513cb1..81cdda1c600 100644 --- a/packages/kap-server/src/protocol/events-zod.ts +++ b/packages/kap-server/src/protocol/events-zod.ts @@ -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; export const mcpOAuthAuthorizationUrlUpdateDataSchema = z.object({ From 7e45ab9fd5ed1003b7fced3ce68d4cb6930e315c Mon Sep 17 00:00:00 2001 From: chengluyu <2239547+chengluyu@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:57:54 +0800 Subject: [PATCH 10/14] refactor(agent-core-v2): expose the WaitFor progress scheduler as a public seam --- .../tools/task/task-wait/taskWaitTool.ts | 47 ++++++++++--------- .../test/agent/task/tools/task-tools.test.ts | 13 +---- 2 files changed, 28 insertions(+), 32 deletions(-) diff --git a/packages/agent-core-v2/src/agent/tools/task/task-wait/taskWaitTool.ts b/packages/agent-core-v2/src/agent/tools/task/task-wait/taskWaitTool.ts index 5a357a8d166..4b91f6e9d9d 100644 --- a/packages/agent-core-v2/src/agent/tools/task/task-wait/taskWaitTool.ts +++ b/packages/agent-core-v2/src/agent/tools/task/task-wait/taskWaitTool.ts @@ -69,6 +69,31 @@ export function waitForProgressUpdate( }; } +export interface WaitForProgressHandle { + readonly stop: () => void; + readonly tick: () => void; +} + +export function startWaitProgress( + args: WaitForInput, + tasks: Pick, + 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())); + }; + 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; @@ -124,7 +149,7 @@ export class WaitForTool implements IWaitForTool { } let waited: AgentTaskInfo | undefined; - const progress = this.startProgress(args, ctx, startedAt); + const progress = startWaitProgress(args, this.tasks, ctx.onUpdate, startedAt); try { waited = args.task_id === undefined @@ -182,26 +207,6 @@ export class WaitForTool implements IWaitForTool { } } - private startProgress( - args: WaitForInput, - ctx: ExecutableToolContext, - startedAt: number, - ): { readonly stop: () => void; readonly tick: () => void } { - const onUpdate = ctx.onUpdate; - if (onUpdate === undefined) return { stop: () => {}, tick: () => {} }; - const tick = (): void => { - onUpdate(waitForProgressUpdate(args, this.tasks.list(true).length, startedAt, Date.now())); - }; - const interval = setInterval(tick, PROGRESS_INTERVAL_MS); - interval.unref?.(); - return { - stop: () => { - clearInterval(interval); - }, - tick, - }; - } - private collectExtras( runningAtStart: readonly AgentTaskInfo[], finishedTaskId: string, diff --git a/packages/agent-core-v2/test/agent/task/tools/task-tools.test.ts b/packages/agent-core-v2/test/agent/task/tools/task-tools.test.ts index 37952681bf8..39ead929756 100644 --- a/packages/agent-core-v2/test/agent/task/tools/task-tools.test.ts +++ b/packages/agent-core-v2/test/agent/task/tools/task-tools.test.ts @@ -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, waitForProgressUpdate } 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'; @@ -1052,17 +1052,8 @@ describe('WaitForTool', () => { const tasks = new FakeTaskService(); tasks.add(processTask({ taskId: 'bash-prog002' })); const onUpdate = vi.fn(); - const tool = new WaitForTool(tasks, recordingTelemetry([]), stubFlag(true)); - const progress = ( - tool as unknown as { - startProgress( - args: { timeout: number }, - ctx: { onUpdate?: (update: unknown) => void }, - startedAt: number, - ): { stop(): void; tick(): void }; - } - ).startProgress({ timeout: 600 }, { onUpdate }, Date.now() - 30_000); + const progress = startWaitProgress({ timeout: 600 }, tasks, onUpdate, Date.now() - 30_000); progress.tick(); progress.stop(); From c749138dfc751ae1cd6881390fc3bb5a96e9d121 Mon Sep 17 00:00:00 2001 From: chengluyu <2239547+chengluyu@users.noreply.github.com> Date: Wed, 19 Aug 2026 11:11:45 +0800 Subject: [PATCH 11/14] fix(kimi-code): pass child wait statuses without the trailing newline --- apps/kimi-code/src/tui/controllers/subagent-event-handler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts b/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts index 95bfef8b3bb..9b9005cdf04 100644 --- a/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts @@ -126,7 +126,7 @@ export class SubAgentEventHandler { ) { toolCall.appendSubToolLiveOutput( `${childAgentId}:${event.toolCallId}`, - event.update.kind === 'status' ? `${event.update.text}\n` : event.update.text, + event.update.text, { replace: event.update.replace === true }, ); } else if (event.type === 'tool.result') { From 4b4211616574d7846b81e29c52ebff99217c4b1f Mon Sep 17 00:00:00 2001 From: chengluyu <2239547+chengluyu@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:04:52 +0800 Subject: [PATCH 12/14] feat(agent-core-v2): tick the WaitFor progress status every second --- .../src/agent/tools/task/task-wait/taskWaitTool.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/agent-core-v2/src/agent/tools/task/task-wait/taskWaitTool.ts b/packages/agent-core-v2/src/agent/tools/task/task-wait/taskWaitTool.ts index 4b91f6e9d9d..f7dd8c84e69 100644 --- a/packages/agent-core-v2/src/agent/tools/task/task-wait/taskWaitTool.ts +++ b/packages/agent-core-v2/src/agent/tools/task/task-wait/taskWaitTool.ts @@ -24,7 +24,7 @@ const OUTPUT_PREVIEW_BYTES = 32 * 1024; const PAGING_HINT_LINES = 300; -const PROGRESS_INTERVAL_MS = 10_000; +const PROGRESS_INTERVAL_MS = 1_000; type WaitForOutcome = 'completed' | 'timed_out' | 'task_not_found' | 'aborted'; @@ -84,6 +84,7 @@ export function startWaitProgress( const tick = (): void => { onUpdate(waitForProgressUpdate(args, tasks.list(true).length, startedAt, Date.now())); }; + tick(); const interval = setInterval(tick, PROGRESS_INTERVAL_MS); interval.unref?.(); return { From 34d1c38e3bbb1e20677c47c62328ee09d1b62135 Mon Sep 17 00:00:00 2001 From: chengluyu <2239547+chengluyu@users.noreply.github.com> Date: Wed, 19 Aug 2026 13:13:01 +0800 Subject: [PATCH 13/14] feat(agent-core-v2): format WaitFor progress durations as 1m 15s --- .../src/agent/tools/task/task-wait/taskWaitTool.ts | 11 ++++++++++- .../test/agent/task/tools/task-tools.test.ts | 7 +++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/agent-core-v2/src/agent/tools/task/task-wait/taskWaitTool.ts b/packages/agent-core-v2/src/agent/tools/task/task-wait/taskWaitTool.ts index f7dd8c84e69..1026f6ae29e 100644 --- a/packages/agent-core-v2/src/agent/tools/task/task-wait/taskWaitTool.ts +++ b/packages/agent-core-v2/src/agent/tools/task/task-wait/taskWaitTool.ts @@ -63,12 +63,21 @@ export function waitForProgressUpdate( return { kind: 'status', text: - `Waiting ${String(elapsedS)}s / ${String(args.timeout)}s · ` + + `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 `${String(minutes)}m ${seconds.toString().padStart(2, '0')}s`; + const hours = Math.floor(minutes / 60); + return `${String(hours)}h ${(minutes % 60).toString().padStart(2, '0')}m`; +} + export interface WaitForProgressHandle { readonly stop: () => void; readonly tick: () => void; diff --git a/packages/agent-core-v2/test/agent/task/tools/task-tools.test.ts b/packages/agent-core-v2/test/agent/task/tools/task-tools.test.ts index 39ead929756..7a67c22aa84 100644 --- a/packages/agent-core-v2/test/agent/task/tools/task-tools.test.ts +++ b/packages/agent-core-v2/test/agent/task/tools/task-tools.test.ts @@ -1038,7 +1038,7 @@ describe('WaitForTool', () => { expect(update).toMatchObject({ kind: 'status', replace: true, - text: 'Waiting 30s / 600s · 2 background tasks still running', + text: 'Waiting 30s / 10m 00s · 2 background tasks still running', }); expect(waitForProgressUpdate({ timeout: 600 }, 1, 1_000, 31_000).text).toContain( '1 background task still running', @@ -1046,6 +1046,9 @@ describe('WaitForTool', () => { 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 00s', + ); }); it('routes the composed progress update through onUpdate on a manual tick', () => { @@ -1061,7 +1064,7 @@ describe('WaitForTool', () => { expect.objectContaining({ kind: 'status', replace: true, - text: 'Waiting 30s / 600s · 1 background task still running', + text: expect.stringMatching(/^Waiting 3\ds \/ 10m 00s · 1 background task still running$/), }), ); }); From 0f6f2a26769fd8da67e710d10f6926e9fc25766c Mon Sep 17 00:00:00 2001 From: chengluyu <2239547+chengluyu@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:08:16 +0800 Subject: [PATCH 14/14] feat(agent-core-v2): omit zero seconds and minutes in WaitFor durations --- .../src/agent/tools/task/task-wait/taskWaitTool.ts | 11 +++++++++-- .../test/agent/task/tools/task-tools.test.ts | 9 ++++++--- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/agent-core-v2/src/agent/tools/task/task-wait/taskWaitTool.ts b/packages/agent-core-v2/src/agent/tools/task/task-wait/taskWaitTool.ts index 1026f6ae29e..498054b24f0 100644 --- a/packages/agent-core-v2/src/agent/tools/task/task-wait/taskWaitTool.ts +++ b/packages/agent-core-v2/src/agent/tools/task/task-wait/taskWaitTool.ts @@ -73,9 +73,16 @@ 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 `${String(minutes)}m ${seconds.toString().padStart(2, '0')}s`; + if (minutes < 60) { + return seconds === 0 + ? `${String(minutes)}m` + : `${String(minutes)}m ${seconds.toString().padStart(2, '0')}s`; + } const hours = Math.floor(minutes / 60); - return `${String(hours)}h ${(minutes % 60).toString().padStart(2, '0')}m`; + const remainingMinutes = minutes % 60; + return remainingMinutes === 0 + ? `${String(hours)}h` + : `${String(hours)}h ${remainingMinutes.toString().padStart(2, '0')}m`; } export interface WaitForProgressHandle { diff --git a/packages/agent-core-v2/test/agent/task/tools/task-tools.test.ts b/packages/agent-core-v2/test/agent/task/tools/task-tools.test.ts index 7a67c22aa84..2d64cbfb930 100644 --- a/packages/agent-core-v2/test/agent/task/tools/task-tools.test.ts +++ b/packages/agent-core-v2/test/agent/task/tools/task-tools.test.ts @@ -1038,7 +1038,7 @@ describe('WaitForTool', () => { expect(update).toMatchObject({ kind: 'status', replace: true, - text: 'Waiting 30s / 10m 00s · 2 background tasks still running', + 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', @@ -1047,7 +1047,10 @@ describe('WaitForTool', () => { '0 background tasks still running', ); expect(waitForProgressUpdate({ timeout: 600 }, 1, 1_000, 76_000).text).toContain( - 'Waiting 1m 15s / 10m 00s', + 'Waiting 1m 15s / 10m', + ); + expect(waitForProgressUpdate({ timeout: 180 }, 1, 1_000, 61_000).text).toContain( + 'Waiting 1m / 3m', ); }); @@ -1064,7 +1067,7 @@ describe('WaitForTool', () => { expect.objectContaining({ kind: 'status', replace: true, - text: expect.stringMatching(/^Waiting 3\ds \/ 10m 00s · 1 background task still running$/), + text: expect.stringMatching(/^Waiting 3\ds \/ 10m · 1 background task still running$/), }), ); });