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

Open 💬 2 comments 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 ↗

2 Comments

Nicolas0315 · 2 months ago

I pushed an equivalent fix branch here:

https://github.com/openai/codex/compare/main...Nicolas0315:codex:codex/exec-agent-message-phase?expand=1

Change summary:

  • Adds optional phase: Option<MessagePhase> to exec AgentMessageItem, omitted from JSON when absent.
  • Threads ThreadItem::AgentMessage.phase through the JSONL event mapper.
  • Keeps final-message extraction based only on text.
  • Mirrors the optional phase?: MessagePhase type in the hand-written TypeScript SDK item types and exports MessagePhase.
  • Adds a focused regression test for MessagePhase::Commentary surfacing in codex exec JSON events.

Verification:

  • rtx4090 local: just fmt passed.
  • rtx4090 local: git diff --check passed.
  • rtx4090 local: cargo test --manifest-path codex-rs\Cargo.toml -p codex-exec agent_message_item_ passed; 3 focused tests passed.
  • rtx4090 local: cargo check --manifest-path codex-rs\Cargo.toml -p codex-exec passed.
  • rtx4090 local: sdk/typescript direct tsup build passed and generated declarations. pnpm --dir sdk/typescript build ran tsup successfully during prepare, then exited on the local pnpm trusted-build policy for esbuild; generated pnpm metadata was reverted.
  • cyberagent-mac-m5max over SSH: fresh checkout verified eaad321bc over base 5267e805f; git diff --check passed; cargo test --manifest-path codex-rs/Cargo.toml -p codex-exec agent_message_item_ passed in 3m01s.
  • nicolas-macbook-pro over SSH: fresh checkout verified the same one-commit diff and git diff --check passed. Rust toolchain is not installed there.
  • rtx5060ti over SSH: fresh checkout verified the same one-commit diff and git diff --check passed. cargo check is blocked by the host linker/toolchain issue (link.exe rejects MSVC-style arguments with extra operand ...).
  • nicolas2025 over SSH: fresh checkout verified the same one-commit diff and git diff --check passed. No usable cargo/rustc output on that host.
ropepop · 17 days ago

Supporting the proposal. phase is genuinely load-bearing for rendering: with chat-completions third-party providers, the interim commentary vs final answer distinction is what lets the desktop app render the expandable reasoning block separately from the final response. Any surface that drops phase (exec JSON events, or the mobile remote path per #37598) loses that distinction — users then can't see the middle steps remotely. Threading phase through as Option<MessagePhase> with serde default/skip would fix the exec/SDK fidelity gap cleanly.