mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
5267e805fb
## Description This PR adds a new `historyMode = "legacy" | "paginated"` to `Thread`. This will be stored in `SessionMeta` in the JSONL rollout file and as a new column in the SQLite thread_metadata table, and exposed on `thread/start` and on the `Thread` object in app-server. ## What changed - Added canonical `ThreadHistoryMode` with `legacy` and `paginated`, defaulting old and new SessionMeta to `legacy`. - Carried `history_mode` through core session config, ThreadStore stored metadata, local/in-memory stores, rollout metadata extraction, and the existing SQLite `threads` table. - Added experimental `historyMode` to app-server v2 `Thread` and `thread/start`. - Made paginated stored threads metadata-discoverable but unsupported for legacy full-history reads, `load_history`, live resume, and create paths. - Regenerated app-server schema fixtures and added protocol/state/thread-store/app-server coverage for persistence and fail-closed behavior. ## Compatibility floor Because users may be running various versions of Codex binaries on the same machine (TUI, Codex App, etc.), we will need to establish a compatibility floor for upcoming paginated threads, which will change how thread storage reads and writes work. The overall plan here: ``` Release N: - Add historyMode to SessionMeta / Thread / SQLite metadata. - Teach binaries to understand paginated threads. - If a binary sees `historyMode="paginated"` but does not support the paginated contract, it refuses to resume/mutate the thread. - Default remains `"legacy"`. Release N+1: - First-party clients start opting into paginated threads where appropriate. - Internal dogfood / staged rollout. - Measure old-client usage and paginated-thread unsupported errors. Release N+2: - Only after Release N+ is overwhelmingly deployed, make paginated the default. - Accept that a small tail of N-1-or-older binaries may not understand paginated threads. ``` The important behavior change is fail-closed handling for a binary that encounters a persisted `paginated` thread before it knows how to fully support paginated history. In app-server, if a thread is `paginated`, we will: - allow metadata-only discovery paths like `thread/list` and `thread/read(includeTurns=false)`, so clients can still see the thread and inspect its `historyMode` - reject legacy full-history/live-thread paths like `thread/read(includeTurns=true)` and `thread/resume` with an unsupported JSON-RPC error - avoid silently treating an unknown or future `historyMode` as `legacy` Under the hood, the ThreadStore layer also rejects legacy operations that would need to load or replay the full thread history for a paginated thread. That gives us the behavior we want for Release N: future paginated threads are visible, but this binary fails closed instead of trying to operate on them as if they were legacy threads.
447 lines
16 KiB
Rust
447 lines
16 KiB
Rust
//! Regression coverage for app-server thread operations backed by a non-local
|
|
//! `ThreadStore`.
|
|
//!
|
|
//! The app-server startup path should honor `experimental_thread_store`
|
|
//! by routing all thread persistence through the configured store. This suite uses
|
|
//! the thread-store crate's test-only in-memory store to exercise the non-local
|
|
//! config-driven selection path without touching local rollout or sqlite storage.
|
|
//!
|
|
//! The important failure mode is accidentally materializing local persistence
|
|
//! while a non-local store is configured. After `thread/start` and a simple turn,
|
|
//! the temporary `codex_home` must not contain rollout session files or sqlite
|
|
//! state files. This does not observe read-only probes that leave no artifact; it
|
|
//! is a stop-gap that prevents additional local persistence writes from slipping
|
|
//! in unnoticed.
|
|
|
|
use std::collections::BTreeSet;
|
|
use std::path::Path;
|
|
use std::sync::Arc;
|
|
|
|
use anyhow::Result;
|
|
use app_test_support::create_mock_responses_server_repeating_assistant;
|
|
use codex_app_server::in_process;
|
|
use codex_app_server::in_process::InProcessClientHandle;
|
|
use codex_app_server::in_process::InProcessServerEvent;
|
|
use codex_app_server::in_process::InProcessStartArgs;
|
|
use codex_app_server_protocol::ClientInfo;
|
|
use codex_app_server_protocol::ClientRequest;
|
|
use codex_app_server_protocol::InitializeParams;
|
|
use codex_app_server_protocol::RequestId;
|
|
use codex_app_server_protocol::ServerNotification;
|
|
use codex_app_server_protocol::ThreadDeleteParams;
|
|
use codex_app_server_protocol::ThreadDeleteResponse;
|
|
use codex_app_server_protocol::ThreadListParams;
|
|
use codex_app_server_protocol::ThreadListResponse;
|
|
use codex_app_server_protocol::ThreadResumeParams;
|
|
use codex_app_server_protocol::ThreadStartParams;
|
|
use codex_app_server_protocol::ThreadStartResponse;
|
|
use codex_app_server_protocol::TurnStartParams;
|
|
use codex_app_server_protocol::UserInput as V2UserInput;
|
|
use codex_arg0::Arg0DispatchPaths;
|
|
use codex_config::CloudConfigBundleLoader;
|
|
use codex_config::LoaderOverrides;
|
|
use codex_config::NoopThreadConfigLoader;
|
|
use codex_core::config::Config;
|
|
use codex_core::config::ConfigBuilder;
|
|
use codex_exec_server::EnvironmentManager;
|
|
use codex_feedback::CodexFeedback;
|
|
use codex_protocol::ThreadId;
|
|
use codex_protocol::models::BaseInstructions;
|
|
use codex_protocol::protocol::SessionSource;
|
|
use codex_protocol::protocol::ThreadMemoryMode;
|
|
use codex_thread_store::CreateThreadParams as StoreCreateThreadParams;
|
|
use codex_thread_store::InMemoryThreadStore;
|
|
use codex_thread_store::ThreadPersistenceMetadata;
|
|
use codex_thread_store::ThreadStore;
|
|
use pretty_assertions::assert_eq;
|
|
use tempfile::TempDir;
|
|
use tokio::time::timeout;
|
|
use uuid::Uuid;
|
|
|
|
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
|
|
|
#[tokio::test]
|
|
async fn thread_delete_with_non_local_thread_store_does_not_create_local_persistence() -> Result<()>
|
|
{
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
let codex_home = TempDir::new()?;
|
|
let store_id = Uuid::new_v4().to_string();
|
|
// Plugin startup warmups may create `.tmp` under codex_home. Disable them
|
|
// here so this regression stays focused on thread persistence artifacts.
|
|
create_config_toml_with_thread_store(codex_home.path(), &server.uri(), &store_id)?;
|
|
|
|
let thread_store = InMemoryThreadStore::for_id(store_id.clone());
|
|
let _in_memory_store = InMemoryThreadStoreId { store_id };
|
|
|
|
let mut client = start_in_process_server(codex_home.path()).await?;
|
|
|
|
let response = client
|
|
.request(ClientRequest::ThreadStart {
|
|
request_id: RequestId::Integer(1),
|
|
params: ThreadStartParams::default(),
|
|
})
|
|
.await?
|
|
.expect("thread/start should succeed");
|
|
let ThreadStartResponse { thread, .. } =
|
|
serde_json::from_value(response).expect("thread/start response should parse");
|
|
assert_eq!(thread.path, None);
|
|
|
|
client
|
|
.request(ClientRequest::TurnStart {
|
|
request_id: RequestId::Integer(2),
|
|
params: TurnStartParams {
|
|
thread_id: thread.id.clone(),
|
|
client_user_message_id: None,
|
|
input: vec![V2UserInput::Text {
|
|
text: "Hello".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
..Default::default()
|
|
},
|
|
})
|
|
.await?
|
|
.expect("turn/start should succeed");
|
|
|
|
timeout(DEFAULT_READ_TIMEOUT, async {
|
|
loop {
|
|
let Some(event) = client.next_event().await else {
|
|
anyhow::bail!("in-process app-server stopped before turn/completed");
|
|
};
|
|
if let InProcessServerEvent::ServerNotification(ServerNotification::TurnCompleted(
|
|
completed,
|
|
)) = event
|
|
&& completed.thread_id == thread.id
|
|
{
|
|
return Ok::<(), anyhow::Error>(());
|
|
}
|
|
}
|
|
})
|
|
.await??;
|
|
|
|
let response = client
|
|
.request(ClientRequest::ThreadList {
|
|
request_id: RequestId::Integer(3),
|
|
params: ThreadListParams {
|
|
cursor: None,
|
|
limit: Some(10),
|
|
sort_key: None,
|
|
sort_direction: None,
|
|
model_providers: Some(Vec::new()),
|
|
source_kinds: None,
|
|
archived: None,
|
|
cwd: None,
|
|
use_state_db_only: false,
|
|
search_term: None,
|
|
parent_thread_id: None,
|
|
ancestor_thread_id: None,
|
|
},
|
|
})
|
|
.await?
|
|
.expect("thread/list should succeed");
|
|
let ThreadListResponse { data, .. } =
|
|
serde_json::from_value(response).expect("thread/list response should parse");
|
|
assert_eq!(data.len(), 1);
|
|
assert_eq!(data[0].id, thread.id);
|
|
assert_eq!(data[0].path, None);
|
|
|
|
delete_thread(&client, /*request_id*/ 4, thread.id.clone()).await?;
|
|
let unloaded_thread_id = ThreadId::from_string(&Uuid::new_v4().to_string())?;
|
|
thread_store
|
|
.create_thread(StoreCreateThreadParams {
|
|
session_id: unloaded_thread_id.into(),
|
|
thread_id: unloaded_thread_id,
|
|
extra_config: None,
|
|
forked_from_id: None,
|
|
parent_thread_id: None,
|
|
source: SessionSource::Cli,
|
|
thread_source: None,
|
|
originator: "test_originator".to_string(),
|
|
base_instructions: BaseInstructions::default(),
|
|
dynamic_tools: Vec::new(),
|
|
selected_capability_roots: Vec::new(),
|
|
multi_agent_version: None,
|
|
history_mode: Default::default(),
|
|
initial_window_id: Uuid::now_v7().to_string(),
|
|
metadata: ThreadPersistenceMetadata {
|
|
cwd: Some(codex_home.path().to_path_buf()),
|
|
model_provider: "mock_provider".to_string(),
|
|
memory_mode: ThreadMemoryMode::Enabled,
|
|
},
|
|
})
|
|
.await?;
|
|
delete_thread(
|
|
&client,
|
|
/*request_id*/ 5,
|
|
unloaded_thread_id.to_string(),
|
|
)
|
|
.await?;
|
|
|
|
client.shutdown().await?;
|
|
|
|
let calls = thread_store.calls().await;
|
|
assert_eq!(calls.create_thread, 2);
|
|
assert_eq!(calls.list_threads, 1);
|
|
assert_eq!(calls.delete_thread, 2);
|
|
assert!(
|
|
calls.append_items > 0,
|
|
"turn/start should append rollout items through the injected store"
|
|
);
|
|
assert!(
|
|
calls.flush_thread > 0,
|
|
"turn completion should flush through the injected store"
|
|
);
|
|
|
|
assert_no_local_persistence_artifacts(codex_home.path())?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cold_thread_resume_reuses_non_local_history_probe() -> Result<()> {
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
let codex_home = TempDir::new()?;
|
|
let store_id = Uuid::new_v4().to_string();
|
|
create_config_toml_with_thread_store(codex_home.path(), &server.uri(), &store_id)?;
|
|
|
|
let loader_overrides = LoaderOverrides::without_managed_config_for_tests();
|
|
let config = Arc::new(
|
|
ConfigBuilder::default()
|
|
.codex_home(codex_home.path().to_path_buf())
|
|
.fallback_cwd(Some(codex_home.path().to_path_buf()))
|
|
.loader_overrides(loader_overrides.clone())
|
|
.build()
|
|
.await?,
|
|
);
|
|
let thread_store = InMemoryThreadStore::for_id(store_id.clone());
|
|
let _in_memory_store = InMemoryThreadStoreId { store_id };
|
|
|
|
let mut client = start_in_process_client(config.clone(), loader_overrides.clone()).await?;
|
|
let response = client
|
|
.request(ClientRequest::ThreadStart {
|
|
request_id: RequestId::Integer(1),
|
|
params: ThreadStartParams::default(),
|
|
})
|
|
.await?
|
|
.expect("thread/start should succeed");
|
|
let ThreadStartResponse { thread, .. } = serde_json::from_value(response)?;
|
|
|
|
client
|
|
.request(ClientRequest::TurnStart {
|
|
request_id: RequestId::Integer(2),
|
|
params: TurnStartParams {
|
|
thread_id: thread.id.clone(),
|
|
client_user_message_id: None,
|
|
input: vec![V2UserInput::Text {
|
|
text: "Materialize the thread".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
..Default::default()
|
|
},
|
|
})
|
|
.await?
|
|
.expect("turn/start should succeed");
|
|
timeout(DEFAULT_READ_TIMEOUT, async {
|
|
loop {
|
|
let Some(event) = client.next_event().await else {
|
|
anyhow::bail!("in-process app-server stopped before turn/completed");
|
|
};
|
|
if let InProcessServerEvent::ServerNotification(ServerNotification::TurnCompleted(
|
|
completed,
|
|
)) = event
|
|
&& completed.thread_id == thread.id
|
|
{
|
|
return Ok::<(), anyhow::Error>(());
|
|
}
|
|
}
|
|
})
|
|
.await??;
|
|
client.shutdown().await?;
|
|
|
|
let client = start_in_process_client(config, loader_overrides).await?;
|
|
let reads_before_resume = thread_store.calls().await.read_thread_with_history;
|
|
// The in-memory store is pathless, so resume currently fails later while
|
|
// assembling the response. The history-bearing probe must still be reused.
|
|
let _resume_result = client
|
|
.request(ClientRequest::ThreadResume {
|
|
request_id: RequestId::Integer(3),
|
|
params: ThreadResumeParams {
|
|
thread_id: thread.id.clone(),
|
|
..Default::default()
|
|
},
|
|
})
|
|
.await?;
|
|
|
|
assert_eq!(
|
|
thread_store.calls().await.read_thread_with_history,
|
|
reads_before_resume + 1
|
|
);
|
|
|
|
client.shutdown().await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn start_in_process_server(codex_home: &Path) -> Result<InProcessClientHandle> {
|
|
let loader_overrides = LoaderOverrides::without_managed_config_for_tests();
|
|
let config = Arc::new(
|
|
ConfigBuilder::default()
|
|
.codex_home(codex_home.to_path_buf())
|
|
.fallback_cwd(Some(codex_home.to_path_buf()))
|
|
.loader_overrides(loader_overrides.clone())
|
|
.build()
|
|
.await?,
|
|
);
|
|
|
|
Ok(start_in_process_client(config, loader_overrides).await?)
|
|
}
|
|
|
|
async fn start_in_process_client(
|
|
config: Arc<Config>,
|
|
loader_overrides: LoaderOverrides,
|
|
) -> std::io::Result<InProcessClientHandle> {
|
|
in_process::start(InProcessStartArgs {
|
|
arg0_paths: Arg0DispatchPaths::default(),
|
|
config,
|
|
cli_overrides: Vec::new(),
|
|
loader_overrides,
|
|
strict_config: false,
|
|
cloud_config_bundle: CloudConfigBundleLoader::default(),
|
|
thread_config_loader: Arc::new(NoopThreadConfigLoader),
|
|
feedback: CodexFeedback::new(),
|
|
log_db: None,
|
|
state_db: None,
|
|
environment_manager: Arc::new(EnvironmentManager::default_for_tests()),
|
|
config_warnings: Vec::new(),
|
|
session_source: SessionSource::Cli,
|
|
enable_codex_api_key_env: false,
|
|
initialize: InitializeParams {
|
|
client_info: ClientInfo {
|
|
name: "codex-app-server-tests".to_string(),
|
|
title: None,
|
|
version: "0.1.0".to_string(),
|
|
},
|
|
capabilities: None,
|
|
},
|
|
channel_capacity: in_process::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY,
|
|
})
|
|
.await
|
|
}
|
|
|
|
async fn delete_thread(
|
|
client: &InProcessClientHandle,
|
|
request_id: i64,
|
|
thread_id: String,
|
|
) -> Result<()> {
|
|
let response = client
|
|
.request(ClientRequest::ThreadDelete {
|
|
request_id: RequestId::Integer(request_id),
|
|
params: ThreadDeleteParams { thread_id },
|
|
})
|
|
.await?
|
|
.map_err(|error| anyhow::anyhow!("thread/delete failed: {}", error.message))?;
|
|
let _: ThreadDeleteResponse = serde_json::from_value(response)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn assert_no_local_persistence_artifacts(codex_home: &Path) -> Result<()> {
|
|
// These are the observable tripwires for accidental local persistence. If a
|
|
// future code path constructs a local rollout/session store or opens the
|
|
// local thread sqlite database, it should leave one of these artifacts in
|
|
// the isolated test codex_home.
|
|
assert!(
|
|
!codex_home.join("sessions").exists(),
|
|
"non-local thread persistence should not create local rollout sessions"
|
|
);
|
|
assert!(
|
|
!codex_home.join("archived_sessions").exists(),
|
|
"non-local thread persistence should not create archived rollout sessions"
|
|
);
|
|
assert!(
|
|
!codex_state::state_db_path(codex_home).exists(),
|
|
"non-local thread persistence should not create local thread sqlite"
|
|
);
|
|
|
|
let sqlite_artifacts = std::fs::read_dir(codex_home)?
|
|
.filter_map(std::result::Result::ok)
|
|
.map(|entry| entry.path())
|
|
.filter(|path| {
|
|
path.file_name()
|
|
.and_then(|name| name.to_str())
|
|
.is_some_and(|name| {
|
|
name.ends_with(".sqlite")
|
|
|| name.ends_with(".sqlite-shm")
|
|
|| name.ends_with(".sqlite-wal")
|
|
})
|
|
})
|
|
.collect::<Vec<_>>();
|
|
|
|
assert!(
|
|
sqlite_artifacts.is_empty(),
|
|
"non-local thread persistence should not create sqlite artifacts: {sqlite_artifacts:?}"
|
|
);
|
|
let mut entries = codex_home_entries(codex_home)?;
|
|
// Bazel test runs may initialize shell snapshot storage under codex_home.
|
|
// That is not thread persistence; keep the assertion focused on rollout,
|
|
// session, sqlite, and other unexpected thread-store artifacts.
|
|
entries.remove("shell_snapshots");
|
|
assert_eq!(
|
|
entries,
|
|
BTreeSet::from([
|
|
"config.toml".to_string(),
|
|
"installation_id".to_string(),
|
|
"skills".to_string(),
|
|
]),
|
|
"non-local thread persistence should not create unexpected files in codex_home"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn codex_home_entries(codex_home: &Path) -> Result<BTreeSet<String>> {
|
|
Ok(std::fs::read_dir(codex_home)?
|
|
.filter_map(|entry| {
|
|
let entry = entry.ok()?;
|
|
Some(entry.file_name().to_string_lossy().into_owned())
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
struct InMemoryThreadStoreId {
|
|
store_id: String,
|
|
}
|
|
|
|
impl Drop for InMemoryThreadStoreId {
|
|
fn drop(&mut self) {
|
|
InMemoryThreadStore::remove_id(&self.store_id);
|
|
}
|
|
}
|
|
|
|
fn create_config_toml_with_thread_store(
|
|
codex_home: &Path,
|
|
server_uri: &str,
|
|
store_id: &str,
|
|
) -> std::io::Result<()> {
|
|
std::fs::write(
|
|
codex_home.join("config.toml"),
|
|
format!(
|
|
r#"
|
|
model = "mock-model"
|
|
approval_policy = "never"
|
|
sandbox_mode = "read-only"
|
|
experimental_thread_store = {{ type = "in_memory", id = "{store_id}" }}
|
|
|
|
model_provider = "mock_provider"
|
|
|
|
[model_providers.mock_provider]
|
|
name = "Mock provider for test"
|
|
base_url = "{server_uri}/v1"
|
|
wire_api = "responses"
|
|
request_max_retries = 0
|
|
stream_max_retries = 0
|
|
|
|
[features]
|
|
plugins = false
|
|
"#
|
|
),
|
|
)
|
|
}
|