mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
ensure thread.history_mode is immutable (#30261)
## Description This PR makes `thread.history_mode` immutable after the thread's canonical first `SessionMeta` has been written. Later same-thread `SessionMeta` lines are compatibility metadata writes, not a new thread definition. Without this, an older binary could append a `SessionMeta` that omits `history_mode`; when a newer binary replays it, serde defaults that missing field to `legacy` and SQLite could downgrade a paginated thread. ## Why `history_mode` is the persisted thread storage contract. Paginated-thread fail-closed behavior and SQLite memory filtering depend on it staying aligned with canonical rollout metadata, especially when multiple Codex binary versions can touch the same local rollout. ## What changed - Stop generic rollout metadata replay from overwriting `history_mode` from later `SessionMeta` items. - Remove `history_mode` from `ThreadMetadataPatch`, so mutable metadata sync and app-server metadata updates cannot rewrite it. - When local metadata sync has to recreate a missing SQLite row, recover `history_mode` from the rollout's canonical first `SessionMeta` instead of from a mutable patch. - Keep the in-memory thread store using the created thread's canonical `history_mode` instead of metadata patches. - Fill the one remaining core test `CreateThreadParams` initializer with the new `history_mode` field; Bazel CI caught this after the parent history-mode PR landed. ## Validation - `just fmt` - `just test -p codex-thread-store` - `just test -p codex-state session_meta_does_not_set_model_or_reasoning_effort`
This commit is contained in:
1 parent
cf36c688b3
commit
812cd2bb57
7 files changed
+226
-66
No files matched your search
@@ -56,7 +56,7 @@ fn apply_session_meta_from_item(metadata: &mut ThreadMetadata, meta_line: &Sessi
|
|||||||
}
|
}
|
||||||
metadata.id = meta_line.meta.id;
|
metadata.id = meta_line.meta.id;
|
||||||
metadata.source = enum_to_string(&meta_line.meta.source);
|
metadata.source = enum_to_string(&meta_line.meta.source);
|
||||||
metadata.history_mode = meta_line.meta.history_mode;
|
// Later SessionMeta lines do not redefine the canonical history_mode.
|
||||||
metadata.thread_source = meta_line.meta.thread_source.clone();
|
metadata.thread_source = meta_line.meta.thread_source.clone();
|
||||||
metadata.agent_nickname = meta_line.meta.agent_nickname.clone();
|
metadata.agent_nickname = meta_line.meta.agent_nickname.clone();
|
||||||
metadata.agent_role = meta_line.meta.agent_role.clone();
|
metadata.agent_role = meta_line.meta.agent_role.clone();
|
||||||
@@ -178,6 +178,7 @@ mod tests {
|
|||||||
use codex_protocol::protocol::ThreadGoal;
|
use codex_protocol::protocol::ThreadGoal;
|
||||||
use codex_protocol::protocol::ThreadGoalStatus;
|
use codex_protocol::protocol::ThreadGoalStatus;
|
||||||
use codex_protocol::protocol::ThreadGoalUpdatedEvent;
|
use codex_protocol::protocol::ThreadGoalUpdatedEvent;
|
||||||
|
use codex_protocol::protocol::ThreadHistoryMode;
|
||||||
use codex_protocol::protocol::TurnContextItem;
|
use codex_protocol::protocol::TurnContextItem;
|
||||||
use codex_protocol::protocol::USER_MESSAGE_BEGIN;
|
use codex_protocol::protocol::USER_MESSAGE_BEGIN;
|
||||||
use codex_protocol::protocol::UserMessageEvent;
|
use codex_protocol::protocol::UserMessageEvent;
|
||||||
@@ -516,6 +517,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn session_meta_does_not_set_model_or_reasoning_effort() {
|
fn session_meta_does_not_set_model_or_reasoning_effort() {
|
||||||
let mut metadata = metadata_for_test();
|
let mut metadata = metadata_for_test();
|
||||||
|
metadata.history_mode = ThreadHistoryMode::Paginated;
|
||||||
let thread_id = metadata.id;
|
let thread_id = metadata.id;
|
||||||
|
|
||||||
apply_rollout_item(
|
apply_rollout_item(
|
||||||
@@ -540,7 +542,7 @@ mod tests {
|
|||||||
dynamic_tools: None,
|
dynamic_tools: None,
|
||||||
selected_capability_roots: Vec::new(),
|
selected_capability_roots: Vec::new(),
|
||||||
memory_mode: None,
|
memory_mode: None,
|
||||||
history_mode: Default::default(),
|
history_mode: ThreadHistoryMode::Legacy,
|
||||||
multi_agent_version: None,
|
multi_agent_version: None,
|
||||||
context_window: None,
|
context_window: None,
|
||||||
},
|
},
|
||||||
@@ -551,6 +553,7 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(metadata.model, None);
|
assert_eq!(metadata.model, None);
|
||||||
assert_eq!(metadata.reasoning_effort, None);
|
assert_eq!(metadata.reasoning_effort, None);
|
||||||
|
assert_eq!(metadata.history_mode, ThreadHistoryMode::Paginated);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn metadata_for_test() -> ThreadMetadata {
|
fn metadata_for_test() -> ThreadMetadata {
|
||||||
|
|||||||
@@ -218,17 +218,22 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("register rollout path");
|
.expect("register rollout path");
|
||||||
store
|
{
|
||||||
.update_thread_metadata(UpdateThreadMetadataParams {
|
let mut state = store.state.lock().await;
|
||||||
thread_id,
|
state
|
||||||
patch: ThreadMetadataPatch {
|
.created_threads
|
||||||
history_mode: Some(ThreadHistoryMode::Paginated),
|
.get_mut(&thread_id)
|
||||||
..Default::default()
|
.expect("created thread")
|
||||||
},
|
.history_mode = ThreadHistoryMode::Paginated;
|
||||||
include_archived: false,
|
let Some(RolloutItem::SessionMeta(meta_line)) = state
|
||||||
})
|
.histories
|
||||||
.await
|
.get_mut(&thread_id)
|
||||||
.expect("seed paginated metadata");
|
.and_then(|history| history.first_mut())
|
||||||
|
else {
|
||||||
|
panic!("canonical session meta");
|
||||||
|
};
|
||||||
|
meta_line.meta.history_mode = ThreadHistoryMode::Paginated;
|
||||||
|
}
|
||||||
|
|
||||||
let thread = store
|
let thread = store
|
||||||
.read_thread(ReadThreadParams {
|
.read_thread(ReadThreadParams {
|
||||||
@@ -784,9 +789,7 @@ fn stored_thread_from_state(
|
|||||||
source: metadata
|
source: metadata
|
||||||
.and_then(|metadata| metadata.source.clone())
|
.and_then(|metadata| metadata.source.clone())
|
||||||
.unwrap_or_else(|| created.source.clone()),
|
.unwrap_or_else(|| created.source.clone()),
|
||||||
history_mode: metadata
|
history_mode: created.history_mode,
|
||||||
.and_then(|metadata| metadata.history_mode)
|
|
||||||
.unwrap_or(created.history_mode),
|
|
||||||
thread_source: metadata
|
thread_source: metadata
|
||||||
.and_then(|metadata| metadata.thread_source.clone())
|
.and_then(|metadata| metadata.thread_source.clone())
|
||||||
.unwrap_or_else(|| created.thread_source.clone()),
|
.unwrap_or_else(|| created.thread_source.clone()),
|
||||||
@@ -811,15 +814,9 @@ fn history_mode_from_state(
|
|||||||
thread_id: ThreadId,
|
thread_id: ThreadId,
|
||||||
) -> ThreadHistoryMode {
|
) -> ThreadHistoryMode {
|
||||||
state
|
state
|
||||||
.metadata_updates
|
.created_threads
|
||||||
.get(&thread_id)
|
.get(&thread_id)
|
||||||
.and_then(|metadata| metadata.history_mode)
|
.map(|thread| thread.history_mode)
|
||||||
.or_else(|| {
|
|
||||||
state
|
|
||||||
.created_threads
|
|
||||||
.get(&thread_id)
|
|
||||||
.map(|thread| thread.history_mode)
|
|
||||||
})
|
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,9 +26,12 @@ pub(super) async fn create_thread(
|
|||||||
params: CreateThreadParams,
|
params: CreateThreadParams,
|
||||||
) -> ThreadStoreResult<()> {
|
) -> ThreadStoreResult<()> {
|
||||||
let thread_id = params.thread_id;
|
let thread_id = params.thread_id;
|
||||||
|
let history_mode = params.history_mode;
|
||||||
store.ensure_live_recorder_absent(thread_id).await?;
|
store.ensure_live_recorder_absent(thread_id).await?;
|
||||||
let recorder = create_thread::create_thread(store, params).await?;
|
let recorder = create_thread::create_thread(store, params).await?;
|
||||||
store.insert_live_recorder(thread_id, recorder).await
|
store
|
||||||
|
.insert_live_recorder(thread_id, recorder, history_mode)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn resume_thread(
|
pub(super) async fn resume_thread(
|
||||||
@@ -98,7 +101,9 @@ pub(super) async fn resume_thread(
|
|||||||
.map_err(|err| ThreadStoreError::Internal {
|
.map_err(|err| ThreadStoreError::Internal {
|
||||||
message: format!("failed to resume local thread recorder: {err}"),
|
message: format!("failed to resume local thread recorder: {err}"),
|
||||||
})?;
|
})?;
|
||||||
store.insert_live_recorder(params.thread_id, recorder).await
|
store
|
||||||
|
.insert_live_recorder(params.thread_id, recorder, history_mode)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument(
|
#[tracing::instrument(
|
||||||
@@ -191,6 +196,7 @@ pub(super) async fn rollout_path(
|
|||||||
.await
|
.await
|
||||||
.get(&thread_id)
|
.get(&thread_id)
|
||||||
.ok_or(ThreadStoreError::ThreadNotFound { thread_id })?
|
.ok_or(ThreadStoreError::ThreadNotFound { thread_id })?
|
||||||
|
.recorder
|
||||||
.rollout_path()
|
.rollout_path()
|
||||||
.to_path_buf())
|
.to_path_buf())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ mod update_thread_metadata;
|
|||||||
mod test_support;
|
mod test_support;
|
||||||
|
|
||||||
use codex_protocol::ThreadId;
|
use codex_protocol::ThreadId;
|
||||||
|
use codex_protocol::protocol::ThreadHistoryMode;
|
||||||
use codex_rollout::RolloutRecorder;
|
use codex_rollout::RolloutRecorder;
|
||||||
use codex_rollout::StateDbHandle;
|
use codex_rollout::StateDbHandle;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -57,10 +58,18 @@ use crate::UpdateThreadMetadataParams;
|
|||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct LocalThreadStore {
|
pub struct LocalThreadStore {
|
||||||
pub(super) config: LocalThreadStoreConfig,
|
pub(super) config: LocalThreadStoreConfig,
|
||||||
live_recorders: Arc<Mutex<HashMap<ThreadId, RolloutRecorder>>>,
|
live_recorders: Arc<Mutex<HashMap<ThreadId, LiveRecorderEntry>>>,
|
||||||
state_db: Option<StateDbHandle>,
|
state_db: Option<StateDbHandle>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct LiveRecorderEntry {
|
||||||
|
recorder: RolloutRecorder,
|
||||||
|
// Local rollout files are materialized lazily, but metadata updates can arrive before the
|
||||||
|
// canonical SessionMeta is durable. Retain the mode captured when live persistence was opened
|
||||||
|
// so missing SQLite rows can still be seeded.
|
||||||
|
history_mode: ThreadHistoryMode,
|
||||||
|
}
|
||||||
|
|
||||||
/// Process-scoped configuration for local thread storage.
|
/// Process-scoped configuration for local thread storage.
|
||||||
///
|
///
|
||||||
/// This describes where local storage lives. New-thread rollout metadata such
|
/// This describes where local storage lives. New-thread rollout metadata such
|
||||||
@@ -135,7 +144,7 @@ impl LocalThreadStore {
|
|||||||
.lock()
|
.lock()
|
||||||
.await
|
.await
|
||||||
.get(&thread_id)
|
.get(&thread_id)
|
||||||
.cloned()
|
.map(|entry| entry.recorder.clone())
|
||||||
.ok_or(ThreadStoreError::ThreadNotFound { thread_id })
|
.ok_or(ThreadStoreError::ThreadNotFound { thread_id })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,13 +164,17 @@ impl LocalThreadStore {
|
|||||||
&self,
|
&self,
|
||||||
thread_id: ThreadId,
|
thread_id: ThreadId,
|
||||||
recorder: RolloutRecorder,
|
recorder: RolloutRecorder,
|
||||||
|
history_mode: ThreadHistoryMode,
|
||||||
) -> ThreadStoreResult<()> {
|
) -> ThreadStoreResult<()> {
|
||||||
match self.live_recorders.lock().await.entry(thread_id) {
|
match self.live_recorders.lock().await.entry(thread_id) {
|
||||||
Entry::Occupied(entry) => Err(ThreadStoreError::InvalidRequest {
|
Entry::Occupied(entry) => Err(ThreadStoreError::InvalidRequest {
|
||||||
message: format!("thread {} already has a live local writer", entry.key()),
|
message: format!("thread {} already has a live local writer", entry.key()),
|
||||||
}),
|
}),
|
||||||
Entry::Vacant(entry) => {
|
Entry::Vacant(entry) => {
|
||||||
entry.insert(recorder);
|
entry.insert(LiveRecorderEntry {
|
||||||
|
recorder,
|
||||||
|
history_mode,
|
||||||
|
});
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -580,6 +593,46 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn live_thread_memory_mode_update_before_rollout_materializes_keeps_history_mode() {
|
||||||
|
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
|
||||||
|
.update_memory_mode(ThreadMemoryMode::Disabled, /*include_archived*/ false)
|
||||||
|
.await
|
||||||
|
.expect("update memory mode");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
runtime
|
||||||
|
.get_thread(thread_id)
|
||||||
|
.await
|
||||||
|
.expect("sqlite metadata read")
|
||||||
|
.expect("sqlite metadata")
|
||||||
|
.history_mode,
|
||||||
|
ThreadHistoryMode::Legacy
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
runtime
|
||||||
|
.get_thread_memory_mode(thread_id)
|
||||||
|
.await
|
||||||
|
.expect("thread memory mode should be readable")
|
||||||
|
.as_deref(),
|
||||||
|
Some("disabled")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn live_thread_shutdown_with_buffered_items_materializes_before_metadata_read() {
|
async fn live_thread_shutdown_with_buffered_items_materializes_before_metadata_read() {
|
||||||
let home = TempDir::new().expect("temp dir");
|
let home = TempDir::new().expect("temp dir");
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use codex_protocol::ThreadId;
|
|||||||
use codex_protocol::protocol::GitInfo;
|
use codex_protocol::protocol::GitInfo;
|
||||||
use codex_protocol::protocol::RolloutItem;
|
use codex_protocol::protocol::RolloutItem;
|
||||||
use codex_protocol::protocol::SessionSource;
|
use codex_protocol::protocol::SessionSource;
|
||||||
|
use codex_protocol::protocol::ThreadHistoryMode;
|
||||||
use codex_protocol::protocol::ThreadMemoryMode;
|
use codex_protocol::protocol::ThreadMemoryMode;
|
||||||
use codex_rollout::ARCHIVED_SESSIONS_SUBDIR;
|
use codex_rollout::ARCHIVED_SESSIONS_SUBDIR;
|
||||||
use codex_rollout::append_rollout_item_to_path;
|
use codex_rollout::append_rollout_item_to_path;
|
||||||
@@ -225,31 +226,27 @@ async fn apply_metadata_update(
|
|||||||
rollout_path_archived = resolved.archived;
|
rollout_path_archived = resolved.archived;
|
||||||
rollout_path = Some(resolved.path);
|
rollout_path = Some(resolved.path);
|
||||||
}
|
}
|
||||||
let mut metadata = existing.clone().unwrap_or_else(|| {
|
let mut metadata = match existing.clone() {
|
||||||
let created_at = patch
|
Some(metadata) => metadata,
|
||||||
.created_at
|
None => {
|
||||||
.or(patch.updated_at)
|
let rollout_path =
|
||||||
.unwrap_or_else(Utc::now);
|
rollout_path
|
||||||
let mut builder = ThreadMetadataBuilder::new(
|
.as_deref()
|
||||||
thread_id,
|
.ok_or_else(|| ThreadStoreError::Internal {
|
||||||
rollout_path.clone().unwrap_or_default(),
|
message: format!(
|
||||||
created_at,
|
"thread metadata missing rollout path for {thread_id}"
|
||||||
patch.source.clone().unwrap_or(SessionSource::Unknown),
|
),
|
||||||
);
|
})?;
|
||||||
builder.model_provider = patch.model_provider.clone();
|
metadata_for_missing_sqlite_row(
|
||||||
builder.history_mode = patch.history_mode.unwrap_or_default();
|
store,
|
||||||
builder.thread_source = patch.thread_source.clone().flatten();
|
thread_id,
|
||||||
builder.agent_nickname = patch.agent_nickname.clone().flatten();
|
rollout_path,
|
||||||
builder.agent_role = patch.agent_role.clone().flatten();
|
rollout_path_archived,
|
||||||
builder.agent_path = patch.agent_path.clone().flatten();
|
&patch,
|
||||||
builder.cwd = patch.cwd.clone().map(normalize_cwd).unwrap_or_default();
|
)
|
||||||
builder.cli_version = patch.cli_version.clone();
|
.await?
|
||||||
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 {
|
if let Some(rollout_path) = rollout_path {
|
||||||
metadata.rollout_path = rollout_path;
|
metadata.rollout_path = rollout_path;
|
||||||
}
|
}
|
||||||
@@ -285,9 +282,6 @@ async fn apply_metadata_update(
|
|||||||
if let Some(source) = patch.source {
|
if let Some(source) = patch.source {
|
||||||
metadata.source = enum_to_string(&source);
|
metadata.source = enum_to_string(&source);
|
||||||
}
|
}
|
||||||
if let Some(history_mode) = patch.history_mode {
|
|
||||||
metadata.history_mode = history_mode;
|
|
||||||
}
|
|
||||||
if let Some(thread_source) = patch.thread_source {
|
if let Some(thread_source) = patch.thread_source {
|
||||||
metadata.thread_source = thread_source;
|
metadata.thread_source = thread_source;
|
||||||
}
|
}
|
||||||
@@ -389,6 +383,78 @@ async fn apply_metadata_update(
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn metadata_for_missing_sqlite_row(
|
||||||
|
store: &LocalThreadStore,
|
||||||
|
thread_id: ThreadId,
|
||||||
|
rollout_path: &Path,
|
||||||
|
rollout_path_archived: bool,
|
||||||
|
patch: &ThreadMetadataPatch,
|
||||||
|
) -> ThreadStoreResult<codex_state::ThreadMetadata> {
|
||||||
|
let created_at = patch
|
||||||
|
.created_at
|
||||||
|
.or(patch.updated_at)
|
||||||
|
.unwrap_or_else(Utc::now);
|
||||||
|
let mut builder = ThreadMetadataBuilder::new(
|
||||||
|
thread_id,
|
||||||
|
rollout_path.to_path_buf(),
|
||||||
|
created_at,
|
||||||
|
patch.source.clone().unwrap_or(SessionSource::Unknown),
|
||||||
|
);
|
||||||
|
builder.model_provider = patch.model_provider.clone();
|
||||||
|
builder.history_mode = canonical_history_mode(store, thread_id, rollout_path).await?;
|
||||||
|
builder.thread_source = patch.thread_source.clone().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);
|
||||||
|
}
|
||||||
|
Ok(metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn canonical_history_mode(
|
||||||
|
store: &LocalThreadStore,
|
||||||
|
thread_id: ThreadId,
|
||||||
|
rollout_path: &Path,
|
||||||
|
) -> ThreadStoreResult<ThreadHistoryMode> {
|
||||||
|
let session_meta = match read_session_meta_line(rollout_path).await {
|
||||||
|
Ok(session_meta) => session_meta,
|
||||||
|
Err(err) => {
|
||||||
|
if codex_rollout::existing_rollout_path(rollout_path)
|
||||||
|
.await
|
||||||
|
.is_none()
|
||||||
|
&& let Some(history_mode) = store
|
||||||
|
.live_recorders
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.get(&thread_id)
|
||||||
|
.map(|entry| entry.history_mode)
|
||||||
|
{
|
||||||
|
// The live writer retains the canonical mode selected before its deferred
|
||||||
|
// SessionMeta reaches JSONL.
|
||||||
|
return Ok(history_mode);
|
||||||
|
}
|
||||||
|
return Err(ThreadStoreError::Internal {
|
||||||
|
message: format!(
|
||||||
|
"failed to read canonical session metadata for {thread_id}: {err}"
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if session_meta.meta.id != thread_id {
|
||||||
|
return Err(ThreadStoreError::Internal {
|
||||||
|
message: format!(
|
||||||
|
"failed to rebuild thread metadata: rollout session metadata id mismatch: expected {thread_id}, found {}",
|
||||||
|
session_meta.meta.id
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(session_meta.meta.history_mode)
|
||||||
|
}
|
||||||
|
|
||||||
fn needs_rollout_compatibility_update(patch: &ThreadMetadataPatch) -> bool {
|
fn needs_rollout_compatibility_update(patch: &ThreadMetadataPatch) -> bool {
|
||||||
if patch.name.is_some() {
|
if patch.name.is_some() {
|
||||||
return true;
|
return true;
|
||||||
@@ -431,7 +497,6 @@ fn has_observed_metadata_facts(patch: &ThreadMetadataPatch) -> bool {
|
|||||||
|| patch.permission_profile.is_some()
|
|| patch.permission_profile.is_some()
|
||||||
|| patch.token_usage.is_some()
|
|| patch.token_usage.is_some()
|
||||||
|| patch.first_user_message.is_some()
|
|| patch.first_user_message.is_some()
|
||||||
|| patch.history_mode.is_some()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn enum_to_string<T: serde::Serialize>(value: &T) -> String {
|
fn enum_to_string<T: serde::Serialize>(value: &T) -> String {
|
||||||
@@ -1475,6 +1540,51 @@ mod tests {
|
|||||||
assert!(metadata.is_none());
|
assert!(metadata.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn observed_metadata_rebuilds_history_mode_from_canonical_session_meta() {
|
||||||
|
let home = TempDir::new().expect("temp dir");
|
||||||
|
let config = test_config(home.path());
|
||||||
|
let uuid = Uuid::from_u128(317);
|
||||||
|
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
|
||||||
|
write_session_file_with_history_mode(
|
||||||
|
home.path(),
|
||||||
|
"2025-01-03T19-15-00",
|
||||||
|
uuid,
|
||||||
|
ThreadHistoryMode::Paginated,
|
||||||
|
)
|
||||||
|
.expect("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("Paginated preview".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
include_archived: false,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("update paginated thread without sqlite row");
|
||||||
|
|
||||||
|
assert_eq!(thread.history_mode, ThreadHistoryMode::Paginated);
|
||||||
|
assert_eq!(
|
||||||
|
runtime
|
||||||
|
.get_thread(thread_id)
|
||||||
|
.await
|
||||||
|
.expect("sqlite metadata read")
|
||||||
|
.expect("sqlite metadata")
|
||||||
|
.history_mode,
|
||||||
|
ThreadHistoryMode::Paginated
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn update_thread_metadata_recreates_missing_archived_sqlite_row_as_archived() {
|
async fn update_thread_metadata_recreates_missing_archived_sqlite_row_as_archived() {
|
||||||
let home = TempDir::new().expect("temp dir");
|
let home = TempDir::new().expect("temp dir");
|
||||||
|
|||||||
@@ -76,7 +76,6 @@ impl ThreadMetadataSync {
|
|||||||
cli_version: Some(env!("CARGO_PKG_VERSION").to_string()),
|
cli_version: Some(env!("CARGO_PKG_VERSION").to_string()),
|
||||||
git_info: git_info.map(git_info_patch_from_observation),
|
git_info: git_info.map(git_info_patch_from_observation),
|
||||||
memory_mode: Some(params.metadata.memory_mode),
|
memory_mode: Some(params.metadata.memory_mode),
|
||||||
history_mode: Some(params.history_mode),
|
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
Self {
|
Self {
|
||||||
@@ -238,7 +237,6 @@ impl ThreadMetadataSync {
|
|||||||
{
|
{
|
||||||
update.memory_mode = Some(memory_mode);
|
update.memory_mode = Some(memory_mode);
|
||||||
}
|
}
|
||||||
update.history_mode = Some(meta_line.meta.history_mode);
|
|
||||||
}
|
}
|
||||||
RolloutItem::TurnContext(turn_ctx) => {
|
RolloutItem::TurnContext(turn_ctx) => {
|
||||||
if !self.cwd_seen {
|
if !self.cwd_seen {
|
||||||
@@ -377,7 +375,6 @@ fn update_has_metadata_facts(update: &ThreadMetadataPatch) -> bool {
|
|||||||
|| update.first_user_message.is_some()
|
|| update.first_user_message.is_some()
|
||||||
|| update.git_info.is_some()
|
|| update.git_info.is_some()
|
||||||
|| update.memory_mode.is_some()
|
|| update.memory_mode.is_some()
|
||||||
|| update.history_mode.is_some()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn git_info_patch_from_observation(git_info: GitInfo) -> GitInfoPatch {
|
fn git_info_patch_from_observation(git_info: GitInfo) -> GitInfoPatch {
|
||||||
|
|||||||
@@ -596,8 +596,6 @@ pub struct ThreadMetadataPatch {
|
|||||||
pub git_info: Option<GitInfoPatch>,
|
pub git_info: Option<GitInfoPatch>,
|
||||||
/// Thread memory behavior.
|
/// Thread memory behavior.
|
||||||
pub memory_mode: Option<MemoryMode>,
|
pub memory_mode: Option<MemoryMode>,
|
||||||
/// Persisted thread history contract.
|
|
||||||
pub history_mode: Option<ThreadHistoryMode>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ThreadMetadataPatch {
|
impl ThreadMetadataPatch {
|
||||||
@@ -678,9 +676,6 @@ impl ThreadMetadataPatch {
|
|||||||
if next.memory_mode.is_some() {
|
if next.memory_mode.is_some() {
|
||||||
self.memory_mode = next.memory_mode;
|
self.memory_mode = next.memory_mode;
|
||||||
}
|
}
|
||||||
if next.history_mode.is_some() {
|
|
||||||
self.history_mode = next.history_mode;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_empty(&self) -> bool {
|
pub fn is_empty(&self) -> bool {
|
||||||
@@ -707,7 +702,6 @@ impl ThreadMetadataPatch {
|
|||||||
&& self.first_user_message.is_none()
|
&& self.first_user_message.is_none()
|
||||||
&& self.git_info.is_none()
|
&& self.git_info.is_none()
|
||||||
&& self.memory_mode.is_none()
|
&& self.memory_mode.is_none()
|
||||||
&& self.history_mode.is_none()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user