fix(tui): restore remote resume and fork history (#14930)

## Problem

When the TUI connects to a **remote** app-server (via WebSocket), resume
and fork operations lost all conversation history.
`AppServerStartedThread` carried only the `SessionConfigured` event, not
the full `Thread` snapshot. After resume or fork, the chat transcript
was empty — prior turns were silently discarded.

A secondary issue: `primary_session_configured` was not cleared on
reset, causing stale session state after reconnection.

## Approach: TUI-side only, zero app-server changes

The app-server **already returns** the full `Thread` object (with
populated `turns: Vec<Turn>`) in its `ThreadStartResponse`,
`ThreadResumeResponse`, and `ThreadForkResponse`. The data was always
there — the TUI was simply throwing it away. The old
`AppServerStartedThread` struct only kept the `SessionConfiguredEvent`,
discarding the rich turn history that the server had already provided.

This PR fixes the problem entirely within `tui_app_server` (3 files
changed, 0 changes to `app-server`, `app-server-protocol`, or any other
crate). Rather than modifying the server to send history in a different
format or adding a new endpoint, the fix preserves the existing `Thread`
snapshot and replays it through the TUI's standard event pipeline —
making restored sessions indistinguishable from live ones.

## Solution

Add a **thread snapshot replay** path. When the server hands back a
`Thread` object (on start, resume, or fork),
`restore_started_app_server_thread` converts its historical turns into
the same core `Event` sequence the TUI already processes for live
interactions, then replays them into the event store so the chat widget
renders them.

Key changes:
- **`AppServerStartedThread` now carries the full `Thread`** —
`started_thread_from_{start,resume,fork}_response` clone the thread into
the struct alongside the existing `SessionConfiguredEvent`.
- **`thread_snapshot_events()`** walks the thread's turns and items,
producing `TurnStarted` → `ItemCompleted`* →
`TurnComplete`/`TurnAborted` event sequences that the TUI already knows
how to render.
- **`restore_started_app_server_thread()`** pushes the session event +
history events into the thread channel's store, activates the channel,
and replays the snapshot — used for initial startup, resume, and fork.
- **`primary_session_configured` cleared on reset** to prevent stale
session state after reconnection.

## Tradeoffs

- **`Thread` is cloned into `AppServerStartedThread`**: The full thread
snapshot (including all historical turns) is cloned at startup. For
long-lived threads this could be large, but it's a one-time cost and
avoids lifetime gymnastics with the response.

## Tests

- `restore_started_app_server_thread_replays_remote_history` —
end-to-end: constructs a `Thread` with one completed turn, restores it,
and asserts user/agent messages appear in the transcript.
- `bridges_thread_snapshot_turns_for_resume_restore` — unit: verifies
`thread_snapshot_events` produces the correct event sequence for
completed and interrupted turns.

## Test plan

- [ ] Verify `cargo check -p codex-tui-app-server` passes
- [ ] Verify `cargo test -p codex-tui-app-server` passes
- [ ] Manual: connect to a remote app-server, resume an existing thread,
confirm history renders in the chat widget
- [ ] Manual: fork a thread via remote, confirm prior turns appear
This commit is contained in:
Felipe Coury
2026-03-17 14:16:08 -03:00
committed by GitHub
Unverified
parent 8e258eb3f5
commit 78e8ee4591
5 changed files with 958 additions and 231 deletions
+38 -176
View File
@@ -55,15 +55,6 @@ use codex_app_server_protocol::TurnSteerResponse;
use codex_core::config::Config;
use codex_otel::TelemetryAuthMode;
use codex_protocol::ThreadId;
use codex_protocol::items::AgentMessageContent;
use codex_protocol::items::AgentMessageItem;
use codex_protocol::items::ContextCompactionItem;
use codex_protocol::items::ImageGenerationItem;
use codex_protocol::items::PlanItem;
use codex_protocol::items::ReasoningItem;
use codex_protocol::items::TurnItem;
use codex_protocol::items::UserMessageItem;
use codex_protocol::items::WebSearchItem;
use codex_protocol::openai_models::ModelAvailabilityNux;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::openai_models::ModelUpgrade;
@@ -73,8 +64,6 @@ use codex_protocol::protocol::ConversationAudioParams;
use codex_protocol::protocol::ConversationStartParams;
use codex_protocol::protocol::ConversationTextParams;
use codex_protocol::protocol::CreditsSnapshot;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::ItemCompletedEvent;
use codex_protocol::protocol::RateLimitSnapshot;
use codex_protocol::protocol::RateLimitWindow;
use codex_protocol::protocol::ReviewRequest;
@@ -123,8 +112,18 @@ impl ThreadParamsMode {
}
}
/// Result of starting, resuming, or forking an app-server thread.
///
/// Carries the full `Thread` snapshot returned by the server alongside the
/// derived `SessionConfiguredEvent`. The snapshot's `turns` are used by
/// `App::restore_started_app_server_thread` to seed the event store and
/// replay transcript history — this is the only source of prior-turn data
/// for remote sessions, where historical websocket notifications are not
/// re-sent after the handshake.
pub(crate) struct AppServerStartedThread {
pub(crate) thread: Thread,
pub(crate) session_configured: SessionConfiguredEvent,
pub(crate) show_raw_agent_reasoning: bool,
}
impl AppServerSession {
@@ -267,7 +266,7 @@ impl AppServerSession {
})
.await
.wrap_err("thread/start failed during TUI bootstrap")?;
started_thread_from_start_response(&response)
started_thread_from_start_response(response)
}
pub(crate) async fn resume_thread(
@@ -289,7 +288,7 @@ impl AppServerSession {
})
.await
.wrap_err("thread/resume failed during TUI bootstrap")?;
started_thread_from_resume_response(&response, show_raw_agent_reasoning)
started_thread_from_resume_response(response, show_raw_agent_reasoning)
}
pub(crate) async fn fork_thread(
@@ -311,7 +310,7 @@ impl AppServerSession {
})
.await
.wrap_err("thread/fork failed during TUI bootstrap")?;
started_thread_from_fork_response(&response, show_raw_agent_reasoning)
started_thread_from_fork_response(response, show_raw_agent_reasoning)
}
fn thread_params_mode(&self) -> ThreadParamsMode {
@@ -836,46 +835,42 @@ fn thread_cwd_from_config(config: &Config, thread_params_mode: ThreadParamsMode)
}
fn started_thread_from_start_response(
response: &ThreadStartResponse,
response: ThreadStartResponse,
) -> Result<AppServerStartedThread> {
let session_configured = session_configured_from_thread_start_response(response)
let session_configured = session_configured_from_thread_start_response(&response)
.map_err(color_eyre::eyre::Report::msg)?;
Ok(AppServerStartedThread { session_configured })
Ok(AppServerStartedThread {
thread: response.thread,
session_configured,
show_raw_agent_reasoning: false,
})
}
fn started_thread_from_resume_response(
response: &ThreadResumeResponse,
response: ThreadResumeResponse,
show_raw_agent_reasoning: bool,
) -> Result<AppServerStartedThread> {
let session_configured = session_configured_from_thread_resume_response(response)
let session_configured = session_configured_from_thread_resume_response(&response)
.map_err(color_eyre::eyre::Report::msg)?;
let thread = response.thread;
Ok(AppServerStartedThread {
session_configured: SessionConfiguredEvent {
initial_messages: thread_initial_messages(
&session_configured.session_id,
&response.thread.turns,
show_raw_agent_reasoning,
),
..session_configured
},
thread,
session_configured,
show_raw_agent_reasoning,
})
}
fn started_thread_from_fork_response(
response: &ThreadForkResponse,
response: ThreadForkResponse,
show_raw_agent_reasoning: bool,
) -> Result<AppServerStartedThread> {
let session_configured = session_configured_from_thread_fork_response(response)
let session_configured = session_configured_from_thread_fork_response(&response)
.map_err(color_eyre::eyre::Report::msg)?;
let thread = response.thread;
Ok(AppServerStartedThread {
session_configured: SessionConfiguredEvent {
initial_messages: thread_initial_messages(
&session_configured.session_id,
&response.thread.turns,
show_raw_agent_reasoning,
),
..session_configured
},
thread,
session_configured,
show_raw_agent_reasoning,
})
}
@@ -992,121 +987,6 @@ fn session_configured_from_thread_response(
})
}
fn thread_initial_messages(
thread_id: &ThreadId,
turns: &[codex_app_server_protocol::Turn],
show_raw_agent_reasoning: bool,
) -> Option<Vec<EventMsg>> {
let events: Vec<EventMsg> = turns
.iter()
.flat_map(|turn| turn_initial_messages(thread_id, turn, show_raw_agent_reasoning))
.collect();
(!events.is_empty()).then_some(events)
}
fn turn_initial_messages(
thread_id: &ThreadId,
turn: &codex_app_server_protocol::Turn,
show_raw_agent_reasoning: bool,
) -> Vec<EventMsg> {
turn.items
.iter()
.cloned()
.filter_map(app_server_thread_item_to_core)
.flat_map(|item| match item {
TurnItem::UserMessage(item) => vec![item.as_legacy_event()],
TurnItem::Plan(item) => vec![EventMsg::ItemCompleted(ItemCompletedEvent {
thread_id: *thread_id,
turn_id: turn.id.clone(),
item: TurnItem::Plan(item),
})],
item => item.as_legacy_events(show_raw_agent_reasoning),
})
.collect()
}
fn app_server_thread_item_to_core(item: codex_app_server_protocol::ThreadItem) -> Option<TurnItem> {
match item {
codex_app_server_protocol::ThreadItem::UserMessage { id, content } => {
Some(TurnItem::UserMessage(UserMessageItem {
id,
content: content
.into_iter()
.map(codex_app_server_protocol::UserInput::into_core)
.collect(),
}))
}
codex_app_server_protocol::ThreadItem::AgentMessage { id, text, phase } => {
Some(TurnItem::AgentMessage(AgentMessageItem {
id,
content: vec![AgentMessageContent::Text { text }],
phase,
}))
}
codex_app_server_protocol::ThreadItem::Plan { id, text } => {
Some(TurnItem::Plan(PlanItem { id, text }))
}
codex_app_server_protocol::ThreadItem::Reasoning {
id,
summary,
content,
} => Some(TurnItem::Reasoning(ReasoningItem {
id,
summary_text: summary,
raw_content: content,
})),
codex_app_server_protocol::ThreadItem::WebSearch { id, query, action } => {
Some(TurnItem::WebSearch(WebSearchItem {
id,
query,
action: app_server_web_search_action_to_core(action?)?,
}))
}
codex_app_server_protocol::ThreadItem::ImageGeneration {
id,
status,
revised_prompt,
result,
} => Some(TurnItem::ImageGeneration(ImageGenerationItem {
id,
status,
revised_prompt,
result,
saved_path: None,
})),
codex_app_server_protocol::ThreadItem::ContextCompaction { id } => {
Some(TurnItem::ContextCompaction(ContextCompactionItem { id }))
}
codex_app_server_protocol::ThreadItem::CommandExecution { .. }
| codex_app_server_protocol::ThreadItem::FileChange { .. }
| codex_app_server_protocol::ThreadItem::McpToolCall { .. }
| codex_app_server_protocol::ThreadItem::DynamicToolCall { .. }
| codex_app_server_protocol::ThreadItem::CollabAgentToolCall { .. }
| codex_app_server_protocol::ThreadItem::ImageView { .. }
| codex_app_server_protocol::ThreadItem::EnteredReviewMode { .. }
| codex_app_server_protocol::ThreadItem::ExitedReviewMode { .. } => None,
}
}
fn app_server_web_search_action_to_core(
action: codex_app_server_protocol::WebSearchAction,
) -> Option<codex_protocol::models::WebSearchAction> {
match action {
codex_app_server_protocol::WebSearchAction::Search { query, queries } => {
Some(codex_protocol::models::WebSearchAction::Search { query, queries })
}
codex_app_server_protocol::WebSearchAction::OpenPage { url } => {
Some(codex_protocol::models::WebSearchAction::OpenPage { url })
}
codex_app_server_protocol::WebSearchAction::FindInPage { url, pattern } => {
Some(codex_protocol::models::WebSearchAction::FindInPage { url, pattern })
}
codex_app_server_protocol::WebSearchAction::Other => {
Some(codex_protocol::models::WebSearchAction::Other)
}
}
}
fn app_server_rate_limit_snapshots_to_core(
response: GetAccountRateLimitsResponse,
) -> Vec<RateLimitSnapshot> {
@@ -1204,7 +1084,7 @@ mod tests {
}
#[test]
fn resume_response_restores_initial_messages_from_turn_items() {
fn resume_response_relies_on_snapshot_replay_not_initial_messages() {
let thread_id = ThreadId::new();
let response = ThreadResumeResponse {
thread: codex_app_server_protocol::Thread {
@@ -1254,29 +1134,11 @@ mod tests {
};
let started =
started_thread_from_resume_response(&response, /*show_raw_agent_reasoning*/ false)
started_thread_from_resume_response(response, /*show_raw_agent_reasoning*/ false)
.expect("resume response should map");
let initial_messages = started
.session_configured
.initial_messages
.expect("resume response should restore replay history");
assert_eq!(initial_messages.len(), 2);
match &initial_messages[0] {
EventMsg::UserMessage(event) => {
assert_eq!(event.message, "hello from history");
assert_eq!(event.images.as_ref(), Some(&Vec::new()));
assert!(event.local_images.is_empty());
assert!(event.text_elements.is_empty());
}
other => panic!("expected replayed user message, got {other:?}"),
}
match &initial_messages[1] {
EventMsg::AgentMessage(event) => {
assert_eq!(event.message, "assistant reply");
assert_eq!(event.phase, None);
}
other => panic!("expected replayed agent message, got {other:?}"),
}
assert!(started.session_configured.initial_messages.is_none());
assert!(!started.show_raw_agent_reasoning);
assert_eq!(started.thread.turns.len(), 1);
assert_eq!(started.thread.turns[0].items.len(), 2);
}
}