feat: stream shell tool output (#676)

This commit is contained in:
_Kerman 2026-06-12 11:10:20 +08:00 committed by GitHub
parent 0a3e87f05a
commit dcf30754d0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 204 additions and 13 deletions

View file

@ -0,0 +1,6 @@
---
"@moonshot-ai/agent-core": patch
"@moonshot-ai/kimi-code": patch
---
Stream foreground Bash stdout and stderr while commands are still running.

View file

@ -20,6 +20,8 @@ export interface ShellExecutionOptions {
*/
readonly commandPreviewLines?: number;
readonly resultPreviewLines?: number;
readonly tailOutput?: boolean;
readonly expandHint?: boolean;
}
export class ShellExecutionComponent extends Container {
@ -35,6 +37,8 @@ export class ShellExecutionComponent extends Container {
options.result,
options.expanded ?? false,
options.resultPreviewLines ?? PREVIEW_LINES,
options.tailOutput ?? false,
options.expandHint ?? true,
);
}
}
@ -53,6 +57,8 @@ export class ShellExecutionComponent extends Container {
result: ToolResultBlockData,
expanded: boolean,
previewLines: number,
tailOutput: boolean,
expandHint: boolean,
): void {
if (!result.output) return;
this.addChild(
@ -60,6 +66,8 @@ export class ShellExecutionComponent extends Container {
expanded,
isError: result.is_error ?? false,
maxLines: previewLines,
tail: tailOutput,
expandHint,
}),
);
}

View file

@ -44,6 +44,7 @@ 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;
type SubagentTextKind = 'thinking' | 'text';
type SubagentPhase = 'queued' | 'spawning' | 'running' | 'done' | 'failed' | 'backgrounded';
@ -557,6 +558,7 @@ export class ToolCallComponent extends Container {
// authoritative final state.
private progressLines: string[] = [];
private static readonly MAX_PROGRESS_LINES = 24;
private liveOutput = '';
/**
* Registered by a group container (`AgentGroupComponent` or
@ -586,6 +588,7 @@ export class ToolCallComponent extends Container {
this.buildCallPreview();
this.callPreviewEndIndex = this.children.length;
this.buildProgressBlock();
this.buildLiveOutputBlock();
this.buildContent();
this.buildSubagentBlock();
this.syncStreamingProgressTimer();
@ -615,6 +618,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.liveOutput = '';
this.finalizeSubagentElapsedIfNeeded();
this.syncStreamingProgressTimer();
this.syncSubagentElapsedTimer();
@ -657,6 +661,19 @@ export class ToolCallComponent extends Container {
this.ui?.requestRender();
}
appendLiveOutput(text: string): void {
if (this.result !== undefined || text.length === 0) return;
this.liveOutput += text;
if (this.liveOutput.length > MAX_LIVE_OUTPUT_CHARS) {
this.liveOutput = `[...truncated]\n${this.liveOutput.slice(
this.liveOutput.length - MAX_LIVE_OUTPUT_CHARS,
)}`;
}
this.rebuildContent();
this.notifySnapshotChange();
this.ui?.requestRender();
}
dispose(): void {
this.stopStreamingProgressTimer();
this.stopSubagentElapsedTimer();
@ -1179,6 +1196,24 @@ export class ToolCallComponent extends Container {
this.ui?.requestRender();
}
appendSubToolLiveOutput(id: string, text: string): 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 ?? '';
let output = existingOutput + text;
if (output.length > MAX_LIVE_OUTPUT_CHARS) {
output = `[...truncated]\n${output.slice(output.length - MAX_LIVE_OUTPUT_CHARS)}`;
}
this.upsertSubToolActivity(id, name, args, activity?.phase ?? 'ongoing', output);
this.rebuildContent();
this.notifySnapshotChange();
this.ui?.requestRender();
}
finishSubToolCall(result: {
tool_call_id: string;
output: string;
@ -1300,6 +1335,7 @@ export class ToolCallComponent extends Container {
this.children.pop();
}
this.buildProgressBlock();
this.buildLiveOutputBlock();
this.buildContent();
this.buildSubagentBlock();
}
@ -1311,6 +1347,7 @@ export class ToolCallComponent extends Container {
this.buildCallPreview();
this.callPreviewEndIndex = this.children.length;
this.buildProgressBlock();
this.buildLiveOutputBlock();
this.buildContent();
this.buildSubagentBlock();
}
@ -1345,6 +1382,24 @@ export class ToolCallComponent extends Container {
}
}
private buildLiveOutputBlock(): void {
if (this.result !== undefined) return;
if (this.liveOutput.length === 0) return;
this.addChild(
new ShellExecutionComponent({
result: {
tool_call_id: this.toolCall.id,
output: this.liveOutput,
is_error: false,
},
expanded: this.expanded,
resultPreviewLines: RESULT_PREVIEW_LINES,
tailOutput: true,
expandHint: false,
}),
);
}
private buildSubagentBlock(): void {
if (
this.subagentAgentId === undefined &&
@ -1612,7 +1667,6 @@ export class ToolCallComponent extends Container {
}
private addSubToolOutputPreview(activity: SubToolActivity): void {
if (activity.phase === 'ongoing') return;
const output = activity.output;
if (output === undefined || output.trim().length === 0) return;
// Mirror the main agent: Bash and any tool without a dedicated renderer
@ -1628,6 +1682,7 @@ export class ToolCallComponent extends Container {
isError: activity.phase === 'failed',
maxLines: RESULT_PREVIEW_LINES,
indent: SUBAGENT_SUBTOOL_OUTPUT_INDENT,
tail: activity.phase === 'ongoing',
}),
);
}

View file

@ -30,6 +30,7 @@ export class TruncatedOutputComponent implements Component {
private readonly maxLines: number;
private readonly indent: number;
private readonly expandHint: boolean;
private readonly tail: boolean;
constructor(
output: string,
@ -41,12 +42,16 @@ export class TruncatedOutputComponent implements Component {
// When false, the truncation footer omits the "ctrl+o to expand" promise
// (for contexts whose output is fixed-truncated and never expands).
expandHint?: boolean;
// When true, collapsed rendering keeps the latest visual rows instead of
// the first rows. This is useful for live output from a running command.
tail?: boolean;
},
) {
this.expanded = options.expanded;
this.maxLines = options.maxLines ?? PREVIEW_LINES;
this.indent = options.indent ?? DEFAULT_INDENT;
this.expandHint = options.expandHint ?? true;
this.tail = options.tail ?? false;
const cleaned = trimTrailingEmptyLines(output.split('\n')).join('\n');
this.textComponent = new Text(
options.isError ? currentTheme.fg('error', cleaned) : currentTheme.dim(cleaned),
@ -67,8 +72,16 @@ export class TruncatedOutputComponent implements Component {
return contentLines;
}
const shown = contentLines.slice(0, this.maxLines);
const remaining = contentLines.length - this.maxLines;
if (this.tail) {
const shown = contentLines.slice(contentLines.length - this.maxLines);
return [
' '.repeat(this.indent) + currentTheme.dim(`... (${String(remaining)} earlier lines)`),
...shown,
];
}
const shown = contentLines.slice(0, this.maxLines);
const hint = this.expandHint
? `... (${String(remaining)} more lines, ctrl+o to expand)`
: `... (${String(remaining)} more lines)`;

View file

@ -515,12 +515,17 @@ export class SessionEventHandler {
}
private handleToolProgress(event: ToolProgressEvent): void {
if (event.update.kind !== 'status') return;
const text = event.update.text;
if (text === undefined || text.length === 0) return;
const tc = this.host.streamingUI.getToolComponent(event.toolCallId);
if (tc === undefined) return;
tc.appendProgress(text);
if (event.update.kind === 'status') {
tc.appendProgress(text);
return;
}
if (event.update.kind === 'stdout' || event.update.kind === 'stderr') {
tc.appendLiveOutput(text);
}
}
private handleToolResult(event: ToolResultEvent): void {

View file

@ -107,6 +107,12 @@ export class SubAgentEventHandler {
name: event.name,
argumentsPart: event.argumentsPart ?? null,
});
} else if (
event.type === 'tool.progress' &&
(event.update.kind === 'stdout' || event.update.kind === 'stderr') &&
event.update.text !== undefined
) {
toolCall.appendSubToolLiveOutput(`${childAgentId}:${event.toolCallId}`, event.update.text);
} else if (event.type === 'tool.result') {
toolCall.finishSubToolCall({
tool_call_id: `${childAgentId}:${event.toolCallId}`,

View file

@ -78,6 +78,48 @@ describe('ToolCallComponent', () => {
expect(expanded).not.toContain('ctrl+o to expand');
});
it('renders live Bash output while the command is running', () => {
const component = new ToolCallComponent(
{
id: 'call_shell_live',
name: 'Bash',
args: { command: 'printf output' },
},
undefined,
);
component.appendLiveOutput('line1\n');
component.appendLiveOutput('line2\n');
const out = strip(component.render(100).join('\n'));
expect(out).toContain('Using Bash');
expect(out).toContain('line1');
expect(out).toContain('line2');
});
it('clears live Bash output when the final result arrives', () => {
const component = new ToolCallComponent(
{
id: 'call_shell_live_done',
name: 'Bash',
args: { command: 'printf output' },
},
undefined,
);
component.appendLiveOutput('streamed-only\n');
component.setResult({
tool_call_id: 'call_shell_live_done',
output: 'final-only\n',
is_error: false,
});
const out = strip(component.render(100).join('\n'));
expect(out).toContain('Used Bash');
expect(out).toContain('final-only');
expect(out).not.toContain('streamed-only');
});
it('hides tool output bodies that start with a <system tag', () => {
const reminderOutput =
'<system-reminder>\nThe task tools have not been used recently.\n</system-reminder>';

View file

@ -44,7 +44,7 @@ File tools handle reading, writing, and searching the local filesystem — the f
- `description`: background task description; required when `run_in_background=true`
- `disable_timeout`: whether to remove the timeout limit for background tasks
Foreground mode blocks the current turn until the command completes or times out; background mode returns a task ID immediately and automatically notifies the Agent when the task finishes. stdin is always closed — interactive commands receive EOF immediately. A two-phase termination strategy (SIGTERM → 5-second grace period → SIGKILL) ensures reliable process cleanup after a timeout. On Windows, Git Bash is used by default.
Foreground mode blocks the current turn until the command completes or times out, and the TUI streams stdout and stderr into the running `Bash` tool card while the command is still active. Background mode returns a task ID immediately and automatically notifies the Agent when the task finishes. stdin is always closed — interactive commands receive EOF immediately. A two-phase termination strategy (SIGTERM → 5-second grace period → SIGKILL) ensures reliable process cleanup after a timeout. On Windows, Git Bash is used by default.
## Web Tools

View file

@ -44,7 +44,7 @@
- `description`:后台任务描述,`run_in_background=true` 时必填
- `disable_timeout`:后台任务是否取消超时限制
前台模式会阻塞当前轮次,直到命令结束或超时;后台模式立即返回任务 ID任务结束时自动通知 Agent。stdin 始终被关闭,交互式命令会立即收到 EOF。两阶段终止策略SIGTERM → 5 秒宽限期 → SIGKILL确保超时后进程可靠结束。Windows 平台默认使用 Git Bash。
前台模式会阻塞当前轮次,直到命令结束或超时;命令运行期间TUI 会把 stdout 和 stderr 流式显示在正在运行的 `Bash` 工具卡片中。后台模式立即返回任务 ID任务结束时自动通知 Agent。stdin 始终被关闭,交互式命令会立即收到 EOF。两阶段终止策略SIGTERM → 5 秒宽限期 → SIGKILL确保超时后进程可靠结束。Windows 平台默认使用 Git Bash。
## 网络类

View file

@ -31,7 +31,7 @@ import { z } from 'zod';
import { ProcessBackgroundTask, type BackgroundManager } from '../../../agent/background';
import type { BuiltinTool } from '../../../agent/tool';
import type { ExecutableToolResult, ToolExecution } from '../../../loop/types';
import type { ExecutableToolResult, ToolExecution, ToolUpdate } from '../../../loop/types';
import { renderPrompt } from '../../../utils/render-prompt';
import { toInputJsonSchema } from '../../support/input-schema';
import { literalRulePattern, matchesGlobRuleSubject } from '../../support/rule-match';
@ -181,7 +181,7 @@ export class BashTool implements BuiltinTool<BashInput> {
},
approvalRule: literalRulePattern(this.name, args.command),
matchesRule: (ruleArgs) => matchesGlobRuleSubject(ruleArgs, args.command),
execute: ({ signal }) => this.execution(args, signal),
execute: ({ signal, onUpdate }) => this.execution(args, signal, onUpdate),
};
}
@ -212,7 +212,11 @@ export class BashTool implements BuiltinTool<BashInput> {
return this.kaos.execWithEnv(shellArgs, mergedEnv);
}
private async execution(args: BashInput, signal: AbortSignal): Promise<ExecutableToolResult> {
private async execution(
args: BashInput,
signal: AbortSignal,
onUpdate?: ((update: ToolUpdate) => void) | undefined,
): Promise<ExecutableToolResult> {
if (signal.aborted) {
return { isError: true, output: 'Aborted before command started' };
}
@ -312,8 +316,8 @@ export class BashTool implements BuiltinTool<BashInput> {
const isTerminating = (): boolean => timedOut || aborted || killed;
const [, exitCode] = await Promise.all([
Promise.all([
readStreamIntoBuilder(proc.stdout, builder, isTerminating),
readStreamIntoBuilder(proc.stderr, builder, isTerminating),
readStreamIntoBuilder(proc.stdout, builder, 'stdout', onUpdate, isTerminating),
readStreamIntoBuilder(proc.stderr, builder, 'stderr', onUpdate, isTerminating),
]),
proc.wait(),
]);
@ -439,6 +443,8 @@ export class BashTool implements BuiltinTool<BashInput> {
async function readStreamIntoBuilder(
stream: Readable,
builder: ToolResultBuilder,
kind: 'stdout' | 'stderr',
onUpdate?: ((update: ToolUpdate) => void) | undefined,
suppressPrematureClose?: () => boolean,
): Promise<void> {
const decoder = new StringDecoder('utf8');
@ -446,14 +452,18 @@ async function readStreamIntoBuilder(
for await (const chunk of stream) {
const buf: Buffer =
typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : (chunk as Buffer);
builder.write(decoder.write(buf));
const text = decoder.write(buf);
if (text.length > 0) onUpdate?.({ kind, text });
builder.write(text);
}
} catch (error) {
if (!isPrematureCloseError(error) || suppressPrematureClose?.() !== true) {
throw error;
}
}
builder.write(decoder.end());
const trailing = decoder.end();
if (trailing.length > 0) onUpdate?.({ kind, text: trailing });
builder.write(trailing);
}
function isPrematureCloseError(error: unknown): boolean {

View file

@ -119,6 +119,7 @@ it('runs an agent turn through builtin tool approval and execution', async () =>
[wire] permission.record_approval_result { "turnId": 0, "toolCallId": "call_bash", "toolName": "Bash", "action": "Running: printf lookup-result", "result": { "decision": "approved", "selectedLabel": "approve" }, "time": "<time>" }
[wire] context.append_loop_event { "event": { "type": "tool.call", "uuid": "call_bash", "turnId": "0", "step": 1, "stepUuid": "<uuid-1>", "toolCallId": "call_bash", "name": "Bash", "args": { "command": "printf lookup-result", "timeout": 60 }, "description": "Running: printf lookup-result", "display": { "kind": "command", "command": "printf lookup-result", "cwd": "<cwd>", "language": "bash" } }, "time": "<time>" }
[emit] tool.call.started { "turnId": 0, "toolCallId": "call_bash", "name": "Bash", "args": { "command": "printf lookup-result", "timeout": 60 }, "description": "Running: printf lookup-result", "display": { "kind": "command", "command": "printf lookup-result", "cwd": "<cwd>", "language": "bash" } }
[emit] tool.progress { "turnId": 0, "toolCallId": "call_bash", "update": { "kind": "stdout", "text": "lookup-result" } }
[wire] context.append_loop_event { "event": { "type": "tool.result", "parentUuid": "call_bash", "toolCallId": "call_bash", "result": { "output": "lookup-result" } }, "time": "<time>" }
[emit] tool.result { "turnId": 0, "toolCallId": "call_bash", "output": "lookup-result" }
[wire] context.append_loop_event { "event": { "type": "step.end", "uuid": "<uuid-1>", "turnId": "0", "step": 1, "usage": { "inputOther": 11, "output": 22, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "tool_use" }, "time": "<time>" }

View file

@ -144,6 +144,7 @@ describe('Agent config', () => {
[wire] tools.set_active_tools { "names": [], "time": "<time>" }
[wire] context.append_loop_event { "event": { "type": "tool.call", "uuid": "call_bash", "turnId": "0", "step": 1, "stepUuid": "<uuid-1>", "toolCallId": "call_bash", "name": "Bash", "args": { "command": "printf original-result", "timeout": 60 }, "description": "Running: printf original-result", "display": { "kind": "command", "command": "printf original-result", "cwd": "<cwd>", "language": "bash" } }, "time": "<time>" }
[emit] tool.call.started { "turnId": 0, "toolCallId": "call_bash", "name": "Bash", "args": { "command": "printf original-result", "timeout": 60 }, "description": "Running: printf original-result", "display": { "kind": "command", "command": "printf original-result", "cwd": "<cwd>", "language": "bash" } }
[emit] tool.progress { "turnId": 0, "toolCallId": "call_bash", "update": { "kind": "stdout", "text": "original-result" } }
[wire] context.append_loop_event { "event": { "type": "tool.result", "parentUuid": "call_bash", "toolCallId": "call_bash", "result": { "output": "original-result" } }, "time": "<time>" }
[emit] tool.result { "turnId": 0, "toolCallId": "call_bash", "output": "original-result" }
[wire] context.append_loop_event { "event": { "type": "step.end", "uuid": "<uuid-1>", "turnId": "0", "step": 1, "usage": { "inputOther": 9, "output": 23, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "tool_use" }, "time": "<time>" }

View file

@ -58,6 +58,7 @@ describe('Agent permission', () => {
[wire] context.append_loop_event { "event": { "type": "content.part", "uuid": "<uuid-2>", "turnId": "0", "step": 1, "stepUuid": "<uuid-1>", "part": { "type": "text", "text": "Running without asking." } }, "time": "<time>" }
[wire] context.append_loop_event { "event": { "type": "tool.call", "uuid": "call_bash", "turnId": "0", "step": 1, "stepUuid": "<uuid-1>", "toolCallId": "call_bash", "name": "Bash", "args": { "command": "printf permission-output", "timeout": 60 }, "description": "Running: printf permission-output", "display": { "kind": "command", "command": "printf permission-output", "cwd": "<cwd>", "language": "bash" } }, "time": "<time>" }
[emit] tool.call.started { "turnId": 0, "toolCallId": "call_bash", "name": "Bash", "args": { "command": "printf permission-output", "timeout": 60 }, "description": "Running: printf permission-output", "display": { "kind": "command", "command": "printf permission-output", "cwd": "<cwd>", "language": "bash" } }
[emit] tool.progress { "turnId": 0, "toolCallId": "call_bash", "update": { "kind": "stdout", "text": "auto-output" } }
[wire] context.append_loop_event { "event": { "type": "tool.result", "parentUuid": "call_bash", "toolCallId": "call_bash", "result": { "output": "auto-output" } }, "time": "<time>" }
[emit] tool.result { "turnId": 0, "toolCallId": "call_bash", "output": "auto-output" }
[wire] context.append_loop_event { "event": { "type": "step.end", "uuid": "<uuid-1>", "turnId": "0", "step": 1, "usage": { "inputOther": 91, "output": 25, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "tool_use" }, "time": "<time>" }
@ -113,6 +114,7 @@ describe('Agent permission', () => {
[wire] context.append_loop_event { "event": { "type": "content.part", "uuid": "<uuid-2>", "turnId": "0", "step": 1, "stepUuid": "<uuid-1>", "part": { "type": "text", "text": "Running in yolo mode." } }, "time": "<time>" }
[wire] context.append_loop_event { "event": { "type": "tool.call", "uuid": "call_bash", "turnId": "0", "step": 1, "stepUuid": "<uuid-1>", "toolCallId": "call_bash", "name": "Bash", "args": { "command": "printf permission-output", "timeout": 60 }, "description": "Running: printf permission-output", "display": { "kind": "command", "command": "printf permission-output", "cwd": "<cwd>", "language": "bash" } }, "time": "<time>" }
[emit] tool.call.started { "turnId": 0, "toolCallId": "call_bash", "name": "Bash", "args": { "command": "printf permission-output", "timeout": 60 }, "description": "Running: printf permission-output", "display": { "kind": "command", "command": "printf permission-output", "cwd": "<cwd>", "language": "bash" } }
[emit] tool.progress { "turnId": 0, "toolCallId": "call_bash", "update": { "kind": "stdout", "text": "yolo-output" } }
[wire] context.append_loop_event { "event": { "type": "tool.result", "parentUuid": "call_bash", "toolCallId": "call_bash", "result": { "output": "yolo-output" } }, "time": "<time>" }
[emit] tool.result { "turnId": 0, "toolCallId": "call_bash", "output": "yolo-output" }
[wire] context.append_loop_event { "event": { "type": "step.end", "uuid": "<uuid-1>", "turnId": "0", "step": 1, "usage": { "inputOther": 7, "output": 25, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "tool_use" }, "time": "<time>" }

View file

@ -448,6 +448,7 @@ describe('plan allows safe tool flow', () => {
[wire] context.append_loop_event { "event": { "type": "content.part", "uuid": "<uuid-2>", "turnId": "0", "step": 1, "stepUuid": "<uuid-1>", "part": { "type": "text", "text": "I will inspect safely." } }, "time": "<time>" }
[wire] context.append_loop_event { "event": { "type": "tool.call", "uuid": "call_bash", "turnId": "0", "step": 1, "stepUuid": "<uuid-1>", "toolCallId": "call_bash", "name": "Bash", "args": { "command": "printf plan-safe", "timeout": 60 }, "description": "Running: printf plan-safe", "display": { "kind": "command", "command": "printf plan-safe", "cwd": "<cwd>", "language": "bash" } }, "time": "<time>" }
[emit] tool.call.started { "turnId": 0, "toolCallId": "call_bash", "name": "Bash", "args": { "command": "printf plan-safe", "timeout": 60 }, "description": "Running: printf plan-safe", "display": { "kind": "command", "command": "printf plan-safe", "cwd": "<cwd>", "language": "bash" } }
[emit] tool.progress { "turnId": 0, "toolCallId": "call_bash", "update": { "kind": "stdout", "text": "plan-safe" } }
[wire] context.append_loop_event { "event": { "type": "tool.result", "parentUuid": "call_bash", "toolCallId": "call_bash", "result": { "output": "plan-safe" } }, "time": "<time>" }
[emit] tool.result { "turnId": 0, "toolCallId": "call_bash", "output": "plan-safe" }
[wire] context.append_loop_event { "event": { "type": "step.end", "uuid": "<uuid-1>", "turnId": "0", "step": 1, "usage": { "inputOther": 536, "output": 23, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "tool_use" }, "time": "<time>" }
@ -501,6 +502,7 @@ describe('plan mode Bash ordinary permission behavior', () => {
[wire] context.append_loop_event { "event": { "type": "content.part", "uuid": "<uuid-2>", "turnId": "0", "step": 1, "stepUuid": "<uuid-1>", "part": { "type": "text", "text": "I will mutate a file." } }, "time": "<time>" }
[wire] context.append_loop_event { "event": { "type": "tool.call", "uuid": "call_bash", "turnId": "0", "step": 1, "stepUuid": "<uuid-1>", "toolCallId": "call_bash", "name": "Bash", "args": { "command": "rm forbidden.txt", "timeout": 60 }, "description": "Running: rm forbidden.txt", "display": { "kind": "command", "command": "rm forbidden.txt", "cwd": "<cwd>", "language": "bash" } }, "time": "<time>" }
[emit] tool.call.started { "turnId": 0, "toolCallId": "call_bash", "name": "Bash", "args": { "command": "rm forbidden.txt", "timeout": 60 }, "description": "Running: rm forbidden.txt", "display": { "kind": "command", "command": "rm forbidden.txt", "cwd": "<cwd>", "language": "bash" } }
[emit] tool.progress { "turnId": 0, "toolCallId": "call_bash", "update": { "kind": "stdout", "text": "removed" } }
[wire] context.append_loop_event { "event": { "type": "tool.result", "parentUuid": "call_bash", "toolCallId": "call_bash", "result": { "output": "removed" } }, "time": "<time>" }
[emit] tool.result { "turnId": 0, "toolCallId": "call_bash", "output": "removed" }
[wire] context.append_loop_event { "event": { "type": "step.end", "uuid": "<uuid-1>", "turnId": "0", "step": 1, "usage": { "inputOther": 533, "output": 23, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "tool_use" }, "time": "<time>" }

View file

@ -1565,6 +1565,7 @@ describe('Agent turn flow', () => {
[wire] permission.record_approval_result { "turnId": 0, "toolCallId": "call_bash", "toolName": "Bash", "action": "Running: printf approved", "result": { "decision": "approved", "selectedLabel": "approve" }, "time": "<time>" }
[wire] context.append_loop_event { "event": { "type": "tool.call", "uuid": "call_bash", "turnId": "0", "step": 1, "stepUuid": "<uuid-1>", "toolCallId": "call_bash", "name": "Bash", "args": { "command": "printf approved", "timeout": 60 }, "description": "Running: printf approved", "display": { "kind": "command", "command": "printf approved", "cwd": "<cwd>", "language": "bash" } }, "time": "<time>" }
[emit] tool.call.started { "turnId": 0, "toolCallId": "call_bash", "name": "Bash", "args": { "command": "printf approved", "timeout": 60 }, "description": "Running: printf approved", "display": { "kind": "command", "command": "printf approved", "cwd": "<cwd>", "language": "bash" } }
[emit] tool.progress { "turnId": 0, "toolCallId": "call_bash", "update": { "kind": "stdout", "text": "approved" } }
[wire] context.append_loop_event { "event": { "type": "tool.result", "parentUuid": "call_bash", "toolCallId": "call_bash", "result": { "output": "approved" } }, "time": "<time>" }
[emit] tool.result { "turnId": 0, "toolCallId": "call_bash", "output": "approved" }
[wire] context.append_loop_event { "event": { "type": "step.end", "uuid": "<uuid-1>", "turnId": "0", "step": 1, "usage": { "inputOther": 7, "output": 22, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "tool_use" }, "time": "<time>" }

View file

@ -35,6 +35,18 @@ function fakeProcess(): KaosProcess {
};
}
function fakeProcessWithOutput(stdout: Readable, stderr: Readable): KaosProcess {
return {
stdin: { end: vi.fn(), write: vi.fn() } as unknown as Writable,
stdout,
stderr,
pid: 321,
exitCode: 0,
wait: vi.fn(async () => 0),
kill: vi.fn(async () => {}),
};
}
const signal = new AbortController().signal;
function captureCommandRewrite(
@ -132,3 +144,30 @@ describe('shell command nul-redirect — non-Windows passthrough', () => {
expect(rewritten).toBe(command);
});
});
describe('BashTool streaming output updates', () => {
it('emits stdout and stderr chunks while preserving the final output', async () => {
const proc = fakeProcessWithOutput(
Readable.from([Buffer.from('out-1\n'), Buffer.from('out-2')]),
Readable.from([Buffer.from('err-1\n')]),
);
const execWithEnv = vi.fn().mockResolvedValue(proc);
const onUpdate = vi.fn();
const tool = new BashTool(createFakeKaos({ execWithEnv, osEnv: linuxEnv }), '/work');
const result = await executeTool(tool, {
turnId: '0',
toolCallId: 'tc_stream',
args: { command: 'printf output' },
signal,
onUpdate,
});
expect(result.output).toContain('out-1\n');
expect(result.output).toContain('out-2');
expect(result.output).toContain('err-1\n');
expect(onUpdate).toHaveBeenCalledWith({ kind: 'stdout', text: 'out-1\n' });
expect(onUpdate).toHaveBeenCalledWith({ kind: 'stdout', text: 'out-2' });
expect(onUpdate).toHaveBeenCalledWith({ kind: 'stderr', text: 'err-1\n' });
});
});