From 936e744c939071367d1c5e89cb8a7a211cc6c18c Mon Sep 17 00:00:00 2001 From: natea-oai Date: Fri, 20 Feb 2026 18:26:57 -0800 Subject: [PATCH] Add field to Thread object for the latest rename set for a given thread (#12301) Exposes through the app server updated names set for a thread. This enables other surfaces to use the core as the source of truth for thread naming. `threadName` is gathered using the helper functions used to interact with `session_index.jsonl`, and is hydrated in: - `thread/list` - `thread/read` - `thread/resume` - `thread/unarchive` - `thread/rollback` We don't do this for `thread/start` and `thread/fork`. --- .../schema/json/ServerNotification.json | 7 + .../codex_app_server_protocol.schemas.json | 7 + .../schema/json/v2/ThreadForkResponse.json | 7 + .../schema/json/v2/ThreadListResponse.json | 7 + .../schema/json/v2/ThreadReadResponse.json | 7 + .../schema/json/v2/ThreadResumeResponse.json | 7 + .../json/v2/ThreadRollbackResponse.json | 7 + .../schema/json/v2/ThreadStartResponse.json | 7 + .../json/v2/ThreadStartedNotification.json | 7 + .../json/v2/ThreadUnarchiveResponse.json | 7 + .../schema/typescript/v2/Thread.ts | 4 + .../app-server-protocol/src/protocol/v2.rs | 2 + .../app-server/src/bespoke_event_handling.rs | 14 ++ .../app-server/src/codex_message_processor.rs | 60 ++++++- codex-rs/app-server/src/thread_status.rs | 1 + .../app-server/tests/common/mcp_process.rs | 10 ++ .../app-server/tests/suite/v2/thread_fork.rs | 24 +++ .../app-server/tests/suite/v2/thread_read.rs | 156 ++++++++++++++++++ .../tests/suite/v2/thread_rollback.rs | 14 ++ .../app-server/tests/suite/v2/thread_start.rs | 24 +++ .../tests/suite/v2/thread_unarchive.rs | 14 ++ 21 files changed, 386 insertions(+), 7 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 6a6bf2d7c..1df0a8b05 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -6736,6 +6736,13 @@ "description": "Model provider used for this thread (for example, 'openai').", "type": "string" }, + "name": { + "description": "Optional user-facing thread title.", + "type": [ + "string", + "null" + ] + }, "path": { "description": "[UNSTABLE] Path to the thread on disk.", "type": [ diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 313c3e62b..4b1728512 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -15553,6 +15553,13 @@ "description": "Model provider used for this thread (for example, 'openai').", "type": "string" }, + "name": { + "description": "Optional user-facing thread title.", + "type": [ + "string", + "null" + ] + }, "path": { "description": "[UNSTABLE] Path to the thread on disk.", "type": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index dad39a512..55480c5bc 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -836,6 +836,13 @@ "description": "Model provider used for this thread (for example, 'openai').", "type": "string" }, + "name": { + "description": "Optional user-facing thread title.", + "type": [ + "string", + "null" + ] + }, "path": { "description": "[UNSTABLE] Path to the thread on disk.", "type": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index 80a1d42a2..9e4e4d63b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -609,6 +609,13 @@ "description": "Model provider used for this thread (for example, 'openai').", "type": "string" }, + "name": { + "description": "Optional user-facing thread title.", + "type": [ + "string", + "null" + ] + }, "path": { "description": "[UNSTABLE] Path to the thread on disk.", "type": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index a4810d585..acb80dd56 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -609,6 +609,13 @@ "description": "Model provider used for this thread (for example, 'openai').", "type": "string" }, + "name": { + "description": "Optional user-facing thread title.", + "type": [ + "string", + "null" + ] + }, "path": { "description": "[UNSTABLE] Path to the thread on disk.", "type": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index c4e1df847..884930d4d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -836,6 +836,13 @@ "description": "Model provider used for this thread (for example, 'openai').", "type": "string" }, + "name": { + "description": "Optional user-facing thread title.", + "type": [ + "string", + "null" + ] + }, "path": { "description": "[UNSTABLE] Path to the thread on disk.", "type": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json index f8cdcd744..555083baf 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -609,6 +609,13 @@ "description": "Model provider used for this thread (for example, 'openai').", "type": "string" }, + "name": { + "description": "Optional user-facing thread title.", + "type": [ + "string", + "null" + ] + }, "path": { "description": "[UNSTABLE] Path to the thread on disk.", "type": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 099a65d21..2acd4da8f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -836,6 +836,13 @@ "description": "Model provider used for this thread (for example, 'openai').", "type": "string" }, + "name": { + "description": "Optional user-facing thread title.", + "type": [ + "string", + "null" + ] + }, "path": { "description": "[UNSTABLE] Path to the thread on disk.", "type": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index 9927bb2f3..a39ec5151 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -609,6 +609,13 @@ "description": "Model provider used for this thread (for example, 'openai').", "type": "string" }, + "name": { + "description": "Optional user-facing thread title.", + "type": [ + "string", + "null" + ] + }, "path": { "description": "[UNSTABLE] Path to the thread on disk.", "type": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index 011a97a34..e18582f06 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -609,6 +609,13 @@ "description": "Model provider used for this thread (for example, 'openai').", "type": "string" }, + "name": { + "description": "Optional user-facing thread title.", + "type": [ + "string", + "null" + ] + }, "path": { "description": "[UNSTABLE] Path to the thread on disk.", "type": [ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts index 5e7a6b7af..ee07081dc 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts @@ -55,6 +55,10 @@ agentRole: string | null, * Optional Git metadata captured when the thread was created. */ gitInfo: GitInfo | null, +/** + * Optional user-facing thread title. + */ +name: string | null, /** * Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` * (when `includeTurns` is true) responses. diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 833e62821..4b3ffaf02 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -2277,6 +2277,8 @@ pub struct Thread { pub agent_role: Option, /// Optional Git metadata captured when the thread was created. pub git_info: Option, + /// Optional user-facing thread title. + pub name: Option, /// Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` /// (when `includeTurns` is true) responses. /// For all other responses and notifications returning a Thread, diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index fca2bcc1c..f53b10b84 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -75,6 +75,7 @@ use codex_app_server_protocol::build_turns_from_rollout_items; use codex_app_server_protocol::convert_patch_changes; use codex_core::CodexThread; use codex_core::ThreadManager; +use codex_core::find_thread_name_by_id; use codex_core::parse_command::shlex_join; use codex_core::protocol::ApplyPatchApprovalRequestEvent; use codex_core::protocol::CodexErrorInfo as CoreCodexErrorInfo; @@ -99,11 +100,13 @@ use codex_protocol::request_user_input::RequestUserInputAnswer as CoreRequestUse use codex_protocol::request_user_input::RequestUserInputResponse as CoreRequestUserInputResponse; use std::collections::HashMap; use std::convert::TryFrom; +use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use tokio::sync::Mutex; use tokio::sync::oneshot; use tracing::error; +use tracing::warn; type JsonValue = serde_json::Value; @@ -129,6 +132,7 @@ pub(crate) async fn apply_bespoke_event_handling( thread_watch_manager: ThreadWatchManager, api_version: ApiVersion, fallback_model_provider: String, + codex_home: &Path, ) { let Event { id: event_turn_id, @@ -1224,6 +1228,16 @@ pub(crate) async fn apply_bespoke_event_handling( thread.status = thread_watch_manager .loaded_status_for_thread(&thread.id) .await; + match find_thread_name_by_id(codex_home, &conversation_id).await { + Ok(name) => { + thread.name = name; + } + Err(err) => { + warn!( + "Failed to read thread name for {conversation_id}: {err}" + ); + } + } ThreadRollbackResponse { thread } } Err(err) => { diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 65b7f1ecb..72dc7eaea 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -202,6 +202,8 @@ use codex_core::features::FEATURES; use codex_core::features::Feature; use codex_core::features::Stage; use codex_core::find_archived_thread_path_by_id_str; +use codex_core::find_thread_name_by_id; +use codex_core::find_thread_names_by_ids; use codex_core::find_thread_path_by_id_str; use codex_core::git_info::git_diff_to_remote; use codex_core::mcp::collect_mcp_snapshot; @@ -2374,6 +2376,7 @@ impl CodexMessageProcessor { .thread_watch_manager .loaded_status_for_thread(&thread.id) .await; + self.attach_thread_name(thread_id, &mut thread).await; let thread_id = thread.id.clone(); let response = ThreadUnarchiveResponse { thread }; self.outgoing.send_response(request_id, response).await; @@ -2544,18 +2547,36 @@ impl CodexMessageProcessor { return; } }; + let mut threads = Vec::with_capacity(summaries.len()); + let mut thread_ids = HashSet::with_capacity(summaries.len()); + let mut status_ids = Vec::with_capacity(summaries.len()); + + for summary in summaries { + let conversation_id = summary.conversation_id; + thread_ids.insert(conversation_id); + + let thread = summary_to_thread(summary); + status_ids.push(thread.id.clone()); + threads.push((conversation_id, thread)); + } + + let names = match find_thread_names_by_ids(&self.config.codex_home, &thread_ids).await { + Ok(names) => names, + Err(err) => { + warn!("Failed to read thread names: {err}"); + HashMap::new() + } + }; - let data = summaries - .into_iter() - .map(summary_to_thread) - .collect::>(); let statuses = self .thread_watch_manager - .loaded_statuses_for_threads(data.iter().map(|thread| thread.id.clone()).collect()) + .loaded_statuses_for_threads(status_ids) .await; - let data = data + + let data = threads .into_iter() - .map(|mut thread| { + .map(|(conversation_id, mut thread)| { + thread.name = names.get(&conversation_id).cloned(); if let Some(status) = statuses.get(&thread.id) { thread.status = status.clone(); } @@ -2717,6 +2738,7 @@ impl CodexMessageProcessor { } build_thread_from_snapshot(thread_uuid, &config_snapshot, loaded_rollout_path) }; + self.attach_thread_name(thread_uuid, &mut thread).await; if include_turns && let Some(rollout_path) = rollout_path.as_ref() { match read_rollout_items_from_rollout(rollout_path).await { @@ -3206,6 +3228,7 @@ impl CodexMessageProcessor { match read_rollout_items_from_rollout(rollout_path).await { Ok(items) => { thread.turns = build_turns_from_rollout_items(&items); + self.attach_thread_name(thread_id, &mut thread).await; Some(thread) } Err(err) => { @@ -3222,6 +3245,17 @@ impl CodexMessageProcessor { } } + async fn attach_thread_name(&self, thread_id: ThreadId, thread: &mut Thread) { + match find_thread_name_by_id(&self.config.codex_home, &thread_id).await { + Ok(name) => { + thread.name = name; + } + Err(err) => { + warn!("Failed to read thread name for {thread_id}: {err}"); + } + } + } + async fn thread_fork(&mut self, request_id: ConnectionRequestId, params: ThreadForkParams) { let ThreadForkParams { thread_id, @@ -3419,6 +3453,7 @@ impl CodexMessageProcessor { return; } }; + // forked thread names do not inherit the source thread name match read_rollout_items_from_rollout(rollout_path.as_path()).await { Ok(items) => { thread.turns = build_turns_from_rollout_items(&items); @@ -5823,6 +5858,7 @@ impl CodexMessageProcessor { let thread_watch_manager = self.thread_watch_manager.clone(); let fallback_model_provider = self.config.model_provider_id.clone(); let single_client_mode = self.single_client_mode; + let codex_home = self.config.codex_home.clone(); tokio::spawn(async move { loop { tokio::select! { @@ -5903,6 +5939,7 @@ impl CodexMessageProcessor { thread_watch_manager.clone(), api_version, fallback_model_provider.clone(), + codex_home.as_path(), ) .await; } @@ -5916,6 +5953,7 @@ impl CodexMessageProcessor { ) => { handle_pending_thread_resume_request( conversation_id, + codex_home.as_path(), &thread_state, &thread_watch_manager, &outgoing_for_task, @@ -6235,6 +6273,7 @@ impl CodexMessageProcessor { async fn handle_pending_thread_resume_request( conversation_id: ThreadId, + codex_home: &Path, thread_state: &Arc>, thread_watch_manager: &ThreadWatchManager, outgoing: &Arc, @@ -6274,6 +6313,11 @@ async fn handle_pending_thread_resume_request( .loaded_status_for_thread(&thread.id) .await; + match find_thread_name_by_id(codex_home, &conversation_id).await { + Ok(thread_name) => thread.name = thread_name, + Err(err) => warn!("Failed to read thread name for {conversation_id}: {err}"), + } + let ThreadConfigSnapshot { model, model_provider_id, @@ -6994,6 +7038,7 @@ fn build_thread_from_snapshot( agent_role: config_snapshot.session_source.get_agent_role(), source: config_snapshot.session_source.clone().into(), git_info: None, + name: None, turns: Vec::new(), } } @@ -7034,6 +7079,7 @@ pub(crate) fn summary_to_thread(summary: ConversationSummary) -> Thread { agent_role: source.get_agent_role(), source: source.into(), git_info, + name: None, turns: Vec::new(), } } diff --git a/codex-rs/app-server/src/thread_status.rs b/codex-rs/app-server/src/thread_status.rs index 301c4b1d0..48e3d9ab0 100644 --- a/codex-rs/app-server/src/thread_status.rs +++ b/codex-rs/app-server/src/thread_status.rs @@ -626,6 +626,7 @@ mod tests { agent_role: None, source, git_info: None, + name: None, turns: Vec::new(), } } diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 249a28021..4e48f909b 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -59,6 +59,7 @@ use codex_app_server_protocol::ThreadLoadedListParams; use codex_app_server_protocol::ThreadReadParams; use codex_app_server_protocol::ThreadResumeParams; use codex_app_server_protocol::ThreadRollbackParams; +use codex_app_server_protocol::ThreadSetNameParams; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadUnarchiveParams; use codex_app_server_protocol::TurnCompletedNotification; @@ -418,6 +419,15 @@ impl McpProcess { self.send_request("thread/archive", params).await } + /// Send a `thread/name/set` JSON-RPC request. + pub async fn send_thread_set_name_request( + &mut self, + params: ThreadSetNameParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("thread/name/set", params).await + } + /// Send a `thread/unarchive` JSON-RPC request. pub async fn send_thread_unarchive_request( &mut self, diff --git a/codex-rs/app-server/tests/suite/v2/thread_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs index 7047622a4..a6f994217 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -18,6 +18,7 @@ use codex_app_server_protocol::ThreadStatus; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::UserInput; use pretty_assertions::assert_eq; +use serde_json::Value; use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; @@ -70,8 +71,20 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> { mcp.read_stream_until_response_message(RequestId::Integer(fork_id)), ) .await??; + let fork_result = fork_resp.result.clone(); let ThreadForkResponse { thread, .. } = to_response::(fork_resp)?; + // Wire contract: thread title field is `name`, serialized as null when unset. + let thread_json = fork_result + .get("thread") + .and_then(Value::as_object) + .expect("thread/fork result.thread must be an object"); + assert_eq!( + thread_json.get("name"), + Some(&Value::Null), + "forked threads do not inherit a name; expected `name: null`" + ); + let after_contents = std::fs::read_to_string(&original_path)?; assert_eq!( after_contents, original_contents, @@ -87,6 +100,7 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> { assert_ne!(thread_path, original_path); assert!(thread.cwd.is_absolute()); assert_eq!(thread.source, SessionSource::VsCode); + assert_eq!(thread.name, None); assert_eq!( thread.turns.len(), @@ -115,6 +129,16 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> { mcp.read_stream_until_notification_message("thread/started"), ) .await??; + let started_params = notif.params.clone().expect("params must be present"); + let started_thread_json = started_params + .get("thread") + .and_then(Value::as_object) + .expect("thread/started params.thread must be an object"); + assert_eq!( + started_thread_json.get("name"), + Some(&Value::Null), + "thread/started must serialize `name: null` when unset" + ); let started: ThreadStartedNotification = serde_json::from_value(notif.params.expect("params must be present"))?; assert_eq!(started.thread, thread); diff --git a/codex-rs/app-server/tests/suite/v2/thread_read.rs b/codex-rs/app-server/tests/suite/v2/thread_read.rs index 5a6925bcb..384aad6c8 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_read.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_read.rs @@ -8,8 +8,14 @@ use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SessionSource; use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadListParams; +use codex_app_server_protocol::ThreadListResponse; use codex_app_server_protocol::ThreadReadParams; use codex_app_server_protocol::ThreadReadResponse; +use codex_app_server_protocol::ThreadResumeParams; +use codex_app_server_protocol::ThreadResumeResponse; +use codex_app_server_protocol::ThreadSetNameParams; +use codex_app_server_protocol::ThreadSetNameResponse; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus; @@ -21,6 +27,7 @@ use codex_protocol::user_input::ByteRange; use codex_protocol::user_input::TextElement; use core_test_support::responses; use pretty_assertions::assert_eq; +use serde_json::Value; use std::path::Path; use std::path::PathBuf; use tempfile::TempDir; @@ -192,6 +199,155 @@ async fn thread_read_loaded_thread_returns_precomputed_path_before_materializati Ok(()) } +#[tokio::test] +async fn thread_name_set_is_reflected_in_read_list_and_resume() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let preview = "Saved user message"; + let conversation_id = create_fake_rollout_with_text_elements( + codex_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + preview, + vec![], + Some("mock_provider"), + None, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + // `thread/name/set` operates on loaded threads (via ThreadManager). A rollout existing on disk + // is not enough; we must `thread/resume` first to load it into the running server. + let pre_resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id: conversation_id.clone(), + ..Default::default() + }) + .await?; + let pre_resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(pre_resume_id)), + ) + .await??; + let ThreadResumeResponse { + thread: pre_resumed, + .. + } = to_response::(pre_resume_resp)?; + assert_eq!(pre_resumed.id, conversation_id); + + // Set a user-facing thread title. + let new_name = "My renamed thread"; + let set_id = mcp + .send_thread_set_name_request(ThreadSetNameParams { + thread_id: conversation_id.clone(), + name: new_name.to_string(), + }) + .await?; + let set_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(set_id)), + ) + .await??; + let _: ThreadSetNameResponse = to_response::(set_resp)?; + + // Read should now surface `thread.name`, and the wire payload must include `name`. + let read_id = mcp + .send_thread_read_request(ThreadReadParams { + thread_id: conversation_id.clone(), + include_turns: false, + }) + .await?; + let read_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(read_id)), + ) + .await??; + let read_result = read_resp.result.clone(); + let ThreadReadResponse { thread } = to_response::(read_resp)?; + assert_eq!(thread.id, conversation_id); + assert_eq!(thread.name.as_deref(), Some(new_name)); + let thread_json = read_result + .get("thread") + .and_then(Value::as_object) + .expect("thread/read result.thread must be an object"); + assert_eq!( + thread_json.get("name").and_then(Value::as_str), + Some(new_name), + "thread/read must serialize `thread.name` on the wire" + ); + + // List should also surface the name. + let list_id = mcp + .send_thread_list_request(ThreadListParams { + cursor: None, + limit: Some(50), + sort_key: None, + model_providers: Some(vec!["mock_provider".to_string()]), + source_kinds: None, + archived: None, + cwd: None, + }) + .await?; + let list_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(list_id)), + ) + .await??; + let list_result = list_resp.result.clone(); + let ThreadListResponse { data, .. } = to_response::(list_resp)?; + let listed = data + .iter() + .find(|t| t.id == conversation_id) + .expect("thread/list should include the created thread"); + assert_eq!(listed.name.as_deref(), Some(new_name)); + let listed_json = list_result + .get("data") + .and_then(Value::as_array) + .expect("thread/list result.data must be an array") + .iter() + .find(|t| t.get("id").and_then(Value::as_str) == Some(&conversation_id)) + .and_then(Value::as_object) + .expect("thread/list should include the created thread as an object"); + assert_eq!( + listed_json.get("name").and_then(Value::as_str), + Some(new_name), + "thread/list must serialize `thread.name` on the wire" + ); + + // Resume should also surface the name. + let resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id: conversation_id.clone(), + ..Default::default() + }) + .await?; + let resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(resume_id)), + ) + .await??; + let resume_result = resume_resp.result.clone(); + let ThreadResumeResponse { + thread: resumed, .. + } = to_response::(resume_resp)?; + assert_eq!(resumed.id, conversation_id); + assert_eq!(resumed.name.as_deref(), Some(new_name)); + let resumed_json = resume_result + .get("thread") + .and_then(Value::as_object) + .expect("thread/resume result.thread must be an object"); + assert_eq!( + resumed_json.get("name").and_then(Value::as_str), + Some(new_name), + "thread/resume must serialize `thread.name` on the wire" + ); + + Ok(()) +} + #[tokio::test] async fn thread_read_include_turns_rejects_unmaterialized_loaded_thread() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; diff --git a/codex-rs/app-server/tests/suite/v2/thread_rollback.rs b/codex-rs/app-server/tests/suite/v2/thread_rollback.rs index 47a358728..3487b9e36 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_rollback.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_rollback.rs @@ -16,6 +16,7 @@ use codex_app_server_protocol::ThreadStatus; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::UserInput as V2UserInput; use pretty_assertions::assert_eq; +use serde_json::Value; use tempfile::TempDir; use tokio::time::timeout; @@ -107,10 +108,23 @@ async fn thread_rollback_drops_last_turns_and_persists_to_rollout() -> Result<() mcp.read_stream_until_response_message(RequestId::Integer(rollback_id)), ) .await??; + let rollback_result = rollback_resp.result.clone(); let ThreadRollbackResponse { thread: rolled_back_thread, } = to_response::(rollback_resp)?; + // Wire contract: thread title field is `name`, serialized as null when unset. + let thread_json = rollback_result + .get("thread") + .and_then(Value::as_object) + .expect("thread/rollback result.thread must be an object"); + assert_eq!(rolled_back_thread.name, None); + assert_eq!( + thread_json.get("name"), + Some(&Value::Null), + "thread/rollback must serialize `name: null` when unset" + ); + assert_eq!(rolled_back_thread.turns.len(), 1); assert_eq!(rolled_back_thread.status, ThreadStatus::Idle); assert_eq!(rolled_back_thread.turns[0].items.len(), 2); diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index bcee63df9..4e63d4c54 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -13,6 +13,7 @@ use codex_app_server_protocol::ThreadStatus; use codex_core::config::set_project_trust_level; use codex_protocol::config_types::TrustLevel; use codex_protocol::openai_models::ReasoningEffort; +use serde_json::Value; use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; @@ -45,6 +46,7 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> { mcp.read_stream_until_response_message(RequestId::Integer(req_id)), ) .await??; + let resp_result = resp.result.clone(); let ThreadStartResponse { thread, model_provider, @@ -68,12 +70,34 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> { "fresh thread rollout should not be materialized until first user message" ); + // Wire contract: thread title field is `name`, serialized as null when unset. + let thread_json = resp_result + .get("thread") + .and_then(Value::as_object) + .expect("thread/start result.thread must be an object"); + assert_eq!( + thread_json.get("name"), + Some(&Value::Null), + "new threads should serialize `name: null`" + ); + assert_eq!(thread.name, None); + // A corresponding thread/started notification should arrive. let notif: JSONRPCNotification = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_notification_message("thread/started"), ) .await??; + let started_params = notif.params.clone().expect("params must be present"); + let started_thread_json = started_params + .get("thread") + .and_then(Value::as_object) + .expect("thread/started params.thread must be an object"); + assert_eq!( + started_thread_json.get("name"), + Some(&Value::Null), + "thread/started should serialize `name: null` for new threads" + ); let started: ThreadStartedNotification = serde_json::from_value(notif.params.expect("params must be present"))?; assert_eq!(started.thread, thread); diff --git a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs index 35de7eee9..b2ae60ae3 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs @@ -18,6 +18,7 @@ use codex_app_server_protocol::UserInput; use codex_core::find_archived_thread_path_by_id_str; use codex_core::find_thread_path_by_id_str; use pretty_assertions::assert_eq; +use serde_json::Value; use std::fs::FileTimes; use std::fs::OpenOptions; use std::path::Path; @@ -120,6 +121,7 @@ async fn thread_unarchive_moves_rollout_back_into_sessions_directory() -> Result mcp.read_stream_until_response_message(RequestId::Integer(unarchive_id)), ) .await??; + let unarchive_result = unarchive_resp.result.clone(); let ThreadUnarchiveResponse { thread: unarchived_thread, } = to_response::(unarchive_resp)?; @@ -140,6 +142,18 @@ async fn thread_unarchive_moves_rollout_back_into_sessions_directory() -> Result ); assert_eq!(unarchived_thread.status, ThreadStatus::NotLoaded); + // Wire contract: thread title field is `name`, serialized as null when unset. + let thread_json = unarchive_result + .get("thread") + .and_then(Value::as_object) + .expect("thread/unarchive result.thread must be an object"); + assert_eq!(unarchived_thread.name, None); + assert_eq!( + thread_json.get("name"), + Some(&Value::Null), + "thread/unarchive must serialize `name: null` when unset" + ); + let rollout_path_display = rollout_path.display(); assert!( rollout_path.exists(),