diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index b6af117660..9cd1dac58e 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -356,6 +356,8 @@ fn sample_turn_completed_notification( codex_error_info: Option, ) -> ServerNotification { ServerNotification::TurnCompleted(TurnCompletedNotification { + final_model: None, + model_snapshot: None, thread_id: thread_id.to_string(), turn: Turn { id: turn_id.to_string(), diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index f703bab1a6..4906f6fc28 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -1196,6 +1196,8 @@ mod tests { fn turn_completed_notification() -> ServerNotification { ServerNotification::TurnCompleted(codex_app_server_protocol::TurnCompletedNotification { + final_model: None, + model_snapshot: None, thread_id: "thread".to_string(), turn: codex_app_server_protocol::Turn { id: "turn".to_string(), @@ -2096,6 +2098,8 @@ mod tests { &InProcessServerEvent::ServerNotification( codex_app_server_protocol::ServerNotification::TurnCompleted( codex_app_server_protocol::TurnCompletedNotification { + final_model: None, + model_snapshot: None, thread_id: "thread".to_string(), turn: codex_app_server_protocol::Turn { id: "turn".to_string(), diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 89831b3feb..0213e3ff47 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -4882,6 +4882,20 @@ }, "TurnCompletedNotification": { "properties": { + "finalModel": { + "description": "Final model used after server-side routing or fallback.", + "type": [ + "string", + "null" + ] + }, + "modelSnapshot": { + "description": "Concrete resolved model snapshot used to execute the turn.", + "type": [ + "string", + "null" + ] + }, "threadId": { "type": "string" }, 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 fcf014d26f..bdd82f7e7b 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 @@ -18777,6 +18777,20 @@ "TurnCompletedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { + "finalModel": { + "description": "Final model used after server-side routing or fallback.", + "type": [ + "string", + "null" + ] + }, + "modelSnapshot": { + "description": "Concrete resolved model snapshot used to execute the turn.", + "type": [ + "string", + "null" + ] + }, "threadId": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index ee0415e13d..e4f6046db1 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -16594,6 +16594,20 @@ "TurnCompletedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { + "finalModel": { + "description": "Final model used after server-side routing or fallback.", + "type": [ + "string", + "null" + ] + }, + "modelSnapshot": { + "description": "Concrete resolved model snapshot used to execute the turn.", + "type": [ + "string", + "null" + ] + }, "threadId": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json index 3cc329c9e5..e9a65d0201 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -1719,6 +1719,20 @@ } }, "properties": { + "finalModel": { + "description": "Final model used after server-side routing or fallback.", + "type": [ + "string", + "null" + ] + }, + "modelSnapshot": { + "description": "Concrete resolved model snapshot used to execute the turn.", + "type": [ + "string", + "null" + ] + }, "threadId": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnCompletedNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnCompletedNotification.ts index e1b151bfa7..5c548693a5 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnCompletedNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnCompletedNotification.ts @@ -3,4 +3,12 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { Turn } from "./Turn"; -export type TurnCompletedNotification = { threadId: string, turn: Turn, }; +export type TurnCompletedNotification = { threadId: string, turn: Turn, +/** + * Final model used after server-side routing or fallback. + */ +finalModel: string | null, +/** + * Concrete resolved model snapshot used to execute the turn. + */ +modelSnapshot: string | 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 1305ec00fb..784987424f 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -1442,6 +1442,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_id.to_string(), last_agent_message: None, + final_model: None, + model_snapshot: None, completed_at: None, duration_ms: None, time_to_first_token_ms: None, @@ -1504,6 +1506,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_id.to_string(), last_agent_message: None, + final_model: None, + model_snapshot: None, completed_at: None, duration_ms: None, time_to_first_token_ms: None, @@ -1582,6 +1586,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-image".into(), last_agent_message: None, + final_model: None, + model_snapshot: None, completed_at: None, duration_ms: None, time_to_first_token_ms: None, @@ -1933,6 +1939,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + final_model: None, + model_snapshot: None, completed_at: None, duration_ms: None, time_to_first_token_ms: None, @@ -2482,6 +2490,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + final_model: None, + model_snapshot: None, completed_at: None, duration_ms: None, time_to_first_token_ms: None, @@ -2524,6 +2534,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + final_model: None, + model_snapshot: None, completed_at: None, duration_ms: None, time_to_first_token_ms: None, @@ -2580,6 +2592,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + final_model: None, + model_snapshot: None, completed_at: None, duration_ms: None, time_to_first_token_ms: None, @@ -2622,6 +2636,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + final_model: None, + model_snapshot: None, completed_at: None, duration_ms: None, time_to_first_token_ms: None, @@ -2810,6 +2826,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + final_model: None, + model_snapshot: None, completed_at: None, duration_ms: None, time_to_first_token_ms: None, @@ -2832,6 +2850,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + final_model: None, + model_snapshot: None, completed_at: None, duration_ms: None, time_to_first_token_ms: None, @@ -2844,6 +2864,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-b".into(), last_agent_message: None, + final_model: None, + model_snapshot: None, completed_at: None, duration_ms: None, time_to_first_token_ms: None, @@ -2882,6 +2904,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + final_model: None, + model_snapshot: None, completed_at: None, duration_ms: None, time_to_first_token_ms: None, @@ -2944,6 +2968,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-compact".into(), last_agent_message: None, + final_model: None, + model_snapshot: None, completed_at: None, duration_ms: None, time_to_first_token_ms: None, @@ -3206,6 +3232,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + final_model: None, + model_snapshot: None, completed_at: None, duration_ms: None, time_to_first_token_ms: None, @@ -3271,6 +3299,8 @@ mod tests { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + final_model: None, + model_snapshot: None, completed_at: None, duration_ms: None, time_to_first_token_ms: None, @@ -3326,6 +3356,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + final_model: None, + model_snapshot: None, completed_at: None, duration_ms: None, time_to_first_token_ms: None, @@ -3375,6 +3407,8 @@ mod tests { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), last_agent_message: None, + final_model: None, + model_snapshot: None, completed_at: None, duration_ms: None, time_to_first_token_ms: None, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs index 84567fb4c2..6f82b11f45 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs @@ -373,6 +373,10 @@ pub struct Usage { pub struct TurnCompletedNotification { pub thread_id: String, pub turn: Turn, + /// Final model used after server-side routing or fallback. + pub final_model: Option, + /// Concrete resolved model snapshot used to execute the turn. + pub model_snapshot: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 0532ffa706..eb9523d885 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1293,7 +1293,7 @@ Because audio is intentionally separate from `ThreadItem`, clients can opt out o The app-server streams JSON-RPC notifications while a turn is running. Each turn emits `turn/started` when it begins running and ends with `turn/completed` (final `turn` status). Token usage events stream separately via `thread/tokenUsage/updated`. Clients subscribe to the events they care about, rendering each item incrementally as updates arrive. The per-item lifecycle is always: `item/started` → zero or more item-specific deltas → `item/completed`. - `turn/started` — `{ turn }` with the turn id, empty `items`, and `status: "inProgress"`. -- `turn/completed` — `{ turn }` where `turn.status` is `completed`, `interrupted`, or `failed`; failures carry `{ error: { message, codexErrorInfo?, additionalDetails? } }`. +- `turn/completed` — `{ turn, finalModel, modelSnapshot }` where `turn.status` is `completed`, `interrupted`, or `failed`; execution identity fields are `null` when the Responses API does not report them, and failures carry `{ error: { message, codexErrorInfo?, additionalDetails? } }`. - `turn/diff/updated` — `{ threadId, turnId, diff }` represents the up-to-date snapshot of the turn-level unified diff, emitted after every FileChange item. `diff` is the latest aggregated unified diff across every file change in the turn. UIs can render this to show the full "what changed" view without stitching individual `fileChange` items. - `turn/plan/updated` — `{ turnId, explanation?, plan }` whenever the agent shares or changes its plan; each `plan` entry is `{ step, status }` with `status` in `pending`, `inProgress`, or `completed`. - `model/rerouted` — `{ threadId, turnId, fromModel, toModel, reason }` when the backend reroutes a request to a different model (for example, due to high-risk cyber safety checks). diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 1f47bc4dd8..c18bd4778c 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -1318,6 +1318,8 @@ struct TurnCompletionMetadata { started_at: Option, completed_at: Option, duration_ms: Option, + final_model: Option, + model_snapshot: Option, } async fn emit_turn_completed_with_status( @@ -1338,6 +1340,8 @@ async fn emit_turn_completed_with_status( completed_at: turn_completion_metadata.completed_at, duration_ms: turn_completion_metadata.duration_ms, }, + final_model: turn_completion_metadata.final_model, + model_snapshot: turn_completion_metadata.model_snapshot, }; outgoing .send_server_notification(ServerNotification::TurnCompleted(notification)) @@ -1521,6 +1525,8 @@ async fn handle_turn_complete( started_at: turn_summary.started_at, completed_at: turn_complete_event.completed_at, duration_ms: turn_complete_event.duration_ms, + final_model: turn_complete_event.final_model, + model_snapshot: turn_complete_event.model_snapshot, }, outgoing, ) @@ -1545,6 +1551,8 @@ async fn handle_turn_interrupted( started_at: turn_summary.started_at, completed_at: turn_aborted_event.completed_at, duration_ms: turn_aborted_event.duration_ms, + final_model: None, + model_snapshot: None, }, outgoing, ) @@ -2256,6 +2264,8 @@ mod tests { TurnCompleteEvent { turn_id: turn_id.to_string(), last_agent_message: None, + final_model: None, + model_snapshot: None, completed_at: Some(TEST_TURN_COMPLETED_AT), duration_ms: Some(TEST_TURN_DURATION_MS), time_to_first_token_ms: None, @@ -3423,6 +3433,11 @@ mod tests { ThreadId::new(), ); let thread_state = new_thread_state(); + let turn_complete = TurnCompleteEvent { + final_model: Some("test-final-model".to_string()), + model_snapshot: Some("test-model-snapshot".to_string()), + ..turn_complete_event(&event_turn_id) + }; { let mut state = thread_state.lock().await; state.track_current_turn_event( @@ -3437,14 +3452,14 @@ mod tests { ); state.track_current_turn_event( &event_turn_id, - &EventMsg::TurnComplete(turn_complete_event(&event_turn_id)), + &EventMsg::TurnComplete(turn_complete.clone()), ); } handle_turn_complete( conversation_id, event_turn_id.clone(), - turn_complete_event(&event_turn_id), + turn_complete, &outgoing, &thread_state, ) @@ -3461,6 +3476,8 @@ mod tests { assert_eq!(n.turn.started_at, Some(42)); assert_eq!(n.turn.completed_at, Some(TEST_TURN_COMPLETED_AT)); assert_eq!(n.turn.duration_ms, Some(TEST_TURN_DURATION_MS)); + assert_eq!(n.final_model.as_deref(), Some("test-final-model")); + assert_eq!(n.model_snapshot.as_deref(), Some("test-model-snapshot")); } other => bail!("unexpected message: {other:?}"), } @@ -3511,6 +3528,8 @@ mod tests { assert_eq!(n.turn.error, None); assert_eq!(n.turn.completed_at, Some(TEST_TURN_COMPLETED_AT)); assert_eq!(n.turn.duration_ms, Some(TEST_TURN_DURATION_MS)); + assert_eq!(n.final_model, None); + assert_eq!(n.model_snapshot, None); } other => bail!("unexpected message: {other:?}"), } diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index 27191e7215..9389c6488b 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -894,6 +894,8 @@ mod tests { completed_at: Some(0), duration_ms: None, }, + final_model: None, + model_snapshot: None, }) )); assert!(server_notification_requires_delivery( diff --git a/codex-rs/core/src/agent/control/residency_tests.rs b/codex-rs/core/src/agent/control/residency_tests.rs index 88d4b004d3..f87ceb18f6 100644 --- a/codex-rs/core/src/agent/control/residency_tests.rs +++ b/codex-rs/core/src/agent/control/residency_tests.rs @@ -160,6 +160,8 @@ async fn mark_thread_completed(thread: &CodexThread) { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + final_model: None, + model_snapshot: None, completed_at: None, duration_ms: None, time_to_first_token_ms: None, diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index ac42984b58..6e59963f75 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -305,6 +305,8 @@ async fn on_event_updates_status_from_task_complete() { let status = agent_status_from_event(&EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: Some("done".to_string()), + final_model: None, + model_snapshot: None, completed_at: None, duration_ms: None, time_to_first_token_ms: None, @@ -1896,6 +1898,8 @@ async fn multi_agent_v2_completion_ignores_dead_direct_parent() { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: tester_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + final_model: None, + model_snapshot: None, completed_at: None, duration_ms: None, time_to_first_token_ms: None, @@ -1983,6 +1987,8 @@ async fn multi_agent_v2_completion_queues_message_for_direct_parent() { EventMsg::TurnComplete(TurnCompleteEvent { turn_id: tester_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + final_model: None, + model_snapshot: None, completed_at: None, duration_ms: None, time_to_first_token_ms: None, diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 54282c1c0a..95f78df6a5 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -1111,6 +1111,8 @@ mod tests { msg: EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_id.to_string(), last_agent_message: last_agent_message.map(str::to_string), + final_model: None, + model_snapshot: None, completed_at: None, duration_ms: None, time_to_first_token_ms, diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 8f6e01298c..58a3a20e77 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -1980,6 +1980,8 @@ async fn guardian_reused_trunk_ignores_stale_prior_turn_completion() -> anyhow:: "{\"risk_level\":\"high\",\"user_authorization\":\"low\",\"outcome\":\"deny\",\"rationale\":\"stale guardian rationale\"}" .to_string(), ), + final_model: None, + model_snapshot: None, completed_at: None, duration_ms: None, time_to_first_token_ms: Some(1), diff --git a/codex-rs/core/src/session/rollout_reconstruction_tests.rs b/codex-rs/core/src/session/rollout_reconstruction_tests.rs index c84ee7f5f3..e52d016d3e 100644 --- a/codex-rs/core/src/session/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/session/rollout_reconstruction_tests.rs @@ -147,6 +147,8 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif codex_protocol::protocol::TurnCompleteEvent { turn_id, last_agent_message: None, + final_model: None, + model_snapshot: None, completed_at: None, duration_ms: None, time_to_first_token_ms: None, @@ -218,6 +220,8 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + final_model: None, + model_snapshot: None, completed_at: None, duration_ms: None, time_to_first_token_ms: None, @@ -249,6 +253,8 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com codex_protocol::protocol::TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + final_model: None, + model_snapshot: None, completed_at: None, duration_ms: None, time_to_first_token_ms: None, @@ -322,6 +328,8 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + final_model: None, + model_snapshot: None, completed_at: None, duration_ms: None, time_to_first_token_ms: None, @@ -418,6 +426,8 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + final_model: None, + model_snapshot: None, completed_at: None, duration_ms: None, time_to_first_token_ms: None, @@ -448,6 +458,8 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: second_turn_id, last_agent_message: None, + final_model: None, + model_snapshot: None, completed_at: None, duration_ms: None, time_to_first_token_ms: None, @@ -467,6 +479,8 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad codex_protocol::protocol::TurnCompleteEvent { turn_id: standalone_turn_id, last_agent_message: None, + final_model: None, + model_snapshot: None, completed_at: None, duration_ms: None, time_to_first_token_ms: None, @@ -543,6 +557,8 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + final_model: None, + model_snapshot: None, completed_at: None, duration_ms: None, time_to_first_token_ms: None, @@ -564,6 +580,8 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: assistant_turn_id, last_agent_message: None, + final_model: None, + model_snapshot: None, completed_at: None, duration_ms: None, time_to_first_token_ms: None, @@ -635,6 +653,8 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding codex_protocol::protocol::TurnCompleteEvent { turn_id: only_turn_id, last_agent_message: None, + final_model: None, + model_snapshot: None, completed_at: None, duration_ms: None, time_to_first_token_ms: None, @@ -688,6 +708,8 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: user_turn_id, last_agent_message: None, + final_model: None, + model_snapshot: None, completed_at: None, duration_ms: None, time_to_first_token_ms: None, @@ -707,6 +729,8 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { codex_protocol::protocol::TurnCompleteEvent { turn_id: standalone_turn_id, last_agent_message: None, + final_model: None, + model_snapshot: None, completed_at: None, duration_ms: None, time_to_first_token_ms: None, @@ -764,6 +788,8 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + final_model: None, + model_snapshot: None, completed_at: None, duration_ms: None, time_to_first_token_ms: None, @@ -931,6 +957,8 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear codex_protocol::protocol::TurnCompleteEvent { turn_id: current_turn_id, last_agent_message: None, + final_model: None, + model_snapshot: None, completed_at: None, duration_ms: None, time_to_first_token_ms: None, @@ -1005,6 +1033,8 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + final_model: None, + model_snapshot: None, completed_at: None, duration_ms: None, time_to_first_token_ms: None, @@ -1110,6 +1140,8 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + final_model: None, + model_snapshot: None, completed_at: None, duration_ms: None, time_to_first_token_ms: None, @@ -1225,6 +1257,8 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + final_model: None, + model_snapshot: None, completed_at: None, duration_ms: None, time_to_first_token_ms: None, @@ -1262,6 +1296,8 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo codex_protocol::protocol::TurnCompleteEvent { turn_id: current_turn_id, last_agent_message: None, + final_model: None, + model_snapshot: None, completed_at: None, duration_ms: None, time_to_first_token_ms: None, @@ -1348,6 +1384,8 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + final_model: None, + model_snapshot: None, completed_at: None, duration_ms: None, time_to_first_token_ms: None, @@ -1509,6 +1547,8 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear codex_protocol::protocol::TurnCompleteEvent { turn_id: previous_turn_id, last_agent_message: None, + final_model: None, + model_snapshot: None, completed_at: None, duration_ms: None, time_to_first_token_ms: None, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 29de7ca662..46c15a4fef 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -2595,6 +2595,8 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { codex_protocol::protocol::TurnCompleteEvent { turn_id, last_agent_message: None, + final_model: None, + model_snapshot: None, completed_at: None, duration_ms: None, time_to_first_token_ms: None, @@ -2793,6 +2795,8 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + final_model: None, + model_snapshot: None, completed_at: None, duration_ms: None, time_to_first_token_ms: None, @@ -2822,6 +2826,8 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + final_model: None, + model_snapshot: None, completed_at: None, duration_ms: None, time_to_first_token_ms: None, @@ -2906,6 +2912,8 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn_id, last_agent_message: None, + final_model: None, + model_snapshot: None, completed_at: None, duration_ms: None, time_to_first_token_ms: None, @@ -2927,6 +2935,8 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: compact_turn_id, last_agent_message: None, + final_model: None, + model_snapshot: None, completed_at: None, duration_ms: None, time_to_first_token_ms: None, @@ -2958,6 +2968,8 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: rolled_back_turn_id, last_agent_message: None, + final_model: None, + model_snapshot: None, completed_at: None, duration_ms: None, time_to_first_token_ms: None, @@ -3016,6 +3028,8 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-1".to_string(), last_agent_message: None, + final_model: None, + model_snapshot: None, completed_at: None, duration_ms: None, time_to_first_token_ms: None, @@ -3043,6 +3057,8 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-2".to_string(), last_agent_message: None, + final_model: None, + model_snapshot: None, completed_at: None, duration_ms: None, time_to_first_token_ms: None, @@ -3070,6 +3086,8 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-3".to_string(), last_agent_message: None, + final_model: None, + model_snapshot: None, completed_at: None, duration_ms: None, time_to_first_token_ms: None, diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 52af0d04db..e33d781cf8 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -742,6 +742,8 @@ impl Session { let event = EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_context.sub_id.clone(), last_agent_message, + final_model: None, + model_snapshot: None, completed_at, duration_ms, time_to_first_token_ms, diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index f55d9c0876..9fce877a8d 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -1557,6 +1557,8 @@ async fn multi_agent_v2_list_agents_returns_completed_status_without_encrypted_s EventMsg::TurnComplete(TurnCompleteEvent { turn_id: child_turn.sub_id.clone(), last_agent_message: Some("done".to_string()), + final_model: None, + model_snapshot: None, completed_at: None, duration_ms: None, time_to_first_token_ms: None, @@ -2008,6 +2010,8 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() EventMsg::TurnComplete(TurnCompleteEvent { turn_id: first_turn.sub_id.clone(), last_agent_message: Some("first done".to_string()), + final_model: None, + model_snapshot: None, completed_at: None, duration_ms: None, time_to_first_token_ms: None, @@ -2037,6 +2041,8 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() EventMsg::TurnComplete(TurnCompleteEvent { turn_id: second_turn.sub_id.clone(), last_agent_message: Some("second done".to_string()), + final_model: None, + model_snapshot: None, completed_at: None, duration_ms: None, time_to_first_token_ms: None, diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index 422489a86a..31ce58d965 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -69,6 +69,8 @@ fn resume_history( RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id, last_agent_message: None, + final_model: None, + model_snapshot: None, completed_at: None, duration_ms: None, time_to_first_token_ms: None, diff --git a/codex-rs/exec/src/event_processor_with_human_output_tests.rs b/codex-rs/exec/src/event_processor_with_human_output_tests.rs index 5e89d48c7f..9889f089b7 100644 --- a/codex-rs/exec/src/event_processor_with_human_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_human_output_tests.rs @@ -307,6 +307,8 @@ fn turn_completed_recovers_final_message_from_turn_items() { let status = processor.process_server_notification(ServerNotification::TurnCompleted( codex_app_server_protocol::TurnCompletedNotification { + final_model: None, + model_snapshot: None, thread_id: "thread-1".to_string(), turn: Turn { id: "turn-1".to_string(), @@ -355,6 +357,8 @@ fn turn_completed_overwrites_stale_final_message_from_turn_items() { let status = processor.process_server_notification(ServerNotification::TurnCompleted( codex_app_server_protocol::TurnCompletedNotification { + final_model: None, + model_snapshot: None, thread_id: "thread-1".to_string(), turn: Turn { id: "turn-1".to_string(), @@ -404,6 +408,8 @@ fn turn_completed_preserves_streamed_final_message_when_turn_items_are_empty() { let status = processor.process_server_notification(ServerNotification::TurnCompleted( codex_app_server_protocol::TurnCompletedNotification { + final_model: None, + model_snapshot: None, thread_id: "thread-1".to_string(), turn: Turn { id: "turn-1".to_string(), @@ -448,6 +454,8 @@ fn turn_failed_clears_stale_final_message() { let status = processor.process_server_notification(ServerNotification::TurnCompleted( codex_app_server_protocol::TurnCompletedNotification { + final_model: None, + model_snapshot: None, thread_id: "thread-1".to_string(), turn: Turn { id: "turn-1".to_string(), @@ -493,6 +501,8 @@ fn turn_interrupted_clears_stale_final_message() { let status = processor.process_server_notification(ServerNotification::TurnCompleted( codex_app_server_protocol::TurnCompletedNotification { + final_model: None, + model_snapshot: None, thread_id: "thread-1".to_string(), turn: Turn { id: "turn-1".to_string(), diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs index b8b0267fc4..3d5659bb6e 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs @@ -30,6 +30,8 @@ fn failed_turn_does_not_overwrite_output_last_message_file() { let status = processor.process_server_notification(ServerNotification::TurnCompleted( codex_app_server_protocol::TurnCompletedNotification { + final_model: None, + model_snapshot: None, thread_id: "thread-1".to_string(), turn: codex_app_server_protocol::Turn { id: "turn-1".to_string(), diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index 8b2f214987..5b78371573 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -371,6 +371,8 @@ fn turn_items_for_thread_returns_matching_turn_items() { fn should_backfill_turn_completed_items_skips_ephemeral_threads() { let notification = ServerNotification::TurnCompleted(codex_app_server_protocol::TurnCompletedNotification { + final_model: None, + model_snapshot: None, thread_id: "thread-1".to_string(), turn: codex_app_server_protocol::Turn { id: "turn-1".to_string(), 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 2cee9b0e83..ef96efe94c 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -1103,6 +1103,8 @@ fn plan_update_emits_started_then_updated_then_completed() { let completed = processor.collect_thread_events(ServerNotification::TurnCompleted( TurnCompletedNotification { + final_model: None, + model_snapshot: None, thread_id: "thread-1".to_string(), turn: Turn { id: "turn-1".to_string(), @@ -1163,6 +1165,8 @@ fn plan_update_after_completion_starts_new_todo_list_with_new_id() { )); let _ = processor.collect_thread_events(ServerNotification::TurnCompleted( TurnCompletedNotification { + final_model: None, + model_snapshot: None, thread_id: "thread-1".to_string(), turn: Turn { id: "turn-1".to_string(), @@ -1246,6 +1250,8 @@ fn token_usage_update_is_emitted_on_turn_completion() { let completed = processor.collect_thread_events(ServerNotification::TurnCompleted( TurnCompletedNotification { + final_model: None, + model_snapshot: None, thread_id: "thread-1".to_string(), turn: Turn { id: "turn-1".to_string(), @@ -1281,6 +1287,8 @@ fn turn_completion_recovers_final_message_from_turn_items() { let completed = processor.collect_thread_events(ServerNotification::TurnCompleted( TurnCompletedNotification { + final_model: None, + model_snapshot: None, thread_id: "thread-1".to_string(), turn: Turn { id: "turn-1".to_string(), @@ -1354,6 +1362,8 @@ fn turn_completion_reconciles_started_items_from_turn_items() { let completed = processor.collect_thread_events(ServerNotification::TurnCompleted( TurnCompletedNotification { + final_model: None, + model_snapshot: None, thread_id: "thread-1".to_string(), turn: Turn { id: "turn-1".to_string(), @@ -1422,6 +1432,8 @@ fn turn_completion_overwrites_stale_final_message_from_turn_items() { let completed = processor.collect_thread_events(ServerNotification::TurnCompleted( TurnCompletedNotification { + final_model: None, + model_snapshot: None, thread_id: "thread-1".to_string(), turn: Turn { id: "turn-1".to_string(), @@ -1472,6 +1484,8 @@ fn turn_completion_preserves_streamed_final_message_when_turn_items_are_empty() let completed = processor.collect_thread_events(ServerNotification::TurnCompleted( TurnCompletedNotification { + final_model: None, + model_snapshot: None, thread_id: "thread-1".to_string(), turn: Turn { id: "turn-1".to_string(), @@ -1521,6 +1535,8 @@ fn failed_turn_clears_stale_final_message() { let collected = processor.collect_thread_events(ServerNotification::TurnCompleted( TurnCompletedNotification { + final_model: None, + model_snapshot: None, thread_id: "thread-1".to_string(), turn: Turn { id: "turn-1".to_string(), @@ -1549,6 +1565,8 @@ fn turn_completion_falls_back_to_final_plan_text() { let completed = processor.collect_thread_events(ServerNotification::TurnCompleted( TurnCompletedNotification { + final_model: None, + model_snapshot: None, thread_id: "thread-1".to_string(), turn: Turn { id: "turn-1".to_string(), @@ -1604,6 +1622,8 @@ fn turn_failure_prefers_structured_error_message() { let failed = processor.collect_thread_events(ServerNotification::TurnCompleted( TurnCompletedNotification { + final_model: None, + model_snapshot: None, thread_id: "thread-1".to_string(), turn: Turn { id: "turn-1".to_string(), diff --git a/codex-rs/external-agent-sessions/src/export.rs b/codex-rs/external-agent-sessions/src/export.rs index 05a3004ee9..8426ce50e0 100644 --- a/codex-rs/external-agent-sessions/src/export.rs +++ b/codex-rs/external-agent-sessions/src/export.rs @@ -167,6 +167,8 @@ fn turn_complete_item(turn_id: String, completed_at: Option) -> RolloutItem RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id, last_agent_message: None, + final_model: None, + model_snapshot: None, completed_at, duration_ms: None, time_to_first_token_ms: None, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 01e664c734..21bf92d9ee 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1876,6 +1876,16 @@ pub struct ContextCompactedEvent; pub struct TurnCompleteEvent { pub turn_id: String, pub last_agent_message: Option, + /// Final model used by the terminal successful sampling request, after routing or fallback. + #[serde(default, skip_serializing, skip_deserializing)] + #[schemars(skip)] + #[ts(skip)] + pub final_model: Option, + /// Concrete model snapshot used by the terminal successful sampling request. + #[serde(default, skip_serializing, skip_deserializing)] + #[schemars(skip)] + #[ts(skip)] + pub model_snapshot: Option, /// Unix timestamp (in seconds) when the turn completed. #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(type = "number | null", optional)] @@ -5307,6 +5317,32 @@ mod tests { Ok(()) } + #[test] + fn turn_complete_execution_identity_is_not_serialized() -> Result<()> { + let event = EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-1".to_string(), + last_agent_message: Some("done".to_string()), + final_model: Some("test-final-model".to_string()), + model_snapshot: Some("test-model-snapshot".to_string()), + completed_at: Some(1_717_171_717), + duration_ms: Some(1_234), + time_to_first_token_ms: Some(321), + }); + + assert_eq!( + serde_json::to_value(event)?, + json!({ + "type": "task_complete", + "turn_id": "turn-1", + "last_agent_message": "done", + "completed_at": 1_717_171_717, + "duration_ms": 1_234, + "time_to_first_token_ms": 321, + }) + ); + Ok(()) + } + #[test] fn deserialize_legacy_session_configured_event_uses_sandbox_policy() -> Result<()> { let cwd = test_path_buf("/home/user/project"); diff --git a/codex-rs/tui/src/app/pending_interactive_replay.rs b/codex-rs/tui/src/app/pending_interactive_replay.rs index 1a21d4df50..7e819da585 100644 --- a/codex-rs/tui/src/app/pending_interactive_replay.rs +++ b/codex-rs/tui/src/app/pending_interactive_replay.rs @@ -664,6 +664,8 @@ mod tests { fn turn_completed(turn_id: &str) -> ServerNotification { ServerNotification::TurnCompleted(TurnCompletedNotification { + final_model: None, + model_snapshot: None, thread_id: "thread-1".to_string(), turn: Turn { id: turn_id.to_string(), diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index ed89c72878..b468dce5ce 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -4403,6 +4403,8 @@ fn turn_completed_notification( duration_ms: Some(1), ..test_turn(turn_id, status, Vec::new()) }, + final_model: None, + model_snapshot: None, }) } diff --git a/codex-rs/tui/src/app/thread_events.rs b/codex-rs/tui/src/app/thread_events.rs index 48dc1d0848..e901bd771f 100644 --- a/codex-rs/tui/src/app/thread_events.rs +++ b/codex-rs/tui/src/app/thread_events.rs @@ -416,6 +416,8 @@ mod tests { duration_ms: Some(1), ..test_turn(turn_id, status, Vec::new()) }, + final_model: None, + model_snapshot: None, }) } diff --git a/codex-rs/tui/src/chatwidget/replay.rs b/codex-rs/tui/src/chatwidget/replay.rs index bdb8b8d3a1..f339cc3458 100644 --- a/codex-rs/tui/src/chatwidget/replay.rs +++ b/codex-rs/tui/src/chatwidget/replay.rs @@ -36,6 +36,8 @@ impl ChatWidget { ) { self.handle_turn_completed_notification( TurnCompletedNotification { + final_model: None, + model_snapshot: None, thread_id: self.thread_id.map(|id| id.to_string()).unwrap_or_default(), turn: Turn { id: turn_id, diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index f4fcc85e43..90fba86f5c 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -346,6 +346,8 @@ async fn live_app_server_turn_completed_clears_working_status_after_answer_item( chat.handle_server_notification( ServerNotification::TurnCompleted(TurnCompletedNotification { + final_model: None, + model_snapshot: None, thread_id: "thread-1".to_string(), turn: AppServerTurn { id: "turn-1".to_string(), @@ -766,6 +768,8 @@ async fn live_app_server_failed_turn_does_not_duplicate_error_history() { chat.handle_server_notification( ServerNotification::TurnCompleted(TurnCompletedNotification { + final_model: None, + model_snapshot: None, thread_id: "thread-1".to_string(), turn: AppServerTurn { id: "turn-1".to_string(), diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 214ca969aa..eb0350d9f7 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -996,6 +996,8 @@ pub(super) fn handle_turn_completed( ) { chat.handle_server_notification( ServerNotification::TurnCompleted(TurnCompletedNotification { + final_model: None, + model_snapshot: None, thread_id: chat.thread_id.map(|id| id.to_string()).unwrap_or_default(), turn: app_server_turn( turn_id, @@ -1011,6 +1013,8 @@ pub(super) fn handle_turn_completed( pub(super) fn handle_turn_interrupted(chat: &mut ChatWidget, turn_id: &str) { chat.handle_server_notification( ServerNotification::TurnCompleted(TurnCompletedNotification { + final_model: None, + model_snapshot: None, thread_id: chat.thread_id.map(|id| id.to_string()).unwrap_or_default(), turn: app_server_turn( turn_id, diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index 74cd527038..f5cb0cb4ba 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -1172,6 +1172,8 @@ async fn submit_user_message_queues_while_compaction_turn_is_running() { chat.handle_server_notification( ServerNotification::TurnCompleted(TurnCompletedNotification { + final_model: None, + model_snapshot: None, thread_id: thread_id.to_string(), turn: AppServerTurn { id: "turn-1".to_string(), diff --git a/codex-rs/tui/src/chatwidget/tests/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index af5816cac2..f26141545e 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -1271,6 +1271,8 @@ async fn interrupted_turn_after_goal_budget_limited_uses_budget_message_snapshot chat.handle_server_notification( codex_app_server_protocol::ServerNotification::TurnCompleted( codex_app_server_protocol::TurnCompletedNotification { + final_model: None, + model_snapshot: None, thread_id: "thread-1".to_string(), turn: codex_app_server_protocol::Turn { id: "turn-1".to_string(),