From 3a951f809651ddf876cf9b8ea565e55985942b98 Mon Sep 17 00:00:00 2001 From: Jack Mousseau Date: Thu, 19 Feb 2026 09:56:56 -0800 Subject: [PATCH] Restore phase when loading from history (#12244) --- .../schema/json/EventMsg.json | 22 +++++++++ .../schema/json/ServerNotification.json | 11 +++++ .../codex_app_server_protocol.schemas.json | 11 +++++ .../json/v1/ForkConversationResponse.json | 11 +++++ .../json/v1/ResumeConversationResponse.json | 11 +++++ .../v1/SessionConfiguredNotification.json | 11 +++++ .../schema/typescript/AgentMessageEvent.ts | 3 +- .../src/protocol/thread_history.rs | 46 +++++++++++++++++-- codex-rs/core/src/rollout/recorder.rs | 1 + .../src/event_processor_with_human_output.rs | 2 +- .../tests/event_processor_with_json_output.rs | 1 + codex-rs/protocol/src/items.rs | 3 +- codex-rs/protocol/src/protocol.rs | 3 ++ codex-rs/tui/src/chatwidget.rs | 4 +- codex-rs/tui/src/chatwidget/tests.rs | 13 +++++- 15 files changed, 144 insertions(+), 9 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/EventMsg.json b/codex-rs/app-server-protocol/schema/json/EventMsg.json index f38c9b994..5ca7c850f 100644 --- a/codex-rs/app-server-protocol/schema/json/EventMsg.json +++ b/codex-rs/app-server-protocol/schema/json/EventMsg.json @@ -649,6 +649,17 @@ "message": { "type": "string" }, + "phase": { + "anyOf": [ + { + "$ref": "#/definitions/MessagePhase" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "agent_message" @@ -5596,6 +5607,17 @@ "message": { "type": "string" }, + "phase": { + "anyOf": [ + { + "$ref": "#/definitions/MessagePhase" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "agent_message" diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 5bd76ffee..f3e71b70e 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -1465,6 +1465,17 @@ "message": { "type": "string" }, + "phase": { + "anyOf": [ + { + "$ref": "#/definitions/MessagePhase2" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "agent_message" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 741f50da1..06b0c773f 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -2693,6 +2693,17 @@ "message": { "type": "string" }, + "phase": { + "anyOf": [ + { + "$ref": "#/definitions/v2/MessagePhase" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "agent_message" diff --git a/codex-rs/app-server-protocol/schema/json/v1/ForkConversationResponse.json b/codex-rs/app-server-protocol/schema/json/v1/ForkConversationResponse.json index f460c4a28..c7018d5b7 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/ForkConversationResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v1/ForkConversationResponse.json @@ -649,6 +649,17 @@ "message": { "type": "string" }, + "phase": { + "anyOf": [ + { + "$ref": "#/definitions/MessagePhase" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "agent_message" diff --git a/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationResponse.json b/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationResponse.json index a39f1fd8e..2d7cc7e70 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationResponse.json @@ -649,6 +649,17 @@ "message": { "type": "string" }, + "phase": { + "anyOf": [ + { + "$ref": "#/definitions/MessagePhase" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "agent_message" diff --git a/codex-rs/app-server-protocol/schema/json/v1/SessionConfiguredNotification.json b/codex-rs/app-server-protocol/schema/json/v1/SessionConfiguredNotification.json index 20f4771e5..efd614139 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/SessionConfiguredNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v1/SessionConfiguredNotification.json @@ -649,6 +649,17 @@ "message": { "type": "string" }, + "phase": { + "anyOf": [ + { + "$ref": "#/definitions/MessagePhase" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "agent_message" diff --git a/codex-rs/app-server-protocol/schema/typescript/AgentMessageEvent.ts b/codex-rs/app-server-protocol/schema/typescript/AgentMessageEvent.ts index ee436566e..b32680055 100644 --- a/codex-rs/app-server-protocol/schema/typescript/AgentMessageEvent.ts +++ b/codex-rs/app-server-protocol/schema/typescript/AgentMessageEvent.ts @@ -1,5 +1,6 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { MessagePhase } from "./MessagePhase"; -export type AgentMessageEvent = { message: string, }; +export type AgentMessageEvent = { message: string, phase: MessagePhase | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index bb8001032..e5005077e 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -16,6 +16,7 @@ use crate::protocol::v2::TurnError; use crate::protocol::v2::TurnStatus; use crate::protocol::v2::UserInput; use crate::protocol::v2::WebSearchAction; +use codex_protocol::models::MessagePhase as CoreMessagePhase; use codex_protocol::protocol::AgentReasoningEvent; use codex_protocol::protocol::AgentReasoningRawContentEvent; use codex_protocol::protocol::AgentStatus; @@ -81,7 +82,9 @@ impl ThreadHistoryBuilder { fn handle_event(&mut self, event: &EventMsg) { match event { EventMsg::UserMessage(payload) => self.handle_user_message(payload), - EventMsg::AgentMessage(payload) => self.handle_agent_message(payload.message.clone()), + EventMsg::AgentMessage(payload) => { + self.handle_agent_message(payload.message.clone(), payload.phase.clone()) + } EventMsg::AgentReasoning(payload) => self.handle_agent_reasoning(payload), EventMsg::AgentReasoningRawContent(payload) => { self.handle_agent_reasoning_raw_content(payload) @@ -143,7 +146,7 @@ impl ThreadHistoryBuilder { self.current_turn = Some(turn); } - fn handle_agent_message(&mut self, text: String) { + fn handle_agent_message(&mut self, text: String, phase: Option) { if text.is_empty() { return; } @@ -152,7 +155,7 @@ impl ThreadHistoryBuilder { self.ensure_turn().items.push(ThreadItem::AgentMessage { id, text, - phase: None, + phase: phase.map(Into::into), }); } @@ -758,6 +761,7 @@ impl From for Turn { mod tests { use super::*; use codex_protocol::ThreadId; + use codex_protocol::models::MessagePhase as CoreMessagePhase; use codex_protocol::models::WebSearchAction as CoreWebSearchAction; use codex_protocol::parse_command::ParsedCommand; use codex_protocol::protocol::AgentMessageEvent; @@ -792,6 +796,7 @@ mod tests { }), EventMsg::AgentMessage(AgentMessageEvent { message: "Hi there".into(), + phase: None, }), EventMsg::AgentReasoning(AgentReasoningEvent { text: "thinking".into(), @@ -807,6 +812,7 @@ mod tests { }), EventMsg::AgentMessage(AgentMessageEvent { message: "Reply two".into(), + phase: None, }), ]; @@ -877,6 +883,29 @@ mod tests { ); } + #[test] + fn preserves_agent_message_phase_in_history() { + let events = vec![EventMsg::AgentMessage(AgentMessageEvent { + message: "Final reply".into(), + phase: Some(CoreMessagePhase::FinalAnswer), + })]; + + let items = events + .into_iter() + .map(RolloutItem::EventMsg) + .collect::>(); + let turns = build_turns_from_rollout_items(&items); + assert_eq!(turns.len(), 1); + assert_eq!( + turns[0].items[0], + ThreadItem::AgentMessage { + id: "item-1".into(), + text: "Final reply".into(), + phase: Some(crate::protocol::v2::MessagePhase::FinalAnswer), + } + ); + } + #[test] fn splits_reasoning_when_interleaved() { let events = vec![ @@ -894,6 +923,7 @@ mod tests { }), EventMsg::AgentMessage(AgentMessageEvent { message: "interlude".into(), + phase: None, }), EventMsg::AgentReasoning(AgentReasoningEvent { text: "second summary".into(), @@ -938,6 +968,7 @@ mod tests { }), EventMsg::AgentMessage(AgentMessageEvent { message: "Working...".into(), + phase: None, }), EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-1".into()), @@ -951,6 +982,7 @@ mod tests { }), EventMsg::AgentMessage(AgentMessageEvent { message: "Second attempt complete.".into(), + phase: None, }), ]; @@ -1017,6 +1049,7 @@ mod tests { }), EventMsg::AgentMessage(AgentMessageEvent { message: "A1".into(), + phase: None, }), EventMsg::UserMessage(UserMessageEvent { message: "Second".into(), @@ -1026,6 +1059,7 @@ mod tests { }), EventMsg::AgentMessage(AgentMessageEvent { message: "A2".into(), + phase: None, }), EventMsg::ThreadRolledBack(ThreadRolledBackEvent { num_turns: 1 }), EventMsg::UserMessage(UserMessageEvent { @@ -1036,6 +1070,7 @@ mod tests { }), EventMsg::AgentMessage(AgentMessageEvent { message: "A3".into(), + phase: None, }), ]; @@ -1097,6 +1132,7 @@ mod tests { }), EventMsg::AgentMessage(AgentMessageEvent { message: "A1".into(), + phase: None, }), EventMsg::UserMessage(UserMessageEvent { message: "Two".into(), @@ -1106,6 +1142,7 @@ mod tests { }), EventMsg::AgentMessage(AgentMessageEvent { message: "A2".into(), + phase: None, }), EventMsg::ThreadRolledBack(ThreadRolledBackEvent { num_turns: 99 }), ]; @@ -1469,6 +1506,7 @@ mod tests { }), EventMsg::AgentMessage(AgentMessageEvent { message: "still in b".into(), + phase: None, }), EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), @@ -1522,6 +1560,7 @@ mod tests { }), EventMsg::AgentMessage(AgentMessageEvent { message: "still in b".into(), + phase: None, }), ]; @@ -1626,6 +1665,7 @@ mod tests { }), EventMsg::AgentMessage(AgentMessageEvent { message: "done".into(), + phase: None, }), EventMsg::Error(ErrorEvent { message: "rollback failed".into(), diff --git a/codex-rs/core/src/rollout/recorder.rs b/codex-rs/core/src/rollout/recorder.rs index d0df0828a..eab442743 100644 --- a/codex-rs/core/src/rollout/recorder.rs +++ b/codex-rs/core/src/rollout/recorder.rs @@ -1073,6 +1073,7 @@ mod tests { .record_items(&[RolloutItem::EventMsg(EventMsg::AgentMessage( AgentMessageEvent { message: "buffered-event".to_string(), + phase: None, }, ))]) .await?; diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index 26861af7c..f77a4ec16 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -299,7 +299,7 @@ impl EventProcessor for EventProcessorWithHumanOutput { ); } } - EventMsg::AgentMessage(AgentMessageEvent { message }) => { + EventMsg::AgentMessage(AgentMessageEvent { message, .. }) => { ts_msg!( self, "{}\n{}", diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index 46cd61545..35cb962ee 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -738,6 +738,7 @@ fn agent_message_produces_item_completed_agent_message() { "e1", EventMsg::AgentMessage(AgentMessageEvent { message: "hello".to_string(), + phase: None, }), ); let out = ep.collect_thread_events(&ev); diff --git a/codex-rs/protocol/src/items.rs b/codex-rs/protocol/src/items.rs index 35bed2ab4..9284bbddf 100644 --- a/codex-rs/protocol/src/items.rs +++ b/codex-rs/protocol/src/items.rs @@ -190,13 +190,12 @@ impl AgentMessageItem { } pub fn as_legacy_events(&self) -> Vec { - // Legacy events only preserve visible assistant text; `phase` has no - // representation in the v1 event stream. self.content .iter() .map(|c| match c { AgentMessageContent::Text { text } => EventMsg::AgentMessage(AgentMessageEvent { message: text.clone(), + phase: self.phase.clone(), }), }) .collect() diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 5676dc3e2..b7e4b547f 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -32,6 +32,7 @@ use crate::mcp::Tool as McpTool; use crate::message_history::HistoryEntry; use crate::models::BaseInstructions; use crate::models::ContentItem; +use crate::models::MessagePhase; use crate::models::ResponseItem; use crate::models::WebSearchAction; use crate::num_format::format_with_separators; @@ -1580,6 +1581,8 @@ impl fmt::Display for FinalOutput { #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct AgentMessageEvent { pub message: String, + #[serde(default)] + pub phase: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 6d112f5ad..9e746b4c5 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -3986,7 +3986,9 @@ impl ChatWidget { match msg { EventMsg::SessionConfigured(e) => self.on_session_configured(e), EventMsg::ThreadNameUpdated(e) => self.on_thread_name_updated(e), - EventMsg::AgentMessage(AgentMessageEvent { message }) => self.on_agent_message(message), + EventMsg::AgentMessage(AgentMessageEvent { message, .. }) => { + self.on_agent_message(message) + } EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => { self.on_agent_message_delta(delta) } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 99cb96407..15f1057cf 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -169,6 +169,7 @@ async fn resumed_initial_messages_render_history() { }), EventMsg::AgentMessage(AgentMessageEvent { message: "assistant reply".to_string(), + phase: None, }), ]), network_proxy: None, @@ -3322,6 +3323,7 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() { id: "turn-1".into(), msg: EventMsg::AgentMessage(AgentMessageEvent { message: "Final response.".into(), + phase: None, }), }); chat.handle_codex_event(Event { @@ -7013,6 +7015,7 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { id: "s1".into(), msg: EventMsg::AgentMessage(AgentMessageEvent { message: "First message".into(), + phase: None, }), }); @@ -7021,6 +7024,7 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { id: "s1".into(), msg: EventMsg::AgentMessage(AgentMessageEvent { message: "Second message".into(), + phase: None, }), }); @@ -7066,6 +7070,7 @@ async fn final_reasoning_then_message_without_deltas_are_rendered() { id: "s1".into(), msg: EventMsg::AgentMessage(AgentMessageEvent { message: "Here is the result.".into(), + phase: None, }), }); @@ -7126,6 +7131,7 @@ async fn deltas_then_same_final_message_are_rendered_snapshot() { id: "s1".into(), msg: EventMsg::AgentMessage(AgentMessageEvent { message: "Here is the result.".into(), + phase: None, }), }); @@ -7147,7 +7153,12 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; chat.handle_codex_event(Event { id: "t1".into(), - msg: EventMsg::AgentMessage(AgentMessageEvent { message: "I’m going to search the repo for where “Change Approved” is rendered to update that view.".into() }), + msg: EventMsg::AgentMessage(AgentMessageEvent { + message: + "I’m going to search the repo for where “Change Approved” is rendered to update that view." + .into(), + phase: None, + }), }); let command = vec!["bash".into(), "-lc".into(), "rg \"Change Approved\"".into()];