fix(console): bound retained history reconciliation

This commit is contained in:
2026-09-30 10:56:01 +09:00
parent aa144ee6fa
commit 4976b6fd00
12 changed files with 1725 additions and 235 deletions
+8 -1
View File
@@ -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 &&
@@ -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);
});