exec JSON events drop agent message `phase` (commentary vs final_answer)

Open 💬 1 comment Opened Jun 26, 2026 by jaxxjj

What

codex exec JSON events (and the @openai/codex-sdk that wraps them) emit agent_message items carrying only text. The phase field that distinguishes interim commentary (mid-turn preamble / progress narration) from the final_answer is dropped, even though it already exists everywhere upstream.

  • The internal codex_app_server_protocol::ThreadItem::AgentMessage carries phase: Option<MessagePhase> (and the app-server v2 protocol exposes it: agentMessage { id, text, phase, memoryCitation }).
  • But the exec surface defines AgentMessageItem { text } (codex-rs/exec/src/exec_events.rs), and map_item_with_id in event_processor_with_jsonl_output.rs destructures ThreadItem::AgentMessage { text, .. } — discarding phase.

So the exec/SDK path is strictly lower-fidelity than the app-server v2 path for the same item.

Why

Downstream SDK consumers that stream a Codex turn to a chat/UI surface cannot tell an interim commentary message apart from the final answer. They either surface the preamble narration to end users, or have to heuristically guess (which is unreliable — commentary is normal output text, not reasoning). The information is present in the model output and on the v2 protocol; it's only the exec event schema that omits it.

Proposal

Thread phase through the exec AgentMessageItem as Option<MessagePhase>, #[serde(default, skip_serializing_if = "Option::is_none")] so it is fully backward compatible — the field is simply absent from the JSON when the model did not report a phase, and existing consumers are unaffected. Mirror it in the hand-written TS SDK types (sdk/typescript/src/items.ts).

I have a small patch ready (5 files, +53/-3, incl. tests) and am happy to open a PR if invited per the contribution policy.

<details>
<summary>Proposed patch</summary>

diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output.rs b/codex-rs/exec/src/event_processor_with_jsonl_output.rs
index 79bf88eaa..b2a6fdaba 100644
--- a/codex-rs/exec/src/event_processor_with_jsonl_output.rs
+++ b/codex-rs/exec/src/event_processor_with_jsonl_output.rs
@@ -143,9 +143,9 @@ impl EventProcessorWithJsonOutput {
         make_id: impl FnOnce() -> String,
     ) -> Option<ExecThreadItem> {
         match item {
-            ThreadItem::AgentMessage { text, .. } => Some(ExecThreadItem {
+            ThreadItem::AgentMessage { text, phase, .. } => Some(ExecThreadItem {
                 id: make_id(),
-                details: ThreadItemDetails::AgentMessage(AgentMessageItem { text }),
+                details: ThreadItemDetails::AgentMessage(AgentMessageItem { text, phase }),
             }),
             ThreadItem::Reasoning { summary, .. } => {
                 let text = summary.join("\n");
@@ -473,7 +473,7 @@ impl EventProcessorWithJsonOutput {
             }
             ServerNotification::ItemCompleted(notification) => {
                 if let Some(item) = self.map_completed_item_mut(notification.item) {
-                    if let ThreadItemDetails::AgentMessage(AgentMessageItem { text }) =
+                    if let ThreadItemDetails::AgentMessage(AgentMessageItem { text, .. }) =
                         &item.details
                     {
                         self.final_message = Some(text.clone());
diff --git a/codex-rs/exec/src/exec_events.rs b/codex-rs/exec/src/exec_events.rs
index 078a876ef..8ea19772b 100644
--- a/codex-rs/exec/src/exec_events.rs
+++ b/codex-rs/exec/src/exec_events.rs
@@ -1,3 +1,4 @@
+use codex_protocol::models::MessagePhase;
 use codex_protocol::models::WebSearchAction;
 use serde::Deserialize;
 use serde::Serialize;
@@ -134,6 +135,11 @@ pub enum ThreadItemDetails {
 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
 pub struct AgentMessageItem {
     pub text: String,
+    /// Classifies the message as interim commentary or the final answer for
+    /// the turn. Omitted when the model did not report a phase.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    #[ts(optional)]
+    pub phase: Option<MessagePhase>,
 }
 
 /// Agent's reasoning summary.
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 1f44ca31a..f6e003b2d 100644
--- a/codex-rs/exec/tests/event_processor_with_json_output.rs
+++ b/codex-rs/exec/tests/event_processor_with_json_output.rs
@@ -29,6 +29,7 @@ use codex_app_server_protocol::TurnStatus;
 use codex_app_server_protocol::WebSearchAction as ApiWebSearchAction;
 use codex_protocol::SessionId;
 use codex_protocol::ThreadId;
+use codex_protocol::models::MessagePhase;
 use codex_protocol::models::PermissionProfile;
 use codex_protocol::models::WebSearchAction;
 use codex_protocol::protocol::AskForApproval;
@@ -315,6 +316,7 @@ fn unsupported_items_do_not_consume_synthetic_ids() {
                     id: "item_0".to_string(),
                     details: ThreadItemDetails::AgentMessage(AgentMessageItem {
                         text: "hello".to_string(),
+                        phase: None,
                     }),
                 },
             })],
@@ -928,6 +930,7 @@ fn agent_message_item_updates_final_message() {
                     id: "item_0".to_string(),
                     details: ThreadItemDetails::AgentMessage(AgentMessageItem {
                         text: "hello".to_string(),
+                        phase: None,
                     }),
                 },
             })],
@@ -937,6 +940,41 @@ fn agent_message_item_updates_final_message() {
     assert_eq!(processor.final_message(), Some("hello"));
 }
 
+#[test]
+fn agent_message_item_surfaces_phase() {
+    let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None);
+
+    let collected = processor.collect_thread_events(ServerNotification::ItemCompleted(
+        ItemCompletedNotification {
+            item: ThreadItem::AgentMessage {
+                id: "msg-1".to_string(),
+                text: "preamble".to_string(),
+                phase: Some(MessagePhase::Commentary),
+                memory_citation: None,
+            },
+            thread_id: "thread-1".to_string(),
+            turn_id: "turn-1".to_string(),
+            completed_at_ms: 0,
+        },
+    ));
+
+    assert_eq!(
+        collected,
+        CollectedThreadEvents {
+            events: vec![ThreadEvent::ItemCompleted(ItemCompletedEvent {
+                item: ExecThreadItem {
+                    id: "item_0".to_string(),
+                    details: ThreadItemDetails::AgentMessage(AgentMessageItem {
+                        text: "preamble".to_string(),
+                        phase: Some(MessagePhase::Commentary),
+                    }),
+                },
+            })],
+            status: CodexStatus::Running,
+        }
+    );
+}
+
 #[test]
 fn agent_message_item_started_is_ignored() {
     let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None);
diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts
index a74f391a7..0a2def517 100644
--- a/sdk/typescript/src/index.ts
+++ b/sdk/typescript/src/index.ts
@@ -14,6 +14,7 @@ export type {
 export type {
   ThreadItem,
   AgentMessageItem,
+  MessagePhase,
   ReasoningItem,
   CommandExecutionItem,
   FileChangeItem,
diff --git a/sdk/typescript/src/items.ts b/sdk/typescript/src/items.ts
index 8fd3b2c7f..02628a05e 100644
--- a/sdk/typescript/src/items.ts
+++ b/sdk/typescript/src/items.ts
@@ -71,12 +71,17 @@ export type McpToolCallItem = {
   status: McpToolCallStatus;
 };
 
+/** Classifies an assistant message as interim commentary or the final answer for the turn. */
+export type MessagePhase = "commentary" | "final_answer";
+
 /** Response from the agent. Either natural-language text or JSON when structured output is requested. */
 export type AgentMessageItem = {
   id: string;
   type: "agent_message";
   /** Either natural-language text or JSON when structured output is requested. */
   text: string;
+  /** Phase of the message; omitted when the model did not report one. */
+  phase?: MessagePhase;
 };
 
 /** Agent's reasoning summary. */

</details>

View original on GitHub ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗