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
+6
View File
@@ -853,6 +853,12 @@ pub struct SessionHistoryPage {
pub session_id: String,
/// Stable identity of the adopted active lineage used to fence stale pages.
pub 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.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub compact_ancestor_lineage_ids: Vec<String>,
pub turns: Vec<SessionConversationTurn>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub next_cursor: Option<String>,
File diff suppressed because it is too large Load Diff
@@ -17,7 +17,7 @@ use crate::{PasteArtifactLimits, SegmentId, SessionId};
use protocol::PasteArtifactRef;
use serde::{Deserialize, Serialize};
use std::fs::{self, File, OpenOptions};
use std::io::{Read, Seek, SeekFrom, Write};
use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::SystemTime;
@@ -42,6 +42,106 @@ struct SessionManifest {
session_id: SessionId,
}
const RETAINED_BACKWARD_READ_CHUNK_BYTES: usize = 64 * 1024;
#[derive(Debug)]
pub(crate) struct RetainedLogRecord {
pub start_offset: u64,
pub end_offset: u64,
pub entry: LogEntry,
}
pub(crate) struct RetainedSegmentReader {
file: File,
read_end: u64,
buffer_start: u64,
buffer: Vec<u8>,
}
impl RetainedSegmentReader {
pub fn previous_record(
&mut self,
max_additional_bytes: u64,
) -> Result<(Option<RetainedLogRecord>, u64), StoreError> {
let mut read_bytes = 0_u64;
loop {
if !self.buffer.is_empty() {
if self.buffer.last() != Some(&b'\n') {
return Err(StoreError::Corrupt {
line: 0,
message: "retained Segment cursor is not at a record boundary".to_string(),
});
}
let prior_newline = self.buffer[..self.buffer.len() - 1]
.iter()
.rposition(|byte| *byte == b'\n');
if let Some(newline) =
prior_newline.or((self.buffer_start == 0).then_some(usize::MAX))
{
let record_start_in_buffer = if newline == usize::MAX {
0
} else {
newline + 1
};
let record_start = self.buffer_start + record_start_in_buffer as u64;
let record_end = self.buffer_start + self.buffer.len() as u64;
let record =
self.buffer[record_start_in_buffer..self.buffer.len() - 1].to_vec();
self.buffer.truncate(record_start_in_buffer);
self.read_end = record_start;
if record.is_empty() {
return Err(StoreError::Corrupt {
line: 0,
message: "empty retained Segment log record".to_string(),
});
}
let entry = serde_json::from_slice(&record)?;
return Ok((
Some(RetainedLogRecord {
start_offset: record_start,
end_offset: record_end,
entry,
}),
read_bytes,
));
}
} else if self.read_end == 0 {
return Ok((None, read_bytes));
}
if self.buffer_start == 0 && !self.buffer.is_empty() {
return Err(StoreError::Corrupt {
line: 0,
message: "retained Segment log does not end at a record boundary".to_string(),
});
}
if read_bytes >= max_additional_bytes {
return Err(StoreError::ReadLimitExceeded);
}
let end = if self.buffer.is_empty() {
self.read_end
} else {
self.buffer_start
};
let available = max_additional_bytes - read_bytes;
let chunk_len = end
.min(RETAINED_BACKWARD_READ_CHUNK_BYTES as u64)
.min(available);
if chunk_len == 0 {
return Err(StoreError::ReadLimitExceeded);
}
let start = end - chunk_len;
self.file.seek(SeekFrom::Start(start))?;
let mut chunk = vec![0_u8; chunk_len as usize];
self.file.read_exact(&mut chunk)?;
read_bytes += chunk_len;
chunk.extend_from_slice(&self.buffer);
self.buffer = chunk;
self.buffer_start = start;
}
}
}
impl WorkerSessionStore {
/// Open the Session store rooted at `<worker-aggregate>/session`.
pub fn new(root: impl Into<PathBuf>) -> Result<Self, StoreError> {
@@ -150,6 +250,198 @@ impl WorkerSessionStore {
Ok((parse_jsonl(&bytes)?, byte_len))
}
pub(crate) fn read_first_log_record_read_only_bounded(
&self,
session_id: SessionId,
segment_id: SegmentId,
max_bytes: u64,
) -> Result<(Option<RetainedLogRecord>, u64, u64), StoreError> {
self.validate_retained_session(session_id)?;
let file = open_retained_file(&self.log_path(segment_id)).map_err(|error| {
if error.kind() == std::io::ErrorKind::NotFound {
StoreError::NotFound(segment_id)
} else {
StoreError::Io(error)
}
})?;
let file_len = file.metadata()?.len();
if file_len == 0 {
return Ok((None, 0, 0));
}
let mut reader = BufReader::new(file.take(max_bytes.saturating_add(1)));
let mut record = Vec::new();
let read_bytes = reader.read_until(b'\n', &mut record)? as u64;
if read_bytes > max_bytes || record.last() != Some(&b'\n') {
return Err(StoreError::ReadLimitExceeded);
}
record.pop();
if record.is_empty() {
return Err(StoreError::Corrupt {
line: 0,
message: "empty retained Segment log record".to_string(),
});
}
let entry = serde_json::from_slice(&record)?;
Ok((
Some(RetainedLogRecord {
start_offset: 0,
end_offset: read_bytes,
entry,
}),
read_bytes,
file_len,
))
}
pub(crate) fn read_next_log_record_read_only_bounded(
&self,
session_id: SessionId,
segment_id: SegmentId,
start_offset: u64,
max_bytes: u64,
) -> Result<(Option<RetainedLogRecord>, u64), StoreError> {
self.validate_retained_session(session_id)?;
let mut file = open_retained_file(&self.log_path(segment_id)).map_err(|error| {
if error.kind() == std::io::ErrorKind::NotFound {
StoreError::NotFound(segment_id)
} else {
StoreError::Io(error)
}
})?;
let file_len = file.metadata()?.len();
if start_offset >= file_len {
return Ok((None, 0));
}
file.seek(SeekFrom::Start(start_offset))?;
let mut reader = BufReader::new(file.take(max_bytes.saturating_add(1)));
let mut record = Vec::new();
let read_bytes = reader.read_until(b'\n', &mut record)? as u64;
if read_bytes > max_bytes || record.last() != Some(&b'\n') {
return Err(StoreError::ReadLimitExceeded);
}
record.pop();
if record.is_empty() {
return Err(StoreError::Corrupt {
line: 0,
message: "empty retained Segment log record".to_string(),
});
}
let entry = serde_json::from_slice(&record)?;
Ok((
Some(RetainedLogRecord {
start_offset,
end_offset: start_offset + read_bytes,
entry,
}),
read_bytes,
))
}
pub(crate) fn read_log_record_range_read_only_bounded(
&self,
session_id: SessionId,
segment_id: SegmentId,
start_offset: u64,
end_offset: u64,
max_bytes: u64,
) -> Result<RetainedLogRecord, StoreError> {
self.validate_retained_session(session_id)?;
let record_len =
end_offset
.checked_sub(start_offset)
.ok_or_else(|| StoreError::Corrupt {
line: 0,
message: "retained Segment record range is reversed".to_string(),
})?;
if record_len == 0 || record_len > max_bytes {
return Err(StoreError::ReadLimitExceeded);
}
let mut file = open_retained_file(&self.log_path(segment_id)).map_err(|error| {
if error.kind() == std::io::ErrorKind::NotFound {
StoreError::NotFound(segment_id)
} else {
StoreError::Io(error)
}
})?;
if end_offset > file.metadata()?.len() {
return Err(StoreError::Corrupt {
line: 0,
message: "retained Segment record range exceeds the committed log".to_string(),
});
}
file.seek(SeekFrom::Start(start_offset))?;
let mut record = vec![0_u8; record_len as usize];
file.read_exact(&mut record)?;
if record.last() != Some(&b'\n') || record[..record.len() - 1].contains(&b'\n') {
return Err(StoreError::Corrupt {
line: 0,
message: "retained Segment cursor does not identify one record".to_string(),
});
}
record.pop();
let entry = serde_json::from_slice(&record)?;
Ok(RetainedLogRecord {
start_offset,
end_offset,
entry,
})
}
pub(crate) fn open_retained_segment_reader(
&self,
session_id: SessionId,
segment_id: SegmentId,
before_offset: u64,
) -> Result<RetainedSegmentReader, StoreError> {
self.validate_retained_session(session_id)?;
let mut file = open_retained_file(&self.log_path(segment_id)).map_err(|error| {
if error.kind() == std::io::ErrorKind::NotFound {
StoreError::NotFound(segment_id)
} else {
StoreError::Io(error)
}
})?;
let file_len = file.metadata()?.len();
if before_offset > file_len {
return Err(StoreError::Corrupt {
line: 0,
message: "retained Segment cursor is beyond the committed log".to_string(),
});
}
if before_offset > 0 {
file.seek(SeekFrom::Start(before_offset - 1))?;
let mut boundary = [0_u8; 1];
file.read_exact(&mut boundary)?;
if boundary[0] != b'\n' {
return Err(StoreError::Corrupt {
line: 0,
message: "retained Segment cursor is not at a record boundary".to_string(),
});
}
}
Ok(RetainedSegmentReader {
file,
read_end: before_offset,
buffer_start: before_offset,
buffer: Vec::new(),
})
}
fn validate_retained_session(&self, requested: SessionId) -> Result<(), StoreError> {
let retained_session_id = self.session_id.lock().map_err(|_| StoreError::Corrupt {
line: 0,
message: "Worker Session identity lock poisoned".to_string(),
})?;
if *retained_session_id != Some(requested) {
return Err(StoreError::Corrupt {
line: 0,
message: "active Worker Session identity does not match retained manifest"
.to_string(),
});
}
Ok(())
}
pub fn segment_log_len(&self, segment_id: SegmentId) -> Result<u64, StoreError> {
Ok(fs::metadata(self.log_path(segment_id))?.len())
}
@@ -430,6 +722,25 @@ impl Store for WorkerSessionStore {
}
}
fn open_retained_file(path: &Path) -> std::io::Result<File> {
#[cfg(any(target_os = "linux", target_os = "android"))]
{
use std::os::unix::fs::OpenOptionsExt;
OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOATIME)
.open(path)
}
#[cfg(not(any(target_os = "linux", target_os = "android")))]
{
let _ = path;
Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"read-only retained observation requires no-atime file reads",
))
}
}
fn segment_log_paths(root: &Path) -> Result<Vec<(SegmentId, PathBuf)>, StoreError> {
let segments = root.join(SEGMENTS_DIR);
if !segments.exists() {
@@ -64,6 +64,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: [historyEntry(index, "user"), historyEntry(index, "assistant")],
@@ -162,7 +163,7 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
) {
return json({
availability: "retained_snapshot",
identity: { session_id: "session-a", segment_id: "segment-a", entry_count: 0 },
identity: { session_id: "session-a", segment_id: "segment-a", entry_count: 12 },
snapshot: {
pending_submissions: {
revision: 0,
@@ -170,7 +171,10 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
head_id: null,
submissions: [],
},
entries: [],
entries: [7, 8, 9, 10, 11, 12].flatMap((index) => [
historyEntry(index, "user"),
historyEntry(index, "assistant"),
]),
},
});
}
@@ -98,10 +98,20 @@ Deno.test("production Console pages backward by real turns while preserving both
);
}
const navigation = page.getByRole("complementary", { name: "Conversation turns" });
await navigation.getByText("question 8", { exact: true }).waitFor();
await navigation.getByText("question 7", { exact: true }).waitFor();
assertEquals(await historyRequestCount(baseUrl), 1);
assertEquals(await transcript.getByText(/^question (8|9|10|11|12)$/).count(), 5);
assertEquals(await navigation.getByText(/^question (8|9|10|11|12)$/).count(), 5);
assertEquals(await transcript.getByText(/^question (7|8|9|10|11|12)$/).count(), 6);
assertEquals(await navigation.getByText(/^question (7|8|9|10|11|12)$/).count(), 6);
const currentQuestionTop = await transcript
.getByText("question 7", { exact: true })
.evaluate((element) => element.getBoundingClientRect().top);
const retainedQuestionTop = await transcript
.getByText("question 8", { exact: true })
.evaluate((element) => element.getBoundingClientRect().top);
assert(
currentQuestionTop < retainedQuestionTop,
"the unmatched current-snapshot turn must precede overlapping retained history",
);
const question12 = navigation.getByRole("button", {
name: "Jump to conversation: question 12",
+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);
});