diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst index a7fa0aa20c..067fbf2831 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst differ diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index b183d6a2e5..559942482a 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -720,6 +720,12 @@ client_request_definitions! { serialization: thread_id(params.thread_id), response: v2::ThreadMemoryModeSetResponse, }, + #[experimental("thread/memory/read")] + ThreadMemoryRead => "thread/memory/read" { + params: v2::ThreadMemoryReadParams, + serialization: thread_id(params.thread_id), + response: v2::ThreadMemoryReadResponse, + }, #[experimental("memory/status")] MemoryStatus => "memory/status" { params: v2::MemoryStatusParams, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/memory.rs b/codex-rs/app-server-protocol/src/protocol/v2/memory.rs index 3e5ccc3a10..f14e5520bc 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/memory.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/memory.rs @@ -1,10 +1,27 @@ -//! Readiness for selecting v2 memory context after background consolidation. +//! Memory readiness and summary reads using a loaded thread's selected pipeline. use crate::JsonSchema; use crate::TS; use serde::Deserialize; use serde::Serialize; +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadMemoryReadParams { + /// The thread must already be started or resumed. + pub thread_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadMemoryReadResponse { + /// Summary from the thread's selected memory version. Null when memory use is + /// disabled or the selected summary is missing, unreadable, or blank. + pub summary: Option, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 2c4d9a70e7..97d91065f4 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1,3 +1,11 @@ +# Thread memory summary (experimental) + +`thread/memory/read` takes `{threadId}` for a loaded thread and returns +`{summary}`. Start or resume the thread first. The summary uses that thread's +resolved memory version, including thread-specific configuration overrides. +It is `null` when memory use is disabled or the selected summary is missing, +unreadable, or blank. It never falls back to the other memory version. + # Thread list exclusions `thread/list` accepts `excludedThreadIds`, an optional array of up to 100 diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index e1e5a7a948..a1b1133119 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -1472,6 +1472,9 @@ impl MessageProcessor { ClientRequest::MemoryStatus { params, .. } => { self.thread_processor.memory_status(params).await } + ClientRequest::ThreadMemoryRead { params, .. } => { + self.thread_processor.thread_memory_read(params).await + } ClientRequest::MemoryReset { .. } => self.thread_processor.memory_reset().await, ClientRequest::RolloutCompress { .. } => self.thread_processor.rollout_compress(), ClientRequest::ThreadUnarchive { params, .. } => { diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index f76e655805..21c8d2327c 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -570,6 +570,7 @@ mod thread_attachments; mod thread_enrichment; mod thread_fork_goal; mod thread_input; +mod thread_memory; mod thread_processor; mod thread_queue_processor; mod thread_read_state; diff --git a/codex-rs/app-server/src/request_processors/thread_memory.rs b/codex-rs/app-server/src/request_processors/thread_memory.rs new file mode 100644 index 0000000000..b58ea6e8b6 --- /dev/null +++ b/codex-rs/app-server/src/request_processors/thread_memory.rs @@ -0,0 +1,30 @@ +//! Reads memory context from the loaded thread's resolved version, without fallback. + +use super::*; +use codex_app_server_protocol::ThreadMemoryReadParams; +use codex_app_server_protocol::ThreadMemoryReadResponse; + +impl ThreadRequestProcessor { + pub(crate) async fn thread_memory_read( + &self, + params: ThreadMemoryReadParams, + ) -> Result, JSONRPCErrorError> { + let (_, thread) = self.load_thread(¶ms.thread_id).await?; + let config = thread.config().await; + let summary = + if config.features.enabled(Feature::MemoryTool) && config.memories.use_memories { + tokio::fs::read_to_string( + config + .codex_home + .join(config.memories.version.directory_name()) + .join("memory_summary.md"), + ) + .await + .ok() + .filter(|summary| !summary.trim().is_empty()) + } else { + None + }; + Ok(Some(ThreadMemoryReadResponse { summary }.into())) + } +} diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index ffade96e3c..941e32e5c0 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -1016,7 +1016,7 @@ impl ThreadRequestProcessor { .map(|response| Some(response.into())) } - async fn load_thread( + pub(super) async fn load_thread( &self, thread_id: &str, ) -> Result<(ThreadId, Arc), JSONRPCErrorError> { diff --git a/codex-rs/app-server/tests/suite/v2/memory_read.rs b/codex-rs/app-server/tests/suite/v2/memory_read.rs index 77dde57b4b..2cc136ff2b 100644 --- a/codex-rs/app-server/tests/suite/v2/memory_read.rs +++ b/codex-rs/app-server/tests/suite/v2/memory_read.rs @@ -3,6 +3,7 @@ use anyhow::Result; use app_test_support::MockResponsesConfig; use app_test_support::TestAppServer; +use codex_app_server_protocol::ThreadMemoryReadResponse; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::TurnCompletedNotification; @@ -22,11 +23,30 @@ use tempfile::TempDir; #[tokio::test] async fn memory_read_version_selects_only_its_own_context() -> Result<()> { - for (version, has_v1_workspace, expected, excluded) in [ - (Some("v1"), true, "legacy-only-marker", "v2-only-marker"), - (Some("v2"), true, "v2-only-marker", "legacy-only-marker"), - (None, true, "legacy-only-marker", "v2-only-marker"), - (None, false, "v2-only-marker", "legacy-only-marker"), + for (version, has_v1_workspace, expected, excluded, use_memories) in [ + ( + Some("v1"), + true, + "legacy-only-marker", + "v2-only-marker", + true, + ), + ( + Some("v2"), + true, + "v2-only-marker", + "legacy-only-marker", + true, + ), + (None, true, "legacy-only-marker", "v2-only-marker", true), + (None, false, "v2-only-marker", "legacy-only-marker", true), + ( + Some("v2"), + true, + "v2-only-marker", + "legacy-only-marker", + false, + ), ] { let server = start_mock_server().await; let home = TempDir::new()?; @@ -60,10 +80,10 @@ async fn memory_read_version_selects_only_its_own_context() -> Result<()> { .with_codex_home(home.path()) .build_initialized() .await?; - let mut config = std::collections::HashMap::from([( - "memories.generate_memories".to_string(), - json!(false), - )]); + let mut config = std::collections::HashMap::from([ + ("memories.generate_memories".to_string(), json!(false)), + ("memories.use_memories".to_string(), json!(use_memories)), + ]); if let Some(version) = version { config.insert("memories.version".to_string(), json!(version)); } @@ -76,8 +96,21 @@ async fn memory_read_version_selects_only_its_own_context() -> Result<()> { }) .await?; let response: ThreadStartResponse = app.read_response(start).await?; + let summary_request = app + .send_raw_request( + "thread/memory/read", + Some(json!({ "threadId": response.thread.id })), + ) + .await?; + let summary: ThreadMemoryReadResponse = app.read_response(summary_request).await?; + assert_eq!( + summary, + ThreadMemoryReadResponse { + summary: use_memories.then(|| format!("v1\n{expected}\n")), + } + ); app.send_turn_start_request(TurnStartParams { - thread_id: response.thread.id, + thread_id: response.thread.id.clone(), input: vec![UserInput::Text { text: "Use my memory preferences".to_string(), text_elements: Vec::new(), @@ -88,8 +121,23 @@ async fn memory_read_version_selects_only_its_own_context() -> Result<()> { let completed: TurnCompletedNotification = app.read_notification("turn/completed").await?; assert_eq!(completed.turn.status, TurnStatus::Completed); let request = mock.single_request(); - assert!(request.body_contains_text(expected)); + assert_eq!(request.body_contains_text(expected), use_memories); assert!(!request.body_contains_text(excluded)); + + let directory = if version == Some("v1") || (version.is_none() && has_v1_workspace) { + "memories" + } else { + "memories_v2" + }; + tokio::fs::remove_file(home.path().join(directory).join("memory_summary.md")).await?; + let summary_request = app + .send_raw_request( + "thread/memory/read", + Some(json!({ "threadId": response.thread.id })), + ) + .await?; + let summary: ThreadMemoryReadResponse = app.read_response(summary_request).await?; + assert_eq!(summary, ThreadMemoryReadResponse { summary: None }); } Ok(()) }