Page session/list with a cursor

session/list ignored the request's cursor and returned every saved thread in one reply. A reply now holds at most 100 threads and carries nextCursor when there are more. The cursor names the last thread on the page by modified time and id, the two things the list is sorted on. An offset would shift when a thread is saved or deleted between two requests and the next page would repeat or skip one. A cursor that does not parse is rejected with -32602 instead of starting again from the first page. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-Authored-By: siGit Code v1.6.7-acp <noreply@sigit.si>

Seto Elkahfi committed Oct 12, 2026 at 09:47 UTC 3cba3d9430dc131fa0b72680fae034d09aa7a518
2 files changed +266 -11
src/main.rs
+176 -11
@@ -649,6 +649,77 @@ fn history_message_text(message: &serde_json::Value) -> String {
649 }
650 }
651
652 +/// How many sessions one `session/list` reply carries. A client asks for the
653 +/// rest with the `nextCursor` of the page before.
654 +const SESSION_LIST_PAGE_SIZE: usize = 100;
655 +
656 +/// Where a `session/list` page ended: the last session on it, by the two
657 +/// things the list is sorted on. Naming the entry instead of counting an
658 +/// offset keeps the next page right when a thread is saved or deleted between
659 +/// the two requests.
660 +#[derive(Debug, Clone, PartialEq, Eq)]
661 +struct SessionListCursor {
662 + modified_nanos: u128,
663 + id: String,
664 +}
665 +
666 +impl SessionListCursor {
667 + fn after(entry: &session_store::SessionEntry) -> Self {
668 + Self {
669 + modified_nanos: modified_nanos(entry.modified),
670 + id: entry.id.clone(),
671 + }
672 + }
673 +
674 + /// The cursor as the client carries it. Clients must treat it as opaque.
675 + fn encode(&self) -> String {
676 + format!("{}:{}", self.modified_nanos, self.id)
677 + }
678 +
679 + fn decode(cursor: &str) -> Option<Self> {
680 + let (modified_nanos, id) = cursor.split_once(':')?;
681 + if id.is_empty() {
682 + return None;
683 + }
684 + Some(Self {
685 + modified_nanos: modified_nanos.parse().ok()?,
686 + id: id.to_string(),
687 + })
688 + }
689 +
690 + /// Does `entry` come after this cursor in `session_store::list` order
691 + /// (newest first, then by id)?
692 + fn precedes(&self, entry: &session_store::SessionEntry) -> bool {
693 + let modified = modified_nanos(entry.modified);
694 + modified < self.modified_nanos
695 + || (modified == self.modified_nanos && entry.id.as_str() > self.id.as_str())
696 + }
697 +}
698 +
699 +fn modified_nanos(time: std::time::SystemTime) -> u128 {
700 + time.duration_since(std::time::UNIX_EPOCH)
701 + .map(|elapsed| elapsed.as_nanos())
702 + .unwrap_or_default()
703 +}
704 +
705 +/// One page of `sessions` (already in list order) starting after `cursor`,
706 +/// and the cursor for the page after it when there is one.
707 +fn session_list_page(
708 + sessions: Vec<session_store::SessionEntry>,
709 + cursor: Option<&SessionListCursor>,
710 + page_size: usize,
711 +) -> (Vec<session_store::SessionEntry>, Option<String>) {
712 + let mut rest = sessions
713 + .into_iter()
714 + .filter(|entry| cursor.is_none_or(|cursor| cursor.precedes(entry)));
715 + let page: Vec<_> = rest.by_ref().take(page_size).collect();
716 + let next_cursor = match (rest.next(), page.last()) {
717 + (Some(_), Some(last)) => Some(SessionListCursor::after(last).encode()),
718 + _ => None,
719 + };
720 + (page, next_cursor)
721 +}
722 +
723 /// Do two paths name the same directory, for `session/list` filtering?
724 ///
725 /// Canonicalizing resolves the symlinks and `..` segments that make a client's
@@ -2849,19 +2920,34 @@ impl SiGitAgent {
2920 format!("cwd must be an absolute path, got {}", cwd.display()),
2921 ));
2922 }
2923 + // A cursor is one this agent handed out. Anything else is a client
2924 + // error, not a reason to start again from the first page.
2925 + let cursor = match args.cursor.as_deref() {
2926 + Some(cursor) => Some(SessionListCursor::decode(cursor).ok_or_else(|| {
2927 + agent_client_protocol::Error::new(
2928 + ErrorCode::InvalidParams.into(),
2929 + "cursor is not one this agent returned from session/list",
2930 + )
2931 + })?),
2932 + None => None,
2933 + };
2934 let filter = args.cwd.clone();
2853 - let sessions: Vec<SessionInfo> = session_store::list()
2935 + let entries: Vec<session_store::SessionEntry> = session_store::list()
2936 .into_iter()
2937 .filter(|entry| entry.message_count > 0)
2938 + .filter(|entry| match (&entry.cwd, &filter) {
2939 + (None, _) => false,
2940 + (Some(cwd), Some(filter)) => same_dir(cwd, filter),
2941 + (Some(_), None) => true,
2942 + })
2943 + .collect();
2944 + let (page, next_cursor) =
2945 + session_list_page(entries, cursor.as_ref(), SESSION_LIST_PAGE_SIZE);
2946 + let sessions: Vec<SessionInfo> = page
2947 + .into_iter()
2948 .filter_map(|entry| {
2857 - let cwd = entry.cwd?;
2858 - if let Some(filter) = &filter
2859 - && !same_dir(&cwd, filter)
2860 - {
2861 - return None;
2862 - }
2949 Some(
2864 - SessionInfo::new(SessionId::new(entry.id), cwd)
2950 + SessionInfo::new(SessionId::new(entry.id), entry.cwd?)
2951 .additional_directories(entry.additional_directories)
2952 .title(entry.title)
2953 .updated_at(session_store::iso8601(entry.modified)),
@@ -2870,15 +2956,20 @@ impl SiGitAgent {
2956 .collect();
2957
2958 log::info!(
2873 - "list_sessions: {} session(s){}",
2959 + "list_sessions: {} session(s){}{}",
2960 sessions.len(),
2961 args.cwd
2962 .as_ref()
2963 .map(|cwd| format!(" for cwd={}", cwd.display()))
2878 - .unwrap_or_default()
2964 + .unwrap_or_default(),
2965 + if next_cursor.is_some() {
2966 + ", more to come"
2967 + } else {
2968 + ""
2969 + }
2970 );
2971
2881 - Ok(ListSessionsResponse::new(sessions))
2972 + Ok(ListSessionsResponse::new(sessions).next_cursor(next_cursor))
2973 }
2974
2975 async fn handle_load_session(
@@ -7784,6 +7875,80 @@ mod tests {
7875 assert!(history_replay_updates(&history).is_empty());
7876 }
7877
7878 + fn listed(id: &str, modified_secs: u64) -> session_store::SessionEntry {
7879 + session_store::SessionEntry {
7880 + id: id.to_string(),
7881 + modified: std::time::UNIX_EPOCH + std::time::Duration::from_secs(modified_secs),
7882 + message_count: 1,
7883 + cwd: Some(PathBuf::from("/project")),
7884 + additional_directories: Vec::new(),
7885 + model_id: None,
7886 + title: None,
7887 + }
7888 + }
7889 +
7890 + fn ids(page: &[session_store::SessionEntry]) -> Vec<&str> {
7891 + page.iter().map(|entry| entry.id.as_str()).collect()
7892 + }
7893 +
7894 + #[test]
7895 + fn session_list_pages_follow_one_another_without_gaps_or_repeats() {
7896 + // In `session_store::list` order: newest first, ties by id.
7897 + let sessions = || {
7898 + vec![
7899 + listed("e", 50),
7900 + listed("a", 40),
7901 + listed("b", 40),
7902 + listed("c", 40),
7903 + listed("d", 10),
7904 + ]
7905 + };
7906 +
7907 + let (first, cursor) = session_list_page(sessions(), None, 2);
7908 + assert_eq!(ids(&first), ["e", "a"]);
7909 + let cursor = SessionListCursor::decode(&cursor.expect("more pages follow")).unwrap();
7910 +
7911 + let (second, cursor) = session_list_page(sessions(), Some(&cursor), 2);
7912 + assert_eq!(ids(&second), ["b", "c"]);
7913 + let cursor = SessionListCursor::decode(&cursor.expect("one more page")).unwrap();
7914 +
7915 + let (third, cursor) = session_list_page(sessions(), Some(&cursor), 2);
7916 + assert_eq!(ids(&third), ["d"]);
7917 + assert_eq!(cursor, None, "the last page carries no cursor");
7918 + }
7919 +
7920 + #[test]
7921 + fn session_list_has_no_cursor_when_everything_fits_on_one_page() {
7922 + let (page, cursor) = session_list_page(vec![listed("a", 2), listed("b", 1)], None, 2);
7923 + assert_eq!(ids(&page), ["a", "b"]);
7924 + assert_eq!(cursor, None);
7925 + }
7926 +
7927 + #[test]
7928 + fn session_list_cursor_survives_the_session_it_names_being_deleted() {
7929 + let (_, cursor) = session_list_page(
7930 + vec![listed("a", 30), listed("b", 20), listed("c", 10)],
7931 + None,
7932 + 2,
7933 + );
7934 + let cursor = SessionListCursor::decode(&cursor.unwrap()).unwrap();
7935 + // "b" ended the first page and is gone by the second request.
7936 + let (page, _) = session_list_page(vec![listed("a", 30), listed("c", 10)], Some(&cursor), 2);
7937 + assert_eq!(ids(&page), ["c"]);
7938 + }
7939 +
7940 + #[test]
7941 + fn session_list_cursor_rejects_what_it_did_not_write() {
7942 + for cursor in ["", "abc", "12", "12:", "x:session", ":session"] {
7943 + assert_eq!(SessionListCursor::decode(cursor), None, "{cursor:?}");
7944 + }
7945 + let cursor = SessionListCursor {
7946 + modified_nanos: 1_700_000_000_123_456_789,
7947 + id: "a:b".to_string(),
7948 + };
7949 + assert_eq!(SessionListCursor::decode(&cursor.encode()), Some(cursor));
7950 + }
7951 +
7952 #[test]
7953 fn history_replay_reads_block_form_content() {
7954 // Some OpenAI-compatible endpoints hand back content blocks rather than
tests/acp_session_list.rs
+90
@@ -154,6 +154,22 @@ impl AgentUnderTest {
154 }
155 }
156
157 +impl AgentUnderTest {
158 + /// Like `wait_for_response`, for a request that is expected to fail.
159 + fn wait_for_reply(&mut self, id: u64) -> Value {
160 + let deadline = Instant::now() + TIMEOUT;
161 + loop {
162 + let remaining = deadline.saturating_duration_since(Instant::now());
163 + let Ok(message) = self.incoming.recv_timeout(remaining) else {
164 + panic!("timed out waiting for the reply to request {id}");
165 + };
166 + if message["id"] == id && message.get("method").is_none() {
167 + return message;
168 + }
169 + }
170 + }
171 +}
172 +
173 impl Drop for AgentUnderTest {
174 fn drop(&mut self) {
175 let _ = self.child.kill();
@@ -423,3 +439,77 @@ fn a_saved_turn_sends_session_info_to_the_client() {
439 drop(agent);
440 let _ = std::fs::remove_dir_all(&scratch);
441 }
442 +
443 +/// Write a saved thread straight into the session store, the way a finished
444 +/// turn leaves one.
445 +fn seed_session(config_dir: &std::path::Path, id: &str, cwd: &std::path::Path) {
446 + let sessions = config_dir.join("sessions");
447 + std::fs::create_dir_all(&sessions).unwrap();
448 + std::fs::write(
449 + sessions.join(format!("{id}.jsonl")),
450 + format!(
451 + "{}\n",
452 + json!({"role": "user", "content": format!("thread {id}")})
453 + ),
454 + )
455 + .unwrap();
456 + std::fs::write(
457 + sessions.join(format!("{id}.meta.json")),
458 + json!({"cwd": cwd}).to_string(),
459 + )
460 + .unwrap();
461 +}
462 +
463 +#[test]
464 +fn a_long_session_list_is_paged_with_a_cursor() {
465 + let scratch = std::env::temp_dir().join(format!("sigit_acp_list_pages_{}", std::process::id()));
466 + let config_dir = scratch.join("config");
467 + let project = scratch.join("project");
468 + std::fs::create_dir_all(&project).unwrap();
469 + // One more than a page holds.
470 + for index in 0..101 {
471 + seed_session(&config_dir, &format!("thread-{index:03}"), &project);
472 + }
473 +
474 + let port = start_fake_endpoint(Vec::new());
475 + let mut agent = spawn_agent(port, &config_dir);
476 + let id = agent.request(
477 + "initialize",
478 + json!({"protocolVersion": 1, "clientCapabilities": {}}),
479 + );
480 + agent.wait_for_response(id);
481 +
482 + let id = agent.request("session/list", json!({"cwd": project}));
483 + let first = agent.wait_for_response(id);
484 + let first_page = first["result"]["sessions"].as_array().expect("sessions");
485 + assert_eq!(first_page.len(), 100);
486 + let cursor = first["result"]["nextCursor"]
487 + .as_str()
488 + .expect("a full page with more behind it carries nextCursor")
489 + .to_string();
490 +
491 + let id = agent.request("session/list", json!({"cwd": project, "cursor": cursor}));
492 + let second = agent.wait_for_response(id);
493 + let second_page = second["result"]["sessions"].as_array().expect("sessions");
494 + assert_eq!(second_page.len(), 1);
495 + assert!(
496 + second["result"].get("nextCursor").is_none(),
497 + "the last page has no cursor: {second}"
498 + );
499 +
500 + let mut seen: Vec<&str> = first_page
501 + .iter()
502 + .chain(second_page)
503 + .map(|session| session["sessionId"].as_str().unwrap())
504 + .collect();
505 + seen.sort_unstable();
506 + seen.dedup();
507 + assert_eq!(seen.len(), 101, "every thread is listed exactly once");
508 +
509 + let id = agent.request("session/list", json!({"cursor": "not-a-cursor"}));
510 + let rejected = agent.wait_for_reply(id);
511 + assert_eq!(rejected["error"]["code"], -32602, "{rejected}");
512 +
513 + drop(agent);
514 + let _ = std::fs::remove_dir_all(&scratch);
515 +}