mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-10 01:39:25 +00:00
feat: stream shell tool output (#676)
This commit is contained in:
parent
0a3e87f05a
commit
dcf30754d0
16 changed files with 204 additions and 13 deletions
6
.changeset/shell-streaming-output.md
Normal file
6
.changeset/shell-streaming-output.md
Normal 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.
|
||||
|
|
@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)`;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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>';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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。
|
||||
|
||||
## 网络类
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>" }
|
||||
|
|
|
|||
|
|
@ -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>" }
|
||||
|
|
|
|||
|
|
@ -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>" }
|
||||
|
|
|
|||
|
|
@ -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>" }
|
||||
|
|
|
|||
|
|
@ -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>" }
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue