fix(resume): count user input from rollouts

This commit is contained in:
James Peter
2025-11-16 23:25:14 +10:00
parent 0b266279b6
commit 479edd17f3
12 changed files with 355 additions and 145 deletions
+1
View File
@@ -1239,6 +1239,7 @@ dependencies = [
"tokio",
"tracing",
"tracing-subscriber",
"uuid",
]
[[package]]
+49 -19
View File
@@ -19,6 +19,7 @@ mod llm;
use llm::{LlmCli, run_llm};
use code_common::CliConfigOverrides;
use code_core::{entry_to_rollout_path, SessionCatalog, SessionQuery};
use code_protocol::protocol::SessionSource;
use code_cloud_tasks::Cli as CloudTasksCli;
use code_exec::Cli as ExecCli;
use code_responses_api_proxy::Args as ResponsesApiProxyArgs;
@@ -593,15 +594,16 @@ fn resolve_resume_path(session_id: Option<&str>, last: bool) -> anyhow::Result<O
.await
.context("failed to look up session by id")?;
Ok(entry.map(|entry| entry_to_rollout_path(&code_home, &entry)))
} else if last {
let query = SessionQuery {
cwd: None,
git_root: None,
sources: Vec::new(),
include_archived: false,
include_deleted: false,
limit: Some(1),
};
} else if last {
let query = SessionQuery {
cwd: None,
git_root: None,
sources: vec![SessionSource::Cli, SessionSource::VSCode, SessionSource::Exec],
min_user_messages: 1,
include_archived: false,
include_deleted: false,
limit: Some(1),
};
let entry = catalog
.get_latest(&query)
.await
@@ -1113,11 +1115,13 @@ mod tests {
static CODE_HOME_MUTEX: Mutex<()> = Mutex::new(());
fn with_temp_code_home<F, R>(f: F) -> R
where
F: FnOnce(&Path) -> R,
{
let _guard = CODE_HOME_MUTEX.lock().expect("lock CODE_HOME mutex");
fn with_temp_code_home<F, R>(f: F) -> R
where
F: FnOnce(&Path) -> R,
{
let _guard = CODE_HOME_MUTEX
.lock()
.unwrap_or_else(|poison| poison.into_inner());
let temp_home = TempDir::new().expect("temp code home");
let prev_code_home = std::env::var("CODE_HOME").ok();
let prev_codex_home = std::env::var("CODEX_HOME").ok();
@@ -1181,8 +1185,6 @@ mod tests {
);
let path = sessions_dir.join(filename);
let session_id_str = id.to_string();
let session_meta = SessionMeta {
id: ConversationId::from(*id),
timestamp: created_at.to_string(),
@@ -1215,6 +1217,17 @@ mod tests {
}),
};
let user_line = RolloutLine {
timestamp: last_event_at.to_string(),
item: RolloutItem::ResponseItem(ResponseItem::Message {
id: Some(format!("user-{}", id)),
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: user_message.to_string(),
}],
}),
};
let response_line = RolloutLine {
timestamp: last_event_at.to_string(),
item: RolloutItem::ResponseItem(ResponseItem::Message {
@@ -1231,6 +1244,8 @@ mod tests {
writer.write_all(b"\n").expect("newline");
serde_json::to_writer(&mut writer, &event_line).expect("write event");
writer.write_all(b"\n").expect("newline");
serde_json::to_writer(&mut writer, &user_line).expect("write user message");
writer.write_all(b"\n").expect("newline");
serde_json::to_writer(&mut writer, &response_line).expect("write response");
writer.write_all(b"\n").expect("newline");
writer.flush().expect("flush session file");
@@ -1299,7 +1314,12 @@ mod tests {
);
let path = resolve_resume_path(None, true).expect("query").expect("path");
assert!(path.ends_with("22222222-2222-4222-8222-222222222222.jsonl"));
let path_str = path.to_string_lossy();
assert!(
path_str.contains("22222222-2222-4222-8222-222222222222"),
"path resolved to {}",
path_str
);
});
}
@@ -1321,7 +1341,12 @@ mod tests {
let result = resolve_resume_path(Some("33333333"), false)
.expect("query")
.expect("path");
assert!(result.ends_with("33333333-3333-4333-8333-333333333333.jsonl"));
let result_str = result.to_string_lossy();
assert!(
result_str.contains("33333333-3333-4333-8333-333333333333"),
"path resolved to {}",
result_str
);
});
}
@@ -1356,7 +1381,12 @@ mod tests {
set_file_mtime(&newer_path, FileTime::from_system_time(base + Duration::from_secs(60))).unwrap();
let path = resolve_resume_path(None, true).expect("query").expect("path");
assert!(path.ends_with("55555555-5555-4555-8555-555555555555.jsonl"));
let path_str = path.to_string_lossy();
assert!(
path_str.contains("55555555-5555-4555-8555-555555555555"),
"path resolved to {}",
path_str
);
});
}
+54 -27
View File
@@ -6,6 +6,7 @@ use std::fs;
use std::io::{self, BufRead, BufReader};
use std::path::{Path, PathBuf};
use code_protocol::models::{ContentItem, ResponseItem};
use code_protocol::protocol::{RolloutItem, RolloutLine, SessionSource};
use serde::{Deserialize, Serialize};
use tracing::warn;
@@ -60,6 +61,10 @@ pub struct SessionIndexEntry {
/// Number of messages/turns in the session
pub message_count: usize,
/// Number of user messages in the session
#[serde(default)]
pub user_message_count: usize,
/// Snippet from the last user message
#[serde(skip_serializing_if = "Option::is_none")]
pub last_user_snippet: Option<String>,
@@ -93,7 +98,7 @@ impl SessionIndexEntry {
}
/// In-memory session catalog with secondary indexes for efficient querying.
#[derive(Debug, Default)]
#[derive(Debug, Default, Clone)]
pub struct SessionCatalog {
/// All entries indexed by session_id
pub(crate) entries: HashMap<Uuid, SessionIndexEntry>,
@@ -425,6 +430,7 @@ async fn parse_rollout_file(
let mut git_project_root: Option<PathBuf> = None;
let mut session_source: Option<SessionSource> = None;
let mut message_count = 0usize;
let mut user_message_count = 0usize;
let mut last_user_snippet: Option<String> = None;
// Parse the file line by line
@@ -455,25 +461,24 @@ async fn parse_rollout_file(
git_project_root = Some(meta_line.meta.cwd.clone());
}
}
RolloutItem::ResponseItem(_) => {
message_count += 1;
}
RolloutItem::Event(event) => {
RolloutItem::ResponseItem(response_item) => {
message_count += 1;
// Try to extract user message snippet
if let Some(msg) = crate::protocol::event_msg_from_protocol(&event.msg) {
if let crate::protocol::EventMsg::UserMessage(user_msg) = msg {
last_user_snippet = Some(
user_msg
.message
.chars()
.take(100)
.collect::<String>(),
);
if let ResponseItem::Message { role, content, .. } = response_item {
if role.eq_ignore_ascii_case("user") {
user_message_count += 1;
if let Some(snippet) = snippet_from_content(&content) {
last_user_snippet = Some(snippet);
}
}
}
}
RolloutItem::Event(_event) => {
// Event lines record internal state changes (tool output, approvals, etc.).
// They are not a reliable indicator of user-submitted turns, so avoid
// counting them toward `user_message_count` to keep resume filters strict.
message_count += 1;
}
RolloutItem::Compacted(_) | RolloutItem::TurnContext(_) => {
message_count += 1;
}
@@ -520,6 +525,7 @@ async fn parse_rollout_file(
model_provider: None, // TODO: extract from session if available
session_source,
message_count,
user_message_count,
last_user_snippet,
sync_origin_device: None,
sync_version: 0,
@@ -555,26 +561,39 @@ pub async fn update_catalog_entry(
let mut catalog = SessionCatalog::load(code_home)?;
// If entry exists, update last_event_at
if let Some(mut entry) = catalog.entries.remove(&session_id) {
entry.last_event_at = last_timestamp.to_string();
if let Some(mut entry) = catalog.entries.remove(&session_id) {
entry.last_event_at = last_timestamp.to_string();
// Re-parse file to update message count and snippet
if let Some(updated) = parse_rollout_file(rollout_path, &code_home.join(SESSIONS_SUBDIR)).await {
entry.message_count = updated.message_count;
entry.last_user_snippet = updated.last_user_snippet;
}
// Re-parse file to update message count and snippet
if let Some(updated) = parse_rollout_file(rollout_path, &code_home.join(SESSIONS_SUBDIR)).await {
entry.message_count = updated.message_count;
entry.user_message_count = updated.user_message_count;
entry.last_user_snippet = updated.last_user_snippet;
}
catalog.upsert(entry)?;
} else {
// Entry doesn't exist, parse and add it
if let Some(entry) = parse_rollout_file(rollout_path, &code_home.join(SESSIONS_SUBDIR)).await {
catalog.upsert(entry)?;
}
} else {
// Entry doesn't exist, parse and add it
if let Some(entry) = parse_rollout_file(rollout_path, &code_home.join(SESSIONS_SUBDIR)).await {
catalog.upsert(entry)?;
}
}
Ok(())
}
fn snippet_from_content(content: &[ContentItem]) -> Option<String> {
content.iter().find_map(|item| match item {
ContentItem::InputText { text }
| ContentItem::OutputText { text } => Some(truncate_snippet(text)),
_ => None,
})
}
fn truncate_snippet(text: &str) -> String {
text.chars().take(100).collect()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -595,6 +614,7 @@ mod tests {
model_provider: None,
session_source: SessionSource::Cli,
message_count: 5,
user_message_count: 2,
last_user_snippet: None,
sync_origin_device: None,
sync_version: 0,
@@ -629,6 +649,7 @@ mod tests {
model_provider: None,
session_source: SessionSource::Cli,
message_count: 5,
user_message_count: 1,
last_user_snippet: Some("test message".to_string()),
sync_origin_device: None,
sync_version: 0,
@@ -668,6 +689,7 @@ mod tests {
model_provider: None,
session_source: SessionSource::Cli,
message_count: 5,
user_message_count: 1,
last_user_snippet: None,
sync_origin_device: None,
sync_version: 0,
@@ -717,6 +739,7 @@ mod tests {
model_provider: None,
session_source: SessionSource::Cli,
message_count: 5,
user_message_count: 2,
last_user_snippet: Some("first message".to_string()),
sync_origin_device: None,
sync_version: 0,
@@ -765,6 +788,7 @@ mod tests {
model_provider: None,
session_source: SessionSource::Cli,
message_count: 5,
user_message_count: 1,
last_user_snippet: None,
sync_origin_device: None,
sync_version: 0,
@@ -807,6 +831,7 @@ mod tests {
model_provider: None,
session_source: SessionSource::Cli,
message_count: 5,
user_message_count: 2,
last_user_snippet: None,
sync_origin_device: None,
sync_version: 0,
@@ -866,6 +891,7 @@ mod tests {
model_provider: None,
session_source: SessionSource::Cli,
message_count: 5,
user_message_count: 2,
last_user_snippet: None,
sync_origin_device: None,
sync_version: 0,
@@ -942,6 +968,7 @@ mod tests {
model_provider: None,
session_source: SessionSource::Cli,
message_count: 5,
user_message_count: 2,
last_user_snippet: None,
sync_origin_device: None,
sync_version: 0,
+37 -1
View File
@@ -1,10 +1,14 @@
//! Async-friendly wrapper around the rollout session catalog.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use anyhow::{Context, Result};
use code_protocol::protocol::SessionSource;
use once_cell::sync::OnceCell;
use tokio::task;
use tokio::sync::Mutex as AsyncMutex;
use crate::rollout::catalog::{self as rollout_catalog, SessionIndexEntry};
@@ -17,6 +21,8 @@ pub struct SessionQuery {
pub git_root: Option<PathBuf>,
/// Restrict to these sources; empty = all sources.
pub sources: Vec<SessionSource>,
/// Minimum number of user messages required.
pub min_user_messages: usize,
/// Include archived sessions.
pub include_archived: bool,
/// Include deleted sessions.
@@ -28,12 +34,14 @@ pub struct SessionQuery {
/// Public catalog facade used by TUI/CLI/Exec entrypoints.
pub struct SessionCatalog {
code_home: PathBuf,
cache: Arc<AsyncMutex<Option<rollout_catalog::SessionCatalog>>>,
}
impl SessionCatalog {
/// Create a catalog facade for the provided code home directory.
pub fn new(code_home: PathBuf) -> Self {
Self { code_home }
let cache = catalog_cache_handle(&code_home);
Self { code_home, cache }
}
/// Query the catalog with the provided filters, returning ordered entries.
@@ -61,6 +69,9 @@ impl SessionCatalog {
if !query.sources.is_empty() && !query.sources.contains(&entry.session_source) {
continue;
}
if entry.user_message_count < query.min_user_messages {
continue;
}
rows.push(entry.clone());
@@ -108,6 +119,17 @@ impl SessionCatalog {
}
async fn load_inner(&self) -> Result<rollout_catalog::SessionCatalog> {
{
let mut guard = self.cache.lock().await;
if let Some(existing) = guard.as_mut() {
existing
.reconcile(&self.code_home)
.await
.context("failed to reconcile session catalog")?;
return Ok(existing.clone());
}
}
let code_home = self.code_home.clone();
let mut catalog = task::spawn_blocking(move || rollout_catalog::SessionCatalog::load(&code_home))
.await
@@ -119,6 +141,8 @@ impl SessionCatalog {
.await
.context("failed to reconcile session catalog")?;
let mut guard = self.cache.lock().await;
*guard = Some(catalog.clone());
Ok(catalog)
}
}
@@ -127,3 +151,15 @@ impl SessionCatalog {
pub fn entry_to_rollout_path(code_home: &Path, entry: &SessionIndexEntry) -> PathBuf {
code_home.join(&entry.rollout_path)
}
type SharedCatalog = Arc<AsyncMutex<Option<rollout_catalog::SessionCatalog>>>;
fn catalog_cache_handle(code_home: &Path) -> SharedCatalog {
static CACHE: OnceCell<Mutex<HashMap<PathBuf, SharedCatalog>>> = OnceCell::new();
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
let mut guard = cache.lock().expect("session catalog cache poisoned");
guard
.entry(code_home.to_path_buf())
.or_insert_with(|| Arc::new(AsyncMutex::new(None)))
.clone()
}
+65 -72
View File
@@ -3,7 +3,7 @@ use std::io::{BufWriter, Write};
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use code_core::{SessionCatalog, SessionIndexEntry, SessionQuery};
use code_core::{SessionCatalog, SessionQuery};
use code_protocol::models::{ContentItem, ResponseItem};
use code_protocol::protocol::{
EventMsg as ProtoEventMsg, RecordedEvent, RolloutItem, RolloutLine, SessionMeta,
@@ -14,41 +14,6 @@ use filetime::{set_file_mtime, FileTime};
use tempfile::TempDir;
use uuid::Uuid;
fn write_catalog(code_home: &Path, entries: &[SessionIndexEntry]) {
let catalog_path = code_home.join("sessions/index/catalog.jsonl");
if let Some(parent) = catalog_path.parent() {
fs::create_dir_all(parent).unwrap();
}
let data = entries
.iter()
.map(|entry| serde_json::to_string(entry).unwrap())
.collect::<Vec<_>>()
.join("\n");
fs::write(catalog_path, format!("{}\n", data)).unwrap();
}
fn entry(id: &str, source: SessionSource, last: &str, created: &str, cwd: &Path) -> SessionIndexEntry {
SessionIndexEntry {
session_id: Uuid::parse_str(id).unwrap(),
rollout_path: PathBuf::from(format!("sessions/2025/11/16/rollout-{id}.jsonl")),
snapshot_path: None,
created_at: created.to_string(),
last_event_at: last.to_string(),
cwd_real: cwd.to_path_buf(),
cwd_display: cwd.to_string_lossy().to_string(),
git_project_root: None,
git_branch: None,
model_provider: Some("test-provider".to_string()),
session_source: source,
message_count: 3,
last_user_snippet: Some("last user input".to_string()),
sync_origin_device: None,
sync_version: 0,
archived: false,
deleted: false,
}
}
fn write_rollout_transcript(
code_home: &Path,
session_id: Uuid,
@@ -100,6 +65,17 @@ fn write_rollout_transcript(
}),
};
let user_line = RolloutLine {
timestamp: last_event_at.to_string(),
item: RolloutItem::ResponseItem(ResponseItem::Message {
id: Some(format!("user-{}", session_id)),
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: user_message.to_string(),
}],
}),
};
let response_line = RolloutLine {
timestamp: last_event_at.to_string(),
item: RolloutItem::ResponseItem(ResponseItem::Message {
@@ -116,6 +92,8 @@ fn write_rollout_transcript(
writer.write_all(b"\n").unwrap();
serde_json::to_writer(&mut writer, &user_event).unwrap();
writer.write_all(b"\n").unwrap();
serde_json::to_writer(&mut writer, &user_line).unwrap();
writer.write_all(b"\n").unwrap();
serde_json::to_writer(&mut writer, &response_line).unwrap();
writer.write_all(b"\n").unwrap();
writer.flush().unwrap();
@@ -127,26 +105,29 @@ fn write_rollout_transcript(
async fn query_includes_exec_sessions() {
let temp = TempDir::new().unwrap();
let cwd = PathBuf::from("/workspace/project");
let cli_entry = entry(
"11111111-1111-4111-8111-111111111111",
SessionSource::Cli,
write_rollout_transcript(
temp.path(),
Uuid::parse_str("11111111-1111-4111-8111-111111111111").unwrap(),
"2025-11-15T12:00:00Z",
"2025-11-15T11:59:00Z",
"2025-11-15T12:00:10Z",
&cwd,
SessionSource::Cli,
"cli",
);
let exec_entry = entry(
"22222222-2222-4222-8222-222222222222",
SessionSource::Exec,
write_rollout_transcript(
temp.path(),
Uuid::parse_str("22222222-2222-4222-8222-222222222222").unwrap(),
"2025-11-15T13:00:00Z",
"2025-11-15T12:59:00Z",
"2025-11-15T13:00:10Z",
&cwd,
SessionSource::Exec,
"exec",
);
write_catalog(temp.path(), &[cli_entry.clone(), exec_entry.clone()]);
let catalog = SessionCatalog::new(temp.path().to_path_buf());
let query = SessionQuery {
cwd: Some(cwd.clone()),
min_user_messages: 1,
..SessionQuery::default()
};
let results = catalog.query(&query).await.unwrap();
@@ -161,50 +142,60 @@ async fn query_includes_exec_sessions() {
async fn latest_prefers_newer_timestamp() {
let temp = TempDir::new().unwrap();
let cwd = PathBuf::from("/workspace/project");
let older = entry(
"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
SessionSource::Cli,
write_rollout_transcript(
temp.path(),
Uuid::parse_str("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa").unwrap(),
"2025-11-15T10:00:00Z",
"2025-11-15T09:00:00Z",
"2025-11-15T10:00:10Z",
&cwd,
);
let newer = entry(
"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
SessionSource::Cli,
"older",
);
write_rollout_transcript(
temp.path(),
Uuid::parse_str("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb").unwrap(),
"2025-11-16T10:00:00Z",
"2025-11-16T09:00:00Z",
"2025-11-16T10:00:10Z",
&cwd,
SessionSource::Cli,
"newer",
);
write_catalog(temp.path(), &[older, newer.clone()]);
let catalog = SessionCatalog::new(temp.path().to_path_buf());
let query = SessionQuery { limit: Some(1), ..SessionQuery::default() };
let query = SessionQuery {
limit: Some(1),
min_user_messages: 1,
..SessionQuery::default()
};
let result = catalog.get_latest(&query).await.unwrap().unwrap();
assert_eq!(result.session_id, newer.session_id);
assert_eq!(
result.session_id,
Uuid::parse_str("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb").unwrap()
);
}
#[tokio::test]
async fn find_by_prefix_matches_entry() {
let temp = TempDir::new().unwrap();
let cwd = PathBuf::from("/workspace/project");
let target = entry(
"12345678-9abc-4def-8123-456789abcdef",
SessionSource::Cli,
"2025-11-16T12:34:56Z",
let target_id = Uuid::parse_str("12345678-9abc-4def-8123-456789abcdef").unwrap();
write_rollout_transcript(
temp.path(),
target_id,
"2025-11-16T12:00:00Z",
"2025-11-16T12:34:56Z",
&cwd,
SessionSource::Cli,
"prefix target",
);
write_catalog(temp.path(), &[target.clone()]);
let catalog = SessionCatalog::new(temp.path().to_path_buf());
let result = catalog
.find_by_id("12345678")
.await
.unwrap()
.expect("entry should exist");
assert_eq!(result.session_id, target.session_id);
assert_eq!(result.session_id, target_id);
}
#[tokio::test]
@@ -237,6 +228,7 @@ async fn bootstrap_catalog_from_rollouts() {
let catalog = SessionCatalog::new(temp.path().to_path_buf());
let query = SessionQuery {
cwd: Some(cwd.clone()),
min_user_messages: 1,
..SessionQuery::default()
};
let results = catalog.query(&query).await.unwrap();
@@ -258,21 +250,19 @@ async fn reconcile_removes_deleted_sessions() {
"2025-11-15T12:05:00Z",
&cwd,
SessionSource::Cli,
"to delete",
"delete",
);
let catalog = SessionCatalog::new(temp.path().to_path_buf());
let query = SessionQuery {
cwd: Some(cwd.clone()),
min_user_messages: 1,
..SessionQuery::default()
};
let results = catalog.query(&query).await.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(catalog.query(&query).await.unwrap().len(), 1);
fs::remove_file(rollout_path).unwrap();
let results_after = catalog.query(&query).await.unwrap();
assert!(results_after.is_empty());
assert_eq!(catalog.query(&query).await.unwrap().len(), 0);
}
#[tokio::test]
@@ -308,7 +298,10 @@ async fn reconcile_prefers_last_event_over_mtime() {
let catalog = SessionCatalog::new(temp.path().to_path_buf());
let latest = catalog
.get_latest(&SessionQuery::default())
.get_latest(&SessionQuery {
min_user_messages: 1,
..SessionQuery::default()
})
.await
.unwrap()
.expect("latest entry");
+1
View File
@@ -51,3 +51,4 @@ libc = "0.2"
[dev-dependencies]
filetime = { workspace = true }
tempfile = { workspace = true }
uuid = { version = "1", features = ["v4"] }
+34 -4
View File
@@ -455,7 +455,8 @@ async fn resolve_resume_path(
let query = SessionQuery {
cwd: None,
git_root: None,
sources: Vec::new(),
sources: vec![SessionSource::Cli, SessionSource::VSCode, SessionSource::Exec],
min_user_messages: 1,
include_archived: false,
include_deleted: false,
limit: Some(1),
@@ -499,6 +500,7 @@ fn load_output_schema(path: Option<PathBuf>) -> Option<Value> {
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
@@ -573,6 +575,17 @@ mod tests {
}),
}),
};
let user_line = RolloutLine {
timestamp: last_event_at.to_string(),
item: RolloutItem::ResponseItem(ResponseItem::Message {
id: Some(format!("user-{}", session_id)),
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: message.to_string(),
}],
}),
};
let assistant_line = RolloutLine {
timestamp: last_event_at.to_string(),
item: RolloutItem::ResponseItem(ResponseItem::Message {
@@ -589,6 +602,8 @@ mod tests {
writer.write_all(b"\n").unwrap();
serde_json::to_writer(&mut writer, &event_line).unwrap();
writer.write_all(b"\n").unwrap();
serde_json::to_writer(&mut writer, &user_line).unwrap();
writer.write_all(b"\n").unwrap();
serde_json::to_writer(&mut writer, &assistant_line).unwrap();
writer.write_all(b"\n").unwrap();
writer.flush().unwrap();
@@ -629,7 +644,12 @@ mod tests {
.await
.unwrap()
.expect("path");
assert!(path.ends_with("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb.jsonl"));
let path_str = path.to_string_lossy();
assert!(
path_str.contains("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"),
"resolved path should reference newer session, got {}",
path_str
);
}
#[tokio::test]
@@ -656,7 +676,12 @@ mod tests {
.await
.unwrap()
.expect("path");
assert!(path.ends_with("cccccccc-cccc-4ccc-8ccc-cccccccccccc.jsonl"));
let path_str = path.to_string_lossy();
assert!(
path_str.contains("cccccccc-cccc-4ccc-8ccc-cccccccccccc"),
"resolved path should match requested session, got {}",
path_str
);
}
#[tokio::test]
@@ -696,6 +721,11 @@ mod tests {
.await
.unwrap()
.expect("path");
assert!(path.ends_with("eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee.jsonl"));
let path_str = path.to_string_lossy();
assert!(
path_str.contains("eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee"),
"resolved path should ignore mtime drift, got {}",
path_str
);
}
}
@@ -15,9 +15,9 @@ use super::{BottomPane, popup_consts::MAX_POPUP_ROWS};
pub struct ResumeRow {
pub modified: String,
pub created: String,
pub msgs: String,
pub user_msgs: String,
pub branch: String,
pub summary: String,
pub last_user_message: String,
pub path: std::path::PathBuf,
}
@@ -171,8 +171,14 @@ impl BottomPaneView<'_> for ResumeSelectionView {
let rows_iter = self.rows[start..end].iter().enumerate().map(|(idx, r)| {
let i = start + idx; // absolute index
let cells = vec![
r.modified.clone(), r.created.clone(), r.msgs.clone(), r.branch.clone(), r.summary.clone()
].into_iter().map(|c| ratatui::widgets::Cell::from(c));
r.modified.clone(),
r.created.clone(),
r.user_msgs.clone(),
r.branch.clone(),
r.last_user_message.clone(),
]
.into_iter()
.map(ratatui::widgets::Cell::from);
let mut row = Row::new(cells).height(1);
if i == self.selected {
row = row.style(Style::default().bg(crate::colors::selection()).add_modifier(Modifier::BOLD));
@@ -184,12 +190,12 @@ impl BottomPaneView<'_> for ResumeSelectionView {
let widths = [
Constraint::Length(10), // Modified
Constraint::Length(10), // Created
Constraint::Length(6), // #Msgs
Constraint::Length(11), // User Msgs
Constraint::Length(10), // Branch
Constraint::Min(10), // Summary
Constraint::Min(10), // Last User Message
];
let header = Row::new(vec!["Modified", "Created", "#Msgs", "Branch", "Summary"]).height(1)
let header = Row::new(vec!["Modified", "Created", "User Msgs", "Branch", "Last User Message"]).height(1)
.style(Style::default().fg(crate::colors::text_bright()));
let table = Table::new(rows_iter, widths)
+9 -4
View File
@@ -3353,7 +3353,12 @@ impl ChatWidget<'_> {
// Discover candidates
let cwd = self.config.cwd.clone();
let code_home = self.config.code_home.clone();
let candidates = crate::resume::discovery::list_sessions_for_cwd(&cwd, &code_home);
let exclude_path = self.config.experimental_resume.clone();
let candidates = crate::resume::discovery::list_sessions_for_cwd(
&cwd,
&code_home,
exclude_path.as_deref(),
);
if candidates.is_empty() {
self.push_background_tail("No past sessions found for this folder".to_string());
return;
@@ -3392,7 +3397,7 @@ impl ChatWidget<'_> {
.map(|c| {
let modified = human_ago(&c.modified_ts.unwrap_or_default());
let created = human_ago(&c.created_ts.unwrap_or_default());
let msgs = format!("{}", c.message_count);
let user_msgs = format!("{}", c.user_message_count);
let branch = c.branch.unwrap_or_else(|| "-".to_string());
let mut summary = c.snippet.unwrap_or_else(|| c.subtitle.unwrap_or_default());
const SNIPPET_MAX: usize = 64;
@@ -3402,9 +3407,9 @@ impl ChatWidget<'_> {
crate::bottom_pane::resume_selection_view::ResumeRow {
modified,
created,
msgs,
user_msgs,
branch,
summary,
last_user_message: summary,
path: c.path,
}
})
+22 -4
View File
@@ -1,4 +1,5 @@
use code_core::{entry_to_rollout_path, SessionCatalog, SessionIndexEntry, SessionQuery};
use code_protocol::protocol::SessionSource;
use std::path::{Path, PathBuf};
use std::thread;
use tokio::runtime::{Builder, Handle};
@@ -9,25 +10,31 @@ pub struct ResumeCandidate {
pub subtitle: Option<String>,
pub created_ts: Option<String>,
pub modified_ts: Option<String>,
pub message_count: usize,
pub user_message_count: usize,
pub branch: Option<String>,
pub snippet: Option<String>,
}
/// Return sessions matching the provided cwd using the SessionCatalog.
/// Includes CLI, VSCode, Exec/model sessions, etc.
pub fn list_sessions_for_cwd(cwd: &Path, code_home: &Path) -> Vec<ResumeCandidate> {
pub fn list_sessions_for_cwd(
cwd: &Path,
code_home: &Path,
exclude_path: Option<&Path>,
) -> Vec<ResumeCandidate> {
const MAX_RESULTS: usize = 200;
let code_home = code_home.to_path_buf();
let cwd = cwd.to_path_buf();
let exclude_path = exclude_path.map(|p| p.to_path_buf());
let fetch = async move {
let catalog = SessionCatalog::new(code_home.clone());
let query = SessionQuery {
cwd: Some(cwd),
git_root: None,
sources: Vec::new(),
sources: vec![SessionSource::Cli, SessionSource::VSCode, SessionSource::Exec],
min_user_messages: 1,
include_archived: false,
include_deleted: false,
limit: Some(MAX_RESULTS),
@@ -36,6 +43,17 @@ pub fn list_sessions_for_cwd(cwd: &Path, code_home: &Path) -> Vec<ResumeCandidat
match catalog.query(&query).await {
Ok(entries) => entries
.into_iter()
.filter(|entry| {
if entry.session_source == SessionSource::Mcp {
return false;
}
if let Some(exclude) = exclude_path.as_deref() {
if entry_to_rollout_path(&code_home, entry) == exclude {
return false;
}
}
true
})
.map(|entry| entry_to_candidate(&code_home, entry))
.collect(),
Err(err) => {
@@ -75,7 +93,7 @@ fn entry_to_candidate(code_home: &Path, entry: SessionIndexEntry) -> ResumeCandi
subtitle: entry.last_user_snippet.clone(),
created_ts: Some(entry.created_at.clone()),
modified_ts: Some(entry.last_event_at.clone()),
message_count: entry.message_count,
user_message_count: entry.user_message_count,
branch: entry.git_branch.clone(),
snippet: entry.last_user_snippet.clone(),
}
+3 -5
View File
@@ -79,12 +79,10 @@ fn event_only_sessions_are_dropped_by_resume_discovery() {
let rollout_path = sessions_dir.join("rollout-2025-10-06T12-00-00-00000000-0000-0000-0000-000000000042.jsonl");
write_event_only_session(&rollout_path, &project_cwd);
let results = super::discovery::list_sessions_for_cwd(&project_cwd, code_home);
let results = super::discovery::list_sessions_for_cwd(&project_cwd, code_home, None);
assert!(
!results.is_empty(),
"expected resume discovery to surface sessions that only contain Event-based user messages. \
An empty result demonstrates the regression where event-only transcripts are filtered out, \
making '/resume' miss recent conversations."
results.is_empty(),
"event-only sessions should be excluded from the resume picker"
);
}
@@ -65,6 +65,17 @@ fn write_rollout(
}),
};
let user_line = RolloutLine {
timestamp: last_event_at.to_string(),
item: RolloutItem::ResponseItem(ResponseItem::Message {
id: Some(format!("user-{}", session_id)),
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: user_text.to_string(),
}],
}),
};
let assistant_line = RolloutLine {
timestamp: last_event_at.to_string(),
item: RolloutItem::ResponseItem(ResponseItem::Message {
@@ -81,6 +92,8 @@ fn write_rollout(
writer.write_all(b"\n").unwrap();
serde_json::to_writer(&mut writer, &event_line).unwrap();
writer.write_all(b"\n").unwrap();
serde_json::to_writer(&mut writer, &user_line).unwrap();
writer.write_all(b"\n").unwrap();
serde_json::to_writer(&mut writer, &assistant_line).unwrap();
writer.write_all(b"\n").unwrap();
writer.flush().unwrap();
@@ -113,7 +126,7 @@ fn resume_picker_lists_exec_sessions() {
"exec",
);
let results = list_sessions_for_cwd(&cwd, temp.path());
let results = list_sessions_for_cwd(&cwd, temp.path(), None);
assert_eq!(results.len(), 2);
let sources: Vec<_> = results.iter().map(|cand| cand.subtitle.clone()).collect();
assert!(sources.iter().any(|s| s.as_deref() == Some("cli")));
@@ -150,7 +163,59 @@ fn resume_picker_orders_by_last_event_even_with_mtime_drift() {
set_file_mtime(&old_path, FileTime::from_system_time(base + Duration::from_secs(300))).unwrap();
set_file_mtime(&new_path, FileTime::from_system_time(base + Duration::from_secs(60))).unwrap();
let results = list_sessions_for_cwd(&cwd, temp.path());
let results = list_sessions_for_cwd(&cwd, temp.path(), None);
assert_eq!(results.len(), 2);
assert_eq!(results[0].path, new_path);
}
#[test]
fn resume_picker_excludes_current_path_and_empty_sessions() {
let temp = TempDir::new().unwrap();
let cwd = std::path::PathBuf::from("/project");
let exclude = write_rollout(
temp.path(),
Uuid::parse_str("eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee").unwrap(),
"2025-11-17T10:00:00Z",
"2025-11-17T10:00:05Z",
&cwd,
SessionSource::Cli,
"current",
);
// Empty session
let sessions_dir = temp.path().join("sessions").join("2025").join("11").join("18");
std::fs::create_dir_all(&sessions_dir).unwrap();
let empty_path = sessions_dir.join("rollout-empty.jsonl");
let session_meta = SessionMeta {
id: ConversationId::new(),
timestamp: "2025-11-18T09:00:00Z".to_string(),
cwd: cwd.clone(),
originator: "test".to_string(),
cli_version: "0.0.0-test".to_string(),
instructions: None,
source: SessionSource::Cli,
};
let session_line = RolloutLine {
timestamp: session_meta.timestamp.clone(),
item: RolloutItem::SessionMeta(SessionMetaLine { meta: session_meta, git: None }),
};
let mut writer = BufWriter::new(std::fs::File::create(&empty_path).unwrap());
serde_json::to_writer(&mut writer, &session_line).unwrap();
writer.write_all(b"\n").unwrap();
writer.flush().unwrap();
let visible = write_rollout(
temp.path(),
Uuid::parse_str("ffffffff-ffff-4fff-8fff-ffffffffffff").unwrap(),
"2025-11-18T08:00:00Z",
"2025-11-18T08:05:00Z",
&cwd,
SessionSource::Exec,
"visible",
);
let results = list_sessions_for_cwd(&cwd, temp.path(), Some(exclude.as_path()));
assert_eq!(results.len(), 1);
assert_eq!(results[0].path, visible);
}