mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
committed by
GitHub
Unverified
parent
8e258eb3f5
commit
78e8ee4591
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user