fix: preserve console alert order and improve alert colors
This commit is contained in:
@@ -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
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+49
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user