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>
2 Comments
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:
phase: Option<MessagePhase>to execAgentMessageItem, omitted from JSON when absent.ThreadItem::AgentMessage.phasethrough the JSONL event mapper.text.phase?: MessagePhasetype in the hand-written TypeScript SDK item types and exportsMessagePhase.MessagePhase::Commentarysurfacing incodex execJSON events.Verification:
just fmtpassed.git diff --checkpassed.cargo test --manifest-path codex-rs\Cargo.toml -p codex-exec agent_message_item_passed; 3 focused tests passed.cargo check --manifest-path codex-rs\Cargo.toml -p codex-execpassed.sdk/typescriptdirecttsupbuild passed and generated declarations.pnpm --dir sdk/typescript buildrantsupsuccessfully during prepare, then exited on the local pnpm trusted-build policy foresbuild; generated pnpm metadata was reverted.cyberagent-mac-m5maxover SSH: fresh checkout verifiedeaad321bcover base5267e805f;git diff --checkpassed;cargo test --manifest-path codex-rs/Cargo.toml -p codex-exec agent_message_item_passed in 3m01s.nicolas-macbook-proover SSH: fresh checkout verified the same one-commit diff andgit diff --checkpassed. Rust toolchain is not installed there.rtx5060tiover SSH: fresh checkout verified the same one-commit diff andgit diff --checkpassed.cargo checkis blocked by the host linker/toolchain issue (link.exerejects MSVC-style arguments withextra operand ...).nicolas2025over SSH: fresh checkout verified the same one-commit diff andgit diff --checkpassed. No usable cargo/rustc output on that host.Supporting the proposal.
phaseis 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 dropsphase(exec JSON events, or the mobile remote path per #37598) loses that distinction — users then can't see the middle steps remotely. Threadingphasethrough as Option<MessagePhase> with serde default/skip would fix the exec/SDK fidelity gap cleanly.