Skip to main content

codex_app_server_sdk/
api.rs

1use std::sync::Arc;
2use std::sync::atomic::{AtomicBool, Ordering};
3
4use serde_json::{Map, Value};
5use thiserror::Error;
6use tokio::sync::{Mutex, mpsc};
7use tokio::task::JoinHandle;
8
9use crate::CodexClient;
10use crate::client::StdioConfig;
11use crate::client::WsConfig;
12use crate::error::ClientError;
13use crate::events::{ServerEvent, ServerNotification};
14use crate::protocol::shared::EmptyObject;
15use crate::protocol::{requests, responses};
16use crate::schema::OpenAiSerializable;
17
18const THREAD_LIST_PAGE_LIMIT: u32 = 100;
19const MAX_THREAD_LIST_PAGES: usize = 100;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum ApprovalMode {
23    Never,
24    OnRequest,
25    OnFailure,
26    Untrusted,
27}
28
29impl ApprovalMode {
30    fn as_str(self) -> &'static str {
31        match self {
32            Self::Never => "never",
33            Self::OnRequest => "on-request",
34            Self::OnFailure => "on-failure",
35            Self::Untrusted => "untrusted",
36        }
37    }
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum SandboxMode {
42    ReadOnly,
43    WorkspaceWrite,
44    DangerFullAccess,
45}
46
47impl SandboxMode {
48    fn as_str(self) -> &'static str {
49        match self {
50            Self::ReadOnly => "read-only",
51            Self::WorkspaceWrite => "workspace-write",
52            Self::DangerFullAccess => "danger-full-access",
53        }
54    }
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum ModelReasoningEffort {
59    None,
60    Minimal,
61    Low,
62    Medium,
63    High,
64    XHigh,
65}
66
67impl ModelReasoningEffort {
68    fn as_str(self) -> &'static str {
69        match self {
70            Self::None => "none",
71            Self::Minimal => "minimal",
72            Self::Low => "low",
73            Self::Medium => "medium",
74            Self::High => "high",
75            Self::XHigh => "xhigh",
76        }
77    }
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum ModelReasoningSummary {
82    None,
83    Auto,
84    Concise,
85    Detailed,
86}
87
88impl ModelReasoningSummary {
89    fn as_str(self) -> &'static str {
90        match self {
91            Self::None => "none",
92            Self::Auto => "auto",
93            Self::Concise => "concise",
94            Self::Detailed => "detailed",
95        }
96    }
97}
98
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub enum Personality {
101    None,
102    Friendly,
103    Pragmatic,
104}
105
106impl Personality {
107    fn as_str(self) -> &'static str {
108        match self {
109            Self::None => "none",
110            Self::Friendly => "friendly",
111            Self::Pragmatic => "pragmatic",
112        }
113    }
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117pub enum WebSearchMode {
118    Disabled,
119    Cached,
120    Live,
121}
122
123impl WebSearchMode {
124    fn as_str(self) -> &'static str {
125        match self {
126            Self::Disabled => "disabled",
127            Self::Cached => "cached",
128            Self::Live => "live",
129        }
130    }
131}
132
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub enum CollaborationModeKind {
135    Plan,
136    Default,
137}
138
139impl CollaborationModeKind {
140    fn as_str(self) -> &'static str {
141        match self {
142            Self::Plan => "plan",
143            Self::Default => "default",
144        }
145    }
146}
147
148#[derive(Debug, Clone, PartialEq, Eq)]
149pub struct CollaborationModeSettings {
150    pub model: String,
151    pub reasoning_effort: Option<ModelReasoningEffort>,
152    pub developer_instructions: Option<String>,
153}
154
155impl CollaborationModeSettings {
156    pub fn new(model: impl Into<String>) -> Self {
157        Self {
158            model: model.into(),
159            reasoning_effort: None,
160            developer_instructions: None,
161        }
162    }
163
164    pub fn with_reasoning_effort(mut self, reasoning_effort: ModelReasoningEffort) -> Self {
165        self.reasoning_effort = Some(reasoning_effort);
166        self
167    }
168
169    pub fn with_developer_instructions(
170        mut self,
171        developer_instructions: impl Into<String>,
172    ) -> Self {
173        self.developer_instructions = Some(developer_instructions.into());
174        self
175    }
176}
177
178#[derive(Debug, Clone, PartialEq, Eq)]
179pub struct CollaborationMode {
180    pub mode: CollaborationModeKind,
181    pub settings: CollaborationModeSettings,
182}
183
184impl CollaborationMode {
185    pub fn new(mode: CollaborationModeKind, settings: CollaborationModeSettings) -> Self {
186        Self { mode, settings }
187    }
188
189    fn as_value(&self) -> Value {
190        let mut settings = Map::new();
191        settings.insert(
192            "model".to_string(),
193            Value::String(self.settings.model.clone()),
194        );
195        if let Some(reasoning_effort) = self.settings.reasoning_effort {
196            settings.insert(
197                "reasoning_effort".to_string(),
198                Value::String(reasoning_effort.as_str().to_string()),
199            );
200        }
201        if let Some(instructions) = &self.settings.developer_instructions {
202            settings.insert(
203                "developer_instructions".to_string(),
204                Value::String(instructions.clone()),
205            );
206        }
207
208        let mut value = Map::new();
209        value.insert(
210            "mode".to_string(),
211            Value::String(self.mode.as_str().to_string()),
212        );
213        value.insert("settings".to_string(), Value::Object(settings));
214        Value::Object(value)
215    }
216}
217
218#[derive(Debug, Clone, PartialEq)]
219pub struct DynamicToolSpec {
220    pub name: String,
221    pub description: String,
222    pub input_schema: Value,
223}
224
225impl DynamicToolSpec {
226    pub fn new(
227        name: impl Into<String>,
228        description: impl Into<String>,
229        input_schema: Value,
230    ) -> Self {
231        Self {
232            name: name.into(),
233            description: description.into(),
234            input_schema,
235        }
236    }
237
238    fn as_value(&self) -> Value {
239        let mut value = Map::new();
240        value.insert("name".to_string(), Value::String(self.name.clone()));
241        value.insert(
242            "description".to_string(),
243            Value::String(self.description.clone()),
244        );
245        value.insert("inputSchema".to_string(), self.input_schema.clone());
246        Value::Object(value)
247    }
248}
249
250#[derive(Debug, Clone, Default)]
251pub struct ThreadOptions {
252    pub model: Option<String>,
253    pub model_provider: Option<String>,
254    pub sandbox_mode: Option<SandboxMode>,
255    pub sandbox_policy: Option<Value>,
256    pub working_directory: Option<String>,
257    pub skip_git_repo_check: Option<bool>,
258    pub model_reasoning_effort: Option<ModelReasoningEffort>,
259    pub model_reasoning_summary: Option<ModelReasoningSummary>,
260    pub network_access_enabled: Option<bool>,
261    pub web_search_mode: Option<WebSearchMode>,
262    pub web_search_enabled: Option<bool>,
263    pub approval_policy: Option<ApprovalMode>,
264    pub additional_directories: Option<Vec<String>>,
265    pub personality: Option<Personality>,
266    pub base_instructions: Option<String>,
267    pub developer_instructions: Option<String>,
268    pub ephemeral: Option<bool>,
269    pub collaboration_mode: Option<CollaborationMode>,
270    pub config: Option<Map<String, Value>>,
271    pub dynamic_tools: Option<Vec<DynamicToolSpec>>,
272    pub experimental_raw_events: Option<bool>,
273    pub persist_extended_history: Option<bool>,
274}
275
276impl ThreadOptions {
277    pub fn builder() -> ThreadOptionsBuilder {
278        ThreadOptionsBuilder::new()
279    }
280}
281
282#[derive(Debug, Clone, Default)]
283pub struct ThreadOptionsBuilder {
284    options: ThreadOptions,
285}
286
287impl ThreadOptionsBuilder {
288    pub fn new() -> Self {
289        Self::default()
290    }
291
292    pub fn build(self) -> ThreadOptions {
293        self.options
294    }
295
296    pub fn model(mut self, model: impl Into<String>) -> Self {
297        self.options.model = Some(model.into());
298        self
299    }
300
301    pub fn model_provider(mut self, model_provider: impl Into<String>) -> Self {
302        self.options.model_provider = Some(model_provider.into());
303        self
304    }
305
306    pub fn sandbox_mode(mut self, sandbox_mode: SandboxMode) -> Self {
307        self.options.sandbox_mode = Some(sandbox_mode);
308        self
309    }
310
311    pub fn sandbox_policy(mut self, sandbox_policy: Value) -> Self {
312        self.options.sandbox_policy = Some(sandbox_policy);
313        self
314    }
315
316    pub fn working_directory(mut self, working_directory: impl Into<String>) -> Self {
317        self.options.working_directory = Some(working_directory.into());
318        self
319    }
320
321    pub fn skip_git_repo_check(mut self, enabled: bool) -> Self {
322        self.options.skip_git_repo_check = Some(enabled);
323        self
324    }
325
326    pub fn model_reasoning_effort(mut self, model_reasoning_effort: ModelReasoningEffort) -> Self {
327        self.options.model_reasoning_effort = Some(model_reasoning_effort);
328        self
329    }
330
331    pub fn model_reasoning_summary(
332        mut self,
333        model_reasoning_summary: ModelReasoningSummary,
334    ) -> Self {
335        self.options.model_reasoning_summary = Some(model_reasoning_summary);
336        self
337    }
338
339    pub fn network_access_enabled(mut self, enabled: bool) -> Self {
340        self.options.network_access_enabled = Some(enabled);
341        self
342    }
343
344    pub fn web_search_mode(mut self, web_search_mode: WebSearchMode) -> Self {
345        self.options.web_search_mode = Some(web_search_mode);
346        self
347    }
348
349    pub fn web_search_enabled(mut self, enabled: bool) -> Self {
350        self.options.web_search_enabled = Some(enabled);
351        self
352    }
353
354    pub fn approval_policy(mut self, approval_policy: ApprovalMode) -> Self {
355        self.options.approval_policy = Some(approval_policy);
356        self
357    }
358
359    pub fn additional_directories(mut self, additional_directories: Vec<String>) -> Self {
360        self.options.additional_directories = Some(additional_directories);
361        self
362    }
363
364    pub fn add_directory(mut self, directory: impl Into<String>) -> Self {
365        self.options
366            .additional_directories
367            .get_or_insert_with(Vec::new)
368            .push(directory.into());
369        self
370    }
371
372    pub fn personality(mut self, personality: Personality) -> Self {
373        self.options.personality = Some(personality);
374        self
375    }
376
377    pub fn base_instructions(mut self, base_instructions: impl Into<String>) -> Self {
378        self.options.base_instructions = Some(base_instructions.into());
379        self
380    }
381
382    pub fn developer_instructions(mut self, developer_instructions: impl Into<String>) -> Self {
383        self.options.developer_instructions = Some(developer_instructions.into());
384        self
385    }
386
387    pub fn ephemeral(mut self, ephemeral: bool) -> Self {
388        self.options.ephemeral = Some(ephemeral);
389        self
390    }
391
392    pub fn collaboration_mode(mut self, collaboration_mode: CollaborationMode) -> Self {
393        self.options.collaboration_mode = Some(collaboration_mode);
394        self
395    }
396
397    pub fn config(mut self, config: Map<String, Value>) -> Self {
398        self.options.config = Some(config);
399        self
400    }
401
402    pub fn insert_config(mut self, key: impl Into<String>, value: Value) -> Self {
403        self.options
404            .config
405            .get_or_insert_with(Map::new)
406            .insert(key.into(), value);
407        self
408    }
409
410    pub fn dynamic_tools(mut self, dynamic_tools: Vec<DynamicToolSpec>) -> Self {
411        self.options.dynamic_tools = Some(dynamic_tools);
412        self
413    }
414
415    pub fn experimental_raw_events(mut self, enabled: bool) -> Self {
416        self.options.experimental_raw_events = Some(enabled);
417        self
418    }
419
420    pub fn persist_extended_history(mut self, enabled: bool) -> Self {
421        self.options.persist_extended_history = Some(enabled);
422        self
423    }
424}
425
426#[derive(Debug, Clone, Default)]
427pub struct TurnOptions {
428    pub output_schema: Option<Value>,
429    pub working_directory: Option<String>,
430    pub model: Option<String>,
431    pub model_provider: Option<String>,
432    pub model_reasoning_effort: Option<ModelReasoningEffort>,
433    pub model_reasoning_summary: Option<ModelReasoningSummary>,
434    pub personality: Option<Personality>,
435    pub approval_policy: Option<ApprovalMode>,
436    pub sandbox_policy: Option<Value>,
437    pub collaboration_mode: Option<CollaborationMode>,
438    pub skip_git_repo_check: Option<bool>,
439    pub web_search_mode: Option<WebSearchMode>,
440    pub web_search_enabled: Option<bool>,
441    pub network_access_enabled: Option<bool>,
442    pub additional_directories: Option<Vec<String>>,
443    pub extra: Option<Map<String, Value>>,
444}
445
446impl TurnOptions {
447    pub fn builder() -> TurnOptionsBuilder {
448        TurnOptionsBuilder::new()
449    }
450
451    pub fn with_output_schema(mut self, output_schema: Value) -> Self {
452        self.output_schema = Some(output_schema);
453        self
454    }
455
456    pub fn with_output_schema_for<T: OpenAiSerializable>(mut self) -> Self {
457        self.output_schema = Some(T::openai_output_schema());
458        self
459    }
460
461    pub fn with_model(mut self, model: impl Into<String>) -> Self {
462        self.model = Some(model.into());
463        self
464    }
465
466    pub fn with_working_directory(mut self, working_directory: impl Into<String>) -> Self {
467        self.working_directory = Some(working_directory.into());
468        self
469    }
470}
471
472#[derive(Debug, Clone, Default)]
473pub struct TurnOptionsBuilder {
474    options: TurnOptions,
475}
476
477impl TurnOptionsBuilder {
478    pub fn new() -> Self {
479        Self::default()
480    }
481
482    pub fn build(self) -> TurnOptions {
483        self.options
484    }
485
486    pub fn output_schema(mut self, output_schema: Value) -> Self {
487        self.options.output_schema = Some(output_schema);
488        self
489    }
490
491    pub fn output_schema_for<T: OpenAiSerializable>(mut self) -> Self {
492        self.options.output_schema = Some(T::openai_output_schema());
493        self
494    }
495
496    pub fn clear_output_schema(mut self) -> Self {
497        self.options.output_schema = None;
498        self
499    }
500
501    pub fn working_directory(mut self, working_directory: impl Into<String>) -> Self {
502        self.options.working_directory = Some(working_directory.into());
503        self
504    }
505
506    pub fn model(mut self, model: impl Into<String>) -> Self {
507        self.options.model = Some(model.into());
508        self
509    }
510
511    pub fn model_provider(mut self, model_provider: impl Into<String>) -> Self {
512        self.options.model_provider = Some(model_provider.into());
513        self
514    }
515
516    pub fn model_reasoning_effort(mut self, model_reasoning_effort: ModelReasoningEffort) -> Self {
517        self.options.model_reasoning_effort = Some(model_reasoning_effort);
518        self
519    }
520
521    pub fn model_reasoning_summary(
522        mut self,
523        model_reasoning_summary: ModelReasoningSummary,
524    ) -> Self {
525        self.options.model_reasoning_summary = Some(model_reasoning_summary);
526        self
527    }
528
529    pub fn personality(mut self, personality: Personality) -> Self {
530        self.options.personality = Some(personality);
531        self
532    }
533
534    pub fn approval_policy(mut self, approval_policy: ApprovalMode) -> Self {
535        self.options.approval_policy = Some(approval_policy);
536        self
537    }
538
539    pub fn sandbox_policy(mut self, sandbox_policy: Value) -> Self {
540        self.options.sandbox_policy = Some(sandbox_policy);
541        self
542    }
543
544    pub fn collaboration_mode(mut self, collaboration_mode: CollaborationMode) -> Self {
545        self.options.collaboration_mode = Some(collaboration_mode);
546        self
547    }
548
549    pub fn skip_git_repo_check(mut self, enabled: bool) -> Self {
550        self.options.skip_git_repo_check = Some(enabled);
551        self
552    }
553
554    pub fn web_search_mode(mut self, web_search_mode: WebSearchMode) -> Self {
555        self.options.web_search_mode = Some(web_search_mode);
556        self
557    }
558
559    pub fn web_search_enabled(mut self, enabled: bool) -> Self {
560        self.options.web_search_enabled = Some(enabled);
561        self
562    }
563
564    pub fn network_access_enabled(mut self, enabled: bool) -> Self {
565        self.options.network_access_enabled = Some(enabled);
566        self
567    }
568
569    pub fn additional_directories(mut self, additional_directories: Vec<String>) -> Self {
570        self.options.additional_directories = Some(additional_directories);
571        self
572    }
573
574    pub fn add_directory(mut self, directory: impl Into<String>) -> Self {
575        self.options
576            .additional_directories
577            .get_or_insert_with(Vec::new)
578            .push(directory.into());
579        self
580    }
581
582    pub fn extra(mut self, extra: Map<String, Value>) -> Self {
583        self.options.extra = Some(extra);
584        self
585    }
586
587    pub fn insert_extra(mut self, key: impl Into<String>, value: Value) -> Self {
588        self.options
589            .extra
590            .get_or_insert_with(Map::new)
591            .insert(key.into(), value);
592        self
593    }
594}
595
596#[derive(Debug, Clone, PartialEq, Eq)]
597pub struct ThreadError {
598    pub message: String,
599}
600
601#[derive(Debug, Error)]
602pub enum ThreadRunError {
603    #[error(transparent)]
604    Client(#[from] ClientError),
605    #[error("{message}")]
606    TurnFailed { message: String },
607}
608
609#[derive(Debug, Clone, PartialEq, Eq)]
610pub enum UserInput {
611    Text { text: String },
612    LocalImage { path: String },
613}
614
615#[derive(Debug, Clone, PartialEq, Eq)]
616pub enum Input {
617    Text(String),
618    Items(Vec<UserInput>),
619}
620
621impl Input {
622    pub fn text(text: impl Into<String>) -> Self {
623        Self::Text(text.into())
624    }
625
626    pub fn items(items: Vec<UserInput>) -> Self {
627        Self::Items(items)
628    }
629}
630
631impl From<String> for Input {
632    fn from(value: String) -> Self {
633        Self::Text(value)
634    }
635}
636
637impl From<&str> for Input {
638    fn from(value: &str) -> Self {
639        Self::Text(value.to_string())
640    }
641}
642
643impl From<Vec<UserInput>> for Input {
644    fn from(value: Vec<UserInput>) -> Self {
645        Self::Items(value)
646    }
647}
648
649#[derive(Debug, Clone, PartialEq, Eq)]
650pub struct Usage {
651    pub input_tokens: i64,
652    pub cached_input_tokens: i64,
653    pub output_tokens: i64,
654}
655
656#[derive(Debug, Clone, PartialEq)]
657pub struct Turn {
658    pub items: Vec<ThreadItem>,
659    pub final_response: String,
660    pub usage: Option<Usage>,
661}
662
663pub type RunResult = Turn;
664
665pub struct StreamedTurn {
666    receiver: mpsc::Receiver<Result<ThreadEvent, ClientError>>,
667    task: JoinHandle<()>,
668}
669
670impl StreamedTurn {
671    pub async fn next_event(&mut self) -> Option<Result<ThreadEvent, ClientError>> {
672        self.receiver.recv().await
673    }
674}
675
676impl Drop for StreamedTurn {
677    fn drop(&mut self) {
678        self.task.abort();
679    }
680}
681
682#[derive(Debug, Clone, PartialEq)]
683pub enum ThreadEvent {
684    ThreadStarted { thread_id: String },
685    TurnStarted,
686    TurnCompleted { usage: Option<Usage> },
687    TurnFailed { error: ThreadError },
688    ItemStarted { item: ThreadItem },
689    ItemUpdated { item: ThreadItem },
690    ItemCompleted { item: ThreadItem },
691    Error { message: String },
692}
693
694#[derive(Debug, Clone, Copy, PartialEq, Eq)]
695pub enum AgentMessagePhase {
696    Commentary,
697    FinalAnswer,
698    Unknown,
699}
700
701impl AgentMessagePhase {
702    pub fn as_str(self) -> &'static str {
703        match self {
704            Self::Commentary => "commentary",
705            Self::FinalAnswer => "final_answer",
706            Self::Unknown => "unknown",
707        }
708    }
709}
710
711#[derive(Debug, Clone, PartialEq, Eq)]
712pub struct AgentMessageItem {
713    pub id: String,
714    pub text: String,
715    pub phase: Option<AgentMessagePhase>,
716}
717
718impl AgentMessageItem {
719    pub fn is_final_answer(&self) -> bool {
720        matches!(self.phase, Some(AgentMessagePhase::FinalAnswer))
721    }
722}
723
724#[derive(Debug, Clone, PartialEq)]
725pub enum UserMessageContentItem {
726    Text { text: String },
727    Image { url: String },
728    LocalImage { path: String },
729    Unknown(Value),
730}
731
732#[derive(Debug, Clone, PartialEq)]
733pub struct UserMessageItem {
734    pub id: String,
735    pub content: Vec<UserMessageContentItem>,
736}
737
738#[derive(Debug, Clone, PartialEq, Eq)]
739pub struct PlanItem {
740    pub id: String,
741    pub text: String,
742}
743
744#[derive(Debug, Clone, PartialEq, Eq)]
745pub struct ReasoningItem {
746    pub id: String,
747    pub text: String,
748}
749
750#[derive(Debug, Clone, Copy, PartialEq, Eq)]
751pub enum CommandExecutionStatus {
752    InProgress,
753    Completed,
754    Failed,
755    Declined,
756    Unknown,
757}
758
759#[derive(Debug, Clone, PartialEq, Eq)]
760pub struct CommandExecutionItem {
761    pub id: String,
762    pub command: String,
763    pub aggregated_output: String,
764    pub exit_code: Option<i32>,
765    pub status: CommandExecutionStatus,
766}
767
768#[derive(Debug, Clone, Copy, PartialEq, Eq)]
769pub enum PatchChangeKind {
770    Add,
771    Delete,
772    Update,
773    Unknown,
774}
775
776#[derive(Debug, Clone, PartialEq, Eq)]
777pub struct FileUpdateChange {
778    pub path: String,
779    pub kind: PatchChangeKind,
780}
781
782#[derive(Debug, Clone, Copy, PartialEq, Eq)]
783pub enum PatchApplyStatus {
784    InProgress,
785    Completed,
786    Failed,
787    Declined,
788    Unknown,
789}
790
791#[derive(Debug, Clone, PartialEq, Eq)]
792pub struct FileChangeItem {
793    pub id: String,
794    pub changes: Vec<FileUpdateChange>,
795    pub status: PatchApplyStatus,
796}
797
798#[derive(Debug, Clone, Copy, PartialEq, Eq)]
799pub enum McpToolCallStatus {
800    InProgress,
801    Completed,
802    Failed,
803    Unknown,
804}
805
806#[derive(Debug, Clone, PartialEq)]
807pub struct McpToolCallItem {
808    pub id: String,
809    pub server: String,
810    pub tool: String,
811    pub arguments: Value,
812    pub result: Option<Value>,
813    pub error: Option<ThreadError>,
814    pub status: McpToolCallStatus,
815}
816
817#[derive(Debug, Clone, PartialEq)]
818pub struct DynamicToolCallItem {
819    pub id: String,
820    pub tool: String,
821    pub arguments: Value,
822    pub status: String,
823    pub content_items: Vec<Value>,
824    pub success: Option<bool>,
825    pub duration_ms: Option<u64>,
826}
827
828#[derive(Debug, Clone, PartialEq, Eq)]
829pub struct CollabToolCallItem {
830    pub id: String,
831    pub tool: String,
832    pub status: String,
833    pub sender_thread_id: String,
834    pub receiver_thread_id: Option<String>,
835    pub new_thread_id: Option<String>,
836    pub prompt: Option<String>,
837    pub agent_status: Option<String>,
838}
839
840#[derive(Debug, Clone, PartialEq, Eq)]
841pub struct WebSearchItem {
842    pub id: String,
843    pub query: String,
844}
845
846#[derive(Debug, Clone, PartialEq, Eq)]
847pub struct ImageViewItem {
848    pub id: String,
849    pub path: String,
850}
851
852#[derive(Debug, Clone, PartialEq, Eq)]
853pub struct ReviewModeItem {
854    pub id: String,
855    pub review: String,
856}
857
858#[derive(Debug, Clone, PartialEq, Eq)]
859pub struct ContextCompactionItem {
860    pub id: String,
861}
862
863#[derive(Debug, Clone, PartialEq, Eq)]
864pub struct TodoItem {
865    pub text: String,
866    pub completed: bool,
867}
868
869#[derive(Debug, Clone, PartialEq, Eq)]
870pub struct TodoListItem {
871    pub id: String,
872    pub items: Vec<TodoItem>,
873}
874
875#[derive(Debug, Clone, PartialEq, Eq)]
876pub struct ErrorItem {
877    pub id: String,
878    pub message: String,
879}
880
881#[derive(Debug, Clone, PartialEq)]
882pub struct UnknownItem {
883    pub id: Option<String>,
884    pub item_type: Option<String>,
885    pub raw: Value,
886}
887
888#[derive(Debug, Clone, PartialEq)]
889pub enum ThreadItem {
890    AgentMessage(AgentMessageItem),
891    UserMessage(UserMessageItem),
892    Plan(PlanItem),
893    Reasoning(ReasoningItem),
894    CommandExecution(CommandExecutionItem),
895    FileChange(FileChangeItem),
896    McpToolCall(McpToolCallItem),
897    DynamicToolCall(DynamicToolCallItem),
898    CollabToolCall(CollabToolCallItem),
899    WebSearch(WebSearchItem),
900    ImageView(ImageViewItem),
901    EnteredReviewMode(ReviewModeItem),
902    ExitedReviewMode(ReviewModeItem),
903    ContextCompaction(ContextCompactionItem),
904    TodoList(TodoListItem),
905    Error(ErrorItem),
906    Unknown(UnknownItem),
907}
908
909macro_rules! codex_forward_typed_method {
910    ($method:ident, $params_ty:ty, $result_ty:ty) => {
911        pub async fn $method(&self, params: $params_ty) -> Result<$result_ty, ClientError> {
912            self.ensure_initialized().await?;
913            self.inner.client.$method(params).await
914        }
915    };
916}
917
918macro_rules! codex_forward_null_method {
919    ($method:ident, $result_ty:ty) => {
920        pub async fn $method(&self) -> Result<$result_ty, ClientError> {
921            self.ensure_initialized().await?;
922            self.inner.client.$method().await
923        }
924    };
925}
926
927#[derive(Clone)]
928pub struct Codex {
929    inner: Arc<CodexInner>,
930}
931
932struct CodexInner {
933    client: CodexClient,
934    initialize_params: requests::InitializeParams,
935    initialized: AtomicBool,
936    initialize_lock: Mutex<()>,
937}
938
939impl Codex {
940    pub fn with_initialize_params(
941        client: CodexClient,
942        initialize_params: requests::InitializeParams,
943    ) -> Self {
944        Self {
945            inner: Arc::new(CodexInner {
946                client,
947                initialize_params,
948                initialized: AtomicBool::new(false),
949                initialize_lock: Mutex::new(()),
950            }),
951        }
952    }
953
954    pub fn from_client(client: CodexClient) -> Self {
955        let initialize_params = requests::InitializeParams::new(requests::ClientInfo::new(
956            "codex_sdk_rs",
957            "Codex Rust SDK",
958            env!("CARGO_PKG_VERSION"),
959        ));
960        Self::with_initialize_params(client, initialize_params)
961    }
962
963    pub async fn spawn_stdio(config: StdioConfig) -> Result<Self, ClientError> {
964        let client = CodexClient::spawn_stdio(config).await?;
965        Ok(Self::from_client(client))
966    }
967
968    pub async fn connect_ws(config: WsConfig) -> Result<Self, ClientError> {
969        let client = CodexClient::connect_ws(config).await?;
970        Ok(Self::from_client(client))
971    }
972
973    pub async fn start_and_connect_ws(config: WsConfig) -> Result<Self, ClientError> {
974        let client = CodexClient::start_and_connect_ws(config).await?;
975        Ok(Self::from_client(client))
976    }
977
978    pub fn start_thread(&self, options: ThreadOptions) -> Thread {
979        Thread {
980            codex: self.clone(),
981            id: None,
982            pending_resume: None,
983            last_turn_id: None,
984            options,
985        }
986    }
987
988    pub fn resume_thread(&self, id: impl Into<String>, options: ThreadOptions) -> Thread {
989        self.resume_thread_by_id(id, options)
990    }
991
992    pub fn resume_thread_by_id(&self, id: impl Into<String>, options: ThreadOptions) -> Thread {
993        let id = id.into();
994        Thread {
995            codex: self.clone(),
996            id: Some(id.clone()),
997            pending_resume: Some(PendingResume::ById(id)),
998            last_turn_id: None,
999            options,
1000        }
1001    }
1002
1003    pub fn resume_latest_thread(&self, options: ThreadOptions) -> Thread {
1004        Thread {
1005            codex: self.clone(),
1006            id: None,
1007            pending_resume: Some(PendingResume::Latest),
1008            last_turn_id: None,
1009            options,
1010        }
1011    }
1012
1013    /// Runs a one-shot turn on a new thread and returns only the final agent response text.
1014    pub async fn ask(&self, input: impl Into<Input>) -> Result<String, ThreadRunError> {
1015        self.ask_with_options(input, ThreadOptions::default(), TurnOptions::default())
1016            .await
1017    }
1018
1019    /// Runs a one-shot turn on a new thread and returns only the final agent response text.
1020    pub async fn ask_with_options(
1021        &self,
1022        input: impl Into<Input>,
1023        thread_options: ThreadOptions,
1024        turn_options: TurnOptions,
1025    ) -> Result<String, ThreadRunError> {
1026        let mut thread = self.start_thread(thread_options);
1027        thread.ask(input, turn_options).await
1028    }
1029
1030    /// Lists recorded threads after ensuring the app-server handshake is complete.
1031    pub async fn thread_list(
1032        &self,
1033        params: requests::ThreadListParams,
1034    ) -> Result<responses::ThreadListResult, ClientError> {
1035        self.ensure_initialized().await?;
1036        self.inner.client.thread_list(params).await
1037    }
1038
1039    codex_forward_typed_method!(
1040        thread_start,
1041        requests::ThreadStartParams,
1042        responses::ThreadResult
1043    );
1044    codex_forward_typed_method!(
1045        thread_resume,
1046        requests::ThreadResumeParams,
1047        responses::ThreadResult
1048    );
1049    codex_forward_typed_method!(
1050        thread_fork,
1051        requests::ThreadForkParams,
1052        responses::ThreadResult
1053    );
1054    codex_forward_typed_method!(
1055        thread_archive,
1056        requests::ThreadArchiveParams,
1057        responses::ThreadArchiveResult
1058    );
1059    codex_forward_typed_method!(
1060        thread_name_set,
1061        requests::ThreadSetNameParams,
1062        responses::ThreadSetNameResult
1063    );
1064    codex_forward_typed_method!(
1065        thread_unarchive,
1066        requests::ThreadUnarchiveParams,
1067        responses::ThreadUnarchiveResult
1068    );
1069    codex_forward_typed_method!(
1070        thread_compact_start,
1071        requests::ThreadCompactStartParams,
1072        responses::ThreadCompactStartResult
1073    );
1074    codex_forward_typed_method!(
1075        thread_background_terminals_clean,
1076        requests::ThreadBackgroundTerminalsCleanParams,
1077        responses::ThreadBackgroundTerminalsCleanResult
1078    );
1079    codex_forward_typed_method!(
1080        thread_rollback,
1081        requests::ThreadRollbackParams,
1082        responses::ThreadRollbackResult
1083    );
1084    codex_forward_typed_method!(
1085        thread_loaded_list,
1086        requests::ThreadLoadedListParams,
1087        responses::ThreadLoadedListResult
1088    );
1089    codex_forward_typed_method!(
1090        thread_read,
1091        requests::ThreadReadParams,
1092        responses::ThreadReadResult
1093    );
1094    codex_forward_typed_method!(
1095        skills_list,
1096        requests::SkillsListParams,
1097        responses::SkillsListResult
1098    );
1099    codex_forward_typed_method!(
1100        skills_remote_list,
1101        requests::SkillsRemoteReadParams,
1102        responses::SkillsRemoteReadResult
1103    );
1104    codex_forward_typed_method!(
1105        skills_remote_export,
1106        requests::SkillsRemoteWriteParams,
1107        responses::SkillsRemoteWriteResult
1108    );
1109    codex_forward_typed_method!(
1110        app_list,
1111        requests::AppsListParams,
1112        responses::AppsListResult
1113    );
1114    codex_forward_typed_method!(
1115        skills_config_write,
1116        requests::SkillsConfigWriteParams,
1117        responses::SkillsConfigWriteResult
1118    );
1119    codex_forward_typed_method!(turn_start, requests::TurnStartParams, responses::TurnResult);
1120    codex_forward_typed_method!(
1121        turn_steer,
1122        requests::TurnSteerParams,
1123        responses::TurnSteerResult
1124    );
1125    codex_forward_typed_method!(turn_interrupt, requests::TurnInterruptParams, EmptyObject);
1126    codex_forward_typed_method!(
1127        review_start,
1128        requests::ReviewStartParams,
1129        responses::ReviewStartResult
1130    );
1131    codex_forward_typed_method!(
1132        model_list,
1133        requests::ModelListParams,
1134        responses::ModelListResult
1135    );
1136    codex_forward_typed_method!(
1137        experimental_feature_list,
1138        requests::ExperimentalFeatureListParams,
1139        responses::ExperimentalFeatureListResult
1140    );
1141    codex_forward_typed_method!(
1142        collaboration_mode_list,
1143        requests::CollaborationModeListParams,
1144        responses::CollaborationModeListResult
1145    );
1146    codex_forward_typed_method!(
1147        mock_experimental_method,
1148        requests::MockExperimentalMethodParams,
1149        responses::MockExperimentalMethodResult
1150    );
1151    codex_forward_typed_method!(
1152        mcp_server_oauth_login,
1153        requests::McpServerOauthLoginParams,
1154        responses::McpServerOauthLoginResult
1155    );
1156    codex_forward_typed_method!(
1157        mcp_server_status_list,
1158        requests::ListMcpServerStatusParams,
1159        responses::McpServerStatusListResult
1160    );
1161    codex_forward_typed_method!(
1162        windows_sandbox_setup_start,
1163        requests::WindowsSandboxSetupStartParams,
1164        responses::WindowsSandboxSetupStartResult
1165    );
1166    codex_forward_typed_method!(
1167        account_login_start,
1168        requests::LoginAccountParams,
1169        responses::LoginAccountResult
1170    );
1171    codex_forward_typed_method!(
1172        account_login_cancel,
1173        requests::CancelLoginAccountParams,
1174        EmptyObject
1175    );
1176    codex_forward_typed_method!(
1177        feedback_upload,
1178        requests::FeedbackUploadParams,
1179        responses::FeedbackUploadResult
1180    );
1181    codex_forward_typed_method!(
1182        command_exec,
1183        requests::CommandExecParams,
1184        responses::CommandExecResult
1185    );
1186    codex_forward_typed_method!(
1187        config_read,
1188        requests::ConfigReadParams,
1189        responses::ConfigReadResult
1190    );
1191    codex_forward_typed_method!(
1192        config_value_write,
1193        requests::ConfigValueWriteParams,
1194        responses::ConfigValueWriteResult
1195    );
1196    codex_forward_typed_method!(
1197        config_batch_write,
1198        requests::ConfigBatchWriteParams,
1199        responses::ConfigBatchWriteResult
1200    );
1201    codex_forward_typed_method!(
1202        account_read,
1203        requests::GetAccountParams,
1204        responses::GetAccountResult
1205    );
1206    codex_forward_typed_method!(
1207        fuzzy_file_search_session_start,
1208        requests::FuzzyFileSearchSessionStartParams,
1209        responses::FuzzyFileSearchSessionStartResult
1210    );
1211    codex_forward_typed_method!(
1212        fuzzy_file_search_session_update,
1213        requests::FuzzyFileSearchSessionUpdateParams,
1214        responses::FuzzyFileSearchSessionUpdateResult
1215    );
1216    codex_forward_typed_method!(
1217        fuzzy_file_search_session_stop,
1218        requests::FuzzyFileSearchSessionStopParams,
1219        responses::FuzzyFileSearchSessionStopResult
1220    );
1221
1222    pub async fn skills_remote_read(
1223        &self,
1224        params: requests::SkillsRemoteReadParams,
1225    ) -> Result<responses::SkillsRemoteReadResult, ClientError> {
1226        self.skills_remote_list(params).await
1227    }
1228
1229    pub async fn skills_remote_write(
1230        &self,
1231        params: requests::SkillsRemoteWriteParams,
1232    ) -> Result<responses::SkillsRemoteWriteResult, ClientError> {
1233        self.skills_remote_export(params).await
1234    }
1235
1236    codex_forward_null_method!(config_mcp_server_reload, EmptyObject);
1237    codex_forward_null_method!(account_logout, EmptyObject);
1238    codex_forward_null_method!(
1239        account_rate_limits_read,
1240        responses::AccountRateLimitsReadResult
1241    );
1242    codex_forward_null_method!(
1243        config_requirements_read,
1244        responses::ConfigRequirementsReadResult
1245    );
1246
1247    pub async fn send_raw_request(
1248        &self,
1249        method: impl Into<String>,
1250        params: Value,
1251        timeout: Option<std::time::Duration>,
1252    ) -> Result<Value, ClientError> {
1253        self.ensure_initialized().await?;
1254        self.inner
1255            .client
1256            .send_raw_request(method, params, timeout)
1257            .await
1258    }
1259
1260    pub async fn send_raw_notification(
1261        &self,
1262        method: impl Into<String>,
1263        params: Value,
1264    ) -> Result<(), ClientError> {
1265        self.ensure_initialized().await?;
1266        self.inner
1267            .client
1268            .send_raw_notification(method, params)
1269            .await
1270    }
1271
1272    pub fn client(&self) -> CodexClient {
1273        self.inner.client.clone()
1274    }
1275
1276    async fn ensure_initialized(&self) -> Result<(), ClientError> {
1277        if self.inner.initialized.load(Ordering::SeqCst) {
1278            return Ok(());
1279        }
1280
1281        let _guard = self.inner.initialize_lock.lock().await;
1282        if self.inner.initialized.load(Ordering::SeqCst) {
1283            return Ok(());
1284        }
1285
1286        match self
1287            .inner
1288            .client
1289            .initialize(self.inner.initialize_params.clone())
1290            .await
1291        {
1292            Ok(_) | Err(ClientError::AlreadyInitialized) => {}
1293            Err(err) => return Err(err),
1294        }
1295
1296        self.inner.client.initialized().await?;
1297        self.inner.initialized.store(true, Ordering::SeqCst);
1298        Ok(())
1299    }
1300}
1301
1302#[derive(Debug, Clone)]
1303enum PendingResume {
1304    ById(String),
1305    Latest,
1306}
1307
1308pub struct Thread {
1309    codex: Codex,
1310    id: Option<String>,
1311    pending_resume: Option<PendingResume>,
1312    last_turn_id: Option<String>,
1313    options: ThreadOptions,
1314}
1315
1316impl Thread {
1317    pub fn id(&self) -> Option<&str> {
1318        self.id.as_deref()
1319    }
1320
1321    async fn ensure_thread_started_or_resumed(
1322        &mut self,
1323    ) -> Result<(String, Option<String>), ClientError> {
1324        self.codex.ensure_initialized().await?;
1325
1326        let mut emit_thread_started = None;
1327        if let Some(pending_resume) = self.pending_resume.clone() {
1328            let thread_id = match pending_resume {
1329                PendingResume::ById(thread_id) => thread_id,
1330                PendingResume::Latest => self.resolve_latest_thread_id().await?,
1331            };
1332            let resume_params = build_thread_resume_params(&thread_id, &self.options);
1333            let resumed = self.codex.inner.client.thread_resume(resume_params).await?;
1334            self.id = Some(resumed.thread.id);
1335            self.pending_resume = None;
1336            self.last_turn_id = None;
1337        } else if self.id.is_none() {
1338            let thread = self
1339                .codex
1340                .inner
1341                .client
1342                .thread_start(build_thread_start_params(&self.options))
1343                .await?;
1344            self.id = Some(thread.thread.id.clone());
1345            emit_thread_started = Some(thread.thread.id);
1346            self.last_turn_id = None;
1347        }
1348
1349        let thread_id = self.id.clone().ok_or_else(|| {
1350            ClientError::TransportSend("thread id unavailable after start/resume".to_string())
1351        })?;
1352
1353        Ok((thread_id, emit_thread_started))
1354    }
1355
1356    async fn resolve_latest_thread_id(&self) -> Result<String, ClientError> {
1357        let mut cursor: Option<String> = None;
1358        let mut pages_scanned = 0usize;
1359        let mut threads = Vec::new();
1360
1361        loop {
1362            pages_scanned += 1;
1363            if pages_scanned > MAX_THREAD_LIST_PAGES {
1364                return Err(ClientError::TransportSend(format!(
1365                    "could not resolve latest thread after scanning {MAX_THREAD_LIST_PAGES} pages"
1366                )));
1367            }
1368
1369            let result = self
1370                .codex
1371                .inner
1372                .client
1373                .thread_list(requests::ThreadListParams {
1374                    limit: Some(THREAD_LIST_PAGE_LIMIT),
1375                    cursor: cursor.clone(),
1376                    ..Default::default()
1377                })
1378                .await?;
1379
1380            threads.extend(result.data);
1381
1382            match result.next_cursor {
1383                Some(next) => cursor = Some(next),
1384                None => break,
1385            }
1386        }
1387
1388        select_latest_thread_id(&threads, self.options.working_directory.as_deref()).ok_or_else(
1389            || match self.options.working_directory.as_deref() {
1390                Some(working_directory) => ClientError::TransportSend(format!(
1391                    "no recorded thread found for working directory `{working_directory}`"
1392                )),
1393                None => ClientError::TransportSend("no recorded thread found".to_string()),
1394            },
1395        )
1396    }
1397
1398    pub async fn set_name(
1399        &mut self,
1400        name: impl Into<String>,
1401    ) -> Result<responses::ThreadSetNameResult, ClientError> {
1402        let (thread_id, _) = self.ensure_thread_started_or_resumed().await?;
1403        self.codex
1404            .inner
1405            .client
1406            .thread_name_set(requests::ThreadSetNameParams {
1407                thread_id,
1408                name: name.into(),
1409                extra: Map::new(),
1410            })
1411            .await
1412    }
1413
1414    pub async fn read(
1415        &mut self,
1416        include_turns: Option<bool>,
1417    ) -> Result<responses::ThreadReadResult, ClientError> {
1418        let (thread_id, _) = self.ensure_thread_started_or_resumed().await?;
1419        self.codex
1420            .inner
1421            .client
1422            .thread_read(requests::ThreadReadParams {
1423                thread_id,
1424                include_turns,
1425                extra: Map::new(),
1426            })
1427            .await
1428    }
1429
1430    pub async fn archive(&mut self) -> Result<responses::ThreadArchiveResult, ClientError> {
1431        let (thread_id, _) = self.ensure_thread_started_or_resumed().await?;
1432        self.codex
1433            .inner
1434            .client
1435            .thread_archive(requests::ThreadArchiveParams {
1436                thread_id,
1437                extra: Map::new(),
1438            })
1439            .await
1440    }
1441
1442    pub async fn unarchive(&mut self) -> Result<responses::ThreadUnarchiveResult, ClientError> {
1443        let (thread_id, _) = self.ensure_thread_started_or_resumed().await?;
1444        self.codex
1445            .inner
1446            .client
1447            .thread_unarchive(requests::ThreadUnarchiveParams {
1448                thread_id,
1449                extra: Map::new(),
1450            })
1451            .await
1452    }
1453
1454    pub async fn rollback(
1455        &mut self,
1456        count: u32,
1457    ) -> Result<responses::ThreadRollbackResult, ClientError> {
1458        let (thread_id, _) = self.ensure_thread_started_or_resumed().await?;
1459        self.codex
1460            .inner
1461            .client
1462            .thread_rollback(requests::ThreadRollbackParams {
1463                thread_id,
1464                count,
1465                extra: Map::new(),
1466            })
1467            .await
1468    }
1469
1470    pub async fn compact_start(
1471        &mut self,
1472    ) -> Result<responses::ThreadCompactStartResult, ClientError> {
1473        let (thread_id, _) = self.ensure_thread_started_or_resumed().await?;
1474        self.codex
1475            .inner
1476            .client
1477            .thread_compact_start(requests::ThreadCompactStartParams {
1478                thread_id,
1479                extra: Map::new(),
1480            })
1481            .await
1482    }
1483
1484    pub async fn steer(
1485        &mut self,
1486        input: impl Into<Input>,
1487        expected_turn_id: Option<String>,
1488    ) -> Result<responses::TurnSteerResult, ClientError> {
1489        let (thread_id, _) = self.ensure_thread_started_or_resumed().await?;
1490        let expected_turn_id = expected_turn_id.or_else(|| self.last_turn_id.clone());
1491        let expected_turn_id = expected_turn_id.ok_or_else(|| {
1492            ClientError::TransportSend(
1493                "turn/steer requires expected_turn_id or a previously started turn".to_string(),
1494            )
1495        })?;
1496        self.codex
1497            .inner
1498            .client
1499            .turn_steer(requests::TurnSteerParams {
1500                thread_id,
1501                input: normalize_input(input.into()),
1502                expected_turn_id: Some(expected_turn_id),
1503                extra: Map::new(),
1504            })
1505            .await
1506    }
1507
1508    pub async fn interrupt(&mut self, turn_id: impl Into<String>) -> Result<(), ClientError> {
1509        let (thread_id, _) = self.ensure_thread_started_or_resumed().await?;
1510        self.codex
1511            .inner
1512            .client
1513            .turn_interrupt(requests::TurnInterruptParams {
1514                thread_id,
1515                turn_id: turn_id.into(),
1516                extra: Map::new(),
1517            })
1518            .await?;
1519        Ok(())
1520    }
1521
1522    pub async fn run_streamed(
1523        &mut self,
1524        input: impl Into<Input>,
1525        turn_options: TurnOptions,
1526    ) -> Result<StreamedTurn, ClientError> {
1527        let (thread_id, emit_thread_started) = self.ensure_thread_started_or_resumed().await?;
1528
1529        let server_events = self.codex.inner.client.subscribe();
1530
1531        let turn_response = self
1532            .codex
1533            .inner
1534            .client
1535            .turn_start(build_turn_start_params(
1536                &thread_id,
1537                input.into(),
1538                &self.options,
1539                &turn_options,
1540            ))
1541            .await?;
1542        let turn_id = turn_response.turn.id;
1543        self.last_turn_id = Some(turn_id.clone());
1544
1545        let (tx, rx) = mpsc::channel(256);
1546
1547        if let Some(started_thread_id) = emit_thread_started {
1548            if tx
1549                .send(Ok(ThreadEvent::ThreadStarted {
1550                    thread_id: started_thread_id,
1551                }))
1552                .await
1553                .is_err()
1554            {
1555                return Err(ClientError::TransportClosed);
1556            }
1557        }
1558
1559        if tx.send(Ok(ThreadEvent::TurnStarted)).await.is_err() {
1560            return Err(ClientError::TransportClosed);
1561        }
1562
1563        let task = tokio::spawn(async move {
1564            pump_turn_events(server_events, tx, thread_id, turn_id).await;
1565        });
1566
1567        Ok(StreamedTurn { receiver: rx, task })
1568    }
1569
1570    pub async fn run(
1571        &mut self,
1572        input: impl Into<Input>,
1573        turn_options: TurnOptions,
1574    ) -> Result<Turn, ThreadRunError> {
1575        let mut streamed = self.run_streamed(input, turn_options).await?;
1576        let mut items = Vec::new();
1577        let mut final_answer = None;
1578        let mut fallback_response = None;
1579        let mut usage = None;
1580        let mut saw_terminal = false;
1581
1582        while let Some(next) = streamed.next_event().await {
1583            let event = next.map_err(ThreadRunError::Client)?;
1584            match event {
1585                ThreadEvent::ItemCompleted { item } => {
1586                    update_final_response_candidates(
1587                        &item,
1588                        &mut final_answer,
1589                        &mut fallback_response,
1590                    );
1591                    items.push(item);
1592                }
1593                ThreadEvent::TurnCompleted { usage: completed } => {
1594                    usage = completed;
1595                    saw_terminal = true;
1596                    break;
1597                }
1598                ThreadEvent::TurnFailed { error } => {
1599                    return Err(ThreadRunError::TurnFailed {
1600                        message: error.message,
1601                    });
1602                }
1603                ThreadEvent::Error { message } => {
1604                    return Err(ThreadRunError::TurnFailed { message });
1605                }
1606                ThreadEvent::ThreadStarted { .. }
1607                | ThreadEvent::TurnStarted
1608                | ThreadEvent::ItemStarted { .. }
1609                | ThreadEvent::ItemUpdated { .. } => {}
1610            }
1611        }
1612
1613        if !saw_terminal {
1614            return Err(ThreadRunError::Client(ClientError::TransportClosed));
1615        }
1616
1617        Ok(Turn {
1618            items,
1619            final_response: final_answer.or(fallback_response).unwrap_or_default(),
1620            usage,
1621        })
1622    }
1623
1624    /// Runs one turn on this thread and returns only the final agent response text.
1625    pub async fn ask(
1626        &mut self,
1627        input: impl Into<Input>,
1628        turn_options: TurnOptions,
1629    ) -> Result<String, ThreadRunError> {
1630        let turn = self.run(input, turn_options).await?;
1631        Ok(turn.final_response)
1632    }
1633}
1634
1635async fn pump_turn_events(
1636    mut server_events: tokio::sync::broadcast::Receiver<ServerEvent>,
1637    tx: mpsc::Sender<Result<ThreadEvent, ClientError>>,
1638    thread_id: String,
1639    turn_id: String,
1640) {
1641    let mut latest_usage: Option<Usage> = None;
1642
1643    loop {
1644        let next = server_events.recv().await;
1645        let server_event = match next {
1646            Ok(event) => event,
1647            Err(tokio::sync::broadcast::error::RecvError::Closed) => {
1648                let _ = tx.send(Err(ClientError::TransportClosed)).await;
1649                break;
1650            }
1651            Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
1652                continue;
1653            }
1654        };
1655
1656        match server_event {
1657            ServerEvent::Notification(notification) => match notification {
1658                ServerNotification::ItemStarted(payload)
1659                    if matches_target_from_extra(&payload.extra, &thread_id, Some(&turn_id)) =>
1660                {
1661                    if tx
1662                        .send(Ok(ThreadEvent::ItemStarted {
1663                            item: parse_thread_item(payload.item),
1664                        }))
1665                        .await
1666                        .is_err()
1667                    {
1668                        break;
1669                    }
1670                }
1671                ServerNotification::ItemCompleted(payload)
1672                    if matches_target_from_extra(&payload.extra, &thread_id, Some(&turn_id)) =>
1673                {
1674                    if tx
1675                        .send(Ok(ThreadEvent::ItemCompleted {
1676                            item: parse_thread_item(payload.item),
1677                        }))
1678                        .await
1679                        .is_err()
1680                    {
1681                        break;
1682                    }
1683                }
1684                ServerNotification::ItemAgentMessageDelta(delta)
1685                    if matches_target_from_extra(&delta.extra, &thread_id, Some(&turn_id)) =>
1686                {
1687                    let text = delta.delta.or(delta.text).unwrap_or_default();
1688                    if text.is_empty() {
1689                        continue;
1690                    }
1691                    let item = ThreadItem::AgentMessage(AgentMessageItem {
1692                        id: delta.item_id.unwrap_or_default(),
1693                        text,
1694                        phase: None,
1695                    });
1696                    if tx
1697                        .send(Ok(ThreadEvent::ItemUpdated { item }))
1698                        .await
1699                        .is_err()
1700                    {
1701                        break;
1702                    }
1703                }
1704                ServerNotification::ItemPlanDelta(delta)
1705                    if matches_target_from_extra(&delta.extra, &thread_id, Some(&turn_id)) =>
1706                {
1707                    let text = delta.delta.or(delta.text).unwrap_or_default();
1708                    if text.is_empty() {
1709                        continue;
1710                    }
1711                    let item = ThreadItem::Plan(PlanItem {
1712                        id: delta.item_id.unwrap_or_default(),
1713                        text,
1714                    });
1715                    if tx
1716                        .send(Ok(ThreadEvent::ItemUpdated { item }))
1717                        .await
1718                        .is_err()
1719                    {
1720                        break;
1721                    }
1722                }
1723                ServerNotification::TurnCompleted(payload)
1724                    if payload.turn.id == turn_id
1725                        && matches_target_from_extra(
1726                            &payload.turn.extra,
1727                            &thread_id,
1728                            Some(&turn_id),
1729                        ) =>
1730                {
1731                    let status = payload.turn.status.unwrap_or_default().to_ascii_lowercase();
1732                    if status == "failed" {
1733                        let message = payload
1734                            .turn
1735                            .error
1736                            .map(|error| error.message)
1737                            .unwrap_or_else(|| "turn failed".to_string());
1738                        let _ = tx
1739                            .send(Ok(ThreadEvent::TurnFailed {
1740                                error: ThreadError { message },
1741                            }))
1742                            .await;
1743                        break;
1744                    }
1745
1746                    let usage = parse_usage_from_turn_extra(&payload.turn.extra)
1747                        .or_else(|| latest_usage.clone());
1748                    let _ = tx.send(Ok(ThreadEvent::TurnCompleted { usage })).await;
1749                    break;
1750                }
1751                ServerNotification::ThreadTokenUsageUpdated(payload)
1752                    if payload
1753                        .thread_id
1754                        .as_deref()
1755                        .is_none_or(|incoming| incoming == thread_id) =>
1756                {
1757                    if !matches_target_from_extra(&payload.extra, &thread_id, Some(&turn_id)) {
1758                        continue;
1759                    }
1760                    latest_usage = payload
1761                        .usage
1762                        .as_ref()
1763                        .and_then(parse_usage_from_value)
1764                        .or_else(|| {
1765                            payload
1766                                .extra
1767                                .get("tokenUsage")
1768                                .and_then(parse_usage_from_value)
1769                        });
1770                }
1771                ServerNotification::Error(payload)
1772                    if matches_target_from_extra(&payload.extra, &thread_id, Some(&turn_id)) =>
1773                {
1774                    let _ = tx
1775                        .send(Ok(ThreadEvent::Error {
1776                            message: payload.error.message,
1777                        }))
1778                        .await;
1779                }
1780                _ => {}
1781            },
1782            ServerEvent::TransportClosed => {
1783                let _ = tx.send(Err(ClientError::TransportClosed)).await;
1784                break;
1785            }
1786            ServerEvent::ServerRequest(_) => {}
1787        }
1788    }
1789}
1790
1791fn update_final_response_candidates(
1792    item: &ThreadItem,
1793    final_answer: &mut Option<String>,
1794    fallback_response: &mut Option<String>,
1795) {
1796    let ThreadItem::AgentMessage(agent_message) = item else {
1797        return;
1798    };
1799
1800    if agent_message.is_final_answer() {
1801        *final_answer = Some(agent_message.text.clone());
1802    } else {
1803        *fallback_response = Some(agent_message.text.clone());
1804    }
1805}
1806
1807fn select_latest_thread_id(
1808    threads: &[responses::ThreadSummary],
1809    working_directory: Option<&str>,
1810) -> Option<String> {
1811    let mut newest: Option<(i64, String)> = None;
1812    let mut fallback_id: Option<String> = None;
1813
1814    for thread in threads {
1815        if !thread_matches_working_directory(thread, working_directory) {
1816            continue;
1817        }
1818
1819        if fallback_id.is_none() {
1820            fallback_id = Some(thread.id.clone());
1821        }
1822
1823        if let Some(score) = thread_recency_score(thread) {
1824            match &newest {
1825                Some((best_score, _)) if score <= *best_score => {}
1826                _ => newest = Some((score, thread.id.clone())),
1827            }
1828        }
1829    }
1830
1831    newest.map(|(_, thread_id)| thread_id).or(fallback_id)
1832}
1833
1834fn thread_matches_working_directory(
1835    thread: &responses::ThreadSummary,
1836    working_directory: Option<&str>,
1837) -> bool {
1838    let Some(working_directory) = working_directory else {
1839        return true;
1840    };
1841
1842    thread.extra.get("cwd").and_then(Value::as_str) == Some(working_directory)
1843}
1844
1845fn thread_recency_score(thread: &responses::ThreadSummary) -> Option<i64> {
1846    parse_timestamp(thread.extra.get("updatedAt"))
1847        .or_else(|| parse_timestamp(thread.extra.get("createdAt")))
1848}
1849
1850fn parse_timestamp(value: Option<&Value>) -> Option<i64> {
1851    let value = value?;
1852    match value {
1853        Value::Number(number) => number
1854            .as_i64()
1855            .or_else(|| number.as_u64().and_then(|raw| i64::try_from(raw).ok())),
1856        Value::String(raw) => raw.parse::<i64>().ok(),
1857        _ => None,
1858    }
1859}
1860
1861fn build_thread_start_params(options: &ThreadOptions) -> requests::ThreadStartParams {
1862    let mut extra = Map::new();
1863    if let Some(skip) = options.skip_git_repo_check {
1864        extra.insert("skipGitRepoCheck".to_string(), Value::Bool(skip));
1865    }
1866    if let Some(mode) = options.web_search_mode {
1867        extra.insert(
1868            "webSearchMode".to_string(),
1869            Value::String(mode.as_str().to_string()),
1870        );
1871    }
1872    if let Some(enabled) = options.web_search_enabled {
1873        extra.insert("webSearchEnabled".to_string(), Value::Bool(enabled));
1874    }
1875    if let Some(network) = options.network_access_enabled {
1876        extra.insert("networkAccessEnabled".to_string(), Value::Bool(network));
1877    }
1878    if let Some(additional) = &options.additional_directories {
1879        extra.insert(
1880            "additionalDirectories".to_string(),
1881            Value::Array(
1882                additional
1883                    .iter()
1884                    .map(|entry| Value::String(entry.clone()))
1885                    .collect(),
1886            ),
1887        );
1888    }
1889    if let Some(config) = &options.config {
1890        extra.insert("config".to_string(), Value::Object(config.clone()));
1891    }
1892    if let Some(dynamic_tools) = &options.dynamic_tools {
1893        extra.insert(
1894            "dynamicTools".to_string(),
1895            Value::Array(
1896                dynamic_tools
1897                    .iter()
1898                    .map(DynamicToolSpec::as_value)
1899                    .collect(),
1900            ),
1901        );
1902    }
1903    if let Some(enabled) = options.experimental_raw_events {
1904        extra.insert("experimentalRawEvents".to_string(), Value::Bool(enabled));
1905    }
1906    if let Some(enabled) = options.persist_extended_history {
1907        extra.insert("persistExtendedHistory".to_string(), Value::Bool(enabled));
1908    }
1909
1910    requests::ThreadStartParams {
1911        model: options.model.clone(),
1912        model_provider: options.model_provider.clone(),
1913        cwd: options.working_directory.clone(),
1914        approval_policy: options
1915            .approval_policy
1916            .map(|mode| mode.as_str().to_string()),
1917        sandbox: options.sandbox_mode.map(|mode| mode.as_str().to_string()),
1918        sandbox_policy: options.sandbox_policy.clone(),
1919        effort: options
1920            .model_reasoning_effort
1921            .map(|effort| effort.as_str().to_string()),
1922        summary: options
1923            .model_reasoning_summary
1924            .map(|summary| summary.as_str().to_string()),
1925        personality: options.personality.map(|value| value.as_str().to_string()),
1926        ephemeral: options.ephemeral,
1927        base_instructions: options.base_instructions.clone(),
1928        developer_instructions: options.developer_instructions.clone(),
1929        extra,
1930    }
1931}
1932
1933fn build_thread_resume_params(
1934    thread_id: &str,
1935    options: &ThreadOptions,
1936) -> requests::ThreadResumeParams {
1937    let mut extra = Map::new();
1938    if let Some(skip) = options.skip_git_repo_check {
1939        extra.insert("skipGitRepoCheck".to_string(), Value::Bool(skip));
1940    }
1941    if let Some(mode) = options.web_search_mode {
1942        extra.insert(
1943            "webSearchMode".to_string(),
1944            Value::String(mode.as_str().to_string()),
1945        );
1946    }
1947    if let Some(enabled) = options.web_search_enabled {
1948        extra.insert("webSearchEnabled".to_string(), Value::Bool(enabled));
1949    }
1950    if let Some(network) = options.network_access_enabled {
1951        extra.insert("networkAccessEnabled".to_string(), Value::Bool(network));
1952    }
1953    if let Some(additional) = &options.additional_directories {
1954        extra.insert(
1955            "additionalDirectories".to_string(),
1956            Value::Array(
1957                additional
1958                    .iter()
1959                    .map(|entry| Value::String(entry.clone()))
1960                    .collect(),
1961            ),
1962        );
1963    }
1964    if let Some(policy) = &options.sandbox_policy {
1965        extra.insert("sandboxPolicy".to_string(), policy.clone());
1966    }
1967    if let Some(effort) = options.model_reasoning_effort {
1968        extra.insert(
1969            "effort".to_string(),
1970            Value::String(effort.as_str().to_string()),
1971        );
1972    }
1973    if let Some(summary) = options.model_reasoning_summary {
1974        extra.insert(
1975            "summary".to_string(),
1976            Value::String(summary.as_str().to_string()),
1977        );
1978    }
1979    if let Some(ephemeral) = options.ephemeral {
1980        extra.insert("ephemeral".to_string(), Value::Bool(ephemeral));
1981    }
1982    if let Some(collaboration_mode) = &options.collaboration_mode {
1983        extra.insert(
1984            "collaborationMode".to_string(),
1985            collaboration_mode.as_value(),
1986        );
1987    }
1988    if let Some(dynamic_tools) = &options.dynamic_tools {
1989        extra.insert(
1990            "dynamicTools".to_string(),
1991            Value::Array(
1992                dynamic_tools
1993                    .iter()
1994                    .map(DynamicToolSpec::as_value)
1995                    .collect(),
1996            ),
1997        );
1998    }
1999    if let Some(enabled) = options.experimental_raw_events {
2000        extra.insert("experimentalRawEvents".to_string(), Value::Bool(enabled));
2001    }
2002
2003    requests::ThreadResumeParams {
2004        thread_id: thread_id.to_string(),
2005        history: None,
2006        path: None,
2007        model: options.model.clone(),
2008        model_provider: options.model_provider.clone(),
2009        cwd: options.working_directory.clone(),
2010        approval_policy: options
2011            .approval_policy
2012            .map(|mode| mode.as_str().to_string()),
2013        sandbox: options.sandbox_mode.map(|mode| mode.as_str().to_string()),
2014        config: options.config.clone(),
2015        base_instructions: options.base_instructions.clone(),
2016        developer_instructions: options.developer_instructions.clone(),
2017        personality: options.personality.map(|value| value.as_str().to_string()),
2018        persist_extended_history: options.persist_extended_history,
2019        extra,
2020    }
2021}
2022
2023fn build_turn_start_params(
2024    thread_id: &str,
2025    input: Input,
2026    options: &ThreadOptions,
2027    turn_options: &TurnOptions,
2028) -> requests::TurnStartParams {
2029    let mut extra = Map::new();
2030    if let Some(skip) = turn_options
2031        .skip_git_repo_check
2032        .or(options.skip_git_repo_check)
2033    {
2034        extra.insert("skipGitRepoCheck".to_string(), Value::Bool(skip));
2035    }
2036    if let Some(mode) = turn_options.web_search_mode.or(options.web_search_mode) {
2037        extra.insert(
2038            "webSearchMode".to_string(),
2039            Value::String(mode.as_str().to_string()),
2040        );
2041    }
2042    if let Some(enabled) = turn_options
2043        .web_search_enabled
2044        .or(options.web_search_enabled)
2045    {
2046        extra.insert("webSearchEnabled".to_string(), Value::Bool(enabled));
2047    }
2048    if let Some(network) = turn_options
2049        .network_access_enabled
2050        .or(options.network_access_enabled)
2051    {
2052        extra.insert("networkAccessEnabled".to_string(), Value::Bool(network));
2053    }
2054    if let Some(additional) = turn_options
2055        .additional_directories
2056        .as_ref()
2057        .or(options.additional_directories.as_ref())
2058    {
2059        extra.insert(
2060            "additionalDirectories".to_string(),
2061            Value::Array(
2062                additional
2063                    .iter()
2064                    .map(|entry| Value::String(entry.clone()))
2065                    .collect(),
2066            ),
2067        );
2068    }
2069    if let Some(collaboration_mode) = turn_options
2070        .collaboration_mode
2071        .as_ref()
2072        .or(options.collaboration_mode.as_ref())
2073    {
2074        extra.insert(
2075            "collaborationMode".to_string(),
2076            collaboration_mode.as_value(),
2077        );
2078    }
2079    if let Some(extra_overrides) = &turn_options.extra {
2080        for (key, value) in extra_overrides {
2081            extra.insert(key.clone(), value.clone());
2082        }
2083    }
2084
2085    requests::TurnStartParams {
2086        thread_id: thread_id.to_string(),
2087        input: normalize_input(input),
2088        cwd: turn_options
2089            .working_directory
2090            .clone()
2091            .or_else(|| options.working_directory.clone()),
2092        model: turn_options.model.clone().or_else(|| options.model.clone()),
2093        model_provider: turn_options
2094            .model_provider
2095            .clone()
2096            .or_else(|| options.model_provider.clone()),
2097        effort: turn_options
2098            .model_reasoning_effort
2099            .or(options.model_reasoning_effort)
2100            .map(|effort| effort.as_str().to_string()),
2101        summary: turn_options
2102            .model_reasoning_summary
2103            .or(options.model_reasoning_summary)
2104            .map(|summary| summary.as_str().to_string()),
2105        personality: turn_options
2106            .personality
2107            .or(options.personality)
2108            .map(|value| value.as_str().to_string()),
2109        output_schema: turn_options.output_schema.clone(),
2110        approval_policy: turn_options
2111            .approval_policy
2112            .or(options.approval_policy)
2113            .map(|mode| mode.as_str().to_string()),
2114        sandbox_policy: turn_options
2115            .sandbox_policy
2116            .clone()
2117            .or_else(|| options.sandbox_policy.clone()),
2118        collaboration_mode: None,
2119        extra,
2120    }
2121}
2122
2123fn normalize_input(input: Input) -> Vec<requests::TurnInputItem> {
2124    match input {
2125        Input::Text(text) => vec![requests::TurnInputItem::Text { text }],
2126        Input::Items(items) => {
2127            let mut text_parts = Vec::new();
2128            let mut normalized = Vec::new();
2129
2130            for item in items {
2131                match item {
2132                    UserInput::Text { text } => text_parts.push(text),
2133                    UserInput::LocalImage { path } => {
2134                        normalized.push(requests::TurnInputItem::LocalImage { path });
2135                    }
2136                }
2137            }
2138
2139            if !text_parts.is_empty() {
2140                normalized.insert(
2141                    0,
2142                    requests::TurnInputItem::Text {
2143                        text: text_parts.join("\n\n"),
2144                    },
2145                );
2146            }
2147
2148            normalized
2149        }
2150    }
2151}
2152
2153fn matches_target_from_extra(
2154    extra: &Map<String, Value>,
2155    thread_id: &str,
2156    turn_id: Option<&str>,
2157) -> bool {
2158    let thread_matches = extra
2159        .get("threadId")
2160        .and_then(Value::as_str)
2161        .map(|incoming| incoming == thread_id)
2162        .unwrap_or(true);
2163
2164    let turn_matches = match turn_id {
2165        Some(target_turn_id) => extra
2166            .get("turnId")
2167            .and_then(Value::as_str)
2168            .map(|incoming| incoming == target_turn_id)
2169            .unwrap_or(true),
2170        None => true,
2171    };
2172
2173    thread_matches && turn_matches
2174}
2175
2176fn parse_usage_from_turn_extra(extra: &Map<String, Value>) -> Option<Usage> {
2177    extra
2178        .get("usage")
2179        .and_then(parse_usage_from_value)
2180        .or_else(|| extra.get("tokenUsage").and_then(parse_usage_from_value))
2181}
2182
2183fn parse_usage_from_value(value: &Value) -> Option<Usage> {
2184    let object = value.as_object()?;
2185    if let Some(last) = object.get("last") {
2186        return parse_usage_from_value(last);
2187    }
2188
2189    let input_tokens = object.get("inputTokens").and_then(Value::as_i64)?;
2190    let cached_input_tokens = object
2191        .get("cachedInputTokens")
2192        .and_then(Value::as_i64)
2193        .unwrap_or(0);
2194    let output_tokens = object.get("outputTokens").and_then(Value::as_i64)?;
2195
2196    Some(Usage {
2197        input_tokens,
2198        cached_input_tokens,
2199        output_tokens,
2200    })
2201}
2202
2203fn parse_thread_item(item: Value) -> ThreadItem {
2204    let object = match item.as_object() {
2205        Some(object) => object,
2206        None => {
2207            return ThreadItem::Unknown(UnknownItem {
2208                id: None,
2209                item_type: None,
2210                raw: item,
2211            });
2212        }
2213    };
2214
2215    let item_type = object
2216        .get("type")
2217        .and_then(Value::as_str)
2218        .map(|value| value.to_string());
2219    let id = object
2220        .get("id")
2221        .and_then(Value::as_str)
2222        .map(|value| value.to_string());
2223
2224    match item_type.as_deref() {
2225        Some("agentMessage") => ThreadItem::AgentMessage(AgentMessageItem {
2226            id: id.unwrap_or_default(),
2227            text: object
2228                .get("text")
2229                .and_then(Value::as_str)
2230                .unwrap_or_default()
2231                .to_string(),
2232            phase: object
2233                .get("phase")
2234                .and_then(Value::as_str)
2235                .map(parse_agent_message_phase),
2236        }),
2237        Some("userMessage") => ThreadItem::UserMessage(UserMessageItem {
2238            id: id.unwrap_or_default(),
2239            content: object
2240                .get("content")
2241                .and_then(Value::as_array)
2242                .map(|entries| {
2243                    entries
2244                        .iter()
2245                        .map(parse_user_message_content_item)
2246                        .collect()
2247                })
2248                .unwrap_or_default(),
2249        }),
2250        Some("plan") => ThreadItem::Plan(PlanItem {
2251            id: id.unwrap_or_default(),
2252            text: object
2253                .get("text")
2254                .and_then(Value::as_str)
2255                .unwrap_or_default()
2256                .to_string(),
2257        }),
2258        Some("reasoning") => ThreadItem::Reasoning(ReasoningItem {
2259            id: id.unwrap_or_default(),
2260            text: parse_reasoning_text(object),
2261        }),
2262        Some("commandExecution") => ThreadItem::CommandExecution(CommandExecutionItem {
2263            id: id.unwrap_or_default(),
2264            command: object
2265                .get("command")
2266                .and_then(Value::as_str)
2267                .unwrap_or_default()
2268                .to_string(),
2269            aggregated_output: object
2270                .get("aggregatedOutput")
2271                .and_then(Value::as_str)
2272                .unwrap_or_default()
2273                .to_string(),
2274            exit_code: object
2275                .get("exitCode")
2276                .and_then(Value::as_i64)
2277                .map(|value| value as i32),
2278            status: parse_command_execution_status(
2279                object
2280                    .get("status")
2281                    .and_then(Value::as_str)
2282                    .unwrap_or_default(),
2283            ),
2284        }),
2285        Some("fileChange") => ThreadItem::FileChange(FileChangeItem {
2286            id: id.unwrap_or_default(),
2287            changes: object
2288                .get("changes")
2289                .and_then(Value::as_array)
2290                .map(|changes| {
2291                    changes
2292                        .iter()
2293                        .filter_map(parse_file_update_change)
2294                        .collect::<Vec<_>>()
2295                })
2296                .unwrap_or_default(),
2297            status: parse_patch_apply_status(
2298                object
2299                    .get("status")
2300                    .and_then(Value::as_str)
2301                    .unwrap_or_default(),
2302            ),
2303        }),
2304        Some("mcpToolCall") => {
2305            let error = object
2306                .get("error")
2307                .and_then(Value::as_object)
2308                .and_then(|error| error.get("message"))
2309                .and_then(Value::as_str)
2310                .map(|message| ThreadError {
2311                    message: message.to_string(),
2312                });
2313
2314            ThreadItem::McpToolCall(McpToolCallItem {
2315                id: id.unwrap_or_default(),
2316                server: object
2317                    .get("server")
2318                    .and_then(Value::as_str)
2319                    .unwrap_or_default()
2320                    .to_string(),
2321                tool: object
2322                    .get("tool")
2323                    .and_then(Value::as_str)
2324                    .unwrap_or_default()
2325                    .to_string(),
2326                arguments: object.get("arguments").cloned().unwrap_or(Value::Null),
2327                result: object.get("result").cloned(),
2328                error,
2329                status: parse_mcp_tool_call_status(
2330                    object
2331                        .get("status")
2332                        .and_then(Value::as_str)
2333                        .unwrap_or_default(),
2334                ),
2335            })
2336        }
2337        Some("dynamicToolCall") => ThreadItem::DynamicToolCall(DynamicToolCallItem {
2338            id: id.unwrap_or_default(),
2339            tool: object
2340                .get("tool")
2341                .and_then(Value::as_str)
2342                .unwrap_or_default()
2343                .to_string(),
2344            arguments: object.get("arguments").cloned().unwrap_or(Value::Null),
2345            status: object
2346                .get("status")
2347                .and_then(Value::as_str)
2348                .unwrap_or_default()
2349                .to_string(),
2350            content_items: object
2351                .get("contentItems")
2352                .and_then(Value::as_array)
2353                .cloned()
2354                .unwrap_or_default(),
2355            success: object.get("success").and_then(Value::as_bool),
2356            duration_ms: object.get("durationMs").and_then(Value::as_u64),
2357        }),
2358        Some("collabToolCall") => ThreadItem::CollabToolCall(CollabToolCallItem {
2359            id: id.unwrap_or_default(),
2360            tool: object
2361                .get("tool")
2362                .and_then(Value::as_str)
2363                .unwrap_or_default()
2364                .to_string(),
2365            status: object
2366                .get("status")
2367                .and_then(Value::as_str)
2368                .unwrap_or_default()
2369                .to_string(),
2370            sender_thread_id: object
2371                .get("senderThreadId")
2372                .and_then(Value::as_str)
2373                .unwrap_or_default()
2374                .to_string(),
2375            receiver_thread_id: object
2376                .get("receiverThreadId")
2377                .and_then(Value::as_str)
2378                .map(str::to_string),
2379            new_thread_id: object
2380                .get("newThreadId")
2381                .and_then(Value::as_str)
2382                .map(str::to_string),
2383            prompt: object
2384                .get("prompt")
2385                .and_then(Value::as_str)
2386                .map(str::to_string),
2387            agent_status: object
2388                .get("agentStatus")
2389                .and_then(Value::as_str)
2390                .map(str::to_string),
2391        }),
2392        Some("webSearch") => ThreadItem::WebSearch(WebSearchItem {
2393            id: id.unwrap_or_default(),
2394            query: object
2395                .get("query")
2396                .and_then(Value::as_str)
2397                .unwrap_or_default()
2398                .to_string(),
2399        }),
2400        Some("imageView") => ThreadItem::ImageView(ImageViewItem {
2401            id: id.unwrap_or_default(),
2402            path: object
2403                .get("path")
2404                .and_then(Value::as_str)
2405                .unwrap_or_default()
2406                .to_string(),
2407        }),
2408        Some("enteredReviewMode") => ThreadItem::EnteredReviewMode(ReviewModeItem {
2409            id: id.unwrap_or_default(),
2410            review: object
2411                .get("review")
2412                .and_then(Value::as_str)
2413                .unwrap_or_default()
2414                .to_string(),
2415        }),
2416        Some("exitedReviewMode") => ThreadItem::ExitedReviewMode(ReviewModeItem {
2417            id: id.unwrap_or_default(),
2418            review: object
2419                .get("review")
2420                .and_then(Value::as_str)
2421                .unwrap_or_default()
2422                .to_string(),
2423        }),
2424        Some("contextCompaction") => ThreadItem::ContextCompaction(ContextCompactionItem {
2425            id: id.unwrap_or_default(),
2426        }),
2427        Some("todoList") => ThreadItem::TodoList(TodoListItem {
2428            id: id.unwrap_or_default(),
2429            items: object
2430                .get("items")
2431                .and_then(Value::as_array)
2432                .map(|items| items.iter().filter_map(parse_todo_item).collect())
2433                .unwrap_or_default(),
2434        }),
2435        Some("error") => ThreadItem::Error(ErrorItem {
2436            id: id.unwrap_or_default(),
2437            message: object
2438                .get("message")
2439                .and_then(Value::as_str)
2440                .unwrap_or_default()
2441                .to_string(),
2442        }),
2443        _ => ThreadItem::Unknown(UnknownItem {
2444            id,
2445            item_type,
2446            raw: item,
2447        }),
2448    }
2449}
2450
2451fn parse_agent_message_phase(value: &str) -> AgentMessagePhase {
2452    match value {
2453        "commentary" => AgentMessagePhase::Commentary,
2454        "final_answer" => AgentMessagePhase::FinalAnswer,
2455        _ => AgentMessagePhase::Unknown,
2456    }
2457}
2458
2459fn parse_user_message_content_item(value: &Value) -> UserMessageContentItem {
2460    let Some(object) = value.as_object() else {
2461        return UserMessageContentItem::Unknown(value.clone());
2462    };
2463
2464    match object.get("type").and_then(Value::as_str) {
2465        Some("text") => UserMessageContentItem::Text {
2466            text: object
2467                .get("text")
2468                .and_then(Value::as_str)
2469                .unwrap_or_default()
2470                .to_string(),
2471        },
2472        Some("image") => UserMessageContentItem::Image {
2473            url: object
2474                .get("url")
2475                .and_then(Value::as_str)
2476                .unwrap_or_default()
2477                .to_string(),
2478        },
2479        Some("localImage") => UserMessageContentItem::LocalImage {
2480            path: object
2481                .get("path")
2482                .and_then(Value::as_str)
2483                .unwrap_or_default()
2484                .to_string(),
2485        },
2486        _ => UserMessageContentItem::Unknown(value.clone()),
2487    }
2488}
2489
2490fn parse_reasoning_text(object: &Map<String, Value>) -> String {
2491    if let Some(text) = object.get("text").and_then(Value::as_str) {
2492        return text.to_string();
2493    }
2494
2495    let summary = object
2496        .get("summary")
2497        .and_then(Value::as_array)
2498        .map(|entries| {
2499            entries
2500                .iter()
2501                .filter_map(Value::as_str)
2502                .collect::<Vec<_>>()
2503                .join("\n")
2504        })
2505        .unwrap_or_default();
2506
2507    let content = object
2508        .get("content")
2509        .and_then(Value::as_array)
2510        .map(|entries| {
2511            entries
2512                .iter()
2513                .filter_map(Value::as_str)
2514                .collect::<Vec<_>>()
2515                .join("\n")
2516        })
2517        .unwrap_or_default();
2518
2519    if summary.is_empty() {
2520        content
2521    } else if content.is_empty() {
2522        summary
2523    } else {
2524        format!("{summary}\n{content}")
2525    }
2526}
2527
2528fn parse_file_update_change(change: &Value) -> Option<FileUpdateChange> {
2529    let object = change.as_object()?;
2530    let kind = match object.get("kind") {
2531        Some(Value::String(kind)) => parse_patch_change_kind(kind),
2532        Some(Value::Object(kind_object)) => kind_object
2533            .get("type")
2534            .and_then(Value::as_str)
2535            .map(parse_patch_change_kind)
2536            .unwrap_or(PatchChangeKind::Unknown),
2537        _ => PatchChangeKind::Unknown,
2538    };
2539
2540    Some(FileUpdateChange {
2541        path: object
2542            .get("path")
2543            .and_then(Value::as_str)
2544            .unwrap_or_default()
2545            .to_string(),
2546        kind,
2547    })
2548}
2549
2550fn parse_todo_item(value: &Value) -> Option<TodoItem> {
2551    let object = value.as_object()?;
2552    Some(TodoItem {
2553        text: object
2554            .get("text")
2555            .and_then(Value::as_str)
2556            .unwrap_or_default()
2557            .to_string(),
2558        completed: object
2559            .get("completed")
2560            .and_then(Value::as_bool)
2561            .unwrap_or(false),
2562    })
2563}
2564
2565fn parse_command_execution_status(status: &str) -> CommandExecutionStatus {
2566    match status {
2567        "inProgress" | "in_progress" => CommandExecutionStatus::InProgress,
2568        "completed" => CommandExecutionStatus::Completed,
2569        "failed" => CommandExecutionStatus::Failed,
2570        "declined" => CommandExecutionStatus::Declined,
2571        _ => CommandExecutionStatus::Unknown,
2572    }
2573}
2574
2575fn parse_patch_change_kind(kind: &str) -> PatchChangeKind {
2576    match kind {
2577        "add" => PatchChangeKind::Add,
2578        "delete" => PatchChangeKind::Delete,
2579        "update" => PatchChangeKind::Update,
2580        _ => PatchChangeKind::Unknown,
2581    }
2582}
2583
2584fn parse_patch_apply_status(status: &str) -> PatchApplyStatus {
2585    match status {
2586        "inProgress" | "in_progress" => PatchApplyStatus::InProgress,
2587        "completed" => PatchApplyStatus::Completed,
2588        "failed" => PatchApplyStatus::Failed,
2589        "declined" => PatchApplyStatus::Declined,
2590        _ => PatchApplyStatus::Unknown,
2591    }
2592}
2593
2594fn parse_mcp_tool_call_status(status: &str) -> McpToolCallStatus {
2595    match status {
2596        "inProgress" | "in_progress" => McpToolCallStatus::InProgress,
2597        "completed" => McpToolCallStatus::Completed,
2598        "failed" => McpToolCallStatus::Failed,
2599        _ => McpToolCallStatus::Unknown,
2600    }
2601}
2602
2603#[cfg(test)]
2604mod tests {
2605    use super::*;
2606    use schemars::JsonSchema;
2607    use serde::{Deserialize, Serialize};
2608    use serde_json::json;
2609
2610    #[derive(
2611        Debug,
2612        Clone,
2613        PartialEq,
2614        Eq,
2615        Serialize,
2616        Deserialize,
2617        JsonSchema,
2618        codex_app_server_sdk::OpenAiSerializable,
2619    )]
2620    struct StructuredReply {
2621        answer: String,
2622    }
2623
2624    fn thread_summary(
2625        id: &str,
2626        cwd: Option<&str>,
2627        updated_at: Option<i64>,
2628        created_at: Option<i64>,
2629    ) -> responses::ThreadSummary {
2630        let mut extra = Map::new();
2631        if let Some(cwd) = cwd {
2632            extra.insert("cwd".to_string(), Value::String(cwd.to_string()));
2633        }
2634        if let Some(updated_at) = updated_at {
2635            extra.insert("updatedAt".to_string(), Value::from(updated_at));
2636        }
2637        if let Some(created_at) = created_at {
2638            extra.insert("createdAt".to_string(), Value::from(created_at));
2639        }
2640
2641        responses::ThreadSummary {
2642            id: id.to_string(),
2643            extra,
2644            ..Default::default()
2645        }
2646    }
2647
2648    #[test]
2649    fn normalize_input_combines_text_and_images() {
2650        let normalized = normalize_input(Input::Items(vec![
2651            UserInput::Text {
2652                text: "first".to_string(),
2653            },
2654            UserInput::Text {
2655                text: "second".to_string(),
2656            },
2657            UserInput::LocalImage {
2658                path: "/tmp/one.png".to_string(),
2659            },
2660            UserInput::LocalImage {
2661                path: "/tmp/two.png".to_string(),
2662            },
2663        ]));
2664
2665        assert_eq!(normalized.len(), 3);
2666        match &normalized[0] {
2667            requests::TurnInputItem::Text { text } => assert_eq!(text, "first\n\nsecond"),
2668            other => panic!("expected text input item, got {other:?}"),
2669        }
2670        match &normalized[1] {
2671            requests::TurnInputItem::LocalImage { path } => assert_eq!(path, "/tmp/one.png"),
2672            other => panic!("expected local image input item, got {other:?}"),
2673        }
2674        match &normalized[2] {
2675            requests::TurnInputItem::LocalImage { path } => assert_eq!(path, "/tmp/two.png"),
2676            other => panic!("expected local image input item, got {other:?}"),
2677        }
2678    }
2679
2680    #[test]
2681    fn parse_agent_message_item() {
2682        let item = parse_thread_item(json!({
2683            "id": "item_1",
2684            "type": "agentMessage",
2685            "text": "hello",
2686            "phase": "final_answer"
2687        }));
2688
2689        assert_eq!(
2690            item,
2691            ThreadItem::AgentMessage(AgentMessageItem {
2692                id: "item_1".to_string(),
2693                text: "hello".to_string(),
2694                phase: Some(AgentMessagePhase::FinalAnswer),
2695            })
2696        );
2697    }
2698
2699    #[test]
2700    fn parse_missing_documented_thread_item_variants() {
2701        let cases = vec![
2702            (
2703                json!({
2704                    "id": "user_1",
2705                    "type": "userMessage",
2706                    "content": [
2707                        { "type": "text", "text": "hello" },
2708                        { "type": "localImage", "path": "/tmp/example.png" }
2709                    ]
2710                }),
2711                ThreadItem::UserMessage(UserMessageItem {
2712                    id: "user_1".to_string(),
2713                    content: vec![
2714                        UserMessageContentItem::Text {
2715                            text: "hello".to_string(),
2716                        },
2717                        UserMessageContentItem::LocalImage {
2718                            path: "/tmp/example.png".to_string(),
2719                        },
2720                    ],
2721                }),
2722            ),
2723            (
2724                json!({
2725                    "id": "plan_1",
2726                    "type": "plan",
2727                    "text": "1. inspect\n2. patch"
2728                }),
2729                ThreadItem::Plan(PlanItem {
2730                    id: "plan_1".to_string(),
2731                    text: "1. inspect\n2. patch".to_string(),
2732                }),
2733            ),
2734            (
2735                json!({
2736                    "id": "tool_1",
2737                    "type": "dynamicToolCall",
2738                    "tool": "tool/search",
2739                    "arguments": { "q": "rust" },
2740                    "status": "completed",
2741                    "contentItems": [{ "type": "text", "text": "done" }],
2742                    "success": true,
2743                    "durationMs": 12
2744                }),
2745                ThreadItem::DynamicToolCall(DynamicToolCallItem {
2746                    id: "tool_1".to_string(),
2747                    tool: "tool/search".to_string(),
2748                    arguments: json!({ "q": "rust" }),
2749                    status: "completed".to_string(),
2750                    content_items: vec![json!({ "type": "text", "text": "done" })],
2751                    success: Some(true),
2752                    duration_ms: Some(12),
2753                }),
2754            ),
2755            (
2756                json!({
2757                    "id": "collab_1",
2758                    "type": "collabToolCall",
2759                    "tool": "delegate",
2760                    "status": "completed",
2761                    "senderThreadId": "thr_a",
2762                    "receiverThreadId": "thr_b",
2763                    "newThreadId": "thr_c",
2764                    "prompt": "review this",
2765                    "agentStatus": "idle"
2766                }),
2767                ThreadItem::CollabToolCall(CollabToolCallItem {
2768                    id: "collab_1".to_string(),
2769                    tool: "delegate".to_string(),
2770                    status: "completed".to_string(),
2771                    sender_thread_id: "thr_a".to_string(),
2772                    receiver_thread_id: Some("thr_b".to_string()),
2773                    new_thread_id: Some("thr_c".to_string()),
2774                    prompt: Some("review this".to_string()),
2775                    agent_status: Some("idle".to_string()),
2776                }),
2777            ),
2778            (
2779                json!({
2780                    "id": "image_1",
2781                    "type": "imageView",
2782                    "path": "/tmp/example.jpg"
2783                }),
2784                ThreadItem::ImageView(ImageViewItem {
2785                    id: "image_1".to_string(),
2786                    path: "/tmp/example.jpg".to_string(),
2787                }),
2788            ),
2789            (
2790                json!({
2791                    "id": "review_1",
2792                    "type": "enteredReviewMode",
2793                    "review": "current changes"
2794                }),
2795                ThreadItem::EnteredReviewMode(ReviewModeItem {
2796                    id: "review_1".to_string(),
2797                    review: "current changes".to_string(),
2798                }),
2799            ),
2800            (
2801                json!({
2802                    "id": "review_2",
2803                    "type": "exitedReviewMode",
2804                    "review": "looks good"
2805                }),
2806                ThreadItem::ExitedReviewMode(ReviewModeItem {
2807                    id: "review_2".to_string(),
2808                    review: "looks good".to_string(),
2809                }),
2810            ),
2811            (
2812                json!({
2813                    "id": "compact_1",
2814                    "type": "contextCompaction"
2815                }),
2816                ThreadItem::ContextCompaction(ContextCompactionItem {
2817                    id: "compact_1".to_string(),
2818                }),
2819            ),
2820        ];
2821
2822        for (raw, expected) in cases {
2823            assert_eq!(parse_thread_item(raw), expected);
2824        }
2825    }
2826
2827    #[test]
2828    fn final_response_prefers_final_answer_phase() {
2829        let mut final_answer = None;
2830        let mut fallback_response = None;
2831
2832        update_final_response_candidates(
2833            &ThreadItem::AgentMessage(AgentMessageItem {
2834                id: "msg_1".to_string(),
2835                text: "thinking".to_string(),
2836                phase: Some(AgentMessagePhase::Commentary),
2837            }),
2838            &mut final_answer,
2839            &mut fallback_response,
2840        );
2841        update_final_response_candidates(
2842            &ThreadItem::AgentMessage(AgentMessageItem {
2843                id: "msg_2".to_string(),
2844                text: "final".to_string(),
2845                phase: Some(AgentMessagePhase::FinalAnswer),
2846            }),
2847            &mut final_answer,
2848            &mut fallback_response,
2849        );
2850
2851        assert_eq!(fallback_response.as_deref(), Some("thinking"));
2852        assert_eq!(final_answer.as_deref(), Some("final"));
2853        assert_eq!(
2854            final_answer.or(fallback_response),
2855            Some("final".to_string())
2856        );
2857    }
2858
2859    #[test]
2860    fn parse_usage_from_token_usage_payload() {
2861        let usage = parse_usage_from_value(&json!({
2862            "last": {
2863                "inputTokens": 10,
2864                "cachedInputTokens": 2,
2865                "outputTokens": 7
2866            }
2867        }));
2868
2869        assert_eq!(
2870            usage,
2871            Some(Usage {
2872                input_tokens: 10,
2873                cached_input_tokens: 2,
2874                output_tokens: 7,
2875            })
2876        );
2877    }
2878
2879    #[test]
2880    fn parse_unknown_item_preserves_payload() {
2881        let raw = json!({
2882            "id": "x",
2883            "type": "newItemType",
2884            "payload": {"a": 1}
2885        });
2886        let parsed = parse_thread_item(raw.clone());
2887
2888        match parsed {
2889            ThreadItem::Unknown(unknown) => {
2890                assert_eq!(unknown.id.as_deref(), Some("x"));
2891                assert_eq!(unknown.item_type.as_deref(), Some("newItemType"));
2892                assert_eq!(unknown.raw, raw);
2893            }
2894            other => panic!("expected unknown item, got {other:?}"),
2895        }
2896    }
2897
2898    #[test]
2899    fn select_latest_thread_id_prefers_newest_matching_working_directory() {
2900        let threads = vec![
2901            thread_summary("thread_other", Some("/tmp/other"), Some(300), None),
2902            thread_summary("thread_old", Some("/tmp/workspace"), None, Some(100)),
2903            thread_summary("thread_new", Some("/tmp/workspace"), Some(200), None),
2904        ];
2905
2906        assert_eq!(
2907            select_latest_thread_id(&threads, Some("/tmp/workspace")),
2908            Some("thread_new".to_string())
2909        );
2910    }
2911
2912    #[test]
2913    fn select_latest_thread_id_falls_back_to_first_matching_thread_without_timestamps() {
2914        let threads = vec![
2915            thread_summary("thread_other", Some("/tmp/other"), None, None),
2916            thread_summary("thread_match", Some("/tmp/workspace"), None, None),
2917            thread_summary("thread_match_two", Some("/tmp/workspace"), None, None),
2918        ];
2919
2920        assert_eq!(
2921            select_latest_thread_id(&threads, Some("/tmp/workspace")),
2922            Some("thread_match".to_string())
2923        );
2924    }
2925
2926    #[test]
2927    fn thread_options_builder_maps_extended_protocol_fields() {
2928        let collaboration_mode = CollaborationMode::new(
2929            CollaborationModeKind::Default,
2930            CollaborationModeSettings::new("gpt-5.2-codex")
2931                .with_reasoning_effort(ModelReasoningEffort::High),
2932        );
2933
2934        let options = ThreadOptions::builder()
2935            .model("gpt-5.2-codex")
2936            .model_provider("mock_provider")
2937            .sandbox_mode(SandboxMode::WorkspaceWrite)
2938            .sandbox_policy(json!({"type": "dangerFullAccess"}))
2939            .working_directory("/tmp/workspace")
2940            .skip_git_repo_check(true)
2941            .model_reasoning_effort(ModelReasoningEffort::None)
2942            .model_reasoning_summary(ModelReasoningSummary::Auto)
2943            .network_access_enabled(true)
2944            .web_search_mode(WebSearchMode::Live)
2945            .web_search_enabled(false)
2946            .approval_policy(ApprovalMode::OnRequest)
2947            .add_directory("/tmp/one")
2948            .add_directory("/tmp/two")
2949            .personality(Personality::Pragmatic)
2950            .base_instructions("base instructions")
2951            .developer_instructions("developer instructions")
2952            .ephemeral(true)
2953            .insert_config("sandbox_workspace_write.network_access", Value::Bool(true))
2954            .dynamic_tools(vec![DynamicToolSpec::new(
2955                "demo_tool",
2956                "Demo dynamic tool",
2957                json!({"type": "object"}),
2958            )])
2959            .experimental_raw_events(true)
2960            .persist_extended_history(true)
2961            .collaboration_mode(collaboration_mode)
2962            .build();
2963
2964        let thread_params = build_thread_start_params(&options);
2965        assert_eq!(thread_params.model.as_deref(), Some("gpt-5.2-codex"));
2966        assert_eq!(
2967            thread_params.model_provider.as_deref(),
2968            Some("mock_provider")
2969        );
2970        assert_eq!(thread_params.cwd.as_deref(), Some("/tmp/workspace"));
2971        assert_eq!(thread_params.approval_policy.as_deref(), Some("on-request"));
2972        assert_eq!(thread_params.sandbox.as_deref(), Some("workspace-write"));
2973        assert_eq!(
2974            thread_params.sandbox_policy,
2975            Some(json!({"type": "dangerFullAccess"}))
2976        );
2977        assert_eq!(thread_params.effort.as_deref(), Some("none"));
2978        assert_eq!(thread_params.summary.as_deref(), Some("auto"));
2979        assert_eq!(thread_params.personality.as_deref(), Some("pragmatic"));
2980        assert_eq!(thread_params.ephemeral, Some(true));
2981        assert_eq!(
2982            thread_params.base_instructions.as_deref(),
2983            Some("base instructions")
2984        );
2985        assert_eq!(
2986            thread_params.developer_instructions.as_deref(),
2987            Some("developer instructions")
2988        );
2989        assert_eq!(
2990            thread_params.extra.get("skipGitRepoCheck"),
2991            Some(&Value::Bool(true))
2992        );
2993        assert_eq!(
2994            thread_params.extra.get("webSearchMode"),
2995            Some(&Value::String("live".to_string()))
2996        );
2997        assert_eq!(
2998            thread_params.extra.get("webSearchEnabled"),
2999            Some(&Value::Bool(false))
3000        );
3001        assert_eq!(
3002            thread_params.extra.get("networkAccessEnabled"),
3003            Some(&Value::Bool(true))
3004        );
3005        assert_eq!(
3006            thread_params.extra.get("additionalDirectories"),
3007            Some(&json!(["/tmp/one", "/tmp/two"]))
3008        );
3009        assert_eq!(
3010            thread_params.extra.get("config"),
3011            Some(&json!({"sandbox_workspace_write.network_access": true}))
3012        );
3013        assert_eq!(
3014            thread_params.extra.get("dynamicTools"),
3015            Some(&json!([{
3016                "name": "demo_tool",
3017                "description": "Demo dynamic tool",
3018                "inputSchema": {"type": "object"}
3019            }]))
3020        );
3021        assert_eq!(
3022            thread_params.extra.get("experimentalRawEvents"),
3023            Some(&Value::Bool(true))
3024        );
3025        assert_eq!(
3026            thread_params.extra.get("persistExtendedHistory"),
3027            Some(&Value::Bool(true))
3028        );
3029
3030        let resume_params = build_thread_resume_params("thread_123", &options);
3031        assert_eq!(resume_params.thread_id, "thread_123");
3032        assert_eq!(resume_params.model.as_deref(), Some("gpt-5.2-codex"));
3033        assert_eq!(
3034            resume_params.model_provider.as_deref(),
3035            Some("mock_provider")
3036        );
3037        assert_eq!(resume_params.cwd.as_deref(), Some("/tmp/workspace"));
3038        assert_eq!(resume_params.approval_policy.as_deref(), Some("on-request"));
3039        assert_eq!(resume_params.sandbox.as_deref(), Some("workspace-write"));
3040        assert_eq!(resume_params.personality.as_deref(), Some("pragmatic"));
3041        assert_eq!(
3042            resume_params
3043                .config
3044                .as_ref()
3045                .and_then(|config| config.get("sandbox_workspace_write.network_access")),
3046            Some(&Value::Bool(true))
3047        );
3048        assert_eq!(resume_params.persist_extended_history, Some(true));
3049        assert_eq!(
3050            resume_params.extra.get("sandboxPolicy"),
3051            Some(&json!({"type": "dangerFullAccess"}))
3052        );
3053        assert_eq!(
3054            resume_params.extra.get("effort"),
3055            Some(&Value::String("none".to_string()))
3056        );
3057        assert_eq!(
3058            resume_params.extra.get("summary"),
3059            Some(&Value::String("auto".to_string()))
3060        );
3061        assert_eq!(
3062            resume_params.extra.get("experimentalRawEvents"),
3063            Some(&Value::Bool(true))
3064        );
3065
3066        let turn_params = build_turn_start_params(
3067            "thread_123",
3068            Input::text("hello"),
3069            &options,
3070            &TurnOptions::default(),
3071        );
3072        assert_eq!(turn_params.model_provider.as_deref(), Some("mock_provider"));
3073        assert_eq!(turn_params.effort.as_deref(), Some("none"));
3074        assert_eq!(turn_params.summary.as_deref(), Some("auto"));
3075        assert_eq!(turn_params.personality.as_deref(), Some("pragmatic"));
3076        assert_eq!(
3077            turn_params.sandbox_policy,
3078            Some(json!({"type": "dangerFullAccess"}))
3079        );
3080        assert_eq!(
3081            turn_params.extra.get("collaborationMode"),
3082            Some(&json!({
3083                "mode": "default",
3084                "settings": {
3085                    "model": "gpt-5.2-codex",
3086                    "reasoning_effort": "high"
3087                }
3088            }))
3089        );
3090    }
3091
3092    #[test]
3093    fn thread_options_builder_skip_git_repo_check_matches_cli_flag_semantics() {
3094        let enabled = ThreadOptions::builder().skip_git_repo_check(true).build();
3095        let enabled_params = build_thread_start_params(&enabled);
3096        assert_eq!(
3097            enabled_params.extra.get("skipGitRepoCheck"),
3098            Some(&Value::Bool(true))
3099        );
3100
3101        let disabled = ThreadOptions::builder().skip_git_repo_check(false).build();
3102        let disabled_params = build_thread_start_params(&disabled);
3103        assert_eq!(
3104            disabled_params.extra.get("skipGitRepoCheck"),
3105            Some(&Value::Bool(false))
3106        );
3107    }
3108
3109    #[test]
3110    fn turn_options_builder_sets_typed_output_schema() {
3111        let turn_options = TurnOptions::builder()
3112            .output_schema_for::<StructuredReply>()
3113            .build();
3114        let turn_params = build_turn_start_params(
3115            "thread_123",
3116            Input::text("hello"),
3117            &ThreadOptions::default(),
3118            &turn_options,
3119        );
3120
3121        assert_eq!(
3122            turn_params.output_schema,
3123            Some(StructuredReply::openai_output_schema())
3124        );
3125    }
3126
3127    #[test]
3128    fn turn_options_builder_clear_output_schema_overrides_previous_value() {
3129        let turn_options = TurnOptions::builder()
3130            .output_schema(json!({"type": "object"}))
3131            .clear_output_schema()
3132            .build();
3133        let turn_params = build_turn_start_params(
3134            "thread_123",
3135            Input::text("hello"),
3136            &ThreadOptions::default(),
3137            &turn_options,
3138        );
3139
3140        assert_eq!(turn_params.output_schema, None);
3141    }
3142
3143    #[test]
3144    fn turn_options_value_helpers_set_raw_and_typed_schemas() {
3145        let raw = TurnOptions::default().with_output_schema(json!({"type": "object"}));
3146        assert_eq!(raw.output_schema, Some(json!({"type": "object"})));
3147
3148        let typed = TurnOptions::default().with_output_schema_for::<StructuredReply>();
3149        assert_eq!(
3150            typed.output_schema,
3151            Some(StructuredReply::openai_output_schema())
3152        );
3153    }
3154
3155    #[test]
3156    fn turn_options_builder_overrides_thread_defaults() {
3157        let thread_options = ThreadOptions::builder()
3158            .model("gpt-5-thread-default")
3159            .model_provider("provider-thread")
3160            .working_directory("/tmp/thread")
3161            .model_reasoning_effort(ModelReasoningEffort::Low)
3162            .model_reasoning_summary(ModelReasoningSummary::Auto)
3163            .personality(Personality::Friendly)
3164            .approval_policy(ApprovalMode::OnRequest)
3165            .sandbox_policy(json!({"thread": true}))
3166            .skip_git_repo_check(false)
3167            .network_access_enabled(false)
3168            .web_search_mode(WebSearchMode::Cached)
3169            .web_search_enabled(false)
3170            .add_directory("/tmp/thread-dir")
3171            .build();
3172
3173        let turn_options = TurnOptions::builder()
3174            .model("gpt-5-turn-override")
3175            .model_provider("provider-turn")
3176            .working_directory("/tmp/turn")
3177            .model_reasoning_effort(ModelReasoningEffort::High)
3178            .model_reasoning_summary(ModelReasoningSummary::Detailed)
3179            .personality(Personality::Pragmatic)
3180            .approval_policy(ApprovalMode::Never)
3181            .sandbox_policy(json!({"turn": true}))
3182            .skip_git_repo_check(true)
3183            .network_access_enabled(true)
3184            .web_search_mode(WebSearchMode::Live)
3185            .web_search_enabled(true)
3186            .add_directory("/tmp/turn-dir")
3187            .insert_extra("customTurnFlag", Value::Bool(true))
3188            .build();
3189
3190        let params = build_turn_start_params(
3191            "thread_123",
3192            Input::text("hello"),
3193            &thread_options,
3194            &turn_options,
3195        );
3196
3197        assert_eq!(params.cwd.as_deref(), Some("/tmp/turn"));
3198        assert_eq!(params.model.as_deref(), Some("gpt-5-turn-override"));
3199        assert_eq!(params.model_provider.as_deref(), Some("provider-turn"));
3200        assert_eq!(params.effort.as_deref(), Some("high"));
3201        assert_eq!(params.summary.as_deref(), Some("detailed"));
3202        assert_eq!(params.personality.as_deref(), Some("pragmatic"));
3203        assert_eq!(params.approval_policy.as_deref(), Some("never"));
3204        assert_eq!(params.sandbox_policy, Some(json!({"turn": true})));
3205        assert_eq!(
3206            params.extra.get("skipGitRepoCheck"),
3207            Some(&Value::Bool(true))
3208        );
3209        assert_eq!(
3210            params.extra.get("networkAccessEnabled"),
3211            Some(&Value::Bool(true))
3212        );
3213        assert_eq!(
3214            params.extra.get("webSearchMode"),
3215            Some(&Value::String("live".to_string()))
3216        );
3217        assert_eq!(
3218            params.extra.get("webSearchEnabled"),
3219            Some(&Value::Bool(true))
3220        );
3221        assert_eq!(
3222            params.extra.get("additionalDirectories"),
3223            Some(&json!(["/tmp/turn-dir"]))
3224        );
3225        assert_eq!(params.extra.get("customTurnFlag"), Some(&Value::Bool(true)));
3226    }
3227}