fix(console): bound retained history reconciliation
This commit is contained in:
@@ -155,7 +155,14 @@ export type SessionHistoryPage = { session_id: string,
|
||||
/**
|
||||
* Stable identity of the adopted active lineage used to fence stale pages.
|
||||
*/
|
||||
lineage_id: string, turns: Array<SessionConversationTurn>, next_cursor?: string | null, has_more: boolean, };
|
||||
lineage_id: string,
|
||||
/**
|
||||
* Older lineage identities that the active lineage fully adopted through
|
||||
* one or more consecutive Compact rotations. Clients may retain already
|
||||
* loaded turns when their prior lineage appears here, while replacing the
|
||||
* cursor with this page's boundary.
|
||||
*/
|
||||
compact_ancestor_lineage_ids?: Array<string>, turns: Array<SessionConversationTurn>, next_cursor?: string | null, has_more: boolean, };
|
||||
|
||||
export type PendingSubmissionSummary = { submission_id: string, accepted_at_ms: number, segment_count: number, byte_len: number, };
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
beginConsoleHistoryRequest,
|
||||
type ConsoleHistoryState,
|
||||
conversationTurnPreviews,
|
||||
conversationTurnPreviewsFromLines,
|
||||
emptyConsoleHistoryState,
|
||||
historyEntries,
|
||||
setConsoleHistoryTopEdge,
|
||||
@@ -53,10 +54,13 @@ function turn(index: number): SessionConversationTurn {
|
||||
function page(
|
||||
turns: SessionConversationTurn[],
|
||||
cursor: string | null,
|
||||
lineageId = "lineage-a",
|
||||
compactAncestorLineageIds: string[] = [],
|
||||
): SessionHistoryPage {
|
||||
return {
|
||||
session_id: "session-a",
|
||||
lineage_id: "lineage-a",
|
||||
lineage_id: lineageId,
|
||||
compact_ancestor_lineage_ids: compactAncestorLineageIds,
|
||||
turns,
|
||||
next_cursor: cursor,
|
||||
has_more: cursor !== null,
|
||||
@@ -144,6 +148,151 @@ Deno.test("refresh replaces overlapping latest turns while retaining loaded earl
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("same-lineage refresh without overlap replaces the paging boundary", () => {
|
||||
let state: ConsoleHistoryState = {
|
||||
...emptyConsoleHistoryState(),
|
||||
sessionId: "session-a",
|
||||
lineageId: "lineage-a",
|
||||
turns: [1, 2, 3, 4].map(turn),
|
||||
cursor: null,
|
||||
hasMore: false,
|
||||
status: "loading",
|
||||
requestedCursor: "__initial__",
|
||||
};
|
||||
state = applyConsoleHistoryPage(
|
||||
state,
|
||||
page([6, 7, 8, 9, 10].map(turn), "cursor-6", "lineage-a"),
|
||||
null,
|
||||
);
|
||||
|
||||
assert(
|
||||
state.turns.map((value) => value.turn_id).join(",") ===
|
||||
"u-1,u-2,u-3,u-4,u-6,u-7,u-8,u-9,u-10",
|
||||
"a reconnect may preserve loaded rows while still exposing the missing gap",
|
||||
);
|
||||
assert(
|
||||
state.cursor === "cursor-6" && state.hasMore,
|
||||
"a non-overlapping same-lineage page must replace an exhausted old boundary",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("changed lineage keeps the adopted prefix and discards the replaced branch", () => {
|
||||
let state = emptyConsoleHistoryState();
|
||||
state = beginConsoleHistoryRequest(state, null)!;
|
||||
state = applyConsoleHistoryPage(
|
||||
state,
|
||||
page([6, 7, 8, 9, 10].map(turn), "cursor-6"),
|
||||
null,
|
||||
);
|
||||
state = beginConsoleHistoryRequest(state, "cursor-6")!;
|
||||
state = applyConsoleHistoryPage(
|
||||
state,
|
||||
page([1, 2, 3, 4, 5].map(turn), null),
|
||||
"cursor-6",
|
||||
);
|
||||
|
||||
state = beginConsoleHistoryRequest(state, null)!;
|
||||
state = applyConsoleHistoryPage(
|
||||
state,
|
||||
page([3, 4, 5, 11, 12].map(turn), "cursor-3-new", "lineage-b"),
|
||||
null,
|
||||
);
|
||||
|
||||
assert(
|
||||
state.turns.map((value) => value.turn_id).join(",") ===
|
||||
"u-1,u-2,u-3,u-4,u-5,u-11,u-12",
|
||||
"rewind must preserve only the prefix adopted by the new lineage",
|
||||
);
|
||||
assert(
|
||||
state.cursor === "cursor-3-new" && state.hasMore,
|
||||
"a changed lineage must replace the stale paging cursor",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Compact lineage refresh preserves already loaded adopted history", () => {
|
||||
let state: ConsoleHistoryState = {
|
||||
...emptyConsoleHistoryState(),
|
||||
sessionId: "session-a",
|
||||
lineageId: "lineage-a",
|
||||
turns: Array.from({ length: 10 }, (_, index) => turn(index + 1)),
|
||||
status: "loading",
|
||||
requestedCursor: "__initial__",
|
||||
};
|
||||
state = applyConsoleHistoryPage(
|
||||
state,
|
||||
page(
|
||||
[11, 12, 13, 14, 15].map(turn),
|
||||
"cursor-11-new",
|
||||
"lineage-compact",
|
||||
["lineage-a"],
|
||||
),
|
||||
null,
|
||||
);
|
||||
|
||||
assert(
|
||||
state.turns.map((value) => value.turn_id).join(",") ===
|
||||
Array.from({ length: 15 }, (_, index) => `u-${index + 1}`).join(","),
|
||||
"Compact must keep the adopted prefix without requiring newest-page overlap",
|
||||
);
|
||||
assert(
|
||||
state.cursor === "cursor-11-new" && state.hasMore,
|
||||
"Compact must replace the old-lineage cursor even when adopted turns are preserved",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("changed lineage without a stable overlap replaces retained turns", () => {
|
||||
let state: ConsoleHistoryState = {
|
||||
...emptyConsoleHistoryState(),
|
||||
sessionId: "session-a",
|
||||
lineageId: "lineage-a",
|
||||
turns: [1, 2, 3].map(turn),
|
||||
cursor: "old-cursor",
|
||||
hasMore: true,
|
||||
status: "loading",
|
||||
requestedCursor: "__initial__",
|
||||
};
|
||||
state = applyConsoleHistoryPage(
|
||||
state,
|
||||
page([11, 12].map(turn), null, "lineage-b"),
|
||||
null,
|
||||
);
|
||||
assert(
|
||||
state.turns.map((value) => value.turn_id).join(",") === "u-11,u-12",
|
||||
"unrelated lineage content must not survive refresh",
|
||||
);
|
||||
assert(
|
||||
state.cursor === null && !state.hasMore,
|
||||
"new lineage boundary must be authoritative",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("line previews include a current turn outside the retained five-turn page", () => {
|
||||
const lines = [1, 2, 3, 4, 5, 6].flatMap((index) => [
|
||||
{
|
||||
id: `user-${index}`,
|
||||
entryId: `u-${index}`,
|
||||
kind: "user" as const,
|
||||
title: "User",
|
||||
body: `question ${index}`,
|
||||
source: "event" as const,
|
||||
},
|
||||
{
|
||||
id: `assistant-${index}`,
|
||||
entryId: `a-${index}`,
|
||||
kind: "assistant" as const,
|
||||
title: "Assistant",
|
||||
body: `done ${index}`,
|
||||
source: "event" as const,
|
||||
},
|
||||
]);
|
||||
assert(
|
||||
conversationTurnPreviewsFromLines(lines).map((preview) => preview.turnId)
|
||||
.join(",") ===
|
||||
"u-1,u-2,u-3,u-4,u-5,u-6",
|
||||
"turn navigation must include unmatched current snapshot turns in transcript order",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("turn preview uses one user line and final non-empty assistant up to three lines", () => {
|
||||
const preview = conversationTurnPreviews([turn(4)])[0];
|
||||
assert(preview.user === "question 4", "user preview must use its first line");
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ConsoleLine } from "./model.ts";
|
||||
import type {
|
||||
SessionConversationTurn,
|
||||
SessionHistoryPage,
|
||||
@@ -83,9 +84,19 @@ export function applyConsoleHistoryPage(
|
||||
const refreshingSameSession = initial &&
|
||||
state.sessionId === page.session_id &&
|
||||
state.turns.length > 0;
|
||||
const refreshingSameLineage = refreshingSameSession &&
|
||||
state.lineageId === page.lineage_id;
|
||||
const refreshingCompactDescendant = refreshingSameSession &&
|
||||
state.lineageId !== null &&
|
||||
(page.compact_ancestor_lineage_ids ?? []).includes(state.lineageId);
|
||||
const refreshBridgesRetained = refreshingSameLineage && incoming.some(
|
||||
(turn) => state.turns.some((retained) => retained.turn_id === turn.turn_id),
|
||||
);
|
||||
const turns = initial
|
||||
? refreshingSameSession
|
||||
? refreshingSameLineage || refreshingCompactDescendant
|
||||
? mergeRefreshedTurns(state.turns, incoming)
|
||||
: refreshingSameSession
|
||||
? reconcileChangedLineage(state.turns, incoming)
|
||||
: dedupeTurns(incoming)
|
||||
: dedupeTurns([...incoming, ...state.turns]);
|
||||
return {
|
||||
@@ -93,8 +104,8 @@ export function applyConsoleHistoryPage(
|
||||
sessionId: page.session_id,
|
||||
lineageId: page.lineage_id,
|
||||
turns,
|
||||
cursor: refreshingSameSession ? state.cursor : (page.next_cursor ?? null),
|
||||
hasMore: refreshingSameSession ? state.hasMore : page.has_more,
|
||||
cursor: refreshBridgesRetained ? state.cursor : (page.next_cursor ?? null),
|
||||
hasMore: refreshBridgesRetained ? state.hasMore : page.has_more,
|
||||
status: "ready",
|
||||
error: null,
|
||||
requestedCursor: null,
|
||||
@@ -144,20 +155,86 @@ export function conversationTurnPreviews(
|
||||
.filter(isAssistantEntry)
|
||||
.map(entryText)
|
||||
.filter((text) => text.trim().length > 0);
|
||||
const assistant = (assistants.at(-1) ?? "")
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 3);
|
||||
return {
|
||||
turnId: turn.turn_id,
|
||||
lineId: userEntry?.entry_id ?? turn.turn_id,
|
||||
user: firstNonEmptyLine(userEntry ? entryText(userEntry) : ""),
|
||||
assistant,
|
||||
assistant: previewAssistant(assistants.at(-1) ?? ""),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function conversationTurnPreviewsFromLines(
|
||||
lines: readonly ConsoleLine[],
|
||||
): ConsoleTurnPreview[] {
|
||||
const previews: ConsoleTurnPreview[] = [];
|
||||
let current: {
|
||||
turnId: string;
|
||||
lineId: string;
|
||||
user: string;
|
||||
assistants: string[];
|
||||
} | null = null;
|
||||
for (const line of lines) {
|
||||
if (line.kind === "user") {
|
||||
if (current) {
|
||||
previews.push({
|
||||
turnId: current.turnId,
|
||||
lineId: current.lineId,
|
||||
user: current.user,
|
||||
assistant: previewAssistant(current.assistants.at(-1) ?? ""),
|
||||
});
|
||||
}
|
||||
const stableId = line.entryId ?? line.id;
|
||||
current = {
|
||||
turnId: stableId,
|
||||
lineId: stableId,
|
||||
user: firstNonEmptyLine(line.body),
|
||||
assistants: [],
|
||||
};
|
||||
} else if (
|
||||
line.kind === "assistant" && current && line.body.trim().length > 0
|
||||
) {
|
||||
current.assistants.push(line.body);
|
||||
}
|
||||
}
|
||||
if (current) {
|
||||
previews.push({
|
||||
turnId: current.turnId,
|
||||
lineId: current.lineId,
|
||||
user: current.user,
|
||||
assistant: previewAssistant(current.assistants.at(-1) ?? ""),
|
||||
});
|
||||
}
|
||||
return previews;
|
||||
}
|
||||
|
||||
function previewAssistant(value: string): string[] {
|
||||
return value
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 3);
|
||||
}
|
||||
|
||||
function reconcileChangedLineage(
|
||||
retained: readonly SessionConversationTurn[],
|
||||
refreshed: readonly SessionConversationTurn[],
|
||||
): SessionConversationTurn[] {
|
||||
const retainedPositions = new Map(
|
||||
retained.map((turn, index) => [turn.turn_id, index] as const),
|
||||
);
|
||||
const firstShared = refreshed.find((turn) =>
|
||||
retainedPositions.has(turn.turn_id)
|
||||
);
|
||||
if (!firstShared) return dedupeTurns(refreshed);
|
||||
|
||||
const retainedPrefix = retained.slice(
|
||||
0,
|
||||
retainedPositions.get(firstShared.turn_id) ?? 0,
|
||||
);
|
||||
return dedupeTurns([...retainedPrefix, ...refreshed]);
|
||||
}
|
||||
|
||||
function mergeRefreshedTurns(
|
||||
retained: readonly SessionConversationTurn[],
|
||||
refreshed: readonly SessionConversationTurn[],
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
consoleWorkerViews,
|
||||
createConsoleProjector,
|
||||
isConsoleProjectionEvent,
|
||||
mergeCommittedHistoryLines,
|
||||
projectConsole,
|
||||
projectConsoleLines,
|
||||
projectOverviewLines,
|
||||
@@ -3080,3 +3081,48 @@ Deno.test("committed tool identities reconcile one live call block", () => {
|
||||
assertEquals(lines[0].entryId, "tool-result-entry");
|
||||
assertEquals(lines[0].toolCall?.state, "done");
|
||||
});
|
||||
|
||||
Deno.test("committed history preserves unmatched current rows in stable entry order", () => {
|
||||
const history = [2, 3, 4, 5, 6].flatMap((turn) => {
|
||||
const user = consoleLine(`history-user-${turn}`, "user");
|
||||
user.entryId = `user-${turn}`;
|
||||
const assistant = consoleLine(`history-assistant-${turn}`, "assistant");
|
||||
assistant.entryId = `assistant-${turn}`;
|
||||
return [user, assistant];
|
||||
});
|
||||
const current = [1, 2, 3, 4, 5, 6].flatMap((turn) => {
|
||||
const user = consoleLine(`current-user-${turn}`, "user");
|
||||
user.entryId = `user-${turn}`;
|
||||
const assistant = consoleLine(`current-assistant-${turn}`, "assistant");
|
||||
assistant.entryId = `assistant-${turn}`;
|
||||
return [user, assistant];
|
||||
});
|
||||
|
||||
const merged = mergeCommittedHistoryLines(history, current);
|
||||
assertEquals(
|
||||
merged.map((line) => line.entryId),
|
||||
[1, 2, 3, 4, 5, 6].flatMap((turn) => [
|
||||
`user-${turn}`,
|
||||
`assistant-${turn}`,
|
||||
]),
|
||||
);
|
||||
assert(
|
||||
merged.every((line) => line.id.startsWith("current-")),
|
||||
"the ordered current snapshot must remain authoritative for overlapping entries",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("committed history inserts current live rows after their last stable anchor", () => {
|
||||
const historyUser = consoleLine("history-user", "user");
|
||||
historyUser.entryId = "user-1";
|
||||
const currentUser = consoleLine("current-user", "user");
|
||||
currentUser.entryId = "user-1";
|
||||
const live = consoleLine("live-assistant", "in_flight");
|
||||
|
||||
assertEquals(
|
||||
mergeCommittedHistoryLines([historyUser], [currentUser, live]).map((line) =>
|
||||
line.id
|
||||
),
|
||||
["current-user", "live-assistant"],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -286,14 +286,16 @@ export function mergeCommittedHistoryLines(
|
||||
history: ConsoleLine[],
|
||||
current: ConsoleLine[],
|
||||
): ConsoleLine[] {
|
||||
const committed = new Set(
|
||||
history.map((line) => line.entryId).filter((id): id is string =>
|
||||
const currentEntryIds = new Set(
|
||||
current.map((line) => line.entryId).filter((id): id is string =>
|
||||
Boolean(id)
|
||||
),
|
||||
);
|
||||
return [
|
||||
...history,
|
||||
...current.filter((line) => !line.entryId || !committed.has(line.entryId)),
|
||||
...history.filter((line) =>
|
||||
!line.entryId || !currentEntryIds.has(line.entryId)
|
||||
),
|
||||
...current,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
import {
|
||||
applyConsoleHistoryPage,
|
||||
beginConsoleHistoryRequest,
|
||||
conversationTurnPreviewsFromLines,
|
||||
emptyConsoleHistoryState,
|
||||
failConsoleHistoryRequest,
|
||||
historyEntries,
|
||||
@@ -82,7 +83,7 @@
|
||||
shouldLoadHistoryAtTop,
|
||||
type ConsoleHistoryState,
|
||||
} from "$lib/workspace/console/history";
|
||||
import { buildTurnNavigationItems, type TurnNavigationItem } from "$lib/workspace/console/turn-navigation";
|
||||
import type { TurnNavigationItem } from "$lib/workspace/console/turn-navigation";
|
||||
import type { Diagnostic, Worker } from "$lib/workspace/sidebar/types";
|
||||
|
||||
type Props = {
|
||||
@@ -290,7 +291,7 @@
|
||||
mergeCommittedHistoryLines(committedHistoryLines, currentLines),
|
||||
);
|
||||
const turnNavigationItems = $derived(
|
||||
buildTurnNavigationItems(selectedHistory.turns),
|
||||
conversationTurnPreviewsFromLines(lines),
|
||||
);
|
||||
const tasks = $derived(selectedConsoleProjection.tasks);
|
||||
const timelineLayout = $derived(
|
||||
@@ -396,6 +397,7 @@
|
||||
}
|
||||
const payload = (await response.json()) as WorkerSessionHistoryResponse;
|
||||
if (token !== reloadToken || mainHistoryKey(target) !== key) return;
|
||||
if (pendingHistoryRefreshes.has(key)) return;
|
||||
if (payload.availability === "unavailable") {
|
||||
setHistoryState(
|
||||
key,
|
||||
@@ -410,7 +412,12 @@
|
||||
await tick();
|
||||
restoreHistoryAnchors(anchors);
|
||||
} catch (error) {
|
||||
if (token !== reloadToken || mainHistoryKey(target) !== key) return;
|
||||
if (
|
||||
token !== reloadToken ||
|
||||
mainHistoryKey(target) !== key ||
|
||||
pendingHistoryRefreshes.has(key)
|
||||
)
|
||||
return;
|
||||
setHistoryState(
|
||||
key,
|
||||
failConsoleHistoryRequest(
|
||||
@@ -425,6 +432,13 @@
|
||||
token === reloadToken &&
|
||||
mainHistoryKey(target) === key
|
||||
) {
|
||||
const stale = historyStateFor(key);
|
||||
setHistoryState(key, {
|
||||
...stale,
|
||||
status: stale.turns.length > 0 ? "ready" : "idle",
|
||||
error: null,
|
||||
requestedCursor: null,
|
||||
});
|
||||
void requestHistoryPage(target, key, null, token);
|
||||
}
|
||||
}
|
||||
@@ -509,6 +523,22 @@
|
||||
);
|
||||
}
|
||||
|
||||
function fenceCurrentHistoryForRewind() {
|
||||
const target = consoleTarget;
|
||||
if (!target) return;
|
||||
const key = mainHistoryKey(target);
|
||||
const current = historyStateFor(key);
|
||||
const loading = current.status === "loading";
|
||||
const reset = emptyConsoleHistoryState();
|
||||
setHistoryState(key, {
|
||||
...reset,
|
||||
status: loading ? "loading" : "idle",
|
||||
requestedCursor: loading ? current.requestedCursor : null,
|
||||
topEdgeArmed: current.topEdgeArmed,
|
||||
});
|
||||
if (loading) pendingHistoryRefreshes.add(key);
|
||||
}
|
||||
|
||||
function refreshCurrentHistory() {
|
||||
const target = consoleTarget;
|
||||
if (!target) return;
|
||||
@@ -533,7 +563,9 @@
|
||||
}
|
||||
|
||||
function jumpToConversationTurn(item: TurnNavigationItem) {
|
||||
const line = lines.find((candidate) => candidate.entryId === item.lineId);
|
||||
const line = lines.find(
|
||||
(candidate) => candidate.entryId === item.lineId || candidate.id === item.lineId,
|
||||
);
|
||||
const target = line
|
||||
? consoleBodyElement?.querySelector(
|
||||
`[data-console-line-id="${cssEscape(line.id)}"]`,
|
||||
@@ -694,6 +726,22 @@
|
||||
? "shutdown"
|
||||
: workerStateFromSnapshot(consoleProjection.workerState);
|
||||
advanceEventObservedAtVersion();
|
||||
if (
|
||||
eventBatch.some((event) => event.event.event === "rewind_applied")
|
||||
) {
|
||||
fenceCurrentHistoryForRewind();
|
||||
}
|
||||
if (
|
||||
eventBatch.some((event) =>
|
||||
event.event.event === "snapshot" ||
|
||||
event.event.event === "segment_rotated" ||
|
||||
event.event.event === "rewind_applied" ||
|
||||
event.event.event === "user_message" ||
|
||||
event.event.event === "session_entry_committed"
|
||||
)
|
||||
) {
|
||||
refreshCurrentHistory();
|
||||
}
|
||||
if (
|
||||
initialSnapshotApplication &&
|
||||
eventBatch.some(
|
||||
@@ -724,13 +772,6 @@
|
||||
} else if (payload.event === "pending_submissions_changed") {
|
||||
pendingSubmissions = payload.data.pending;
|
||||
}
|
||||
if (
|
||||
payload.event === "segment_rotated" ||
|
||||
payload.event === "user_message" ||
|
||||
payload.event === "session_entry_committed"
|
||||
) {
|
||||
refreshCurrentHistory();
|
||||
}
|
||||
if (payload.event === "error") {
|
||||
queueObservationDiagnostic({
|
||||
code: payload.data.code,
|
||||
@@ -1335,9 +1376,6 @@
|
||||
];
|
||||
return;
|
||||
}
|
||||
untrack(() => {
|
||||
void requestHistoryPage(target, mainHistoryKey(target), null, token);
|
||||
});
|
||||
if (
|
||||
action.kind === "subscribe_live" &&
|
||||
targetWorker &&
|
||||
|
||||
+134
-11
@@ -6,6 +6,7 @@ import {
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from "@testing-library/svelte";
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
import type {
|
||||
@@ -112,6 +113,7 @@ function emptyHistoryPage() {
|
||||
page: {
|
||||
session_id: "session-a",
|
||||
lineage_id: "lineage-a",
|
||||
compact_ancestor_lineage_ids: [],
|
||||
turns: [],
|
||||
next_cursor: null,
|
||||
has_more: false,
|
||||
@@ -125,6 +127,7 @@ function historyPage(indices: number[], cursor: string | null) {
|
||||
page: {
|
||||
session_id: "session-a",
|
||||
lineage_id: "lineage-a",
|
||||
compact_ancestor_lineage_ids: [],
|
||||
turns: indices.map((index) => ({
|
||||
turn_id: `user-${index}`,
|
||||
entries: [
|
||||
@@ -296,7 +299,10 @@ test("applies retained empty and nonempty snapshots before showing their read-on
|
||||
snapshot: sessionWithUserMessage("retained message"),
|
||||
}));
|
||||
|
||||
expect(await screen.findByText("retained message")).not.toBeNull();
|
||||
expect(
|
||||
await within(screen.getByRole("article", { name: "main transcript" }))
|
||||
.findByText("retained message"),
|
||||
).not.toBeNull();
|
||||
expect(screen.getByText("Read-only retained conversation")).not.toBeNull();
|
||||
expect(screen.queryByText("No conversation to display")).toBeNull();
|
||||
});
|
||||
@@ -364,17 +370,107 @@ test("preserves rendered content while reconnecting", async () => {
|
||||
latestListener().onFrame(
|
||||
subscribedFrame(sessionWithUserMessage("kept message")),
|
||||
);
|
||||
expect(await screen.findByText("kept message")).not.toBeNull();
|
||||
expect(
|
||||
await within(screen.getByRole("article", { name: "main transcript" }))
|
||||
.findByText("kept message"),
|
||||
).not.toBeNull();
|
||||
|
||||
latestListener().onStatus?.("closed", "Workspace subscription disconnected");
|
||||
expect(await screen.findByText("Conversation updates are unavailable")).not
|
||||
.toBeNull();
|
||||
expect(screen.getByText("kept message")).not.toBeNull();
|
||||
expect(
|
||||
within(screen.getByRole("article", { name: "main transcript" })).getByText(
|
||||
"kept message",
|
||||
),
|
||||
).not.toBeNull();
|
||||
expect(screen.queryByText("No conversation to display")).toBeNull();
|
||||
|
||||
latestListener().onStatus?.("connecting", "resubscribing");
|
||||
expect(await screen.findByText("Refreshing conversation")).not.toBeNull();
|
||||
expect(screen.getByText("kept message")).not.toBeNull();
|
||||
expect(
|
||||
within(screen.getByRole("article", { name: "main transcript" })).getByText(
|
||||
"kept message",
|
||||
),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
test("reconnect snapshot refreshes lineage and removes stale sibling history", async () => {
|
||||
let historyRequests = 0;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn((input: RequestInfo | URL) => {
|
||||
if (!String(input).includes("/session/history")) {
|
||||
return Promise.resolve(
|
||||
Response.json({ availability: "live_protocol" }),
|
||||
);
|
||||
}
|
||||
historyRequests += 1;
|
||||
const response = historyPage([historyRequests], null);
|
||||
if (historyRequests === 2) {
|
||||
response.page.lineage_id = "lineage-b";
|
||||
}
|
||||
return Promise.resolve(Response.json(response));
|
||||
}),
|
||||
);
|
||||
|
||||
render(ConsolePage, { data: pageData() });
|
||||
await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledOnce());
|
||||
latestListener().onFrame(
|
||||
subscribedFrame(sessionWithUserMessage("lineage A current")),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getAllByText("question 1")).toHaveLength(2)
|
||||
);
|
||||
|
||||
latestListener().onStatus?.("closed", "disconnected during rewind");
|
||||
latestListener().onStatus?.("connecting", "reconnecting");
|
||||
latestListener().onFrame(
|
||||
subscribedFrame(sessionWithUserMessage("lineage B current")),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getAllByText("question 2")).toHaveLength(2)
|
||||
);
|
||||
expect(screen.queryByText("question 1")).toBeNull();
|
||||
expect(historyRequests).toBe(2);
|
||||
});
|
||||
|
||||
test("snapshot supersedes an in-flight page before stale lineage can render", async () => {
|
||||
const staleHistory = deferred<Response>();
|
||||
let historyRequests = 0;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn((input: RequestInfo | URL) => {
|
||||
if (!String(input).includes("/session/history")) {
|
||||
return Promise.resolve(
|
||||
Response.json({ availability: "live_protocol" }),
|
||||
);
|
||||
}
|
||||
historyRequests += 1;
|
||||
if (historyRequests === 1) return staleHistory.promise;
|
||||
const response = historyPage([2], null);
|
||||
response.page.lineage_id = "lineage-b";
|
||||
return Promise.resolve(Response.json(response));
|
||||
}),
|
||||
);
|
||||
|
||||
render(ConsolePage, { data: pageData() });
|
||||
await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledOnce());
|
||||
latestListener().onFrame(
|
||||
subscribedFrame(sessionWithUserMessage("lineage A current")),
|
||||
);
|
||||
await waitFor(() => expect(historyRequests).toBe(1));
|
||||
|
||||
latestListener().onFrame(
|
||||
subscribedFrame(sessionWithUserMessage("lineage B current")),
|
||||
);
|
||||
staleHistory.resolve(Response.json(historyPage([1], null)));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getAllByText("question 2")).toHaveLength(2)
|
||||
);
|
||||
expect(screen.queryByText("question 1")).toBeNull();
|
||||
expect(historyRequests).toBe(2);
|
||||
});
|
||||
|
||||
test("route changes clear prior content and fence stale Session responses", async () => {
|
||||
@@ -400,7 +496,10 @@ test("route changes clear prior content and fence stale Session responses", asyn
|
||||
latestListener().onFrame(
|
||||
subscribedFrame(sessionWithUserMessage("previous worker content")),
|
||||
);
|
||||
expect(await screen.findByText("previous worker content")).not.toBeNull();
|
||||
expect(
|
||||
await within(screen.getByRole("article", { name: "main transcript" }))
|
||||
.findByText("previous worker content"),
|
||||
).not.toBeNull();
|
||||
|
||||
latestListener().onStatus?.("closed", "reload old worker");
|
||||
await fireEvent.click(
|
||||
@@ -408,7 +507,10 @@ test("route changes clear prior content and fence stale Session responses", asyn
|
||||
);
|
||||
await waitFor(() => expect(firstWorkerLoads).toBe(2));
|
||||
await view.rerender({ data: pageData("worker-b", "runtime-b") });
|
||||
expect(screen.queryByText("previous worker content")).toBeNull();
|
||||
expect(
|
||||
within(screen.getByRole("article", { name: "main transcript" }))
|
||||
.queryByText("previous worker content"),
|
||||
).toBeNull();
|
||||
expect(screen.getByRole("status").textContent).toContain(
|
||||
"Loading conversation",
|
||||
);
|
||||
@@ -417,7 +519,12 @@ test("route changes clear prior content and fence stale Session responses", asyn
|
||||
availability: "retained_snapshot",
|
||||
snapshot: sessionWithUserMessage("current worker content"),
|
||||
}));
|
||||
expect(await screen.findByText("current worker content")).not.toBeNull();
|
||||
expect(
|
||||
await within(screen.getByRole("article", { name: "main transcript" }))
|
||||
.findByText(
|
||||
"current worker content",
|
||||
),
|
||||
).not.toBeNull();
|
||||
|
||||
staleReload.resolve(Response.json({
|
||||
availability: "unavailable",
|
||||
@@ -425,8 +532,15 @@ test("route changes clear prior content and fence stale Session responses", asyn
|
||||
}));
|
||||
await settleMicrotasks();
|
||||
expect(screen.queryByText("stale worker error")).toBeNull();
|
||||
expect(screen.queryByText("previous worker content")).toBeNull();
|
||||
expect(screen.getByText("current worker content")).not.toBeNull();
|
||||
expect(
|
||||
within(screen.getByRole("article", { name: "main transcript" }))
|
||||
.queryByText("previous worker content"),
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(screen.getByRole("article", { name: "main transcript" })).getByText(
|
||||
"current worker content",
|
||||
),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
test("loads retained conversation turns five at a time into the log and turn bar", async () => {
|
||||
@@ -436,6 +550,7 @@ test("loads retained conversation turns five at a time into the log and turn bar
|
||||
page: {
|
||||
session_id: "session-a",
|
||||
lineage_id: "lineage-a",
|
||||
compact_ancestor_lineage_ids: [],
|
||||
turns: [8, 9, 10, 11, 12].map((index) => ({
|
||||
turn_id: `user-${index}`,
|
||||
entries: [
|
||||
@@ -466,6 +581,7 @@ test("loads retained conversation turns five at a time into the log and turn bar
|
||||
page: {
|
||||
session_id: "session-a",
|
||||
lineage_id: "lineage-a",
|
||||
compact_ancestor_lineage_ids: [],
|
||||
turns: [3, 4, 5, 6, 7].map((index) => ({
|
||||
turn_id: `user-${index}`,
|
||||
entries: [{
|
||||
@@ -567,7 +683,10 @@ test("keeps live content when history fails and retries the bounded page explici
|
||||
subscribedFrame(sessionWithUserMessage("current response remains")),
|
||||
);
|
||||
|
||||
expect(await screen.findByText("current response remains")).not.toBeNull();
|
||||
expect(
|
||||
await within(screen.getByRole("article", { name: "main transcript" }))
|
||||
.findByText("current response remains"),
|
||||
).not.toBeNull();
|
||||
await fireEvent.click(
|
||||
await screen.findByRole("button", {
|
||||
name: "Earlier conversation unavailable · retry",
|
||||
@@ -577,7 +696,11 @@ test("keeps live content when history fails and retries the bounded page explici
|
||||
expect(screen.getAllByText("question 1")).toHaveLength(2)
|
||||
);
|
||||
expect(historyRequests).toBe(2);
|
||||
expect(screen.getByText("current response remains")).not.toBeNull();
|
||||
expect(
|
||||
within(screen.getByRole("article", { name: "main transcript" })).getByText(
|
||||
"current response remains",
|
||||
),
|
||||
).not.toBeNull();
|
||||
expect(screen.getAllByText("Start of conversation")).toHaveLength(2);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user