mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
Fix forked thread name inheritance (#26075)
Fixes #25950. ## Why Forking a renamed thread could fall back to the source thread's first-prompt title because the fork path did not preserve the source's explicit name. That meant fork-of-renamed-fork flows could show stale sidebar labels even though the user had renamed the parent. ## What changed `thread/fork` now reads the source thread's distinct `name`, normalizes it, persists it onto materialized forks, and applies it to the returned API thread. Because the source `name` already excludes first-prompt pseudo-titles, forks inherit only an explicit user rename instead of stale generated metadata.
This commit is contained in:
committed by
GitHub
Unverified
parent
a2ebe07b39
commit
d8121f93c8
@@ -3173,6 +3173,10 @@ impl ThreadRequestProcessor {
|
||||
.read_stored_thread_for_resume(&thread_id, path.as_ref(), /*include_history*/ true)
|
||||
.await?;
|
||||
let source_thread_id = source_thread.thread_id;
|
||||
let source_thread_name = source_thread
|
||||
.name
|
||||
.as_deref()
|
||||
.and_then(codex_core::util::normalize_thread_name);
|
||||
let history_items = source_thread
|
||||
.history
|
||||
.as_ref()
|
||||
@@ -3264,6 +3268,21 @@ impl ThreadRequestProcessor {
|
||||
app_server_client_version,
|
||||
)
|
||||
.await?;
|
||||
if session_configured.rollout_path.is_some()
|
||||
&& let Some(name) = source_thread_name.clone()
|
||||
{
|
||||
self.thread_manager
|
||||
.update_thread_metadata(
|
||||
thread_id,
|
||||
StoreThreadMetadataPatch {
|
||||
name: Some(Some(name)),
|
||||
..Default::default()
|
||||
},
|
||||
/*include_archived*/ true,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| core_thread_write_error("inherit source thread name", err))?;
|
||||
}
|
||||
|
||||
// Auto-attach a conversation listener when forking a thread.
|
||||
log_listener_attach_result(
|
||||
@@ -3291,7 +3310,6 @@ impl ThreadRequestProcessor {
|
||||
)
|
||||
} else {
|
||||
let config_snapshot = forked_thread.config_snapshot().await;
|
||||
// forked thread names do not inherit the source thread name
|
||||
let mut thread = build_thread_from_snapshot(
|
||||
thread_id,
|
||||
session_configured.session_id.to_string(),
|
||||
@@ -3309,6 +3327,9 @@ impl ThreadRequestProcessor {
|
||||
}
|
||||
thread
|
||||
};
|
||||
if let Some(name) = source_thread_name {
|
||||
set_thread_name_from_title(&mut thread, name);
|
||||
}
|
||||
thread.session_id = session_configured.session_id.to_string();
|
||||
thread.thread_source = forked_thread
|
||||
.config_snapshot()
|
||||
|
||||
@@ -30,9 +30,11 @@ use codex_app_server_protocol::TurnStatus;
|
||||
use codex_app_server_protocol::UserInput;
|
||||
use codex_config::types::AuthCredentialsStoreMode;
|
||||
use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::protocol::MultiAgentVersion;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_rollout::append_rollout_item_to_path;
|
||||
use codex_rollout::append_thread_name;
|
||||
use codex_rollout::read_session_meta_line;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::Value;
|
||||
@@ -56,6 +58,29 @@ const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs
|
||||
#[cfg(not(windows))]
|
||||
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
async fn list_threads(mcp: &mut TestAppServer) -> Result<ThreadListResponse> {
|
||||
let list_id = mcp
|
||||
.send_thread_list_request(ThreadListParams {
|
||||
cursor: None,
|
||||
limit: Some(50),
|
||||
sort_key: None,
|
||||
sort_direction: None,
|
||||
model_providers: None,
|
||||
source_kinds: None,
|
||||
archived: None,
|
||||
cwd: None,
|
||||
use_state_db_only: false,
|
||||
search_term: None,
|
||||
})
|
||||
.await?;
|
||||
let list_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(list_id)),
|
||||
)
|
||||
.await??;
|
||||
to_response::<ThreadListResponse>(list_resp)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> {
|
||||
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
||||
@@ -224,6 +249,50 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_fork_inherits_explicit_source_name_from_session_index() -> 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 conversation_id = create_fake_rollout(
|
||||
codex_home.path(),
|
||||
"2025-01-05T12-00-00",
|
||||
"2025-01-05T12:00:00Z",
|
||||
"Saved user message",
|
||||
Some("mock_provider"),
|
||||
/*git_info*/ None,
|
||||
)?;
|
||||
let source_thread_id = ThreadId::from_string(&conversation_id)?;
|
||||
let source_name = "Renamed parent thread";
|
||||
append_thread_name(codex_home.path(), source_thread_id, source_name).await?;
|
||||
|
||||
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let fork_id = mcp
|
||||
.send_thread_fork_request(ThreadForkParams {
|
||||
thread_id: conversation_id.clone(),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let fork_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(fork_id)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadForkResponse { thread, .. } = to_response::<ThreadForkResponse>(fork_resp)?;
|
||||
|
||||
let ThreadListResponse { data, .. } = list_threads(&mut mcp).await?;
|
||||
let listed = data
|
||||
.iter()
|
||||
.find(|candidate| candidate.id == thread.id)
|
||||
.expect("thread/list should include the forked thread");
|
||||
assert_eq!(listed.name.as_deref(), Some(source_name));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_fork_can_load_source_by_path() -> Result<()> {
|
||||
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
||||
@@ -723,26 +792,7 @@ async fn thread_fork_ephemeral_remains_pathless_and_omits_listing() -> Result<()
|
||||
expected_started_thread.turns.clear();
|
||||
assert_eq!(started.thread, expected_started_thread);
|
||||
|
||||
let list_id = mcp
|
||||
.send_thread_list_request(ThreadListParams {
|
||||
cursor: None,
|
||||
limit: Some(10),
|
||||
sort_key: None,
|
||||
sort_direction: None,
|
||||
model_providers: None,
|
||||
source_kinds: None,
|
||||
archived: None,
|
||||
cwd: None,
|
||||
use_state_db_only: false,
|
||||
search_term: None,
|
||||
})
|
||||
.await?;
|
||||
let list_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(list_id)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadListResponse { data, .. } = to_response::<ThreadListResponse>(list_resp)?;
|
||||
let ThreadListResponse { data, .. } = list_threads(&mut mcp).await?;
|
||||
assert!(
|
||||
data.iter().all(|candidate| candidate.id != fork_thread_id),
|
||||
"ephemeral forks should not appear in thread/list"
|
||||
|
||||
Reference in New Issue
Block a user