Files
codex/codex-rs/app-server/tests/suite/v2/memory_reset.rs
jif 2cbbf0c9b5 Add memory dual writing and v2 readiness reporting (#43827)
## Why

Allow v2 memories to build in the background while the selected memory version continues supplying context, and let clients check whether v2 has enough consolidated data and a valid summary to use.

## What changed

- Add `memories.dual_write`, disabled by default, to run v1 and v2 extraction and consolidation concurrently with separate stores and directories.
- Add the experimental `memory/status` endpoint, returning `v2ConsolidatedThreads` and `v2Ready`. Readiness requires a valid v2 summary and a consolidation thread count meeting `minConsolidatedThreads` (default 20, supported range 1–4096).
- Persist the largest thread count from a successful consolidation across pruning and clear it on memory reset.
- Share v2 summary validation between consolidation and status reporting, and tag memory metrics with `memory_version`.

## Testing

Add tests for independent dual-write outputs, preserving existing notes without importing them into v2, and readiness behavior across consolidation ownership checks, missing summaries, configurable thresholds, thread deletion, and memory reset.

GitOrigin-RevId: 54417cc60bf419062401355c0da8c79119995f61
2026-09-08 14:23:23 +00:00

257 lines
8.5 KiB
Rust

use anyhow::Result;
use app_test_support::MockResponsesConfig;
use app_test_support::TestAppServer;
use chrono::Utc;
use codex_app_server_protocol::MemoryResetResponse;
use codex_features::Feature;
use codex_protocol::MemoryVersion;
use codex_protocol::ThreadId;
use codex_protocol::protocol::SessionSource;
use codex_state::Stage1JobClaimOutcome;
use codex_state::StateRuntime;
use codex_state::ThreadMetadataBuilder;
use codex_utils_absolute_path::test_support::PathExt;
use pretty_assertions::assert_eq;
use std::path::Path;
use std::sync::Arc;
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 memory_reset_clears_memory_files_and_rows_preserves_threads() -> Result<()> {
let codex_home = TempDir::new()?;
MockResponsesConfig::new("http://127.0.0.1:9")
.with_root_config("suppress_unstable_features_warning = true")
.enable_feature(Feature::Sqlite)
.write(codex_home.path())?;
let state_db = init_state_db(codex_home.path()).await?;
let mut thread_ids = Vec::new();
for version in [MemoryVersion::V1, MemoryVersion::V2] {
let root = codex_home.path().join(version.directory_name());
tokio::fs::create_dir_all(root.join("rollout_summaries")).await?;
tokio::fs::write(root.join("memory_summary.md"), "v1\nstale memory\n").await?;
tokio::fs::write(
root.join("rollout_summaries/stale.md"),
"stale rollout summary\n",
)
.await?;
thread_ids.push(seed_stage1_output(&state_db, codex_home.path(), version).await?);
}
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build_initialized_with_timeout(DEFAULT_READ_TIMEOUT)
.await?;
let request_id = mcp
.send_raw_request("memory/reset", /*params*/ None)
.await?;
let _: MemoryResetResponse =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??;
for version in [MemoryVersion::V1, MemoryVersion::V2] {
let outputs = state_db
.memories_for_version(version)
.await?
.list_stage1_outputs_for_global(/*n*/ 10)
.await?;
assert_eq!(outputs, Vec::new());
let root = codex_home.path().join(version.directory_name());
assert!(
tokio::fs::read_dir(root)
.await?
.next_entry()
.await?
.is_none()
);
}
for thread_id in thread_ids {
assert_eq!(
state_db.get_thread_memory_mode(thread_id).await?.as_deref(),
Some("enabled")
);
}
Ok(())
}
async fn seed_stage1_output(
state_db: &Arc<StateRuntime>,
codex_home: &Path,
version: MemoryVersion,
) -> Result<ThreadId> {
let now = Utc::now();
let thread_id = ThreadId::from_string(&Uuid::new_v4().to_string())?;
let worker_id = ThreadId::from_string(&Uuid::new_v4().to_string())?;
let mut builder = ThreadMetadataBuilder::new(
thread_id,
codex_home.join("sessions").join("test.jsonl"),
now,
SessionSource::Cli,
);
builder.updated_at = Some(now);
builder.cwd = codex_home.to_path_buf();
let metadata = builder.build("mock_provider");
state_db.upsert_thread(&metadata).await?;
let store = state_db.memories_for_version(version).await?;
let claim = store
.try_claim_stage1_job(
thread_id,
worker_id,
now.timestamp(),
/*lease_seconds*/ 3600,
/*max_running_jobs*/ 64,
)
.await?;
let Stage1JobClaimOutcome::Claimed { ownership_token } = claim else {
anyhow::bail!("unexpected stage1 claim outcome: {claim:?}");
};
assert!(
store
.mark_stage1_job_succeeded(
thread_id,
ownership_token.as_str(),
now.timestamp(),
if version == MemoryVersion::V1 {
"raw memory"
} else {
""
},
"rollout summary",
/*rollout_slug*/ None,
)
.await?,
"stage1 success should be recorded"
);
store.enqueue_global_consolidation(now.timestamp()).await?;
Ok(thread_id)
}
async fn init_state_db(codex_home: &Path) -> Result<Arc<StateRuntime>> {
let state_db = StateRuntime::init(
codex_state::SqliteConfig::new_for_testing(codex_home.abs()),
"mock_provider".into(),
)
.await?;
state_db
.mark_backfill_complete(/*last_watermark*/ None)
.await?;
Ok(state_db)
}
#[tokio::test]
async fn memory_status_requires_successful_v2_consolidation_and_resets() -> Result<()> {
use codex_app_server_protocol::MemoryStatusResponse;
use codex_state::Phase2JobClaimOutcome;
let home = TempDir::new()?;
MockResponsesConfig::new("http://127.0.0.1:9")
.enable_feature(Feature::Sqlite)
.write(home.path())?;
let db = init_state_db(home.path()).await?;
seed_stage1_output(&db, home.path(), MemoryVersion::V1).await?;
let source = seed_stage1_output(&db, home.path(), MemoryVersion::V2).await?;
seed_stage1_output(&db, home.path(), MemoryVersion::V2).await?;
let mut server = TestAppServer::builder()
.with_codex_home(home.path())
.without_auto_env()
.build_initialized_with_timeout(DEFAULT_READ_TIMEOUT)
.await?;
let params = serde_json::json!({"minConsolidatedThreads": 2});
let request = server
.send_raw_request("memory/status", Some(params.clone()))
.await?;
let status: MemoryStatusResponse = server.read_response(request).await?;
assert_eq!(
status,
MemoryStatusResponse {
v2_consolidated_threads: 0,
v2_ready: false
}
);
let store = db.memories_for_version(MemoryVersion::V2).await?;
let outputs = store.list_stage1_outputs_for_global(/*n*/ 20).await?;
let Phase2JobClaimOutcome::Claimed {
ownership_token,
input_watermark,
} = store
.try_claim_global_phase2_job(source, /*lease_seconds*/ 60)
.await?
else {
panic!("claim phase 2")
};
assert!(
!store
.mark_global_phase2_job_succeeded("wrong owner", input_watermark, &outputs)
.await?
);
assert_eq!(store.max_consolidated_thread_count().await?, 0);
assert!(
store
.mark_global_phase2_job_succeeded(&ownership_token, input_watermark, &outputs)
.await?
);
// A completed job without a usable artifact must not activate v2.
let request = server
.send_raw_request("memory/status", Some(params.clone()))
.await?;
let status: MemoryStatusResponse = server.read_response(request).await?;
assert_eq!(
status,
MemoryStatusResponse {
v2_consolidated_threads: 2,
v2_ready: false
}
);
let root = home.path().join("memories_v2");
tokio::fs::create_dir_all(&root).await?;
tokio::fs::write(root.join("memory_summary.md"), "v1\n## User Profile\nTest user\n## User preferences\nTest preference\n## General Tips\nTest tip\n## What's in Memory\nTest source\n").await?;
db.delete_thread(source).await?;
let request = server
.send_raw_request("memory/status", Some(params.clone()))
.await?;
let status: MemoryStatusResponse = server.read_response(request).await?;
assert_eq!(
status,
MemoryStatusResponse {
v2_consolidated_threads: 2,
v2_ready: true
}
);
// The default threshold remains 20 even though a caller can choose a smaller cohort.
let request = server
.send_raw_request("memory/status", Some(serde_json::json!({})))
.await?;
let status: MemoryStatusResponse = server.read_response(request).await?;
assert_eq!(
status,
MemoryStatusResponse {
v2_consolidated_threads: 2,
v2_ready: false
}
);
let request = server
.send_raw_request("memory/reset", /*params*/ None)
.await?;
let _: MemoryResetResponse = server.read_response(request).await?;
let request = server
.send_raw_request("memory/status", Some(params))
.await?;
let status: MemoryStatusResponse = server.read_response(request).await?;
assert_eq!(
status,
MemoryStatusResponse {
v2_consolidated_threads: 0,
v2_ready: false
}
);
Ok(())
}