Unify thread metadata updates above store (#22236)

- make ThreadStore::update_thread_metadata accept a broad range of
metadata patches
- keep ThreadStore::append_items as raw canonical history append (no
metadata side effects)
- in the local store, write these metadata updates to a combination of
sqlite and rollout jsonl files for backwards-compat. It special cases
which fields need to go into jsonl vs sqlite vs whatever, confining the
awkwardness to just this implementation
- in remote stores we can simply persist the metadata directly to a
database, no special casing required.
- move the "implicit metadata updates triggered by appending rollout
items" from the RolloutRecorder (which is local-threadstore-specific) to
the LiveThread layer above the ThreadStore, inside of a private helper
utility called ThreadMetadataSync. LiveThread calls ThreadStore
append_items and update_metadata separately.
- Add a generic update metadata method to ThreadManager that works on
both live threads and "cold" threads
- Call that ThreadManager method from app server code, so app server
doesn't need to worry about whether the thread is live or not
This commit is contained in:
Tom
2026-05-13 00:28:15 +00:00
committed by GitHub
parent f11ad1eacb
commit c51c65ad09
31 changed files with 2382 additions and 762 deletions
+66 -21
View File
@@ -22,6 +22,7 @@ use crate::ReadThreadParams;
use crate::ResumeThreadParams;
use crate::StoredThread;
use crate::StoredThreadHistory;
use crate::ThreadMetadataPatch;
use crate::ThreadPage;
use crate::ThreadStore;
use crate::ThreadStoreError;
@@ -127,6 +128,7 @@ struct InMemoryThreadStoreState {
calls: InMemoryThreadStoreCalls,
created_threads: HashMap<ThreadId, CreateThreadParams>,
histories: HashMap<ThreadId, Vec<RolloutItem>>,
metadata_updates: HashMap<ThreadId, ThreadMetadataPatch>,
names: HashMap<ThreadId, Option<String>>,
rollout_paths: HashMap<PathBuf, ThreadId>,
}
@@ -271,9 +273,14 @@ impl ThreadStore for InMemoryThreadStore {
) -> ThreadStoreResult<StoredThread> {
let mut state = self.state.lock().await;
state.calls.update_thread_metadata += 1;
if let Some(name) = params.patch.name {
state.names.insert(params.thread_id, Some(name));
if let Some(name) = params.patch.name.clone() {
state.names.insert(params.thread_id, name);
}
state
.metadata_updates
.entry(params.thread_id)
.or_default()
.merge(params.patch);
stored_thread_from_state(&state, params.thread_id, /*include_history*/ false)
}
@@ -307,6 +314,7 @@ fn stored_thread_from_state(
items: history_items.clone(),
});
let name = state.names.get(&thread_id).cloned().flatten();
let metadata = state.metadata_updates.get(&thread_id);
let rollout_path = state
.rollout_paths
.iter()
@@ -316,28 +324,65 @@ fn stored_thread_from_state(
Ok(StoredThread {
thread_id,
rollout_path,
rollout_path: metadata
.and_then(|metadata| metadata.rollout_path.clone())
.or(rollout_path),
forked_from_id: created.forked_from_id,
preview: String::new(),
preview: metadata
.and_then(|metadata| metadata.preview.clone())
.unwrap_or_default(),
name,
model_provider: "test".to_string(),
model: None,
reasoning_effort: None,
created_at: Utc::now(),
updated_at: Utc::now(),
model_provider: metadata
.and_then(|metadata| metadata.model_provider.clone())
.unwrap_or_else(|| "test".to_string()),
model: metadata.and_then(|metadata| metadata.model.clone()),
reasoning_effort: metadata.and_then(|metadata| metadata.reasoning_effort),
created_at: metadata
.and_then(|metadata| metadata.created_at)
.unwrap_or_else(Utc::now),
updated_at: metadata
.and_then(|metadata| metadata.updated_at)
.unwrap_or_else(Utc::now),
archived_at: None,
cwd: PathBuf::new(),
cli_version: "test".to_string(),
source: created.source.clone(),
thread_source: created.thread_source,
agent_nickname: None,
agent_role: None,
agent_path: None,
git_info: None,
approval_mode: AskForApproval::Never,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
token_usage: None,
first_user_message: None,
cwd: metadata
.and_then(|metadata| metadata.cwd.clone())
.unwrap_or_default(),
cli_version: metadata
.and_then(|metadata| metadata.cli_version.clone())
.unwrap_or_else(|| "test".to_string()),
source: metadata
.and_then(|metadata| metadata.source.clone())
.unwrap_or_else(|| created.source.clone()),
thread_source: metadata
.and_then(|metadata| metadata.thread_source)
.unwrap_or(created.thread_source),
agent_nickname: metadata.and_then(|metadata| metadata.agent_nickname.clone().flatten()),
agent_role: metadata.and_then(|metadata| metadata.agent_role.clone().flatten()),
agent_path: metadata.and_then(|metadata| metadata.agent_path.clone().flatten()),
git_info: metadata.and_then(git_info_from_patch),
approval_mode: metadata
.and_then(|metadata| metadata.approval_mode)
.unwrap_or(AskForApproval::Never),
sandbox_policy: metadata
.and_then(|metadata| metadata.sandbox_policy.clone())
.unwrap_or_else(SandboxPolicy::new_read_only_policy),
token_usage: metadata.and_then(|metadata| metadata.token_usage.clone()),
first_user_message: metadata.and_then(|metadata| metadata.first_user_message.clone()),
history,
})
}
fn git_info_from_patch(patch: &ThreadMetadataPatch) -> Option<codex_protocol::protocol::GitInfo> {
let git_info = patch.git_info.as_ref()?;
let sha = git_info.sha.clone().flatten();
let branch = git_info.branch.clone().flatten();
let origin_url = git_info.origin_url.clone().flatten();
if sha.is_none() && branch.is_none() && origin_url.is_none() {
return None;
}
Some(codex_protocol::protocol::GitInfo {
commit_hash: sha.as_deref().map(codex_git_utils::GitSha::new),
branch,
repository_url: origin_url,
})
}
+2 -1
View File
@@ -9,6 +9,7 @@ mod in_memory;
mod live_thread;
mod local;
mod store;
mod thread_metadata_sync;
mod types;
pub use error::ThreadStoreError;
@@ -22,6 +23,7 @@ pub use local::LocalThreadStoreConfig;
pub use store::ThreadStore;
pub use types::AppendThreadItemsParams;
pub use types::ArchiveThreadParams;
pub use types::ClearableField;
pub use types::CreateThreadParams;
pub use types::GitInfoPatch;
pub use types::ItemPage;
@@ -29,7 +31,6 @@ pub use types::ListItemsParams;
pub use types::ListThreadsParams;
pub use types::ListTurnsParams;
pub use types::LoadThreadHistoryParams;
pub use types::OptionalStringPatch;
pub use types::ReadThreadByRolloutPathParams;
pub use types::ReadThreadParams;
pub use types::ResumeThreadParams;
+109 -5
View File
@@ -4,6 +4,9 @@ use std::sync::Arc;
use codex_protocol::ThreadId;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::ThreadMemoryMode;
use codex_rollout::EventPersistenceMode;
use codex_rollout::persisted_rollout_items;
use tokio::sync::Mutex;
use tracing::warn;
use crate::AppendThreadItemsParams;
@@ -14,10 +17,12 @@ use crate::ReadThreadParams;
use crate::ResumeThreadParams;
use crate::StoredThread;
use crate::StoredThreadHistory;
use crate::ThreadEventPersistenceMode;
use crate::ThreadMetadataPatch;
use crate::ThreadStore;
use crate::ThreadStoreResult;
use crate::UpdateThreadMetadataParams;
use crate::thread_metadata_sync::ThreadMetadataSync;
/// Handle for an active thread's persistence lifecycle.
///
@@ -28,6 +33,8 @@ use crate::UpdateThreadMetadataParams;
pub struct LiveThread {
thread_id: ThreadId,
thread_store: Arc<dyn ThreadStore>,
event_persistence_mode: EventPersistenceMode,
metadata_sync: Arc<Mutex<ThreadMetadataSync>>,
}
/// Owns a live thread while session initialization is still fallible.
@@ -85,43 +92,96 @@ impl LiveThread {
params: CreateThreadParams,
) -> ThreadStoreResult<Self> {
let thread_id = params.thread_id;
let event_persistence_mode = event_persistence_mode(params.event_persistence_mode);
let metadata_sync = ThreadMetadataSync::for_create(&params).await;
thread_store.create_thread(params).await?;
Ok(Self {
thread_id,
thread_store,
event_persistence_mode,
metadata_sync: Arc::new(Mutex::new(metadata_sync)),
})
}
pub async fn resume(
thread_store: Arc<dyn ThreadStore>,
params: ResumeThreadParams,
mut params: ResumeThreadParams,
) -> ThreadStoreResult<Self> {
let thread_id = params.thread_id;
thread_store.resume_thread(params).await?;
let event_persistence_mode = event_persistence_mode(params.event_persistence_mode);
let should_load_history = params.history.is_none();
let include_archived = params.include_archived;
thread_store.resume_thread(params.clone()).await?;
if should_load_history {
match thread_store
.load_history(LoadThreadHistoryParams {
thread_id,
include_archived,
})
.await
{
Ok(history) => params.history = Some(history.items),
Err(err) => {
let _ = thread_store.discard_thread(thread_id).await;
return Err(err);
}
}
}
let metadata_sync = ThreadMetadataSync::for_resume(&params);
Ok(Self {
thread_id,
thread_store,
event_persistence_mode,
metadata_sync: Arc::new(Mutex::new(metadata_sync)),
})
}
pub async fn append_items(&self, items: &[RolloutItem]) -> ThreadStoreResult<()> {
let canonical_items = persisted_rollout_items(items, self.event_persistence_mode);
if canonical_items.is_empty() {
return Ok(());
}
self.thread_store
.append_items(AppendThreadItemsParams {
thread_id: self.thread_id,
items: items.to_vec(),
items: canonical_items.clone(),
})
.await?;
let update = self
.metadata_sync
.lock()
.await
.observe_appended_items(canonical_items.as_slice());
if let Some(update) = update {
self.thread_store
.update_thread_metadata(UpdateThreadMetadataParams {
thread_id: self.thread_id,
patch: update.patch.clone(),
include_archived: true,
})
.await?;
self.metadata_sync
.lock()
.await
.mark_pending_update_applied(&update);
}
Ok(())
}
pub async fn persist(&self) -> ThreadStoreResult<()> {
self.thread_store.persist_thread(self.thread_id).await
self.thread_store.persist_thread(self.thread_id).await?;
self.flush_pending_metadata_update().await
}
pub async fn flush(&self) -> ThreadStoreResult<()> {
self.thread_store.flush_thread(self.thread_id).await
self.thread_store.flush_thread(self.thread_id).await?;
self.flush_pending_metadata_update_for_existing_history()
.await
}
pub async fn shutdown(&self) -> ThreadStoreResult<()> {
self.flush_pending_metadata_update_for_existing_history()
.await?;
self.thread_store.shutdown_thread(self.thread_id).await
}
@@ -160,6 +220,7 @@ impl LiveThread {
mode: ThreadMemoryMode,
include_archived: bool,
) -> ThreadStoreResult<()> {
self.flush_pending_metadata_update().await?;
self.thread_store
.update_thread_metadata(UpdateThreadMetadataParams {
thread_id: self.thread_id,
@@ -178,6 +239,7 @@ impl LiveThread {
patch: ThreadMetadataPatch,
include_archived: bool,
) -> ThreadStoreResult<StoredThread> {
self.flush_pending_metadata_update().await?;
self.thread_store
.update_thread_metadata(UpdateThreadMetadataParams {
thread_id: self.thread_id,
@@ -203,4 +265,46 @@ impl LiveThread {
.await
.map(Some)
}
async fn flush_pending_metadata_update(&self) -> ThreadStoreResult<()> {
let update = self.metadata_sync.lock().await.take_pending_update();
self.apply_pending_metadata_update(update).await
}
async fn flush_pending_metadata_update_for_existing_history(&self) -> ThreadStoreResult<()> {
let update = self
.metadata_sync
.lock()
.await
.take_pending_update_for_existing_history();
self.apply_pending_metadata_update(update).await
}
async fn apply_pending_metadata_update(
&self,
update: Option<crate::thread_metadata_sync::PendingThreadMetadataPatch>,
) -> ThreadStoreResult<()> {
let Some(update) = update else {
return Ok(());
};
self.thread_store
.update_thread_metadata(UpdateThreadMetadataParams {
thread_id: self.thread_id,
patch: update.patch.clone(),
include_archived: true,
})
.await?;
self.metadata_sync
.lock()
.await
.mark_pending_update_applied(&update);
Ok(())
}
}
fn event_persistence_mode(mode: ThreadEventPersistenceMode) -> EventPersistenceMode {
match mode {
ThreadEventPersistenceMode::Limited => EventPersistenceMode::Limited,
ThreadEventPersistenceMode::Extended => EventPersistenceMode::Extended,
}
}
@@ -1,10 +1,8 @@
use super::LocalThreadStore;
use crate::CreateThreadParams;
use crate::ThreadEventPersistenceMode;
use crate::ThreadStoreError;
use crate::ThreadStoreResult;
use codex_protocol::protocol::ThreadMemoryMode;
use codex_rollout::EventPersistenceMode;
use codex_rollout::RolloutConfig;
use codex_rollout::RolloutRecorder;
use codex_rollout::RolloutRecorderParams;
@@ -27,7 +25,6 @@ pub(super) async fn create_thread(
model_provider_id: params.metadata.model_provider.clone(),
generate_memories: matches!(params.metadata.memory_mode, ThreadMemoryMode::Enabled),
};
let state_db_ctx = store.state_db().await;
let recorder = RolloutRecorder::new(
&config,
RolloutRecorderParams::new(
@@ -37,10 +34,7 @@ pub(super) async fn create_thread(
params.thread_source,
params.base_instructions,
params.dynamic_tools,
event_persistence_mode(params.event_persistence_mode),
),
state_db_ctx,
/*state_builder*/ None,
)
.await
.map_err(|err| ThreadStoreError::Internal {
@@ -49,10 +43,3 @@ pub(super) async fn create_thread(
Ok(recorder)
}
pub(super) fn event_persistence_mode(mode: ThreadEventPersistenceMode) -> EventPersistenceMode {
match mode {
ThreadEventPersistenceMode::Limited => EventPersistenceMode::Limited,
ThreadEventPersistenceMode::Extended => EventPersistenceMode::Extended,
}
}
+66 -33
View File
@@ -5,7 +5,7 @@ use codex_protocol::protocol::ThreadMemoryMode;
use codex_rollout::RolloutConfig;
use codex_rollout::RolloutRecorder;
use codex_rollout::RolloutRecorderParams;
use codex_rollout::builder_from_items;
use tracing::warn;
use super::LocalThreadStore;
use super::create_thread;
@@ -31,8 +31,8 @@ pub(super) async fn resume_thread(
params: ResumeThreadParams,
) -> ThreadStoreResult<()> {
store.ensure_live_recorder_absent(params.thread_id).await?;
let (rollout_path, history) = match (params.rollout_path, params.history) {
(Some(rollout_path), history) => (rollout_path, history),
let rollout_path = match (params.rollout_path, params.history) {
(Some(rollout_path), _history) => rollout_path,
(None, history) => {
let thread = super::read_thread::read_thread(
store,
@@ -43,20 +43,14 @@ pub(super) async fn resume_thread(
},
)
.await?;
let rollout_path = thread
thread
.rollout_path
.ok_or_else(|| ThreadStoreError::Internal {
message: format!("thread {} does not have a rollout path", params.thread_id),
})?;
(
rollout_path,
history.or_else(|| thread.history.map(|history| history.items)),
)
})?
}
};
let state_builder = history
.as_deref()
.and_then(|items| builder_from_items(items, rollout_path.as_path()));
let cwd = params
.metadata
.cwd
@@ -71,20 +65,11 @@ pub(super) async fn resume_thread(
model_provider_id: params.metadata.model_provider.clone(),
generate_memories: matches!(params.metadata.memory_mode, ThreadMemoryMode::Enabled),
};
let state_db_ctx = store.state_db().await;
let recorder = RolloutRecorder::new(
&config,
RolloutRecorderParams::resume(
rollout_path,
create_thread::event_persistence_mode(params.event_persistence_mode),
),
state_db_ctx,
state_builder,
)
.await
.map_err(|err| ThreadStoreError::Internal {
message: format!("failed to resume local thread recorder: {err}"),
})?;
let recorder = RolloutRecorder::new(&config, RolloutRecorderParams::resume(rollout_path))
.await
.map_err(|err| ThreadStoreError::Internal {
message: format!("failed to resume local thread recorder: {err}"),
})?;
store.insert_live_recorder(params.thread_id, recorder).await
}
@@ -92,12 +77,14 @@ pub(super) async fn append_items(
store: &LocalThreadStore,
params: AppendThreadItemsParams,
) -> ThreadStoreResult<()> {
store
.live_recorder(params.thread_id)
.await?
.record_items(params.items.as_slice())
let recorder = store.live_recorder(params.thread_id).await?;
recorder
.record_canonical_items(params.items.as_slice())
.await
.map_err(thread_store_io_error)
.map_err(thread_store_io_error)?;
// LiveThread applies metadata immediately after append_items returns. Wait for the local
// writer so SQLite never gets ahead of JSONL for accepted live appends.
recorder.flush().await.map_err(thread_store_io_error)
}
pub(super) async fn persist_thread(
@@ -109,7 +96,8 @@ pub(super) async fn persist_thread(
.await?
.persist()
.await
.map_err(thread_store_io_error)
.map_err(thread_store_io_error)?;
sync_materialized_rollout_path(store, thread_id).await
}
pub(super) async fn flush_thread(
@@ -121,7 +109,8 @@ pub(super) async fn flush_thread(
.await?
.flush()
.await
.map_err(thread_store_io_error)
.map_err(thread_store_io_error)?;
sync_materialized_rollout_path(store, thread_id).await
}
pub(super) async fn shutdown_thread(
@@ -130,6 +119,7 @@ pub(super) async fn shutdown_thread(
) -> ThreadStoreResult<()> {
let recorder = store.live_recorder(thread_id).await?;
recorder.shutdown().await.map_err(thread_store_io_error)?;
sync_materialized_rollout_path(store, thread_id).await?;
store.live_recorders.lock().await.remove(&thread_id);
Ok(())
}
@@ -161,6 +151,49 @@ pub(super) async fn rollout_path(
.to_path_buf())
}
async fn sync_materialized_rollout_path(
store: &LocalThreadStore,
thread_id: ThreadId,
) -> ThreadStoreResult<()> {
let rollout_path = rollout_path(store, thread_id).await?;
if !tokio::fs::try_exists(rollout_path.as_path())
.await
.unwrap_or(false)
{
return Ok(());
}
let Some(state_db) = store.state_db().await else {
return Ok(());
};
let result: ThreadStoreResult<()> = async {
let Some(mut metadata) =
state_db
.get_thread(thread_id)
.await
.map_err(|err| ThreadStoreError::Internal {
message: format!("failed to read thread metadata for {thread_id}: {err}"),
})?
else {
return Ok(());
};
if metadata.rollout_path != rollout_path {
metadata.rollout_path = rollout_path;
state_db
.upsert_thread(&metadata)
.await
.map_err(|err| ThreadStoreError::Internal {
message: format!("failed to update thread metadata for {thread_id}: {err}"),
})?;
}
Ok(())
}
.await;
if let Err(err) = result {
warn!("failed to sync materialized rollout path for thread {thread_id}: {err}");
}
Ok(())
}
fn thread_store_io_error(err: std::io::Error) -> ThreadStoreError {
ThreadStoreError::Internal {
message: err.to_string(),
+276
View File
@@ -37,6 +37,18 @@ use crate::ThreadStoreResult;
use crate::UpdateThreadMetadataParams;
/// Local filesystem/SQLite-backed implementation of [`ThreadStore`].
///
/// Local storage has two compatibility surfaces. Rollout JSONL files are the
/// durable replay format and remain readable without SQLite, including older
/// files that encode metadata in `SessionMeta` items and name-index entries.
/// The SQLite state DB, when available, is the queryable metadata index used by
/// list/read paths for fast lookup.
///
/// Live appends still write canonical JSONL history, but append-derived
/// metadata is observed above the store and applied through
/// [`ThreadStore::update_thread_metadata`]. This implementation applies that
/// patch literally to SQLite while keeping the JSONL/name-index compatibility
/// behavior needed for SQLite-less reads, repair, and old local rollout files.
#[derive(Clone)]
pub struct LocalThreadStore {
pub(super) config: LocalThreadStoreConfig,
@@ -270,6 +282,8 @@ impl ThreadStore for LocalThreadStore {
#[cfg(test)]
mod tests {
use std::sync::Arc;
use codex_protocol::ThreadId;
use codex_protocol::models::BaseInstructions;
use codex_protocol::protocol::EventMsg;
@@ -280,6 +294,7 @@ mod tests {
use tempfile::TempDir;
use super::*;
use crate::LiveThread;
use crate::ThreadEventPersistenceMode;
use crate::ThreadPersistenceMetadata;
use crate::local::test_support::test_config;
@@ -335,6 +350,267 @@ mod tests {
);
}
#[tokio::test]
async fn raw_append_items_does_not_update_sqlite_metadata() {
// This pins the ThreadStore contract: raw appends are history-only. Callers that need
// metadata updates must use LiveThread or call update_thread_metadata explicitly.
let home = TempDir::new().expect("temp dir");
let config = test_config(home.path());
let runtime = codex_state::StateRuntime::init(
config.sqlite_home.clone(),
config.default_model_provider_id.clone(),
)
.await
.expect("state db should initialize");
let store = LocalThreadStore::new(config, Some(runtime.clone()));
let thread_id = ThreadId::default();
store
.create_thread(create_thread_params(thread_id))
.await
.expect("create live thread");
store
.append_items(AppendThreadItemsParams {
thread_id,
items: vec![user_message_item("raw append")],
})
.await
.expect("append raw item");
store.flush_thread(thread_id).await.expect("flush thread");
assert_eq!(
runtime
.get_thread(thread_id)
.await
.expect("sqlite metadata read"),
None
);
}
#[tokio::test]
async fn live_thread_observes_appended_items_into_sqlite_metadata() {
let home = TempDir::new().expect("temp dir");
let config = test_config(home.path());
let runtime = codex_state::StateRuntime::init(
config.sqlite_home.clone(),
config.default_model_provider_id.clone(),
)
.await
.expect("state db should initialize");
let store = Arc::new(LocalThreadStore::new(config, Some(runtime.clone())));
let thread_id = ThreadId::default();
let live_thread = LiveThread::create(store.clone(), create_thread_params(thread_id))
.await
.expect("create live thread");
live_thread
.append_items(&[user_message_item("observed append")])
.await
.expect("append observed item");
live_thread.flush().await.expect("flush thread");
let metadata = runtime
.get_thread(thread_id)
.await
.expect("sqlite metadata read")
.expect("sqlite metadata");
assert_eq!(
metadata.first_user_message.as_deref(),
Some("observed append")
);
assert_eq!(metadata.preview.as_deref(), Some("observed append"));
assert_eq!(metadata.title, "observed append");
}
#[tokio::test]
async fn live_thread_shutdown_does_not_materialize_empty_thread_metadata() {
let home = TempDir::new().expect("temp dir");
let config = test_config(home.path());
let runtime = codex_state::StateRuntime::init(
config.sqlite_home.clone(),
config.default_model_provider_id.clone(),
)
.await
.expect("state db should initialize");
let store = Arc::new(LocalThreadStore::new(config, Some(runtime.clone())));
let thread_id = ThreadId::default();
let live_thread = LiveThread::create(store.clone(), create_thread_params(thread_id))
.await
.expect("create live thread");
let rollout_path = store
.live_rollout_path(thread_id)
.await
.expect("live rollout path");
live_thread.shutdown().await.expect("shutdown thread");
assert!(
!tokio::fs::try_exists(rollout_path.as_path())
.await
.expect("rollout path should be checkable")
);
assert_eq!(
runtime
.get_thread(thread_id)
.await
.expect("sqlite metadata read"),
None
);
}
#[tokio::test]
async fn live_thread_shutdown_with_buffered_items_materializes_before_metadata_read() {
let home = TempDir::new().expect("temp dir");
let config = test_config(home.path());
let runtime = codex_state::StateRuntime::init(
config.sqlite_home.clone(),
config.default_model_provider_id.clone(),
)
.await
.expect("state db should initialize");
let store = Arc::new(LocalThreadStore::new(config, Some(runtime.clone())));
let thread_id = ThreadId::default();
let live_thread = LiveThread::create(store.clone(), create_thread_params(thread_id))
.await
.expect("create live thread");
let rollout_path = store
.live_rollout_path(thread_id)
.await
.expect("live rollout path");
live_thread
.append_items(&[RolloutItem::EventMsg(EventMsg::TokenCount(
codex_protocol::protocol::TokenCountEvent {
info: None,
rate_limits: None,
},
))])
.await
.expect("append metadata-only item");
live_thread.shutdown().await.expect("shutdown thread");
assert!(
tokio::fs::try_exists(rollout_path.as_path())
.await
.expect("rollout path should be checkable")
);
let metadata = runtime
.get_thread(thread_id)
.await
.expect("sqlite metadata read")
.expect("sqlite metadata");
assert_eq!(metadata.rollout_path, rollout_path);
}
#[tokio::test]
async fn live_thread_resume_loads_history_before_observing_metadata() {
let home = TempDir::new().expect("temp dir");
let config = test_config(home.path());
let runtime = codex_state::StateRuntime::init(
config.sqlite_home.clone(),
config.default_model_provider_id.clone(),
)
.await
.expect("state db should initialize");
let store = Arc::new(LocalThreadStore::new(config, Some(runtime.clone())));
let uuid = uuid::Uuid::from_u128(401);
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
let rollout_path =
write_session_file(home.path(), "2025-01-03T17-00-00", uuid).expect("session file");
let live_thread = LiveThread::resume(
store,
ResumeThreadParams {
thread_id,
rollout_path: Some(rollout_path),
history: None,
include_archived: false,
metadata: ThreadPersistenceMetadata {
cwd: Some(home.path().to_path_buf()),
model_provider: "different-provider".to_string(),
memory_mode: ThreadMemoryMode::Enabled,
},
event_persistence_mode: ThreadEventPersistenceMode::Limited,
},
)
.await
.expect("resume live thread");
live_thread
.append_items(&[user_message_item("new live append")])
.await
.expect("append after resume");
let metadata = runtime
.get_thread(thread_id)
.await
.expect("sqlite metadata read")
.expect("sqlite metadata");
assert_eq!(
metadata.created_at.to_rfc3339(),
"2025-01-03T17:00:00+00:00"
);
assert_eq!(metadata.model_provider, "test-provider");
assert_eq!(
metadata.first_user_message.as_deref(),
Some("Hello from user")
);
}
#[tokio::test]
async fn live_thread_resume_loads_history_from_explicit_external_rollout_path() {
let home = TempDir::new().expect("temp dir");
let external_home = TempDir::new().expect("external temp dir");
let config = test_config(home.path());
let runtime = codex_state::StateRuntime::init(
config.sqlite_home.clone(),
config.default_model_provider_id.clone(),
)
.await
.expect("state db should initialize");
let store = Arc::new(LocalThreadStore::new(config, Some(runtime.clone())));
let uuid = uuid::Uuid::from_u128(402);
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
let rollout_path = write_session_file(external_home.path(), "2025-01-03T17-30-00", uuid)
.expect("external session file");
let live_thread = LiveThread::resume(
store,
ResumeThreadParams {
thread_id,
rollout_path: Some(rollout_path),
history: None,
include_archived: false,
metadata: ThreadPersistenceMetadata {
cwd: Some(home.path().to_path_buf()),
model_provider: "different-provider".to_string(),
memory_mode: ThreadMemoryMode::Enabled,
},
event_persistence_mode: ThreadEventPersistenceMode::Limited,
},
)
.await
.expect("resume external live thread");
live_thread
.append_items(&[user_message_item("new external append")])
.await
.expect("append after external resume");
let metadata = runtime
.get_thread(thread_id)
.await
.expect("sqlite metadata read")
.expect("sqlite metadata");
assert_eq!(
metadata.created_at.to_rfc3339(),
"2025-01-03T17:30:00+00:00"
);
assert_eq!(metadata.model_provider, "test-provider");
assert_eq!(
metadata.first_user_message.as_deref(),
Some("Hello from user")
);
}
#[tokio::test]
async fn create_thread_rejects_missing_cwd() {
let home = TempDir::new().expect("temp dir");
@@ -273,7 +273,8 @@ async fn stored_thread_from_sqlite_metadata(
None => find_thread_name_by_id(store.config.codex_home.as_path(), &metadata.id)
.await
.ok()
.flatten(),
.flatten()
.filter(|title| !title.trim().is_empty()),
};
let session_meta = read_session_meta_line(metadata.rollout_path.as_path())
.await
@@ -1,9 +1,11 @@
use std::path::Path;
use std::path::PathBuf;
use chrono::Utc;
use codex_protocol::ThreadId;
use codex_protocol::protocol::GitInfo;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::ThreadMemoryMode;
use codex_rollout::ARCHIVED_SESSIONS_SUBDIR;
use codex_rollout::append_rollout_item_to_path;
@@ -11,6 +13,7 @@ use codex_rollout::append_thread_name;
use codex_rollout::find_archived_thread_path_by_id_str;
use codex_rollout::find_thread_path_by_id_str;
use codex_rollout::read_session_meta_line;
use codex_state::ThreadMetadataBuilder;
use super::LocalThreadStore;
use super::helpers::git_info_from_parts;
@@ -18,6 +21,7 @@ use super::live_writer;
use crate::GitInfoPatch;
use crate::ReadThreadParams;
use crate::StoredThread;
use crate::ThreadMetadataPatch;
use crate::ThreadStoreError;
use crate::ThreadStoreResult;
use crate::UpdateThreadMetadataParams;
@@ -32,25 +36,35 @@ pub(super) async fn update_thread_metadata(
store: &LocalThreadStore,
params: UpdateThreadMetadataParams,
) -> ThreadStoreResult<StoredThread> {
let field_count = usize::from(params.patch.name.is_some())
+ usize::from(params.patch.memory_mode.is_some())
+ usize::from(params.patch.git_info.is_some());
if field_count > 1 {
return Err(ThreadStoreError::InvalidRequest {
message: "local thread store applies one metadata field per patch in this slice"
.to_string(),
});
let thread_id = params.thread_id;
let patch = params.patch;
if patch.is_empty() {
return read_thread::read_thread(
store,
ReadThreadParams {
thread_id,
include_archived: params.include_archived,
include_history: false,
},
)
.await;
}
let needs_rollout_compat = needs_rollout_compatibility_update(&patch);
let updated =
apply_metadata_update(store, thread_id, patch.clone(), params.include_archived).await?;
if !needs_rollout_compat {
return Ok(updated);
}
let thread_id = params.thread_id;
if live_writer::rollout_path(store, thread_id).await.is_ok() {
live_writer::persist_thread(store, thread_id).await?;
}
let resolved_rollout_path =
resolve_rollout_path(store, thread_id, params.include_archived).await?;
let name = params.patch.name;
let git_info = params.patch.git_info;
if let Some(memory_mode) = params.patch.memory_mode {
let name = patch.name;
let git_info = patch.git_info;
if let Some(memory_mode) = patch.memory_mode {
apply_thread_memory_mode(resolved_rollout_path.path.as_path(), thread_id, memory_mode)
.await?;
}
@@ -68,7 +82,7 @@ pub(super) async fn update_thread_metadata(
.await;
if let Some(name) = name {
apply_thread_name(store, thread_id, name).await?;
apply_thread_name(store, thread_id, name.unwrap_or_default()).await?;
}
let resolved_git_info = match git_info {
@@ -150,6 +164,218 @@ pub(super) async fn update_thread_metadata(
Ok(thread)
}
async fn apply_metadata_update(
store: &LocalThreadStore,
thread_id: ThreadId,
patch: ThreadMetadataPatch,
include_archived: bool,
) -> ThreadStoreResult<StoredThread> {
let live_rollout_path = live_writer::rollout_path(store, thread_id).await.ok();
let mut rollout_path = patch.rollout_path.clone().or(live_rollout_path);
let mut rollout_path_archived = rollout_path
.as_deref()
.is_some_and(|path| rollout_path_is_archived(store, path));
let state_db = store.state_db().await;
let sqlite_write_result: ThreadStoreResult<()> = if let Some(state_db) = state_db.as_ref() {
let patch = patch.clone();
async {
let existing =
state_db
.get_thread(thread_id)
.await
.map_err(|err| ThreadStoreError::Internal {
message: format!("failed to read thread metadata for {thread_id}: {err}"),
})?;
if existing.is_none() && rollout_path.is_none() {
let resolved = resolve_rollout_path(store, thread_id, include_archived).await?;
rollout_path_archived = resolved.archived;
rollout_path = Some(resolved.path);
}
let mut metadata = existing.clone().unwrap_or_else(|| {
let created_at = patch
.created_at
.or(patch.updated_at)
.unwrap_or_else(Utc::now);
let mut builder = ThreadMetadataBuilder::new(
thread_id,
rollout_path.clone().unwrap_or_default(),
created_at,
patch.source.clone().unwrap_or(SessionSource::Unknown),
);
builder.model_provider = patch.model_provider.clone();
builder.thread_source = patch.thread_source.flatten();
builder.agent_nickname = patch.agent_nickname.clone().flatten();
builder.agent_role = patch.agent_role.clone().flatten();
builder.agent_path = patch.agent_path.clone().flatten();
builder.cwd = patch.cwd.clone().map(normalize_cwd).unwrap_or_default();
builder.cli_version = patch.cli_version.clone();
let mut metadata = builder.build(store.config.default_model_provider_id.as_str());
if rollout_path_archived {
metadata.archived_at = Some(metadata.updated_at);
}
metadata
});
if let Some(rollout_path) = rollout_path {
metadata.rollout_path = rollout_path;
}
if let Some(preview) = patch.preview {
metadata.preview = Some(preview);
}
if let Some(name) = patch.name {
metadata.title = name.unwrap_or_default();
}
if let Some(title) = patch.title {
metadata.title = title;
}
if let Some(model_provider) = patch.model_provider {
metadata.model_provider = model_provider;
}
if let Some(model) = patch.model {
metadata.model = Some(model);
}
if let Some(reasoning_effort) = patch.reasoning_effort {
metadata.reasoning_effort = Some(reasoning_effort);
}
if let Some(created_at) = patch.created_at {
metadata.created_at = created_at;
}
if let Some(updated_at) = patch.updated_at {
metadata.updated_at = updated_at;
}
if let Some(source) = patch.source {
metadata.source = enum_to_string(&source);
}
if let Some(thread_source) = patch.thread_source {
metadata.thread_source = thread_source;
}
if let Some(agent_nickname) = patch.agent_nickname {
metadata.agent_nickname = agent_nickname;
}
if let Some(agent_role) = patch.agent_role {
metadata.agent_role = agent_role;
}
if let Some(agent_path) = patch.agent_path {
metadata.agent_path = agent_path;
}
if let Some(cwd) = patch.cwd {
metadata.cwd = normalize_cwd(cwd);
}
if let Some(cli_version) = patch.cli_version {
metadata.cli_version = cli_version;
}
if let Some(approval_mode) = patch.approval_mode {
metadata.approval_mode = enum_to_string(&approval_mode);
}
if let Some(sandbox_policy) = patch.sandbox_policy {
metadata.sandbox_policy = enum_to_string(&sandbox_policy);
}
if let Some(token_usage) = patch.token_usage {
metadata.tokens_used = token_usage.total_tokens.max(0);
}
if let Some(first_user_message) = patch.first_user_message {
metadata.first_user_message = Some(first_user_message);
}
if let Some(git_info) = patch.git_info {
let existing_git_info = git_info_from_parts(
metadata.git_sha.clone(),
metadata.git_branch.clone(),
metadata.git_origin_url.clone(),
);
let (sha, branch, origin_url) = resolve_git_info_patch(existing_git_info, git_info);
metadata.git_sha = sha;
metadata.git_branch = branch;
metadata.git_origin_url = origin_url;
}
state_db
.upsert_thread(&metadata)
.await
.map_err(|err| ThreadStoreError::Internal {
message: format!("failed to update thread metadata for {thread_id}: {err}"),
})?;
if let Some(memory_mode) = patch.memory_mode {
state_db
.set_thread_memory_mode(thread_id, memory_mode_as_str(memory_mode))
.await
.map_err(|err| ThreadStoreError::Internal {
message: format!("failed to update memory mode for {thread_id}: {err}"),
})?;
}
if let Some(dynamic_tools) = patch.dynamic_tools {
state_db
.persist_dynamic_tools(thread_id, Some(dynamic_tools.as_slice()))
.await
.map_err(|err| ThreadStoreError::Internal {
message: format!("failed to update dynamic tools for {thread_id}: {err}"),
})?;
}
Ok(())
}
.await
} else {
Ok(())
};
match (state_db.is_some(), sqlite_write_result) {
(true, Ok(())) => {}
(true, Err(err)) => return Err(err),
(false, Ok(())) => {}
(false, Err(err)) => return Err(err),
}
read_thread::read_thread(
store,
ReadThreadParams {
thread_id,
include_archived,
include_history: false,
},
)
.await
}
fn needs_rollout_compatibility_update(patch: &ThreadMetadataPatch) -> bool {
if patch.name.is_some() {
return true;
}
if patch.memory_mode.is_none() && patch.git_info.is_none() {
return false;
}
!has_observed_metadata_facts(patch)
}
fn has_observed_metadata_facts(patch: &ThreadMetadataPatch) -> bool {
patch.rollout_path.is_some()
|| patch.preview.is_some()
|| patch.title.is_some()
|| patch.model_provider.is_some()
|| patch.model.is_some()
|| patch.reasoning_effort.is_some()
|| patch.created_at.is_some()
|| patch.source.is_some()
|| patch.thread_source.is_some()
|| patch.agent_nickname.is_some()
|| patch.agent_role.is_some()
|| patch.agent_path.is_some()
|| patch.cwd.is_some()
|| patch.cli_version.is_some()
|| patch.approval_mode.is_some()
|| patch.sandbox_policy.is_some()
|| patch.token_usage.is_some()
|| patch.first_user_message.is_some()
|| patch.dynamic_tools.is_some()
}
fn enum_to_string<T: serde::Serialize>(value: &T) -> String {
match serde_json::to_value(value) {
Ok(serde_json::Value::String(value)) => value,
Ok(other) => other.to_string(),
Err(_) => String::new(),
}
}
fn normalize_cwd(cwd: PathBuf) -> PathBuf {
codex_utils_path::normalize_for_path_comparison(cwd.as_path()).unwrap_or(cwd)
}
async fn apply_thread_git_info(
store: &LocalThreadStore,
thread_id: ThreadId,
@@ -363,10 +589,13 @@ mod tests {
use super::*;
use crate::GitInfoPatch;
use crate::ListThreadsParams;
use crate::ResumeThreadParams;
use crate::SortDirection;
use crate::ThreadEventPersistenceMode;
use crate::ThreadMetadataPatch;
use crate::ThreadPersistenceMetadata;
use crate::ThreadSortKey;
use crate::ThreadStore;
use crate::local::LocalThreadStore;
use crate::local::test_support::test_config;
@@ -385,7 +614,7 @@ mod tests {
.update_thread_metadata(UpdateThreadMetadataParams {
thread_id,
patch: ThreadMetadataPatch {
name: Some("A sharper name".to_string()),
name: Some(Some("A sharper name".to_string())),
..Default::default()
},
include_archived: false,
@@ -855,44 +1084,306 @@ mod tests {
}
#[tokio::test]
async fn update_thread_metadata_rejects_multi_field_patch_without_partial_write() {
async fn update_thread_metadata_applies_combined_explicit_patch() {
let home = TempDir::new().expect("temp dir");
let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None);
let config = test_config(home.path());
let runtime = codex_state::StateRuntime::init(
home.path().to_path_buf(),
config.default_model_provider_id.clone(),
)
.await
.expect("state db should initialize");
let store = LocalThreadStore::new(config, Some(runtime.clone()));
let uuid = Uuid::from_u128(305);
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
let path =
write_session_file(home.path(), "2025-01-03T15-30-00", uuid).expect("session file");
let original = std::fs::read_to_string(&path).expect("read rollout");
let err = store
let thread = store
.update_thread_metadata(UpdateThreadMetadataParams {
thread_id,
patch: ThreadMetadataPatch {
name: Some("Should not persist".to_string()),
name: Some(Some("Combined metadata".to_string())),
memory_mode: Some(ThreadMemoryMode::Disabled),
git_info: Some(GitInfoPatch {
branch: Some(Some("combined".to_string())),
..Default::default()
}),
..Default::default()
},
include_archived: false,
})
.await
.expect_err("multi-field patch should fail");
.expect("combined patch should apply");
assert!(matches!(err, ThreadStoreError::InvalidRequest { .. }));
assert_eq!(thread.name.as_deref(), Some("Combined metadata"));
assert_eq!(
std::fs::read_to_string(&path).expect("read rollout"),
original
thread.git_info.expect("git info").branch.as_deref(),
Some("combined")
);
let appended = last_rollout_item(path.as_path());
assert_eq!(appended["type"], "session_meta");
assert_eq!(appended["payload"]["memory_mode"], "disabled");
assert_eq!(appended["payload"]["git"]["branch"], "combined");
let latest_name = codex_rollout::find_thread_name_by_id(home.path(), &thread_id)
.await
.expect("find thread name");
assert_eq!(latest_name, None);
assert_eq!(latest_name.as_deref(), Some("Combined metadata"));
let memory_mode = runtime
.get_thread_memory_mode(thread_id)
.await
.expect("thread memory mode should be readable");
assert_eq!(memory_mode.as_deref(), Some("disabled"));
}
#[tokio::test]
async fn metadata_patch_applies_title_over_existing_name() {
let home = TempDir::new().expect("temp dir");
let config = test_config(home.path());
let runtime = codex_state::StateRuntime::init(
home.path().to_path_buf(),
config.default_model_provider_id.clone(),
)
.await
.expect("state db should initialize");
let store = LocalThreadStore::new(config, Some(runtime));
let uuid = Uuid::from_u128(306);
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
write_session_file(home.path(), "2025-01-03T15-45-00", uuid).expect("session file");
store
.update_thread_metadata(UpdateThreadMetadataParams {
thread_id,
patch: ThreadMetadataPatch {
name: Some(Some("User chosen name".to_string())),
..Default::default()
},
include_archived: false,
})
.await
.expect("set explicit name");
let thread = store
.update_thread_metadata(UpdateThreadMetadataParams {
thread_id,
patch: ThreadMetadataPatch {
title: Some("Derived first message".to_string()),
preview: Some("Derived first message".to_string()),
..Default::default()
},
include_archived: false,
})
.await
.expect("apply observed metadata");
assert_eq!(thread.name.as_deref(), Some("Derived first message"));
}
#[tokio::test]
async fn metadata_patch_applies_latest_preview_and_first_user_message() {
let home = TempDir::new().expect("temp dir");
let config = test_config(home.path());
let runtime = codex_state::StateRuntime::init(
home.path().to_path_buf(),
config.default_model_provider_id.clone(),
)
.await
.expect("state db should initialize");
let store = LocalThreadStore::new(config, Some(runtime.clone()));
let uuid = Uuid::from_u128(313);
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
write_session_file(home.path(), "2025-01-03T19-00-00", uuid).expect("session file");
store
.update_thread_metadata(UpdateThreadMetadataParams {
thread_id,
patch: ThreadMetadataPatch {
preview: Some("Original preview".to_string()),
first_user_message: Some("Original first message".to_string()),
..Default::default()
},
include_archived: false,
})
.await
.expect("set observed metadata");
let thread = store
.update_thread_metadata(UpdateThreadMetadataParams {
thread_id,
patch: ThreadMetadataPatch {
preview: Some("Later preview".to_string()),
first_user_message: Some("Later first message".to_string()),
..Default::default()
},
include_archived: false,
})
.await
.expect("apply later observed metadata");
assert_eq!(thread.preview, "Hello from user");
assert_eq!(
thread.first_user_message.as_deref(),
Some("Hello from user")
);
let metadata = runtime
.get_thread(thread_id)
.await
.expect("sqlite metadata read")
.expect("sqlite metadata");
assert_eq!(metadata.preview.as_deref(), Some("Later preview"));
assert_eq!(
metadata.first_user_message.as_deref(),
Some("Later first message")
);
}
#[tokio::test]
async fn observed_metadata_rejects_unknown_thread_without_rollout() {
let home = TempDir::new().expect("temp dir");
let config = test_config(home.path());
let runtime = codex_state::StateRuntime::init(
home.path().to_path_buf(),
config.default_model_provider_id.clone(),
)
.await
.expect("state db should initialize");
let store = LocalThreadStore::new(config, Some(runtime.clone()));
let uuid = Uuid::from_u128(314);
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
let err = store
.update_thread_metadata(UpdateThreadMetadataParams {
thread_id,
patch: ThreadMetadataPatch {
preview: Some("phantom".to_string()),
..Default::default()
},
include_archived: false,
})
.await
.expect_err("metadata-only update should not create a missing thread");
assert!(matches!(
err,
ThreadStoreError::InvalidRequest { message }
if message == format!("thread not found: {thread_id}")
));
let metadata = runtime
.get_thread(thread_id)
.await
.expect("sqlite metadata read");
assert!(metadata.is_none());
}
#[tokio::test]
async fn update_thread_metadata_recreates_missing_archived_sqlite_row_as_archived() {
let home = TempDir::new().expect("temp dir");
let config = test_config(home.path());
let uuid = Uuid::from_u128(315);
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
write_archived_session_file(home.path(), "2025-01-03T19-30-00", uuid)
.expect("archived session file");
let runtime = codex_state::StateRuntime::init(
home.path().to_path_buf(),
config.default_model_provider_id.clone(),
)
.await
.expect("state db should initialize");
let store = LocalThreadStore::new(config, Some(runtime.clone()));
let thread = store
.update_thread_metadata(UpdateThreadMetadataParams {
thread_id,
patch: ThreadMetadataPatch {
preview: Some("Archived missing sqlite row".to_string()),
..Default::default()
},
include_archived: true,
})
.await
.expect("update archived thread without sqlite row");
assert!(thread.archived_at.is_some());
assert!(
runtime
.get_thread(thread_id)
.await
.expect("get metadata")
.expect("metadata")
.archived_at
.is_some()
);
}
#[tokio::test]
async fn observed_metadata_normalizes_cwd_for_list_filters() {
let home = TempDir::new().expect("temp dir");
let config = test_config(home.path());
let runtime = codex_state::StateRuntime::init(
home.path().to_path_buf(),
config.default_model_provider_id.clone(),
)
.await
.expect("state db should initialize");
let store = LocalThreadStore::new(config, Some(runtime.clone()));
let uuid = Uuid::from_u128(316);
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
write_session_file(home.path(), "2025-01-03T20-00-00", uuid).expect("session file");
let workspace = home.path().join("workspace");
let child = workspace.join("child");
std::fs::create_dir_all(child.as_path()).expect("create workspace");
let unnormalized_cwd = child.join("..");
let normalized_cwd = codex_utils_path::normalize_for_path_comparison(workspace.as_path())
.expect("normalize cwd");
store
.update_thread_metadata(UpdateThreadMetadataParams {
thread_id,
patch: ThreadMetadataPatch {
cwd: Some(unnormalized_cwd),
preview: Some("cwd preview".to_string()),
..Default::default()
},
include_archived: false,
})
.await
.expect("update observed cwd");
let metadata = runtime
.get_thread(thread_id)
.await
.expect("get metadata")
.expect("metadata");
assert_eq!(metadata.cwd, normalized_cwd);
let page = store
.list_threads(ListThreadsParams {
page_size: 10,
cursor: None,
sort_key: ThreadSortKey::UpdatedAt,
sort_direction: SortDirection::Desc,
allowed_sources: Vec::new(),
model_providers: Some(Vec::new()),
cwd_filters: Some(vec![workspace]),
archived: false,
search_term: None,
use_state_db_only: true,
})
.await
.expect("list threads by cwd");
assert_eq!(
page.items
.iter()
.map(|thread| thread.thread_id)
.collect::<Vec<_>>(),
vec![thread_id]
);
}
#[tokio::test]
async fn update_thread_metadata_keeps_archived_thread_archived_in_sqlite() {
let home = TempDir::new().expect("temp dir");
let config = test_config(home.path());
let uuid = Uuid::from_u128(306);
let uuid = Uuid::from_u128(307);
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
let archived_path = write_archived_session_file(home.path(), "2025-01-03T16-00-00", uuid)
.expect("archived session file");
@@ -931,7 +1422,7 @@ mod tests {
.update_thread_metadata(UpdateThreadMetadataParams {
thread_id,
patch: ThreadMetadataPatch {
name: Some("Archived title".to_string()),
name: Some(Some("Archived title".to_string())),
..Default::default()
},
include_archived: true,
@@ -996,7 +1487,7 @@ mod tests {
.update_thread_metadata(UpdateThreadMetadataParams {
thread_id,
patch: ThreadMetadataPatch {
name: Some("Live archived title".to_string()),
name: Some(Some("Live archived title".to_string())),
..Default::default()
},
include_archived: true,
+9 -2
View File
@@ -33,7 +33,11 @@ pub trait ThreadStore: Any + Send + Sync {
/// Reopens an existing thread for live appends.
async fn resume_thread(&self, params: ResumeThreadParams) -> ThreadStoreResult<()>;
/// Appends items to a live thread.
/// Appends canonical rollout items to a live thread.
///
/// This is the raw history API. It does not infer metadata from item contents. Callers that
/// need metadata updates should call [`ThreadStore::update_thread_metadata`] with explicit
/// metadata facts prepared above the store.
async fn append_items(&self, params: AppendThreadItemsParams) -> ThreadStoreResult<()>;
/// Materializes the thread if persistence is lazy, then persists all queued items.
@@ -86,7 +90,10 @@ pub trait ThreadStore: Any + Send + Sync {
})
}
/// Applies a mutable metadata patch and returns the updated thread.
/// Applies a literal metadata patch and returns the updated thread.
///
/// Implementations should apply the supplied fields directly. Policy such as deciding whether
/// an append-derived preview should be emitted belongs above the store.
async fn update_thread_metadata(
&self,
params: UpdateThreadMetadataParams,
@@ -0,0 +1,580 @@
use std::time::Duration;
use std::time::Instant;
use chrono::DateTime;
use chrono::NaiveDateTime;
use chrono::Utc;
use codex_git_utils::collect_git_info;
use codex_git_utils::get_git_repo_root;
use codex_protocol::ThreadId;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::GitInfo;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::ThreadMemoryMode;
use codex_protocol::protocol::USER_MESSAGE_BEGIN;
use codex_protocol::protocol::UserMessageEvent;
use crate::CreateThreadParams;
use crate::GitInfoPatch;
use crate::ResumeThreadParams;
use crate::ThreadMetadataPatch;
const IMAGE_ONLY_USER_MESSAGE_PLACEHOLDER: &str = "[Image]";
#[cfg(not(test))]
const THREAD_UPDATED_AT_TOUCH_INTERVAL: Duration = Duration::from_secs(5);
#[cfg(test)]
const THREAD_UPDATED_AT_TOUCH_INTERVAL: Duration = Duration::from_millis(50);
/// Live-thread helper that derives metadata updates from canonical rollout items.
///
/// Stores receive raw history plus explicit metadata patches. This helper keeps append-derived
/// metadata observation in the live layer without owning persistence-policy filtering or making
/// `append_items` infer metadata inside a `ThreadStore` implementation.
pub(crate) struct ThreadMetadataSync {
thread_id: ThreadId,
cwd_seen: bool,
preview_seen: bool,
first_user_message_seen: bool,
title_seen: bool,
pending_update: Option<ThreadMetadataPatch>,
pending_update_generation: u64,
last_touch_persisted_at: Option<Instant>,
defer_create_update_until_history_exists: bool,
defer_resume_update_until_append: bool,
}
pub(crate) struct PendingThreadMetadataPatch {
pub(crate) patch: ThreadMetadataPatch,
generation: u64,
}
impl ThreadMetadataSync {
pub(crate) async fn for_create(params: &CreateThreadParams) -> Self {
let created_at = Utc::now();
let cwd = params.metadata.cwd.clone().unwrap_or_default();
let git_info = if get_git_repo_root(cwd.as_path()).is_some() {
collect_git_info(cwd.as_path()).await.map(|info| GitInfo {
commit_hash: info.commit_hash,
branch: info.branch,
repository_url: info.repository_url,
})
} else {
None
};
let dynamic_tools =
(!params.dynamic_tools.is_empty()).then(|| params.dynamic_tools.clone());
let update = ThreadMetadataPatch {
model_provider: Some(params.metadata.model_provider.clone()),
created_at: Some(created_at),
updated_at: Some(created_at),
source: Some(params.source.clone()),
thread_source: Some(params.thread_source),
agent_nickname: Some(params.source.get_nickname()),
agent_role: Some(params.source.get_agent_role()),
agent_path: Some(params.source.get_agent_path().map(Into::into)),
cwd: Some(cwd.clone()),
cli_version: Some(env!("CARGO_PKG_VERSION").to_string()),
git_info: git_info.map(git_info_patch_from_observation),
memory_mode: Some(params.metadata.memory_mode),
dynamic_tools,
..Default::default()
};
Self {
thread_id: params.thread_id,
cwd_seen: !cwd.as_os_str().is_empty(),
preview_seen: false,
first_user_message_seen: false,
title_seen: false,
pending_update: Some(update),
pending_update_generation: 1,
last_touch_persisted_at: None,
defer_create_update_until_history_exists: true,
defer_resume_update_until_append: false,
}
}
pub(crate) fn for_resume(params: &ResumeThreadParams) -> Self {
let mut sync = Self {
thread_id: params.thread_id,
cwd_seen: params
.metadata
.cwd
.as_ref()
.is_some_and(|cwd| !cwd.as_os_str().is_empty()),
preview_seen: false,
first_user_message_seen: false,
title_seen: false,
pending_update: None,
pending_update_generation: 0,
last_touch_persisted_at: None,
defer_create_update_until_history_exists: false,
defer_resume_update_until_append: false,
};
if let Some(history) = params.history.as_deref() {
let update = sync.observe_resume_history(history);
sync.merge_pending_update(update);
sync.defer_resume_update_until_append = sync.pending_update.is_some();
}
sync
}
pub(crate) fn take_pending_update(&self) -> Option<PendingThreadMetadataPatch> {
self.pending_update
.clone()
.map(|patch| PendingThreadMetadataPatch {
patch,
generation: self.pending_update_generation,
})
}
pub(crate) fn take_pending_update_for_existing_history(
&self,
) -> Option<PendingThreadMetadataPatch> {
if self.defer_create_update_until_history_exists {
return None;
}
if self.defer_resume_update_until_append {
return None;
}
self.take_pending_update()
}
pub(crate) fn mark_pending_update_applied(&mut self, update: &PendingThreadMetadataPatch) {
if self.pending_update_generation == update.generation {
self.pending_update = None;
}
if update.patch.updated_at.is_some() {
self.last_touch_persisted_at = Some(Instant::now());
}
}
pub(crate) fn observe_appended_items(
&mut self,
items: &[RolloutItem],
) -> Option<PendingThreadMetadataPatch> {
self.defer_create_update_until_history_exists = false;
self.defer_resume_update_until_append = false;
let affects_metadata = items
.iter()
.any(codex_state::rollout_item_affects_thread_metadata);
let update = if affects_metadata {
self.observe_items(items)?
} else {
thread_updated_at_touch()
};
self.merge_pending_update(Some(update));
if !affects_metadata
&& !self
.pending_update
.as_ref()
.is_some_and(update_has_metadata_facts)
&& self.last_touch_persisted_at.is_some_and(|last_touch| {
Instant::now().duration_since(last_touch) < THREAD_UPDATED_AT_TOUCH_INTERVAL
})
{
return None;
}
self.take_pending_update()
}
fn observe_items(&mut self, items: &[RolloutItem]) -> Option<ThreadMetadataPatch> {
self.observe_items_with_update(
items,
ThreadMetadataPatch {
updated_at: Some(Utc::now()),
..Default::default()
},
)
}
fn observe_resume_history(&mut self, items: &[RolloutItem]) -> Option<ThreadMetadataPatch> {
self.observe_items_with_update(items, ThreadMetadataPatch::default())
}
fn observe_items_with_update(
&mut self,
items: &[RolloutItem],
mut update: ThreadMetadataPatch,
) -> Option<ThreadMetadataPatch> {
if items.is_empty() {
return None;
}
for item in items {
match item {
RolloutItem::SessionMeta(meta_line) if meta_line.meta.id == self.thread_id => {
update.created_at = parse_session_timestamp(meta_line.meta.timestamp.as_str());
update.source = Some(meta_line.meta.source.clone());
update.thread_source = Some(meta_line.meta.thread_source);
update.agent_nickname = Some(meta_line.meta.agent_nickname.clone());
update.agent_role = Some(meta_line.meta.agent_role.clone());
update.agent_path = Some(meta_line.meta.agent_path.clone());
if let Some(model_provider) = meta_line.meta.model_provider.clone()
&& !model_provider.is_empty()
{
update.model_provider = Some(model_provider);
}
if !meta_line.meta.cli_version.is_empty() {
update.cli_version = Some(meta_line.meta.cli_version.clone());
}
if !meta_line.meta.cwd.as_os_str().is_empty() {
self.cwd_seen = true;
update.cwd = Some(meta_line.meta.cwd.clone());
}
if let Some(git_info) = meta_line.git.clone() {
update.git_info = Some(git_info_patch_from_observation(git_info));
}
if let Some(memory_mode) = meta_line.meta.memory_mode.as_deref()
&& let Some(memory_mode) = parse_memory_mode(memory_mode)
{
update.memory_mode = Some(memory_mode);
}
if let Some(dynamic_tools) = meta_line.meta.dynamic_tools.clone() {
update.dynamic_tools = Some(dynamic_tools);
}
}
RolloutItem::TurnContext(turn_ctx) => {
if !self.cwd_seen && !turn_ctx.cwd.as_os_str().is_empty() {
self.cwd_seen = true;
update.cwd = Some(turn_ctx.cwd.clone());
}
update.model = Some(turn_ctx.model.clone());
update.reasoning_effort = turn_ctx.effort;
update.approval_mode = Some(turn_ctx.approval_policy);
update.sandbox_policy = Some(turn_ctx.sandbox_policy.clone());
}
RolloutItem::EventMsg(EventMsg::UserMessage(user)) => {
if let Some(preview) = user_message_preview(user) {
if !self.first_user_message_seen {
self.first_user_message_seen = true;
update.first_user_message = Some(preview.clone());
}
if !self.preview_seen {
self.preview_seen = true;
update.preview = Some(preview);
}
}
if !self.title_seen {
let title = strip_user_message_prefix(user.message.as_str());
if !title.is_empty() {
self.title_seen = true;
update.title = Some(title.to_string());
}
}
}
RolloutItem::EventMsg(EventMsg::TokenCount(token_count)) => {
if let Some(info) = token_count.info.as_ref() {
update.token_usage = Some(info.total_token_usage.clone());
}
}
RolloutItem::EventMsg(EventMsg::ThreadGoalUpdated(event)) => {
if !self.preview_seen {
let objective = event.goal.objective.trim();
if !objective.is_empty() {
self.preview_seen = true;
update.preview = Some(objective.to_string());
}
}
}
RolloutItem::SessionMeta(_)
| RolloutItem::EventMsg(_)
| RolloutItem::ResponseItem(_)
| RolloutItem::Compacted(_) => {}
}
}
Some(update)
}
fn merge_pending_update(&mut self, update: Option<ThreadMetadataPatch>) {
let Some(update) = update else {
return;
};
match self.pending_update.as_mut() {
Some(pending_update) => pending_update.merge(update),
None => self.pending_update = Some(update),
}
self.pending_update_generation = self.pending_update_generation.wrapping_add(1);
}
}
fn parse_memory_mode(value: &str) -> Option<ThreadMemoryMode> {
match value {
"enabled" => Some(ThreadMemoryMode::Enabled),
"disabled" => Some(ThreadMemoryMode::Disabled),
_ => None,
}
}
fn parse_session_timestamp(value: &str) -> Option<DateTime<Utc>> {
DateTime::parse_from_rfc3339(value)
.map(|timestamp| timestamp.with_timezone(&Utc))
.or_else(|_| {
NaiveDateTime::parse_from_str(value, "%Y-%m-%dT%H-%M-%S")
.map(|timestamp| DateTime::from_naive_utc_and_offset(timestamp, Utc))
})
.ok()
}
fn strip_user_message_prefix(text: &str) -> &str {
match text.find(USER_MESSAGE_BEGIN) {
Some(idx) => text[idx + USER_MESSAGE_BEGIN.len()..].trim(),
None => text.trim(),
}
}
fn user_message_preview(user: &UserMessageEvent) -> Option<String> {
let message = strip_user_message_prefix(user.message.as_str());
if !message.is_empty() {
return Some(message.to_string());
}
if user
.images
.as_ref()
.is_some_and(|images| !images.is_empty())
|| !user.local_images.is_empty()
{
return Some(IMAGE_ONLY_USER_MESSAGE_PLACEHOLDER.to_string());
}
None
}
fn thread_updated_at_touch() -> ThreadMetadataPatch {
ThreadMetadataPatch {
updated_at: Some(Utc::now()),
..Default::default()
}
}
fn update_has_metadata_facts(update: &ThreadMetadataPatch) -> bool {
update.rollout_path.is_some()
|| update.preview.is_some()
|| update.title.is_some()
|| update.model_provider.is_some()
|| update.model.is_some()
|| update.reasoning_effort.is_some()
|| update.created_at.is_some()
|| update.source.is_some()
|| update.thread_source.is_some()
|| update.agent_nickname.is_some()
|| update.agent_role.is_some()
|| update.agent_path.is_some()
|| update.cwd.is_some()
|| update.cli_version.is_some()
|| update.approval_mode.is_some()
|| update.sandbox_policy.is_some()
|| update.token_usage.is_some()
|| update.first_user_message.is_some()
|| update.git_info.is_some()
|| update.memory_mode.is_some()
|| update.dynamic_tools.is_some()
}
fn git_info_patch_from_observation(git_info: GitInfo) -> GitInfoPatch {
GitInfoPatch {
sha: git_info.commit_hash.map(|sha| Some(sha.0)),
branch: git_info.branch.map(Some),
origin_url: git_info.repository_url.map(Some),
}
}
#[cfg(test)]
mod tests {
use codex_protocol::protocol::CompactedItem;
use codex_protocol::protocol::SessionMeta;
use codex_protocol::protocol::SessionMetaLine;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::ThreadGoal;
use codex_protocol::protocol::ThreadGoalStatus;
use codex_protocol::protocol::ThreadGoalUpdatedEvent;
use codex_protocol::protocol::UserMessageEvent;
use pretty_assertions::assert_eq;
use super::*;
use crate::ThreadEventPersistenceMode;
use crate::ThreadPersistenceMetadata;
#[test]
fn resume_history_keeps_derived_metadata_pending_until_applied() {
let thread_id = ThreadId::new();
let mut sync = ThreadMetadataSync::for_resume(&resume_params(
thread_id,
vec![
RolloutItem::SessionMeta(session_meta(thread_id)),
RolloutItem::EventMsg(EventMsg::UserMessage(user_message("hello metadata"))),
],
));
let update = sync.take_pending_update().expect("pending metadata update");
assert_eq!(
update
.patch
.created_at
.expect("created_at should come from session metadata")
.to_rfc3339(),
"2025-01-03T12:00:00+00:00"
);
assert_eq!(update.patch.preview.as_deref(), Some("hello metadata"));
assert_eq!(update.patch.title.as_deref(), Some("hello metadata"));
assert_eq!(
update.patch.first_user_message.as_deref(),
Some("hello metadata")
);
assert_eq!(update.patch.updated_at, None);
assert!(
sync.take_pending_update().is_some(),
"taking the pending update should not drop retry state"
);
sync.mark_pending_update_applied(&update);
assert!(sync.take_pending_update().is_none());
}
#[test]
fn goal_update_sets_preview_without_overriding_existing_preview() {
let thread_id = ThreadId::new();
let sync = ThreadMetadataSync::for_resume(&resume_params(
thread_id,
vec![
RolloutItem::EventMsg(EventMsg::ThreadGoalUpdated(goal_update(
thread_id,
"ship the refactor",
))),
RolloutItem::EventMsg(EventMsg::UserMessage(user_message("first user text"))),
],
));
let update = sync.take_pending_update().expect("pending metadata update");
assert_eq!(update.patch.preview.as_deref(), Some("ship the refactor"));
assert_eq!(
update.patch.first_user_message.as_deref(),
Some("first user text")
);
assert_eq!(update.patch.title.as_deref(), Some("first user text"));
}
#[test]
fn later_user_messages_do_not_emit_existing_preview_fields() {
let thread_id = ThreadId::new();
let mut sync = ThreadMetadataSync::for_resume(&resume_params(
thread_id,
vec![RolloutItem::EventMsg(EventMsg::UserMessage(user_message(
"first user text",
)))],
));
let pending = sync.take_pending_update().expect("pending resume metadata");
sync.mark_pending_update_applied(&pending);
let update = sync
.observe_appended_items(&[RolloutItem::EventMsg(EventMsg::UserMessage(user_message(
"later user text",
)))])
.expect("updated_at touch");
assert_eq!(update.patch.preview, None);
assert_eq!(update.patch.title, None);
assert_eq!(update.patch.first_user_message, None);
assert!(update.patch.updated_at.is_some());
}
#[test]
fn metadata_irrelevant_items_coalesce_updated_at_touches() {
let thread_id = ThreadId::new();
let mut sync = ThreadMetadataSync::for_resume(&resume_params(thread_id, Vec::new()));
let item = RolloutItem::Compacted(CompactedItem {
message: "compacted".to_string(),
replacement_history: None,
});
let first = sync
.observe_appended_items(std::slice::from_ref(&item))
.expect("first touch should apply immediately");
assert!(first.patch.updated_at.is_some());
sync.mark_pending_update_applied(&first);
assert!(
sync.observe_appended_items(std::slice::from_ref(&item))
.is_none(),
"second touch inside the coalescing window should wait for a barrier"
);
assert!(
sync.take_pending_update().is_some(),
"coalesced touches still flush at the next barrier"
);
}
#[test]
fn resume_history_waits_for_append_before_flushing_metadata() {
let thread_id = ThreadId::new();
let mut sync = ThreadMetadataSync::for_resume(&resume_params(
thread_id,
vec![
RolloutItem::SessionMeta(session_meta(thread_id)),
RolloutItem::EventMsg(EventMsg::UserMessage(user_message("hello metadata"))),
],
));
assert!(
sync.take_pending_update_for_existing_history().is_none(),
"resume-only metadata should not flush without a new append"
);
assert!(
sync.observe_appended_items(&[RolloutItem::EventMsg(EventMsg::UserMessage(
user_message("new append"),
))])
.is_some(),
"the first append should flush resume metadata together with append metadata"
);
}
fn resume_params(thread_id: ThreadId, history: Vec<RolloutItem>) -> ResumeThreadParams {
ResumeThreadParams {
thread_id,
rollout_path: None,
history: Some(history),
include_archived: false,
metadata: ThreadPersistenceMetadata {
cwd: None,
model_provider: "test-provider".to_string(),
memory_mode: ThreadMemoryMode::Enabled,
},
event_persistence_mode: ThreadEventPersistenceMode::Limited,
}
}
fn user_message(message: &str) -> UserMessageEvent {
UserMessageEvent {
message: message.to_string(),
images: None,
local_images: Vec::new(),
text_elements: Vec::new(),
}
}
fn session_meta(thread_id: ThreadId) -> SessionMetaLine {
SessionMetaLine {
meta: SessionMeta {
id: thread_id,
timestamp: "2025-01-03T12:00:00Z".to_string(),
source: SessionSource::Exec,
..Default::default()
},
git: None,
}
}
fn goal_update(thread_id: ThreadId, objective: &str) -> ThreadGoalUpdatedEvent {
ThreadGoalUpdatedEvent {
thread_id,
turn_id: None,
goal: ThreadGoal {
thread_id,
objective: objective.to_string(),
status: ThreadGoalStatus::Active,
token_budget: None,
tokens_used: 0,
time_used_seconds: 0,
created_at: 0,
updated_at: 0,
},
}
}
}
+358 -11
View File
@@ -15,7 +15,32 @@ use codex_protocol::protocol::ThreadMemoryMode as MemoryMode;
use codex_protocol::protocol::ThreadSource;
use codex_protocol::protocol::TokenUsage;
use serde::Deserialize;
use serde::Deserializer;
use serde::Serialize;
use serde::Serializer;
mod optional_option {
use super::*;
pub fn serialize<T, S>(value: &Option<Option<T>>, serializer: S) -> Result<S::Ok, S::Error>
where
T: Serialize,
S: Serializer,
{
match value {
Some(value) => value.serialize(serializer),
None => serializer.serialize_none(),
}
}
pub fn deserialize<'de, T, D>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
where
T: Deserialize<'de>,
D: Deserializer<'de>,
{
Option::<T>::deserialize(deserializer).map(Some)
}
}
/// Controls how many event variants should be persisted for future replay.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
@@ -348,32 +373,241 @@ pub struct StoredThread {
}
/// Optional field patch where omission leaves a value unchanged and `Some(None)` clears it.
pub type OptionalStringPatch = Option<Option<String>>;
pub type ClearableField<T> = Option<Option<T>>;
/// Patch for thread Git metadata.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct GitInfoPatch {
/// Replacement commit SHA, clear request, or no-op.
pub sha: OptionalStringPatch,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "optional_option"
)]
pub sha: ClearableField<String>,
/// Replacement branch name, clear request, or no-op.
pub branch: OptionalStringPatch,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "optional_option"
)]
pub branch: ClearableField<String>,
/// Replacement origin URL, clear request, or no-op.
pub origin_url: OptionalStringPatch,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "optional_option"
)]
pub origin_url: ClearableField<String>,
}
/// Patch for mutable thread metadata.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
impl GitInfoPatch {
/// Merges another patch into this one using field-presence semantics.
///
/// Omitted fields in `next` leave the current patch unchanged. Present fields replace the
/// current value, including clear requests like `Some(None)`.
pub fn merge(&mut self, next: Self) {
if next.sha.is_some() {
self.sha = next.sha;
}
if next.branch.is_some() {
self.branch = next.branch;
}
if next.origin_url.is_some() {
self.origin_url = next.origin_url;
}
}
}
/// Patch for thread metadata.
///
/// Every field is literal: `None` leaves that field unchanged, while `Some`
/// applies the supplied value. Fields whose value may itself be cleared use an
/// inner `Option`, where `Some(None)` clears the field.
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct ThreadMetadataPatch {
/// Replacement user-facing thread name.
pub name: Option<String>,
/// Replacement thread memory behavior.
pub memory_mode: Option<MemoryMode>,
/// Optional Git metadata patch.
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "optional_option"
)]
pub name: ClearableField<String>,
/// Known local rollout path for stores that expose one.
pub rollout_path: Option<PathBuf>,
/// Best available preview text for discovery/listing.
pub preview: Option<String>,
/// Best-effort title derived from history.
pub title: Option<String>,
/// Model provider associated with the thread.
pub model_provider: Option<String>,
/// Latest observed model.
pub model: Option<String>,
/// Latest observed reasoning effort.
pub reasoning_effort: Option<ReasoningEffort>,
/// Creation timestamp when known.
pub created_at: Option<DateTime<Utc>>,
/// Last update timestamp for this metadata observation.
pub updated_at: Option<DateTime<Utc>>,
/// Session source.
pub source: Option<SessionSource>,
/// Optional analytics source classification.
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "optional_option"
)]
pub thread_source: ClearableField<ThreadSource>,
/// Optional agent nickname.
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "optional_option"
)]
pub agent_nickname: ClearableField<String>,
/// Optional agent role.
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "optional_option"
)]
pub agent_role: ClearableField<String>,
/// Optional canonical agent path.
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "optional_option"
)]
pub agent_path: ClearableField<String>,
/// Working directory.
pub cwd: Option<PathBuf>,
/// CLI version that created the thread.
pub cli_version: Option<String>,
/// Approval mode.
pub approval_mode: Option<AskForApproval>,
/// Sandbox policy.
pub sandbox_policy: Option<SandboxPolicy>,
/// Last observed token usage.
pub token_usage: Option<TokenUsage>,
/// First user message observed for this thread.
pub first_user_message: Option<String>,
/// Git metadata patch.
pub git_info: Option<GitInfoPatch>,
/// Thread memory behavior.
pub memory_mode: Option<MemoryMode>,
/// Dynamic tools available to this thread.
pub dynamic_tools: Option<Vec<DynamicToolSpec>>,
}
impl ThreadMetadataPatch {
/// Merges another patch into this one using field-presence semantics.
///
/// Omitted fields in `next` leave the current patch unchanged. Present fields replace the
/// current value, including clear requests like `Some(None)`. Nested patches use the same
/// semantics.
pub fn merge(&mut self, next: Self) {
if next.name.is_some() {
self.name = next.name;
}
if next.rollout_path.is_some() {
self.rollout_path = next.rollout_path;
}
if next.preview.is_some() {
self.preview = next.preview;
}
if next.title.is_some() {
self.title = next.title;
}
if next.model_provider.is_some() {
self.model_provider = next.model_provider;
}
if next.model.is_some() {
self.model = next.model;
}
if next.reasoning_effort.is_some() {
self.reasoning_effort = next.reasoning_effort;
}
if next.created_at.is_some() {
self.created_at = next.created_at;
}
if next.updated_at.is_some() {
self.updated_at = next.updated_at;
}
if next.source.is_some() {
self.source = next.source;
}
if next.thread_source.is_some() {
self.thread_source = next.thread_source;
}
if next.agent_nickname.is_some() {
self.agent_nickname = next.agent_nickname;
}
if next.agent_role.is_some() {
self.agent_role = next.agent_role;
}
if next.agent_path.is_some() {
self.agent_path = next.agent_path;
}
if next.cwd.is_some() {
self.cwd = next.cwd;
}
if next.cli_version.is_some() {
self.cli_version = next.cli_version;
}
if next.approval_mode.is_some() {
self.approval_mode = next.approval_mode;
}
if next.sandbox_policy.is_some() {
self.sandbox_policy = next.sandbox_policy;
}
if next.token_usage.is_some() {
self.token_usage = next.token_usage;
}
if next.first_user_message.is_some() {
self.first_user_message = next.first_user_message;
}
if let Some(git_info) = next.git_info {
self.git_info
.get_or_insert_with(GitInfoPatch::default)
.merge(git_info);
}
if next.memory_mode.is_some() {
self.memory_mode = next.memory_mode;
}
if next.dynamic_tools.is_some() {
self.dynamic_tools = next.dynamic_tools;
}
}
pub fn is_empty(&self) -> bool {
self.name.is_none()
&& self.rollout_path.is_none()
&& self.preview.is_none()
&& self.title.is_none()
&& self.model_provider.is_none()
&& self.model.is_none()
&& self.reasoning_effort.is_none()
&& self.created_at.is_none()
&& self.updated_at.is_none()
&& self.source.is_none()
&& self.thread_source.is_none()
&& self.agent_nickname.is_none()
&& self.agent_role.is_none()
&& self.agent_path.is_none()
&& self.cwd.is_none()
&& self.cli_version.is_none()
&& self.approval_mode.is_none()
&& self.sandbox_policy.is_none()
&& self.token_usage.is_none()
&& self.first_user_message.is_none()
&& self.git_info.is_none()
&& self.memory_mode.is_none()
&& self.dynamic_tools.is_none()
}
}
/// Parameters for patching mutable thread metadata.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct UpdateThreadMetadataParams {
/// Thread id to update.
pub thread_id: ThreadId,
@@ -389,3 +623,116 @@ pub struct ArchiveThreadParams {
/// Thread id to archive or unarchive.
pub thread_id: ThreadId,
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use serde_json::json;
use super::*;
#[test]
fn thread_metadata_patch_round_trips_optional_clears() {
let patch = ThreadMetadataPatch {
name: Some(None),
thread_source: Some(None),
agent_nickname: Some(None),
agent_role: Some(None),
agent_path: Some(None),
..Default::default()
};
let value = serde_json::to_value(&patch).expect("serialize patch");
assert_eq!(value["name"], json!(null));
assert_eq!(value["thread_source"], json!(null));
assert_eq!(value["agent_nickname"], json!(null));
assert_eq!(value["agent_role"], json!(null));
assert_eq!(value["agent_path"], json!(null));
let decoded: ThreadMetadataPatch =
serde_json::from_value(value).expect("deserialize patch");
assert_eq!(decoded.name, Some(None));
assert_eq!(decoded.thread_source, Some(None));
assert_eq!(decoded.agent_nickname, Some(None));
assert_eq!(decoded.agent_role, Some(None));
assert_eq!(decoded.agent_path, Some(None));
}
#[test]
fn git_info_patch_round_trips_optional_clears() {
let patch = ThreadMetadataPatch {
git_info: Some(GitInfoPatch {
sha: None,
branch: Some(Some("main".to_string())),
origin_url: Some(None),
}),
..Default::default()
};
let value = serde_json::to_value(&patch).expect("serialize patch");
assert_eq!(
value["git_info"],
json!({
"branch": "main",
"origin_url": null,
})
);
let decoded: ThreadMetadataPatch =
serde_json::from_value(value).expect("deserialize patch");
assert_eq!(
decoded.git_info,
Some(GitInfoPatch {
sha: None,
branch: Some(Some("main".to_string())),
origin_url: Some(None),
})
);
}
#[test]
fn thread_metadata_patch_accepts_missing_fields() {
let decoded: ThreadMetadataPatch =
serde_json::from_value(json!({})).expect("deserialize legacy patch");
assert!(decoded.is_empty());
}
#[test]
fn thread_metadata_patch_merge_uses_presence_semantics() {
let mut current = ThreadMetadataPatch {
name: Some(Some("old name".to_string())),
preview: Some("old preview".to_string()),
git_info: Some(GitInfoPatch {
sha: Some(Some("abc123".to_string())),
branch: Some(Some("main".to_string())),
origin_url: None,
}),
..Default::default()
};
current.merge(ThreadMetadataPatch {
name: Some(None),
preview: None,
title: Some("new title".to_string()),
git_info: Some(GitInfoPatch {
sha: None,
branch: Some(Some("feature".to_string())),
origin_url: Some(None),
}),
..Default::default()
});
assert_eq!(current.name, Some(None));
assert_eq!(current.preview.as_deref(), Some("old preview"));
assert_eq!(current.title.as_deref(), Some("new title"));
assert_eq!(
current.git_info,
Some(GitInfoPatch {
sha: Some(Some("abc123".to_string())),
branch: Some(Some("feature".to_string())),
origin_url: Some(None),
})
);
}
}