feat: stream workdir command output to consoles

This commit is contained in:
2026-08-20 10:32:58 +09:00
parent 2315c69f0a
commit 92594488da
15 changed files with 1332 additions and 76 deletions
+12 -2
View File
@@ -22,6 +22,16 @@ export type Permission = "read" | "write";
export type InFlightToolCallState = "pending" | "streaming_args" | "done";
export type CommandStatus = "running" | "completed" | "failed" | "timed_out" | "cancelled";
export type CommandStream = "stdout" | "stderr";
export type CommandStreamSlice = { start_offset: number, end_offset: number, content: string, truncated: boolean, };
export type CommandSnapshot = { command_id: string, tool_call_id: string | null, status: CommandStatus, stdout: CommandStreamSlice, stderr: CommandStreamSlice, exit_code: number | null, };
export type CommandEvent = { "kind": "started", command_id: string, tool_call_id: string | null, } | { "kind": "output", command_id: string, stream: CommandStream, start_offset: number, end_offset: number, content: string, } | { "kind": "terminal", command_id: string, status: CommandStatus, exit_code: number | null, };
export type ScopeRule = {
/**
* Target path. Must be absolute by the time a `Scope` is built from
@@ -51,7 +61,7 @@ export type RewindSummary = { truncated_to_entries: number, discarded_entries: n
export type InFlightBlock = { "kind": "text", text: string, finished?: boolean, } | { "kind": "thinking", text: string, finished?: boolean, } | { "kind": "tool_call", id: string, name: string, args: string, state?: InFlightToolCallState, };
export type InFlightSnapshot = { blocks?: Array<InFlightBlock>, };
export type InFlightSnapshot = { blocks?: Array<InFlightBlock>, commands?: Array<CommandSnapshot>, };
export type InternalWorkerKind = "sub_worker";
@@ -178,4 +188,4 @@ in_flight?: InFlightSnapshot,
* Parent-owned Internal Worker sessions visible to this client.
* Service-private Internal Workers are deliberately excluded.
*/
internal_workers?: Array<InternalWorkerSnapshot>, } } | { "event": "internal_worker", "data": { worker: InternalWorkerRef, revision: number, event: Event, } } | { "event": "segment_rotated", "data": { entry: unknown, } } | { "event": "status", "data": { status: WorkerStatus, } } | { "event": "completions", "data": { kind: CompletionKind, entries: Array<CompletionEntry>, } } | { "event": "rewind_targets", "data": { head_entries: number, targets: Array<RewindTarget>, } } | { "event": "rewind_applied", "data": { entries: Array<unknown>, input: Array<Segment>, summary: RewindSummary, } } | { "event": "workers_listed", "data": { workers: unknown, } } | { "event": "worker_restored", "data": { result: unknown, } } | { "event": "peer_registered", "data": { result: unknown, } } | { "event": "alert", "data": Alert } | { "event": "memory_worker", "data": MemoryWorkerEvent } | { "event": "compact_start" } | { "event": "compact_done", "data": { new_segment_id: string, } } | { "event": "compact_failed", "data": { error: string, } } | { "event": "shutdown" };
internal_workers?: Array<InternalWorkerSnapshot>, } } | { "event": "internal_worker", "data": { worker: InternalWorkerRef, revision: number, event: Event, } } | { "event": "segment_rotated", "data": { entry: unknown, } } | { "event": "status", "data": { status: WorkerStatus, } } | { "event": "command", "data": { event: CommandEvent, } } | { "event": "completions", "data": { kind: CompletionKind, entries: Array<CompletionEntry>, } } | { "event": "rewind_targets", "data": { head_entries: number, targets: Array<RewindTarget>, } } | { "event": "rewind_applied", "data": { entries: Array<unknown>, input: Array<Segment>, summary: RewindSummary, } } | { "event": "workers_listed", "data": { workers: unknown, } } | { "event": "worker_restored", "data": { result: unknown, } } | { "event": "peer_registered", "data": { result: unknown, } } | { "event": "alert", "data": Alert } | { "event": "memory_worker", "data": MemoryWorkerEvent } | { "event": "compact_start" } | { "event": "compact_done", "data": { new_segment_id: string, } } | { "event": "compact_failed", "data": { error: string, } } | { "event": "shutdown" };
@@ -334,6 +334,123 @@ Deno.test("projectConsole groups tool call lifecycle into one Call block", () =>
);
});
Deno.test("projectConsole streams distinct Bash stdout and stderr through terminal status", () => {
const projection = projectConsole([
{
eventId: "command-tool",
event: {
event: "tool_call_done",
data: {
id: "bash-stream",
name: "Bash",
arguments: JSON.stringify({ command: "long-command" }),
},
} satisfies Event,
},
{
eventId: "command-started",
event: {
event: "command",
data: {
event: {
kind: "started",
command_id: "command-1",
tool_call_id: "bash-stream",
},
},
} satisfies Event,
},
{
eventId: "command-stdout",
event: {
event: "command",
data: {
event: {
kind: "output",
command_id: "command-1",
stream: "stdout",
start_offset: 0,
end_offset: 6,
content: "ready\n",
},
},
} satisfies Event,
},
{
eventId: "command-stderr",
event: {
event: "command",
data: {
event: {
kind: "output",
command_id: "command-1",
stream: "stderr",
start_offset: 0,
end_offset: 5,
content: "warn\n",
},
},
} satisfies Event,
},
{
eventId: "command-terminal",
event: {
event: "command",
data: {
event: {
kind: "terminal",
command_id: "command-1",
status: "failed",
exit_code: 7,
},
},
} satisfies Event,
},
]);
const [line] = projection.lines.filter((line) => line.kind === "tool");
assert(line.body.includes("Bash — failed (exit 7)"), line.body);
assert(line.body.includes("stdout:\nready\n"), line.body);
assert(line.body.includes("stderr:\nwarn\n"), line.body);
assertEquals(line.streaming, false);
assertEquals(line.error, true);
});
Deno.test("snapshot restores bounded in-flight Bash command output", () => {
const snapshot = snapshotEvent("/repo");
if (snapshot.event !== "snapshot") throw new Error("snapshot fixture expected");
snapshot.data.status = "running";
snapshot.data.in_flight = {
blocks: [{
kind: "tool_call",
id: "bash-snapshot",
name: "Bash",
args: JSON.stringify({ command: "slow" }),
state: "done",
}],
commands: [{
command_id: "command-2",
tool_call_id: "bash-snapshot",
status: "running",
stdout: {
start_offset: 1024,
end_offset: 1031,
content: "tail\n",
truncated: true,
},
stderr: { start_offset: 0, end_offset: 0, content: "", truncated: false },
exit_code: null,
}],
};
const projection = projectConsole([{ eventId: "snapshot-command", event: snapshot }]);
const [line] = projection.lines.filter((line) => line.kind === "tool");
assert(line.body.includes("Bash — running…"), line.body);
assert(line.body.includes("[stdout tail; earlier output omitted]"), line.body);
assert(line.body.includes("stdout:\ntail\n"), line.body);
assertEquals(line.streaming, true);
});
Deno.test("projectConsole caps default tool request and result previews", () => {
const projection = projectConsole([
{
@@ -1,5 +1,8 @@
import type {
Alert,
CommandEvent,
CommandSnapshot,
CommandStreamSlice,
Event as ProtocolEvent,
InFlightBlock,
InFlightToolCallState,
@@ -42,6 +45,7 @@ type ToolCallView = {
output?: string | null;
isError?: boolean;
cwd?: string | null;
command?: CommandSnapshot;
};
export type ConsoleDiffLine = {
@@ -239,6 +243,126 @@ function appendSnapshotInFlightLines(
});
}
const COMMAND_STREAM_DISPLAY_BYTES = 32 * 1024;
function appendSnapshotCommands(
projection: ConsoleProjection,
commands: CommandSnapshot[],
eventId: string,
): void {
commands.forEach((command) => upsertCommandSnapshot(projection, eventId, command));
}
function upsertCommandSnapshot(
projection: ConsoleProjection,
eventId: string,
command: CommandSnapshot,
): void {
const toolCallId = command.tool_call_id ?? `command:${command.command_id}`;
const existingIndex = findToolCallLineIndex(projection, toolCallId);
const existing = existingIndex >= 0
? projection.lines[existingIndex].toolCall
: undefined;
upsertToolCall(projection, eventId, toolCallId, {
name: existing?.name ?? "Bash",
state: existing?.state ?? "running",
command,
});
}
function applyCommandEvent(
projection: ConsoleProjection,
eventId: string,
event: CommandEvent,
): void {
if (event.kind === "started") {
upsertCommandSnapshot(projection, eventId, {
command_id: event.command_id,
tool_call_id: event.tool_call_id,
status: "running",
stdout: emptyCommandStream(),
stderr: emptyCommandStream(),
exit_code: null,
});
return;
}
const index = projection.lines.findIndex((line) =>
line.toolCall?.command?.command_id === event.command_id
);
if (index < 0) {
if (event.kind === "output") {
const stream = commandStreamFromEvent(event);
upsertCommandSnapshot(projection, eventId, {
command_id: event.command_id,
tool_call_id: null,
status: "running",
stdout: event.stream === "stdout" ? stream : emptyCommandStream(),
stderr: event.stream === "stderr" ? stream : emptyCommandStream(),
exit_code: null,
});
}
return;
}
const existing = projection.lines[index].toolCall!.command!;
if (event.kind === "terminal") {
upsertCommandSnapshot(projection, eventId, {
...existing,
status: event.status,
exit_code: event.exit_code,
});
return;
}
const updatedStream = appendCommandStream(
event.stream === "stdout" ? existing.stdout : existing.stderr,
event.start_offset,
event.end_offset,
event.content,
);
upsertCommandSnapshot(projection, eventId, {
...existing,
stdout: event.stream === "stdout" ? updatedStream : existing.stdout,
stderr: event.stream === "stderr" ? updatedStream : existing.stderr,
});
}
function emptyCommandStream(): CommandStreamSlice {
return { start_offset: 0, end_offset: 0, content: "", truncated: false };
}
function commandStreamFromEvent(
event: Extract<CommandEvent, { kind: "output" }>,
): CommandStreamSlice {
return appendCommandStream(
emptyCommandStream(),
event.start_offset,
event.end_offset,
event.content,
);
}
function appendCommandStream(
existing: CommandStreamSlice,
startOffset: number,
endOffset: number,
content: string,
): CommandStreamSlice {
if (endOffset <= existing.end_offset) return existing;
const contiguous = startOffset === existing.end_offset;
const combined = contiguous ? `${existing.content}${content}` : content;
const tail = combined.length > COMMAND_STREAM_DISPLAY_BYTES
? combined.slice(-COMMAND_STREAM_DISPLAY_BYTES)
: combined;
return {
start_offset: endOffset - tail.length,
end_offset: endOffset,
content: tail,
truncated: existing.truncated || !contiguous || tail.length < combined.length ||
startOffset > 0,
};
}
function projectInternalWorkerSnapshot(
snapshot: InternalWorkerSnapshot,
eventId: string,
@@ -256,6 +380,11 @@ function projectInternalWorkerSnapshot(
`${eventId}:internal:${snapshot.worker.session_id}:in-flight`,
cwd,
);
appendSnapshotCommands(
console,
snapshot.in_flight?.commands ?? [],
`${eventId}:internal:${snapshot.worker.session_id}:command`,
);
if (snapshot.error) {
console.lines.push({
id: `${eventId}:internal:${snapshot.worker.session_id}:error`,
@@ -403,6 +532,11 @@ export function applyProtocolEvent(
`${envelope.eventId}:snapshot-in-flight`,
next.cwd,
);
appendSnapshotCommands(
next,
event.data.in_flight?.commands ?? [],
`${envelope.eventId}:snapshot-command`,
);
next.internalWorkers = (event.data.internal_workers ?? []).map((worker) =>
projectInternalWorkerSnapshot(worker, envelope.eventId, next.cwd)
);
@@ -436,6 +570,9 @@ export function applyProtocolEvent(
case "status":
next.status = event.data.status;
break;
case "command":
applyCommandEvent(next, envelope.eventId, event.data.event);
break;
case "segment_rotated": {
const retainedErrors = next.lines.filter((line) => line.kind === "error");
const segment = snapshotProjectionFromEntries(
@@ -786,6 +923,10 @@ function refreshedToolLine(item: ConsoleLine): ConsoleLine {
if (!toolCall) {
return item;
}
const commandTerminal = toolCall.command !== undefined &&
toolCall.command.status !== "running";
const commandError = toolCall.command !== undefined &&
["failed", "timed_out", "cancelled"].includes(toolCall.command.status);
return {
...item,
title: item.title.startsWith("Call · Tool result")
@@ -794,8 +935,8 @@ function refreshedToolLine(item: ConsoleLine): ConsoleLine {
body: renderToolCall(toolCall),
detail: toolCallDetail(toolCall),
diff: toolCall.name === "Edit" ? editDiff(toolCall) : undefined,
streaming: !["done", "error"].includes(toolCall.state),
error: toolCall.state === "error",
streaming: !["done", "error"].includes(toolCall.state) && !commandTerminal,
error: toolCall.state === "error" || commandError,
};
}
@@ -1040,9 +1181,37 @@ function renderBashTool(toolCall: ToolCallView): string {
const args = parsedArgs(toolCall);
const command = stringField(args, "command");
return compactLines([
`Bash — ${stateSuffix(toolCall.state)}`,
`Bash — ${commandStateSuffix(toolCall)}`,
command ? `$ ${command}` : argsText(toolCall),
cappedDisplaySection(resultText(toolCall), 10),
["done", "error"].includes(toolCall.state)
? cappedDisplaySection(resultText(toolCall), 10)
: renderLiveCommandOutput(toolCall.command),
]);
}
function commandStateSuffix(toolCall: ToolCallView): string {
const command = toolCall.command;
if (!command) return stateSuffix(toolCall.state);
if (command.status === "completed") {
return command.exit_code === null
? "completed"
: `completed (exit ${command.exit_code})`;
}
if (command.status === "failed") {
return command.exit_code === null ? "failed" : `failed (exit ${command.exit_code})`;
}
if (command.status === "timed_out") return "timed out";
if (command.status === "cancelled") return "cancelled";
return "running…";
}
function renderLiveCommandOutput(command?: CommandSnapshot): string | undefined {
if (!command) return undefined;
return compactLines([
command.stdout.truncated ? "[stdout tail; earlier output omitted]" : undefined,
command.stdout.content ? `stdout:\n${command.stdout.content}` : undefined,
command.stderr.truncated ? "[stderr tail; earlier output omitted]" : undefined,
command.stderr.content ? `stderr:\n${command.stderr.content}` : undefined,
]);
}