Use app server metadata for fork parent titles (#18632)

## Problem
The TUI resolved fork parent titles from local CODEX_HOME metadata,
which could show missing or stale titles when app-server metadata is
authoritative.

This is a lingering bug left over from the migration of the TUI to the
app-server interface. I found it when I asked Codex to review all places
where the TUI code was still directly accessing the local CODEX_HOME.

## Solution
Route fork parent title metadata through the app-server session state
and render only that supplied title, with focused snapshot coverage for
stale local metadata.

## Testing
I manually tested by renaming a thread then forking it and confirming
that the "forked from" message indicated the parent thread's name.
This commit is contained in:
Eric Traut
2026-04-20 15:37:31 -07:00
committed by GitHub
parent 54bd07d28c
commit b8e78e8869
9 changed files with 267 additions and 61 deletions
@@ -0,0 +1,5 @@
---
source: tui/src/chatwidget/tests/history_replay.rs
expression: combined
---
• Thread forked from app-server-parent-thread (e9f18a88-8081-4e51-9d4e-8af5cde2d8dd)
@@ -0,0 +1,5 @@
---
source: tui/src/chatwidget/tests/history_replay.rs
expression: combined
---
• Thread forked from 019c2d47-4935-7423-a190-05691f566092
@@ -0,0 +1,5 @@
---
source: tui/src/chatwidget/tests/history_replay.rs
expression: combined
---
• Thread forked from named-thread (e9f18a88-8081-4e51-9d4e-8af5cde2d8dd)
@@ -387,6 +387,36 @@ async fn replayed_user_message_with_only_local_images_does_not_render_history_ce
assert!(!found_user_history_cell);
}
#[tokio::test]
async fn forked_thread_history_line_includes_name_and_id_snapshot() {
let (chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let mut chat = chat;
let forked_from_id =
ThreadId::from_string("e9f18a88-8081-4e51-9d4e-8af5cde2d8dd").expect("forked id");
chat.emit_forked_thread_event(forked_from_id, Some("named-thread".to_string()));
let history_cell = tokio::time::timeout(std::time::Duration::from_secs(2), async {
loop {
match rx.recv().await {
Some(AppEvent::InsertHistoryCell(cell)) => break cell,
Some(_) => continue,
None => panic!("app event channel closed before forked thread history was emitted"),
}
}
})
.await
.expect("timed out waiting for forked thread history");
let combined = lines_to_single_string(&history_cell.display_lines(/*width*/ 80));
assert!(
combined.contains("Thread forked from"),
"expected forked thread message in history"
);
assert_chatwidget_snapshot!("forked_thread_history_line", combined);
}
#[tokio::test]
async fn forked_thread_history_line_without_name_shows_id_once_snapshot() {
let (chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
@@ -398,7 +428,7 @@ async fn forked_thread_history_line_without_name_shows_id_once_snapshot() {
let forked_from_id =
ThreadId::from_string("019c2d47-4935-7423-a190-05691f566092").expect("forked id");
chat.emit_forked_thread_event(forked_from_id);
chat.emit_forked_thread_event(forked_from_id, /*fork_parent_title*/ None);
let history_cell = tokio::time::timeout(std::time::Duration::from_secs(2), async {
loop {
@@ -416,6 +446,88 @@ async fn forked_thread_history_line_without_name_shows_id_once_snapshot() {
assert_chatwidget_snapshot!("forked_thread_history_line_without_name", combined);
}
#[tokio::test]
async fn app_server_forked_thread_history_line_uses_app_server_title_snapshot() {
let (chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let mut chat = chat;
let temp = tempdir().expect("tempdir");
chat.config.codex_home =
codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(temp.path())
.expect("temp dir is absolute");
let forked_from_id =
ThreadId::from_string("e9f18a88-8081-4e51-9d4e-8af5cde2d8dd").expect("forked id");
let session_index_entry = format!(
"{{\"id\":\"{forked_from_id}\",\"thread_name\":\"stale-local-thread\",\"updated_at\":\"2024-01-02T00:00:00Z\"}}\n"
);
std::fs::write(temp.path().join("session_index.jsonl"), session_index_entry)
.expect("write session index");
chat.emit_forked_thread_event(forked_from_id, Some("app-server-parent-thread".to_string()));
let history_cell = tokio::time::timeout(std::time::Duration::from_secs(2), async {
loop {
match rx.recv().await {
Some(AppEvent::InsertHistoryCell(cell)) => break cell,
Some(_) => continue,
None => panic!("app event channel closed before forked thread history was emitted"),
}
}
})
.await
.expect("timed out waiting for forked thread history");
let combined = lines_to_single_string(&history_cell.display_lines(/*width*/ 80));
assert!(combined.contains("app-server-parent-thread"));
assert!(
!combined.contains("stale-local-thread"),
"app-server fork title lookup should not read local CODEX_HOME"
);
assert_chatwidget_snapshot!("app_server_forked_thread_history_line", combined);
}
#[tokio::test]
async fn app_server_forked_thread_history_line_without_app_server_name_ignores_local_snapshot() {
let (chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let mut chat = chat;
let temp = tempdir().expect("tempdir");
chat.config.codex_home =
codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(temp.path())
.expect("temp dir is absolute");
let forked_from_id =
ThreadId::from_string("019c2d47-4935-7423-a190-05691f566092").expect("forked id");
let session_index_entry = format!(
"{{\"id\":\"{forked_from_id}\",\"thread_name\":\"stale-local-thread\",\"updated_at\":\"2024-01-02T00:00:00Z\"}}\n"
);
std::fs::write(temp.path().join("session_index.jsonl"), session_index_entry)
.expect("write session index");
chat.emit_forked_thread_event(forked_from_id, /*fork_parent_title*/ None);
let history_cell = tokio::time::timeout(std::time::Duration::from_secs(2), async {
loop {
match rx.recv().await {
Some(AppEvent::InsertHistoryCell(cell)) => break cell,
Some(_) => continue,
None => panic!("app event channel closed before forked thread history was emitted"),
}
}
})
.await
.expect("timed out waiting for forked thread history");
let combined = lines_to_single_string(&history_cell.display_lines(/*width*/ 80));
assert!(
!combined.contains("stale-local-thread"),
"app-server fork title lookup should not read local CODEX_HOME"
);
assert_chatwidget_snapshot!(
"app_server_forked_thread_history_line_without_app_server_name",
combined
);
}
#[tokio::test]
async fn thread_snapshot_replay_preserves_agent_message_during_review_mode() {
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
+1 -1
View File
@@ -9,7 +9,7 @@ async fn forked_thread_history_line_without_name_shows_id_once_snapshot() {
let forked_from_id =
ThreadId::from_string("019c2d47-4935-7423-a190-05691f566092").expect("forked id");
chat.emit_forked_thread_event(forked_from_id);
chat.emit_forked_thread_event(forked_from_id, /*fork_parent_title*/ None);
let history_cell = tokio::time::timeout(std::time::Duration::from_secs(2), async {
loop {