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