fix: preserve command stream boundaries and lifecycle evidence
This commit is contained in:
@@ -28,9 +28,9 @@ 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 CommandSnapshot = { command_id: string, tool_call_id: string | null, status: CommandStatus, started_at_ms: number, observed_at_ms: number, last_output_at_ms: number | null, 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 CommandEvent = { "kind": "started", command_id: string, tool_call_id: string | null, observed_at_ms: number, } | { "kind": "output", command_id: string, stream: CommandStream, start_offset: number, end_offset: number, content: string, observed_at_ms: number, } | { "kind": "terminal", command_id: string, status: CommandStatus, exit_code: number | null, stdout_end_offset: number, stderr_end_offset: number, observed_at_ms: number, };
|
||||
|
||||
export type ScopeRule = {
|
||||
/**
|
||||
|
||||
@@ -356,6 +356,7 @@ Deno.test("projectConsole streams distinct Bash stdout and stderr through termin
|
||||
kind: "started",
|
||||
command_id: "command-1",
|
||||
tool_call_id: "bash-stream",
|
||||
observed_at_ms: 1000,
|
||||
},
|
||||
},
|
||||
} satisfies Event,
|
||||
@@ -372,6 +373,7 @@ Deno.test("projectConsole streams distinct Bash stdout and stderr through termin
|
||||
start_offset: 0,
|
||||
end_offset: 6,
|
||||
content: "ready\n",
|
||||
observed_at_ms: 1100,
|
||||
},
|
||||
},
|
||||
} satisfies Event,
|
||||
@@ -388,6 +390,7 @@ Deno.test("projectConsole streams distinct Bash stdout and stderr through termin
|
||||
start_offset: 0,
|
||||
end_offset: 5,
|
||||
content: "warn\n",
|
||||
observed_at_ms: 1200,
|
||||
},
|
||||
},
|
||||
} satisfies Event,
|
||||
@@ -402,6 +405,9 @@ Deno.test("projectConsole streams distinct Bash stdout and stderr through termin
|
||||
command_id: "command-1",
|
||||
status: "failed",
|
||||
exit_code: 7,
|
||||
stdout_end_offset: 6,
|
||||
stderr_end_offset: 5,
|
||||
observed_at_ms: 1300,
|
||||
},
|
||||
},
|
||||
} satisfies Event,
|
||||
@@ -410,6 +416,7 @@ Deno.test("projectConsole streams distinct Bash stdout and stderr through termin
|
||||
|
||||
const [line] = projection.lines.filter((line) => line.kind === "tool");
|
||||
assert(line.body.includes("Bash — failed (exit 7)"), line.body);
|
||||
assert(line.body.includes("elapsed 300ms"), line.body);
|
||||
assert(line.body.includes("stdout:\nready\n"), line.body);
|
||||
assert(line.body.includes("stderr:\nwarn\n"), line.body);
|
||||
assertEquals(line.streaming, false);
|
||||
@@ -432,6 +439,9 @@ Deno.test("snapshot restores bounded in-flight Bash command output", () => {
|
||||
command_id: "command-2",
|
||||
tool_call_id: "bash-snapshot",
|
||||
status: "running",
|
||||
started_at_ms: 1000,
|
||||
observed_at_ms: 1250,
|
||||
last_output_at_ms: 1200,
|
||||
stdout: {
|
||||
start_offset: 1024,
|
||||
end_offset: 1031,
|
||||
@@ -446,6 +456,10 @@ Deno.test("snapshot restores bounded in-flight Bash command output", () => {
|
||||
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("elapsed 250ms · last output at +200ms"),
|
||||
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);
|
||||
|
||||
@@ -280,6 +280,9 @@ function applyCommandEvent(
|
||||
command_id: event.command_id,
|
||||
tool_call_id: event.tool_call_id,
|
||||
status: "running",
|
||||
started_at_ms: event.observed_at_ms,
|
||||
observed_at_ms: event.observed_at_ms,
|
||||
last_output_at_ms: null,
|
||||
stdout: emptyCommandStream(),
|
||||
stderr: emptyCommandStream(),
|
||||
exit_code: null,
|
||||
@@ -297,6 +300,9 @@ function applyCommandEvent(
|
||||
command_id: event.command_id,
|
||||
tool_call_id: null,
|
||||
status: "running",
|
||||
started_at_ms: event.observed_at_ms,
|
||||
observed_at_ms: event.observed_at_ms,
|
||||
last_output_at_ms: event.observed_at_ms,
|
||||
stdout: event.stream === "stdout" ? stream : emptyCommandStream(),
|
||||
stderr: event.stream === "stderr" ? stream : emptyCommandStream(),
|
||||
exit_code: null,
|
||||
@@ -311,6 +317,7 @@ function applyCommandEvent(
|
||||
...existing,
|
||||
status: event.status,
|
||||
exit_code: event.exit_code,
|
||||
observed_at_ms: event.observed_at_ms,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -322,6 +329,8 @@ function applyCommandEvent(
|
||||
);
|
||||
upsertCommandSnapshot(projection, eventId, {
|
||||
...existing,
|
||||
observed_at_ms: event.observed_at_ms,
|
||||
last_output_at_ms: event.observed_at_ms,
|
||||
stdout: event.stream === "stdout" ? updatedStream : existing.stdout,
|
||||
stderr: event.stream === "stderr" ? updatedStream : existing.stderr,
|
||||
});
|
||||
@@ -1183,6 +1192,7 @@ function renderBashTool(toolCall: ToolCallView): string {
|
||||
return compactLines([
|
||||
`Bash — ${commandStateSuffix(toolCall)}`,
|
||||
command ? `$ ${command}` : argsText(toolCall),
|
||||
commandTiming(toolCall.command),
|
||||
["done", "error"].includes(toolCall.state)
|
||||
? cappedDisplaySection(resultText(toolCall), 10)
|
||||
: renderLiveCommandOutput(toolCall.command),
|
||||
@@ -1205,6 +1215,25 @@ function commandStateSuffix(toolCall: ToolCallView): string {
|
||||
return "running…";
|
||||
}
|
||||
|
||||
function commandTiming(command?: CommandSnapshot): string | undefined {
|
||||
if (!command) return undefined;
|
||||
const elapsed = Math.max(0, command.observed_at_ms - command.started_at_ms);
|
||||
if (command.status !== "running") return `elapsed ${durationLabel(elapsed)}`;
|
||||
if (command.last_output_at_ms === null) {
|
||||
return `elapsed ${durationLabel(elapsed)} · awaiting first output`;
|
||||
}
|
||||
const lastOutputElapsed = Math.max(
|
||||
0,
|
||||
command.last_output_at_ms - command.started_at_ms,
|
||||
);
|
||||
return `elapsed ${durationLabel(elapsed)} · last output at +${durationLabel(lastOutputElapsed)}`;
|
||||
}
|
||||
|
||||
function durationLabel(milliseconds: number): string {
|
||||
if (milliseconds < 1000) return `${milliseconds}ms`;
|
||||
return `${(milliseconds / 1000).toFixed(milliseconds < 10_000 ? 1 : 0)}s`;
|
||||
}
|
||||
|
||||
function renderLiveCommandOutput(command?: CommandSnapshot): string | undefined {
|
||||
if (!command) return undefined;
|
||||
return compactLines([
|
||||
|
||||
Reference in New Issue
Block a user