mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
fix(resume): count user input from rollouts
This commit is contained in:
Generated
+1
@@ -1239,6 +1239,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+49
-19
@@ -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
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -51,3 +51,4 @@ libc = "0.2"
|
||||
[dev-dependencies]
|
||||
filetime = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
|
||||
+34
-4
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user