exec JSON events drop agent message `phase` (commentary vs final_answer)
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::AgentMessagecarriesphase: 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), andmap_item_with_idinevent_processor_with_jsonl_output.rsdestructuresThreadItem::AgentMessage { text, .. }— discardingphase.
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>
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗