mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
Fixes #22300. ## Summary MCP tool calls can appear in thread history while still in progress. During replay, `handle_thread_item` routed every `ThreadItem::McpToolCall` to the completion handler, so an in-progress item with no result or error was rendered as `MCP tool call completed without a result`. This updates replay handling to mirror command executions: `InProgress` MCP calls go through `on_mcp_tool_call_started`, while completed and failed calls continue through the completion path. ## Validation - `cargo test -p codex-tui replayed_in_progress_mcp_tool_call_stays_active`
201 lines
7.6 KiB
Rust
201 lines
7.6 KiB
Rust
//! Thread replay rendering for `ChatWidget`.
|
|
//!
|
|
//! This module rehydrates turns and items into transcript state while avoiding
|
|
//! live-only side effects.
|
|
|
|
use super::*;
|
|
|
|
impl ChatWidget {
|
|
/// Replay a subset of initial events into the UI to seed the transcript when
|
|
/// resuming an existing session. This approximates the live event flow and
|
|
/// is intentionally conservative: only safe-to-replay items are rendered to
|
|
/// avoid triggering side effects. Event ids are passed as `None` to
|
|
/// distinguish replayed events from live ones.
|
|
pub(crate) fn replay_thread_turns(&mut self, turns: Vec<Turn>, replay_kind: ReplayKind) {
|
|
for turn in turns {
|
|
let Turn {
|
|
id: turn_id,
|
|
items_view: _,
|
|
items,
|
|
status,
|
|
error,
|
|
started_at,
|
|
completed_at,
|
|
duration_ms,
|
|
} = turn;
|
|
if matches!(status, TurnStatus::InProgress) {
|
|
self.last_non_retry_error = None;
|
|
self.on_task_started();
|
|
}
|
|
for item in items {
|
|
self.replay_thread_item(item, turn_id.clone(), replay_kind);
|
|
}
|
|
if matches!(
|
|
status,
|
|
TurnStatus::Completed | TurnStatus::Interrupted | TurnStatus::Failed
|
|
) {
|
|
self.handle_turn_completed_notification(
|
|
TurnCompletedNotification {
|
|
thread_id: self.thread_id.map(|id| id.to_string()).unwrap_or_default(),
|
|
turn: Turn {
|
|
id: turn_id,
|
|
items_view: codex_app_server_protocol::TurnItemsView::NotLoaded,
|
|
items: Vec::new(),
|
|
status,
|
|
error,
|
|
started_at,
|
|
completed_at,
|
|
duration_ms,
|
|
},
|
|
},
|
|
Some(replay_kind),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn replay_thread_item(
|
|
&mut self,
|
|
item: ThreadItem,
|
|
turn_id: String,
|
|
replay_kind: ReplayKind,
|
|
) {
|
|
self.handle_thread_item(item, turn_id, ThreadItemRenderSource::Replay(replay_kind));
|
|
}
|
|
|
|
pub(super) fn handle_thread_item(
|
|
&mut self,
|
|
item: ThreadItem,
|
|
turn_id: String,
|
|
render_source: ThreadItemRenderSource,
|
|
) {
|
|
let from_replay = render_source.is_replay();
|
|
let replay_kind = render_source.replay_kind();
|
|
match item {
|
|
ThreadItem::UserMessage { content, .. } => {
|
|
self.on_committed_user_message(&content, from_replay);
|
|
}
|
|
ThreadItem::AgentMessage {
|
|
id,
|
|
text,
|
|
phase,
|
|
memory_citation,
|
|
} => {
|
|
self.on_agent_message_item_completed(
|
|
AgentMessageItem {
|
|
id,
|
|
content: vec![AgentMessageContent::Text { text }],
|
|
phase,
|
|
memory_citation: memory_citation.map(|citation| {
|
|
codex_protocol::memory_citation::MemoryCitation {
|
|
entries: citation
|
|
.entries
|
|
.into_iter()
|
|
.map(|entry| {
|
|
codex_protocol::memory_citation::MemoryCitationEntry {
|
|
path: entry.path,
|
|
line_start: entry.line_start,
|
|
line_end: entry.line_end,
|
|
note: entry.note,
|
|
}
|
|
})
|
|
.collect(),
|
|
rollout_ids: citation.thread_ids,
|
|
}
|
|
}),
|
|
},
|
|
from_replay,
|
|
);
|
|
}
|
|
ThreadItem::Plan { text, .. } => self.on_plan_item_completed(text),
|
|
ThreadItem::Reasoning {
|
|
summary, content, ..
|
|
} => {
|
|
if from_replay {
|
|
for delta in summary {
|
|
self.on_agent_reasoning_delta(delta);
|
|
}
|
|
if self.config.show_raw_agent_reasoning {
|
|
for delta in content {
|
|
self.on_agent_reasoning_delta(delta);
|
|
}
|
|
}
|
|
}
|
|
self.on_agent_reasoning_final();
|
|
}
|
|
item @ ThreadItem::CommandExecution {
|
|
status: codex_app_server_protocol::CommandExecutionStatus::InProgress,
|
|
..
|
|
} => self.on_command_execution_started(item),
|
|
item @ ThreadItem::CommandExecution { .. } => self.on_command_execution_completed(item),
|
|
ThreadItem::FileChange {
|
|
status: codex_app_server_protocol::PatchApplyStatus::InProgress,
|
|
..
|
|
} => {}
|
|
item @ ThreadItem::FileChange { .. } => self.on_file_change_completed(item),
|
|
item @ ThreadItem::McpToolCall {
|
|
status: codex_app_server_protocol::McpToolCallStatus::InProgress,
|
|
..
|
|
} => self.on_mcp_tool_call_started(item),
|
|
item @ ThreadItem::McpToolCall { .. } => self.on_mcp_tool_call_completed(item),
|
|
ThreadItem::WebSearch { id, query, action } => {
|
|
self.on_web_search_begin(id.clone());
|
|
self.on_web_search_end(
|
|
id,
|
|
query,
|
|
action.unwrap_or(codex_app_server_protocol::WebSearchAction::Other),
|
|
);
|
|
}
|
|
ThreadItem::ImageView { id: _, path } => {
|
|
self.on_view_image_tool_call(path);
|
|
}
|
|
ThreadItem::ImageGeneration {
|
|
id,
|
|
revised_prompt,
|
|
saved_path,
|
|
..
|
|
} => {
|
|
self.on_image_generation_end(id, revised_prompt, saved_path);
|
|
}
|
|
ThreadItem::EnteredReviewMode { review, .. } => {
|
|
if from_replay {
|
|
self.enter_review_mode_with_hint(review, /*from_replay*/ true);
|
|
}
|
|
}
|
|
ThreadItem::ExitedReviewMode { .. } => {
|
|
self.exit_review_mode_after_item();
|
|
}
|
|
ThreadItem::ContextCompaction { .. } => {
|
|
self.add_info_message("Context compacted".to_string(), /*hint*/ None);
|
|
}
|
|
ThreadItem::HookPrompt { .. } => {}
|
|
ThreadItem::CollabAgentToolCall {
|
|
id,
|
|
tool,
|
|
status,
|
|
sender_thread_id,
|
|
receiver_thread_ids,
|
|
prompt,
|
|
model,
|
|
reasoning_effort,
|
|
agents_states,
|
|
} => self.on_collab_agent_tool_call(ThreadItem::CollabAgentToolCall {
|
|
id,
|
|
tool,
|
|
status,
|
|
sender_thread_id,
|
|
receiver_thread_ids,
|
|
prompt,
|
|
model,
|
|
reasoning_effort,
|
|
agents_states,
|
|
}),
|
|
ThreadItem::DynamicToolCall { .. } => {}
|
|
}
|
|
|
|
if matches!(replay_kind, Some(ReplayKind::ThreadSnapshot)) && turn_id.is_empty() {
|
|
self.request_redraw();
|
|
}
|
|
}
|
|
}
|