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 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 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 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 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}