fix(console): reconcile history around lineage anchors
This commit is contained in:
@@ -842,6 +842,16 @@ pub struct SessionConversationTurn {
|
|||||||
pub entries: Vec<SessionSnapshotEntry>,
|
pub entries: Vec<SessionSnapshotEntry>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The immediate parent lineage and the stable real-user boundary adopted from it.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||||
|
pub struct SessionHistoryLineageBoundary {
|
||||||
|
pub lineage_id: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub adopted_through_turn_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Bounded backward page over the adopted conversation lineage.
|
/// Bounded backward page over the adopted conversation lineage.
|
||||||
///
|
///
|
||||||
/// Turns remain chronological within a page. `next_cursor` is an opaque,
|
/// Turns remain chronological within a page. `next_cursor` is an opaque,
|
||||||
@@ -859,6 +869,10 @@ pub struct SessionHistoryPage {
|
|||||||
/// cursor with this page's boundary.
|
/// cursor with this page's boundary.
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
pub compact_ancestor_lineage_ids: Vec<String>,
|
pub compact_ancestor_lineage_ids: Vec<String>,
|
||||||
|
/// Immediate parent relationship used to retain only the adopted prefix
|
||||||
|
/// across fork/rewind refreshes that have no newest-page overlap.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub parent_lineage: Option<SessionHistoryLineageBoundary>,
|
||||||
pub turns: Vec<SessionConversationTurn>,
|
pub turns: Vec<SessionConversationTurn>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub next_cursor: Option<String>,
|
pub next_cursor: Option<String>,
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ use crate::{
|
|||||||
PasteArtifactAvailability, PasteArtifactMediaType, PasteArtifactRef, PendingSubmissionSummary,
|
PasteArtifactAvailability, PasteArtifactMediaType, PasteArtifactRef, PendingSubmissionSummary,
|
||||||
PendingSubmissionsSnapshot, Permission, RewindSummary, RewindTarget, RewindTargetId, RunResult,
|
PendingSubmissionsSnapshot, Permission, RewindSummary, RewindTarget, RewindTargetId, RunResult,
|
||||||
ScopeRule, Segment, SessionContentPart, SessionConversationTurn, SessionEntryProvenance,
|
ScopeRule, Segment, SessionContentPart, SessionConversationTurn, SessionEntryProvenance,
|
||||||
SessionHistoryPage, SessionMessageRole, SessionSnapshot, SessionSnapshotEntry,
|
SessionHistoryLineageBoundary, SessionHistoryPage, SessionMessageRole, SessionSnapshot,
|
||||||
SessionSnapshotEntryData, SessionToolAttachment, SubmissionDisposition, SymlinkPolicy,
|
SessionSnapshotEntry, SessionSnapshotEntryData, SessionToolAttachment, SubmissionDisposition,
|
||||||
ToolResultDisposition, TurnResult, UploadedFileAvailability, UploadedFileRef, WorkerBusyState,
|
SymlinkPolicy, ToolResultDisposition, TurnResult, UploadedFileAvailability, UploadedFileRef,
|
||||||
WorkerCommandAcknowledgement, WorkerCommandDisposition, WorkerCommandEnvelope,
|
WorkerBusyState, WorkerCommandAcknowledgement, WorkerCommandDisposition, WorkerCommandEnvelope,
|
||||||
WorkerCommandKind, WorkerEvent, WorkerMaintenanceState, WorkerRunState, WorkerState,
|
WorkerCommandKind, WorkerEvent, WorkerMaintenanceState, WorkerRunState, WorkerState,
|
||||||
WorkerStateSnapshot, WorkerStatus,
|
WorkerStateSnapshot, WorkerStatus,
|
||||||
subscription::{
|
subscription::{
|
||||||
@@ -106,6 +106,7 @@ pub fn generated_protocol_types() -> String {
|
|||||||
push_decl::<SessionSnapshotEntryData>(&cfg, &mut output);
|
push_decl::<SessionSnapshotEntryData>(&cfg, &mut output);
|
||||||
push_decl::<SessionSnapshotEntry>(&cfg, &mut output);
|
push_decl::<SessionSnapshotEntry>(&cfg, &mut output);
|
||||||
push_decl::<SessionConversationTurn>(&cfg, &mut output);
|
push_decl::<SessionConversationTurn>(&cfg, &mut output);
|
||||||
|
push_decl::<SessionHistoryLineageBoundary>(&cfg, &mut output);
|
||||||
push_decl::<SessionHistoryPage>(&cfg, &mut output);
|
push_decl::<SessionHistoryPage>(&cfg, &mut output);
|
||||||
push_decl::<PendingSubmissionSummary>(&cfg, &mut output);
|
push_decl::<PendingSubmissionSummary>(&cfg, &mut output);
|
||||||
push_decl::<PendingSubmissionsSnapshot>(&cfg, &mut output);
|
push_decl::<PendingSubmissionsSnapshot>(&cfg, &mut output);
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ use base64::{
|
|||||||
};
|
};
|
||||||
use protocol::{
|
use protocol::{
|
||||||
Segment, SessionContentPart, SessionConversationTurn, SessionEntryProvenance,
|
Segment, SessionContentPart, SessionConversationTurn, SessionEntryProvenance,
|
||||||
SessionHistoryPage, SessionMessageRole, SessionSnapshot, SessionSnapshotEntry,
|
SessionHistoryLineageBoundary, SessionHistoryPage, SessionMessageRole, SessionSnapshot,
|
||||||
SessionSnapshotEntryData, SessionToolAttachment,
|
SessionSnapshotEntry, SessionSnapshotEntryData, SessionToolAttachment,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
@@ -261,6 +261,28 @@ pub fn read_retained_session_history_page(
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
let parent_lineage = if lineage.len() > 1 {
|
||||||
|
let parent_turns = read_history_turns_backward(
|
||||||
|
&store,
|
||||||
|
active.session_id,
|
||||||
|
&lineage[1..],
|
||||||
|
None,
|
||||||
|
1,
|
||||||
|
RetainedHistoryReadLimits {
|
||||||
|
max_scan_bytes: limits.max_scan_bytes.saturating_sub(metadata_bytes),
|
||||||
|
max_entries: limits.max_entries.saturating_sub(metadata_entries),
|
||||||
|
..limits
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
Some(SessionHistoryLineageBoundary {
|
||||||
|
lineage_id: lineage_identity(active.session_id, &lineage[1..]),
|
||||||
|
adopted_through_turn_id: parent_turns
|
||||||
|
.first()
|
||||||
|
.map(|positioned| positioned.turn.turn_id.clone()),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
let mut turns = read_history_turns_backward(
|
let mut turns = read_history_turns_backward(
|
||||||
&store,
|
&store,
|
||||||
active.session_id,
|
active.session_id,
|
||||||
@@ -302,6 +324,7 @@ pub fn read_retained_session_history_page(
|
|||||||
session_id: active.session_id.to_string(),
|
session_id: active.session_id.to_string(),
|
||||||
lineage_id,
|
lineage_id,
|
||||||
compact_ancestor_lineage_ids,
|
compact_ancestor_lineage_ids,
|
||||||
|
parent_lineage,
|
||||||
turns: turns
|
turns: turns
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|positioned| positioned.turn)
|
.map(|positioned| positioned.turn)
|
||||||
@@ -1735,6 +1758,21 @@ mod tests {
|
|||||||
.all(|entry| entry.entry_id != "assistant-1-unadopted")
|
.all(|entry| entry.entry_id != "assistant-1-unadopted")
|
||||||
}));
|
}));
|
||||||
assert!(page.turns.iter().all(|turn| turn.turn_id != "user-2"));
|
assert!(page.turns.iter().all(|turn| turn.turn_id != "user-2"));
|
||||||
|
let store = WorkerSessionStore::open_read_only(aggregate_root.join("session")).unwrap();
|
||||||
|
let (_, source_lineage_id, _, _) = load_adopted_lineage(
|
||||||
|
&store,
|
||||||
|
session_id,
|
||||||
|
source_segment,
|
||||||
|
RetainedHistoryReadLimits::default(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
page.parent_lineage,
|
||||||
|
Some(SessionHistoryLineageBoundary {
|
||||||
|
lineage_id: source_lineage_id,
|
||||||
|
adopted_through_turn_id: Some("user-1".to_string()),
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -151,6 +151,8 @@ timestamp: number, provenance: SessionEntryProvenance, derived_from?: Array<stri
|
|||||||
|
|
||||||
export type SessionConversationTurn = { turn_id: string, entries: Array<SessionSnapshotEntry>, };
|
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,
|
export type SessionHistoryPage = { session_id: string,
|
||||||
/**
|
/**
|
||||||
* Stable identity of the adopted active lineage used to fence stale pages.
|
* 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
|
* loaded turns when their prior lineage appears here, while replacing the
|
||||||
* cursor with this page's boundary.
|
* 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, };
|
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,
|
cursor: string | null,
|
||||||
lineageId = "lineage-a",
|
lineageId = "lineage-a",
|
||||||
compactAncestorLineageIds: string[] = [],
|
compactAncestorLineageIds: string[] = [],
|
||||||
|
parentLineage?: {
|
||||||
|
lineage_id: string;
|
||||||
|
adopted_through_turn_id?: string | null;
|
||||||
|
},
|
||||||
): SessionHistoryPage {
|
): SessionHistoryPage {
|
||||||
return {
|
return {
|
||||||
session_id: "session-a",
|
session_id: "session-a",
|
||||||
lineage_id: lineageId,
|
lineage_id: lineageId,
|
||||||
compact_ancestor_lineage_ids: compactAncestorLineageIds,
|
compact_ancestor_lineage_ids: compactAncestorLineageIds,
|
||||||
|
parent_lineage: parentLineage,
|
||||||
turns,
|
turns,
|
||||||
next_cursor: cursor,
|
next_cursor: cursor,
|
||||||
has_more: cursor !== null,
|
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", () => {
|
Deno.test("changed lineage without a stable overlap replaces retained turns", () => {
|
||||||
let state: ConsoleHistoryState = {
|
let state: ConsoleHistoryState = {
|
||||||
...emptyConsoleHistoryState(),
|
...emptyConsoleHistoryState(),
|
||||||
|
|||||||
@@ -86,6 +86,9 @@ export function applyConsoleHistoryPage(
|
|||||||
state.turns.length > 0;
|
state.turns.length > 0;
|
||||||
const refreshingSameLineage = refreshingSameSession &&
|
const refreshingSameLineage = refreshingSameSession &&
|
||||||
state.lineageId === page.lineage_id;
|
state.lineageId === page.lineage_id;
|
||||||
|
const refreshingParentLineage = refreshingSameSession &&
|
||||||
|
state.lineageId !== null &&
|
||||||
|
page.parent_lineage?.lineage_id === state.lineageId;
|
||||||
const refreshingCompactDescendant = refreshingSameSession &&
|
const refreshingCompactDescendant = refreshingSameSession &&
|
||||||
state.lineageId !== null &&
|
state.lineageId !== null &&
|
||||||
(page.compact_ancestor_lineage_ids ?? []).includes(state.lineageId);
|
(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),
|
(turn) => state.turns.some((retained) => retained.turn_id === turn.turn_id),
|
||||||
);
|
);
|
||||||
const turns = initial
|
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)
|
? mergeRefreshedTurns(state.turns, incoming)
|
||||||
: refreshingSameSession
|
: refreshingSameSession
|
||||||
? reconcileChangedLineage(state.turns, incoming)
|
? reconcileChangedLineage(state.turns, incoming)
|
||||||
@@ -216,6 +227,19 @@ function previewAssistant(value: string): string[] {
|
|||||||
.slice(0, 3);
|
.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(
|
function reconcileChangedLineage(
|
||||||
retained: readonly SessionConversationTurn[],
|
retained: readonly SessionConversationTurn[],
|
||||||
refreshed: 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", () => {
|
Deno.test("committed history inserts current live rows after their last stable anchor", () => {
|
||||||
const historyUser = consoleLine("history-user", "user");
|
const historyUser = consoleLine("history-user", "user");
|
||||||
historyUser.entryId = "user-1";
|
historyUser.entryId = "user-1";
|
||||||
|
|||||||
@@ -291,12 +291,59 @@ export function mergeCommittedHistoryLines(
|
|||||||
Boolean(id)
|
Boolean(id)
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return [
|
const historyPositions = new Map<string, number>();
|
||||||
...history.filter((line) =>
|
history.forEach((line, index) => {
|
||||||
!line.entryId || !currentEntryIds.has(line.entryId)
|
if (line.entryId && !historyPositions.has(line.entryId)) {
|
||||||
),
|
historyPositions.set(line.entryId, index);
|
||||||
...current,
|
}
|
||||||
];
|
});
|
||||||
|
|
||||||
|
// 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(
|
export function projectSessionHistoryEntries(
|
||||||
|
|||||||
+68
-2
@@ -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 {
|
return {
|
||||||
availability: "page",
|
availability: "page",
|
||||||
page: {
|
page: {
|
||||||
session_id: "session-a",
|
session_id: "session-a",
|
||||||
lineage_id: "lineage-a",
|
lineage_id: lineageId,
|
||||||
compact_ancestor_lineage_ids: [],
|
compact_ancestor_lineage_ids: [],
|
||||||
|
parent_lineage: parentLineage,
|
||||||
turns: indices.map((index) => ({
|
turns: indices.map((index) => ({
|
||||||
turn_id: `user-${index}`,
|
turn_id: `user-${index}`,
|
||||||
entries: [
|
entries: [
|
||||||
@@ -473,6 +482,63 @@ test("snapshot supersedes an in-flight page before stale lineage can render", as
|
|||||||
expect(historyRequests).toBe(2);
|
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 () => {
|
test("route changes clear prior content and fence stale Session responses", async () => {
|
||||||
const staleReload = deferred<Response>();
|
const staleReload = deferred<Response>();
|
||||||
const current = deferred<Response>();
|
const current = deferred<Response>();
|
||||||
|
|||||||
Reference in New Issue
Block a user