Conversation naming (#8991)

Session renaming:
- `/rename my_session`
- `/rename` without arg and passing an argument in `customViewPrompt`
- AppExitInfo shows resume hint using the session name if set instead of
uuid, defaults to uuid if not set
- Names are stored in `CODEX_HOME/sessions.jsonl`

Session resuming:
- codex resume <name> lookup for `CODEX_HOME/sessions.jsonl` first entry
matching the name and resumes the session

---------

Co-authored-by: jif-oai <jif@openai.com>
This commit is contained in:
pap-openai
2026-01-30 10:40:09 +00:00
committed by GitHub
parent 25ad414680
commit 1ef5455eb6
33 changed files with 908 additions and 30 deletions

View File

@@ -26,6 +26,7 @@ use crate::features::maybe_push_unstable_features_warning;
use crate::models_manager::manager::ModelsManager;
use crate::parse_command::parse_command;
use crate::parse_turn_item;
use crate::rollout::session_index;
use crate::stream_events_utils::HandleOutputCtx;
use crate::stream_events_utils::handle_non_tool_response_item;
use crate::stream_events_utils::handle_output_item_done;
@@ -354,6 +355,8 @@ impl Codex {
sandbox_policy: config.sandbox_policy.clone(),
windows_sandbox_level: WindowsSandboxLevel::from_config(&config),
cwd: config.cwd.clone(),
codex_home: config.codex_home.clone(),
thread_name: None,
original_config_do_not_use: Arc::clone(&config),
session_source,
dynamic_tools,
@@ -544,6 +547,10 @@ pub(crate) struct SessionConfiguration {
/// `ConfigureSession` operation so that the business-logic layer can
/// operate deterministically.
cwd: PathBuf,
/// Directory containing all Codex state for this session.
codex_home: PathBuf,
/// Optional user-facing name for the thread, updated during the session.
thread_name: Option<String>,
// TODO(pakrym): Remove config from here
original_config_do_not_use: Arc<Config>,
@@ -553,6 +560,10 @@ pub(crate) struct SessionConfiguration {
}
impl SessionConfiguration {
pub(crate) fn codex_home(&self) -> &PathBuf {
&self.codex_home
}
fn thread_config_snapshot(&self) -> ThreadConfigSnapshot {
ThreadConfigSnapshot {
model: self.collaboration_mode.model().to_string(),
@@ -623,6 +634,11 @@ impl Session {
per_turn_config
}
pub(crate) async fn codex_home(&self) -> PathBuf {
let state = self.state.lock().await;
state.session_configuration.codex_home().clone()
}
#[allow(clippy::too_many_arguments)]
fn make_turn_context(
auth_manager: Option<Arc<AuthManager>>,
@@ -683,7 +699,7 @@ impl Session {
#[allow(clippy::too_many_arguments)]
async fn new(
session_configuration: SessionConfiguration,
mut session_configuration: SessionConfiguration,
config: Arc<Config>,
auth_manager: Arc<AuthManager>,
models_manager: Arc<ModelsManager>,
@@ -863,6 +879,16 @@ impl Session {
otel_manager.clone(),
);
}
let thread_name =
match session_index::find_thread_name_by_id(&config.codex_home, &conversation_id).await
{
Ok(name) => name,
Err(err) => {
warn!("Failed to read session index for thread name: {err}");
None
}
};
session_configuration.thread_name = thread_name.clone();
let state = SessionState::new(session_configuration.clone());
let services = SessionServices {
@@ -904,6 +930,7 @@ impl Session {
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
session_id: conversation_id,
forked_from_id,
thread_name: session_configuration.thread_name.clone(),
model: session_configuration.collaboration_mode.model().to_string(),
model_provider_id: config.model_provider_id.clone(),
approval_policy: session_configuration.approval_policy.value(),
@@ -1451,8 +1478,7 @@ impl Session {
.lock()
.await
.session_configuration
.original_config_do_not_use
.codex_home
.codex_home()
.clone();
if !features.enabled(Feature::ExecPolicy) {
@@ -2440,6 +2466,9 @@ async fn submission_loop(sess: Arc<Session>, config: Arc<Config>, rx_sub: Receiv
Op::ThreadRollback { num_turns } => {
handlers::thread_rollback(&sess, sub.id.clone(), num_turns).await;
}
Op::SetThreadName { name } => {
handlers::set_thread_name(&sess, sub.id.clone(), name).await;
}
Op::RunUserShellCommand { command } => {
handlers::run_user_shell_command(
&sess,
@@ -2483,6 +2512,7 @@ mod handlers {
use crate::mcp::collect_mcp_snapshot_from_manager;
use crate::mcp::effective_mcp_servers;
use crate::review_prompts::resolve_review_request;
use crate::rollout::session_index;
use crate::tasks::CompactTask;
use crate::tasks::RegularTask;
use crate::tasks::UndoTask;
@@ -2499,6 +2529,7 @@ mod handlers {
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::protocol::ReviewRequest;
use codex_protocol::protocol::SkillsListEntry;
use codex_protocol::protocol::ThreadNameUpdatedEvent;
use codex_protocol::protocol::ThreadRolledBackEvent;
use codex_protocol::protocol::TurnAbortReason;
use codex_protocol::protocol::WarningEvent;
@@ -2952,6 +2983,72 @@ mod handlers {
.await;
}
/// Persists the thread name in the session index, updates in-memory state, and emits
/// a `ThreadNameUpdated` event on success.
///
/// This appends the name to `CODEX_HOME/sessions_index.jsonl` via `session_index::append_thread_name` for the
/// current `thread_id`, then updates `SessionConfiguration::thread_name`.
///
/// Returns an error event if the name is empty or session persistence is disabled.
pub async fn set_thread_name(sess: &Arc<Session>, sub_id: String, name: String) {
let Some(name) = crate::util::normalize_thread_name(&name) else {
let event = Event {
id: sub_id,
msg: EventMsg::Error(ErrorEvent {
message: "Thread name cannot be empty.".to_string(),
codex_error_info: Some(CodexErrorInfo::BadRequest),
}),
};
sess.send_event_raw(event).await;
return;
};
let persistence_enabled = {
let rollout = sess.services.rollout.lock().await;
rollout.is_some()
};
if !persistence_enabled {
let event = Event {
id: sub_id,
msg: EventMsg::Error(ErrorEvent {
message: "Session persistence is disabled; cannot rename thread.".to_string(),
codex_error_info: Some(CodexErrorInfo::Other),
}),
};
sess.send_event_raw(event).await;
return;
};
let codex_home = sess.codex_home().await;
if let Err(e) =
session_index::append_thread_name(&codex_home, sess.conversation_id, &name).await
{
let event = Event {
id: sub_id,
msg: EventMsg::Error(ErrorEvent {
message: format!("Failed to set thread name: {e}"),
codex_error_info: Some(CodexErrorInfo::Other),
}),
};
sess.send_event_raw(event).await;
return;
}
{
let mut state = sess.state.lock().await;
state.session_configuration.thread_name = Some(name.clone());
}
sess.send_event_raw(Event {
id: sub_id,
msg: EventMsg::ThreadNameUpdated(ThreadNameUpdatedEvent {
thread_id: sess.conversation_id,
thread_name: Some(name),
}),
})
.await;
}
pub async fn shutdown(sess: &Arc<Session>, sub_id: String) -> bool {
sess.abort_all_tasks(TurnAbortReason::Interrupted).await;
sess.services
@@ -4440,6 +4537,8 @@ mod tests {
sandbox_policy: config.sandbox_policy.clone(),
windows_sandbox_level: WindowsSandboxLevel::from_config(&config),
cwd: config.cwd.clone(),
codex_home: config.codex_home.clone(),
thread_name: None,
original_config_do_not_use: Arc::clone(&config),
session_source: SessionSource::Exec,
dynamic_tools: Vec::new(),
@@ -4521,6 +4620,8 @@ mod tests {
sandbox_policy: config.sandbox_policy.clone(),
windows_sandbox_level: WindowsSandboxLevel::from_config(&config),
cwd: config.cwd.clone(),
codex_home: config.codex_home.clone(),
thread_name: None,
original_config_do_not_use: Arc::clone(&config),
session_source: SessionSource::Exec,
dynamic_tools: Vec::new(),
@@ -4786,6 +4887,8 @@ mod tests {
sandbox_policy: config.sandbox_policy.clone(),
windows_sandbox_level: WindowsSandboxLevel::from_config(&config),
cwd: config.cwd.clone(),
codex_home: config.codex_home.clone(),
thread_name: None,
original_config_do_not_use: Arc::clone(&config),
session_source: SessionSource::Exec,
dynamic_tools: Vec::new(),
@@ -4900,6 +5003,8 @@ mod tests {
sandbox_policy: config.sandbox_policy.clone(),
windows_sandbox_level: WindowsSandboxLevel::from_config(&config),
cwd: config.cwd.clone(),
codex_home: config.codex_home.clone(),
thread_name: None,
original_config_do_not_use: Arc::clone(&config),
session_source: SessionSource::Exec,
dynamic_tools: Vec::new(),

View File

@@ -208,6 +208,10 @@ async fn forward_events(
id: _,
msg: EventMsg::SessionConfigured(_),
} => {}
Event {
id: _,
msg: EventMsg::ThreadNameUpdated(_),
} => {}
Event {
id,
msg: EventMsg::ExecApprovalRequest(event),

View File

@@ -100,12 +100,14 @@ pub mod turn_diff_tracker;
pub use rollout::ARCHIVED_SESSIONS_SUBDIR;
pub use rollout::INTERACTIVE_SESSION_SOURCES;
pub use rollout::RolloutRecorder;
pub use rollout::RolloutRecorderParams;
pub use rollout::SESSIONS_SUBDIR;
pub use rollout::SessionMeta;
pub use rollout::find_archived_thread_path_by_id_str;
#[deprecated(note = "use find_thread_path_by_id_str")]
pub use rollout::find_conversation_path_by_id_str;
pub use rollout::find_thread_path_by_id_str;
pub use rollout::find_thread_path_by_name_str;
pub use rollout::list::Cursor;
pub use rollout::list::ThreadItem;
pub use rollout::list::ThreadSortKey;

View File

@@ -12,6 +12,7 @@ pub mod list;
pub(crate) mod metadata;
pub(crate) mod policy;
pub mod recorder;
pub(crate) mod session_index;
pub(crate) mod truncation;
pub use codex_protocol::protocol::SessionMeta;
@@ -23,6 +24,7 @@ pub use list::find_thread_path_by_id_str as find_conversation_path_by_id_str;
pub use list::rollout_date_parts;
pub use recorder::RolloutRecorder;
pub use recorder::RolloutRecorderParams;
pub use session_index::find_thread_path_by_name_str;
#[cfg(test)]
pub mod tests;

View File

@@ -58,6 +58,7 @@ pub(crate) fn should_persist_event_msg(ev: &EventMsg) -> bool {
| EventMsg::AgentReasoningSectionBreak(_)
| EventMsg::RawResponseItem(_)
| EventMsg::SessionConfigured(_)
| EventMsg::ThreadNameUpdated(_)
| EventMsg::McpToolCallBegin(_)
| EventMsg::McpToolCallEnd(_)
| EventMsg::WebSearchBegin(_)

View File

@@ -0,0 +1,325 @@
use std::fs::File;
use std::io::Read;
use std::io::Seek;
use std::io::SeekFrom;
use std::path::Path;
use std::path::PathBuf;
use codex_protocol::ThreadId;
use serde::Deserialize;
use serde::Serialize;
use tokio::io::AsyncWriteExt;
const SESSION_INDEX_FILE: &str = "session_index.jsonl";
const READ_CHUNK_SIZE: usize = 8192;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SessionIndexEntry {
pub id: ThreadId,
pub thread_name: String,
pub updated_at: String,
}
/// Append a thread name update to the session index.
/// The index is append-only; the most recent entry wins when resolving names or ids.
pub async fn append_thread_name(
codex_home: &Path,
thread_id: ThreadId,
name: &str,
) -> std::io::Result<()> {
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
let updated_at = OffsetDateTime::now_utc()
.format(&Rfc3339)
.unwrap_or_else(|_| "unknown".to_string());
let entry = SessionIndexEntry {
id: thread_id,
thread_name: name.to_string(),
updated_at,
};
append_session_index_entry(codex_home, &entry).await
}
/// Append a raw session index entry to `session_index.jsonl`.
/// The file is append-only; consumers scan from the end to find the newest match.
pub async fn append_session_index_entry(
codex_home: &Path,
entry: &SessionIndexEntry,
) -> std::io::Result<()> {
let path = session_index_path(codex_home);
let mut file = tokio::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.await?;
let mut line = serde_json::to_string(entry).map_err(std::io::Error::other)?;
line.push('\n');
file.write_all(line.as_bytes()).await?;
file.flush().await?;
Ok(())
}
/// Find the latest thread name for a thread id, if any.
pub async fn find_thread_name_by_id(
codex_home: &Path,
thread_id: &ThreadId,
) -> std::io::Result<Option<String>> {
let path = session_index_path(codex_home);
if !path.exists() {
return Ok(None);
}
let id = *thread_id;
let entry = tokio::task::spawn_blocking(move || scan_index_from_end_by_id(&path, &id))
.await
.map_err(std::io::Error::other)??;
Ok(entry.map(|entry| entry.thread_name))
}
/// Find the most recently updated thread id for a thread name, if any.
pub async fn find_thread_id_by_name(
codex_home: &Path,
name: &str,
) -> std::io::Result<Option<ThreadId>> {
if name.trim().is_empty() {
return Ok(None);
}
let path = session_index_path(codex_home);
if !path.exists() {
return Ok(None);
}
let name = name.to_string();
let entry = tokio::task::spawn_blocking(move || scan_index_from_end_by_name(&path, &name))
.await
.map_err(std::io::Error::other)??;
Ok(entry.map(|entry| entry.id))
}
/// Locate a recorded thread rollout file by thread name using newest-first ordering.
/// Returns `Ok(Some(path))` if found, `Ok(None)` if not present.
pub async fn find_thread_path_by_name_str(
codex_home: &Path,
name: &str,
) -> std::io::Result<Option<PathBuf>> {
let Some(thread_id) = find_thread_id_by_name(codex_home, name).await? else {
return Ok(None);
};
super::list::find_thread_path_by_id_str(codex_home, &thread_id.to_string()).await
}
fn session_index_path(codex_home: &Path) -> PathBuf {
codex_home.join(SESSION_INDEX_FILE)
}
fn scan_index_from_end_by_id(
path: &Path,
thread_id: &ThreadId,
) -> std::io::Result<Option<SessionIndexEntry>> {
scan_index_from_end(path, |entry| entry.id == *thread_id)
}
fn scan_index_from_end_by_name(
path: &Path,
name: &str,
) -> std::io::Result<Option<SessionIndexEntry>> {
scan_index_from_end(path, |entry| entry.thread_name == name)
}
fn scan_index_from_end<F>(
path: &Path,
mut predicate: F,
) -> std::io::Result<Option<SessionIndexEntry>>
where
F: FnMut(&SessionIndexEntry) -> bool,
{
let mut file = File::open(path)?;
let mut remaining = file.metadata()?.len();
let mut line_rev: Vec<u8> = Vec::new();
let mut buf = vec![0u8; READ_CHUNK_SIZE];
while remaining > 0 {
let read_size = usize::try_from(remaining.min(READ_CHUNK_SIZE as u64))
.map_err(std::io::Error::other)?;
remaining -= read_size as u64;
file.seek(SeekFrom::Start(remaining))?;
file.read_exact(&mut buf[..read_size])?;
for &byte in buf[..read_size].iter().rev() {
if byte == b'\n' {
if let Some(entry) = parse_line_from_rev(&mut line_rev, &mut predicate)? {
return Ok(Some(entry));
}
continue;
}
line_rev.push(byte);
}
}
if let Some(entry) = parse_line_from_rev(&mut line_rev, &mut predicate)? {
return Ok(Some(entry));
}
Ok(None)
}
fn parse_line_from_rev<F>(
line_rev: &mut Vec<u8>,
predicate: &mut F,
) -> std::io::Result<Option<SessionIndexEntry>>
where
F: FnMut(&SessionIndexEntry) -> bool,
{
if line_rev.is_empty() {
return Ok(None);
}
line_rev.reverse();
let line = std::mem::take(line_rev);
let Ok(mut line) = String::from_utf8(line) else {
return Ok(None);
};
if line.ends_with('\r') {
line.pop();
}
let trimmed = line.trim();
if trimmed.is_empty() {
return Ok(None);
}
let Ok(entry) = serde_json::from_str::<SessionIndexEntry>(trimmed) else {
return Ok(None);
};
if predicate(&entry) {
return Ok(Some(entry));
}
Ok(None)
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
fn write_index(path: &Path, lines: &[SessionIndexEntry]) -> std::io::Result<()> {
let mut out = String::new();
for entry in lines {
out.push_str(&serde_json::to_string(entry).unwrap());
out.push('\n');
}
std::fs::write(path, out)
}
#[test]
fn find_thread_id_by_name_prefers_latest_entry() -> std::io::Result<()> {
let temp = TempDir::new()?;
let path = session_index_path(temp.path());
let id1 = ThreadId::new();
let id2 = ThreadId::new();
let lines = vec![
SessionIndexEntry {
id: id1,
thread_name: "same".to_string(),
updated_at: "2024-01-01T00:00:00Z".to_string(),
},
SessionIndexEntry {
id: id2,
thread_name: "same".to_string(),
updated_at: "2024-01-02T00:00:00Z".to_string(),
},
];
write_index(&path, &lines)?;
let found = scan_index_from_end_by_name(&path, "same")?;
assert_eq!(found.map(|entry| entry.id), Some(id2));
Ok(())
}
#[test]
fn find_thread_name_by_id_prefers_latest_entry() -> std::io::Result<()> {
let temp = TempDir::new()?;
let path = session_index_path(temp.path());
let id = ThreadId::new();
let lines = vec![
SessionIndexEntry {
id,
thread_name: "first".to_string(),
updated_at: "2024-01-01T00:00:00Z".to_string(),
},
SessionIndexEntry {
id,
thread_name: "second".to_string(),
updated_at: "2024-01-02T00:00:00Z".to_string(),
},
];
write_index(&path, &lines)?;
let found = scan_index_from_end_by_id(&path, &id)?;
assert_eq!(
found.map(|entry| entry.thread_name),
Some("second".to_string())
);
Ok(())
}
#[test]
fn scan_index_returns_none_when_entry_missing() -> std::io::Result<()> {
let temp = TempDir::new()?;
let path = session_index_path(temp.path());
let id = ThreadId::new();
let lines = vec![SessionIndexEntry {
id,
thread_name: "present".to_string(),
updated_at: "2024-01-01T00:00:00Z".to_string(),
}];
write_index(&path, &lines)?;
let missing_name = scan_index_from_end_by_name(&path, "missing")?;
assert_eq!(missing_name, None);
let missing_id = scan_index_from_end_by_id(&path, &ThreadId::new())?;
assert_eq!(missing_id, None);
Ok(())
}
#[test]
fn scan_index_finds_latest_match_among_mixed_entries() -> std::io::Result<()> {
let temp = TempDir::new()?;
let path = session_index_path(temp.path());
let id_target = ThreadId::new();
let id_other = ThreadId::new();
let expected = SessionIndexEntry {
id: id_target,
thread_name: "target".to_string(),
updated_at: "2024-01-03T00:00:00Z".to_string(),
};
let expected_other = SessionIndexEntry {
id: id_other,
thread_name: "target".to_string(),
updated_at: "2024-01-02T00:00:00Z".to_string(),
};
// Resolution is based on append order (scan from end), not updated_at.
let lines = vec![
SessionIndexEntry {
id: id_target,
thread_name: "target".to_string(),
updated_at: "2024-01-01T00:00:00Z".to_string(),
},
expected_other.clone(),
expected.clone(),
SessionIndexEntry {
id: ThreadId::new(),
thread_name: "another".to_string(),
updated_at: "2024-01-04T00:00:00Z".to_string(),
},
];
write_index(&path, &lines)?;
let found_by_name = scan_index_from_end_by_name(&path, "target")?;
assert_eq!(found_by_name, Some(expected.clone()));
let found_by_id = scan_index_from_end_by_id(&path, &id_target)?;
assert_eq!(found_by_id, Some(expected));
let found_other_by_id = scan_index_from_end_by_id(&path, &id_other)?;
assert_eq!(found_other_by_id, Some(expected_other));
Ok(())
}
}

View File

@@ -2,10 +2,13 @@ use std::path::Path;
use std::path::PathBuf;
use std::time::Duration;
use codex_protocol::ThreadId;
use rand::Rng;
use tracing::debug;
use tracing::error;
use crate::parse_command::shlex_join;
const INITIAL_DELAY_MS: u64 = 200;
const BACKOFF_FACTOR: f64 = 2.0;
@@ -72,6 +75,32 @@ pub fn resolve_path(base: &Path, path: &PathBuf) -> PathBuf {
}
}
/// Trim a thread name and return `None` if it is empty after trimming.
pub fn normalize_thread_name(name: &str) -> Option<String> {
let trimmed = name.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
pub fn resume_command(thread_name: Option<&str>, thread_id: Option<ThreadId>) -> Option<String> {
let resume_target = thread_name
.filter(|name| !name.is_empty())
.map(str::to_string)
.or_else(|| thread_id.map(|thread_id| thread_id.to_string()));
resume_target.map(|target| {
let needs_double_dash = target.starts_with('-');
let escaped = shlex_join(&[target]);
if needs_double_dash {
format!("codex resume -- {escaped}")
} else {
format!("codex resume {escaped}")
}
})
}
#[cfg(test)]
mod tests {
use super::*;
@@ -107,4 +136,51 @@ mod tests {
feedback_tags!(model = "gpt-5", cached = true, debug_only = OnlyDebug);
}
#[test]
fn normalize_thread_name_trims_and_rejects_empty() {
assert_eq!(normalize_thread_name(" "), None);
assert_eq!(
normalize_thread_name(" my thread "),
Some("my thread".to_string())
);
}
#[test]
fn resume_command_prefers_name_over_id() {
let thread_id = ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap();
let command = resume_command(Some("my-thread"), Some(thread_id));
assert_eq!(command, Some("codex resume my-thread".to_string()));
}
#[test]
fn resume_command_with_only_id() {
let thread_id = ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap();
let command = resume_command(None, Some(thread_id));
assert_eq!(
command,
Some("codex resume 123e4567-e89b-12d3-a456-426614174000".to_string())
);
}
#[test]
fn resume_command_with_no_name_or_id() {
let command = resume_command(None, None);
assert_eq!(command, None);
}
#[test]
fn resume_command_quotes_thread_name_when_needed() {
let command = resume_command(Some("-starts-with-dash"), None);
assert_eq!(
command,
Some("codex resume -- -starts-with-dash".to_string())
);
let command = resume_command(Some("two words"), None);
assert_eq!(command, Some("codex resume 'two words'".to_string()));
let command = resume_command(Some("quote'case"), None);
assert_eq!(command, Some("codex resume \"quote'case\"".to_string()));
}
}

View File

@@ -3,8 +3,16 @@ use std::io::Write;
use std::path::Path;
use std::path::PathBuf;
use codex_core::RolloutRecorder;
use codex_core::RolloutRecorderParams;
use codex_core::config::ConfigBuilder;
use codex_core::find_archived_thread_path_by_id_str;
use codex_core::find_thread_path_by_id_str;
use codex_core::find_thread_path_by_name_str;
use codex_core::protocol::SessionSource;
use codex_protocol::ThreadId;
use codex_protocol::models::BaseInstructions;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use uuid::Uuid;
@@ -87,6 +95,54 @@ async fn find_ignores_granular_gitignore_rules() {
assert_eq!(found, Some(expected));
}
#[tokio::test]
async fn find_locates_rollout_file_written_by_recorder() -> std::io::Result<()> {
// Ensures the name-based finder locates a rollout produced by the real recorder.
let home = TempDir::new().unwrap();
let config = ConfigBuilder::default()
.codex_home(home.path().to_path_buf())
.build()
.await?;
let thread_id = ThreadId::new();
let thread_name = "named thread";
let recorder = RolloutRecorder::new(
&config,
RolloutRecorderParams::new(
thread_id,
None,
SessionSource::Exec,
BaseInstructions::default(),
Vec::new(),
),
None,
None,
)
.await?;
recorder.flush().await?;
let index_path = home.path().join("session_index.jsonl");
std::fs::write(
&index_path,
format!(
"{}\n",
serde_json::json!({
"id": thread_id,
"thread_name": thread_name,
"updated_at": "2024-01-01T00:00:00Z"
})
),
)?;
let found = find_thread_path_by_name_str(home.path(), thread_name).await?;
let path = found.expect("expected rollout path to be found");
assert!(path.exists());
let contents = std::fs::read_to_string(&path)?;
assert!(contents.contains(&thread_id.to_string()));
recorder.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn find_archived_locates_rollout_file_by_id() {
let home = TempDir::new().unwrap();