Add experimental thread memory summary reads (#56465)

## What changed

Add `thread/memory/read` to return `{summary}` for a thread that has been started or resumed. Read `memory_summary.md` from the thread's resolved memory version, honoring thread-specific configuration overrides without falling back to another version.

Return `null` when memory use is disabled or the selected summary is missing, unreadable, or blank.

## Testing

Extend app-server integration coverage to check explicit and automatic memory version selection, disabled memory use, and missing summaries without fallback.

GitOrigin-RevId: b218f31e379c69ccbacb444bb9d6ee920cd4882b
This commit is contained in:
jif
2026-10-23 12:35:58 +00:00
committed by copyberry
parent 90894a780d
commit b559e6a398
9 changed files with 126 additions and 13 deletions
@@ -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,
@@ -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<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
+8
View File
@@ -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
@@ -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, .. } => {
@@ -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;
@@ -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<Option<ClientResponsePayload>, JSONRPCErrorError> {
let (_, thread) = self.load_thread(&params.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()))
}
}
@@ -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<CodexThread>), JSONRPCErrorError> {
@@ -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(())
}