feat: stream workdir command output to consoles
This commit is contained in:
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user