mirror of
https://github.com/openai/codex.git
synced 2026-10-23 18:08:45 +00:00
Track resume picker pagination and selection metadata per backend (#56543)
## What changed - Add a thread catalog that tracks cursors, pending requests, and exhaustion per backend, preserves cursors on errors for retry, and rejects stale completions after a reset. - Tag listed rows with their backend and use that backend when selecting a session. - Store history mode on each row, preserving updates from later duplicate pages. ## Testing Add coverage for independent source pagination, retry cursors, stale completions, and selection routing. Extend the history-mode test to verify that later metadata updates are used when resuming. GitOrigin-RevId: ed5cf33b2df0e91394682f58ad5b353b949255ff
This commit is contained in:
committed by
copyberry
parent
7c568b331b
commit
f970f4be67
@@ -73,6 +73,7 @@ use unicode_width::UnicodeWidthStr;
|
||||
use uuid::Uuid;
|
||||
|
||||
mod archive;
|
||||
mod catalog;
|
||||
mod layout;
|
||||
mod page_loading;
|
||||
|
||||
@@ -758,16 +759,26 @@ fn spawn_app_server_page_loader(
|
||||
tokio::spawn(async move {
|
||||
let mut app_server = app_server;
|
||||
let mut page_cwd_filter = PageCwdFilter::default();
|
||||
let mut catalog = catalog::ThreadCatalog::default();
|
||||
while let Some(request) = request_rx.recv().await {
|
||||
match request {
|
||||
PickerLoadRequest::Page(request) => {
|
||||
if request.cursor.is_none() {
|
||||
catalog.reset();
|
||||
}
|
||||
let Some(catalog_request) = catalog.begin_page(app_server.backend_id()) else {
|
||||
continue;
|
||||
};
|
||||
let cwd_filter = page_cwd_filter.for_request(
|
||||
request.cursor.as_ref(),
|
||||
request.cwd_filter.as_deref(),
|
||||
request.filter_mode,
|
||||
uses_remote_filesystem,
|
||||
);
|
||||
let cursor = request.cursor.map(|PageCursor::AppServer(cursor)| cursor);
|
||||
let cursor = catalog_request
|
||||
.cursor
|
||||
.clone()
|
||||
.map(|PageCursor::AppServer(cursor)| cursor);
|
||||
let params = thread_list_params(
|
||||
cursor,
|
||||
cwd_filter,
|
||||
@@ -778,6 +789,9 @@ fn spawn_app_server_page_loader(
|
||||
matches!(request.mode, PageLoadMode::StateDbOnly),
|
||||
);
|
||||
let page = load_app_server_page(&mut app_server, params).await;
|
||||
let Some(page) = catalog.finish_page(catalog_request, page) else {
|
||||
continue;
|
||||
};
|
||||
let _ = bg_tx.send(BackgroundEvent::Page {
|
||||
request_token: request.request_token,
|
||||
search_token: request.search_token,
|
||||
@@ -900,7 +914,6 @@ struct PickerState {
|
||||
pagination: PaginationState,
|
||||
all_rows: Vec<Row>,
|
||||
filtered_rows: Vec<Row>,
|
||||
thread_history_modes: HashMap<ThreadId, ThreadHistoryMode>,
|
||||
seen_rows: HashSet<SeenRowKey>,
|
||||
selected: usize,
|
||||
scroll_top: usize,
|
||||
@@ -1020,6 +1033,8 @@ impl SearchState {
|
||||
#[derive(Clone)]
|
||||
#[cfg_attr(test, derive(Debug, PartialEq))]
|
||||
struct Row {
|
||||
backend_id: Option<crate::backend_id::BackendId>,
|
||||
history_mode: Option<ThreadHistoryMode>,
|
||||
path: Option<PathBuf>,
|
||||
preview: String,
|
||||
thread_id: Option<ThreadId>,
|
||||
@@ -1103,7 +1118,6 @@ impl PickerState {
|
||||
pagination: PaginationState::new(),
|
||||
all_rows: Vec::new(),
|
||||
filtered_rows: Vec::new(),
|
||||
thread_history_modes: HashMap::new(),
|
||||
seen_rows: HashSet::new(),
|
||||
selected: 0,
|
||||
scroll_top: 0,
|
||||
@@ -1248,7 +1262,6 @@ impl PickerState {
|
||||
self.request_frame();
|
||||
return;
|
||||
};
|
||||
|
||||
match self.transcript_cells.get(&thread_id) {
|
||||
Some(SessionTranscriptState::Loaded(_)) => {
|
||||
self.begin_transcript_loading(thread_id);
|
||||
@@ -1376,11 +1389,11 @@ impl PickerState {
|
||||
return Ok(None);
|
||||
}
|
||||
return Ok(Some(self.action.selection(SessionTarget {
|
||||
backend_id: self.backend_id,
|
||||
backend_id: row.backend_id.unwrap_or(self.backend_id),
|
||||
path,
|
||||
thread_id,
|
||||
cwd: row.cwd.clone(),
|
||||
history_mode: self.thread_history_modes.get(&thread_id).copied(),
|
||||
history_mode: row.history_mode,
|
||||
})));
|
||||
}
|
||||
self.inline_error = Some(match path {
|
||||
@@ -1517,7 +1530,6 @@ impl PickerState {
|
||||
self.reset_pagination();
|
||||
self.all_rows.clear();
|
||||
self.filtered_rows.clear();
|
||||
self.thread_history_modes.clear();
|
||||
self.seen_rows.clear();
|
||||
self.local_cwd_matches.clear();
|
||||
self.selected = 0;
|
||||
@@ -1673,9 +1685,10 @@ impl PickerState {
|
||||
} = page;
|
||||
self.pagination
|
||||
.complete_page(next_cursor, num_scanned_files, reached_scan_cap);
|
||||
self.thread_history_modes.extend(history_modes);
|
||||
|
||||
for row in rows {
|
||||
for mut row in rows {
|
||||
if let Some(thread_id) = row.thread_id {
|
||||
row.history_mode = history_modes.get(&thread_id).copied();
|
||||
}
|
||||
if let Some(cwd) = row.cwd.as_ref()
|
||||
&& !self.local_cwd_matches.contains_key(cwd)
|
||||
{
|
||||
@@ -1683,8 +1696,15 @@ impl PickerState {
|
||||
self.local_cwd_matches.insert(cwd.clone(), matches);
|
||||
}
|
||||
if let Some(seen_key) = row.seen_key() {
|
||||
if self.seen_rows.insert(seen_key) {
|
||||
if self.seen_rows.insert(seen_key.clone()) {
|
||||
self.all_rows.push(row);
|
||||
} else if let Some(existing) = self.all_rows.iter_mut().find(|existing| {
|
||||
existing.seen_key().as_ref() == Some(&seen_key)
|
||||
&& existing.backend_id == row.backend_id
|
||||
&& existing.thread_id == row.thread_id
|
||||
}) {
|
||||
// Preserve the pre-catalog behavior where metadata from a later page wins.
|
||||
existing.history_mode = row.history_mode;
|
||||
}
|
||||
} else {
|
||||
self.all_rows.push(row);
|
||||
@@ -2133,6 +2153,8 @@ fn row_from_app_server_thread(thread: Thread) -> Option<Row> {
|
||||
};
|
||||
let preview = thread.preview.trim();
|
||||
Some(Row {
|
||||
backend_id: None,
|
||||
history_mode: None,
|
||||
path: thread.path,
|
||||
preview: if preview.is_empty() {
|
||||
String::from("(no message yet)")
|
||||
@@ -3699,7 +3721,7 @@ mod tests {
|
||||
use std::sync::Mutex;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn page(
|
||||
pub(super) fn page(
|
||||
rows: Vec<Row>,
|
||||
next_cursor: Option<&str>,
|
||||
num_scanned_files: usize,
|
||||
@@ -3742,9 +3764,11 @@ mod tests {
|
||||
Ok(page(rows, next_cursor, n, /*reached_scan_cap*/ false))
|
||||
}
|
||||
|
||||
fn make_row(path: &str, ts: &str, preview: &str) -> Row {
|
||||
pub(super) fn make_row(path: &str, ts: &str, preview: &str) -> Row {
|
||||
let timestamp = parse_timestamp_str(ts).expect("timestamp should parse");
|
||||
Row {
|
||||
backend_id: None,
|
||||
history_mode: None,
|
||||
path: Some(PathBuf::from(path)),
|
||||
preview: preview.to_string(),
|
||||
thread_id: None,
|
||||
@@ -3756,7 +3780,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn local_db_first_state() -> (PickerState, Arc<Mutex<Vec<PageLoadRequest>>>) {
|
||||
pub(super) fn local_db_first_state() -> (PickerState, Arc<Mutex<Vec<PageLoadRequest>>>) {
|
||||
let requests = Arc::new(Mutex::new(Vec::new()));
|
||||
let request_sink = Arc::clone(&requests);
|
||||
let loader = page_only_loader(move |request| {
|
||||
@@ -3807,7 +3831,7 @@ mod tests {
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn render_picker_list(state: &PickerState, width: u16, height: u16) -> String {
|
||||
pub(super) fn render_picker_list(state: &PickerState, width: u16, height: u16) -> String {
|
||||
use crate::custom_terminal::Terminal;
|
||||
use crate::test_backend::VT100Backend;
|
||||
|
||||
@@ -3826,6 +3850,8 @@ mod tests {
|
||||
#[test]
|
||||
fn row_display_preview_prefers_thread_name() {
|
||||
let row = Row {
|
||||
backend_id: None,
|
||||
history_mode: None,
|
||||
path: Some(PathBuf::from("/tmp/a.jsonl")),
|
||||
preview: String::from("first message"),
|
||||
thread_id: None,
|
||||
@@ -4181,7 +4207,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_db_first_accepts_db_thread_id_without_rollout_validation() {
|
||||
async fn local_db_first_accepts_db_thread_id_and_latest_history_mode() {
|
||||
let (mut state, requests) = local_db_first_state();
|
||||
state.start_initial_load();
|
||||
let db_request = requests.lock().unwrap()[0].clone();
|
||||
@@ -4200,6 +4226,20 @@ mod tests {
|
||||
.insert(thread_id, ThreadHistoryMode::Legacy);
|
||||
deliver_page(&mut state, &db_request, Ok(listed_page)).await;
|
||||
|
||||
let mut updated_row = make_row(
|
||||
"/tmp/missing-rollout.jsonl",
|
||||
"2025-01-02T00:00:00Z",
|
||||
"updated indexed metadata",
|
||||
);
|
||||
updated_row.thread_id = Some(thread_id);
|
||||
updated_row.cwd = Some(PathBuf::from("/tmp/saved-cwd"));
|
||||
let mut updated_page = ok_page(vec![updated_row], /*next_cursor*/ None)
|
||||
.expect("updated indexed thread page should be available");
|
||||
updated_page
|
||||
.history_modes
|
||||
.insert(thread_id, ThreadHistoryMode::Paginated);
|
||||
state.ingest_page(updated_page);
|
||||
|
||||
let selection = state
|
||||
.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
|
||||
.await
|
||||
@@ -4209,7 +4249,7 @@ mod tests {
|
||||
Some(SessionSelection::Resume(SessionTarget {
|
||||
thread_id: selected_thread_id,
|
||||
cwd: Some(cwd),
|
||||
history_mode: Some(ThreadHistoryMode::Legacy),
|
||||
history_mode: Some(ThreadHistoryMode::Paginated),
|
||||
..
|
||||
})) if selected_thread_id == thread_id && cwd == Path::new("/tmp/saved-cwd")
|
||||
));
|
||||
@@ -4220,6 +4260,8 @@ mod tests {
|
||||
let thread_id =
|
||||
ThreadId::from_string("019dabc1-0ef5-7431-b81c-03037f51f62c").expect("thread id");
|
||||
let row = Row {
|
||||
backend_id: None,
|
||||
history_mode: None,
|
||||
path: Some(PathBuf::from("/tmp/a.jsonl")),
|
||||
preview: String::from("first message"),
|
||||
thread_id: Some(thread_id),
|
||||
@@ -4282,6 +4324,8 @@ mod tests {
|
||||
state.clock_format = ClockFormat::TwentyFourHour;
|
||||
state.relative_time_reference = parse_timestamp_str("2026-05-02T14:48:19Z");
|
||||
let row = Row {
|
||||
backend_id: None,
|
||||
history_mode: None,
|
||||
path: Some(PathBuf::from("/tmp/a.jsonl")),
|
||||
preview: String::from("first message"),
|
||||
thread_id: Some(thread_id),
|
||||
@@ -4530,6 +4574,8 @@ mod tests {
|
||||
}
|
||||
|
||||
let row = Row {
|
||||
backend_id: None,
|
||||
history_mode: None,
|
||||
path: None,
|
||||
preview: String::from("remote session"),
|
||||
thread_id: Some(ThreadId::new()),
|
||||
@@ -4556,6 +4602,8 @@ mod tests {
|
||||
SessionPickerAction::Resume,
|
||||
);
|
||||
let row = Row {
|
||||
backend_id: None,
|
||||
history_mode: None,
|
||||
path: None,
|
||||
preview: String::from("remote session"),
|
||||
thread_id: Some(ThreadId::new()),
|
||||
@@ -4585,6 +4633,8 @@ mod tests {
|
||||
let now = parse_timestamp_str("2026-04-28T16:30:00Z").expect("timestamp");
|
||||
let rows = vec![
|
||||
Row {
|
||||
backend_id: None,
|
||||
history_mode: None,
|
||||
path: Some(PathBuf::from("/tmp/a.jsonl")),
|
||||
preview: String::from("Fix resume picker timestamps"),
|
||||
thread_id: None,
|
||||
@@ -4595,6 +4645,8 @@ mod tests {
|
||||
git_branch: None,
|
||||
},
|
||||
Row {
|
||||
backend_id: None,
|
||||
history_mode: None,
|
||||
path: Some(PathBuf::from("/tmp/b.jsonl")),
|
||||
preview: String::from("Investigate lazy pagination cap"),
|
||||
thread_id: None,
|
||||
@@ -4605,6 +4657,8 @@ mod tests {
|
||||
git_branch: None,
|
||||
},
|
||||
Row {
|
||||
backend_id: None,
|
||||
history_mode: None,
|
||||
path: Some(PathBuf::from("/tmp/c.jsonl")),
|
||||
preview: String::from("Explain the codebase"),
|
||||
thread_id: None,
|
||||
@@ -5046,6 +5100,8 @@ mod tests {
|
||||
SessionPickerAction::Resume,
|
||||
);
|
||||
state.filtered_rows = vec![Row {
|
||||
backend_id: None,
|
||||
history_mode: None,
|
||||
path: None,
|
||||
preview: String::from("preview"),
|
||||
thread_id: Some(thread_id),
|
||||
@@ -5085,6 +5141,8 @@ mod tests {
|
||||
);
|
||||
state.filtered_rows = vec![
|
||||
Row {
|
||||
backend_id: None,
|
||||
history_mode: None,
|
||||
path: None,
|
||||
preview: String::from("one"),
|
||||
thread_id: Some(ThreadId::new()),
|
||||
@@ -5095,6 +5153,8 @@ mod tests {
|
||||
git_branch: None,
|
||||
},
|
||||
Row {
|
||||
backend_id: None,
|
||||
history_mode: None,
|
||||
path: None,
|
||||
preview: String::from("two"),
|
||||
thread_id: Some(ThreadId::new()),
|
||||
@@ -5222,6 +5282,8 @@ mod tests {
|
||||
state.pending_transcript_open = Some(thread_id);
|
||||
state.filtered_rows = vec![
|
||||
Row {
|
||||
backend_id: None,
|
||||
history_mode: None,
|
||||
path: None,
|
||||
preview: String::from("Find pending threads and emails"),
|
||||
thread_id: Some(thread_id),
|
||||
@@ -5232,6 +5294,8 @@ mod tests {
|
||||
git_branch: None,
|
||||
},
|
||||
Row {
|
||||
backend_id: None,
|
||||
history_mode: None,
|
||||
path: None,
|
||||
preview: String::from("Plan raw scrollback mode"),
|
||||
thread_id: Some(ThreadId::new()),
|
||||
@@ -5288,6 +5352,8 @@ mod tests {
|
||||
SessionPickerAction::Resume,
|
||||
);
|
||||
state.filtered_rows = vec![Row {
|
||||
backend_id: None,
|
||||
history_mode: None,
|
||||
path: None,
|
||||
preview: String::from("preview"),
|
||||
thread_id: Some(thread_id),
|
||||
@@ -5319,6 +5385,8 @@ mod tests {
|
||||
SessionPickerAction::Resume,
|
||||
);
|
||||
state.filtered_rows = vec![Row {
|
||||
backend_id: None,
|
||||
history_mode: None,
|
||||
path: Some(PathBuf::from("/tmp/a.jsonl")),
|
||||
preview: String::from("preview"),
|
||||
thread_id: None,
|
||||
@@ -5462,6 +5530,8 @@ mod tests {
|
||||
SessionPickerAction::Resume,
|
||||
);
|
||||
state.filtered_rows = vec![Row {
|
||||
backend_id: None,
|
||||
history_mode: None,
|
||||
path: None,
|
||||
preview: String::from("preview"),
|
||||
thread_id: Some(thread_id),
|
||||
@@ -5631,6 +5701,8 @@ session_picker_view = "dense"
|
||||
SessionPickerAction::Resume,
|
||||
);
|
||||
state.filtered_rows = vec![Row {
|
||||
backend_id: None,
|
||||
history_mode: None,
|
||||
path: None,
|
||||
preview: String::from("preview"),
|
||||
thread_id: Some(thread_id),
|
||||
@@ -5671,6 +5743,8 @@ session_picker_view = "dense"
|
||||
SessionPickerAction::Resume,
|
||||
);
|
||||
state.filtered_rows = vec![Row {
|
||||
backend_id: None,
|
||||
history_mode: None,
|
||||
path: None,
|
||||
preview: String::from("preview"),
|
||||
thread_id: Some(thread_id),
|
||||
@@ -5747,6 +5821,8 @@ session_picker_view = "dense"
|
||||
|
||||
fn dense_snapshot_row() -> Row {
|
||||
Row {
|
||||
backend_id: None,
|
||||
history_mode: None,
|
||||
path: Some(PathBuf::from("/tmp/a.jsonl")),
|
||||
preview: String::from(
|
||||
"Propose session picker redesign with enough title text to exercise truncation",
|
||||
@@ -5983,6 +6059,8 @@ session_picker_view = "dense"
|
||||
let thread_id =
|
||||
ThreadId::from_string("019dabc1-0ef5-7431-b81c-03037f51f62c").expect("thread id");
|
||||
let row = Row {
|
||||
backend_id: None,
|
||||
history_mode: None,
|
||||
path: Some(PathBuf::from("/tmp/a.jsonl")),
|
||||
preview: String::from("Investigate picker expansion"),
|
||||
thread_id: Some(thread_id),
|
||||
@@ -6054,6 +6132,8 @@ session_picker_view = "dense"
|
||||
|
||||
let loader = page_only_loader(|_| {});
|
||||
let row = Row {
|
||||
backend_id: None,
|
||||
history_mode: None,
|
||||
path: Some(PathBuf::from("/tmp/a.jsonl")),
|
||||
preview: String::from("Investigate picker expansion"),
|
||||
thread_id: Some(
|
||||
@@ -6116,6 +6196,8 @@ session_picker_view = "dense"
|
||||
let now = parse_timestamp_str("2026-04-28T16:30:00Z").expect("timestamp");
|
||||
state.all_rows = (0..5)
|
||||
.map(|idx| Row {
|
||||
backend_id: None,
|
||||
history_mode: None,
|
||||
path: Some(PathBuf::from(format!("/tmp/{idx}.jsonl"))),
|
||||
preview: format!("item-{idx}"),
|
||||
thread_id: None,
|
||||
@@ -6169,6 +6251,8 @@ session_picker_view = "dense"
|
||||
let now = parse_timestamp_str("2026-04-28T16:30:00Z").expect("timestamp");
|
||||
state.all_rows = (0..4)
|
||||
.map(|idx| Row {
|
||||
backend_id: None,
|
||||
history_mode: None,
|
||||
path: Some(PathBuf::from(format!("/tmp/{idx}.jsonl"))),
|
||||
preview: format!("item-{idx}"),
|
||||
thread_id: None,
|
||||
@@ -6767,6 +6851,8 @@ session_picker_view = "dense"
|
||||
);
|
||||
|
||||
let row = Row {
|
||||
backend_id: None,
|
||||
history_mode: None,
|
||||
path: Some(PathBuf::from("/tmp/missing.jsonl")),
|
||||
preview: String::from("missing metadata"),
|
||||
thread_id: None,
|
||||
@@ -6807,6 +6893,8 @@ session_picker_view = "dense"
|
||||
);
|
||||
let thread_id = ThreadId::new();
|
||||
let row = Row {
|
||||
backend_id: None,
|
||||
history_mode: None,
|
||||
path: None,
|
||||
preview: String::from("pathless thread"),
|
||||
thread_id: Some(thread_id),
|
||||
|
||||
@@ -50,6 +50,8 @@ fn archive_picker_state() -> (PickerState, Arc<Mutex<Vec<ThreadId>>>) {
|
||||
|
||||
fn set_selected_session(state: &mut PickerState, thread_id: ThreadId) {
|
||||
state.all_rows = vec![Row {
|
||||
backend_id: None,
|
||||
history_mode: None,
|
||||
path: None,
|
||||
preview: String::from("Selected session"),
|
||||
thread_id: Some(thread_id),
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
//! Coordinates independently paginated sources and tags rows with their backend.
|
||||
//! Resetting a listing invalidates pending pages without mixing source cursors.
|
||||
|
||||
use super::PageCursor;
|
||||
use super::PickerPage;
|
||||
use crate::backend_id::BackendId;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct ThreadCatalog {
|
||||
sources: HashMap<BackendId, Source>,
|
||||
next_request: u64,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Source {
|
||||
cursor: Option<PageCursor>,
|
||||
pending: Option<u64>,
|
||||
exhausted: bool,
|
||||
}
|
||||
|
||||
pub(super) struct PageRequest {
|
||||
backend: BackendId,
|
||||
token: u64,
|
||||
pub(super) cursor: Option<PageCursor>,
|
||||
}
|
||||
|
||||
impl ThreadCatalog {
|
||||
pub(super) fn reset(&mut self) {
|
||||
self.sources.clear();
|
||||
}
|
||||
|
||||
/// A slow or exhausted source does not prevent another source from loading.
|
||||
pub(super) fn begin_page(&mut self, backend: BackendId) -> Option<PageRequest> {
|
||||
let source = self.sources.entry(backend).or_default();
|
||||
if source.pending.is_some() || source.exhausted {
|
||||
return None;
|
||||
}
|
||||
let token = self.next_request;
|
||||
self.next_request += 1;
|
||||
source.pending = Some(token);
|
||||
Some(PageRequest {
|
||||
backend,
|
||||
token,
|
||||
cursor: source.cursor.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Errors retain the cursor for an explicit retry and never become empty pages.
|
||||
pub(super) fn finish_page(
|
||||
&mut self,
|
||||
request: PageRequest,
|
||||
page: std::io::Result<PickerPage>,
|
||||
) -> Option<std::io::Result<PickerPage>> {
|
||||
let source = self.sources.get_mut(&request.backend)?;
|
||||
if source.pending != Some(request.token) {
|
||||
return None;
|
||||
}
|
||||
source.pending = None;
|
||||
Some(page.map(|mut page| {
|
||||
source.cursor = page.next_cursor.clone();
|
||||
source.exhausted = source.cursor.is_none();
|
||||
for row in &mut page.rows {
|
||||
row.backend_id = Some(request.backend);
|
||||
}
|
||||
page
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "catalog_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,141 @@
|
||||
//! Source pagination, stale completions, and row-specific source routing.
|
||||
|
||||
use super::*;
|
||||
use crate::resume_picker::tests::make_row;
|
||||
use crate::resume_picker::tests::page;
|
||||
use codex_protocol::ThreadId;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn listed(thread: ThreadId, cursor: Option<&str>) -> PickerPage {
|
||||
let mut row = make_row(
|
||||
&format!("/tmp/{thread}.jsonl"),
|
||||
"2025-01-01T00:00:00Z",
|
||||
"conversation",
|
||||
);
|
||||
row.thread_id = Some(thread);
|
||||
page(
|
||||
vec![row],
|
||||
cursor,
|
||||
/*num_scanned_files*/ 1,
|
||||
/*reached_scan_cap*/ false,
|
||||
)
|
||||
}
|
||||
|
||||
fn cursor(request: &PageRequest) -> Option<&str> {
|
||||
request
|
||||
.cursor
|
||||
.as_ref()
|
||||
.map(|PageCursor::AppServer(value)| value.as_str())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sources_progress_independently_and_retry_their_own_cursor() {
|
||||
let mut catalog = ThreadCatalog::default();
|
||||
let slow = BackendId::new();
|
||||
let failed = BackendId::new();
|
||||
let healthy = BackendId::new();
|
||||
let slow_request = catalog.begin_page(slow).unwrap();
|
||||
let first = catalog.begin_page(failed).unwrap();
|
||||
catalog
|
||||
.finish_page(first, Ok(listed(ThreadId::new(), Some("failed-next"))))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let failing_request = catalog.begin_page(failed).unwrap();
|
||||
assert_eq!(cursor(&failing_request), Some("failed-next"));
|
||||
let error = catalog
|
||||
.finish_page(failing_request, Err(std::io::Error::other("unavailable")))
|
||||
.unwrap()
|
||||
.err()
|
||||
.unwrap();
|
||||
assert_eq!(error.to_string(), "unavailable");
|
||||
let healthy_request = catalog.begin_page(healthy).unwrap();
|
||||
let thread = ThreadId::new();
|
||||
let result = catalog
|
||||
.finish_page(healthy_request, Ok(listed(thread, /*cursor*/ None)))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(result.rows[0].backend_id, Some(healthy));
|
||||
assert!(catalog.begin_page(healthy).is_none());
|
||||
assert!(catalog.begin_page(slow).is_none());
|
||||
let retry = catalog.begin_page(failed).unwrap();
|
||||
assert_eq!(cursor(&retry), Some("failed-next"));
|
||||
catalog
|
||||
.finish_page(retry, Ok(listed(ThreadId::new(), /*cursor*/ None)))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
catalog
|
||||
.finish_page(slow_request, Ok(listed(ThreadId::new(), /*cursor*/ None)))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(catalog.begin_page(failed).is_none());
|
||||
assert!(catalog.begin_page(slow).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_rejects_old_completions_without_finishing_new_requests() {
|
||||
let mut catalog = ThreadCatalog::default();
|
||||
let backend = BackendId::new();
|
||||
let old = catalog.begin_page(backend).unwrap();
|
||||
catalog.reset();
|
||||
let current = catalog.begin_page(backend).unwrap();
|
||||
assert!(
|
||||
catalog
|
||||
.finish_page(old, Ok(listed(ThreadId::new(), /*cursor*/ None)))
|
||||
.is_none()
|
||||
);
|
||||
assert!(catalog.begin_page(backend).is_none());
|
||||
let thread = ThreadId::new();
|
||||
let result = catalog
|
||||
.finish_page(current, Ok(listed(thread, /*cursor*/ None)))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(result.rows[0].backend_id, Some(backend));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn picker_selections_retain_each_rows_source() {
|
||||
use crate::resume_picker::SessionSelection;
|
||||
use crate::resume_picker::tests::local_db_first_state;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
|
||||
let (mut picker, _) = local_db_first_state();
|
||||
let owner = BackendId::new();
|
||||
let thread = ThreadId::new();
|
||||
let mut catalog = ThreadCatalog::default();
|
||||
let request = catalog.begin_page(owner).unwrap();
|
||||
let page = catalog
|
||||
.finish_page(request, Ok(listed(thread, /*cursor*/ None)))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
picker.ingest_page(page);
|
||||
let other = BackendId::new();
|
||||
let other_thread = ThreadId::new();
|
||||
let other_request = catalog.begin_page(other).unwrap();
|
||||
let other_page = catalog
|
||||
.finish_page(other_request, Ok(listed(other_thread, /*cursor*/ None)))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
picker.ingest_page(other_page);
|
||||
let selection = picker
|
||||
.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
|
||||
.await
|
||||
.unwrap();
|
||||
let Some(SessionSelection::Resume(target)) = selection else {
|
||||
panic!("expected selection")
|
||||
};
|
||||
assert_eq!((target.thread_id, target.backend_id), (thread, owner));
|
||||
picker
|
||||
.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE))
|
||||
.await
|
||||
.unwrap();
|
||||
let selection = picker
|
||||
.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
|
||||
.await
|
||||
.unwrap();
|
||||
let Some(SessionSelection::Resume(target)) = selection else {
|
||||
panic!("expected selection")
|
||||
};
|
||||
assert_eq!((target.thread_id, target.backend_id), (other_thread, other));
|
||||
}
|
||||
@@ -15,6 +15,8 @@ fn thread_colors_and_selection_contrast_in_both_picker_layouts() {
|
||||
.clone(),
|
||||
);
|
||||
let row = Row {
|
||||
backend_id: None,
|
||||
history_mode: None,
|
||||
path: None,
|
||||
thread_id: Some(ThreadId::from_u128(/*value*/ 42)),
|
||||
thread_name: Some("Named task".into()),
|
||||
@@ -161,6 +163,8 @@ fn compact_picker_keeps_metadata_and_labeled_primary_actions() {
|
||||
state.relative_time_reference = timestamp;
|
||||
state.filtered_rows = (1..=6)
|
||||
.map(|id| Row {
|
||||
backend_id: None,
|
||||
history_mode: None,
|
||||
path: None,
|
||||
thread_id: Some(ThreadId::from_u128(id)),
|
||||
thread_name: Some(format!("Session {id}")),
|
||||
|
||||
@@ -35,6 +35,8 @@ async fn later_page_error_preserves_loaded_sessions() {
|
||||
search_token: request.search_token,
|
||||
page: Ok(PickerPage {
|
||||
rows: vec![Row {
|
||||
backend_id: None,
|
||||
history_mode: None,
|
||||
path: None,
|
||||
preview: "Loaded session".to_string(),
|
||||
thread_id: Some(thread_id),
|
||||
|
||||
Reference in New Issue
Block a user