fix(console): reconcile history around lineage anchors

This commit is contained in:
2026-09-30 11:16:23 +09:00
parent 4976b6fd00
commit 709f3c5530
9 changed files with 288 additions and 16 deletions
+8 -1
View File
@@ -151,6 +151,8 @@ timestamp: number, provenance: SessionEntryProvenance, derived_from?: Array<stri
export type SessionConversationTurn = { turn_id: string, entries: Array<SessionSnapshotEntry>, };
export type SessionHistoryLineageBoundary = { lineage_id: string, adopted_through_turn_id?: string | null, };
export type SessionHistoryPage = { session_id: string,
/**
* Stable identity of the adopted active lineage used to fence stale pages.
@@ -162,7 +164,12 @@ lineage_id: string,
* 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, };
compact_ancestor_lineage_ids?: Array<string>,
/**
* Immediate parent relationship used to retain only the adopted prefix
* across fork/rewind refreshes that have no newest-page overlap.
*/
parent_lineage?: SessionHistoryLineageBoundary | null, 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, };
@@ -56,11 +56,16 @@ function page(
cursor: string | null,
lineageId = "lineage-a",
compactAncestorLineageIds: string[] = [],
parentLineage?: {
lineage_id: string;
adopted_through_turn_id?: string | null;
},
): SessionHistoryPage {
return {
session_id: "session-a",
lineage_id: lineageId,
compact_ancestor_lineage_ids: compactAncestorLineageIds,
parent_lineage: parentLineage,
turns,
next_cursor: cursor,
has_more: cursor !== null,
@@ -240,6 +245,46 @@ Deno.test("Compact lineage refresh preserves already loaded adopted history", ()
);
});
Deno.test("fork lineage boundary preserves an adopted prefix without page overlap", () => {
let state: ConsoleHistoryState = {
...emptyConsoleHistoryState(),
sessionId: "session-a",
lineageId: "lineage-a",
turns: Array.from({ length: 50 }, (_, index) => turn(index + 1)),
cursor: null,
hasMore: false,
status: "loading",
requestedCursor: "__initial__",
};
state = applyConsoleHistoryPage(
state,
page(
[101, 102, 103, 104, 105].map(turn),
"cursor-101",
"lineage-fork",
[],
{
lineage_id: "lineage-a",
adopted_through_turn_id: "u-25",
},
),
null,
);
assert(
state.turns.map((value) => value.turn_id).join(",") ===
[
...Array.from({ length: 25 }, (_, index) => `u-${index + 1}`),
...Array.from({ length: 5 }, (_, index) => `u-${index + 101}`),
].join(","),
"fork refresh must retain only the authoritative adopted prefix",
);
assert(
state.cursor === "cursor-101" && state.hasMore,
"fork refresh must replace the old paging boundary",
);
});
Deno.test("changed lineage without a stable overlap replaces retained turns", () => {
let state: ConsoleHistoryState = {
...emptyConsoleHistoryState(),
@@ -86,6 +86,9 @@ export function applyConsoleHistoryPage(
state.turns.length > 0;
const refreshingSameLineage = refreshingSameSession &&
state.lineageId === page.lineage_id;
const refreshingParentLineage = refreshingSameSession &&
state.lineageId !== null &&
page.parent_lineage?.lineage_id === state.lineageId;
const refreshingCompactDescendant = refreshingSameSession &&
state.lineageId !== null &&
(page.compact_ancestor_lineage_ids ?? []).includes(state.lineageId);
@@ -93,7 +96,15 @@ export function applyConsoleHistoryPage(
(turn) => state.turns.some((retained) => retained.turn_id === turn.turn_id),
);
const turns = initial
? refreshingSameLineage || refreshingCompactDescendant
? refreshingSameLineage
? mergeRefreshedTurns(state.turns, incoming)
: refreshingParentLineage
? reconcileParentLineage(
state.turns,
incoming,
page.parent_lineage?.adopted_through_turn_id ?? null,
)
: refreshingCompactDescendant
? mergeRefreshedTurns(state.turns, incoming)
: refreshingSameSession
? reconcileChangedLineage(state.turns, incoming)
@@ -216,6 +227,19 @@ function previewAssistant(value: string): string[] {
.slice(0, 3);
}
function reconcileParentLineage(
retained: readonly SessionConversationTurn[],
refreshed: readonly SessionConversationTurn[],
adoptedThroughTurnId: string | null,
): SessionConversationTurn[] {
if (adoptedThroughTurnId === null) return dedupeTurns(refreshed);
const boundary = retained.findIndex((turn) =>
turn.turn_id === adoptedThroughTurnId
);
if (boundary < 0) return dedupeTurns(refreshed);
return dedupeTurns([...retained.slice(0, boundary + 1), ...refreshed]);
}
function reconcileChangedLineage(
retained: readonly SessionConversationTurn[],
refreshed: readonly SessionConversationTurn[],
@@ -3112,6 +3112,36 @@ Deno.test("committed history preserves unmatched current rows in stable entry or
);
});
Deno.test("committed history places omitted retained rows between snapshot anchors", () => {
const makeLine = (prefix: string, turn: number) => {
const line = consoleLine(`${prefix}-${turn}`, "user");
line.entryId = `user-${turn}`;
return line;
};
const history = [2, 3, 4, 5, 6].map((turn) => makeLine("history", turn));
const current = [1, 2, 6].map((turn) => makeLine("current", turn));
assertEquals(
mergeCommittedHistoryLines(history, current).map((line) => line.entryId),
[1, 2, 3, 4, 5, 6].map((turn) => `user-${turn}`),
);
});
Deno.test("committed history places a retained suffix before newer current-only rows", () => {
const makeLine = (prefix: string, turn: number) => {
const line = consoleLine(`${prefix}-${turn}`, "user");
line.entryId = `user-${turn}`;
return line;
};
const history = [2, 3, 4, 5, 6].map((turn) => makeLine("history", turn));
const current = [1, 2, 7].map((turn) => makeLine("current", turn));
assertEquals(
mergeCommittedHistoryLines(history, current).map((line) => line.entryId),
[1, 2, 3, 4, 5, 6, 7].map((turn) => `user-${turn}`),
);
});
Deno.test("committed history inserts current live rows after their last stable anchor", () => {
const historyUser = consoleLine("history-user", "user");
historyUser.entryId = "user-1";
@@ -291,12 +291,59 @@ export function mergeCommittedHistoryLines(
Boolean(id)
),
);
return [
...history.filter((line) =>
!line.entryId || !currentEntryIds.has(line.entryId)
),
...current,
];
const historyPositions = new Map<string, number>();
history.forEach((line, index) => {
if (line.entryId && !historyPositions.has(line.entryId)) {
historyPositions.set(line.entryId, index);
}
});
// Use stable entries that occur in the same order in both projections as
// anchors. History-only rows are inserted in their retained interval, while
// current-only rows retain the snapshot's authoritative order. This handles
// Compact snapshots that preserve only a non-contiguous subset of context.
const anchors: Array<{ history: number; current: number }> = [];
let lastHistoryIndex = -1;
current.forEach((line, currentIndex) => {
const historyIndex = line.entryId
? historyPositions.get(line.entryId)
: undefined;
if (historyIndex !== undefined && historyIndex > lastHistoryIndex) {
anchors.push({ history: historyIndex, current: currentIndex });
lastHistoryIndex = historyIndex;
}
});
if (anchors.length === 0) {
return [
...history.filter((line) =>
!line.entryId || !currentEntryIds.has(line.entryId)
),
...current,
];
}
const merged: ConsoleLine[] = [];
let historyCursor = 0;
let currentCursor = 0;
const appendHistoryInterval = (end: number) => {
for (; historyCursor < end; historyCursor += 1) {
const line = history[historyCursor]!;
if (!line.entryId || !currentEntryIds.has(line.entryId)) {
merged.push(line);
}
}
};
for (const anchor of anchors) {
appendHistoryInterval(anchor.history);
merged.push(...current.slice(currentCursor, anchor.current));
merged.push(current[anchor.current]!);
historyCursor = anchor.history + 1;
currentCursor = anchor.current + 1;
}
appendHistoryInterval(history.length);
merged.push(...current.slice(currentCursor));
return merged;
}
export function projectSessionHistoryEntries(
@@ -121,13 +121,22 @@ function emptyHistoryPage() {
};
}
function historyPage(indices: number[], cursor: string | null) {
function historyPage(
indices: number[],
cursor: string | null,
lineageId = "lineage-a",
parentLineage?: {
lineage_id: string;
adopted_through_turn_id?: string | null;
},
) {
return {
availability: "page",
page: {
session_id: "session-a",
lineage_id: "lineage-a",
lineage_id: lineageId,
compact_ancestor_lineage_ids: [],
parent_lineage: parentLineage,
turns: indices.map((index) => ({
turn_id: `user-${index}`,
entries: [
@@ -473,6 +482,63 @@ test("snapshot supersedes an in-flight page before stale lineage can render", as
expect(historyRequests).toBe(2);
});
test("fork refresh reconciles an in-flight older page to the adopted prefix", async () => {
const staleOlder = 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 Promise.resolve(
Response.json(historyPage([46, 47, 48, 49, 50], "older-46")),
);
}
if (historyRequests === 2) return staleOlder.promise;
return Promise.resolve(Response.json(historyPage(
[101, 102, 103, 104, 105],
"older-101",
"lineage-fork",
{
lineage_id: "lineage-a",
adopted_through_turn_id: "user-48",
},
)));
}),
);
render(ConsolePage, { data: pageData() });
await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledOnce());
latestListener().onFrame(subscribedFrame());
await waitFor(() =>
expect(screen.getAllByText("question 50")).toHaveLength(2)
);
const transcriptScroller = screen.getByLabelText("main transcript")
.parentElement as HTMLElement;
transcriptScroller.scrollTop = 0;
await fireEvent.scroll(transcriptScroller);
await waitFor(() => expect(historyRequests).toBe(2));
latestListener().onFrame(
subscribedFrame(sessionWithUserMessage("fork current")),
);
staleOlder.resolve(Response.json(historyPage([41, 42, 43, 44, 45], null)));
await waitFor(() =>
expect(screen.getAllByText("question 101")).toHaveLength(2)
);
expect(screen.getAllByText("question 48")).toHaveLength(2);
expect(screen.queryByText("question 49")).toBeNull();
expect(screen.getAllByText("question 41")).toHaveLength(2);
expect(historyRequests).toBe(3);
});
test("route changes clear prior content and fence stale Session responses", async () => {
const staleReload = deferred<Response>();
const current = deferred<Response>();