fix: preserve console alert order and improve alert colors

This commit is contained in:
2026-10-16 05:28:17 +09:00
parent b67aa2de7d
commit a2cec5afd1
25 changed files with 2098 additions and 90 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
"dev": "deno run -A npm:vite@8.3.2 dev",
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
"check": "deno run -A npm:@sveltejs/kit@3.0.0 sync && deno run -A npm:svelte-check@4.7.6 --tsconfig ./tsconfig.json",
"test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/navigation/deferred.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/api/ticket-operations.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts src/lib/workspace/tickets/ticket-search.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts",
"test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/navigation/deferred.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/api/ticket-operations.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/alert-order.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts src/lib/workspace/tickets/ticket-search.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts",
"test:component": "deno run -A npm:vitest@5.0.3 run --config vitest.config.ts",
"test:browser:empty-workspace": "deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/visual-review/empty-workspace.browser.test.ts",
"test:browser:drive": "deno test --config test/drive-browser/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/drive-browser/drive.browser.test.ts",
File diff suppressed because one or more lines are too long
+12 -2
View File
@@ -4,6 +4,8 @@
export type AlertLevel = "warn" | "error";
export type AlertPosition = { "kind": "start" } | { "kind": "after_entry", entry_id: string, } | { "kind": "after_tool_call", call_id: string, } | { "kind": "after_in_flight", block_id: number, };
export type AlertSource = "worker" | "engine" | "compactor" | "agents_md";
export type CompletionKind = "file" | "feature" | "feature_argument";
@@ -161,7 +163,11 @@ export type InFlightBlock = { "kind": "text", text: string, finished?: boolean,
export type InFlightCompaction = { phase: CompactionPhase, started_at_ms: number, trigger: CompactionTrigger, };
export type InFlightSnapshot = { blocks?: Array<InFlightBlock>, commands?: Array<CommandSnapshot>,
export type InFlightSnapshot = {
/**
* Live-Worker identities parallel to the blocks actually included below.
*/
block_ids?: Array<number>, blocks?: Array<InFlightBlock>, commands?: Array<CommandSnapshot>,
/**
* The currently running compaction, if any.
*
@@ -284,7 +290,11 @@ export type InternalWorkerSnapshot = { worker: InternalWorkerRef, session: Sessi
*/
greeting?: Greeting | null, status: WorkerStatus, error?: string | null, in_flight?: InFlightSnapshot, internal_workers?: Array<InternalWorkerSnapshot>, };
export type Alert = { level: AlertLevel, source: AlertSource, message: string,
export type Alert = {
/**
* Stable only within the emitting live Worker; not durable alert history.
*/
id?: string | null, position?: AlertPosition | null, level: AlertLevel, source: AlertSource, message: string,
/**
* Milliseconds since the Unix epoch.
*/
@@ -1,13 +1,101 @@
// @vitest-environment happy-dom
import type { Event } from "#lib/generated/protocol.ts";
import type { AlertLevel, Event } from "#lib/generated/protocol.ts";
import { cleanup, render, waitFor } from "@testing-library/svelte";
import { afterEach, expect, test } from "vitest";
import { Window } from "happy-dom";
import { compile } from "svelte/compiler";
import ConsoleLineItem from "./ConsoleLineItem.svelte";
import { createConsoleProjector } from "./model.ts";
import componentSource from "./ConsoleLineItem.svelte?raw";
import { type ConsoleLine, createConsoleProjector } from "./model.ts";
afterEach(cleanup);
// Vitest does not attach extracted component CSS. Compile the real scoped CSS
// into an isolated style document so the assertions exercise the cascade.
function styledLine(container: HTMLElement, colorScheme: "light" | "dark") {
const scope = [...container.querySelector("li")!.classList].find((name) =>
name.startsWith("svelte-")
)!;
const componentCss = compile(componentSource, {
filename: "ConsoleLineItem.svelte",
cssHash: () => scope,
}).css!.code;
const browser = new Window({
settings: { device: { prefersColorScheme: colorScheme } },
});
const style = browser.document.createElement("style");
// Happy DOM cannot resolve app.css's @layer/oklch tokens. These sentinel
// values verify that non-Alerts still use the unchanged semantic tokens.
style.textContent =
`:root { --danger: #cc3344; --text-muted: #777777; }\n${componentCss}`;
browser.document.head.append(style);
browser.document.body.innerHTML = container.innerHTML;
return {
heading: browser.getComputedStyle(
browser.document.querySelector(".message-heading")!,
).color,
body: browser.getComputedStyle(
browser.document.querySelector(".console-plain-text")!,
).color,
};
}
for (const colorScheme of ["light", "dark"] as const) {
for (const level of ["warn", "error"] satisfies AlertLevel[]) {
test(`${level} Alert has an orange heading and readable grey body in ${colorScheme}`, () => {
const item: ConsoleLine = {
id: `alert-${level}`,
kind: level === "error" ? "error" : "status",
title: "Alert · engine",
body: "The engine needs attention.",
source: "event",
error: level === "error",
alert: {
level,
source: "engine",
message: "The engine needs attention.",
timestamp_ms: 1,
},
};
const { container } = render(ConsoleLineItem, { item });
expect(container.querySelector("li")?.classList.contains("alert-line"))
.toBe(true);
expect(container.querySelector(".message-heading")?.textContent).toBe(
item.title,
);
expect(container.querySelector(".console-plain-text")?.textContent).toBe(
item.body,
);
expect(styledLine(container, colorScheme)).toEqual({
heading: colorScheme === "light" ? "#a74709" : "#f6a75c",
body: colorScheme === "light" ? "#595959" : "#b8b8b8",
});
});
}
test(`non-Alert errors retain error styling in ${colorScheme}, even with an Alert-like title`, () => {
const { container } = render(ConsoleLineItem, {
item: {
id: "ordinary-error",
kind: "error",
title: "Alert · engine",
body: "An ordinary error, not a protocol Alert.",
source: "event",
error: true,
},
});
expect(container.querySelector("li")?.classList.contains("alert-line"))
.toBe(false);
expect(container.querySelector("li")?.classList.contains("error-line"))
.toBe(true);
expect(styledLine(container, colorScheme)).toEqual({
heading: "#777777",
body: "#cc3344",
});
});
}
function reconnectSnapshot(text: string): Event {
return {
event: "snapshot",
@@ -15,7 +103,6 @@ function reconnectSnapshot(text: string): Event {
session: {
entries: [],
pending_submissions: {
head_id: null,
notification_count: 0,
submissions: [],
@@ -85,6 +85,7 @@
<li
class={`console-line ${lineClass(item)} ${toolClass(item)}`}
class:error-line={item.error}
class:alert-line={!!item.alert}
class:rewind-selected={rewindSelected}
aria-current={rewindSelected ? "true" : undefined}
data-console-line-id={item.id}
@@ -185,6 +186,23 @@
color: var(--danger);
}
.console-line.alert-line {
--alert-heading: #a74709;
--alert-body: #595959;
color: var(--alert-body);
}
.console-line.alert-line .message-heading {
color: var(--alert-heading);
}
@media (prefers-color-scheme: dark) {
.console-line.alert-line {
--alert-heading: #f6a75c;
--alert-body: #b8b8b8;
}
}
.console-line.user {
color: var(--tui-green);
}
@@ -0,0 +1,687 @@
import type {
Alert,
AlertPosition,
Event,
InFlightSnapshot,
SessionSnapshotEntry,
} from "#lib/generated/protocol.ts";
import {
applyProtocolEvent,
type ConsoleLine,
type ConsoleProjection,
emptyConsoleProjection,
mergeCommittedHistoryLines,
projectConsoleLines,
projectSessionHistoryEntries,
projectVisibleConsole,
} from "./model.ts";
declare const Deno: { test(name: string, fn: () => void): void };
function assertEquals(actual: unknown, expected: unknown): void {
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(
`Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
);
}
}
let sequence = 0;
function apply(
projection: ConsoleProjection,
event: Event,
observedAtMs = 10000,
): ConsoleProjection {
return applyProtocolEvent(projection, {
eventId: `event-${sequence++}`,
event,
observedAtMs,
});
}
function message(
id: string,
timestamp: number,
body = id,
): SessionSnapshotEntry {
return {
kind: "message",
entry_id: id,
timestamp,
provenance: "model_output",
derived_from: [],
role: "assistant",
content: [{ kind: "text", text: body }],
};
}
function readCall(id: string, timestamp: number): SessionSnapshotEntry {
return {
kind: "tool_call",
entry_id: `entry-${id}`,
timestamp,
provenance: "model_output",
derived_from: [],
call_id: id,
name: "Read",
arguments: JSON.stringify({ file_path: `${id}.ts` }),
};
}
function snapshot(
entries: SessionSnapshotEntry[],
inFlight?: InFlightSnapshot,
): Event {
return {
event: "snapshot",
data: {
greeting: {
worker_name: "Worker",
cwd: "/workspace",
provider: "provider",
model: "model",
scope_summary: "bounded",
tools: [],
context_window: 100,
context_tokens: 20,
},
state: { last_command_id: 0, state: { kind: "idle" } },
session: {
entries,
pending_submissions: {
notification_count: 0,
head_id: null,
submissions: [],
},
},
in_flight: inFlight,
internal_workers: [],
},
};
}
function alert(
id: string | undefined,
position?: AlertPosition,
timestamp = 150,
): Alert {
return {
id,
position,
level: "warn",
source: "worker",
message: "same warning",
timestamp_ms: timestamp,
};
}
function emit(projection: ConsoleProjection, data: Alert): ConsoleProjection {
return apply(projection, { event: "alert", data });
}
function commit(
projection: ConsoleProjection,
entry: SessionSnapshotEntry,
): ConsoleProjection {
return apply(projection, {
event: "session_entry_committed",
data: { entry },
});
}
function labels(lines: ConsoleLine[]): string[] {
return lines.map((line) =>
line.alert
? `alert:${line.alert.id ?? "legacy"}`
: line.toolCall?.id ?? line.body
);
}
function visible(projection: ConsoleProjection): string[] {
return labels(projectVisibleConsole(projection).lines);
}
Deno.test("live Alert chronology equals fresh snapshot plus replay without timestamp sorting", () => {
const entries = [
message("first", 300),
message("second", 100),
message("third", 200),
];
const warnings = [
alert("start", { kind: "start" }),
alert("a", { kind: "after_entry", entry_id: "first" }),
alert("b", { kind: "after_entry", entry_id: "first" }),
alert("c", { kind: "after_entry", entry_id: "second" }),
];
let live = emit(emptyConsoleProjection(), warnings[0]);
live = commit(live, entries[0]);
live = emit(emit(live, warnings[1]), warnings[2]);
live = emit(commit(live, entries[1]), warnings[3]);
live = commit(live, entries[2]);
let replay = apply(emptyConsoleProjection(), snapshot(entries));
for (const warning of warnings) replay = emit(replay, warning);
assertEquals(visible(live), [
"alert:start",
"first",
"alert:a",
"alert:b",
"second",
"alert:c",
"third",
]);
assertEquals(visible(replay), visible(live));
});
Deno.test("same-id replay updates metadata; distinct same-ms same-body IDs remain distinct", () => {
const entries = [message("one", 100), message("two", 200)];
let projection = apply(emptyConsoleProjection(), snapshot(entries));
projection = emit(
projection,
alert("a", { kind: "after_entry", entry_id: "one" }),
);
projection = emit(
projection,
alert("b", { kind: "after_entry", entry_id: "one" }),
);
const originalId =
projection.lines.find((line) => line.alert?.id === "a")!.id;
projection = apply(projection, snapshot(entries));
assertEquals(visible(projection), ["one", "alert:a", "alert:b", "two"]);
projection = emit(
projection,
alert("a", { kind: "after_entry", entry_id: "two" }),
);
projection = emit(
projection,
alert("a", { kind: "after_entry", entry_id: "two" }),
);
projection = emit(
projection,
alert("b", { kind: "after_entry", entry_id: "one" }),
);
assertEquals(visible(projection), ["one", "alert:b", "two", "alert:a"]);
assertEquals(
projection.lines.find((line) => line.alert?.id === "a")!.id,
originalId,
);
assertEquals(projection.lines.filter((line) => line.alert).length, 2);
});
Deno.test("live pending Alert anchor follows the actual assistant through commit and reconnect", () => {
let live = commit(emptyConsoleProjection(), message("old", 10));
live = apply(live, { event: "text_delta", data: { text: "pending" } }, 100);
live = emit(
live,
alert("pending-alert", { kind: "after_in_flight", block_id: 42 }),
);
assertEquals(visible(live), ["old", "pending", "alert:pending-alert"]);
assertEquals(
live.lines.find((line) => line.body === "pending")?.inFlightBlockId,
42,
);
live = apply(
live,
{ event: "text_done", data: { text: "pending answer" } },
180,
);
live = commit(live, message("answer", 190, "pending answer"));
live = commit(live, message("later", 200));
assertEquals(visible(live), [
"old",
"pending answer",
"alert:pending-alert",
"later",
]);
assertEquals(live.lines.find((line) => line.alert)?.alert?.position, {
kind: "after_entry",
entry_id: "answer",
});
assertEquals(
live.lines.find((line) => line.entryId === "answer")?.inFlightBlockId,
42,
);
// Even a repeated pending-anchor payload cannot bind to a later assistant.
live = emit(
live,
alert("pending-alert", { kind: "after_in_flight", block_id: 42 }),
);
assertEquals(visible(live), [
"old",
"pending answer",
"alert:pending-alert",
"later",
]);
const entries = [
message("old", 10),
message("answer", 190, "pending answer"),
message("later", 200),
];
live = apply(live, snapshot(entries));
assertEquals(visible(live), [
"old",
"pending answer",
"alert:pending-alert",
"later",
]);
const resolved = alert("pending-alert", {
kind: "after_entry",
entry_id: "answer",
});
live = emit(live, resolved);
const fresh = emit(
apply(emptyConsoleProjection(), snapshot(entries)),
resolved,
);
assertEquals(visible(live), visible(fresh));
});
Deno.test("snapshot block IDs restore pending anchors and survive text continuation", () => {
const entries = [message("old", 10)];
const inFlight: InFlightSnapshot = {
block_ids: [7, 8, 9],
blocks: [
{ kind: "text", text: "first pending", finished: true },
{
kind: "tool_call",
id: "read",
name: "Read",
args: "{}",
state: "done",
},
{ kind: "text", text: "second", finished: false },
],
};
let projection = apply(emptyConsoleProjection(), snapshot(entries, inFlight));
projection = emit(
projection,
alert("a", { kind: "after_in_flight", block_id: 7 }),
);
projection = emit(
projection,
alert("b", { kind: "after_in_flight", block_id: 9 }),
);
const before = visible(projection);
projection = apply(projection, snapshot(entries, inFlight));
assertEquals(visible(projection), before);
assertEquals(before, [
"old",
"first pending",
"alert:a",
"read",
"second",
"alert:b",
]);
projection = commit(projection, message("first", 100, "first pending"));
projection = apply(projection, {
event: "text_delta",
data: { text: " pending" },
});
projection = apply(projection, {
event: "text_done",
data: { text: "second pending" },
});
projection = commit(projection, message("second", 200, "second pending"));
assertEquals(visible(projection), [
"old",
"first pending",
"alert:a",
"read",
"second pending",
"alert:b",
]);
assertEquals(
projection.lines.filter((line) => line.alert).map((line) =>
line.alert!.position
),
[
{ kind: "after_entry", entry_id: "first" },
{ kind: "after_entry", entry_id: "second" },
],
);
});
Deno.test("snapshot with committed pending text retains Alerts until replay resolves metadata", () => {
let projection = apply(emptyConsoleProjection(), {
event: "text_delta",
data: { text: "answer" },
});
projection = emit(
projection,
alert("a", { kind: "after_in_flight", block_id: 15 }),
);
projection = apply(
projection,
snapshot([message("answer", 100), message("later", 200)]),
);
assertEquals(projection.lines.filter((line) => line.alert).length, 1);
projection = emit(
projection,
alert("a", { kind: "after_entry", entry_id: "answer" }),
);
assertEquals(visible(projection), ["answer", "alert:a", "later"]);
});
Deno.test("Alert anchors split Read and overview aggregation before and after history merge", () => {
const entries = [
readCall("r1", 100),
readCall("r2", 200),
readCall("r3", 300),
];
let projection = apply(emptyConsoleProjection(), snapshot(entries));
projection = emit(
projection,
alert("boundary", { kind: "after_tool_call", call_id: "r1" }),
);
const lines = projectVisibleConsole(projection).lines;
assertEquals(
lines.map((line) => line.alert ? "alert" : line.toolCallCount ?? 1),
[1, "alert", 2],
);
assertEquals(
projectConsoleLines(lines, "overview").map((line) => line.kind),
["activity", "status", "activity"],
);
const history = projectSessionHistoryEntries(entries, null);
const merged = mergeCommittedHistoryLines(history, lines);
assertEquals(
merged.map((line) => line.alert ? "alert" : line.toolCallCount ?? 1),
[1, "alert", 2],
);
assertEquals(
projectConsoleLines(merged, "overview").map((line) => line.kind),
["activity", "status", "activity"],
);
});
Deno.test("missing older anchors stay before the window and resolve when history arrives", () => {
let projection = apply(
emptyConsoleProjection(),
snapshot([message("current", 300)]),
);
projection = emit(
projection,
alert("older", { kind: "after_entry", entry_id: "older" }),
);
projection = emit(
projection,
alert("same-anchor", { kind: "after_entry", entry_id: "older" }),
);
projection = emit(projection, alert("start", { kind: "start" }));
const current = projectVisibleConsole(projection).lines;
assertEquals(labels(current), [
"alert:older",
"alert:same-anchor",
"alert:start",
"current",
]);
const history = projectSessionHistoryEntries([
message("first", 50),
message("older", 100),
message("middle", 200),
message("current", 300),
], null);
const merged = mergeCommittedHistoryLines(history, current);
assertEquals(labels(merged), [
"alert:start",
"first",
"older",
"alert:older",
"alert:same-anchor",
"middle",
"current",
]);
assertEquals(
labels(mergeCommittedHistoryLines(history, merged)),
labels(merged),
);
});
Deno.test("legacy Alerts use original emission timestamps for live and replay chronology", () => {
let live = apply(emptyConsoleProjection(), {
event: "text_done",
data: { text: "first" },
}, 100);
live = emit(live, alert(undefined, undefined, 150));
live = apply(live, { event: "text_done", data: { text: "second" } }, 200);
assertEquals(visible(live), ["first", "alert:legacy", "second"]);
let replay = apply(
emptyConsoleProjection(),
snapshot([message("first", 100), message("second", 200)]),
);
replay = emit(replay, alert(undefined, undefined, 150));
replay = emit(replay, alert(undefined, undefined, 150));
assertEquals(visible(replay), visible(live));
assertEquals(replay.lines.map((line) => line.timestampMs), [100, 200, 150]);
const merged = mergeCommittedHistoryLines(
projectSessionHistoryEntries([message("earliest", 50)], null),
projectVisibleConsole(replay).lines,
);
assertEquals(labels(merged), ["earliest", "first", "alert:legacy", "second"]);
});
Deno.test("appended entry metadata does not rescan prior event IDs or mutate retained rows", () => {
const projection = apply(
emptyConsoleProjection(),
snapshot(
Array.from(
{ length: 128 },
(_, index) => message(`entry-${index}`, index),
),
),
);
let priorEventIdReads = 0;
projection.lines = projection.lines.map((line) =>
Object.freeze({
...line,
get eventId() {
priorEventIdReads += 1;
return line.eventId;
},
})
);
const next = commit(projection, message("appended", 200));
assertEquals(priorEventIdReads, 0);
assertEquals(next.lines.length, projection.lines.length + 1);
assertEquals(
next.lines.slice(0, -1).every((line, index) =>
line === projection.lines[index]
),
true,
);
assertEquals(next.lines.at(-1)?.entryId, "appended");
assertEquals(next.lines.at(-1)?.sourceEntryIds, ["appended"]);
assertEquals(next.lines.at(-1)?.timestampMs, 200);
});
Deno.test("snapshot retention indexes previous entry metadata in one pass", () => {
const entries = Array.from(
{ length: 128 },
(_, index) => message(`entry-${index}`, index),
);
const projection = apply(emptyConsoleProjection(), snapshot(entries));
let priorEntryIdReads = 0;
projection.lines = projection.lines.map((line, index) =>
Object.freeze({
...line,
inFlightBlockId: index,
get entryId() {
priorEntryIdReads += 1;
return line.entryId;
},
})
);
const next = apply(projection, snapshot(entries));
assertEquals(priorEntryIdReads, entries.length);
assertEquals(
next.lines.map((line) => line.inFlightBlockId),
entries.map((_, index) => index),
);
assertEquals(
next.lines.map((line) => line.timestampMs),
entries.map((_, index) => index),
);
});
Deno.test("tool-result reconciliation retains call-entry anchors and creation timestamp", () => {
let projection = commit(emptyConsoleProjection(), readCall("r1", 100));
projection = emit(
projection,
alert("a", { kind: "after_entry", entry_id: "entry-r1" }),
);
projection = commit(projection, readCall("r2", 200));
const before = projection;
const originalCall = before.lines[0];
Object.freeze(originalCall.sourceEntryIds);
Object.freeze(originalCall);
projection = commit(projection, {
kind: "tool_result",
entry_id: "result-r1",
timestamp: 250,
provenance: "tool_output",
derived_from: [],
call_id: "r1",
summary: "read",
content: "text",
is_error: false,
});
assertEquals(visible(projection), ["r1", "alert:a", "r2"]);
assertEquals(originalCall.sourceEntryIds, ["entry-r1"]);
assertEquals(originalCall.entryId, "entry-r1");
assertEquals(originalCall.toolCall?.state, "running");
assertEquals(projection.lines[0] === originalCall, false);
assertEquals(projection.lines[2] === before.lines[2], true);
assertEquals(projection.lines[0].sourceEntryIds, ["entry-r1", "result-r1"]);
assertEquals(
projection.lines.find((line) => line.toolCall?.id === "r1")?.timestampMs,
100,
);
projection = emit(
projection,
alert("a", { kind: "after_entry", entry_id: "result-r1" }),
);
assertEquals(visible(projection), ["r1", "alert:a", "r2"]);
});
Deno.test("history enriches a live tool row with durable anchors before placing Alerts", () => {
let projection = apply(emptyConsoleProjection(), {
event: "tool_call_start",
data: { id: "r1", name: "Read" },
}, 110);
projection = emit(
projection,
alert("a", { kind: "after_entry", entry_id: "entry-r1" }),
);
projection = apply(projection, {
event: "tool_call_start",
data: { id: "r2", name: "Read" },
}, 210);
const merged = mergeCommittedHistoryLines(
projectSessionHistoryEntries(
[readCall("r1", 100), readCall("r2", 200)],
null,
),
projectVisibleConsole(projection).lines,
);
assertEquals(labels(merged), ["r1", "alert:a", "r2"]);
assertEquals(merged[0].toolCallLines?.[0].timestampMs, 100);
assertEquals(merged[0].toolCallLines?.[0].toolCall?.state, "pending");
});
Deno.test("live pending metadata survives reconnect before commit without changing first observation", () => {
let projection = apply(emptyConsoleProjection(), {
event: "text_delta",
data: { text: "pending" },
}, 100);
const warning = alert("a", { kind: "after_in_flight", block_id: 27 });
projection = emit(projection, warning);
projection = apply(
projection,
snapshot([], {
block_ids: [27],
blocks: [{ kind: "text", text: "pending", finished: false }],
}),
5000,
);
assertEquals(visible(projection), ["pending", "alert:a"]);
assertEquals(projection.lines[0].timestampMs, 100);
projection = emit(projection, warning);
assertEquals(visible(projection), ["pending", "alert:a"]);
projection = apply(projection, {
event: "text_done",
data: { text: "pending" },
});
projection = commit(projection, message("answer", 200, "pending"));
assertEquals(projection.lines.find((line) => line.alert)?.alert?.position, {
kind: "after_entry",
entry_id: "answer",
});
});
Deno.test("parent snapshot retains child Alerts in their own console only", () => {
const worker = {
kind: "sub_worker",
name: "child",
session_id: "child-session",
} as const;
const childEvent = (event: Event): Event => ({
event: "internal_worker",
data: { worker, event },
});
let projection = apply(
emptyConsoleProjection(),
childEvent(snapshot([message("child-answer", 100)])),
);
projection = apply(
projection,
childEvent({
event: "alert",
data: alert("a", { kind: "after_entry", entry_id: "child-answer" }),
}),
);
const parentSnapshot = snapshot([message("parent", 300)]);
if (parentSnapshot.event !== "snapshot") throw new Error("snapshot expected");
const childSnapshot = snapshot([message("child-answer", 100)]);
if (childSnapshot.event !== "snapshot") throw new Error("snapshot expected");
parentSnapshot.data.internal_workers = [{
worker,
status: "idle",
error: null,
session: childSnapshot.data.session,
}];
projection = apply(projection, parentSnapshot);
assertEquals(visible(projection), ["parent"]);
assertEquals(visible(projection.internalWorkers[0].console), [
"child-answer",
"alert:a",
]);
});
Deno.test("snapshot retains warning Alerts but reconciles durable run errors", () => {
let projection = emit(
emptyConsoleProjection(),
alert("warn", { kind: "start" }),
);
projection = emit(projection, {
...alert("error", { kind: "start" }),
level: "error",
message: "failed",
});
projection = apply(
projection,
snapshot([{
kind: "run_error",
entry_id: "failure",
timestamp: 300,
provenance: "legacy_unknown",
derived_from: [],
message: "failed",
failure: "compaction",
}]),
);
assertEquals(
projectVisibleConsole(projection).lines.map((line) =>
line.alert?.id ?? line.title
),
["warn", "Compaction failed"],
);
});
@@ -0,0 +1,131 @@
import type { Alert } from "#lib/generated/protocol.ts";
import type { ConsoleLine } from "./model.ts";
/** Stable IDs are authoritative; legacy replay can only use the original payload. */
export function alertIdentity(alert: Alert): string {
return alert.id != null ? `id:${alert.id}` : `legacy:${
JSON.stringify([
alert.timestamp_ms,
alert.level,
alert.source,
alert.message,
])
}`;
}
/** Keep first-seen order while allowing replay to refresh anchor metadata. */
export function deduplicateAlertLines(lines: ConsoleLine[]): ConsoleLine[] {
const result: ConsoleLine[] = [];
const positions = new Map<string, number>();
for (const line of lines) {
if (!line.alert) {
result.push(line);
continue;
}
const key = alertIdentity(line.alert);
const previous = positions.get(key);
if (previous === undefined) {
positions.set(key, result.length);
result.push(line);
} else {
result[previous] = { ...line, id: result[previous]!.id };
}
}
return result;
}
/**
* Insert Alerts between source rows BEFORE any Read/activity aggregation.
* Conversation order remains authoritative, even when timestamps are skewed.
* An absent explicit anchor belongs before the loaded window, not at its tail;
* its metadata remains intact so loading older history can resolve it later.
*/
export function orderAlertLines(lines: ConsoleLine[]): ConsoleLine[] {
const ungrouped = lines.flatMap((line) => line.toolCallLines ?? [line]);
const unique = deduplicateAlertLines(ungrouped);
const source = unique.filter((line) => !line.alert);
const slots = new Map<number, ConsoleLine[]>();
for (const line of unique) {
const alert = line.alert;
if (!alert) continue;
const position = alert.position;
let index: number;
switch (position?.kind) {
case "start":
index = 0;
break;
case "after_entry":
index = source.findIndex((row) =>
row.entryId === position.entry_id ||
row.sourceEntryIds?.includes(position.entry_id)
) + 1;
break;
case "after_tool_call":
index = source.findIndex((row) =>
row.toolCall?.id === position.call_id
) + 1;
break;
case "after_in_flight":
index =
source.findIndex((row) => row.inFlightBlockId === position.block_id) +
1;
break;
default: {
// Legacy senders have no identity/anchor. Use emission time, never the
// reconnect observation time, without sorting the conversation itself.
const later = source.findIndex((row) =>
row.timestampMs !== undefined && row.timestampMs > alert.timestamp_ms
);
index = later < 0 ? source.length : later;
}
}
const slot = slots.get(index) ?? [];
slot.push(line);
slots.set(index, slot);
}
const result = [...(slots.get(0) ?? [])];
source.forEach((line, index) => {
result.push(line, ...(slots.get(index + 1) ?? []));
});
return result;
}
/** Live deltas lack block IDs; the emitting Alert identifies the pending row. */
export function attachAlertInFlightAnchor(
lines: ConsoleLine[],
alert: Alert,
): ConsoleLine[] {
const position = alert.position;
if (position?.kind !== "after_in_flight") return lines;
if (lines.some((line) => line.inFlightBlockId === position.block_id)) {
return lines;
}
const index = lines.findLastIndex((line) =>
line.kind === "assistant" && !line.entryId
);
if (index < 0 || lines[index].inFlightBlockId !== undefined) return lines;
return lines.map((line, rowIndex) =>
rowIndex === index ? { ...line, inFlightBlockId: position.block_id } : line
);
}
/** Resolve before the pending row is removed; replay may repeat either anchor. */
export function resolveCommittedAlertAnchors(
lines: ConsoleLine[],
blockId: number | undefined,
entryId: string,
): ConsoleLine[] {
if (blockId === undefined) return lines;
return lines.map((line) =>
line.alert?.position?.kind === "after_in_flight" &&
line.alert.position.block_id === blockId
? {
...line,
alert: {
...line.alert,
position: { kind: "after_entry", entry_id: entryId },
},
}
: line
);
}
+198 -32
View File
@@ -24,6 +24,12 @@ import type {
WorkerStatus,
} from "#lib/generated/protocol.ts";
import { invocationInput } from "./composer-invocation.ts";
import {
attachAlertInFlightAnchor,
deduplicateAlertLines,
orderAlertLines,
resolveCommittedAlertAnchors,
} from "./alert-order.ts";
import { stringify as stringifyYaml } from "yaml";
import {
applyRunActivityEvent,
@@ -103,6 +109,12 @@ export type ConsoleLine = {
eventId?: string | null;
/** Stable durable identity when this line originates from a committed entry. */
entryId?: string;
/** Calls/results can share a row while retaining distinct durable anchors. */
sourceEntryIds?: string[];
/** Durable creation time or first live observation; never reconnect time for Alerts. */
timestampMs?: number;
/** Live-Worker block identity, retained through committed-row replacement. */
inFlightBlockId?: number;
source: "event";
streaming?: boolean;
error?: boolean;
@@ -114,6 +126,8 @@ export type ConsoleLine = {
toolCallLines?: ConsoleLine[];
/** Typed `SystemItem.kind` used by presentation-only projections. */
systemItemKind?: string;
/** Original protocol Alert, retained for chronology and Alert-specific presentation. */
alert?: Alert;
};
export type InternalWorkerProjection = {
@@ -340,18 +354,46 @@ export function mergeCommittedHistoryLines(
history: ConsoleLine[],
current: ConsoleLine[],
): ConsoleLine[] {
const durableStats = new Map(
history.filter((line) => line.kind === "run_stats" && line.entryId)
.map((line) => [line.entryId!, line]),
const historySource = history.flatMap((line) => line.toolCallLines ?? [line]);
const currentSource = current.flatMap((line) => line.toolCallLines ?? [line]);
const alerts = [...historySource, ...currentSource].filter((line) =>
line.alert
);
return aggregateReadToolLines(mergeHistorySourceLines(
history.flatMap((line) => line.toolCallLines ?? [line]),
current.flatMap((line) => line.toolCallLines ?? [line]).map((line) =>
line.kind === "run_stats" && line.entryId
? durableStats.get(line.entryId) ?? line
: line
),
));
const durableRows = new Map(
historySource.filter((line) => !line.alert)
.map((line) => [historyLineKey(line), line]),
);
const merged = mergeHistorySourceLines(
historySource.filter((line) => !line.alert),
currentSource.filter((line) => !line.alert).map((line) => {
const key = historyLineKey(line);
const durable = key ? durableRows.get(key) : undefined;
if (!durable) return line;
if (line.kind === "run_stats") return durable;
// Keep the current row authoritative; borrow durable metadata only when
// needed to place Alerts against a live or partially restored tool row.
if (alerts.length === 0) return line;
return {
...line,
entryId: line.entryId ?? durable.entryId,
sourceEntryIds: [
...new Set([
...(durable.sourceEntryIds ?? []),
...(durable.entryId ? [durable.entryId] : []),
...(line.sourceEntryIds ?? []),
...(line.entryId ? [line.entryId] : []),
]),
],
timestampMs: line.entryId
? line.timestampMs ?? durable.timestampMs
: durable.timestampMs ?? line.timestampMs,
};
}),
);
return aggregateReadToolLines(orderAlertLines([
...merged,
...alerts,
]));
}
function historyLineKey(line: ConsoleLine): string | undefined {
@@ -440,12 +482,12 @@ export function projectSessionHistoryEntries(
).lines;
}
function projectVisibleConsole(
export function projectVisibleConsole(
projection: ConsoleProjection,
): ConsoleProjection {
return {
...projection,
lines: aggregateReadToolLines(projection.lines),
lines: aggregateReadToolLines(orderAlertLines(projection.lines)),
internalWorkers: projection.internalWorkers.map((worker) => ({
...worker,
console: projectVisibleConsole(worker.console),
@@ -650,6 +692,8 @@ function appendSnapshotInFlightLines(
blocks: InFlightBlock[],
eventId: string,
cwd: string | null,
blockIds: number[] = [],
observedAtMs?: number,
): void {
blocks.forEach((block, index) => {
const blockEventId = `${eventId}:${index}`;
@@ -703,6 +747,16 @@ function appendSnapshotInFlightLines(
});
break;
}
const rowIndex = projection.lines.findLastIndex((line) =>
line.eventId === blockEventId
);
if (rowIndex >= 0) {
projection.lines[rowIndex] = {
...projection.lines[rowIndex],
inFlightBlockId: blockIds[index],
timestampMs: projection.lines[rowIndex].timestampMs ?? observedAtMs,
};
}
});
}
@@ -862,6 +916,65 @@ function appendCommandStream(
};
}
function retainSnapshotAlerts(
previous: ConsoleProjection,
restored: ConsoleProjection,
): ConsoleProjection {
const entryIndexes = new Map<string, number>();
const blockIndexes = new Map<number, number>();
previous.lines.forEach((line, index) => {
const { entryId, inFlightBlockId } = line;
if (entryId !== undefined && !entryIndexes.has(entryId)) {
entryIndexes.set(entryId, index);
}
if (inFlightBlockId !== undefined && !blockIndexes.has(inFlightBlockId)) {
blockIndexes.set(inFlightBlockId, index);
}
});
const lines = restored.lines.map((line) => {
// Preserve the first matching source row, including when both keys match.
const entryIndex = line.entryId === undefined
? undefined
: entryIndexes.get(line.entryId);
const blockIndex = line.inFlightBlockId === undefined
? undefined
: blockIndexes.get(line.inFlightBlockId);
const known = previous.lines[
Math.min(
entryIndex ?? previous.lines.length,
blockIndex ?? previous.lines.length,
)
];
return known
? {
...line,
inFlightBlockId: line.inFlightBlockId ?? known.inFlightBlockId,
timestampMs: line.entryId
? line.timestampMs
: known.timestampMs ?? line.timestampMs,
}
: line;
});
const alerts = previous.lines.filter((line) =>
line.alert && !(line.error && hasDurableRunFailure(restored, line.body))
);
return {
...restored,
lines: deduplicateAlertLines([...lines, ...alerts]),
internalWorkers: restored.internalWorkers.map((worker) => {
const known = previous.internalWorkers.find((old) =>
old.worker.session_id === worker.worker.session_id
);
return known
? {
...worker,
console: retainSnapshotAlerts(known.console, worker.console),
}
: worker;
}),
};
}
function projectInternalWorkerSnapshot(
snapshot: InternalWorkerSnapshot,
eventId: string,
@@ -882,6 +995,7 @@ function projectInternalWorkerSnapshot(
snapshot.in_flight?.blocks ?? [],
`${eventId}:internal:${snapshot.worker.session_id}:in-flight`,
cwd,
snapshot.in_flight?.block_ids,
);
appendSnapshotCommands(
console,
@@ -1057,7 +1171,10 @@ export function applyProtocolEvent(
switch (event.event) {
case "user_message":
if (event.data.entry_id && next.lines.some((line) => line.entryId === event.data.entry_id)) break;
if (
event.data.entry_id &&
next.lines.some((line) => line.entryId === event.data.entry_id)
) break;
next.lines.push({
...line(
envelope.eventId,
@@ -1072,17 +1189,19 @@ export function applyProtocolEvent(
// A snapshot may already include this commit. Check before reconciling so
// replay cannot consume a later, unrelated live assistant block.
if (isRepeatedSessionEntry(next, event.data.entry)) break;
const replacementIndex = reconcileCommittedSessionEntry(
const replacement = reconcileCommittedSessionEntry(
next,
event.data.entry,
);
applySessionEntry(next, envelope.eventId, event.data.entry);
if (replacementIndex !== undefined) {
if (replacement !== undefined) {
const replacementIndex = replacement.index;
const committedIndex = next.lines.findIndex((line) =>
line.entryId === event.data.entry.entry_id
);
if (committedIndex >= 0) {
const [committed] = next.lines.splice(committedIndex, 1);
committed.inFlightBlockId = replacement.inFlightBlockId;
next.lines.splice(replacementIndex, 0, committed);
const stats = next.lines[replacementIndex + 1];
if (stats?.kind === "run_stats" && !stats.entryId) {
@@ -1212,6 +1331,8 @@ export function applyProtocolEvent(
event.data.in_flight?.blocks ?? [],
`${envelope.eventId}:snapshot-in-flight`,
next.cwd,
event.data.in_flight?.block_ids,
envelope.observedAtMs,
);
appendSnapshotCommands(
next,
@@ -1221,6 +1342,9 @@ export function applyProtocolEvent(
next.internalWorkers = (event.data.internal_workers ?? []).map((worker) =>
projectInternalWorkerSnapshot(worker, envelope.eventId, next.cwd)
);
const retained = retainSnapshotAlerts(projection, next);
next.lines = retained.lines;
next.internalWorkers = retained.internalWorkers;
next.removedInternalWorkers = {};
const withCompaction = applyInFlightCompaction(
next,
@@ -1278,7 +1402,9 @@ export function applyProtocolEvent(
applyCommandEvent(next, envelope.eventId, event.data.event);
break;
case "segment_rotated": {
const retainedErrors = next.lines.filter((line) => line.kind === "error");
const retainedErrors = next.lines.filter((line) =>
line.kind === "error" || line.alert
);
const retainedCompaction = next.lines.filter((line) =>
line.id === RUNTIME_COMPACTION_LINE_ID
);
@@ -1348,6 +1474,11 @@ export function applyProtocolEvent(
break;
}
next.lines = next.lines.map((line) =>
line.timestampMs === undefined && line.eventId === envelope.eventId
? { ...line, timestampMs: envelope.observedAtMs }
: line
);
return next;
}
@@ -1509,8 +1640,9 @@ function appendAlertLine(
): void {
const isError = alert.level === "error";
if (isError && hasDurableRunFailure(projection, alert.message)) return;
projection.lines.push(
line(
projection.lines = attachAlertInFlightAnchor(projection.lines, alert);
projection.lines = deduplicateAlertLines([...projection.lines, {
...line(
eventId,
isError ? "error" : "status",
`Alert · ${alert.source}`,
@@ -1519,7 +1651,9 @@ function appendAlertLine(
false,
isError,
),
);
alert,
timestampMs: alert.timestamp_ms,
}]);
}
function findLastLineIndex(
@@ -2335,7 +2469,7 @@ function snapshotProjectionFromSession(
function reconcileCommittedSessionEntry(
projection: ConsoleProjection,
entry: SessionSnapshotEntry,
): number | undefined {
): { index: number; inFlightBlockId?: number } | undefined {
if (entry.kind !== "message" || entry.role !== "assistant") return;
const turnStart = findLastLineIndex(
projection.lines,
@@ -2348,8 +2482,13 @@ function reconcileCommittedSessionEntry(
!line.entryId &&
line.streaming !== true
) {
projection.lines = resolveCommittedAlertAnchors(
projection.lines,
line.inFlightBlockId,
entry.entry_id,
);
projection.lines.splice(index, 1);
return index;
return { index, inFlightBlockId: line.inFlightBlockId };
}
}
}
@@ -2375,7 +2514,8 @@ function applySessionEntry(
if (!isRecord(value) || isRepeatedSessionEntry(projection, value)) return;
const eventId = stringField(value, "entry_id") ?? fallbackEventId;
const lineCountBefore = projection.lines.length;
switch (stringField(value, "kind")) {
const entryKind = stringField(value, "kind");
switch (entryKind) {
case "user_input":
projection.lines.push(
line(
@@ -2422,7 +2562,10 @@ function applySessionEntry(
const requests = safeTokenCount(value["requests"]);
const upload = safeTokenCount(value["upload_tokens"]);
const output = safeTokenCount(value["output_tokens"]);
if (elapsed !== null && requests !== null && upload !== null && output !== null) {
if (
elapsed !== null && requests !== null && upload !== null &&
output !== null
) {
projection.lines.push(runStatsLine(eventId, {
startedAtMs: 0,
requests,
@@ -2451,18 +2594,41 @@ function applySessionEntry(
break;
}
const entryId = stringField(value, "entry_id");
if (entryId) {
const timestampMs = typeof value["timestamp"] === "number"
? value["timestamp"]
: undefined;
const stampLine = (index: number) => {
const line = projection.lines[index];
if (!line || line.eventId !== eventId) return;
const sourceEntryIds = new Set(line.sourceEntryIds ?? []);
if (line.entryId) sourceEntryIds.add(line.entryId);
if (entryId) sourceEntryIds.add(entryId);
projection.lines[index] = {
...line,
entryId: entryId ?? line.entryId,
sourceEntryIds: [...sourceEntryIds],
timestampMs: entryKind === "tool_result"
? line.timestampMs ?? timestampMs
: timestampMs ?? line.timestampMs,
};
};
if (entryKind === "run_error") {
// Failure reconciliation can remove transient rows before appending.
stampLine(projection.lines.length - 1);
} else if (
(entryKind === "tool_call" || entryKind === "tool_result") &&
projection.lines.length === lineCountBefore
) {
stampLine(
projection.lines.findLastIndex((line) => line.eventId === eventId),
);
} else {
for (
let index = Math.max(
0,
Math.min(lineCountBefore, projection.lines.length - 1),
);
let index = lineCountBefore;
index < projection.lines.length;
index += 1
) {
if (projection.lines[index].eventId === eventId) {
projection.lines[index].entryId = entryId;
}
stampLine(index);
}
}
}
@@ -1268,6 +1268,55 @@ test("preserves rendered content while reconnecting", async () => {
).not.toBeNull();
});
test("Alert keeps its transcript position on reconnect and a fresh Console mount", async () => {
vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL) =>
Promise.resolve(Response.json(String(input).includes("/session/history")
? emptyHistoryPage()
: { availability: "live_protocol" }))
));
const first = sessionWithUserMessage("before the alert");
const second = sessionWithUserMessage("after the alert").entries[0];
const full = { ...first, entries: [...first.entries, second] };
const alert: ProtocolEvent = {
event: "alert",
data: {
id: "alert-reopen-test",
position: { kind: "after_entry", entry_id: first.entries[0].entry_id },
level: "warn", source: "compactor", message: "retained alert body", timestamp_ms: 2,
},
};
const expectOrder = () => {
const article = screen.getByRole("article", { name: "main transcript" });
const rows = Array.from(article.querySelectorAll(".console-line"));
expect(rows).toHaveLength(3);
expect(rows[0].textContent).toContain("before the alert");
expect(rows[1].textContent).toContain("retained alert body");
expect(rows[1].classList.contains("alert-line")).toBe(true);
expect(rows[2].textContent).toContain("after the alert");
};
const initial = render(ConsolePage, { data: pageData() });
await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledOnce());
latestListener().onFrame(subscribedFrame(first));
protocolEvent(alert);
protocolEvent({ event: "user_message", data: {
entry_id: second.entry_id, segments: [{ kind: "text", content: "after the alert" }],
} });
await waitFor(expectOrder);
latestListener().onStatus?.("closed");
latestListener().onStatus?.("connecting");
const replay = subscribedFrame(full);
replay.message.payload.snapshot.data.events.push(alert, alert);
latestListener().onFrame(replay);
await waitFor(expectOrder);
initial.unmount();
render(ConsolePage, { data: pageData() });
await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledTimes(2));
latestListener().onFrame(replay);
await waitFor(expectOrder);
});
test("reconnect snapshot refreshes lineage and removes stale sibling history", async () => {
let historyRequests = 0;
vi.stubGlobal(