Skip to main content

codex_protocol/
protocol.rs

1//! Defines the protocol for a Codex session between a client and an agent.
2//!
3//! Uses a SQ (Submission Queue) / EQ (Event Queue) pattern to asynchronously communicate
4//! between user and agent.
5
6use std::collections::BTreeMap;
7use std::collections::HashMap;
8use std::fmt;
9use std::ops::Mul;
10use std::path::Path;
11use std::path::PathBuf;
12use std::str::FromStr;
13use std::sync::Arc;
14use std::time::Duration;
15
16use strum_macros::EnumIter;
17
18use crate::AgentPath;
19use crate::ResponseItemId;
20use crate::SessionId;
21use crate::ThreadId;
22use crate::approvals::ElicitationRequestEvent;
23use crate::capabilities::SelectedCapabilityRoot;
24use crate::config_types::ApprovalsReviewer;
25use crate::config_types::CollaborationMode;
26use crate::config_types::ModeKind;
27use crate::config_types::MultiAgentMode;
28use crate::config_types::Personality;
29use crate::config_types::ReasoningSummary as ReasoningSummaryConfig;
30use crate::config_types::WindowsSandboxLevel;
31use crate::dynamic_tools::DynamicToolCallOutputContentItem;
32use crate::dynamic_tools::DynamicToolCallRequest;
33use crate::dynamic_tools::DynamicToolResponse;
34use crate::dynamic_tools::DynamicToolSpec;
35use crate::items::TurnItem;
36use crate::mcp::CallToolResult;
37use crate::mcp::RequestId;
38use crate::memory_citation::MemoryCitation;
39use crate::models::ActivePermissionProfile;
40use crate::models::AgentMessageInputContent;
41use crate::models::BaseInstructions;
42use crate::models::ContentItem;
43use crate::models::ImageDetail;
44use crate::models::InternalChatMessageMetadataPassthrough;
45use crate::models::MessagePhase;
46use crate::models::PermissionProfile;
47use crate::models::ResponseInputItem;
48use crate::models::ResponseItem;
49use crate::models::SandboxEnforcement;
50use crate::models::WebSearchAction;
51use crate::num_format::format_with_separators;
52use crate::openai_models::ReasoningEffort as ReasoningEffortConfig;
53use crate::parse_command::ParsedCommand;
54use crate::plan_tool::UpdatePlanArgs;
55use crate::request_permissions::RequestPermissionsEvent;
56use crate::request_permissions::RequestPermissionsResponse;
57use crate::request_user_input::RequestUserInputResponse;
58use crate::user_input::UserInput;
59use codex_utils_absolute_path::AbsolutePathBuf;
60use codex_utils_path_uri::PathUri;
61use schemars::JsonSchema;
62use serde::Deserialize;
63use serde::Deserializer;
64use serde::Serialize;
65use serde::de::Error as _;
66use serde_json::Value;
67use serde_with::serde_as;
68use strum_macros::Display;
69use tracing::error;
70use ts_rs::TS;
71
72pub use crate::approvals::ApplyPatchApprovalRequestEvent;
73pub use crate::approvals::ElicitationAction;
74pub use crate::approvals::ExecApprovalRequestEvent;
75pub use crate::approvals::ExecPolicyAmendment;
76pub use crate::approvals::GuardianAssessmentAction;
77pub use crate::approvals::GuardianAssessmentDecisionSource;
78pub use crate::approvals::GuardianAssessmentEvent;
79pub use crate::approvals::GuardianAssessmentOutcome;
80pub use crate::approvals::GuardianAssessmentStatus;
81pub use crate::approvals::GuardianCommandSource;
82pub use crate::approvals::GuardianRiskLevel;
83pub use crate::approvals::GuardianUserAuthorization;
84pub use crate::approvals::NetworkApprovalContext;
85pub use crate::approvals::NetworkApprovalProtocol;
86pub use crate::approvals::NetworkPolicyAmendment;
87pub use crate::approvals::NetworkPolicyRuleAction;
88pub use crate::legacy_events::HasLegacyEvent;
89pub use crate::permissions::FileSystemAccessMode;
90pub use crate::permissions::FileSystemPath;
91pub use crate::permissions::FileSystemSandboxEntry;
92pub use crate::permissions::FileSystemSandboxKind;
93pub use crate::permissions::FileSystemSandboxPolicy;
94pub use crate::permissions::FileSystemSpecialPath;
95pub use crate::permissions::NetworkSandboxPolicy;
96use crate::permissions::default_read_only_subpaths_for_writable_root;
97pub use crate::request_permissions::RequestPermissionsArgs;
98pub use crate::request_user_input::RequestUserInputEvent;
99
100/// Open/close tags for special context blocks. Used across crates to avoid duplicated hardcoded
101/// strings.
102pub const USER_INSTRUCTIONS_OPEN_TAG: &str = "<user_instructions>";
103pub const USER_INSTRUCTIONS_CLOSE_TAG: &str = "</user_instructions>";
104pub const ENVIRONMENT_CONTEXT_OPEN_TAG: &str = "<environment_context>";
105pub const ENVIRONMENT_CONTEXT_CLOSE_TAG: &str = "</environment_context>";
106pub const ENVIRONMENTS_INSTRUCTIONS_OPEN_TAG: &str = "<environments_instructions>";
107pub const ENVIRONMENTS_INSTRUCTIONS_CLOSE_TAG: &str = "</environments_instructions>";
108pub const APPS_INSTRUCTIONS_OPEN_TAG: &str = "<apps_instructions>";
109pub const APPS_INSTRUCTIONS_CLOSE_TAG: &str = "</apps_instructions>";
110pub const SKILLS_INSTRUCTIONS_OPEN_TAG: &str = "<skills_instructions>";
111pub const SKILLS_INSTRUCTIONS_CLOSE_TAG: &str = "</skills_instructions>";
112pub const PLUGINS_INSTRUCTIONS_OPEN_TAG: &str = "<plugins_instructions>";
113pub const PLUGINS_INSTRUCTIONS_CLOSE_TAG: &str = "</plugins_instructions>";
114pub const COLLABORATION_MODE_OPEN_TAG: &str = "<collaboration_mode>";
115pub const COLLABORATION_MODE_CLOSE_TAG: &str = "</collaboration_mode>";
116pub const MULTI_AGENT_MODE_OPEN_TAG: &str = "<multi_agent_mode>";
117pub const MULTI_AGENT_MODE_CLOSE_TAG: &str = "</multi_agent_mode>";
118pub const REALTIME_CONVERSATION_OPEN_TAG: &str = "<realtime_conversation>";
119pub const REALTIME_CONVERSATION_CLOSE_TAG: &str = "</realtime_conversation>";
120pub const CONTEXT_WINDOW_OPEN_TAG: &str = "<context_window>";
121pub const CONTEXT_WINDOW_CLOSE_TAG: &str = "</context_window>";
122pub const CONTEXT_WINDOW_GUIDANCE_OPEN_TAG: &str = "<context_window_guidance>";
123pub const CONTEXT_WINDOW_GUIDANCE_CLOSE_TAG: &str = "</context_window_guidance>";
124pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:";
125
126/// Removes the model-context prefix from a user message before displaying it.
127pub fn strip_user_message_prefix(text: &str) -> &str {
128    match text.find(USER_MESSAGE_BEGIN) {
129        Some(idx) => text[idx + USER_MESSAGE_BEGIN.len()..].trim(),
130        None => text.trim(),
131    }
132}
133
134// TODO(anp): Replace `TurnEnvironmentSelection` with `PathUri` once path URIs carry environment
135// identifiers.
136#[derive(Debug, Clone, PartialEq)]
137pub struct TurnEnvironmentSelection {
138    pub environment_id: String,
139    pub cwd: PathUri,
140    pub workspace_roots: Vec<PathUri>,
141}
142
143#[derive(Debug, Clone, PartialEq)]
144pub struct TurnEnvironmentSelections {
145    pub legacy_fallback_cwd: AbsolutePathBuf,
146    pub environments: Vec<TurnEnvironmentSelection>,
147}
148
149impl TurnEnvironmentSelections {
150    pub fn new(
151        legacy_fallback_cwd: AbsolutePathBuf,
152        environments: Vec<TurnEnvironmentSelection>,
153    ) -> Self {
154        Self {
155            legacy_fallback_cwd,
156            environments,
157        }
158    }
159}
160
161#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema, TS)]
162#[serde(transparent)]
163#[ts(type = "string")]
164pub struct GitSha(pub String);
165
166impl GitSha {
167    pub fn new(sha: &str) -> Self {
168        Self(sha.to_string())
169    }
170}
171
172/// Submission Queue Entry - requests from user
173#[derive(Debug, Clone)]
174pub struct Submission {
175    /// Unique id for this Submission to correlate with Events
176    pub id: String,
177    /// Payload
178    pub op: Op,
179    /// Client-provided id for the user message represented by `Op::UserInput`.
180    pub client_user_message_id: Option<String>,
181    /// Optional W3C trace carrier propagated across async submission handoffs.
182    pub trace: Option<W3cTraceContext>,
183}
184
185#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
186pub struct W3cTraceContext {
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    #[ts(optional)]
189    pub traceparent: Option<String>,
190    #[serde(default, skip_serializing_if = "Option::is_none")]
191    #[ts(optional)]
192    pub tracestate: Option<String>,
193}
194
195/// Config payload for refreshing MCP servers.
196#[derive(Debug, Clone, PartialEq)]
197pub struct McpServerRefreshConfig {
198    /// Complete runtime server map after source and thread-scoped resolution.
199    pub mcp_servers: Value,
200    /// OAuth credential store mode to use with this server snapshot.
201    pub mcp_oauth_credentials_store_mode: Value,
202    pub auth_keyring_backend_kind: Value,
203}
204
205#[derive(Debug, Clone, PartialEq)]
206pub struct ConversationStartParams {
207    /// Whether Codex response handoffs are managed through explicit client append calls.
208    pub client_managed_handoffs: bool,
209    /// Whether to route any remaining transcript tail through Codex when the session ends.
210    /// TODO: Remove this rollout knob once transcript-tail flushing is always enabled.
211    pub flush_transcript_tail_on_session_end: bool,
212    /// Sends automatic Codex responses as realtime conversation items instead of handoff appends.
213    pub codex_responses_as_items: bool,
214    /// Optional prefix added to automatic Codex response items when `codex_responses_as_items` is set.
215    pub codex_response_item_prefix: Option<String>,
216    /// Selects how automatic Codex handoffs are routed in Frameless Bidi sessions.
217    /// Realtime V1 and V2 ignore this setting.
218    pub codex_response_handoff_mode: CodexResponseHandoffMode,
219    /// Overrides the configured realtime model for this session only.
220    pub model: Option<String>,
221    /// Selects whether the realtime session should produce text or audio output.
222    pub output_modality: RealtimeOutputModality,
223    /// Whether to append Codex's startup context to the realtime backend prompt.
224    pub include_startup_context: bool,
225    /// Complete role-bearing text items to include in the initial realtime session history.
226    pub initial_items: Vec<ConversationTextParams>,
227    pub prompt: Option<Option<String>>,
228    pub realtime_session_id: Option<String>,
229    pub transport: Option<ConversationStartTransport>,
230    /// Overrides the configured realtime protocol version for this session only.
231    pub version: Option<RealtimeConversationVersion>,
232    pub voice: Option<RealtimeVoice>,
233}
234
235#[derive(Debug, Clone, PartialEq)]
236pub enum ConversationStartTransport {
237    Websocket,
238    Webrtc { sdp: String },
239}
240
241#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
242#[serde(rename_all = "snake_case")]
243pub enum RealtimeOutputModality {
244    Text,
245    Audio,
246}
247
248#[derive(
249    Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Hash, JsonSchema, TS, Ord, PartialOrd,
250)]
251#[serde(rename_all = "snake_case")]
252#[ts(rename_all = "snake_case")]
253pub enum RealtimeVoice {
254    Alloy,
255    Arbor,
256    Ash,
257    Ballad,
258    Breeze,
259    Cedar,
260    Coral,
261    Cove,
262    Echo,
263    Ember,
264    Juniper,
265    Maple,
266    Marin,
267    Sage,
268    Shimmer,
269    Sol,
270    Spruce,
271    Vale,
272    Verse,
273}
274
275impl RealtimeVoice {
276    pub fn wire_name(self) -> &'static str {
277        match self {
278            Self::Alloy => "alloy",
279            Self::Arbor => "arbor",
280            Self::Ash => "ash",
281            Self::Ballad => "ballad",
282            Self::Breeze => "breeze",
283            Self::Cedar => "cedar",
284            Self::Coral => "coral",
285            Self::Cove => "cove",
286            Self::Echo => "echo",
287            Self::Ember => "ember",
288            Self::Juniper => "juniper",
289            Self::Maple => "maple",
290            Self::Marin => "marin",
291            Self::Sage => "sage",
292            Self::Shimmer => "shimmer",
293            Self::Sol => "sol",
294            Self::Spruce => "spruce",
295            Self::Vale => "vale",
296            Self::Verse => "verse",
297        }
298    }
299}
300
301#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
302#[serde(rename_all = "camelCase")]
303#[ts(rename_all = "camelCase")]
304pub struct RealtimeVoicesList {
305    pub v1: Vec<RealtimeVoice>,
306    pub v2: Vec<RealtimeVoice>,
307    pub default_v1: RealtimeVoice,
308    pub default_v2: RealtimeVoice,
309}
310
311impl RealtimeVoicesList {
312    pub fn builtin() -> Self {
313        Self {
314            v1: vec![
315                RealtimeVoice::Juniper,
316                RealtimeVoice::Maple,
317                RealtimeVoice::Spruce,
318                RealtimeVoice::Ember,
319                RealtimeVoice::Vale,
320                RealtimeVoice::Breeze,
321                RealtimeVoice::Arbor,
322                RealtimeVoice::Sol,
323                RealtimeVoice::Cove,
324            ],
325            v2: vec![
326                RealtimeVoice::Alloy,
327                RealtimeVoice::Ash,
328                RealtimeVoice::Ballad,
329                RealtimeVoice::Coral,
330                RealtimeVoice::Echo,
331                RealtimeVoice::Sage,
332                RealtimeVoice::Shimmer,
333                RealtimeVoice::Verse,
334                RealtimeVoice::Marin,
335                RealtimeVoice::Cedar,
336            ],
337            default_v1: RealtimeVoice::Cove,
338            default_v2: RealtimeVoice::Marin,
339        }
340    }
341}
342
343#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
344pub struct RealtimeAudioFrame {
345    pub data: String,
346    pub sample_rate: u32,
347    pub num_channels: u16,
348    #[serde(skip_serializing_if = "Option::is_none")]
349    pub samples_per_channel: Option<u32>,
350    #[serde(skip_serializing_if = "Option::is_none")]
351    pub item_id: Option<String>,
352}
353
354#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
355pub struct RealtimeTranscriptDelta {
356    pub delta: String,
357}
358
359#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
360pub struct RealtimeTranscriptDone {
361    pub text: String,
362}
363
364#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
365pub struct RealtimeTranscriptEntry {
366    pub role: String,
367    pub text: String,
368}
369
370#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
371pub struct RealtimeHandoffRequested {
372    pub handoff_id: String,
373    pub item_id: String,
374    pub input_transcript: String,
375    pub active_transcript: Vec<RealtimeTranscriptEntry>,
376}
377
378#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
379pub struct RealtimeNoopRequested {
380    pub call_id: String,
381    pub item_id: String,
382}
383
384#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
385pub struct RealtimeInputAudioSpeechStarted {
386    pub item_id: Option<String>,
387}
388
389#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
390pub struct RealtimeResponseCancelled {
391    pub response_id: Option<String>,
392}
393
394#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
395pub struct RealtimeResponseCreated {
396    pub response_id: Option<String>,
397}
398
399#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
400pub struct RealtimeResponseDone {
401    pub response_id: Option<String>,
402}
403
404#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
405pub enum RealtimeEvent {
406    SessionUpdated {
407        realtime_session_id: String,
408        instructions: Option<String>,
409    },
410    InputAudioSpeechStarted(RealtimeInputAudioSpeechStarted),
411    InputTranscriptDelta(RealtimeTranscriptDelta),
412    InputTranscriptDone(RealtimeTranscriptDone),
413    OutputTranscriptDelta(RealtimeTranscriptDelta),
414    OutputTranscriptDone(RealtimeTranscriptDone),
415    AudioOut(RealtimeAudioFrame),
416    ResponseCreated(RealtimeResponseCreated),
417    ResponseCancelled(RealtimeResponseCancelled),
418    ResponseDone(RealtimeResponseDone),
419    ConversationItemAdded(Value),
420    ConversationItemDone {
421        item_id: String,
422    },
423    HandoffRequested(RealtimeHandoffRequested),
424    NoopRequested(RealtimeNoopRequested),
425    Error(String),
426}
427
428#[derive(Debug, Clone, PartialEq)]
429pub struct ConversationAudioParams {
430    pub frame: RealtimeAudioFrame,
431}
432
433#[derive(Debug, Clone, PartialEq, Eq)]
434pub struct ConversationTextParams {
435    pub text: String,
436    pub role: ConversationTextRole,
437}
438
439#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema, TS)]
440#[serde(rename_all = "snake_case")]
441#[ts(rename_all = "snake_case")]
442pub enum ConversationTextRole {
443    #[default]
444    User,
445    Developer,
446    Assistant,
447}
448
449#[derive(Debug, Clone, PartialEq)]
450pub struct ConversationSpeechParams {
451    pub text: String,
452}
453
454/// Persistent thread-settings overrides that can be applied before user input or
455/// on their own.
456#[derive(Debug, Clone, Default, PartialEq)]
457pub struct ThreadSettingsOverrides {
458    /// Updated fallback `cwd` and environments supplied together as a complete pair.
459    pub environments: Option<TurnEnvironmentSelections>,
460
461    /// Updated profile-defined workspace roots for status summaries and
462    /// per-turn config reconstruction.
463    pub profile_workspace_roots: Option<Vec<AbsolutePathBuf>>,
464
465    /// Updated command approval policy.
466    pub approval_policy: Option<AskForApproval>,
467
468    /// Updated approval reviewer for future approval prompts.
469    pub approvals_reviewer: Option<ApprovalsReviewer>,
470
471    /// Updated sandbox policy for tool calls.
472    pub sandbox_policy: Option<SandboxPolicy>,
473
474    /// Updated permissions profile for tool calls.
475    pub permission_profile: Option<PermissionProfile>,
476
477    /// Named or built-in profile that produced `permission_profile`, if the
478    /// update selected a profile rather than supplying raw permissions.
479    pub active_permission_profile: Option<ActivePermissionProfile>,
480
481    /// Updated Windows sandbox mode for tool execution.
482    pub windows_sandbox_level: Option<WindowsSandboxLevel>,
483
484    /// Updated model slug. When set, the model info is derived automatically.
485    pub model: Option<String>,
486
487    /// Updated reasoning effort (honored only for reasoning-capable models).
488    ///
489    /// Use `Some(Some(_))` to set a specific effort, `Some(None)` to clear the
490    /// effort, or `None` to leave the existing value unchanged.
491    pub effort: Option<Option<ReasoningEffortConfig>>,
492
493    /// Updated reasoning summary preference (honored only for reasoning-capable models).
494    pub summary: Option<ReasoningSummaryConfig>,
495
496    /// Updated service tier preference for future turns.
497    ///
498    /// Use `Some(Some(_))` to set a specific tier, `Some(None)` to clear the
499    /// preference, or `None` to leave the existing value unchanged.
500    pub service_tier: Option<Option<String>>,
501
502    /// EXPERIMENTAL - set a pre-set collaboration mode.
503    /// Takes precedence over model, effort, and developer instructions if set.
504    pub collaboration_mode: Option<CollaborationMode>,
505
506    /// Updated personality preference.
507    pub personality: Option<Personality>,
508}
509
510/// Source classification for client-supplied context.
511#[derive(Debug, Clone, Copy, PartialEq, Eq)]
512pub enum AdditionalContextKind {
513    Untrusted,
514    Application,
515}
516
517/// Client-supplied context keyed by an opaque source identifier.
518#[derive(Debug, Clone, PartialEq, Eq)]
519pub struct AdditionalContextEntry {
520    pub value: String,
521    pub kind: AdditionalContextKind,
522}
523
524/// Submission operation
525#[derive(Debug, Clone, PartialEq)]
526#[allow(clippy::large_enum_variant)]
527#[non_exhaustive]
528pub enum Op {
529    /// Abort current task without terminating background terminal processes.
530    /// This server sends [`EventMsg::TurnAborted`] in response.
531    Interrupt,
532
533    /// Terminate all running background terminal processes for this thread.
534    /// Use this when callers intentionally want to stop long-lived background shells.
535    CleanBackgroundTerminals,
536
537    /// Start a realtime conversation stream.
538    RealtimeConversationStart(ConversationStartParams),
539
540    /// Send audio input to the running realtime conversation stream.
541    RealtimeConversationAudio(ConversationAudioParams),
542
543    /// Send text input to the running realtime conversation stream.
544    RealtimeConversationText(ConversationTextParams),
545
546    /// Append speakable text to the running realtime conversation stream.
547    RealtimeConversationSpeech(ConversationSpeechParams),
548
549    /// Close the running realtime conversation stream.
550    RealtimeConversationClose,
551
552    /// Request the list of voices supported by realtime conversation streams.
553    RealtimeConversationListVoices,
554
555    /// User input, optionally with thread-settings overrides applied first.
556    UserInput {
557        /// User input items, see `InputItem`
558        items: Vec<UserInput>,
559        /// Optional JSON Schema used to constrain the final assistant message for this turn.
560        final_output_json_schema: Option<Value>,
561        /// Optional turn-scoped Responses API `client_metadata`.
562        responsesapi_client_metadata: Option<HashMap<String, String>>,
563        /// Client-supplied context fragments keyed by an opaque source identifier.
564        additional_context: BTreeMap<String, AdditionalContextEntry>,
565
566        /// Persistent thread-settings overrides to apply before the input.
567        thread_settings: ThreadSettingsOverrides,
568    },
569
570    /// Apply persistent thread-settings overrides without starting a turn.
571    ///
572    /// This uses the same submission queue as turn starts so app-server can
573    /// preserve caller order between both kinds of mutation.
574    ThreadSettings {
575        /// Persistent thread-settings overrides to apply.
576        thread_settings: ThreadSettingsOverrides,
577    },
578
579    /// Inter-agent communication that should be recorded as agent-message history
580    /// while still using the normal thread submission lifecycle.
581    InterAgentCommunication {
582        communication: InterAgentCommunication,
583    },
584
585    /// Approve a command execution
586    ExecApproval {
587        /// The id of the submission we are approving
588        id: String,
589        /// Turn id associated with the approval event, when available.
590        turn_id: Option<String>,
591        /// The user's decision in response to the request.
592        decision: ReviewDecision,
593    },
594
595    /// Approve a code patch
596    PatchApproval {
597        /// The id of the submission we are approving
598        id: String,
599        /// The user's decision in response to the request.
600        decision: ReviewDecision,
601    },
602
603    /// Resolve an MCP elicitation request.
604    ResolveElicitation {
605        /// Name of the MCP server that issued the request.
606        server_name: String,
607        /// Request identifier from the MCP server.
608        request_id: RequestId,
609        /// User's decision for the request.
610        decision: ElicitationAction,
611        /// Structured user input supplied for accepted elicitations.
612        content: Option<Value>,
613        /// Optional client metadata associated with the elicitation response.
614        meta: Option<Value>,
615    },
616
617    /// Resolve a request_user_input tool call.
618    UserInputAnswer {
619        /// Turn id for the in-flight request.
620        id: String,
621        /// User-provided answers.
622        response: RequestUserInputResponse,
623    },
624
625    /// Resolve a request_permissions tool call.
626    RequestPermissionsResponse {
627        /// Call id for the in-flight request.
628        id: String,
629        /// User-granted permissions.
630        response: RequestPermissionsResponse,
631    },
632
633    /// Resolve a dynamic tool call request.
634    DynamicToolResponse {
635        /// Call id for the in-flight request.
636        id: String,
637        /// Tool output payload.
638        response: DynamicToolResponse,
639    },
640
641    /// Request MCP servers to reinitialize and refresh cached tool lists.
642    RefreshMcpServers { config: McpServerRefreshConfig },
643
644    /// Reload user config layer overrides for the active session.
645    ///
646    /// This updates runtime config-derived behavior (for example app
647    /// enable/disable state) without restarting the thread.
648    ReloadUserConfig,
649
650    /// Request the agent to summarize the current conversation context.
651    /// The agent will use its existing context (either conversation history or previous response id)
652    /// to generate a summary which will be returned as an AgentMessage event.
653    Compact,
654
655    /// Set whether the thread remains eligible for memory generation.
656    ///
657    /// This persists thread-level memory mode metadata without involving the
658    /// model.
659    SetThreadMemoryMode { mode: ThreadMemoryMode },
660
661    /// Request Codex to drop the last N user turns from in-memory context.
662    ///
663    /// This does not attempt to revert local filesystem changes. Clients are
664    /// responsible for undoing any edits on disk.
665    ThreadRollback { num_turns: u32 },
666
667    /// Request a code review from the agent.
668    Review { review_request: ReviewRequest },
669
670    /// Record that the user approved one retry of a concrete Guardian-denied action.
671    ApproveGuardianDeniedAction { event: GuardianAssessmentEvent },
672
673    /// Request to shut down codex instance.
674    Shutdown,
675
676    /// Execute a user-initiated one-off shell command (triggered by "!cmd").
677    ///
678    /// The command string is executed using the user's default shell and may
679    /// include shell syntax (pipes, redirects, etc.). Output is streamed via
680    /// `ExecCommand*` events and the UI regains control upon `TurnComplete`.
681    RunUserShellCommand {
682        /// The raw command string after '!'
683        command: String,
684    },
685}
686
687#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, JsonSchema)]
688#[serde(rename_all = "lowercase")]
689pub enum ThreadMemoryMode {
690    Enabled,
691    Disabled,
692}
693
694#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq, JsonSchema, TS)]
695#[serde(rename_all = "lowercase")]
696#[ts(rename_all = "lowercase")]
697pub enum ThreadHistoryMode {
698    #[default]
699    Legacy,
700    Paginated,
701}
702
703impl ThreadHistoryMode {
704    pub const fn as_str(self) -> &'static str {
705        match self {
706            Self::Legacy => "legacy",
707            Self::Paginated => "paginated",
708        }
709    }
710}
711
712impl FromStr for ThreadHistoryMode {
713    type Err = String;
714
715    fn from_str(value: &str) -> Result<Self, Self::Err> {
716        match value {
717            "legacy" => Ok(Self::Legacy),
718            "paginated" => Ok(Self::Paginated),
719            _ => Err(format!("unknown thread history mode `{value}`")),
720        }
721    }
722}
723
724impl From<Vec<UserInput>> for Op {
725    fn from(value: Vec<UserInput>) -> Self {
726        Op::UserInput {
727            items: value,
728            final_output_json_schema: None,
729            responsesapi_client_metadata: None,
730            additional_context: Default::default(),
731            thread_settings: ThreadSettingsOverrides::default(),
732        }
733    }
734}
735
736#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema, TS)]
737pub struct InterAgentCommunication {
738    #[serde(default, skip_serializing_if = "Option::is_none")]
739    #[ts(optional)]
740    pub id: Option<ResponseItemId>,
741    pub author: AgentPath,
742    pub recipient: AgentPath,
743    #[serde(default)]
744    pub other_recipients: Vec<AgentPath>,
745    pub content: String,
746    #[serde(default, skip_serializing_if = "Option::is_none")]
747    #[ts(optional)]
748    pub encrypted_content: Option<String>,
749    #[serde(default, skip_serializing_if = "Option::is_none")]
750    #[ts(optional)]
751    pub internal_chat_message_metadata_passthrough: Option<InternalChatMessageMetadataPassthrough>,
752    pub trigger_turn: bool,
753}
754
755impl InterAgentCommunication {
756    pub fn new(
757        author: AgentPath,
758        recipient: AgentPath,
759        other_recipients: Vec<AgentPath>,
760        content: String,
761        trigger_turn: bool,
762    ) -> Self {
763        Self {
764            id: None,
765            author,
766            recipient,
767            other_recipients,
768            content,
769            encrypted_content: None,
770            internal_chat_message_metadata_passthrough: None,
771            trigger_turn,
772        }
773    }
774
775    pub fn new_encrypted(
776        author: AgentPath,
777        recipient: AgentPath,
778        other_recipients: Vec<AgentPath>,
779        encrypted_content: String,
780        trigger_turn: bool,
781    ) -> Self {
782        Self {
783            id: None,
784            author,
785            recipient,
786            other_recipients,
787            content: String::new(),
788            encrypted_content: Some(encrypted_content),
789            internal_chat_message_metadata_passthrough: None,
790            trigger_turn,
791        }
792    }
793
794    pub fn set_turn_id_if_missing(&mut self, turn_id: &str) {
795        InternalChatMessageMetadataPassthrough::set_turn_id_if_missing(
796            &mut self.internal_chat_message_metadata_passthrough,
797            turn_id,
798        );
799    }
800
801    pub fn to_response_input_item(&self) -> ResponseInputItem {
802        let mut communication = self.clone();
803        communication.id = None;
804        communication.internal_chat_message_metadata_passthrough = None;
805        ResponseInputItem::Message {
806            role: "assistant".to_string(),
807            content: vec![ContentItem::OutputText {
808                text: serde_json::to_string(&communication).unwrap_or_default(),
809            }],
810            phase: Some(MessagePhase::Commentary),
811        }
812    }
813
814    pub fn to_model_input_item(&self) -> ResponseItem {
815        let content = match &self.encrypted_content {
816            Some(encrypted_content) => {
817                let message_type = if self.trigger_turn {
818                    "NEW_TASK"
819                } else {
820                    "MESSAGE"
821                };
822                vec![
823                    AgentMessageInputContent::InputText {
824                        text: format!(
825                            "Message Type: {message_type}\nTask name: {}\nSender: {}\nPayload:\n",
826                            self.recipient, self.author
827                        ),
828                    },
829                    AgentMessageInputContent::EncryptedContent {
830                        encrypted_content: encrypted_content.clone(),
831                    },
832                ]
833            }
834            None => vec![AgentMessageInputContent::InputText {
835                text: self.content.clone(),
836            }],
837        };
838        ResponseItem::AgentMessage {
839            id: self.id.clone(),
840            author: self.author.to_string(),
841            recipient: self.recipient.to_string(),
842            content,
843            internal_chat_message_metadata_passthrough: self
844                .internal_chat_message_metadata_passthrough
845                .clone(),
846        }
847    }
848
849    pub fn is_message_content(content: &[ContentItem]) -> bool {
850        Self::from_message_content(content).is_some()
851    }
852
853    pub fn from_message_content(content: &[ContentItem]) -> Option<Self> {
854        match content {
855            [ContentItem::InputText { text }] | [ContentItem::OutputText { text }] => {
856                serde_json::from_str(text).ok()
857            }
858            _ => None,
859        }
860    }
861}
862
863impl Op {
864    pub fn kind(&self) -> &'static str {
865        match self {
866            Self::Interrupt => "interrupt",
867            Self::CleanBackgroundTerminals => "clean_background_terminals",
868            Self::RealtimeConversationStart(_) => "realtime_conversation_start",
869            Self::RealtimeConversationAudio(_) => "realtime_conversation_audio",
870            Self::RealtimeConversationText(_) => "realtime_conversation_text",
871            Self::RealtimeConversationSpeech(_) => "realtime_conversation_speech",
872            Self::RealtimeConversationClose => "realtime_conversation_close",
873            Self::RealtimeConversationListVoices => "realtime_conversation_list_voices",
874            Self::UserInput { .. } => "user_input",
875            Self::ThreadSettings { .. } => "thread_settings",
876            Self::InterAgentCommunication { .. } => "inter_agent_communication",
877            Self::ExecApproval { .. } => "exec_approval",
878            Self::PatchApproval { .. } => "patch_approval",
879            Self::ResolveElicitation { .. } => "resolve_elicitation",
880            Self::UserInputAnswer { .. } => "user_input_answer",
881            Self::RequestPermissionsResponse { .. } => "request_permissions_response",
882            Self::DynamicToolResponse { .. } => "dynamic_tool_response",
883            Self::RefreshMcpServers { .. } => "refresh_mcp_servers",
884            Self::ReloadUserConfig => "reload_user_config",
885            Self::Compact => "compact",
886            Self::SetThreadMemoryMode { .. } => "set_thread_memory_mode",
887            Self::ThreadRollback { .. } => "thread_rollback",
888            Self::Review { .. } => "review",
889            Self::ApproveGuardianDeniedAction { .. } => "approve_guardian_denied_action",
890            Self::Shutdown => "shutdown",
891            Self::RunUserShellCommand { .. } => "run_user_shell_command",
892        }
893    }
894}
895
896/// Determines the conditions under which the user is consulted to approve
897/// running the command proposed by Codex.
898#[derive(
899    Debug,
900    Clone,
901    Copy,
902    Default,
903    PartialEq,
904    Eq,
905    Hash,
906    Serialize,
907    Deserialize,
908    Display,
909    JsonSchema,
910    TS,
911)]
912#[serde(rename_all = "kebab-case")]
913#[strum(serialize_all = "kebab-case")]
914pub enum AskForApproval {
915    /// Under this policy, only "known safe" commands—as determined by
916    /// `is_safe_command()`—that **only read files** are auto‑approved.
917    /// Everything else will ask the user to approve.
918    #[serde(rename = "untrusted")]
919    #[strum(serialize = "untrusted")]
920    UnlessTrusted,
921
922    /// The model decides when to ask the user for approval.
923    #[serde(alias = "on-failure")]
924    #[default]
925    OnRequest,
926
927    /// Fine-grained controls for individual approval flows.
928    ///
929    /// When a field is `true`, commands in that category are allowed. When it
930    /// is `false`, those requests are automatically rejected instead of shown
931    /// to the user.
932    #[strum(serialize = "granular")]
933    Granular(GranularApprovalConfig),
934
935    /// Never ask the user to approve commands. Failures are immediately returned
936    /// to the model, and never escalated to the user for approval.
937    Never,
938}
939
940#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, TS)]
941pub struct GranularApprovalConfig {
942    /// Whether to allow shell command approval requests, including inline
943    /// `with_additional_permissions` and `require_escalated` requests.
944    pub sandbox_approval: bool,
945    /// Whether to allow prompts triggered by execpolicy `prompt` rules.
946    pub rules: bool,
947    /// Whether to allow approval prompts triggered by skill script execution.
948    #[serde(default)]
949    pub skill_approval: bool,
950    /// Whether to allow prompts triggered by the `request_permissions` tool.
951    #[serde(default)]
952    pub request_permissions: bool,
953    /// Whether to allow MCP elicitation prompts.
954    pub mcp_elicitations: bool,
955}
956
957impl GranularApprovalConfig {
958    pub const fn allows_sandbox_approval(self) -> bool {
959        self.sandbox_approval
960    }
961
962    pub const fn allows_rules_approval(self) -> bool {
963        self.rules
964    }
965
966    pub const fn allows_skill_approval(self) -> bool {
967        self.skill_approval
968    }
969
970    pub const fn allows_request_permissions(self) -> bool {
971        self.request_permissions
972    }
973
974    pub const fn allows_mcp_elicitations(self) -> bool {
975        self.mcp_elicitations
976    }
977}
978
979/// Represents whether outbound network access is available to the agent.
980#[derive(
981    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display, Default, JsonSchema, TS,
982)]
983#[serde(rename_all = "kebab-case")]
984#[strum(serialize_all = "kebab-case")]
985pub enum NetworkAccess {
986    #[default]
987    Restricted,
988    Enabled,
989}
990
991impl NetworkAccess {
992    pub fn is_enabled(self) -> bool {
993        matches!(self, NetworkAccess::Enabled)
994    }
995}
996
997/// Determines execution restrictions for model shell commands.
998#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Display, JsonSchema, TS)]
999#[strum(serialize_all = "kebab-case")]
1000#[serde(tag = "type", rename_all = "kebab-case")]
1001pub enum SandboxPolicy {
1002    /// No restrictions whatsoever. Use with caution.
1003    #[serde(rename = "danger-full-access")]
1004    DangerFullAccess,
1005
1006    /// Read-only access configuration.
1007    #[serde(rename = "read-only")]
1008    ReadOnly {
1009        /// When set to `true`, outbound network access is allowed. `false` by
1010        /// default.
1011        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1012        network_access: bool,
1013    },
1014
1015    /// Indicates the process is already in an external sandbox. Allows full
1016    /// disk access while honoring the provided network setting.
1017    #[serde(rename = "external-sandbox")]
1018    ExternalSandbox {
1019        /// Whether the external sandbox permits outbound network traffic.
1020        #[serde(default)]
1021        network_access: NetworkAccess,
1022    },
1023
1024    /// Same as `ReadOnly` but additionally grants write access to the current
1025    /// working directory ("workspace").
1026    #[serde(rename = "workspace-write")]
1027    WorkspaceWrite {
1028        /// Additional folders (beyond cwd and possibly TMPDIR) that should be
1029        /// writable from within the sandbox.
1030        #[serde(default, skip_serializing_if = "Vec::is_empty")]
1031        writable_roots: Vec<AbsolutePathBuf>,
1032
1033        /// When set to `true`, outbound network access is allowed. `false` by
1034        /// default.
1035        #[serde(default)]
1036        network_access: bool,
1037
1038        /// When set to `true`, will NOT include the per-user `TMPDIR`
1039        /// environment variable among the default writable roots. Defaults to
1040        /// `false`.
1041        #[serde(default)]
1042        exclude_tmpdir_env_var: bool,
1043
1044        /// When set to `true`, will NOT include the `/tmp` among the default
1045        /// writable roots on UNIX. Defaults to `false`.
1046        #[serde(default)]
1047        exclude_slash_tmp: bool,
1048    },
1049}
1050
1051/// A writable root path accompanied by a list of subpaths that should remain
1052/// read‑only even when the root is writable. This is primarily used to ensure
1053/// that folders containing files that could be modified to escalate the
1054/// privileges of the agent (e.g. `.codex`, `.git`, notably `.git/hooks`) under
1055/// a writable root are not modified by the agent.
1056#[derive(Debug, Clone, PartialEq, Eq, JsonSchema)]
1057pub struct WritableRoot {
1058    pub root: AbsolutePathBuf,
1059
1060    /// By construction, these subpaths are all under `root`.
1061    pub read_only_subpaths: Vec<AbsolutePathBuf>,
1062
1063    /// Workspace metadata path names that must not be created or replaced under
1064    /// `root` unless the policy grants an explicit write rule for that metadata
1065    /// path.
1066    pub protected_metadata_names: Vec<String>,
1067}
1068
1069impl WritableRoot {
1070    pub fn is_path_writable(&self, path: &Path) -> bool {
1071        // Check if the path is under the root.
1072        if !path.starts_with(&self.root) {
1073            return false;
1074        }
1075
1076        // Check if the path is under any of the read-only subpaths.
1077        for subpath in &self.read_only_subpaths {
1078            if path.starts_with(subpath) {
1079                return false;
1080            }
1081        }
1082
1083        if self.path_contains_protected_metadata_name(path) {
1084            return false;
1085        }
1086
1087        true
1088    }
1089
1090    fn path_contains_protected_metadata_name(&self, path: &Path) -> bool {
1091        let Ok(relative_path) = path.strip_prefix(&self.root) else {
1092            return false;
1093        };
1094
1095        let Some(first_component) = relative_path.components().next() else {
1096            return false;
1097        };
1098
1099        self.protected_metadata_names
1100            .iter()
1101            .any(|name| first_component.as_os_str() == std::ffi::OsStr::new(name))
1102    }
1103}
1104
1105impl FromStr for SandboxPolicy {
1106    type Err = serde_json::Error;
1107
1108    fn from_str(s: &str) -> Result<Self, Self::Err> {
1109        serde_json::from_str(s)
1110    }
1111}
1112
1113impl FromStr for FileSystemSandboxPolicy {
1114    type Err = serde_json::Error;
1115
1116    fn from_str(s: &str) -> Result<Self, Self::Err> {
1117        serde_json::from_str(s)
1118    }
1119}
1120
1121impl FromStr for NetworkSandboxPolicy {
1122    type Err = serde_json::Error;
1123
1124    fn from_str(s: &str) -> Result<Self, Self::Err> {
1125        serde_json::from_str(s)
1126    }
1127}
1128
1129impl SandboxPolicy {
1130    /// Returns a policy with read-only disk access and no network.
1131    pub fn new_read_only_policy() -> Self {
1132        SandboxPolicy::ReadOnly {
1133            network_access: false,
1134        }
1135    }
1136
1137    /// Returns a policy that can read the entire disk, but can only write to
1138    /// the current working directory and the per-user tmp dir on macOS. It does
1139    /// not allow network access.
1140    pub fn new_workspace_write_policy() -> Self {
1141        SandboxPolicy::WorkspaceWrite {
1142            writable_roots: vec![],
1143            network_access: false,
1144            exclude_tmpdir_env_var: false,
1145            exclude_slash_tmp: false,
1146        }
1147    }
1148
1149    pub fn has_full_disk_read_access(&self) -> bool {
1150        true
1151    }
1152
1153    pub fn has_full_disk_write_access(&self) -> bool {
1154        match self {
1155            SandboxPolicy::DangerFullAccess => true,
1156            SandboxPolicy::ExternalSandbox { .. } => true,
1157            SandboxPolicy::ReadOnly { .. } => false,
1158            SandboxPolicy::WorkspaceWrite { .. } => false,
1159        }
1160    }
1161
1162    pub fn has_full_network_access(&self) -> bool {
1163        match self {
1164            SandboxPolicy::DangerFullAccess => true,
1165            SandboxPolicy::ExternalSandbox { network_access } => network_access.is_enabled(),
1166            SandboxPolicy::ReadOnly { network_access, .. } => *network_access,
1167            SandboxPolicy::WorkspaceWrite { network_access, .. } => *network_access,
1168        }
1169    }
1170
1171    /// Returns the list of writable roots (tailored to the current working
1172    /// directory) together with subpaths that should remain read‑only under
1173    /// each writable root.
1174    pub fn get_writable_roots_with_cwd(&self, cwd: &Path) -> Vec<WritableRoot> {
1175        match self {
1176            SandboxPolicy::DangerFullAccess => Vec::new(),
1177            SandboxPolicy::ExternalSandbox { .. } => Vec::new(),
1178            SandboxPolicy::ReadOnly { .. } => Vec::new(),
1179            SandboxPolicy::WorkspaceWrite {
1180                writable_roots,
1181                exclude_tmpdir_env_var,
1182                exclude_slash_tmp,
1183                network_access: _,
1184            } => {
1185                // Start from explicitly configured writable roots.
1186                let mut roots: Vec<AbsolutePathBuf> = writable_roots.clone();
1187
1188                // Always include defaults: cwd, /tmp (if present on Unix), and
1189                // on macOS, the per-user TMPDIR unless explicitly excluded.
1190                // TODO(mbolin): cwd param should be AbsolutePathBuf.
1191                let cwd_absolute = AbsolutePathBuf::from_absolute_path(cwd);
1192                match cwd_absolute {
1193                    Ok(cwd) => {
1194                        roots.push(cwd);
1195                    }
1196                    Err(e) => {
1197                        error!(
1198                            "Ignoring invalid cwd {:?} for sandbox writable root: {}",
1199                            cwd, e
1200                        );
1201                    }
1202                }
1203
1204                // Include /tmp on Unix unless explicitly excluded.
1205                if cfg!(unix) && !exclude_slash_tmp {
1206                    match AbsolutePathBuf::from_absolute_path("/tmp") {
1207                        Ok(slash_tmp) => {
1208                            if slash_tmp.as_path().is_dir() {
1209                                roots.push(slash_tmp);
1210                            }
1211                        }
1212                        Err(e) => {
1213                            error!("Ignoring invalid /tmp for sandbox writable root: {e}");
1214                        }
1215                    }
1216                }
1217
1218                // Include $TMPDIR unless explicitly excluded. On macOS, TMPDIR
1219                // is per-user, so writes to TMPDIR should not be readable by
1220                // other users on the system.
1221                //
1222                // By comparison, TMPDIR is not guaranteed to be defined on
1223                // Linux or Windows, but supporting it here gives users a way to
1224                // provide the model with their own temporary directory without
1225                // having to hardcode it in the config.
1226                if !exclude_tmpdir_env_var
1227                    && let Some(tmpdir) = std::env::var_os("TMPDIR")
1228                    && !tmpdir.is_empty()
1229                {
1230                    match AbsolutePathBuf::from_absolute_path(PathBuf::from(&tmpdir)) {
1231                        Ok(tmpdir_path) => {
1232                            roots.push(tmpdir_path);
1233                        }
1234                        Err(e) => {
1235                            error!(
1236                                "Ignoring invalid TMPDIR value {tmpdir:?} for sandbox writable root: {e}",
1237                            );
1238                        }
1239                    }
1240                }
1241
1242                // For each root, compute subpaths that should remain read-only.
1243                let cwd_root = AbsolutePathBuf::from_absolute_path(cwd).ok();
1244                roots
1245                    .into_iter()
1246                    .map(|writable_root| {
1247                        let protect_missing_dot_codex = cwd_root
1248                            .as_ref()
1249                            .is_some_and(|cwd_root| cwd_root == &writable_root);
1250                        WritableRoot {
1251                            read_only_subpaths: default_read_only_subpaths_for_writable_root(
1252                                &writable_root,
1253                                protect_missing_dot_codex,
1254                            ),
1255                            protected_metadata_names: Vec::new(),
1256                            root: writable_root,
1257                        }
1258                    })
1259                    .collect()
1260            }
1261        }
1262    }
1263}
1264
1265/// Event Queue Entry - events from agent
1266#[derive(Debug, Clone, Deserialize, Serialize)]
1267pub struct Event {
1268    /// Submission `id` that this event is correlated with.
1269    pub id: String,
1270    /// Payload
1271    pub msg: EventMsg,
1272}
1273
1274#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
1275pub struct EnvironmentConnectionEvent {
1276    pub environment_id: String,
1277}
1278
1279/// Response event from the agent
1280/// NOTE: Make sure none of these values have optional types, as it will mess up the extension code-gen.
1281#[derive(Debug, Clone, Deserialize, Serialize, Display, JsonSchema, TS)]
1282#[serde(tag = "type", rename_all = "snake_case")]
1283#[ts(tag = "type")]
1284#[strum(serialize_all = "snake_case")]
1285pub enum EventMsg {
1286    /// Error while executing a submission
1287    Error(ErrorEvent),
1288
1289    /// Warning issued while processing a submission. Unlike `Error`, this
1290    /// indicates the turn continued but the user should still be notified.
1291    Warning(WarningEvent),
1292
1293    /// Warning issued by the guardian automatic approval reviewer.
1294    GuardianWarning(WarningEvent),
1295
1296    /// Realtime conversation lifecycle start event.
1297    RealtimeConversationStarted(RealtimeConversationStartedEvent),
1298
1299    /// Realtime conversation streaming payload event.
1300    RealtimeConversationRealtime(RealtimeConversationRealtimeEvent),
1301
1302    /// Realtime conversation lifecycle close event.
1303    RealtimeConversationClosed(RealtimeConversationClosedEvent),
1304
1305    /// Realtime session description protocol payload.
1306    RealtimeConversationSdp(RealtimeConversationSdpEvent),
1307
1308    /// Model routing changed from the requested model to a different model.
1309    ModelReroute(ModelRerouteEvent),
1310
1311    /// Backend recommends additional account verification for this turn.
1312    ModelVerification(ModelVerificationEvent),
1313
1314    /// Backend moderation metadata intended for first-party turn presentation.
1315    TurnModerationMetadata(TurnModerationMetadataEvent),
1316
1317    /// Backend indicates that response output is waiting on a safety review.
1318    SafetyBuffering(SafetyBufferingEvent),
1319
1320    /// Conversation history was compacted (either automatically or manually).
1321    ContextCompacted(ContextCompactedEvent),
1322
1323    /// Conversation history was rolled back by dropping the last N user turns.
1324    ThreadRolledBack(ThreadRolledBackEvent),
1325
1326    /// Agent has started a turn.
1327    /// v1 wire format uses `task_started`; accept `turn_started` for v2 interop.
1328    #[serde(rename = "task_started", alias = "turn_started")]
1329    TurnStarted(TurnStartedEvent),
1330
1331    /// Persistent thread-settings overrides from the correlated submission have
1332    /// been applied to the session configuration.
1333    ThreadSettingsApplied(ThreadSettingsAppliedEvent),
1334
1335    /// Agent has completed all actions.
1336    /// v1 wire format uses `task_complete`; accept `turn_complete` for v2 interop.
1337    #[serde(rename = "task_complete", alias = "turn_complete")]
1338    TurnComplete(TurnCompleteEvent),
1339
1340    /// Usage update for the current session, including totals and last turn.
1341    /// Optional means unknown — UIs should not display when `None`.
1342    TokenCount(TokenCountEvent),
1343
1344    /// Agent text output message
1345    AgentMessage(AgentMessageEvent),
1346
1347    /// User/system input message (what was sent to the model)
1348    UserMessage(UserMessageEvent),
1349
1350    /// Reasoning event from agent.
1351    AgentReasoning(AgentReasoningEvent),
1352
1353    /// Raw chain-of-thought from agent.
1354    AgentReasoningRawContent(AgentReasoningRawContentEvent),
1355
1356    /// Signaled when the model begins a new reasoning summary section (e.g., a new titled block).
1357    AgentReasoningSectionBreak(AgentReasoningSectionBreakEvent),
1358
1359    /// Ack the client's configure message.
1360    SessionConfigured(SessionConfiguredEvent),
1361
1362    /// A selected environment completed its connection handshake.
1363    EnvironmentConnected(EnvironmentConnectionEvent),
1364
1365    /// A selected environment lost its established connection.
1366    EnvironmentDisconnected(EnvironmentConnectionEvent),
1367
1368    /// Updated long-running goal metadata for the thread.
1369    ThreadGoalUpdated(ThreadGoalUpdatedEvent),
1370
1371    /// Incremental MCP startup progress updates.
1372    McpStartupUpdate(McpStartupUpdateEvent),
1373
1374    /// Aggregate MCP startup completion summary.
1375    McpStartupComplete(McpStartupCompleteEvent),
1376
1377    McpToolCallBegin(McpToolCallBeginEvent),
1378
1379    McpToolCallEnd(McpToolCallEndEvent),
1380
1381    WebSearchBegin(WebSearchBeginEvent),
1382
1383    WebSearchEnd(WebSearchEndEvent),
1384
1385    ImageGenerationBegin(ImageGenerationBeginEvent),
1386
1387    ImageGenerationEnd(ImageGenerationEndEvent),
1388
1389    /// Notification that the server is about to execute a command.
1390    ExecCommandBegin(ExecCommandBeginEvent),
1391
1392    /// Incremental chunk of output from a running command.
1393    ExecCommandOutputDelta(ExecCommandOutputDeltaEvent),
1394
1395    /// Terminal interaction for an in-progress command (stdin sent and stdout observed).
1396    TerminalInteraction(TerminalInteractionEvent),
1397
1398    ExecCommandEnd(ExecCommandEndEvent),
1399
1400    /// Notification that the agent attached a local image via the view_image tool.
1401    ViewImageToolCall(ViewImageToolCallEvent),
1402
1403    ExecApprovalRequest(ExecApprovalRequestEvent),
1404
1405    RequestPermissions(RequestPermissionsEvent),
1406
1407    RequestUserInput(RequestUserInputEvent),
1408
1409    DynamicToolCallRequest(DynamicToolCallRequest),
1410
1411    DynamicToolCallResponse(DynamicToolCallResponseEvent),
1412
1413    ElicitationRequest(ElicitationRequestEvent),
1414
1415    ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent),
1416
1417    /// Structured lifecycle event for a guardian-reviewed approval request.
1418    GuardianAssessment(GuardianAssessmentEvent),
1419
1420    /// Notification advising the user that something they are using has been
1421    /// deprecated and should be phased out.
1422    DeprecationNotice(DeprecationNoticeEvent),
1423
1424    /// Notification that a model stream experienced an error or disconnect
1425    /// and the system is handling it (e.g., retrying with backoff).
1426    StreamError(StreamErrorEvent),
1427
1428    /// Notification that the agent is about to apply a code patch. Mirrors
1429    /// `ExecCommandBegin` so front‑ends can show progress indicators.
1430    PatchApplyBegin(PatchApplyBeginEvent),
1431
1432    /// Latest model-generated structured changes for an `apply_patch` call.
1433    PatchApplyUpdated(PatchApplyUpdatedEvent),
1434
1435    /// Notification that a patch application has finished.
1436    PatchApplyEnd(PatchApplyEndEvent),
1437
1438    TurnDiff(TurnDiffEvent),
1439
1440    /// List of voices supported by realtime conversation streams.
1441    RealtimeConversationListVoicesResponse(RealtimeConversationListVoicesResponseEvent),
1442
1443    PlanUpdate(UpdatePlanArgs),
1444
1445    TurnAborted(TurnAbortedEvent),
1446
1447    /// Notification that the agent is shutting down.
1448    ShutdownComplete,
1449
1450    /// Entered review mode.
1451    EnteredReviewMode(EnteredReviewModeEvent),
1452
1453    /// Exited review mode with an optional final result to apply.
1454    ExitedReviewMode(ExitedReviewModeEvent),
1455
1456    RawResponseItem(RawResponseItemEvent),
1457    RawResponseCompleted(RawResponseCompletedEvent),
1458
1459    ItemStarted(ItemStartedEvent),
1460    ItemCompleted(ItemCompletedEvent),
1461    HookStarted(HookStartedEvent),
1462    HookCompleted(HookCompletedEvent),
1463
1464    AgentMessageContentDelta(AgentMessageContentDeltaEvent),
1465    PlanDelta(PlanDeltaEvent),
1466    ReasoningContentDelta(ReasoningContentDeltaEvent),
1467    ReasoningRawContentDelta(ReasoningRawContentDeltaEvent),
1468
1469    /// Collab interaction: agent spawn begin.
1470    CollabAgentSpawnBegin(CollabAgentSpawnBeginEvent),
1471    /// Collab interaction: agent spawn end.
1472    CollabAgentSpawnEnd(CollabAgentSpawnEndEvent),
1473    /// Collab interaction: agent interaction begin.
1474    CollabAgentInteractionBegin(CollabAgentInteractionBeginEvent),
1475    /// Collab interaction: agent interaction end.
1476    CollabAgentInteractionEnd(CollabAgentInteractionEndEvent),
1477    /// Collab interaction: waiting begin.
1478    CollabWaitingBegin(CollabWaitingBeginEvent),
1479    /// Collab interaction: waiting end.
1480    CollabWaitingEnd(CollabWaitingEndEvent),
1481    /// Collab interaction: close begin.
1482    CollabCloseBegin(CollabCloseBeginEvent),
1483    /// Collab interaction: close end.
1484    CollabCloseEnd(CollabCloseEndEvent),
1485    /// Collab interaction: resume begin.
1486    CollabResumeBegin(CollabResumeBeginEvent),
1487    /// Collab interaction: resume end.
1488    CollabResumeEnd(CollabResumeEndEvent),
1489
1490    /// Path-based v2 sub-agent activity.
1491    SubAgentActivity(SubAgentActivityEvent),
1492}
1493
1494#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS, EnumIter)]
1495#[serde(rename_all = "snake_case")]
1496pub enum HookEventName {
1497    PreToolUse,
1498    PermissionRequest,
1499    PostToolUse,
1500    PreCompact,
1501    PostCompact,
1502    SessionStart,
1503    SessionEnd,
1504    UserPromptSubmit,
1505    SubagentStart,
1506    SubagentStop,
1507    Stop,
1508}
1509
1510#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
1511#[serde(rename_all = "snake_case")]
1512pub enum HookHandlerType {
1513    Command,
1514    Prompt,
1515    Agent,
1516}
1517
1518#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
1519#[serde(rename_all = "snake_case")]
1520pub enum HookExecutionMode {
1521    Sync,
1522    Async,
1523}
1524
1525#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
1526#[serde(rename_all = "snake_case")]
1527pub enum HookScope {
1528    Thread,
1529    Turn,
1530}
1531
1532#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
1533#[serde(rename_all = "snake_case")]
1534pub enum HookSource {
1535    System,
1536    User,
1537    Project,
1538    Mdm,
1539    SessionFlags,
1540    Plugin,
1541    CloudRequirements,
1542    CloudManagedConfig,
1543    LegacyManagedConfigFile,
1544    LegacyManagedConfigMdm,
1545    #[default]
1546    Unknown,
1547}
1548
1549#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
1550#[serde(rename_all = "snake_case")]
1551pub enum HookTrustStatus {
1552    Managed,
1553    Untrusted,
1554    Trusted,
1555    Modified,
1556}
1557
1558#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
1559#[serde(rename_all = "snake_case")]
1560pub enum HookRunStatus {
1561    Running,
1562    Completed,
1563    Failed,
1564    Blocked,
1565    Stopped,
1566}
1567
1568#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
1569#[serde(rename_all = "snake_case")]
1570pub enum HookOutputEntryKind {
1571    Warning,
1572    Stop,
1573    Feedback,
1574    Context,
1575    Error,
1576}
1577
1578#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
1579#[serde(rename_all = "snake_case")]
1580pub struct HookOutputEntry {
1581    pub kind: HookOutputEntryKind,
1582    pub text: String,
1583}
1584
1585#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
1586#[serde(rename_all = "snake_case")]
1587pub struct HookRunSummary {
1588    pub id: String,
1589    pub event_name: HookEventName,
1590    pub handler_type: HookHandlerType,
1591    pub execution_mode: HookExecutionMode,
1592    pub scope: HookScope,
1593    pub source_path: AbsolutePathBuf,
1594    #[serde(default)]
1595    pub source: HookSource,
1596    pub display_order: i64,
1597    pub status: HookRunStatus,
1598    pub status_message: Option<String>,
1599    #[ts(type = "number")]
1600    pub started_at: i64,
1601    #[ts(type = "number | null")]
1602    pub completed_at: Option<i64>,
1603    #[ts(type = "number | null")]
1604    pub duration_ms: Option<i64>,
1605    pub entries: Vec<HookOutputEntry>,
1606}
1607
1608#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
1609#[serde(rename_all = "snake_case")]
1610pub struct HookStartedEvent {
1611    pub turn_id: Option<String>,
1612    pub run: HookRunSummary,
1613}
1614
1615#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
1616#[serde(rename_all = "snake_case")]
1617pub struct HookCompletedEvent {
1618    pub turn_id: Option<String>,
1619    pub run: HookRunSummary,
1620}
1621
1622#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
1623#[serde(rename_all = "snake_case")]
1624pub enum RealtimeConversationVersion {
1625    V1,
1626    #[default]
1627    V2,
1628    V3,
1629}
1630
1631#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
1632#[serde(rename_all = "camelCase")]
1633#[ts(rename_all = "camelCase")]
1634pub enum CodexResponseHandoffMode {
1635    #[default]
1636    Thinking,
1637    Commentary,
1638    BemTags,
1639}
1640
1641#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)]
1642pub struct RealtimeConversationStartedEvent {
1643    pub realtime_session_id: Option<String>,
1644    pub version: RealtimeConversationVersion,
1645}
1646
1647#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)]
1648pub struct RealtimeConversationRealtimeEvent {
1649    pub payload: RealtimeEvent,
1650}
1651
1652#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)]
1653pub struct RealtimeConversationClosedEvent {
1654    #[serde(skip_serializing_if = "Option::is_none")]
1655    pub reason: Option<String>,
1656}
1657
1658#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)]
1659pub struct RealtimeConversationSdpEvent {
1660    pub sdp: String,
1661}
1662
1663impl From<CollabAgentSpawnBeginEvent> for EventMsg {
1664    fn from(event: CollabAgentSpawnBeginEvent) -> Self {
1665        EventMsg::CollabAgentSpawnBegin(event)
1666    }
1667}
1668
1669impl From<CollabAgentSpawnEndEvent> for EventMsg {
1670    fn from(event: CollabAgentSpawnEndEvent) -> Self {
1671        EventMsg::CollabAgentSpawnEnd(event)
1672    }
1673}
1674
1675impl From<CollabAgentInteractionBeginEvent> for EventMsg {
1676    fn from(event: CollabAgentInteractionBeginEvent) -> Self {
1677        EventMsg::CollabAgentInteractionBegin(event)
1678    }
1679}
1680
1681impl From<CollabAgentInteractionEndEvent> for EventMsg {
1682    fn from(event: CollabAgentInteractionEndEvent) -> Self {
1683        EventMsg::CollabAgentInteractionEnd(event)
1684    }
1685}
1686
1687impl From<CollabWaitingBeginEvent> for EventMsg {
1688    fn from(event: CollabWaitingBeginEvent) -> Self {
1689        EventMsg::CollabWaitingBegin(event)
1690    }
1691}
1692
1693impl From<CollabWaitingEndEvent> for EventMsg {
1694    fn from(event: CollabWaitingEndEvent) -> Self {
1695        EventMsg::CollabWaitingEnd(event)
1696    }
1697}
1698
1699impl From<CollabCloseBeginEvent> for EventMsg {
1700    fn from(event: CollabCloseBeginEvent) -> Self {
1701        EventMsg::CollabCloseBegin(event)
1702    }
1703}
1704
1705impl From<CollabCloseEndEvent> for EventMsg {
1706    fn from(event: CollabCloseEndEvent) -> Self {
1707        EventMsg::CollabCloseEnd(event)
1708    }
1709}
1710
1711impl From<CollabResumeBeginEvent> for EventMsg {
1712    fn from(event: CollabResumeBeginEvent) -> Self {
1713        EventMsg::CollabResumeBegin(event)
1714    }
1715}
1716
1717impl From<CollabResumeEndEvent> for EventMsg {
1718    fn from(event: CollabResumeEndEvent) -> Self {
1719        EventMsg::CollabResumeEnd(event)
1720    }
1721}
1722
1723impl From<SubAgentActivityEvent> for EventMsg {
1724    fn from(event: SubAgentActivityEvent) -> Self {
1725        EventMsg::SubAgentActivity(event)
1726    }
1727}
1728
1729/// Agent lifecycle status, derived from emitted events.
1730#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS, Default)]
1731#[serde(rename_all = "snake_case")]
1732#[ts(rename_all = "snake_case")]
1733pub enum AgentStatus {
1734    /// Agent is waiting for initialization.
1735    #[default]
1736    PendingInit,
1737    /// Agent is currently running.
1738    Running,
1739    /// Agent's current turn was interrupted and it may receive more input.
1740    Interrupted,
1741    /// Agent is done. Contains the final assistant message.
1742    Completed(Option<String>),
1743    /// Agent encountered an error.
1744    Errored(String),
1745    /// Agent has been shutdown.
1746    Shutdown,
1747    /// Agent is not found.
1748    NotFound,
1749}
1750
1751/// Turn kinds that reject same-turn steering.
1752#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, JsonSchema, TS)]
1753#[serde(rename_all = "snake_case")]
1754#[ts(rename_all = "snake_case")]
1755pub enum NonSteerableTurnKind {
1756    Review,
1757    Compact,
1758}
1759
1760/// Codex errors that we expose to clients.
1761#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema, TS)]
1762#[serde(rename_all = "snake_case")]
1763#[ts(rename_all = "snake_case")]
1764pub enum CodexErrorInfo {
1765    ContextWindowExceeded,
1766    SessionBudgetExceeded,
1767    UsageLimitExceeded,
1768    ServerOverloaded,
1769    CyberPolicy,
1770    HttpConnectionFailed {
1771        http_status_code: Option<u16>,
1772    },
1773    /// Failed to connect to the response SSE stream.
1774    ResponseStreamConnectionFailed {
1775        http_status_code: Option<u16>,
1776    },
1777    InternalServerError,
1778    Unauthorized,
1779    BadRequest,
1780    SandboxError,
1781    /// The response SSE stream disconnected in the middle of a turnbefore completion.
1782    ResponseStreamDisconnected {
1783        http_status_code: Option<u16>,
1784    },
1785    /// Reached the retry limit for responses.
1786    ResponseTooManyFailedAttempts {
1787        http_status_code: Option<u16>,
1788    },
1789    /// Returned when `turn/start` or `turn/steer` is submitted while the current active turn
1790    /// cannot accept same-turn steering, for example `/review` or manual `/compact`.
1791    ActiveTurnNotSteerable {
1792        turn_kind: NonSteerableTurnKind,
1793    },
1794    ThreadRollbackFailed,
1795    Other,
1796}
1797
1798impl CodexErrorInfo {
1799    /// Whether this error should mark the current turn as failed when replaying history.
1800    pub fn affects_turn_status(&self) -> bool {
1801        match self {
1802            Self::ThreadRollbackFailed | Self::ActiveTurnNotSteerable { .. } => false,
1803            Self::ContextWindowExceeded
1804            | Self::SessionBudgetExceeded
1805            | Self::UsageLimitExceeded
1806            | Self::ServerOverloaded
1807            | Self::CyberPolicy
1808            | Self::HttpConnectionFailed { .. }
1809            | Self::ResponseStreamConnectionFailed { .. }
1810            | Self::InternalServerError
1811            | Self::Unauthorized
1812            | Self::BadRequest
1813            | Self::SandboxError
1814            | Self::ResponseStreamDisconnected { .. }
1815            | Self::ResponseTooManyFailedAttempts { .. }
1816            | Self::Other => true,
1817        }
1818    }
1819}
1820
1821#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]
1822pub struct RawResponseItemEvent {
1823    pub item: ResponseItem,
1824}
1825
1826/// Exact usage reported by one upstream Responses API completion.
1827///
1828/// Unlike TokenCountEvent, this is not accumulated, estimated, or replayed.
1829#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]
1830pub struct RawResponseCompletedEvent {
1831    pub response_id: String,
1832    pub token_usage: Option<TokenUsage>,
1833}
1834
1835#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]
1836pub struct ItemStartedEvent {
1837    pub thread_id: ThreadId,
1838    pub turn_id: String,
1839    pub item: TurnItem,
1840    pub started_at_ms: i64,
1841}
1842
1843#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]
1844pub struct ItemCompletedEvent {
1845    pub thread_id: ThreadId,
1846    pub turn_id: String,
1847    pub item: TurnItem,
1848    // Old rollout files may contain ItemCompleted events for PlanItem without
1849    // this field. Default to 0 so those persisted rollouts still deserialize
1850    // after tightening the core event contract.
1851    #[serde(default = "default_item_completed_at_ms")]
1852    pub completed_at_ms: i64,
1853}
1854
1855const fn default_item_completed_at_ms() -> i64 {
1856    0
1857}
1858
1859#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]
1860pub struct AgentMessageContentDeltaEvent {
1861    pub thread_id: String,
1862    pub turn_id: String,
1863    pub item_id: String,
1864    pub delta: String,
1865}
1866
1867#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]
1868pub struct PlanDeltaEvent {
1869    pub thread_id: String,
1870    pub turn_id: String,
1871    pub item_id: String,
1872    pub delta: String,
1873}
1874
1875#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]
1876pub struct ReasoningContentDeltaEvent {
1877    pub thread_id: String,
1878    pub turn_id: String,
1879    pub item_id: String,
1880    pub delta: String,
1881    // load with default value so it's backward compatible with the old format.
1882    #[serde(default)]
1883    pub summary_index: i64,
1884}
1885
1886#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]
1887pub struct ReasoningRawContentDeltaEvent {
1888    pub thread_id: String,
1889    pub turn_id: String,
1890    pub item_id: String,
1891    pub delta: String,
1892    // load with default value so it's backward compatible with the old format.
1893    #[serde(default)]
1894    pub content_index: i64,
1895}
1896
1897#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
1898pub struct EnteredReviewModeEvent {
1899    pub target: ReviewTarget,
1900    #[serde(skip_serializing_if = "Option::is_none")]
1901    #[ts(optional)]
1902    pub user_facing_hint: Option<String>,
1903    #[serde(default, skip_serializing_if = "Option::is_none")]
1904    #[ts(optional)]
1905    pub turn_id: Option<String>,
1906    #[serde(default, skip_serializing_if = "Option::is_none")]
1907    #[ts(optional)]
1908    pub item_id: Option<String>,
1909}
1910
1911#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
1912pub struct ExitedReviewModeEvent {
1913    #[serde(default, skip_serializing_if = "Option::is_none")]
1914    #[ts(optional)]
1915    pub turn_id: Option<String>,
1916    #[serde(default, skip_serializing_if = "Option::is_none")]
1917    #[ts(optional)]
1918    pub item_id: Option<String>,
1919    pub review_output: Option<ReviewOutputEvent>,
1920}
1921
1922// Individual event payload types matching each `EventMsg` variant.
1923
1924#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
1925pub struct ErrorEvent {
1926    pub message: String,
1927    #[serde(default)]
1928    pub codex_error_info: Option<CodexErrorInfo>,
1929}
1930
1931impl ErrorEvent {
1932    /// Whether this error should mark the current turn as failed when replaying history.
1933    pub fn affects_turn_status(&self) -> bool {
1934        self.codex_error_info
1935            .as_ref()
1936            .is_none_or(CodexErrorInfo::affects_turn_status)
1937    }
1938}
1939
1940#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
1941pub struct WarningEvent {
1942    pub message: String,
1943}
1944
1945#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
1946#[serde(rename_all = "snake_case")]
1947#[ts(rename_all = "snake_case")]
1948pub enum ModelRerouteReason {
1949    HighRiskCyberActivity,
1950}
1951
1952#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
1953pub struct ModelRerouteEvent {
1954    pub from_model: String,
1955    pub to_model: String,
1956    pub reason: ModelRerouteReason,
1957}
1958
1959#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
1960#[serde(rename_all = "snake_case")]
1961#[ts(rename_all = "snake_case")]
1962pub enum ModelVerification {
1963    TrustedAccessForCyber,
1964}
1965
1966#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
1967pub struct ModelVerificationEvent {
1968    pub verifications: Vec<ModelVerification>,
1969}
1970
1971#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)]
1972pub struct TurnModerationMetadataEvent {
1973    pub metadata: Value,
1974}
1975
1976#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
1977pub struct SafetyBufferingEvent {
1978    pub model: String,
1979    pub use_cases: Vec<String>,
1980    pub reasons: Vec<String>,
1981    pub show_buffering_ui: bool,
1982    pub faster_model: Option<String>,
1983}
1984
1985#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
1986pub struct ContextCompactedEvent;
1987
1988#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
1989pub struct TurnCompleteEvent {
1990    pub turn_id: String,
1991    pub last_agent_message: Option<String>,
1992    /// Terminal error details when the turn completed unsuccessfully.
1993    #[serde(default, skip_serializing_if = "Option::is_none")]
1994    #[ts(optional)]
1995    pub error: Option<ErrorEvent>,
1996    /// Unix timestamp (in seconds) when the turn started.
1997    #[serde(default, skip_serializing_if = "Option::is_none")]
1998    #[ts(type = "number | null", optional)]
1999    pub started_at: Option<i64>,
2000    /// Unix timestamp (in seconds) when the turn completed.
2001    #[serde(default, skip_serializing_if = "Option::is_none")]
2002    #[ts(type = "number | null", optional)]
2003    pub completed_at: Option<i64>,
2004    /// Duration between turn start and completion in milliseconds, if known.
2005    #[serde(default, skip_serializing_if = "Option::is_none")]
2006    #[ts(type = "number | null", optional)]
2007    pub duration_ms: Option<i64>,
2008    /// Duration between turn start and the first model token in milliseconds, if known.
2009    #[serde(default, skip_serializing_if = "Option::is_none")]
2010    #[ts(type = "number | null", optional)]
2011    pub time_to_first_token_ms: Option<i64>,
2012}
2013
2014#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
2015pub struct TurnStartedEvent {
2016    pub turn_id: String,
2017    // Persist for rollout consumers that correlate turns with telemetry traces.
2018    #[serde(default, skip_serializing_if = "Option::is_none")]
2019    #[ts(optional)]
2020    pub trace_id: Option<String>,
2021    /// Unix timestamp (in seconds) when the turn started.
2022    #[serde(default, skip_serializing_if = "Option::is_none")]
2023    #[ts(type = "number | null", optional)]
2024    pub started_at: Option<i64>,
2025    // TODO(aibrahim): make this not optional
2026    pub model_context_window: Option<i64>,
2027    #[serde(default)]
2028    pub collaboration_mode_kind: ModeKind,
2029}
2030
2031#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
2032pub struct ThreadSettingsAppliedEvent {
2033    pub thread_settings: ThreadSettingsSnapshot,
2034}
2035
2036#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)]
2037pub struct ThreadSettingsSnapshot {
2038    pub model: String,
2039    pub model_provider_id: String,
2040    #[serde(skip_serializing_if = "Option::is_none")]
2041    pub service_tier: Option<String>,
2042    pub approval_policy: AskForApproval,
2043    pub approvals_reviewer: ApprovalsReviewer,
2044    pub permission_profile: PermissionProfile,
2045    #[serde(default, skip_serializing_if = "Option::is_none")]
2046    #[ts(optional)]
2047    pub active_permission_profile: Option<ActivePermissionProfile>,
2048    pub cwd: AbsolutePathBuf,
2049    #[serde(skip_serializing_if = "Option::is_none")]
2050    pub reasoning_effort: Option<ReasoningEffortConfig>,
2051    #[serde(skip_serializing_if = "Option::is_none")]
2052    pub reasoning_summary: Option<ReasoningSummaryConfig>,
2053    #[serde(skip_serializing_if = "Option::is_none")]
2054    pub personality: Option<Personality>,
2055    pub collaboration_mode: CollaborationMode,
2056}
2057
2058#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq, Eq, JsonSchema, TS)]
2059pub struct TokenUsage {
2060    #[ts(type = "number")]
2061    pub input_tokens: i64,
2062    #[ts(type = "number")]
2063    pub cached_input_tokens: i64,
2064    #[serde(default)]
2065    #[ts(type = "number")]
2066    pub cache_write_input_tokens: i64,
2067    #[ts(type = "number")]
2068    pub output_tokens: i64,
2069    #[ts(type = "number")]
2070    pub reasoning_output_tokens: i64,
2071    #[ts(type = "number")]
2072    pub total_tokens: i64,
2073}
2074
2075#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
2076pub struct TokenUsageInfo {
2077    pub total_token_usage: TokenUsage,
2078    pub last_token_usage: TokenUsage,
2079    // TODO(aibrahim): make this not optional
2080    #[ts(type = "number | null")]
2081    pub model_context_window: Option<i64>,
2082}
2083
2084impl TokenUsageInfo {
2085    pub fn new_or_append(
2086        info: &Option<TokenUsageInfo>,
2087        last: &Option<TokenUsage>,
2088        model_context_window: Option<i64>,
2089    ) -> Option<Self> {
2090        if info.is_none() && last.is_none() {
2091            return None;
2092        }
2093
2094        let mut info = match info {
2095            Some(info) => info.clone(),
2096            None => Self {
2097                total_token_usage: TokenUsage::default(),
2098                last_token_usage: TokenUsage::default(),
2099                model_context_window,
2100            },
2101        };
2102        if let Some(last) = last {
2103            info.append_last_usage(last);
2104        }
2105        if let Some(model_context_window) = model_context_window {
2106            info.model_context_window = Some(model_context_window);
2107        }
2108        Some(info)
2109    }
2110
2111    pub fn append_last_usage(&mut self, last: &TokenUsage) {
2112        self.total_token_usage.add_assign(last);
2113        self.last_token_usage = last.clone();
2114    }
2115
2116    pub fn fill_to_context_window(&mut self, context_window: i64) {
2117        let previous_total = self.total_token_usage.total_tokens;
2118        let delta = (context_window - previous_total).max(0);
2119
2120        self.model_context_window = Some(context_window);
2121        self.total_token_usage = TokenUsage {
2122            total_tokens: context_window,
2123            ..TokenUsage::default()
2124        };
2125        self.last_token_usage = TokenUsage {
2126            total_tokens: delta,
2127            ..TokenUsage::default()
2128        };
2129    }
2130
2131    pub fn full_context_window(context_window: i64) -> Self {
2132        let mut info = Self {
2133            total_token_usage: TokenUsage::default(),
2134            last_token_usage: TokenUsage::default(),
2135            model_context_window: Some(context_window),
2136        };
2137        info.fill_to_context_window(context_window);
2138        info
2139    }
2140}
2141
2142#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
2143pub struct TokenCountEvent {
2144    pub info: Option<TokenUsageInfo>,
2145    pub rate_limits: Option<RateLimitSnapshot>,
2146}
2147
2148#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)]
2149pub struct RateLimitSnapshot {
2150    pub limit_id: Option<String>,
2151    pub limit_name: Option<String>,
2152    pub primary: Option<RateLimitWindow>,
2153    pub secondary: Option<RateLimitWindow>,
2154    pub credits: Option<CreditsSnapshot>,
2155    pub individual_limit: Option<SpendControlLimitSnapshot>,
2156    /// Backend-reported spend-control state. `None` is unavailable, not a sparse-update recovery.
2157    pub spend_control_reached: Option<bool>,
2158    pub plan_type: Option<crate::account::PlanType>,
2159    pub rate_limit_reached_type: Option<RateLimitReachedType>,
2160}
2161
2162#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, TS)]
2163#[serde(rename_all = "snake_case")]
2164#[ts(rename_all = "snake_case")]
2165pub enum RateLimitReachedType {
2166    RateLimitReached,
2167    WorkspaceOwnerCreditsDepleted,
2168    WorkspaceMemberCreditsDepleted,
2169    WorkspaceOwnerUsageLimitReached,
2170    WorkspaceMemberUsageLimitReached,
2171}
2172
2173impl FromStr for RateLimitReachedType {
2174    type Err = String;
2175
2176    fn from_str(value: &str) -> Result<Self, Self::Err> {
2177        match value {
2178            "rate_limit_reached" => Ok(Self::RateLimitReached),
2179            "workspace_owner_credits_depleted" => Ok(Self::WorkspaceOwnerCreditsDepleted),
2180            "workspace_member_credits_depleted" => Ok(Self::WorkspaceMemberCreditsDepleted),
2181            "workspace_owner_usage_limit_reached" => Ok(Self::WorkspaceOwnerUsageLimitReached),
2182            "workspace_member_usage_limit_reached" => Ok(Self::WorkspaceMemberUsageLimitReached),
2183            other => Err(format!("unknown rate limit reached type: {other}")),
2184        }
2185    }
2186}
2187
2188#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)]
2189pub struct RateLimitWindow {
2190    /// Percentage (0-100) of the window that has been consumed.
2191    pub used_percent: f64,
2192    /// Rolling window duration, in minutes.
2193    #[ts(type = "number | null")]
2194    pub window_minutes: Option<i64>,
2195    /// Unix timestamp (seconds since epoch) when the window resets.
2196    #[ts(type = "number | null")]
2197    pub resets_at: Option<i64>,
2198}
2199
2200#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)]
2201pub struct CreditsSnapshot {
2202    pub has_credits: bool,
2203    pub unlimited: bool,
2204    pub balance: Option<String>,
2205}
2206
2207#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)]
2208pub struct SpendControlLimitSnapshot {
2209    pub limit: String,
2210    pub used: String,
2211    pub remaining_percent: i32,
2212    pub resets_at: i64,
2213}
2214
2215// Includes prompts, tools and space to call compact.
2216const BASELINE_TOKENS: i64 = 12000;
2217
2218impl TokenUsage {
2219    pub fn is_zero(&self) -> bool {
2220        self.total_tokens == 0
2221    }
2222
2223    pub fn cached_input(&self) -> i64 {
2224        self.cached_input_tokens.max(0)
2225    }
2226
2227    pub fn non_cached_input(&self) -> i64 {
2228        (self.input_tokens - self.cached_input()).max(0)
2229    }
2230
2231    /// Primary count for display as a single absolute value: non-cached input + output.
2232    pub fn blended_total(&self) -> i64 {
2233        (self.non_cached_input() + self.output_tokens.max(0)).max(0)
2234    }
2235
2236    pub fn tokens_in_context_window(&self) -> i64 {
2237        self.total_tokens
2238    }
2239
2240    /// Estimate the remaining user-controllable percentage of the model's context window.
2241    ///
2242    /// `context_window` is the total size of the model's context window.
2243    /// `BASELINE_TOKENS` should capture tokens that are always present in
2244    /// the context (e.g., system prompt and fixed tool instructions) so that
2245    /// the percentage reflects the portion the user can influence.
2246    ///
2247    /// This normalizes both the numerator and denominator by subtracting the
2248    /// baseline, so immediately after the first prompt the UI shows 100% left
2249    /// and trends toward 0% as the user fills the effective window.
2250    pub fn percent_of_context_window_remaining(&self, context_window: i64) -> i64 {
2251        if context_window <= BASELINE_TOKENS {
2252            return 0;
2253        }
2254
2255        let effective_window = context_window - BASELINE_TOKENS;
2256        let used = (self.tokens_in_context_window() - BASELINE_TOKENS).max(0);
2257        let remaining = (effective_window - used).max(0);
2258        ((remaining as f64 / effective_window as f64) * 100.0)
2259            .clamp(0.0, 100.0)
2260            .round() as i64
2261    }
2262
2263    /// In-place element-wise sum of token counts.
2264    pub fn add_assign(&mut self, other: &TokenUsage) {
2265        self.input_tokens += other.input_tokens;
2266        self.cached_input_tokens += other.cached_input_tokens;
2267        self.cache_write_input_tokens += other.cache_write_input_tokens;
2268        self.output_tokens += other.output_tokens;
2269        self.reasoning_output_tokens += other.reasoning_output_tokens;
2270        self.total_tokens += other.total_tokens;
2271    }
2272}
2273
2274#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
2275pub struct FinalOutput {
2276    pub token_usage: TokenUsage,
2277}
2278
2279impl From<TokenUsage> for FinalOutput {
2280    fn from(token_usage: TokenUsage) -> Self {
2281        Self { token_usage }
2282    }
2283}
2284
2285impl fmt::Display for FinalOutput {
2286    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2287        let token_usage = &self.token_usage;
2288
2289        write!(
2290            f,
2291            "Token usage: total={} input={}{} output={}{}",
2292            format_with_separators(token_usage.blended_total()),
2293            format_with_separators(token_usage.non_cached_input()),
2294            if token_usage.cached_input() > 0 {
2295                format!(
2296                    " (+ {} cached)",
2297                    format_with_separators(token_usage.cached_input())
2298                )
2299            } else {
2300                String::new()
2301            },
2302            format_with_separators(token_usage.output_tokens),
2303            if token_usage.reasoning_output_tokens > 0 {
2304                format!(
2305                    " (reasoning {})",
2306                    format_with_separators(token_usage.reasoning_output_tokens)
2307                )
2308            } else {
2309                String::new()
2310            }
2311        )
2312    }
2313}
2314
2315#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
2316pub struct AgentMessageEvent {
2317    pub message: String,
2318    #[serde(default)]
2319    pub phase: Option<MessagePhase>,
2320    #[serde(default)]
2321    pub memory_citation: Option<MemoryCitation>,
2322}
2323
2324#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema, TS)]
2325pub struct UserMessageEvent {
2326    #[serde(default, skip_serializing_if = "Option::is_none")]
2327    pub client_id: Option<String>,
2328    pub message: String,
2329    /// Image URLs sourced from `UserInput::Image`. These are safe
2330    /// to replay in legacy UI history events and correspond to images sent to
2331    /// the model.
2332    #[serde(skip_serializing_if = "Option::is_none")]
2333    pub images: Option<Vec<String>>,
2334    /// Detail hints for `images`, indexed in parallel. Missing entries imply
2335    /// default image detail behavior.
2336    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2337    pub image_details: Vec<Option<ImageDetail>>,
2338    /// Local file paths sourced from `UserInput::LocalImage`. These are kept so
2339    /// the UI can reattach images when editing history. Local image prompts may
2340    /// include a display form of the path, but these should not be treated as
2341    /// API-ready URLs.
2342    #[serde(default)]
2343    pub local_images: Vec<std::path::PathBuf>,
2344    /// Detail hints for `local_images`, indexed in parallel. Missing entries
2345    /// imply default image detail behavior.
2346    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2347    pub local_image_details: Vec<Option<ImageDetail>>,
2348    /// Audio URLs sourced from `UserInput::Audio`. These are safe to replay in
2349    /// legacy UI history events and correspond to audio sent to the model.
2350    #[serde(skip_serializing_if = "Option::is_none")]
2351    pub audio: Option<Vec<String>>,
2352    /// Local file paths sourced from `UserInput::LocalAudio`. These are kept so
2353    /// clients can reattach audio when editing history and should not be
2354    /// treated as API-ready URLs.
2355    #[serde(default)]
2356    pub local_audio: Vec<std::path::PathBuf>,
2357    /// UI-defined spans within `message` used to render or persist special elements.
2358    #[serde(default)]
2359    pub text_elements: Vec<crate::user_input::TextElement>,
2360}
2361
2362/// Returns the user-facing preview text for a user message.
2363pub fn user_message_preview(user: &UserMessageEvent) -> Option<String> {
2364    let message = strip_user_message_prefix(user.message.as_str());
2365    if !message.is_empty() {
2366        return Some(message.to_string());
2367    }
2368    if user
2369        .images
2370        .as_ref()
2371        .is_some_and(|images| !images.is_empty())
2372        || !user.local_images.is_empty()
2373    {
2374        return Some("[Image]".to_string());
2375    }
2376    if user.audio.as_ref().is_some_and(|audio| !audio.is_empty()) || !user.local_audio.is_empty() {
2377        return Some("[Audio]".to_string());
2378    }
2379    None
2380}
2381
2382#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
2383pub struct AgentReasoningEvent {
2384    pub text: String,
2385}
2386
2387#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
2388pub struct AgentReasoningRawContentEvent {
2389    pub text: String,
2390}
2391
2392#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
2393pub struct AgentReasoningSectionBreakEvent {
2394    // load with default value so it's backward compatible with the old format.
2395    #[serde(default)]
2396    pub item_id: String,
2397    #[serde(default)]
2398    pub summary_index: i64,
2399}
2400
2401#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS, PartialEq)]
2402pub struct McpInvocation {
2403    /// Name of the MCP server as defined in the config.
2404    pub server: String,
2405    /// Name of the tool as given by the MCP server.
2406    pub tool: String,
2407    /// Arguments to the tool call.
2408    pub arguments: Option<serde_json::Value>,
2409}
2410
2411#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS, PartialEq)]
2412pub struct McpToolCallBeginEvent {
2413    /// Identifier so this can be paired with the McpToolCallEnd event.
2414    pub call_id: String,
2415    pub invocation: McpInvocation,
2416    #[serde(default, skip_serializing_if = "Option::is_none")]
2417    #[ts(optional)]
2418    pub connector_id: Option<String>,
2419    #[serde(default, skip_serializing_if = "Option::is_none")]
2420    #[ts(optional)]
2421    pub mcp_app_resource_uri: Option<String>,
2422    #[serde(default, skip_serializing_if = "Option::is_none")]
2423    #[ts(optional)]
2424    pub link_id: Option<String>,
2425    #[serde(default, skip_serializing_if = "Option::is_none")]
2426    #[ts(optional)]
2427    pub app_name: Option<String>,
2428    #[serde(default, skip_serializing_if = "Option::is_none")]
2429    #[ts(optional)]
2430    pub action_name: Option<String>,
2431    #[serde(default, skip_serializing_if = "Option::is_none")]
2432    #[ts(optional)]
2433    pub plugin_id: Option<String>,
2434}
2435
2436#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS, PartialEq)]
2437pub struct McpToolCallEndEvent {
2438    /// Identifier for the corresponding McpToolCallBegin that finished.
2439    pub call_id: String,
2440    pub invocation: McpInvocation,
2441    #[serde(default, skip_serializing_if = "Option::is_none")]
2442    #[ts(optional)]
2443    pub connector_id: Option<String>,
2444    #[serde(default, skip_serializing_if = "Option::is_none")]
2445    #[ts(optional)]
2446    pub mcp_app_resource_uri: Option<String>,
2447    #[serde(default, skip_serializing_if = "Option::is_none")]
2448    #[ts(optional)]
2449    pub link_id: Option<String>,
2450    #[serde(default, skip_serializing_if = "Option::is_none")]
2451    #[ts(optional)]
2452    pub app_name: Option<String>,
2453    #[serde(default, skip_serializing_if = "Option::is_none")]
2454    #[ts(optional)]
2455    pub action_name: Option<String>,
2456    #[serde(default, skip_serializing_if = "Option::is_none")]
2457    #[ts(optional)]
2458    pub plugin_id: Option<String>,
2459    #[ts(type = "string")]
2460    pub duration: Duration,
2461    /// Result of the tool call. Note this could be an error.
2462    pub result: Result<CallToolResult, String>,
2463}
2464
2465#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS, PartialEq)]
2466pub struct DynamicToolCallResponseEvent {
2467    /// Identifier for the corresponding DynamicToolCallRequest.
2468    pub call_id: String,
2469    /// Turn ID that this dynamic tool call belongs to.
2470    pub turn_id: String,
2471    #[serde(default)]
2472    pub completed_at_ms: i64,
2473    /// Dynamic tool namespace, when one was provided.
2474    #[serde(default)]
2475    pub namespace: Option<String>,
2476    /// Dynamic tool name.
2477    pub tool: String,
2478    /// Dynamic tool call arguments.
2479    pub arguments: serde_json::Value,
2480    /// Dynamic tool response content items.
2481    pub content_items: Vec<DynamicToolCallOutputContentItem>,
2482    /// Whether the tool call succeeded.
2483    pub success: bool,
2484    /// Optional error text when the tool call failed before producing a response.
2485    pub error: Option<String>,
2486    /// The duration of the dynamic tool call.
2487    #[ts(type = "string")]
2488    pub duration: Duration,
2489}
2490
2491impl McpToolCallEndEvent {
2492    pub fn is_success(&self) -> bool {
2493        match &self.result {
2494            Ok(result) => !result.is_error.unwrap_or(false),
2495            Err(_) => false,
2496        }
2497    }
2498}
2499
2500#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
2501pub struct WebSearchBeginEvent {
2502    pub call_id: String,
2503}
2504
2505#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
2506pub struct WebSearchEndEvent {
2507    pub call_id: String,
2508    pub query: String,
2509    pub action: WebSearchAction,
2510    /// Structured search results returned out-of-band by standalone web search.
2511    #[serde(default, skip_serializing_if = "Option::is_none")]
2512    #[ts(optional)]
2513    pub results: Option<Vec<Value>>,
2514}
2515
2516#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
2517pub struct ImageGenerationBeginEvent {
2518    pub call_id: String,
2519}
2520
2521#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
2522pub struct ImageGenerationEndEvent {
2523    pub call_id: String,
2524    pub status: String,
2525    #[serde(skip_serializing_if = "Option::is_none")]
2526    #[ts(optional)]
2527    pub revised_prompt: Option<String>,
2528    pub result: String,
2529    #[serde(skip_serializing_if = "Option::is_none")]
2530    #[ts(optional)]
2531    pub saved_path: Option<AbsolutePathBuf>,
2532}
2533
2534// Conversation kept for backward compatibility.
2535/// Response payload for `Op::GetHistory` containing the current session's
2536/// in-memory transcript.
2537#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
2538pub struct ConversationPathResponseEvent {
2539    pub conversation_id: ThreadId,
2540    pub path: PathBuf,
2541}
2542
2543#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
2544pub struct ResumedHistory {
2545    pub conversation_id: ThreadId,
2546    pub history: Arc<Vec<RolloutItem>>,
2547    pub rollout_path: Option<PathBuf>,
2548}
2549
2550#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
2551pub enum InitialHistory {
2552    New,
2553    Cleared,
2554    Resumed(ResumedHistory),
2555    Forked(Vec<RolloutItem>),
2556}
2557
2558impl InitialHistory {
2559    pub fn scan_rollout_items(&self, mut predicate: impl FnMut(&RolloutItem) -> bool) -> bool {
2560        match self {
2561            InitialHistory::New | InitialHistory::Cleared => false,
2562            InitialHistory::Resumed(resumed) => resumed.history.iter().any(&mut predicate),
2563            InitialHistory::Forked(items) => items.iter().any(predicate),
2564        }
2565    }
2566
2567    pub fn forked_from_id(&self) -> Option<ThreadId> {
2568        match self {
2569            InitialHistory::New | InitialHistory::Cleared => None,
2570            InitialHistory::Resumed(resumed) => {
2571                resumed.history.iter().find_map(|item| match item {
2572                    RolloutItem::SessionMeta(meta_line) => meta_line.meta.forked_from_id,
2573                    _ => None,
2574                })
2575            }
2576            InitialHistory::Forked(items) => items.iter().find_map(|item| match item {
2577                RolloutItem::SessionMeta(meta_line) => Some(meta_line.meta.id),
2578                _ => None,
2579            }),
2580        }
2581    }
2582
2583    pub fn session_cwd(&self) -> Option<PathBuf> {
2584        match self {
2585            InitialHistory::New | InitialHistory::Cleared => None,
2586            InitialHistory::Resumed(resumed) => session_cwd_from_items(&resumed.history),
2587            InitialHistory::Forked(items) => session_cwd_from_items(items),
2588        }
2589    }
2590
2591    pub fn get_rollout_items(&self) -> &[RolloutItem] {
2592        match self {
2593            InitialHistory::New | InitialHistory::Cleared => &[],
2594            InitialHistory::Resumed(resumed) => &resumed.history,
2595            InitialHistory::Forked(items) => items,
2596        }
2597    }
2598
2599    pub fn get_event_msgs(&self) -> Option<Vec<EventMsg>> {
2600        match self {
2601            InitialHistory::New | InitialHistory::Cleared => None,
2602            InitialHistory::Resumed(resumed) => Some(
2603                resumed
2604                    .history
2605                    .iter()
2606                    .filter_map(|ri| match ri {
2607                        RolloutItem::EventMsg(ev) => Some(ev.clone()),
2608                        _ => None,
2609                    })
2610                    .collect(),
2611            ),
2612            InitialHistory::Forked(items) => Some(
2613                items
2614                    .iter()
2615                    .filter_map(|ri| match ri {
2616                        RolloutItem::EventMsg(ev) => Some(ev.clone()),
2617                        _ => None,
2618                    })
2619                    .collect(),
2620            ),
2621        }
2622    }
2623
2624    pub fn get_base_instructions(&self) -> Option<BaseInstructions> {
2625        // TODO: SessionMeta should (in theory) always be first in the history, so we can probably only check the first item?
2626        match self {
2627            InitialHistory::New | InitialHistory::Cleared => None,
2628            InitialHistory::Resumed(resumed) => {
2629                resumed.history.iter().find_map(|item| match item {
2630                    RolloutItem::SessionMeta(meta_line) => meta_line.meta.base_instructions.clone(),
2631                    _ => None,
2632                })
2633            }
2634            InitialHistory::Forked(items) => items.iter().find_map(|item| match item {
2635                RolloutItem::SessionMeta(meta_line) => meta_line.meta.base_instructions.clone(),
2636                _ => None,
2637            }),
2638        }
2639    }
2640
2641    pub fn get_dynamic_tools(&self) -> Option<Vec<DynamicToolSpec>> {
2642        match self {
2643            InitialHistory::New | InitialHistory::Cleared => None,
2644            InitialHistory::Resumed(resumed) => {
2645                resumed.history.iter().find_map(|item| match item {
2646                    RolloutItem::SessionMeta(meta_line) => meta_line.meta.dynamic_tools.clone(),
2647                    _ => None,
2648                })
2649            }
2650            InitialHistory::Forked(items) => items.iter().find_map(|item| match item {
2651                RolloutItem::SessionMeta(meta_line) => meta_line.meta.dynamic_tools.clone(),
2652                _ => None,
2653            }),
2654        }
2655    }
2656
2657    pub fn get_selected_capability_roots(&self) -> Vec<SelectedCapabilityRoot> {
2658        self.get_session_meta()
2659            .map(|meta| meta.selected_capability_roots.clone())
2660            .unwrap_or_default()
2661    }
2662
2663    pub fn get_multi_agent_version(&self) -> Option<MultiAgentVersion> {
2664        match self {
2665            InitialHistory::New | InitialHistory::Cleared => None,
2666            InitialHistory::Resumed(resumed) => {
2667                multi_agent_version_from_items(&resumed.history, Some(resumed.conversation_id))
2668            }
2669            InitialHistory::Forked(items) => {
2670                multi_agent_version_from_items(items, /*thread_id*/ None)
2671            }
2672        }
2673    }
2674
2675    pub fn get_history_mode(&self, default_history_mode: ThreadHistoryMode) -> ThreadHistoryMode {
2676        match self {
2677            InitialHistory::New | InitialHistory::Cleared => default_history_mode,
2678            // Forks copy their source rollout items as-is, so they must keep
2679            // the source format instead of converting to the destination default.
2680            InitialHistory::Resumed(_) | InitialHistory::Forked(_) => self
2681                .get_session_meta()
2682                .map(|meta| meta.history_mode)
2683                .unwrap_or(default_history_mode),
2684        }
2685    }
2686
2687    pub fn get_latest_effective_multi_agent_mode(&self) -> Option<MultiAgentMode> {
2688        let items = match self {
2689            InitialHistory::New | InitialHistory::Cleared => return None,
2690            InitialHistory::Resumed(resumed) => &resumed.history,
2691            InitialHistory::Forked(items) => items,
2692        };
2693        items
2694            .iter()
2695            .rev()
2696            .find_map(|item| match item {
2697                RolloutItem::TurnContext(turn_context) => Some(turn_context),
2698                RolloutItem::SessionMeta(_)
2699                | RolloutItem::ResponseItem(_)
2700                | RolloutItem::InterAgentCommunication(_)
2701                | RolloutItem::InterAgentCommunicationMetadata { .. }
2702                | RolloutItem::Compacted(_)
2703                | RolloutItem::WorldState(_)
2704                | RolloutItem::EventMsg(_) => None,
2705            })
2706            .and_then(|turn_context| turn_context.multi_agent_mode.clone())
2707    }
2708
2709    pub fn get_resumed_session_sources(&self) -> Option<(SessionSource, Option<ThreadSource>)> {
2710        let meta = self.get_resumed_session_meta()?;
2711        Some((meta.source.clone(), meta.thread_source.clone()))
2712    }
2713
2714    pub fn get_resumed_thread_source(&self) -> Option<ThreadSource> {
2715        self.get_resumed_session_meta()
2716            .and_then(|meta| meta.thread_source.clone())
2717    }
2718
2719    pub fn get_session_originator(&self) -> Option<String> {
2720        self.get_session_meta()
2721            .map(|meta| meta.originator.clone())
2722            .filter(|originator| !originator.is_empty())
2723    }
2724
2725    pub fn get_resumed_parent_thread_id(&self) -> Option<ThreadId> {
2726        self.get_resumed_session_meta()
2727            .and_then(|meta| meta.parent_thread_id)
2728    }
2729
2730    fn get_session_meta(&self) -> Option<&SessionMeta> {
2731        match self {
2732            InitialHistory::New | InitialHistory::Cleared => None,
2733            InitialHistory::Resumed(resumed) => {
2734                resumed.history.iter().find_map(|item| match item {
2735                    RolloutItem::SessionMeta(meta_line) => Some(&meta_line.meta),
2736                    _ => None,
2737                })
2738            }
2739            InitialHistory::Forked(items) => items.iter().find_map(|item| match item {
2740                RolloutItem::SessionMeta(meta_line) => Some(&meta_line.meta),
2741                _ => None,
2742            }),
2743        }
2744    }
2745
2746    fn get_resumed_session_meta(&self) -> Option<&SessionMeta> {
2747        match self {
2748            InitialHistory::New | InitialHistory::Cleared | InitialHistory::Forked(_) => None,
2749            InitialHistory::Resumed(resumed) => {
2750                resumed.history.iter().find_map(|item| match item {
2751                    RolloutItem::SessionMeta(meta_line) => Some(&meta_line.meta),
2752                    _ => None,
2753                })
2754            }
2755        }
2756    }
2757}
2758
2759fn session_cwd_from_items(items: &[RolloutItem]) -> Option<PathBuf> {
2760    items.iter().find_map(|item| match item {
2761        RolloutItem::SessionMeta(meta_line) => Some(meta_line.meta.cwd.clone()),
2762        _ => None,
2763    })
2764}
2765
2766#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema, TS, Default)]
2767#[serde(rename_all = "lowercase")]
2768#[ts(rename_all = "lowercase")]
2769pub enum SessionSource {
2770    Cli,
2771    #[default]
2772    VSCode,
2773    Exec,
2774    Mcp,
2775    Custom(String),
2776    Internal(InternalSessionSource),
2777    SubAgent(SubAgentSource),
2778    #[serde(other)]
2779    Unknown,
2780}
2781
2782#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema, TS)]
2783#[serde(try_from = "String", into = "String")]
2784#[schemars(with = "String")]
2785#[ts(type = "string")]
2786pub enum ThreadSource {
2787    User,
2788    Subagent,
2789    Feature(String),
2790    MemoryConsolidation,
2791}
2792
2793impl ThreadSource {
2794    pub fn as_str(&self) -> &str {
2795        match self {
2796            ThreadSource::User => "user",
2797            ThreadSource::Subagent => "subagent",
2798            ThreadSource::Feature(feature) => feature,
2799            ThreadSource::MemoryConsolidation => "memory_consolidation",
2800        }
2801    }
2802}
2803
2804impl fmt::Display for ThreadSource {
2805    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2806        f.write_str(self.as_str())
2807    }
2808}
2809
2810impl TryFrom<String> for ThreadSource {
2811    type Error = String;
2812
2813    fn try_from(value: String) -> Result<Self, Self::Error> {
2814        value.parse()
2815    }
2816}
2817
2818impl From<ThreadSource> for String {
2819    fn from(value: ThreadSource) -> Self {
2820        value.to_string()
2821    }
2822}
2823
2824impl FromStr for ThreadSource {
2825    type Err = String;
2826
2827    fn from_str(value: &str) -> Result<Self, Self::Err> {
2828        match value {
2829            "user" => Ok(ThreadSource::User),
2830            "subagent" => Ok(ThreadSource::Subagent),
2831            "memory_consolidation" => Ok(ThreadSource::MemoryConsolidation),
2832            other => Ok(ThreadSource::Feature(other.to_string())),
2833        }
2834    }
2835}
2836
2837#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema, TS)]
2838#[serde(rename_all = "snake_case")]
2839#[ts(rename_all = "snake_case")]
2840pub enum InternalSessionSource {
2841    MemoryConsolidation,
2842}
2843
2844#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema, TS)]
2845#[serde(rename_all = "snake_case")]
2846#[ts(rename_all = "snake_case")]
2847pub enum SubAgentSource {
2848    Review,
2849    Compact,
2850    ThreadSpawn {
2851        parent_thread_id: ThreadId,
2852        depth: i32,
2853        #[serde(default)]
2854        agent_path: Option<AgentPath>,
2855        #[serde(default)]
2856        agent_nickname: Option<String>,
2857        #[serde(default, alias = "agent_type")]
2858        agent_role: Option<String>,
2859    },
2860    MemoryConsolidation,
2861    Other(String),
2862}
2863
2864impl fmt::Display for SessionSource {
2865    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2866        match self {
2867            SessionSource::Cli => f.write_str("cli"),
2868            SessionSource::VSCode => f.write_str("vscode"),
2869            SessionSource::Exec => f.write_str("exec"),
2870            SessionSource::Mcp => f.write_str("mcp"),
2871            SessionSource::Custom(source) => f.write_str(source),
2872            SessionSource::Internal(source) => write!(f, "internal_{source}"),
2873            SessionSource::SubAgent(sub_source) => write!(f, "subagent_{sub_source}"),
2874            SessionSource::Unknown => f.write_str("unknown"),
2875        }
2876    }
2877}
2878
2879impl SessionSource {
2880    pub fn from_startup_arg(value: &str) -> Result<Self, &'static str> {
2881        let trimmed = value.trim();
2882        if trimmed.is_empty() {
2883            return Err("session source must not be empty");
2884        }
2885
2886        let normalized = trimmed.to_ascii_lowercase();
2887        Ok(match normalized.as_str() {
2888            "cli" => SessionSource::Cli,
2889            "vscode" => SessionSource::VSCode,
2890            "exec" => SessionSource::Exec,
2891            "mcp" | "appserver" | "app-server" | "app_server" => SessionSource::Mcp,
2892            "unknown" => SessionSource::Unknown,
2893            _ => SessionSource::Custom(normalized),
2894        })
2895    }
2896
2897    pub fn is_internal(&self) -> bool {
2898        matches!(self, SessionSource::Internal(_))
2899    }
2900
2901    pub fn is_non_root_agent(&self) -> bool {
2902        matches!(
2903            self,
2904            SessionSource::Internal(_) | SessionSource::SubAgent(_)
2905        )
2906    }
2907
2908    pub fn get_nickname(&self) -> Option<String> {
2909        match self {
2910            SessionSource::SubAgent(SubAgentSource::ThreadSpawn { agent_nickname, .. }) => {
2911                agent_nickname.clone()
2912            }
2913            _ => None,
2914        }
2915    }
2916
2917    pub fn get_agent_role(&self) -> Option<String> {
2918        match self {
2919            SessionSource::SubAgent(SubAgentSource::ThreadSpawn { agent_role, .. }) => {
2920                agent_role.clone()
2921            }
2922            _ => None,
2923        }
2924    }
2925
2926    pub fn get_agent_path(&self) -> Option<AgentPath> {
2927        match self {
2928            SessionSource::SubAgent(SubAgentSource::ThreadSpawn { agent_path, .. }) => {
2929                agent_path.clone()
2930            }
2931            _ => None,
2932        }
2933    }
2934
2935    pub fn restriction_product(&self) -> Option<Product> {
2936        match self {
2937            SessionSource::Custom(source) => Product::from_session_source_name(source),
2938            SessionSource::Cli
2939            | SessionSource::VSCode
2940            | SessionSource::Exec
2941            | SessionSource::Mcp
2942            | SessionSource::Unknown => Some(Product::Codex),
2943            SessionSource::Internal(_) | SessionSource::SubAgent(_) => None,
2944        }
2945    }
2946
2947    pub fn matches_product_restriction(&self, products: &[Product]) -> bool {
2948        products.is_empty()
2949            || self
2950                .restriction_product()
2951                .is_some_and(|product| product.matches_product_restriction(products))
2952    }
2953
2954    pub fn parent_thread_id(&self) -> Option<ThreadId> {
2955        match self {
2956            SessionSource::SubAgent(subagent_source) => subagent_source.parent_thread_id(),
2957            SessionSource::Cli
2958            | SessionSource::VSCode
2959            | SessionSource::Exec
2960            | SessionSource::Mcp
2961            | SessionSource::Custom(_)
2962            | SessionSource::Internal(_)
2963            | SessionSource::Unknown => None,
2964        }
2965    }
2966}
2967
2968impl fmt::Display for SubAgentSource {
2969    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2970        match self {
2971            SubAgentSource::Review => f.write_str("review"),
2972            SubAgentSource::Compact => f.write_str("compact"),
2973            SubAgentSource::MemoryConsolidation => f.write_str("memory_consolidation"),
2974            SubAgentSource::ThreadSpawn {
2975                parent_thread_id,
2976                depth,
2977                ..
2978            } => {
2979                write!(f, "thread_spawn_{parent_thread_id}_d{depth}")
2980            }
2981            SubAgentSource::Other(other) => f.write_str(other),
2982        }
2983    }
2984}
2985
2986impl SubAgentSource {
2987    pub fn kind(&self) -> &str {
2988        match self {
2989            SubAgentSource::Review => "review",
2990            SubAgentSource::Compact => "compact",
2991            SubAgentSource::ThreadSpawn { .. } => "thread_spawn",
2992            SubAgentSource::MemoryConsolidation => "memory_consolidation",
2993            SubAgentSource::Other(other) => other,
2994        }
2995    }
2996
2997    pub fn parent_thread_id(&self) -> Option<ThreadId> {
2998        match self {
2999            SubAgentSource::ThreadSpawn {
3000                parent_thread_id, ..
3001            } => Some(*parent_thread_id),
3002            SubAgentSource::Review
3003            | SubAgentSource::Compact
3004            | SubAgentSource::MemoryConsolidation
3005            | SubAgentSource::Other(_) => None,
3006        }
3007    }
3008}
3009
3010impl fmt::Display for InternalSessionSource {
3011    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3012        match self {
3013            InternalSessionSource::MemoryConsolidation => f.write_str("memory_consolidation"),
3014        }
3015    }
3016}
3017
3018fn multi_agent_version_from_items(
3019    items: &[RolloutItem],
3020    thread_id: Option<ThreadId>,
3021) -> Option<MultiAgentVersion> {
3022    let session_meta_version = items.iter().rev().find_map(|item| match item {
3023        RolloutItem::SessionMeta(meta_line)
3024            if thread_id.is_none_or(|thread_id| meta_line.meta.id == thread_id) =>
3025        {
3026            meta_line.meta.multi_agent_version
3027        }
3028        _ => None,
3029    });
3030
3031    session_meta_version.or_else(|| {
3032        items.iter().rev().find_map(|item| match item {
3033            RolloutItem::TurnContext(turn_context) => turn_context.multi_agent_version,
3034            RolloutItem::SessionMeta(_)
3035            | RolloutItem::ResponseItem(_)
3036            | RolloutItem::InterAgentCommunication(_)
3037            | RolloutItem::InterAgentCommunicationMetadata { .. }
3038            | RolloutItem::Compacted(_)
3039            | RolloutItem::WorldState(_)
3040            | RolloutItem::EventMsg(_) => None,
3041        })
3042    })
3043}
3044
3045#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, JsonSchema, TS)]
3046#[serde(rename_all = "snake_case")]
3047#[ts(rename_all = "snake_case")]
3048pub enum MultiAgentVersion {
3049    Disabled,
3050    V1,
3051    V2,
3052}
3053
3054#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema, TS)]
3055pub struct SessionContextWindow {
3056    /// UUIDv7 identity of this context window.
3057    pub window_id: String,
3058}
3059
3060impl SessionContextWindow {
3061    pub fn new(window_id: String) -> Self {
3062        Self { window_id }
3063    }
3064}
3065
3066/// Exclusive position in another thread's paginated rollout history.
3067#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, JsonSchema, TS)]
3068pub struct HistoryPosition {
3069    pub thread_id: ThreadId,
3070    /// First rollout ordinal not included from the prefix file.
3071    pub end_ordinal_exclusive: u64,
3072    /// Byte offset immediately after the last included JSONL record from the prefix file.
3073    pub end_byte_offset: u64,
3074}
3075
3076/// SessionMeta contains session-level data that doesn't correspond to a specific turn.
3077///
3078/// NOTE: There used to be an `instructions` field here, which stored user_instructions, but we
3079/// now save that on TurnContext. base_instructions stores the base instructions for the session,
3080/// and should be used when there is no config override.
3081#[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, TS)]
3082pub struct SessionMeta {
3083    pub session_id: SessionId,
3084    pub id: ThreadId,
3085    #[serde(skip_serializing_if = "Option::is_none")]
3086    pub forked_from_id: Option<ThreadId>,
3087    #[serde(skip_serializing_if = "Option::is_none")]
3088    pub parent_thread_id: Option<ThreadId>,
3089    pub timestamp: String,
3090    pub cwd: PathBuf,
3091    pub originator: String,
3092    pub cli_version: String,
3093    #[serde(default)]
3094    pub source: SessionSource,
3095    /// Optional analytics source classification for this thread.
3096    #[serde(default, skip_serializing_if = "Option::is_none")]
3097    pub thread_source: Option<ThreadSource>,
3098    /// Optional random unique nickname assigned to an AgentControl-spawned sub-agent.
3099    #[serde(skip_serializing_if = "Option::is_none")]
3100    pub agent_nickname: Option<String>,
3101    /// Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.
3102    #[serde(default, alias = "agent_type", skip_serializing_if = "Option::is_none")]
3103    pub agent_role: Option<String>,
3104    /// Optional canonical agent path assigned to an AgentControl-spawned sub-agent.
3105    #[serde(skip_serializing_if = "Option::is_none")]
3106    pub agent_path: Option<String>,
3107    pub model_provider: Option<String>,
3108    /// base_instructions for the session. This *should* always be present when creating a new session,
3109    /// but may be missing for older sessions. If not present, fall back to rendering the base_instructions
3110    /// from ModelsManager.
3111    pub base_instructions: Option<BaseInstructions>,
3112    #[serde(
3113        default,
3114        deserialize_with = "crate::dynamic_tools::deserialize_dynamic_tool_specs",
3115        skip_serializing_if = "Option::is_none"
3116    )]
3117    pub dynamic_tools: Option<Vec<DynamicToolSpec>>,
3118    /// Capability roots selected for this thread by the hosting platform.
3119    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3120    pub selected_capability_roots: Vec<SelectedCapabilityRoot>,
3121    #[serde(skip_serializing_if = "Option::is_none")]
3122    pub memory_mode: Option<String>,
3123    #[serde(default)]
3124    pub history_mode: ThreadHistoryMode,
3125    /// Exclusive prefix of another paginated rollout inherited by this thread.
3126    #[serde(default, skip_serializing_if = "Option::is_none")]
3127    pub history_base: Option<HistoryPosition>,
3128    /// First rollout ordinal that belongs to this subagent's own projected history.
3129    ///
3130    /// Earlier rollout records are inherited model context and stay out of child
3131    /// turn/item projection.
3132    #[serde(default, skip_serializing_if = "Option::is_none")]
3133    pub subagent_history_start_ordinal: Option<u64>,
3134    #[serde(skip_serializing_if = "Option::is_none")]
3135    pub multi_agent_version: Option<MultiAgentVersion>,
3136    /// Initial context-window identity for consumers that tail rollout JSONL before compaction.
3137    #[serde(default, skip_serializing_if = "Option::is_none")]
3138    pub context_window: Option<SessionContextWindow>,
3139}
3140
3141impl Default for SessionMeta {
3142    fn default() -> Self {
3143        let id = ThreadId::default();
3144        SessionMeta {
3145            session_id: id.into(),
3146            id,
3147            forked_from_id: None,
3148            parent_thread_id: None,
3149            timestamp: String::new(),
3150            cwd: PathBuf::new(),
3151            originator: String::new(),
3152            cli_version: String::new(),
3153            source: SessionSource::default(),
3154            thread_source: None,
3155            agent_nickname: None,
3156            agent_role: None,
3157            agent_path: None,
3158            model_provider: None,
3159            base_instructions: None,
3160            dynamic_tools: None,
3161            selected_capability_roots: Vec::new(),
3162            memory_mode: None,
3163            history_mode: ThreadHistoryMode::default(),
3164            history_base: None,
3165            subagent_history_start_ordinal: None,
3166            multi_agent_version: None,
3167            context_window: None,
3168        }
3169    }
3170}
3171
3172#[derive(Serialize, Debug, Clone, JsonSchema, TS)]
3173pub struct SessionMetaLine {
3174    #[serde(flatten)]
3175    pub meta: SessionMeta,
3176    #[serde(skip_serializing_if = "Option::is_none")]
3177    pub git: Option<GitInfo>,
3178}
3179
3180impl<'de> Deserialize<'de> for SessionMetaLine {
3181    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
3182    where
3183        D: Deserializer<'de>,
3184    {
3185        #[derive(Deserialize)]
3186        struct SessionMetaLineFields {
3187            #[serde(flatten)]
3188            meta: SessionMeta,
3189            git: Option<GitInfo>,
3190        }
3191
3192        let mut value = Value::deserialize(deserializer)?;
3193        let fields = value
3194            .as_object_mut()
3195            .ok_or_else(|| D::Error::custom("session metadata must be an object"))?;
3196        if !fields.contains_key("session_id") {
3197            let thread_id = fields
3198                .get("id")
3199                .cloned()
3200                .ok_or_else(|| D::Error::missing_field("id"))?;
3201            fields.insert("session_id".to_string(), thread_id);
3202        }
3203        let SessionMetaLineFields { meta, git } =
3204            serde_json::from_value(value).map_err(D::Error::custom)?;
3205        Ok(Self { meta, git })
3206    }
3207}
3208
3209#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS)]
3210#[serde(tag = "type", content = "payload", rename_all = "snake_case")]
3211pub enum RolloutItem {
3212    SessionMeta(SessionMetaLine),
3213    ResponseItem(ResponseItem),
3214    /// Legacy delivery item reconstructed as a model-visible `agent_message`.
3215    InterAgentCommunication(InterAgentCommunication),
3216    /// Local delivery metadata that is not part of the Responses API item.
3217    InterAgentCommunicationMetadata {
3218        trigger_turn: bool,
3219    },
3220    Compacted(CompactedItem),
3221    TurnContext(TurnContextItem),
3222    WorldState(WorldStateItem),
3223    EventMsg(EventMsg),
3224}
3225
3226/// Persisted comparison state used to resume model-visible world-state diffing.
3227#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema, TS)]
3228pub struct WorldStateItem {
3229    /// Full snapshots establish a new baseline; patches update the current baseline.
3230    pub full: bool,
3231    pub state: Value,
3232}
3233
3234impl WorldStateItem {
3235    pub fn full(state: Value) -> Self {
3236        Self { full: true, state }
3237    }
3238
3239    pub fn patch(state: Value) -> Self {
3240        Self { full: false, state }
3241    }
3242}
3243
3244#[derive(Serialize, Clone, Debug, PartialEq, JsonSchema, TS)]
3245pub struct CompactedItem {
3246    pub message: String,
3247    #[serde(default, skip_serializing_if = "Option::is_none")]
3248    pub replacement_history: Option<Vec<ResponseItem>>,
3249    /// Monotonic position of this context window within the thread.
3250    #[serde(default, skip_serializing_if = "Option::is_none")]
3251    pub window_number: Option<u64>,
3252    /// UUIDv7 identity of the first context window in this thread's window chain.
3253    #[serde(default, skip_serializing_if = "Option::is_none")]
3254    pub first_window_id: Option<String>,
3255    /// UUIDv7 identity of the context window immediately before this one.
3256    #[serde(default, skip_serializing_if = "Option::is_none")]
3257    pub previous_window_id: Option<String>,
3258    /// UUIDv7 identity of this context window.
3259    #[serde(default, skip_serializing_if = "Option::is_none")]
3260    pub window_id: Option<String>,
3261}
3262
3263impl From<CompactedItem> for ResponseItem {
3264    fn from(value: CompactedItem) -> Self {
3265        ResponseItem::Message {
3266            id: None,
3267            role: "assistant".to_string(),
3268            content: vec![ContentItem::OutputText {
3269                text: value.message,
3270            }],
3271            phase: None,
3272            internal_chat_message_metadata_passthrough: None,
3273        }
3274    }
3275}
3276
3277#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema, TS)]
3278pub struct TurnContextNetworkItem {
3279    pub allowed_domains: Vec<String>,
3280    pub denied_domains: Vec<String>,
3281}
3282
3283/// Persist once per real user turn after computing that turn's model-visible
3284/// context updates, and again after mid-turn compaction when replacement
3285/// history re-establishes full context, so resume/fork replay can recover the
3286/// latest durable baseline.
3287#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema, TS)]
3288pub struct TurnContextItem {
3289    #[serde(default, skip_serializing_if = "Option::is_none")]
3290    pub turn_id: Option<String>,
3291    pub cwd: AbsolutePathBuf,
3292    /// Effective workspace roots used to materialize symbolic
3293    /// `:workspace_roots` filesystem permissions in `permission_profile`.
3294    #[serde(default, skip_serializing_if = "Option::is_none")]
3295    pub workspace_roots: Option<Vec<AbsolutePathBuf>>,
3296    #[serde(default, skip_serializing_if = "Option::is_none")]
3297    pub current_date: Option<String>,
3298    #[serde(default, skip_serializing_if = "Option::is_none")]
3299    pub timezone: Option<String>,
3300    pub approval_policy: AskForApproval,
3301    #[serde(default, skip_serializing_if = "Option::is_none")]
3302    pub approvals_reviewer: Option<ApprovalsReviewer>,
3303    pub sandbox_policy: SandboxPolicy,
3304    #[serde(default, skip_serializing_if = "Option::is_none")]
3305    pub permission_profile: Option<PermissionProfile>,
3306    #[serde(skip_serializing_if = "Option::is_none")]
3307    pub network: Option<TurnContextNetworkItem>,
3308    #[serde(default, skip_serializing_if = "Option::is_none")]
3309    pub file_system_sandbox_policy: Option<FileSystemSandboxPolicy>,
3310    pub model: String,
3311    #[serde(default, skip_serializing_if = "Option::is_none")]
3312    pub comp_hash: Option<String>,
3313    #[serde(skip_serializing_if = "Option::is_none")]
3314    pub personality: Option<Personality>,
3315    #[serde(default, skip_serializing_if = "Option::is_none")]
3316    pub collaboration_mode: Option<CollaborationMode>,
3317    #[serde(default, skip_serializing_if = "Option::is_none")]
3318    pub multi_agent_version: Option<MultiAgentVersion>,
3319    /// Effective model-visible mode used as the durable context-diff baseline.
3320    #[serde(default, skip_serializing_if = "Option::is_none")]
3321    pub multi_agent_mode: Option<MultiAgentMode>,
3322    #[serde(default, skip_serializing_if = "Option::is_none")]
3323    pub realtime_active: Option<bool>,
3324    #[serde(skip_serializing_if = "Option::is_none")]
3325    pub effort: Option<ReasoningEffortConfig>,
3326    // Compatibility-only field written with a default value so older Codex
3327    // versions can deserialize turn-context rollout items. It is no longer
3328    // read by context reconstruction and should be removed in a future schema
3329    // cleanup.
3330    pub summary: ReasoningSummaryConfig,
3331}
3332
3333impl TurnContextItem {
3334    pub fn permission_profile(&self) -> PermissionProfile {
3335        self.permission_profile.clone().unwrap_or_else(|| {
3336            let file_system_sandbox_policy =
3337                self.file_system_sandbox_policy.clone().unwrap_or_else(|| {
3338                    FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(
3339                        &self.sandbox_policy,
3340                        self.cwd.as_path(),
3341                    )
3342                });
3343            PermissionProfile::from_runtime_permissions_with_enforcement(
3344                SandboxEnforcement::from_legacy_sandbox_policy(&self.sandbox_policy),
3345                &file_system_sandbox_policy,
3346                NetworkSandboxPolicy::from(&self.sandbox_policy),
3347            )
3348        })
3349    }
3350}
3351
3352#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
3353#[serde(tag = "mode", content = "limit", rename_all = "snake_case")]
3354pub enum TruncationPolicy {
3355    Bytes(usize),
3356    Tokens(usize),
3357}
3358
3359impl From<crate::openai_models::TruncationPolicyConfig> for TruncationPolicy {
3360    fn from(config: crate::openai_models::TruncationPolicyConfig) -> Self {
3361        match config.mode {
3362            crate::openai_models::TruncationMode::Bytes => Self::Bytes(config.limit as usize),
3363            crate::openai_models::TruncationMode::Tokens => Self::Tokens(config.limit as usize),
3364        }
3365    }
3366}
3367
3368impl TruncationPolicy {
3369    pub fn token_budget(&self) -> usize {
3370        match self {
3371            TruncationPolicy::Bytes(bytes) => {
3372                usize::try_from(codex_utils_string::approx_tokens_from_byte_count(*bytes))
3373                    .unwrap_or(usize::MAX)
3374            }
3375            TruncationPolicy::Tokens(tokens) => *tokens,
3376        }
3377    }
3378
3379    pub fn byte_budget(&self) -> usize {
3380        match self {
3381            TruncationPolicy::Bytes(bytes) => *bytes,
3382            TruncationPolicy::Tokens(tokens) => {
3383                codex_utils_string::approx_bytes_for_tokens(*tokens)
3384            }
3385        }
3386    }
3387}
3388
3389impl Mul<f64> for TruncationPolicy {
3390    type Output = Self;
3391
3392    fn mul(self, multiplier: f64) -> Self::Output {
3393        match self {
3394            TruncationPolicy::Bytes(bytes) => {
3395                TruncationPolicy::Bytes((bytes as f64 * multiplier).ceil() as usize)
3396            }
3397            TruncationPolicy::Tokens(tokens) => {
3398                TruncationPolicy::Tokens((tokens as f64 * multiplier).ceil() as usize)
3399            }
3400        }
3401    }
3402}
3403
3404#[derive(Serialize, Deserialize, Clone, JsonSchema)]
3405pub struct RolloutLine {
3406    pub timestamp: String,
3407    #[serde(default, skip_serializing_if = "Option::is_none")]
3408    pub ordinal: Option<u64>,
3409    #[serde(flatten)]
3410    pub item: RolloutItem,
3411}
3412
3413#[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, TS)]
3414pub struct GitInfo {
3415    /// Current commit hash (SHA)
3416    #[serde(skip_serializing_if = "Option::is_none")]
3417    pub commit_hash: Option<GitSha>,
3418    /// Current branch name
3419    #[serde(skip_serializing_if = "Option::is_none")]
3420    pub branch: Option<String>,
3421    /// Repository URL (if available from remote)
3422    #[serde(skip_serializing_if = "Option::is_none")]
3423    pub repository_url: Option<String>,
3424}
3425
3426#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
3427#[serde(rename_all = "snake_case")]
3428pub enum ReviewDelivery {
3429    Inline,
3430    Detached,
3431}
3432
3433#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema, TS)]
3434#[serde(tag = "type", rename_all = "camelCase")]
3435#[ts(tag = "type")]
3436pub enum ReviewTarget {
3437    /// Review the working tree: staged, unstaged, and untracked files.
3438    UncommittedChanges,
3439
3440    /// Review changes between the current branch and the given base branch.
3441    #[serde(rename_all = "camelCase")]
3442    #[ts(rename_all = "camelCase")]
3443    BaseBranch { branch: String },
3444
3445    /// Review the changes introduced by a specific commit.
3446    #[serde(rename_all = "camelCase")]
3447    #[ts(rename_all = "camelCase")]
3448    Commit {
3449        sha: String,
3450        /// Optional human-readable label (e.g., commit subject) for UIs.
3451        title: Option<String>,
3452    },
3453
3454    /// Arbitrary instructions provided by the user.
3455    #[serde(rename_all = "camelCase")]
3456    #[ts(rename_all = "camelCase")]
3457    Custom { instructions: String },
3458}
3459
3460#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)]
3461/// Review request sent to the review session.
3462pub struct ReviewRequest {
3463    pub target: ReviewTarget,
3464    #[serde(skip_serializing_if = "Option::is_none")]
3465    #[ts(optional)]
3466    pub user_facing_hint: Option<String>,
3467}
3468
3469/// Structured review result produced by a child review session.
3470#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)]
3471pub struct ReviewOutputEvent {
3472    pub findings: Vec<ReviewFinding>,
3473    pub overall_correctness: String,
3474    pub overall_explanation: String,
3475    pub overall_confidence_score: f32,
3476}
3477
3478impl Default for ReviewOutputEvent {
3479    fn default() -> Self {
3480        Self {
3481            findings: Vec::new(),
3482            overall_correctness: String::default(),
3483            overall_explanation: String::default(),
3484            overall_confidence_score: 0.0,
3485        }
3486    }
3487}
3488
3489/// A single review finding describing an observed issue or recommendation.
3490#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)]
3491pub struct ReviewFinding {
3492    pub title: String,
3493    pub body: String,
3494    pub confidence_score: f32,
3495    pub priority: i32,
3496    pub code_location: ReviewCodeLocation,
3497}
3498
3499/// Location of the code related to a review finding.
3500#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)]
3501pub struct ReviewCodeLocation {
3502    pub absolute_file_path: PathBuf,
3503    pub line_range: ReviewLineRange,
3504}
3505
3506/// Inclusive line range in a file associated with the finding.
3507#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)]
3508pub struct ReviewLineRange {
3509    pub start: u32,
3510    pub end: u32,
3511}
3512
3513#[derive(
3514    Debug, Clone, Copy, Display, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS, Default,
3515)]
3516#[serde(rename_all = "snake_case")]
3517pub enum ExecCommandSource {
3518    #[default]
3519    Agent,
3520    UserShell,
3521    UnifiedExecStartup,
3522    UnifiedExecInteraction,
3523}
3524
3525#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
3526#[serde(rename_all = "snake_case")]
3527pub enum ExecCommandStatus {
3528    Completed,
3529    Failed,
3530    Declined,
3531}
3532
3533#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
3534pub struct ExecCommandBeginEvent {
3535    /// Identifier so this can be paired with the ExecCommandEnd event.
3536    pub call_id: String,
3537    /// Identifier for the underlying PTY process (when available).
3538    #[serde(default, skip_serializing_if = "Option::is_none")]
3539    #[ts(optional)]
3540    pub process_id: Option<String>,
3541    /// Turn ID that this command belongs to.
3542    pub turn_id: String,
3543    #[serde(default)]
3544    pub started_at_ms: i64,
3545    /// The command to be executed.
3546    pub command: Vec<String>,
3547    /// The command's working directory if not the default cwd for the agent.
3548    pub cwd: PathUri,
3549    pub parsed_cmd: Vec<ParsedCommand>,
3550    /// Where the command originated. Defaults to Agent for backward compatibility.
3551    #[serde(default)]
3552    pub source: ExecCommandSource,
3553    /// Raw input sent to a unified exec session (if this is an interaction event).
3554    #[serde(default, skip_serializing_if = "Option::is_none")]
3555    #[ts(optional)]
3556    pub interaction_input: Option<String>,
3557}
3558
3559#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
3560pub struct ExecCommandEndEvent {
3561    /// Identifier for the ExecCommandBegin that finished.
3562    pub call_id: String,
3563    /// Identifier for the underlying PTY process (when available).
3564    #[serde(default, skip_serializing_if = "Option::is_none")]
3565    #[ts(optional)]
3566    pub process_id: Option<String>,
3567    /// Turn ID that this command belongs to.
3568    pub turn_id: String,
3569    #[serde(default)]
3570    pub completed_at_ms: i64,
3571    /// The command that was executed.
3572    pub command: Vec<String>,
3573    /// The command's working directory if not the default cwd for the agent.
3574    pub cwd: PathUri,
3575    pub parsed_cmd: Vec<ParsedCommand>,
3576    /// Where the command originated. Defaults to Agent for backward compatibility.
3577    #[serde(default)]
3578    pub source: ExecCommandSource,
3579    /// Raw input sent to a unified exec session (if this is an interaction event).
3580    #[serde(default, skip_serializing_if = "Option::is_none")]
3581    #[ts(optional)]
3582    pub interaction_input: Option<String>,
3583
3584    /// Captured stdout
3585    pub stdout: String,
3586    /// Captured stderr
3587    pub stderr: String,
3588    /// Captured aggregated output
3589    #[serde(default)]
3590    pub aggregated_output: String,
3591    /// The command's exit code.
3592    pub exit_code: i32,
3593    /// The duration of the command execution.
3594    #[ts(type = "string")]
3595    pub duration: Duration,
3596    /// Formatted output from the command, as seen by the model.
3597    pub formatted_output: String,
3598    /// Completion status for this command execution.
3599    pub status: ExecCommandStatus,
3600}
3601
3602#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
3603pub struct ViewImageToolCallEvent {
3604    /// Identifier for the originating tool call.
3605    pub call_id: String,
3606    /// Filesystem path resolved for the selected environment.
3607    ///
3608    /// This core event is not exposed directly in the app-server API. App-server
3609    /// converts the path to `LegacyAppPathString` when building its public item.
3610    pub path: PathUri,
3611}
3612
3613#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)]
3614#[serde(rename_all = "snake_case")]
3615pub enum ExecOutputStream {
3616    Stdout,
3617    Stderr,
3618}
3619
3620#[serde_as]
3621#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)]
3622pub struct ExecCommandOutputDeltaEvent {
3623    /// Identifier for the ExecCommandBegin that produced this chunk.
3624    pub call_id: String,
3625    /// Which stream produced this chunk.
3626    pub stream: ExecOutputStream,
3627    /// Raw bytes from the stream (may not be valid UTF-8).
3628    #[serde_as(as = "serde_with::base64::Base64")]
3629    #[schemars(with = "String")]
3630    #[ts(type = "string")]
3631    pub chunk: Vec<u8>,
3632}
3633
3634#[serde_as]
3635#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)]
3636pub struct TerminalInteractionEvent {
3637    /// Identifier for the ExecCommandBegin that produced this chunk.
3638    pub call_id: String,
3639    /// Process id associated with the running command.
3640    pub process_id: String,
3641    /// Stdin sent to the running session.
3642    pub stdin: String,
3643}
3644
3645#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
3646pub struct DeprecationNoticeEvent {
3647    /// Concise summary of what is deprecated.
3648    pub summary: String,
3649    /// Optional extra guidance, such as migration steps or rationale.
3650    #[serde(skip_serializing_if = "Option::is_none")]
3651    pub details: Option<String>,
3652}
3653
3654#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
3655pub struct ThreadRolledBackEvent {
3656    /// Number of user turns that were removed from context.
3657    pub num_turns: u32,
3658}
3659
3660#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
3661pub struct StreamErrorEvent {
3662    pub message: String,
3663    #[serde(default)]
3664    pub codex_error_info: Option<CodexErrorInfo>,
3665    /// Optional details about the underlying stream failure (often the same
3666    /// human-readable message that is surfaced as the terminal error if retries
3667    /// are exhausted).
3668    #[serde(default)]
3669    pub additional_details: Option<String>,
3670}
3671
3672#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
3673pub struct StreamInfoEvent {
3674    pub message: String,
3675}
3676
3677#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
3678pub struct PatchApplyBeginEvent {
3679    /// Identifier so this can be paired with the PatchApplyEnd event.
3680    pub call_id: String,
3681    /// Turn ID that this patch belongs to.
3682    /// Uses `#[serde(default)]` for backwards compatibility.
3683    #[serde(default)]
3684    pub turn_id: String,
3685    /// If true, there was no ApplyPatchApprovalRequest for this patch.
3686    pub auto_approved: bool,
3687    /// The changes to be applied.
3688    pub changes: HashMap<PathBuf, FileChange>,
3689}
3690
3691#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
3692pub struct PatchApplyUpdatedEvent {
3693    /// Identifier for the originating `apply_patch` tool call.
3694    pub call_id: String,
3695    /// Structured file changes parsed from the model-generated patch input so far.
3696    pub changes: HashMap<PathBuf, FileChange>,
3697}
3698
3699#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
3700pub struct PatchApplyEndEvent {
3701    /// Identifier for the PatchApplyBegin that finished.
3702    pub call_id: String,
3703    /// Turn ID that this patch belongs to.
3704    /// Uses `#[serde(default)]` for backwards compatibility.
3705    #[serde(default)]
3706    pub turn_id: String,
3707    /// Captured stdout (summary printed by apply_patch).
3708    pub stdout: String,
3709    /// Captured stderr (parser errors, IO failures, etc.).
3710    pub stderr: String,
3711    /// Whether the patch was applied successfully.
3712    pub success: bool,
3713    /// The changes that were applied (mirrors PatchApplyBeginEvent::changes).
3714    #[serde(default)]
3715    pub changes: HashMap<PathBuf, FileChange>,
3716    /// Completion status for this patch application.
3717    pub status: PatchApplyStatus,
3718}
3719
3720#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
3721#[serde(rename_all = "snake_case")]
3722pub enum PatchApplyStatus {
3723    Completed,
3724    Failed,
3725    Declined,
3726}
3727
3728#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
3729pub struct TurnDiffEvent {
3730    pub unified_diff: String,
3731}
3732
3733#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
3734pub struct McpStartupUpdateEvent {
3735    /// Server name being started.
3736    pub server: String,
3737    /// Current startup status.
3738    pub status: McpStartupStatus,
3739}
3740
3741#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
3742#[serde(rename_all = "snake_case", tag = "state")]
3743#[ts(rename_all = "snake_case", tag = "state")]
3744pub enum McpStartupStatus {
3745    Starting,
3746    Ready,
3747    Failed {
3748        error: String,
3749        #[serde(default, skip_serializing_if = "Option::is_none")]
3750        #[ts(optional = nullable)]
3751        reason: Option<McpStartupFailureReason>,
3752    },
3753    Cancelled,
3754}
3755
3756#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, TS)]
3757#[serde(rename_all = "snake_case")]
3758#[ts(rename_all = "snake_case")]
3759pub enum McpStartupFailureReason {
3760    ReauthenticationRequired,
3761}
3762
3763#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS, Default)]
3764pub struct McpStartupCompleteEvent {
3765    pub ready: Vec<String>,
3766    pub failed: Vec<McpStartupFailure>,
3767    pub cancelled: Vec<String>,
3768}
3769
3770#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
3771pub struct McpStartupFailure {
3772    pub server: String,
3773    pub error: String,
3774}
3775
3776#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
3777#[serde(rename_all = "snake_case")]
3778#[ts(rename_all = "snake_case")]
3779pub enum McpAuthStatus {
3780    Unsupported,
3781    NotLoggedIn,
3782    BearerToken,
3783    OAuth,
3784}
3785
3786impl fmt::Display for McpAuthStatus {
3787    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3788        let text = match self {
3789            McpAuthStatus::Unsupported => "Unsupported",
3790            McpAuthStatus::NotLoggedIn => "Not logged in",
3791            McpAuthStatus::BearerToken => "Bearer token",
3792            McpAuthStatus::OAuth => "OAuth",
3793        };
3794        f.write_str(text)
3795    }
3796}
3797
3798#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
3799pub struct RealtimeConversationListVoicesResponseEvent {
3800    pub voices: RealtimeVoicesList,
3801}
3802
3803#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, TS)]
3804#[serde(rename_all = "lowercase")]
3805#[ts(rename_all = "lowercase")]
3806pub enum Product {
3807    #[serde(alias = "CHATGPT")]
3808    Chatgpt,
3809    #[serde(alias = "CODEX")]
3810    Codex,
3811    #[serde(alias = "ATLAS")]
3812    Atlas,
3813}
3814impl Product {
3815    pub fn to_app_platform(self) -> &'static str {
3816        match self {
3817            Self::Chatgpt => "chat",
3818            Self::Codex => "codex",
3819            Self::Atlas => "atlas",
3820        }
3821    }
3822
3823    pub fn from_session_source_name(value: &str) -> Option<Self> {
3824        let normalized = value.trim().to_ascii_lowercase();
3825        match normalized.as_str() {
3826            "chatgpt" => Some(Self::Chatgpt),
3827            "codex" => Some(Self::Codex),
3828            "atlas" => Some(Self::Atlas),
3829            _ => None,
3830        }
3831    }
3832
3833    pub fn matches_product_restriction(&self, products: &[Product]) -> bool {
3834        products.is_empty() || products.contains(self)
3835    }
3836}
3837#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
3838#[serde(rename_all = "snake_case")]
3839#[ts(rename_all = "snake_case")]
3840pub enum SkillScope {
3841    User,
3842    Repo,
3843    System,
3844    Admin,
3845}
3846
3847#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
3848pub struct SkillMetadata {
3849    pub name: String,
3850    pub description: String,
3851    #[serde(default, skip_serializing_if = "Option::is_none")]
3852    #[ts(optional)]
3853    /// Legacy short_description from SKILL.md. Prefer SKILL.json interface.short_description.
3854    pub short_description: Option<String>,
3855    #[serde(default, skip_serializing_if = "Option::is_none")]
3856    #[ts(optional)]
3857    pub interface: Option<SkillInterface>,
3858    #[serde(default, skip_serializing_if = "Option::is_none")]
3859    #[ts(optional)]
3860    pub dependencies: Option<SkillDependencies>,
3861    pub path: AbsolutePathBuf,
3862    pub scope: SkillScope,
3863    pub enabled: bool,
3864}
3865
3866#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS, PartialEq, Eq)]
3867pub struct SkillInterface {
3868    #[ts(optional)]
3869    pub display_name: Option<String>,
3870    #[ts(optional)]
3871    pub short_description: Option<String>,
3872    #[ts(optional)]
3873    pub icon_small: Option<AbsolutePathBuf>,
3874    #[ts(optional)]
3875    pub icon_large: Option<AbsolutePathBuf>,
3876    #[ts(optional)]
3877    pub brand_color: Option<String>,
3878    #[ts(optional)]
3879    pub default_prompt: Option<String>,
3880}
3881
3882#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS, PartialEq, Eq)]
3883pub struct SkillDependencies {
3884    pub tools: Vec<SkillToolDependency>,
3885}
3886
3887#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS, PartialEq, Eq)]
3888pub struct SkillToolDependency {
3889    #[serde(rename = "type")]
3890    #[ts(rename = "type")]
3891    pub r#type: String,
3892    pub value: String,
3893    #[serde(default, skip_serializing_if = "Option::is_none")]
3894    #[ts(optional)]
3895    pub description: Option<String>,
3896    #[serde(default, skip_serializing_if = "Option::is_none")]
3897    #[ts(optional)]
3898    pub transport: Option<String>,
3899    #[serde(default, skip_serializing_if = "Option::is_none")]
3900    #[ts(optional)]
3901    pub command: Option<String>,
3902    #[serde(default, skip_serializing_if = "Option::is_none")]
3903    #[ts(optional)]
3904    pub url: Option<String>,
3905}
3906
3907#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS, PartialEq, Eq)]
3908pub struct SessionNetworkProxyRuntime {
3909    pub http_addr: String,
3910    pub socks_addr: String,
3911}
3912
3913#[derive(Debug, Clone, Serialize, JsonSchema, TS)]
3914pub struct SessionConfiguredEvent {
3915    pub session_id: SessionId,
3916    pub thread_id: ThreadId,
3917    #[serde(skip_serializing_if = "Option::is_none")]
3918    pub forked_from_id: Option<ThreadId>,
3919    #[serde(skip_serializing_if = "Option::is_none")]
3920    pub parent_thread_id: Option<ThreadId>,
3921    /// Optional analytics source classification for this thread.
3922    #[serde(default, skip_serializing_if = "Option::is_none")]
3923    pub thread_source: Option<ThreadSource>,
3924
3925    /// Optional user-facing thread name (may be unset).
3926    #[serde(default, skip_serializing_if = "Option::is_none")]
3927    #[ts(optional)]
3928    pub thread_name: Option<String>,
3929
3930    /// Tell the client what model is being queried.
3931    pub model: String,
3932
3933    pub model_provider_id: String,
3934
3935    #[serde(skip_serializing_if = "Option::is_none")]
3936    pub service_tier: Option<String>,
3937
3938    /// When to escalate for approval for execution
3939    pub approval_policy: AskForApproval,
3940
3941    /// Configures who approval requests are routed to for review once they have
3942    /// been escalated. This does not disable separate safety checks such as
3943    /// ARC.
3944    #[serde(default)]
3945    pub approvals_reviewer: ApprovalsReviewer,
3946
3947    /// Canonical effective permissions for commands executed in the session.
3948    pub permission_profile: PermissionProfile,
3949
3950    /// Named or implicit built-in profile that produced `permission_profile`,
3951    /// when known.
3952    #[serde(default, skip_serializing_if = "Option::is_none")]
3953    #[ts(optional)]
3954    pub active_permission_profile: Option<ActivePermissionProfile>,
3955
3956    /// Working directory that should be treated as the *root* of the
3957    /// session.
3958    pub cwd: AbsolutePathBuf,
3959
3960    /// The effort the model is putting into reasoning about the user's request.
3961    #[serde(skip_serializing_if = "Option::is_none")]
3962    pub reasoning_effort: Option<ReasoningEffortConfig>,
3963
3964    /// Optional initial messages (as events) for resumed sessions.
3965    /// When present, UIs can use these to seed the history.
3966    #[serde(skip_serializing_if = "Option::is_none")]
3967    pub initial_messages: Option<Vec<EventMsg>>,
3968
3969    /// Runtime proxy bind addresses, when the managed proxy was started for this session.
3970    #[serde(default, skip_serializing_if = "Option::is_none")]
3971    #[ts(optional)]
3972    pub network_proxy: Option<SessionNetworkProxyRuntime>,
3973
3974    /// Path in which the rollout is stored. Can be `None` for ephemeral threads
3975    #[serde(skip_serializing_if = "Option::is_none")]
3976    pub rollout_path: Option<PathBuf>,
3977}
3978
3979impl<'de> Deserialize<'de> for SessionConfiguredEvent {
3980    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
3981    where
3982        D: serde::Deserializer<'de>,
3983    {
3984        #[derive(Deserialize)]
3985        struct Wire {
3986            session_id: SessionId,
3987            #[serde(default)]
3988            thread_id: Option<ThreadId>,
3989            forked_from_id: Option<ThreadId>,
3990            parent_thread_id: Option<ThreadId>,
3991            #[serde(default)]
3992            thread_source: Option<ThreadSource>,
3993            #[serde(default)]
3994            thread_name: Option<String>,
3995            model: String,
3996            model_provider_id: String,
3997            service_tier: Option<String>,
3998            approval_policy: AskForApproval,
3999            #[serde(default)]
4000            approvals_reviewer: ApprovalsReviewer,
4001            // `SessionConfiguredEvent` is persisted into rollout history. Older
4002            // rollouts only have `sandbox_policy`, so accept it on deserialize
4003            // and immediately project it into the canonical `permission_profile`.
4004            sandbox_policy: Option<SandboxPolicy>,
4005            permission_profile: Option<PermissionProfile>,
4006            #[serde(default)]
4007            active_permission_profile: Option<ActivePermissionProfile>,
4008            cwd: AbsolutePathBuf,
4009            reasoning_effort: Option<ReasoningEffortConfig>,
4010            initial_messages: Option<Vec<EventMsg>>,
4011            network_proxy: Option<SessionNetworkProxyRuntime>,
4012            rollout_path: Option<PathBuf>,
4013        }
4014
4015        let wire = Wire::deserialize(deserializer)?;
4016        let permission_profile = match (wire.permission_profile, wire.sandbox_policy) {
4017            (Some(permission_profile), _) => permission_profile,
4018            (None, Some(sandbox_policy)) => PermissionProfile::from_legacy_sandbox_policy_for_cwd(
4019                &sandbox_policy,
4020                wire.cwd.as_path(),
4021            ),
4022            (None, None) => {
4023                return Err(serde::de::Error::missing_field("permission_profile"));
4024            }
4025        };
4026
4027        Ok(Self {
4028            session_id: wire.session_id,
4029            thread_id: wire.thread_id.unwrap_or_else(|| wire.session_id.into()),
4030            forked_from_id: wire.forked_from_id,
4031            parent_thread_id: wire.parent_thread_id,
4032            thread_source: wire.thread_source,
4033            thread_name: wire.thread_name,
4034            model: wire.model,
4035            model_provider_id: wire.model_provider_id,
4036            service_tier: wire.service_tier,
4037            approval_policy: wire.approval_policy,
4038            approvals_reviewer: wire.approvals_reviewer,
4039            permission_profile,
4040            active_permission_profile: wire.active_permission_profile,
4041            cwd: wire.cwd,
4042            reasoning_effort: wire.reasoning_effort,
4043            initial_messages: wire.initial_messages,
4044            network_proxy: wire.network_proxy,
4045            rollout_path: wire.rollout_path,
4046        })
4047    }
4048}
4049
4050#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
4051#[serde(rename_all = "camelCase")]
4052#[ts(export_to = "protocol/")]
4053pub enum ThreadGoalStatus {
4054    Active,
4055    Paused,
4056    Blocked,
4057    UsageLimited,
4058    BudgetLimited,
4059    Complete,
4060}
4061
4062pub const MAX_THREAD_GOAL_OBJECTIVE_CHARS: usize = 4_000;
4063
4064pub fn validate_thread_goal_objective(value: &str) -> Result<(), String> {
4065    if value.is_empty() {
4066        return Err("goal objective must not be empty".to_string());
4067    }
4068    if value.chars().count() > MAX_THREAD_GOAL_OBJECTIVE_CHARS {
4069        return Err(format!(
4070            "goal objective must be at most {MAX_THREAD_GOAL_OBJECTIVE_CHARS} characters"
4071        ));
4072    }
4073    Ok(())
4074}
4075
4076#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
4077#[serde(rename_all = "camelCase")]
4078#[ts(export_to = "protocol/")]
4079pub struct ThreadGoal {
4080    pub thread_id: ThreadId,
4081    pub objective: String,
4082    pub status: ThreadGoalStatus,
4083    #[serde(default, skip_serializing_if = "Option::is_none")]
4084    #[ts(optional)]
4085    pub token_budget: Option<i64>,
4086    pub tokens_used: i64,
4087    pub time_used_seconds: i64,
4088    pub created_at: i64,
4089    pub updated_at: i64,
4090}
4091
4092#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
4093#[serde(rename_all = "camelCase")]
4094#[ts(export_to = "protocol/")]
4095pub struct ThreadGoalUpdatedEvent {
4096    pub thread_id: ThreadId,
4097    #[serde(default, skip_serializing_if = "Option::is_none")]
4098    #[ts(optional)]
4099    pub turn_id: Option<String>,
4100    pub goal: ThreadGoal,
4101}
4102
4103/// User's decision in response to an ExecApprovalRequest.
4104#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Display, JsonSchema, TS)]
4105#[serde(rename_all = "snake_case")]
4106pub enum ReviewDecision {
4107    /// User has approved this command and the agent should execute it.
4108    Approved,
4109
4110    /// User has approved this command and wants to apply the proposed execpolicy
4111    /// amendment so future matching commands are permitted.
4112    ApprovedExecpolicyAmendment {
4113        proposed_execpolicy_amendment: ExecPolicyAmendment,
4114    },
4115
4116    /// User has approved this request and wants future prompts in the same
4117    /// session-scoped approval cache to be automatically approved for the
4118    /// remainder of the session.
4119    ApprovedForSession,
4120
4121    /// User chose to persist a network policy rule (allow/deny) for future
4122    /// requests to the same host.
4123    NetworkPolicyAmendment {
4124        network_policy_amendment: NetworkPolicyAmendment,
4125    },
4126
4127    /// User has denied this command and the agent should not execute it, but
4128    /// it should continue the session and try something else.
4129    Denied { rejection: String },
4130
4131    /// Automatic approval review timed out before reaching a decision.
4132    TimedOut,
4133
4134    /// User has denied this command and the agent should not do anything until
4135    /// the user's next command.
4136    Abort,
4137}
4138
4139impl Default for ReviewDecision {
4140    fn default() -> Self {
4141        Self::Denied {
4142            rejection: "denied".to_string(),
4143        }
4144    }
4145}
4146
4147impl ReviewDecision {
4148    pub fn denied(rejection: impl Into<String>) -> Self {
4149        Self::Denied {
4150            rejection: rejection.into(),
4151        }
4152    }
4153
4154    /// Returns an opaque version of the decision without PII. We can't use an ignored flag
4155    /// on `serde` because the serialization is required by some surfaces.
4156    pub fn to_opaque_string(&self) -> &'static str {
4157        match self {
4158            ReviewDecision::Approved => "approved",
4159            ReviewDecision::ApprovedExecpolicyAmendment { .. } => "approved_with_amendment",
4160            ReviewDecision::ApprovedForSession => "approved_for_session",
4161            ReviewDecision::NetworkPolicyAmendment {
4162                network_policy_amendment,
4163            } => match network_policy_amendment.action {
4164                NetworkPolicyRuleAction::Allow => "approved_with_network_policy_allow",
4165                NetworkPolicyRuleAction::Deny => "denied_with_network_policy_deny",
4166            },
4167            ReviewDecision::Denied { .. } => "denied",
4168            ReviewDecision::TimedOut => "timed_out",
4169            ReviewDecision::Abort => "abort",
4170        }
4171    }
4172}
4173
4174#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)]
4175#[serde(tag = "type", rename_all = "snake_case")]
4176#[ts(tag = "type")]
4177pub enum FileChange {
4178    Add {
4179        content: String,
4180    },
4181    Delete {
4182        content: String,
4183    },
4184    Update {
4185        unified_diff: String,
4186        move_path: Option<PathBuf>,
4187    },
4188}
4189
4190#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
4191pub struct Chunk {
4192    /// 1-based line index of the first line in the original file
4193    pub orig_index: u32,
4194    pub deleted_lines: Vec<String>,
4195    pub inserted_lines: Vec<String>,
4196}
4197
4198#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
4199pub struct TurnAbortedEvent {
4200    pub turn_id: Option<String>,
4201    pub reason: TurnAbortReason,
4202    /// Unix timestamp (in seconds) when the turn started.
4203    #[serde(default, skip_serializing_if = "Option::is_none")]
4204    #[ts(type = "number | null", optional)]
4205    pub started_at: Option<i64>,
4206    /// Unix timestamp (in seconds) when the turn was aborted.
4207    #[serde(default, skip_serializing_if = "Option::is_none")]
4208    #[ts(type = "number | null", optional)]
4209    pub completed_at: Option<i64>,
4210    /// Duration between turn start and abort in milliseconds, if known.
4211    #[serde(default, skip_serializing_if = "Option::is_none")]
4212    #[ts(type = "number | null", optional)]
4213    pub duration_ms: Option<i64>,
4214}
4215
4216#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)]
4217#[serde(rename_all = "snake_case")]
4218pub enum TurnAbortReason {
4219    Interrupted,
4220    Replaced,
4221    ReviewEnded,
4222    BudgetLimited,
4223}
4224
4225#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)]
4226pub struct CollabAgentSpawnBeginEvent {
4227    /// Identifier for the collab tool call.
4228    pub call_id: String,
4229    #[serde(default)]
4230    pub started_at_ms: i64,
4231    /// Thread ID of the sender.
4232    pub sender_thread_id: ThreadId,
4233    /// Initial prompt sent to the agent. Can be empty to prevent CoT leaking at the
4234    /// beginning.
4235    pub prompt: String,
4236    pub model: String,
4237    pub reasoning_effort: ReasoningEffortConfig,
4238}
4239
4240#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
4241pub struct CollabAgentRef {
4242    /// Thread ID of the receiver/new agent.
4243    pub thread_id: ThreadId,
4244    /// Optional nickname assigned to an AgentControl-spawned sub-agent.
4245    #[serde(default, skip_serializing_if = "Option::is_none")]
4246    pub agent_nickname: Option<String>,
4247    /// Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.
4248    #[serde(default, alias = "agent_type", skip_serializing_if = "Option::is_none")]
4249    pub agent_role: Option<String>,
4250}
4251
4252#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
4253pub struct CollabAgentStatusEntry {
4254    /// Thread ID of the receiver/new agent.
4255    pub thread_id: ThreadId,
4256    /// Optional nickname assigned to an AgentControl-spawned sub-agent.
4257    #[serde(default, skip_serializing_if = "Option::is_none")]
4258    pub agent_nickname: Option<String>,
4259    /// Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.
4260    #[serde(default, alias = "agent_type", skip_serializing_if = "Option::is_none")]
4261    pub agent_role: Option<String>,
4262    /// Last known status of the agent.
4263    pub status: AgentStatus,
4264}
4265
4266#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)]
4267pub struct CollabAgentSpawnEndEvent {
4268    /// Identifier for the collab tool call.
4269    pub call_id: String,
4270    #[serde(default)]
4271    pub completed_at_ms: i64,
4272    /// Thread ID of the sender.
4273    pub sender_thread_id: ThreadId,
4274    /// Thread ID of the newly spawned agent, if it was created.
4275    pub new_thread_id: Option<ThreadId>,
4276    /// Optional nickname assigned to the new agent.
4277    #[serde(default, skip_serializing_if = "Option::is_none")]
4278    pub new_agent_nickname: Option<String>,
4279    /// Optional role assigned to the new agent.
4280    #[serde(default, skip_serializing_if = "Option::is_none")]
4281    pub new_agent_role: Option<String>,
4282    /// Initial prompt sent to the agent. Can be empty to prevent CoT leaking at the
4283    /// beginning.
4284    pub prompt: String,
4285    /// Effective model used by the spawned agent after inheritance and role overrides.
4286    pub model: String,
4287    /// Effective reasoning effort used by the spawned agent after inheritance and role overrides.
4288    pub reasoning_effort: ReasoningEffortConfig,
4289    /// Last known status of the new agent reported to the sender agent.
4290    pub status: AgentStatus,
4291}
4292
4293#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)]
4294pub struct CollabAgentInteractionBeginEvent {
4295    /// Identifier for the collab tool call.
4296    pub call_id: String,
4297    #[serde(default)]
4298    pub started_at_ms: i64,
4299    /// Thread ID of the sender.
4300    pub sender_thread_id: ThreadId,
4301    /// Thread ID of the receiver.
4302    pub receiver_thread_id: ThreadId,
4303    /// Prompt sent from the sender to the receiver. Can be empty to prevent CoT
4304    /// leaking at the beginning.
4305    pub prompt: String,
4306}
4307
4308#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)]
4309pub struct CollabAgentInteractionEndEvent {
4310    /// Identifier for the collab tool call.
4311    pub call_id: String,
4312    #[serde(default)]
4313    pub completed_at_ms: i64,
4314    /// Thread ID of the sender.
4315    pub sender_thread_id: ThreadId,
4316    /// Thread ID of the receiver.
4317    pub receiver_thread_id: ThreadId,
4318    /// Optional nickname assigned to the receiver agent.
4319    #[serde(default, skip_serializing_if = "Option::is_none")]
4320    pub receiver_agent_nickname: Option<String>,
4321    /// Optional role assigned to the receiver agent.
4322    #[serde(default, skip_serializing_if = "Option::is_none")]
4323    pub receiver_agent_role: Option<String>,
4324    /// Prompt sent from the sender to the receiver. Can be empty to prevent CoT
4325    /// leaking at the beginning.
4326    pub prompt: String,
4327    /// Last known status of the receiver agent reported to the sender agent.
4328    pub status: AgentStatus,
4329}
4330
4331#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
4332#[serde(rename_all = "snake_case")]
4333#[ts(rename_all = "snake_case")]
4334pub enum SubAgentActivityKind {
4335    Started,
4336    Interacted,
4337    Interrupted,
4338}
4339
4340#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
4341pub struct SubAgentActivityEvent {
4342    pub event_id: String,
4343    #[serde(default)]
4344    pub occurred_at_ms: i64,
4345    /// Thread ID of the affected sub-agent.
4346    pub agent_thread_id: ThreadId,
4347    /// Canonical v2 path of the affected sub-agent.
4348    pub agent_path: AgentPath,
4349    pub kind: SubAgentActivityKind,
4350}
4351
4352#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)]
4353pub struct CollabWaitingBeginEvent {
4354    #[serde(default)]
4355    pub started_at_ms: i64,
4356    /// Thread ID of the sender.
4357    pub sender_thread_id: ThreadId,
4358    /// Thread ID of the receivers.
4359    pub receiver_thread_ids: Vec<ThreadId>,
4360    /// Optional nicknames/roles for receivers.
4361    #[serde(default, skip_serializing_if = "Vec::is_empty")]
4362    pub receiver_agents: Vec<CollabAgentRef>,
4363    /// ID of the waiting call.
4364    pub call_id: String,
4365}
4366
4367#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)]
4368pub struct CollabWaitingEndEvent {
4369    /// Thread ID of the sender.
4370    pub sender_thread_id: ThreadId,
4371    /// ID of the waiting call.
4372    pub call_id: String,
4373    #[serde(default)]
4374    pub completed_at_ms: i64,
4375    /// Optional receiver metadata paired with final statuses.
4376    #[serde(default, skip_serializing_if = "Vec::is_empty")]
4377    pub agent_statuses: Vec<CollabAgentStatusEntry>,
4378    /// Last known status of the receiver agents reported to the sender agent.
4379    pub statuses: HashMap<ThreadId, AgentStatus>,
4380}
4381
4382#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)]
4383pub struct CollabCloseBeginEvent {
4384    /// Identifier for the collab tool call.
4385    pub call_id: String,
4386    #[serde(default)]
4387    pub started_at_ms: i64,
4388    /// Thread ID of the sender.
4389    pub sender_thread_id: ThreadId,
4390    /// Thread ID of the receiver.
4391    pub receiver_thread_id: ThreadId,
4392}
4393
4394#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)]
4395pub struct CollabCloseEndEvent {
4396    /// Identifier for the collab tool call.
4397    pub call_id: String,
4398    #[serde(default)]
4399    pub completed_at_ms: i64,
4400    /// Thread ID of the sender.
4401    pub sender_thread_id: ThreadId,
4402    /// Thread ID of the receiver.
4403    pub receiver_thread_id: ThreadId,
4404    /// Optional nickname assigned to the receiver agent.
4405    #[serde(default, skip_serializing_if = "Option::is_none")]
4406    pub receiver_agent_nickname: Option<String>,
4407    /// Optional role assigned to the receiver agent.
4408    #[serde(default, skip_serializing_if = "Option::is_none")]
4409    pub receiver_agent_role: Option<String>,
4410    /// Last known status of the receiver agent reported to the sender agent before
4411    /// the close.
4412    pub status: AgentStatus,
4413}
4414
4415#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)]
4416pub struct CollabResumeBeginEvent {
4417    /// Identifier for the collab tool call.
4418    pub call_id: String,
4419    #[serde(default)]
4420    pub started_at_ms: i64,
4421    /// Thread ID of the sender.
4422    pub sender_thread_id: ThreadId,
4423    /// Thread ID of the receiver.
4424    pub receiver_thread_id: ThreadId,
4425    /// Optional nickname assigned to the receiver agent.
4426    #[serde(default, skip_serializing_if = "Option::is_none")]
4427    pub receiver_agent_nickname: Option<String>,
4428    /// Optional role assigned to the receiver agent.
4429    #[serde(default, skip_serializing_if = "Option::is_none")]
4430    pub receiver_agent_role: Option<String>,
4431}
4432
4433#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)]
4434pub struct CollabResumeEndEvent {
4435    /// Identifier for the collab tool call.
4436    pub call_id: String,
4437    #[serde(default)]
4438    pub completed_at_ms: i64,
4439    /// Thread ID of the sender.
4440    pub sender_thread_id: ThreadId,
4441    /// Thread ID of the receiver.
4442    pub receiver_thread_id: ThreadId,
4443    /// Optional nickname assigned to the receiver agent.
4444    #[serde(default, skip_serializing_if = "Option::is_none")]
4445    pub receiver_agent_nickname: Option<String>,
4446    /// Optional role assigned to the receiver agent.
4447    #[serde(default, skip_serializing_if = "Option::is_none")]
4448    pub receiver_agent_role: Option<String>,
4449    /// Last known status of the receiver agent reported to the sender agent after
4450    /// resume.
4451    pub status: AgentStatus,
4452}
4453
4454#[cfg(test)]
4455mod tests {
4456    use super::*;
4457    use crate::items::CommandExecutionItem;
4458    use crate::items::CommandExecutionStatus;
4459    use crate::items::DynamicToolCallItem;
4460    use crate::items::DynamicToolCallStatus;
4461    use crate::items::EnteredReviewModeItem;
4462    use crate::items::ExitedReviewModeItem;
4463    use crate::items::FileChangeItem;
4464    use crate::items::ImageGenerationItem;
4465    use crate::items::McpToolCallItem;
4466    use crate::items::McpToolCallStatus;
4467    use crate::items::UserMessageItem;
4468    use crate::items::WebSearchItem;
4469    use crate::mcp::CallToolResult;
4470    use crate::permissions::FileSystemAccessMode;
4471    use crate::permissions::FileSystemPath;
4472    use crate::permissions::FileSystemSandboxEntry;
4473    use crate::permissions::FileSystemSandboxPolicy;
4474    use crate::permissions::FileSystemSpecialPath;
4475    use crate::permissions::NetworkSandboxPolicy;
4476    use anyhow::Result;
4477    use codex_utils_absolute_path::AbsolutePathBuf;
4478    use codex_utils_absolute_path::test_support::PathBufExt;
4479    use codex_utils_absolute_path::test_support::test_path_buf;
4480    use pretty_assertions::assert_eq;
4481    use serde_json::json;
4482    use std::path::PathBuf;
4483    use tempfile::NamedTempFile;
4484    use tempfile::TempDir;
4485
4486    #[test]
4487    fn review_decision_denied_round_trip() -> Result<()> {
4488        let decision = ReviewDecision::Denied {
4489            rejection: "denied reason".to_string(),
4490        };
4491        let value = json!({"denied": {"rejection": "denied reason"}});
4492
4493        assert_eq!(serde_json::to_value(&decision)?, value);
4494        assert_eq!(serde_json::from_value::<ReviewDecision>(value)?, decision);
4495        Ok(())
4496    }
4497
4498    #[test]
4499    fn feature_thread_source_serializes_as_its_app_owned_label() -> Result<()> {
4500        let source = ThreadSource::Feature("automation".to_string());
4501
4502        assert_eq!(serde_json::to_value(&source)?, json!("automation"));
4503        assert_eq!(
4504            serde_json::from_value::<ThreadSource>(json!("automation"))?,
4505            source
4506        );
4507        Ok(())
4508    }
4509
4510    #[test]
4511    fn session_meta_normalizes_legacy_dynamic_tools() -> Result<()> {
4512        let mut value = serde_json::to_value(SessionMeta::default())?;
4513        value["dynamic_tools"] = json!([
4514            {
4515                "namespace": "legacy_app",
4516                "name": "lookup_ticket",
4517                "description": "Look up a ticket",
4518                "inputSchema": {"type": "object", "properties": {}},
4519                "exposeToContext": false
4520            },
4521            {
4522                "namespace": "legacy_app",
4523                "name": "update_ticket",
4524                "description": "Update a ticket",
4525                "inputSchema": {"type": "object", "properties": {}},
4526                "deferLoading": false,
4527                "exposeToContext": false
4528            }
4529        ]);
4530
4531        let meta: SessionMeta = serde_json::from_value(value)?;
4532
4533        assert_eq!(
4534            meta.dynamic_tools,
4535            Some(vec![DynamicToolSpec::Namespace(
4536                crate::dynamic_tools::DynamicToolNamespaceSpec {
4537                    name: "legacy_app".to_string(),
4538                    description: String::new(),
4539                    tools: vec![
4540                        crate::dynamic_tools::DynamicToolNamespaceTool::Function(
4541                            crate::dynamic_tools::DynamicToolFunctionSpec {
4542                                name: "lookup_ticket".to_string(),
4543                                description: "Look up a ticket".to_string(),
4544                                input_schema: json!({"type": "object", "properties": {}}),
4545                                defer_loading: true,
4546                            },
4547                        ),
4548                        crate::dynamic_tools::DynamicToolNamespaceTool::Function(
4549                            crate::dynamic_tools::DynamicToolFunctionSpec {
4550                                name: "update_ticket".to_string(),
4551                                description: "Update a ticket".to_string(),
4552                                input_schema: json!({"type": "object", "properties": {}}),
4553                                defer_loading: false,
4554                            },
4555                        ),
4556                    ],
4557                },
4558            )])
4559        );
4560        Ok(())
4561    }
4562
4563    fn sorted_writable_roots(roots: Vec<WritableRoot>) -> Vec<(PathBuf, Vec<PathBuf>)> {
4564        let mut sorted_roots: Vec<(PathBuf, Vec<PathBuf>)> = roots
4565            .into_iter()
4566            .map(|root| {
4567                let mut read_only_subpaths: Vec<PathBuf> = root
4568                    .read_only_subpaths
4569                    .into_iter()
4570                    .map(|path| path.to_path_buf())
4571                    .collect();
4572                read_only_subpaths.sort();
4573                (root.root.to_path_buf(), read_only_subpaths)
4574            })
4575            .collect();
4576        sorted_roots.sort_by(|left, right| left.0.cmp(&right.0));
4577        sorted_roots
4578    }
4579
4580    fn sandbox_policy_allows_read(policy: &SandboxPolicy, _path: &Path, _cwd: &Path) -> bool {
4581        policy.has_full_disk_read_access()
4582    }
4583
4584    fn sandbox_policy_allows_write(policy: &SandboxPolicy, path: &Path, cwd: &Path) -> bool {
4585        if policy.has_full_disk_write_access() {
4586            return true;
4587        }
4588
4589        policy
4590            .get_writable_roots_with_cwd(cwd)
4591            .iter()
4592            .any(|root| root.is_path_writable(path))
4593    }
4594
4595    #[test]
4596    fn session_source_from_startup_arg_maps_known_values() {
4597        assert_eq!(
4598            SessionSource::from_startup_arg("vscode").unwrap(),
4599            SessionSource::VSCode
4600        );
4601        assert_eq!(
4602            SessionSource::from_startup_arg("app-server").unwrap(),
4603            SessionSource::Mcp
4604        );
4605    }
4606
4607    #[test]
4608    fn inter_agent_communication_response_input_item_preserves_commentary_phase() {
4609        let mut communication = InterAgentCommunication {
4610            id: Some(ResponseItemId::with_suffix("amsg", "1")),
4611            author: AgentPath::root(),
4612            recipient: AgentPath::root().join("reviewer").expect("recipient path"),
4613            other_recipients: vec![AgentPath::root().join("worker").expect("recipient path")],
4614            content: "review the diff".to_string(),
4615            encrypted_content: None,
4616            internal_chat_message_metadata_passthrough: None,
4617            trigger_turn: true,
4618        };
4619        communication.set_turn_id_if_missing("turn-1");
4620        let mut serialized_communication = communication.clone();
4621        serialized_communication.id = None;
4622        serialized_communication.internal_chat_message_metadata_passthrough = None;
4623
4624        assert_eq!(
4625            communication.to_response_input_item(),
4626            ResponseInputItem::Message {
4627                role: "assistant".to_string(),
4628                content: vec![ContentItem::OutputText {
4629                    text: serde_json::to_string(&serialized_communication)
4630                        .expect("serialize communication"),
4631                }],
4632                phase: Some(MessagePhase::Commentary),
4633            }
4634        );
4635    }
4636
4637    #[test]
4638    fn queued_encrypted_inter_agent_communication_renders_message_envelope() {
4639        let communication = InterAgentCommunication::new_encrypted(
4640            AgentPath::root().join("worker").expect("author path"),
4641            AgentPath::root(),
4642            Vec::new(),
4643            "encrypted payload".to_string(),
4644            /*trigger_turn*/ false,
4645        );
4646
4647        assert_eq!(
4648            communication.to_model_input_item(),
4649            ResponseItem::AgentMessage {
4650                id: None,
4651                author: "/root/worker".to_string(),
4652                recipient: "/root".to_string(),
4653                content: vec![
4654                    AgentMessageInputContent::InputText {
4655                        text: "Message Type: MESSAGE\nTask name: /root\nSender: /root/worker\nPayload:\n"
4656                            .to_string(),
4657                    },
4658                    AgentMessageInputContent::EncryptedContent {
4659                        encrypted_content: "encrypted payload".to_string(),
4660                    },
4661                ],
4662                internal_chat_message_metadata_passthrough: None,
4663            }
4664        );
4665    }
4666
4667    #[test]
4668    fn session_source_from_startup_arg_normalizes_custom_values() {
4669        assert_eq!(
4670            SessionSource::from_startup_arg("atlas").unwrap(),
4671            SessionSource::Custom("atlas".to_string())
4672        );
4673        assert_eq!(
4674            SessionSource::from_startup_arg(" Atlas ").unwrap(),
4675            SessionSource::Custom("atlas".to_string())
4676        );
4677    }
4678
4679    #[test]
4680    fn session_source_restriction_product_defaults_non_subagent_sources_to_codex() {
4681        assert_eq!(
4682            SessionSource::Cli.restriction_product(),
4683            Some(Product::Codex)
4684        );
4685        assert_eq!(
4686            SessionSource::VSCode.restriction_product(),
4687            Some(Product::Codex)
4688        );
4689        assert_eq!(
4690            SessionSource::Exec.restriction_product(),
4691            Some(Product::Codex)
4692        );
4693        assert_eq!(
4694            SessionSource::Mcp.restriction_product(),
4695            Some(Product::Codex)
4696        );
4697        assert_eq!(
4698            SessionSource::Unknown.restriction_product(),
4699            Some(Product::Codex)
4700        );
4701    }
4702
4703    #[test]
4704    fn session_source_restriction_product_does_not_guess_subagent_products() {
4705        assert_eq!(
4706            SessionSource::SubAgent(SubAgentSource::Review).restriction_product(),
4707            None
4708        );
4709        assert_eq!(
4710            SessionSource::Internal(InternalSessionSource::MemoryConsolidation)
4711                .restriction_product(),
4712            None
4713        );
4714    }
4715
4716    #[test]
4717    fn session_source_restriction_product_maps_custom_sources_to_products() {
4718        assert_eq!(
4719            SessionSource::Custom("chatgpt".to_string()).restriction_product(),
4720            Some(Product::Chatgpt)
4721        );
4722        assert_eq!(
4723            SessionSource::Custom("ATLAS".to_string()).restriction_product(),
4724            Some(Product::Atlas)
4725        );
4726        assert_eq!(
4727            SessionSource::Custom("codex".to_string()).restriction_product(),
4728            Some(Product::Codex)
4729        );
4730        assert_eq!(
4731            SessionSource::Custom("atlas-dev".to_string()).restriction_product(),
4732            None
4733        );
4734    }
4735
4736    #[test]
4737    fn session_source_matches_product_restriction() {
4738        assert!(
4739            SessionSource::Custom("chatgpt".to_string())
4740                .matches_product_restriction(&[Product::Chatgpt])
4741        );
4742        assert!(
4743            !SessionSource::Custom("chatgpt".to_string())
4744                .matches_product_restriction(&[Product::Codex])
4745        );
4746        assert!(SessionSource::VSCode.matches_product_restriction(&[Product::Codex]));
4747        assert!(
4748            !SessionSource::Custom("atlas-dev".to_string())
4749                .matches_product_restriction(&[Product::Atlas])
4750        );
4751        assert!(SessionSource::Custom("atlas-dev".to_string()).matches_product_restriction(&[]));
4752    }
4753
4754    fn sandbox_policy_probe_paths(policy: &SandboxPolicy, cwd: &Path) -> Vec<PathBuf> {
4755        let mut paths = vec![cwd.to_path_buf()];
4756        for root in policy.get_writable_roots_with_cwd(cwd) {
4757            paths.push(root.root.to_path_buf());
4758            paths.extend(
4759                root.read_only_subpaths
4760                    .into_iter()
4761                    .map(|path| path.to_path_buf()),
4762            );
4763        }
4764        paths.sort();
4765        paths.dedup();
4766        paths
4767    }
4768
4769    fn assert_same_sandbox_policy_semantics(
4770        expected: &SandboxPolicy,
4771        actual: &SandboxPolicy,
4772        cwd: &Path,
4773    ) {
4774        assert_eq!(
4775            actual.has_full_disk_read_access(),
4776            expected.has_full_disk_read_access()
4777        );
4778        assert_eq!(
4779            actual.has_full_disk_write_access(),
4780            expected.has_full_disk_write_access()
4781        );
4782        assert_eq!(
4783            actual.has_full_network_access(),
4784            expected.has_full_network_access()
4785        );
4786        let mut probe_paths = sandbox_policy_probe_paths(expected, cwd);
4787        probe_paths.extend(sandbox_policy_probe_paths(actual, cwd));
4788        probe_paths.sort();
4789        probe_paths.dedup();
4790
4791        for path in probe_paths {
4792            assert_eq!(
4793                sandbox_policy_allows_read(actual, &path, cwd),
4794                sandbox_policy_allows_read(expected, &path, cwd),
4795                "read access mismatch for {}",
4796                path.display()
4797            );
4798            assert_eq!(
4799                sandbox_policy_allows_write(actual, &path, cwd),
4800                sandbox_policy_allows_write(expected, &path, cwd),
4801                "write access mismatch for {}",
4802                path.display()
4803            );
4804        }
4805    }
4806
4807    #[test]
4808    fn external_sandbox_reports_full_access_flags() {
4809        let restricted = SandboxPolicy::ExternalSandbox {
4810            network_access: NetworkAccess::Restricted,
4811        };
4812        assert!(restricted.has_full_disk_write_access());
4813        assert!(!restricted.has_full_network_access());
4814
4815        let enabled = SandboxPolicy::ExternalSandbox {
4816            network_access: NetworkAccess::Enabled,
4817        };
4818        assert!(enabled.has_full_disk_write_access());
4819        assert!(enabled.has_full_network_access());
4820    }
4821
4822    #[test]
4823    fn read_only_reports_network_access_flags() {
4824        let restricted = SandboxPolicy::new_read_only_policy();
4825        assert!(!restricted.has_full_network_access());
4826
4827        let enabled = SandboxPolicy::ReadOnly {
4828            network_access: true,
4829        };
4830        assert!(enabled.has_full_network_access());
4831    }
4832
4833    #[test]
4834    fn granular_approval_config_mcp_elicitation_flag_is_field_driven() {
4835        assert!(
4836            GranularApprovalConfig {
4837                sandbox_approval: false,
4838                rules: false,
4839                skill_approval: false,
4840                request_permissions: false,
4841                mcp_elicitations: true,
4842            }
4843            .allows_mcp_elicitations()
4844        );
4845        assert!(
4846            !GranularApprovalConfig {
4847                sandbox_approval: false,
4848                rules: false,
4849                skill_approval: false,
4850                request_permissions: false,
4851                mcp_elicitations: false,
4852            }
4853            .allows_mcp_elicitations()
4854        );
4855    }
4856
4857    #[test]
4858    fn granular_approval_config_skill_approval_flag_is_field_driven() {
4859        assert!(
4860            GranularApprovalConfig {
4861                sandbox_approval: false,
4862                rules: false,
4863                skill_approval: true,
4864                request_permissions: false,
4865                mcp_elicitations: false,
4866            }
4867            .allows_skill_approval()
4868        );
4869        assert!(
4870            !GranularApprovalConfig {
4871                sandbox_approval: false,
4872                rules: false,
4873                skill_approval: false,
4874                request_permissions: false,
4875                mcp_elicitations: false,
4876            }
4877            .allows_skill_approval()
4878        );
4879    }
4880
4881    #[test]
4882    fn granular_approval_config_request_permissions_flag_is_field_driven() {
4883        assert!(
4884            GranularApprovalConfig {
4885                sandbox_approval: false,
4886                rules: false,
4887                skill_approval: false,
4888                request_permissions: true,
4889                mcp_elicitations: false,
4890            }
4891            .allows_request_permissions()
4892        );
4893        assert!(
4894            !GranularApprovalConfig {
4895                sandbox_approval: false,
4896                rules: false,
4897                skill_approval: false,
4898                request_permissions: false,
4899                mcp_elicitations: false,
4900            }
4901            .allows_request_permissions()
4902        );
4903    }
4904
4905    #[test]
4906    fn granular_approval_config_defaults_missing_optional_flags_to_false() {
4907        let decoded = serde_json::from_value::<GranularApprovalConfig>(serde_json::json!({
4908            "sandbox_approval": true,
4909            "rules": false,
4910            "mcp_elicitations": true,
4911        }))
4912        .expect("granular approval config should deserialize");
4913
4914        assert_eq!(
4915            decoded,
4916            GranularApprovalConfig {
4917                sandbox_approval: true,
4918                rules: false,
4919                skill_approval: false,
4920                request_permissions: false,
4921                mcp_elicitations: true,
4922            }
4923        );
4924    }
4925
4926    #[test]
4927    fn restricted_file_system_policy_reports_full_access_from_root_entries() {
4928        let read_only = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
4929            path: FileSystemPath::Special {
4930                value: FileSystemSpecialPath::Root,
4931            },
4932            access: FileSystemAccessMode::Read,
4933            missing_path_behavior: None,
4934        }]);
4935        assert!(read_only.has_full_disk_read_access());
4936        assert!(!read_only.has_full_disk_write_access());
4937        assert!(!read_only.include_platform_defaults());
4938
4939        let writable = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
4940            path: FileSystemPath::Special {
4941                value: FileSystemSpecialPath::Root,
4942            },
4943            access: FileSystemAccessMode::Write,
4944            missing_path_behavior: None,
4945        }]);
4946        assert!(writable.has_full_disk_read_access());
4947        assert!(writable.has_full_disk_write_access());
4948    }
4949
4950    #[test]
4951    fn restricted_file_system_policy_treats_root_with_carveouts_as_scoped_access() {
4952        let cwd = TempDir::new().expect("tempdir");
4953        let canonical_cwd = codex_utils_absolute_path::canonicalize_preserving_symlinks(cwd.path())
4954            .expect("canonicalize cwd");
4955        let root = AbsolutePathBuf::from_absolute_path(&canonical_cwd)
4956            .expect("absolute canonical tempdir")
4957            .as_path()
4958            .ancestors()
4959            .last()
4960            .and_then(|path| AbsolutePathBuf::from_absolute_path(path).ok())
4961            .expect("filesystem root");
4962        let blocked = AbsolutePathBuf::resolve_path_against_base("blocked", cwd.path());
4963        let expected_blocked = AbsolutePathBuf::from_absolute_path(
4964            codex_utils_absolute_path::canonicalize_preserving_symlinks(cwd.path())
4965                .expect("canonicalize cwd")
4966                .join("blocked"),
4967        )
4968        .expect("canonical blocked");
4969        let policy = FileSystemSandboxPolicy::restricted(vec![
4970            FileSystemSandboxEntry {
4971                path: FileSystemPath::Special {
4972                    value: FileSystemSpecialPath::Root,
4973                },
4974                access: FileSystemAccessMode::Write,
4975                missing_path_behavior: None,
4976            },
4977            FileSystemSandboxEntry {
4978                path: FileSystemPath::Path { path: blocked },
4979                access: FileSystemAccessMode::Deny,
4980                missing_path_behavior: None,
4981            },
4982        ]);
4983
4984        assert!(!policy.has_full_disk_read_access());
4985        assert!(!policy.has_full_disk_write_access());
4986        assert_eq!(
4987            policy.get_readable_roots_with_cwd(cwd.path()),
4988            vec![root.clone()]
4989        );
4990        assert_eq!(
4991            policy.get_unreadable_roots_with_cwd(cwd.path()),
4992            vec![expected_blocked.clone()]
4993        );
4994
4995        let writable_roots = policy.get_writable_roots_with_cwd(cwd.path());
4996        assert_eq!(writable_roots.len(), 1);
4997        assert_eq!(writable_roots[0].root, root);
4998        assert!(
4999            writable_roots[0]
5000                .read_only_subpaths
5001                .iter()
5002                .any(|path| path.as_path() == expected_blocked.as_path())
5003        );
5004    }
5005
5006    #[test]
5007    fn restricted_file_system_policy_derives_effective_paths() {
5008        let cwd = TempDir::new().expect("tempdir");
5009        std::fs::create_dir_all(cwd.path().join(".agents")).expect("create .agents");
5010        std::fs::create_dir_all(cwd.path().join(".codex")).expect("create .codex");
5011        let canonical_cwd = codex_utils_absolute_path::canonicalize_preserving_symlinks(cwd.path())
5012            .expect("canonicalize cwd");
5013        let cwd_absolute =
5014            AbsolutePathBuf::from_absolute_path(&canonical_cwd).expect("absolute tempdir");
5015        let secret = AbsolutePathBuf::resolve_path_against_base("secret", cwd.path());
5016        let expected_secret = AbsolutePathBuf::from_absolute_path(canonical_cwd.join("secret"))
5017            .expect("canonical secret");
5018        let expected_agents = AbsolutePathBuf::from_absolute_path(canonical_cwd.join(".agents"))
5019            .expect("canonical .agents");
5020        let expected_codex = AbsolutePathBuf::from_absolute_path(canonical_cwd.join(".codex"))
5021            .expect("canonical .codex");
5022        let policy = FileSystemSandboxPolicy::restricted(vec![
5023            FileSystemSandboxEntry {
5024                path: FileSystemPath::Special {
5025                    value: FileSystemSpecialPath::Minimal,
5026                },
5027                access: FileSystemAccessMode::Read,
5028                missing_path_behavior: None,
5029            },
5030            FileSystemSandboxEntry {
5031                path: FileSystemPath::Special {
5032                    value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
5033                },
5034                access: FileSystemAccessMode::Write,
5035                missing_path_behavior: None,
5036            },
5037            FileSystemSandboxEntry {
5038                path: FileSystemPath::Path { path: secret },
5039                access: FileSystemAccessMode::Deny,
5040                missing_path_behavior: None,
5041            },
5042        ]);
5043
5044        assert!(!policy.has_full_disk_read_access());
5045        assert!(!policy.has_full_disk_write_access());
5046        assert!(policy.include_platform_defaults());
5047        assert_eq!(
5048            policy.get_readable_roots_with_cwd(cwd.path()),
5049            vec![cwd_absolute.clone()]
5050        );
5051        assert_eq!(
5052            policy.get_unreadable_roots_with_cwd(cwd.path()),
5053            vec![expected_secret.clone()]
5054        );
5055
5056        let writable_roots = policy.get_writable_roots_with_cwd(cwd.path());
5057        assert_eq!(writable_roots.len(), 1);
5058        assert_eq!(writable_roots[0].root, cwd_absolute);
5059        assert!(
5060            writable_roots[0]
5061                .read_only_subpaths
5062                .iter()
5063                .any(|path| path.as_path() == expected_secret.as_path())
5064        );
5065        assert!(
5066            writable_roots[0]
5067                .read_only_subpaths
5068                .iter()
5069                .any(|path| path.as_path() == expected_agents.as_path())
5070        );
5071        assert!(
5072            writable_roots[0]
5073                .read_only_subpaths
5074                .iter()
5075                .any(|path| path.as_path() == expected_codex.as_path())
5076        );
5077    }
5078
5079    #[test]
5080    fn restricted_file_system_policy_treats_read_entries_as_read_only_subpaths() {
5081        let cwd = TempDir::new().expect("tempdir");
5082        let canonical_cwd = codex_utils_absolute_path::canonicalize_preserving_symlinks(cwd.path())
5083            .expect("canonicalize cwd");
5084        let docs = AbsolutePathBuf::resolve_path_against_base("docs", cwd.path());
5085        let docs_public = AbsolutePathBuf::resolve_path_against_base("docs/public", cwd.path());
5086        let expected_docs = AbsolutePathBuf::from_absolute_path(canonical_cwd.join("docs"))
5087            .expect("canonical docs");
5088        let expected_docs_public =
5089            AbsolutePathBuf::from_absolute_path(canonical_cwd.join("docs/public"))
5090                .expect("canonical docs/public");
5091        let expected_dot_codex = AbsolutePathBuf::from_absolute_path(canonical_cwd.join(".codex"))
5092            .expect("canonical .codex");
5093        let policy = FileSystemSandboxPolicy::restricted(vec![
5094            FileSystemSandboxEntry {
5095                path: FileSystemPath::Special {
5096                    value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
5097                },
5098                access: FileSystemAccessMode::Write,
5099                missing_path_behavior: None,
5100            },
5101            FileSystemSandboxEntry {
5102                path: FileSystemPath::Path { path: docs },
5103                access: FileSystemAccessMode::Read,
5104                missing_path_behavior: None,
5105            },
5106            FileSystemSandboxEntry {
5107                path: FileSystemPath::Path { path: docs_public },
5108                access: FileSystemAccessMode::Write,
5109                missing_path_behavior: None,
5110            },
5111        ]);
5112
5113        assert!(!policy.has_full_disk_write_access());
5114        assert_eq!(
5115            sorted_writable_roots(policy.get_writable_roots_with_cwd(cwd.path())),
5116            vec![
5117                (
5118                    canonical_cwd,
5119                    vec![
5120                        expected_dot_codex.to_path_buf(),
5121                        expected_docs.to_path_buf()
5122                    ],
5123                ),
5124                (expected_docs_public.to_path_buf(), Vec::new()),
5125            ]
5126        );
5127    }
5128
5129    #[test]
5130    fn file_system_policy_rejects_legacy_bridge_for_non_workspace_writes() {
5131        let cwd = if cfg!(windows) {
5132            Path::new(r"C:\workspace")
5133        } else {
5134            Path::new("/tmp/workspace")
5135        };
5136        let external_write_path = if cfg!(windows) {
5137            AbsolutePathBuf::from_absolute_path(r"C:\temp").expect("absolute windows temp path")
5138        } else {
5139            AbsolutePathBuf::from_absolute_path("/tmp").expect("absolute tmp path")
5140        };
5141        let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
5142            path: FileSystemPath::Path {
5143                path: external_write_path,
5144            },
5145            access: FileSystemAccessMode::Write,
5146            missing_path_behavior: None,
5147        }]);
5148
5149        let err = policy
5150            .to_legacy_sandbox_policy(NetworkSandboxPolicy::Restricted, cwd)
5151            .expect_err("non-workspace writes should be rejected");
5152
5153        assert!(
5154            err.to_string()
5155                .contains("filesystem writes outside the workspace root"),
5156            "{err}"
5157        );
5158    }
5159
5160    #[test]
5161    fn legacy_sandbox_policy_semantics_survive_split_bridge() {
5162        let cwd = TempDir::new().expect("tempdir");
5163        let writable_root = AbsolutePathBuf::resolve_path_against_base("writable", cwd.path());
5164        let policies = [
5165            SandboxPolicy::DangerFullAccess,
5166            SandboxPolicy::ExternalSandbox {
5167                network_access: NetworkAccess::Restricted,
5168            },
5169            SandboxPolicy::ExternalSandbox {
5170                network_access: NetworkAccess::Enabled,
5171            },
5172            SandboxPolicy::ReadOnly {
5173                network_access: false,
5174            },
5175            SandboxPolicy::WorkspaceWrite {
5176                writable_roots: vec![],
5177                network_access: false,
5178                exclude_tmpdir_env_var: true,
5179                exclude_slash_tmp: true,
5180            },
5181            SandboxPolicy::WorkspaceWrite {
5182                writable_roots: vec![writable_root],
5183                network_access: true,
5184                exclude_tmpdir_env_var: false,
5185                exclude_slash_tmp: true,
5186            },
5187        ];
5188
5189        for expected in policies {
5190            let actual =
5191                FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(&expected, cwd.path())
5192                    .to_legacy_sandbox_policy(NetworkSandboxPolicy::from(&expected), cwd.path())
5193                    .expect("legacy bridge should preserve legacy policy semantics");
5194
5195            assert_same_sandbox_policy_semantics(&expected, &actual, cwd.path());
5196        }
5197    }
5198
5199    #[test]
5200    fn item_started_event_from_web_search_emits_begin_event() {
5201        let event = ItemStartedEvent {
5202            thread_id: ThreadId::new(),
5203            turn_id: "turn-1".into(),
5204            item: TurnItem::WebSearch(WebSearchItem {
5205                id: "search-1".into(),
5206                query: "find docs".into(),
5207                action: WebSearchAction::Search {
5208                    query: Some("find docs".into()),
5209                    queries: None,
5210                },
5211                results: None,
5212            }),
5213            started_at_ms: 0,
5214        };
5215
5216        let legacy_events = event.as_legacy_events(/*show_raw_agent_reasoning*/ false);
5217        assert_eq!(legacy_events.len(), 1);
5218        match &legacy_events[0] {
5219            EventMsg::WebSearchBegin(event) => assert_eq!(event.call_id, "search-1"),
5220            _ => panic!("expected WebSearchBegin event"),
5221        }
5222    }
5223
5224    #[test]
5225    fn item_started_event_from_non_web_search_emits_no_legacy_events() {
5226        let event = ItemStartedEvent {
5227            thread_id: ThreadId::new(),
5228            turn_id: "turn-1".into(),
5229            item: TurnItem::UserMessage(UserMessageItem::new(&[])),
5230            started_at_ms: 0,
5231        };
5232
5233        assert!(
5234            event
5235                .as_legacy_events(/*show_raw_agent_reasoning*/ false)
5236                .is_empty()
5237        );
5238    }
5239
5240    #[test]
5241    fn item_started_event_from_image_generation_emits_begin_event() {
5242        let event = ItemStartedEvent {
5243            thread_id: ThreadId::new(),
5244            turn_id: "turn-1".into(),
5245            item: TurnItem::ImageGeneration(ImageGenerationItem {
5246                id: "ig-1".into(),
5247                status: "in_progress".into(),
5248                revised_prompt: None,
5249                result: String::new(),
5250                saved_path: None,
5251            }),
5252            started_at_ms: 0,
5253        };
5254
5255        let legacy_events = event.as_legacy_events(/*show_raw_agent_reasoning*/ false);
5256        assert_eq!(legacy_events.len(), 1);
5257        match &legacy_events[0] {
5258            EventMsg::ImageGenerationBegin(event) => assert_eq!(event.call_id, "ig-1"),
5259            _ => panic!("expected ImageGenerationBegin event"),
5260        }
5261    }
5262
5263    #[test]
5264    fn item_started_event_from_file_change_emits_patch_begin_event() {
5265        let event = ItemStartedEvent {
5266            thread_id: ThreadId::new(),
5267            turn_id: "turn-1".into(),
5268            started_at_ms: 0,
5269            item: TurnItem::FileChange(FileChangeItem {
5270                id: "patch-1".into(),
5271                changes: [(
5272                    PathBuf::from("new.txt"),
5273                    FileChange::Add {
5274                        content: "hello".into(),
5275                    },
5276                )]
5277                .into_iter()
5278                .collect(),
5279                status: None,
5280                auto_approved: Some(true),
5281                stdout: None,
5282                stderr: None,
5283            }),
5284        };
5285
5286        let legacy_events = event.as_legacy_events(/*show_raw_agent_reasoning*/ false);
5287        assert_eq!(legacy_events.len(), 1);
5288        match &legacy_events[0] {
5289            EventMsg::PatchApplyBegin(event) => {
5290                assert_eq!(event.call_id, "patch-1");
5291                assert_eq!(event.turn_id, "turn-1");
5292                assert!(event.auto_approved);
5293                assert!(event.changes.contains_key(&PathBuf::from("new.txt")));
5294            }
5295            _ => panic!("expected PatchApplyBegin event"),
5296        }
5297    }
5298
5299    #[test]
5300    fn item_started_event_from_mcp_tool_call_emits_begin_event() {
5301        let event = ItemStartedEvent {
5302            thread_id: ThreadId::new(),
5303            turn_id: "turn-1".into(),
5304            started_at_ms: 0,
5305            item: TurnItem::McpToolCall(McpToolCallItem {
5306                id: "mcp-1".into(),
5307                server: "server".into(),
5308                tool: "tool".into(),
5309                arguments: json!({"arg": "value"}),
5310                connector_id: Some("connector".into()),
5311                mcp_app_resource_uri: Some("app://connector".into()),
5312                link_id: Some("link_123".into()),
5313                app_name: Some("Calendar".into()),
5314                action_name: Some("create_event".into()),
5315                plugin_id: Some("sample@test".into()),
5316                status: McpToolCallStatus::InProgress,
5317                result: None,
5318                error: None,
5319                duration: None,
5320            }),
5321        };
5322
5323        let legacy_events = event.as_legacy_events(/*show_raw_agent_reasoning*/ false);
5324        assert_eq!(legacy_events.len(), 1);
5325        match &legacy_events[0] {
5326            EventMsg::McpToolCallBegin(event) => {
5327                assert_eq!(event.call_id, "mcp-1");
5328                assert_eq!(event.invocation.server, "server");
5329                assert_eq!(event.invocation.tool, "tool");
5330                assert_eq!(event.connector_id.as_deref(), Some("connector"));
5331                assert_eq!(
5332                    event.mcp_app_resource_uri.as_deref(),
5333                    Some("app://connector")
5334                );
5335                assert_eq!(event.link_id.as_deref(), Some("link_123"));
5336                assert_eq!(event.app_name.as_deref(), Some("Calendar"));
5337                assert_eq!(event.action_name.as_deref(), Some("create_event"));
5338                assert_eq!(event.plugin_id.as_deref(), Some("sample@test"));
5339            }
5340            _ => panic!("expected McpToolCallBegin event"),
5341        }
5342    }
5343
5344    #[test]
5345    fn item_completed_event_from_image_generation_emits_end_event() {
5346        let event = ItemCompletedEvent {
5347            thread_id: ThreadId::new(),
5348            turn_id: "turn-1".into(),
5349            item: TurnItem::ImageGeneration(ImageGenerationItem {
5350                id: "ig-1".into(),
5351                status: "completed".into(),
5352                revised_prompt: Some("A tiny blue square".into()),
5353                result: "Zm9v".into(),
5354                saved_path: Some(test_path_buf("/tmp/ig-1.png").abs()),
5355            }),
5356            completed_at_ms: 0,
5357        };
5358
5359        let legacy_events = event.as_legacy_events(/*show_raw_agent_reasoning*/ false);
5360        assert_eq!(legacy_events.len(), 1);
5361        match &legacy_events[0] {
5362            EventMsg::ImageGenerationEnd(event) => {
5363                assert_eq!(event.call_id, "ig-1");
5364                assert_eq!(event.status, "completed");
5365                assert_eq!(event.revised_prompt.as_deref(), Some("A tiny blue square"));
5366                assert_eq!(event.result, "Zm9v");
5367                assert_eq!(
5368                    event.saved_path.as_ref().map(AbsolutePathBuf::as_path),
5369                    Some(test_path_buf("/tmp/ig-1.png").as_path())
5370                );
5371            }
5372            _ => panic!("expected ImageGenerationEnd event"),
5373        }
5374    }
5375
5376    #[test]
5377    fn item_completed_event_from_file_change_emits_patch_end_event() {
5378        let event = ItemCompletedEvent {
5379            thread_id: ThreadId::new(),
5380            turn_id: "turn-1".into(),
5381            completed_at_ms: 0,
5382            item: TurnItem::FileChange(FileChangeItem {
5383                id: "patch-1".into(),
5384                changes: [(
5385                    PathBuf::from("new.txt"),
5386                    FileChange::Add {
5387                        content: "hello".into(),
5388                    },
5389                )]
5390                .into_iter()
5391                .collect(),
5392                status: Some(PatchApplyStatus::Completed),
5393                auto_approved: None,
5394                stdout: Some("Done!".into()),
5395                stderr: Some(String::new()),
5396            }),
5397        };
5398
5399        let legacy_events = event.as_legacy_events(/*show_raw_agent_reasoning*/ false);
5400        assert_eq!(legacy_events.len(), 1);
5401        match &legacy_events[0] {
5402            EventMsg::PatchApplyEnd(event) => {
5403                assert_eq!(event.call_id, "patch-1");
5404                assert_eq!(event.turn_id, "turn-1");
5405                assert_eq!(event.stdout, "Done!");
5406                assert!(event.success);
5407                assert_eq!(event.status, PatchApplyStatus::Completed);
5408                assert!(event.changes.contains_key(&PathBuf::from("new.txt")));
5409            }
5410            _ => panic!("expected PatchApplyEnd event"),
5411        }
5412    }
5413
5414    #[test]
5415    fn item_completed_event_from_mcp_tool_call_emits_end_event() {
5416        let event = ItemCompletedEvent {
5417            thread_id: ThreadId::new(),
5418            turn_id: "turn-1".into(),
5419            completed_at_ms: 0,
5420            item: TurnItem::McpToolCall(McpToolCallItem {
5421                id: "mcp-1".into(),
5422                server: "server".into(),
5423                tool: "tool".into(),
5424                arguments: json!({"arg": "value"}),
5425                connector_id: Some("connector".into()),
5426                mcp_app_resource_uri: Some("app://connector".into()),
5427                link_id: Some("link_123".into()),
5428                app_name: Some("Calendar".into()),
5429                action_name: Some("create_event".into()),
5430                plugin_id: Some("sample@test".into()),
5431                status: McpToolCallStatus::Completed,
5432                result: Some(CallToolResult {
5433                    content: vec![json!({"type": "text", "text": "ok"})],
5434                    structured_content: None,
5435                    is_error: Some(false),
5436                    meta: None,
5437                }),
5438                error: None,
5439                duration: Some(Duration::from_millis(42)),
5440            }),
5441        };
5442
5443        let legacy_events = event.as_legacy_events(/*show_raw_agent_reasoning*/ false);
5444        assert_eq!(legacy_events.len(), 1);
5445        match &legacy_events[0] {
5446            EventMsg::McpToolCallEnd(event) => {
5447                assert_eq!(event.call_id, "mcp-1");
5448                assert_eq!(event.invocation.server, "server");
5449                assert_eq!(event.invocation.tool, "tool");
5450                assert_eq!(event.connector_id.as_deref(), Some("connector"));
5451                assert_eq!(
5452                    event.mcp_app_resource_uri.as_deref(),
5453                    Some("app://connector")
5454                );
5455                assert_eq!(event.link_id.as_deref(), Some("link_123"));
5456                assert_eq!(event.app_name.as_deref(), Some("Calendar"));
5457                assert_eq!(event.action_name.as_deref(), Some("create_event"));
5458                assert_eq!(event.plugin_id.as_deref(), Some("sample@test"));
5459                assert_eq!(event.duration, Duration::from_millis(42));
5460                assert!(event.is_success());
5461            }
5462            _ => panic!("expected McpToolCallEnd event"),
5463        }
5464    }
5465
5466    #[test]
5467    fn command_execution_item_lifecycle_emits_legacy_exec_events() {
5468        let cwd = PathUri::from_abs_path(&test_path_buf("/tmp").abs());
5469        let started = ItemStartedEvent {
5470            thread_id: ThreadId::new(),
5471            turn_id: "turn-1".into(),
5472            started_at_ms: 10,
5473            item: TurnItem::CommandExecution(CommandExecutionItem {
5474                id: "exec-1".into(),
5475                process_id: Some("pid-1".into()),
5476                command: vec!["echo".into(), "done".into()],
5477                cwd: cwd.clone(),
5478                parsed_cmd: vec![ParsedCommand::Unknown {
5479                    cmd: "echo done".into(),
5480                }],
5481                source: ExecCommandSource::Agent,
5482                interaction_input: None,
5483                status: CommandExecutionStatus::InProgress,
5484                stdout: None,
5485                stderr: None,
5486                aggregated_output: None,
5487                exit_code: None,
5488                duration: None,
5489                formatted_output: None,
5490            }),
5491        };
5492        let completed = ItemCompletedEvent {
5493            thread_id: ThreadId::new(),
5494            turn_id: "turn-1".into(),
5495            completed_at_ms: 20,
5496            item: TurnItem::CommandExecution(CommandExecutionItem {
5497                id: "exec-1".into(),
5498                process_id: Some("pid-1".into()),
5499                command: vec!["echo".into(), "done".into()],
5500                cwd,
5501                parsed_cmd: vec![ParsedCommand::Unknown {
5502                    cmd: "echo done".into(),
5503                }],
5504                source: ExecCommandSource::Agent,
5505                interaction_input: None,
5506                status: CommandExecutionStatus::Completed,
5507                stdout: Some("done\n".into()),
5508                stderr: Some(String::new()),
5509                aggregated_output: Some("done\n".into()),
5510                exit_code: Some(0),
5511                duration: Some(Duration::from_millis(5)),
5512                formatted_output: Some("done\n".into()),
5513            }),
5514        };
5515
5516        assert!(matches!(
5517            started.as_legacy_events(/*show_raw_agent_reasoning*/ false).as_slice(),
5518            [EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
5519                call_id,
5520                turn_id,
5521                started_at_ms: 10,
5522                ..
5523            })] if call_id == "exec-1" && turn_id == "turn-1"
5524        ));
5525        assert!(matches!(
5526            completed
5527                .as_legacy_events(/*show_raw_agent_reasoning*/ false)
5528                .as_slice(),
5529            [EventMsg::ExecCommandEnd(ExecCommandEndEvent {
5530                call_id,
5531                turn_id,
5532                completed_at_ms: 20,
5533                aggregated_output,
5534                ..
5535            })] if call_id == "exec-1" && turn_id == "turn-1" && aggregated_output == "done\n"
5536        ));
5537    }
5538
5539    #[test]
5540    fn dynamic_tool_call_item_lifecycle_emits_legacy_dynamic_tool_events() {
5541        let started = ItemStartedEvent {
5542            thread_id: ThreadId::new(),
5543            turn_id: "turn-1".into(),
5544            started_at_ms: 10,
5545            item: TurnItem::DynamicToolCall(DynamicToolCallItem {
5546                id: "dynamic-1".into(),
5547                namespace: Some("apps".into()),
5548                tool: "lookup".into(),
5549                arguments: json!({"id": "123"}),
5550                status: DynamicToolCallStatus::InProgress,
5551                content_items: None,
5552                success: None,
5553                error: None,
5554                duration: None,
5555            }),
5556        };
5557        let completed = ItemCompletedEvent {
5558            thread_id: ThreadId::new(),
5559            turn_id: "turn-1".into(),
5560            completed_at_ms: 20,
5561            item: TurnItem::DynamicToolCall(DynamicToolCallItem {
5562                id: "dynamic-1".into(),
5563                namespace: Some("apps".into()),
5564                tool: "lookup".into(),
5565                arguments: json!({"id": "123"}),
5566                status: DynamicToolCallStatus::Completed,
5567                content_items: Some(vec![DynamicToolCallOutputContentItem::InputText {
5568                    text: "ok".into(),
5569                }]),
5570                success: Some(true),
5571                error: None,
5572                duration: Some(Duration::from_millis(5)),
5573            }),
5574        };
5575
5576        assert!(matches!(
5577            started.as_legacy_events(/*show_raw_agent_reasoning*/ false).as_slice(),
5578            [EventMsg::DynamicToolCallRequest(DynamicToolCallRequest {
5579                call_id,
5580                turn_id,
5581                started_at_ms: 10,
5582                ..
5583            })] if call_id == "dynamic-1" && turn_id == "turn-1"
5584        ));
5585        assert!(matches!(
5586            completed
5587                .as_legacy_events(/*show_raw_agent_reasoning*/ false)
5588                .as_slice(),
5589            [EventMsg::DynamicToolCallResponse(DynamicToolCallResponseEvent {
5590                call_id,
5591                turn_id,
5592                completed_at_ms: 20,
5593                success: true,
5594                ..
5595            })] if call_id == "dynamic-1" && turn_id == "turn-1"
5596        ));
5597    }
5598
5599    #[test]
5600    fn review_mode_item_completion_emits_legacy_events_with_ids() {
5601        let entered = ItemCompletedEvent {
5602            thread_id: ThreadId::new(),
5603            turn_id: "turn-1".into(),
5604            completed_at_ms: 0,
5605            item: TurnItem::EnteredReviewMode(EnteredReviewModeItem {
5606                id: "entered-review".into(),
5607                target: ReviewTarget::Custom {
5608                    instructions: "review this".into(),
5609                },
5610                user_facing_hint: "Review requested.".into(),
5611            }),
5612        };
5613        let exited = ItemCompletedEvent {
5614            thread_id: ThreadId::new(),
5615            turn_id: "turn-1".into(),
5616            completed_at_ms: 0,
5617            item: TurnItem::ExitedReviewMode(ExitedReviewModeItem {
5618                id: "exited-review".into(),
5619                review_output: Some(ReviewOutputEvent {
5620                    overall_explanation: "Looks good.".into(),
5621                    ..Default::default()
5622                }),
5623            }),
5624        };
5625
5626        assert!(matches!(
5627            entered
5628                .as_legacy_events(/*show_raw_agent_reasoning*/ false)
5629                .as_slice(),
5630            [EventMsg::EnteredReviewMode(EnteredReviewModeEvent {
5631                target: ReviewTarget::Custom { instructions },
5632                user_facing_hint: Some(user_facing_hint),
5633                turn_id: Some(turn_id),
5634                item_id: Some(item_id),
5635            })]
5636                if instructions == "review this"
5637                    && user_facing_hint == "Review requested."
5638                    && turn_id == "turn-1"
5639                    && item_id == "entered-review"
5640        ));
5641        assert!(matches!(
5642            exited
5643                .as_legacy_events(/*show_raw_agent_reasoning*/ false)
5644                .as_slice(),
5645            [EventMsg::ExitedReviewMode(ExitedReviewModeEvent {
5646                turn_id: Some(turn_id),
5647                item_id: Some(item_id),
5648                review_output: Some(review_output),
5649            })]
5650                if turn_id == "turn-1"
5651                    && item_id == "exited-review"
5652                    && review_output.overall_explanation == "Looks good."
5653        ));
5654    }
5655
5656    #[test]
5657    fn item_started_event_requires_started_at_ms() {
5658        let mut value = serde_json::to_value(ItemStartedEvent {
5659            thread_id: ThreadId::new(),
5660            turn_id: "turn-1".into(),
5661            item: TurnItem::UserMessage(UserMessageItem::new(&[])),
5662            started_at_ms: 123,
5663        })
5664        .unwrap();
5665        value.as_object_mut().unwrap().remove("started_at_ms");
5666
5667        assert!(serde_json::from_value::<ItemStartedEvent>(value).is_err());
5668    }
5669
5670    #[test]
5671    fn item_completed_event_defaults_missing_completed_at_ms() {
5672        let mut value = serde_json::to_value(ItemCompletedEvent {
5673            thread_id: ThreadId::new(),
5674            turn_id: "turn-1".into(),
5675            item: TurnItem::UserMessage(UserMessageItem::new(&[])),
5676            completed_at_ms: 123,
5677        })
5678        .unwrap();
5679        value.as_object_mut().unwrap().remove("completed_at_ms");
5680
5681        let event = serde_json::from_value::<ItemCompletedEvent>(value).unwrap();
5682        assert_eq!(event.completed_at_ms, 0);
5683    }
5684
5685    #[test]
5686    fn review_mode_events_deserialize_legacy_payloads() {
5687        let entered = serde_json::from_value::<EnteredReviewModeEvent>(json!({
5688            "target": {
5689                "type": "custom",
5690                "instructions": "review this"
5691            },
5692            "user_facing_hint": "hint"
5693        }))
5694        .unwrap();
5695        assert_eq!(entered.turn_id, None);
5696        assert_eq!(entered.item_id, None);
5697
5698        let exited = serde_json::from_value::<ExitedReviewModeEvent>(json!({
5699            "review_output": null
5700        }))
5701        .unwrap();
5702        assert_eq!(exited.turn_id, None);
5703        assert_eq!(exited.item_id, None);
5704    }
5705
5706    #[test]
5707    fn rollback_failed_error_does_not_affect_turn_status() {
5708        let event = ErrorEvent {
5709            message: "rollback failed".into(),
5710            codex_error_info: Some(CodexErrorInfo::ThreadRollbackFailed),
5711        };
5712        assert!(!event.affects_turn_status());
5713    }
5714
5715    #[test]
5716    fn active_turn_not_steerable_error_does_not_affect_turn_status() {
5717        let event = ErrorEvent {
5718            message: "cannot steer a review turn".into(),
5719            codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable {
5720                turn_kind: NonSteerableTurnKind::Review,
5721            }),
5722        };
5723        assert!(!event.affects_turn_status());
5724    }
5725
5726    #[test]
5727    fn generic_error_affects_turn_status() {
5728        let event = ErrorEvent {
5729            message: "generic".into(),
5730            codex_error_info: Some(CodexErrorInfo::Other),
5731        };
5732        assert!(event.affects_turn_status());
5733    }
5734
5735    #[test]
5736    fn realtime_conversation_started_event_uses_realtime_session_id() {
5737        let event = RealtimeConversationStartedEvent {
5738            realtime_session_id: Some("conv_1".to_string()),
5739            version: RealtimeConversationVersion::V2,
5740        };
5741
5742        assert_eq!(
5743            serde_json::to_value(&event).unwrap(),
5744            json!({
5745                "realtime_session_id": "conv_1",
5746                "version": "v2"
5747            })
5748        );
5749    }
5750
5751    #[test]
5752    fn realtime_voice_list_is_stable() {
5753        assert_eq!(
5754            RealtimeVoicesList::builtin(),
5755            RealtimeVoicesList {
5756                v1: vec![
5757                    RealtimeVoice::Juniper,
5758                    RealtimeVoice::Maple,
5759                    RealtimeVoice::Spruce,
5760                    RealtimeVoice::Ember,
5761                    RealtimeVoice::Vale,
5762                    RealtimeVoice::Breeze,
5763                    RealtimeVoice::Arbor,
5764                    RealtimeVoice::Sol,
5765                    RealtimeVoice::Cove,
5766                ],
5767                v2: vec![
5768                    RealtimeVoice::Alloy,
5769                    RealtimeVoice::Ash,
5770                    RealtimeVoice::Ballad,
5771                    RealtimeVoice::Coral,
5772                    RealtimeVoice::Echo,
5773                    RealtimeVoice::Sage,
5774                    RealtimeVoice::Shimmer,
5775                    RealtimeVoice::Verse,
5776                    RealtimeVoice::Marin,
5777                    RealtimeVoice::Cedar,
5778                ],
5779                default_v1: RealtimeVoice::Cove,
5780                default_v2: RealtimeVoice::Marin,
5781            }
5782        );
5783    }
5784
5785    #[test]
5786    fn user_input_text_serializes_empty_text_elements() -> Result<()> {
5787        let input = UserInput::Text {
5788            text: "hello".to_string(),
5789            text_elements: Vec::new(),
5790        };
5791
5792        let json_input = serde_json::to_value(input)?;
5793        assert_eq!(
5794            json_input,
5795            json!({
5796                "type": "text",
5797                "text": "hello",
5798                "text_elements": [],
5799            })
5800        );
5801
5802        Ok(())
5803    }
5804
5805    #[test]
5806    fn user_message_event_serializes_empty_metadata_vectors() -> Result<()> {
5807        let event = UserMessageEvent {
5808            client_id: None,
5809            message: "hello".to_string(),
5810            images: None,
5811            local_images: Vec::new(),
5812            text_elements: Vec::new(),
5813            ..Default::default()
5814        };
5815
5816        let json_event = serde_json::to_value(event)?;
5817        assert_eq!(
5818            json_event,
5819            json!({
5820                "message": "hello",
5821                "local_images": [],
5822                "local_audio": [],
5823                "text_elements": [],
5824            })
5825        );
5826
5827        Ok(())
5828    }
5829
5830    #[test]
5831    fn user_message_event_deserializes_without_image_detail_fields() -> Result<()> {
5832        let event: UserMessageEvent = serde_json::from_value(json!({
5833            "message": "hello",
5834            "images": ["https://example.com/image.png"],
5835            "local_images": ["/tmp/local.png"],
5836            "text_elements": [],
5837        }))?;
5838
5839        assert_eq!(event.message, "hello");
5840        assert_eq!(
5841            event.images,
5842            Some(vec!["https://example.com/image.png".to_string()])
5843        );
5844        assert_eq!(event.image_details, Vec::<Option<ImageDetail>>::new());
5845        assert_eq!(event.local_images, vec![PathBuf::from("/tmp/local.png")]);
5846        assert_eq!(event.local_image_details, Vec::<Option<ImageDetail>>::new());
5847        assert_eq!(event.audio, None);
5848        assert_eq!(event.local_audio, Vec::<PathBuf>::new());
5849        assert_eq!(event.text_elements, Vec::new());
5850
5851        Ok(())
5852    }
5853
5854    #[test]
5855    fn user_message_item_legacy_event_preserves_attachments() {
5856        let local_path = PathBuf::from("/tmp/local.png");
5857        let local_audio_path = PathBuf::from("/tmp/local.wav");
5858        let mut item = UserMessageItem::new(&[
5859            crate::user_input::UserInput::Image {
5860                image_url: "https://example.com/first.png".to_string(),
5861                detail: Some(ImageDetail::Original),
5862            },
5863            crate::user_input::UserInput::Image {
5864                image_url: "https://example.com/second.png".to_string(),
5865                detail: None,
5866            },
5867            crate::user_input::UserInput::LocalImage {
5868                path: local_path.clone(),
5869                detail: Some(ImageDetail::Original),
5870            },
5871            crate::user_input::UserInput::Audio {
5872                audio_url: "https://example.com/remote.mp3".to_string(),
5873            },
5874            crate::user_input::UserInput::LocalAudio {
5875                path: local_audio_path.clone(),
5876            },
5877        ]);
5878        item.client_id = Some("client-message-1".to_string());
5879
5880        let EventMsg::UserMessage(event) = item.as_legacy_event() else {
5881            panic!("expected user message event");
5882        };
5883
5884        assert_eq!(
5885            event.images,
5886            Some(vec![
5887                "https://example.com/first.png".to_string(),
5888                "https://example.com/second.png".to_string(),
5889            ])
5890        );
5891        assert_eq!(event.client_id, Some("client-message-1".to_string()));
5892        assert_eq!(event.image_details, vec![Some(ImageDetail::Original)]);
5893        assert_eq!(event.local_images, vec![local_path]);
5894        assert_eq!(event.local_image_details, vec![Some(ImageDetail::Original)]);
5895        assert_eq!(
5896            event.audio,
5897            Some(vec!["https://example.com/remote.mp3".to_string()])
5898        );
5899        assert_eq!(event.local_audio, vec![local_audio_path]);
5900    }
5901
5902    #[test]
5903    fn audio_only_user_message_has_placeholder_preview() {
5904        let event = UserMessageEvent {
5905            audio: Some(vec!["https://example.com/remote.mp3".to_string()]),
5906            ..Default::default()
5907        };
5908
5909        assert_eq!(user_message_preview(&event), Some("[Audio]".to_string()));
5910    }
5911
5912    #[test]
5913    fn turn_aborted_event_deserializes_without_turn_id() -> Result<()> {
5914        let event: EventMsg = serde_json::from_value(json!({
5915            "type": "turn_aborted",
5916            "reason": "interrupted",
5917        }))?;
5918
5919        match event {
5920            EventMsg::TurnAborted(TurnAbortedEvent {
5921                turn_id, reason, ..
5922            }) => {
5923                assert_eq!(turn_id, None);
5924                assert_eq!(reason, TurnAbortReason::Interrupted);
5925            }
5926            _ => panic!("expected turn_aborted event"),
5927        }
5928
5929        Ok(())
5930    }
5931
5932    #[test]
5933    fn session_meta_defaults_legacy_history_mode() -> Result<()> {
5934        let session_meta: SessionMeta = serde_json::from_value(json!({
5935            "session_id": "00000000-0000-0000-0000-000000000001",
5936            "id": "00000000-0000-0000-0000-000000000001",
5937            "timestamp": "2026-01-01T00:00:00Z",
5938            "cwd": "/tmp",
5939            "originator": "codex",
5940            "cli_version": "0.0.0",
5941            "model_provider": null,
5942            "base_instructions": null
5943        }))?;
5944
5945        assert_eq!(session_meta.history_mode, ThreadHistoryMode::Legacy);
5946        assert_eq!(session_meta.history_base, None);
5947        let serialized = serde_json::to_value(&session_meta)?;
5948        assert_eq!(serialized["history_mode"], json!("legacy"));
5949        let mut unknown = serialized;
5950        unknown["history_mode"] = json!("future");
5951        assert!(serde_json::from_value::<SessionMeta>(unknown).is_err());
5952        Ok(())
5953    }
5954
5955    #[test]
5956    fn copied_history_uses_persisted_history_mode() -> Result<()> {
5957        let thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000001")?;
5958        let session_meta = RolloutItem::SessionMeta(SessionMetaLine {
5959            meta: SessionMeta {
5960                session_id: thread_id.into(),
5961                id: thread_id,
5962                history_mode: ThreadHistoryMode::Legacy,
5963                ..SessionMeta::default()
5964            },
5965            git: None,
5966        });
5967        let history = InitialHistory::Resumed(ResumedHistory {
5968            conversation_id: thread_id,
5969            history: Arc::new(vec![session_meta.clone()]),
5970            rollout_path: None,
5971        });
5972
5973        assert_eq!(
5974            history.get_history_mode(ThreadHistoryMode::Paginated),
5975            ThreadHistoryMode::Legacy
5976        );
5977        assert_eq!(
5978            InitialHistory::Forked(vec![session_meta])
5979                .get_history_mode(ThreadHistoryMode::Paginated),
5980            ThreadHistoryMode::Legacy
5981        );
5982        assert_eq!(
5983            InitialHistory::New.get_history_mode(ThreadHistoryMode::Paginated),
5984            ThreadHistoryMode::Paginated
5985        );
5986        assert_eq!(
5987            InitialHistory::Resumed(ResumedHistory {
5988                conversation_id: thread_id,
5989                history: Arc::new(Vec::new()),
5990                rollout_path: None,
5991            })
5992            .get_history_mode(ThreadHistoryMode::Paginated),
5993            ThreadHistoryMode::Paginated
5994        );
5995        Ok(())
5996    }
5997
5998    #[test]
5999    fn turn_context_item_deserializes_without_network() -> Result<()> {
6000        let item: TurnContextItem = serde_json::from_value(json!({
6001            "cwd": test_path_buf("/tmp"),
6002            "approval_policy": "never",
6003            "sandbox_policy": { "type": "danger-full-access" },
6004            "model": "gpt-5",
6005            "summary": "auto",
6006        }))?;
6007
6008        assert_eq!(item.network, None);
6009        assert_eq!(item.file_system_sandbox_policy, None);
6010        assert_eq!(item.comp_hash, None);
6011        Ok(())
6012    }
6013
6014    #[test]
6015    fn turn_context_item_deserializes_legacy_on_failure_as_on_request() -> Result<()> {
6016        let item: TurnContextItem = serde_json::from_value(json!({
6017            "cwd": test_path_buf("/tmp"),
6018            "approval_policy": "on-failure",
6019            "sandbox_policy": { "type": "danger-full-access" },
6020            "model": "gpt-5",
6021            "summary": "auto",
6022        }))?;
6023
6024        assert_eq!(item.approval_policy, AskForApproval::OnRequest);
6025        Ok(())
6026    }
6027
6028    #[test]
6029    fn multi_agent_version_uses_newest_present_session_meta_value() -> Result<()> {
6030        let thread_id = ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8")?;
6031        let older_meta = SessionMetaLine {
6032            meta: SessionMeta {
6033                session_id: thread_id.into(),
6034                id: thread_id,
6035                multi_agent_version: Some(MultiAgentVersion::V2),
6036                ..Default::default()
6037            },
6038            git: None,
6039        };
6040        let newer_meta_without_version = SessionMetaLine {
6041            meta: SessionMeta {
6042                session_id: thread_id.into(),
6043                id: thread_id,
6044                multi_agent_version: None,
6045                ..Default::default()
6046            },
6047            git: None,
6048        };
6049
6050        assert_eq!(
6051            multi_agent_version_from_items(
6052                &[
6053                    RolloutItem::SessionMeta(older_meta),
6054                    RolloutItem::SessionMeta(newer_meta_without_version),
6055                ],
6056                Some(thread_id),
6057            ),
6058            Some(MultiAgentVersion::V2)
6059        );
6060        Ok(())
6061    }
6062
6063    #[test]
6064    fn latest_effective_multi_agent_mode_uses_latest_turn_context_even_when_unset() -> Result<()> {
6065        let turn_context_item = |multi_agent_mode| -> Result<RolloutItem> {
6066            let mut value = json!({
6067                "cwd": test_path_buf("/tmp"),
6068                "approval_policy": "never",
6069                "sandbox_policy": { "type": "danger-full-access" },
6070                "model": "gpt-5",
6071                "summary": "auto",
6072            });
6073            value["multi_agent_mode"] = serde_json::to_value(multi_agent_mode)?;
6074            Ok(RolloutItem::TurnContext(serde_json::from_value(value)?))
6075        };
6076
6077        assert_eq!(
6078            InitialHistory::Forked(vec![
6079                turn_context_item(Some(MultiAgentMode::Proactive))?,
6080                turn_context_item(/*multi_agent_mode*/ None)?,
6081            ])
6082            .get_latest_effective_multi_agent_mode(),
6083            None
6084        );
6085        Ok(())
6086    }
6087
6088    #[test]
6089    fn latest_effective_multi_agent_mode_maps_legacy_none_to_empty_custom() -> Result<()> {
6090        let value = json!({
6091            "cwd": test_path_buf("/tmp"),
6092            "approval_policy": "never",
6093            "sandbox_policy": { "type": "danger-full-access" },
6094            "model": "gpt-5",
6095            "multi_agent_mode": "none",
6096            "summary": "auto",
6097        });
6098        let item = RolloutItem::TurnContext(serde_json::from_value(value)?);
6099
6100        assert_eq!(
6101            InitialHistory::Forked(vec![item]).get_latest_effective_multi_agent_mode(),
6102            Some(MultiAgentMode::Custom(String::new()))
6103        );
6104        Ok(())
6105    }
6106
6107    #[test]
6108    fn turn_context_item_serializes_network_when_present() -> Result<()> {
6109        let item = TurnContextItem {
6110            turn_id: None,
6111            cwd: test_path_buf("/tmp").abs(),
6112            workspace_roots: None,
6113            current_date: None,
6114            timezone: None,
6115            approval_policy: AskForApproval::Never,
6116            approvals_reviewer: None,
6117            sandbox_policy: SandboxPolicy::DangerFullAccess,
6118            permission_profile: None,
6119            network: Some(TurnContextNetworkItem {
6120                allowed_domains: vec!["api.example.com".to_string()],
6121                denied_domains: vec!["blocked.example.com".to_string()],
6122            }),
6123            file_system_sandbox_policy: Some(FileSystemSandboxPolicy::restricted(vec![
6124                FileSystemSandboxEntry {
6125                    path: FileSystemPath::GlobPattern {
6126                        pattern: "/tmp/private/**/*.txt".to_string(),
6127                    },
6128                    access: FileSystemAccessMode::Deny,
6129                    missing_path_behavior: None,
6130                },
6131            ])),
6132            model: "gpt-5".to_string(),
6133            comp_hash: None,
6134            personality: None,
6135            collaboration_mode: None,
6136            multi_agent_version: None,
6137            multi_agent_mode: None,
6138            realtime_active: None,
6139            effort: None,
6140            summary: ReasoningSummaryConfig::Auto,
6141        };
6142
6143        let value = serde_json::to_value(item)?;
6144        assert_eq!(
6145            value["network"],
6146            json!({
6147                "allowed_domains": ["api.example.com"],
6148                "denied_domains": ["blocked.example.com"],
6149            })
6150        );
6151        assert_eq!(
6152            value["file_system_sandbox_policy"],
6153            json!({
6154                "kind": "restricted",
6155                "entries": [{
6156                    "path": {
6157                        "type": "glob_pattern",
6158                        "pattern": "/tmp/private/**/*.txt"
6159                    },
6160                    "access": "deny"
6161                }]
6162            })
6163        );
6164        assert_eq!(value["summary"], json!("auto"));
6165        Ok(())
6166    }
6167
6168    /// Serialize Event to verify that its JSON representation has the expected
6169    /// amount of nesting.
6170    #[test]
6171    fn serialize_event() -> Result<()> {
6172        let session_id = SessionId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c7")?;
6173        let thread_id = ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8")?;
6174        let rollout_file = NamedTempFile::new()?;
6175        let permission_profile = PermissionProfile::read_only();
6176        let event = Event {
6177            id: "1234".to_string(),
6178            msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
6179                session_id,
6180                thread_id,
6181                forked_from_id: None,
6182                parent_thread_id: None,
6183                thread_source: None,
6184                thread_name: None,
6185                model: "codex-mini-latest".to_string(),
6186                model_provider_id: "openai".to_string(),
6187                service_tier: None,
6188                approval_policy: AskForApproval::Never,
6189                approvals_reviewer: ApprovalsReviewer::User,
6190                permission_profile: permission_profile.clone(),
6191                active_permission_profile: None,
6192                cwd: test_path_buf("/home/user/project").abs(),
6193                reasoning_effort: Some(ReasoningEffortConfig::default()),
6194                initial_messages: None,
6195                network_proxy: None,
6196                rollout_path: Some(rollout_file.path().to_path_buf()),
6197            }),
6198        };
6199
6200        let expected = json!({
6201            "id": "1234",
6202            "msg": {
6203                "type": "session_configured",
6204                "session_id": "67e55044-10b1-426f-9247-bb680e5fe0c7",
6205                "thread_id": "67e55044-10b1-426f-9247-bb680e5fe0c8",
6206                "model": "codex-mini-latest",
6207                "model_provider_id": "openai",
6208                "approval_policy": "never",
6209                "approvals_reviewer": "user",
6210                "permission_profile": permission_profile,
6211                "cwd": test_path_buf("/home/user/project"),
6212                "reasoning_effort": "medium",
6213                "rollout_path": format!("{}", rollout_file.path().display()),
6214            }
6215        });
6216        assert_eq!(expected, serde_json::to_value(&event)?);
6217        Ok(())
6218    }
6219
6220    #[test]
6221    fn deserialize_legacy_session_configured_event_uses_sandbox_policy() -> Result<()> {
6222        let cwd = test_path_buf("/home/user/project");
6223        let value = json!({
6224            "session_id": "67e55044-10b1-426f-9247-bb680e5fe0c8",
6225            "model": "codex-mini-latest",
6226            "model_provider_id": "openai",
6227            "approval_policy": "never",
6228            "approvals_reviewer": "user",
6229            "sandbox_policy": {
6230                "type": "read-only"
6231            },
6232            "cwd": cwd,
6233        });
6234
6235        let event: SessionConfiguredEvent = serde_json::from_value(value)?;
6236        assert_eq!(event.permission_profile, PermissionProfile::read_only());
6237        Ok(())
6238    }
6239
6240    #[test]
6241    fn vec_u8_as_base64_serialization_and_deserialization() -> Result<()> {
6242        let event = ExecCommandOutputDeltaEvent {
6243            call_id: "call21".to_string(),
6244            stream: ExecOutputStream::Stdout,
6245            chunk: vec![1, 2, 3, 4, 5],
6246        };
6247        let serialized = serde_json::to_string(&event)?;
6248        assert_eq!(
6249            r#"{"call_id":"call21","stream":"stdout","chunk":"AQIDBAU="}"#,
6250            serialized,
6251        );
6252
6253        let deserialized: ExecCommandOutputDeltaEvent = serde_json::from_str(&serialized)?;
6254        assert_eq!(deserialized, event);
6255        Ok(())
6256    }
6257
6258    #[test]
6259    fn serialize_mcp_startup_update_event() -> Result<()> {
6260        let event = Event {
6261            id: "init".to_string(),
6262            msg: EventMsg::McpStartupUpdate(McpStartupUpdateEvent {
6263                server: "srv".to_string(),
6264                status: McpStartupStatus::Failed {
6265                    error: "boom".to_string(),
6266                    reason: Some(McpStartupFailureReason::ReauthenticationRequired),
6267                },
6268            }),
6269        };
6270
6271        let value = serde_json::to_value(&event)?;
6272        assert_eq!(value["msg"]["type"], "mcp_startup_update");
6273        assert_eq!(value["msg"]["server"], "srv");
6274        assert_eq!(value["msg"]["status"]["state"], "failed");
6275        assert_eq!(value["msg"]["status"]["error"], "boom");
6276        assert_eq!(
6277            value["msg"]["status"]["reason"],
6278            "reauthentication_required"
6279        );
6280        Ok(())
6281    }
6282
6283    #[test]
6284    fn serialize_mcp_startup_complete_event() -> Result<()> {
6285        let event = Event {
6286            id: "init".to_string(),
6287            msg: EventMsg::McpStartupComplete(McpStartupCompleteEvent {
6288                ready: vec!["a".to_string()],
6289                failed: vec![McpStartupFailure {
6290                    server: "b".to_string(),
6291                    error: "bad".to_string(),
6292                }],
6293                cancelled: vec!["c".to_string()],
6294            }),
6295        };
6296
6297        let value = serde_json::to_value(&event)?;
6298        assert_eq!(value["msg"]["type"], "mcp_startup_complete");
6299        assert_eq!(value["msg"]["ready"][0], "a");
6300        assert_eq!(value["msg"]["failed"][0]["server"], "b");
6301        assert_eq!(value["msg"]["failed"][0]["error"], "bad");
6302        assert_eq!(value["msg"]["cancelled"][0], "c");
6303        Ok(())
6304    }
6305
6306    #[test]
6307    fn token_usage_info_new_or_append_updates_context_window_when_provided() {
6308        let initial = Some(TokenUsageInfo {
6309            total_token_usage: TokenUsage::default(),
6310            last_token_usage: TokenUsage::default(),
6311            model_context_window: Some(258_400),
6312        });
6313        let last = Some(TokenUsage {
6314            input_tokens: 10,
6315            cached_input_tokens: 0,
6316            cache_write_input_tokens: 0,
6317            output_tokens: 0,
6318            reasoning_output_tokens: 0,
6319            total_tokens: 10,
6320        });
6321
6322        let info = TokenUsageInfo::new_or_append(&initial, &last, Some(128_000))
6323            .expect("new_or_append should return info");
6324
6325        assert_eq!(info.model_context_window, Some(128_000));
6326    }
6327
6328    #[test]
6329    fn token_usage_info_new_or_append_preserves_context_window_when_not_provided() {
6330        let initial = Some(TokenUsageInfo {
6331            total_token_usage: TokenUsage::default(),
6332            last_token_usage: TokenUsage::default(),
6333            model_context_window: Some(258_400),
6334        });
6335        let last = Some(TokenUsage {
6336            input_tokens: 10,
6337            cached_input_tokens: 0,
6338            cache_write_input_tokens: 0,
6339            output_tokens: 0,
6340            reasoning_output_tokens: 0,
6341            total_tokens: 10,
6342        });
6343
6344        let info =
6345            TokenUsageInfo::new_or_append(&initial, &last, /*model_context_window*/ None)
6346                .expect("new_or_append should return info");
6347
6348        assert_eq!(info.model_context_window, Some(258_400));
6349    }
6350}