1use std::collections::{HashMap, VecDeque};
12use std::sync::{Arc, Mutex};
13use std::time::Duration;
14
15use serde::Serialize;
16use serde_json::{json, Value};
17use thiserror::Error;
18
19use super::app_server::{
20 AppServerError, AppServerIncoming, AppServerTransport, RpcId, ServerResponseError,
21 WriteOutcomeTracker,
22};
23
24const MAX_RAW_EVENTS: usize = 128;
25const MAX_PENDING_SERVER_REQUESTS: usize = 64;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum SessionPhase {
29 Idle,
30 Running,
31 Completed,
32 Interrupted,
38 Failed,
39}
40
41impl Default for SessionPhase {
42 fn default() -> Self {
43 Self::Idle
44 }
45}
46
47#[derive(Debug, Clone, PartialEq)]
48pub struct ItemSnapshot {
49 pub id: String,
50 pub kind: String,
51 pub preview: String,
54 pub completed: bool,
55 pub authoritative: Option<Value>,
56}
57
58#[derive(Debug, Clone, PartialEq)]
59pub struct SessionSnapshot {
60 pub thread_id: Option<String>,
61 pub turn_id: Option<String>,
62 pub phase: SessionPhase,
63 pub items: HashMap<String, ItemSnapshot>,
64 pub raw_unknown_events: Vec<Value>,
65}
66
67#[derive(Debug, Clone, PartialEq)]
68pub enum CodexAppServerEvent {
69 ThreadStarted {
70 thread_id: String,
71 },
72 TurnStarted {
73 thread_id: String,
74 turn_id: String,
75 },
76 AgentMessageDelta {
77 thread_id: String,
78 turn_id: String,
79 item_id: String,
80 delta: String,
81 preview: String,
82 },
83 ItemStarted {
84 thread_id: String,
85 turn_id: String,
86 item_id: String,
87 kind: String,
88 },
89 ItemCompleted {
90 thread_id: String,
91 turn_id: String,
92 item_id: String,
93 kind: String,
94 item: Value,
95 },
96 TurnCompleted {
97 thread_id: String,
98 turn_id: String,
99 status: String,
100 },
101 ThreadStatusChanged {
102 thread_id: String,
103 status: Value,
104 },
105 ServerRequestResolved {
106 thread_id: String,
107 request_id: RpcId,
108 },
109 AccountLoginCompleted {
110 login_id: Option<String>,
111 success: bool,
112 error: Option<String>,
113 },
114 UnknownNotification {
115 method: String,
116 params: Value,
117 },
118}
119
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub enum ServerRequestKind {
122 CommandApproval,
123 FileChangeApproval,
124 PermissionsApproval,
125 UserInput,
126 McpElicitation,
127}
128
129#[derive(Debug, Clone, PartialEq)]
130pub struct PendingServerRequest {
131 pub id: RpcId,
132 pub kind: ServerRequestKind,
133 pub method: String,
134 pub params: Value,
135 pub thread_id: Option<String>,
136 pub turn_id: Option<String>,
137 pub item_id: Option<String>,
138}
139
140#[derive(Debug, Error, Clone, PartialEq)]
141pub enum CodexAppServerProtocolError {
142 #[error(transparent)]
143 Transport(#[from] AppServerError),
144 #[error("App Server response is missing a string field: {0}")]
145 MissingStringField(&'static str),
146 #[error("App Server response is missing an object field: {0}")]
147 MissingObjectField(&'static str),
148 #[error("App Server response is missing a boolean field: {0}")]
149 MissingBoolField(&'static str),
150 #[error("App Server event belongs to thread {actual}, but active thread is {expected}")]
151 ThreadScopeMismatch { expected: String, actual: String },
152 #[error("App Server event belongs to turn {actual}, but active turn is {expected}")]
153 TurnScopeMismatch { expected: String, actual: String },
154 #[error("cannot start a turn before a thread is loaded")]
155 ThreadNotLoaded,
156 #[error("cannot steer or interrupt without an active turn")]
157 TurnNotActive,
158 #[error("cannot start a second turn while turn {0} is active")]
159 TurnAlreadyActive(String),
160 #[error("resume response did not identify the requested thread")]
161 ResumeMissingThread,
162 #[error("unsupported or unsafe App Server request method: {0}")]
163 UnsupportedServerRequest(String),
164 #[error("no pending App Server request matches id")]
165 UnknownServerRequest,
166 #[error("App Server request id is already pending")]
167 DuplicateServerRequest,
168 #[error("too many App Server requests are pending a decision (max {0})")]
169 TooManyPendingRequests(usize),
170}
171
172#[derive(Debug, Clone, Serialize, Default)]
173#[serde(rename_all = "camelCase")]
174pub struct ThreadStartOptions {
175 #[serde(skip_serializing_if = "Option::is_none")]
176 pub model: Option<String>,
177 #[serde(skip_serializing_if = "Option::is_none")]
178 pub cwd: Option<String>,
179 #[serde(skip_serializing_if = "Option::is_none")]
180 pub developer_instructions: Option<String>,
181}
182
183#[derive(Debug, Clone, Serialize)]
184#[serde(rename_all = "camelCase")]
185struct ThreadResumeParams {
186 thread_id: String,
187 #[serde(skip_serializing_if = "Option::is_none")]
188 model: Option<String>,
189 #[serde(skip_serializing_if = "Option::is_none")]
190 cwd: Option<String>,
191}
192
193#[derive(Debug, Clone, Serialize)]
194#[serde(rename_all = "camelCase")]
195struct TurnStartParams {
196 thread_id: String,
197 input: Vec<TextUserInput>,
198}
199
200#[derive(Debug, Clone, Serialize)]
201#[serde(rename_all = "camelCase")]
202pub struct ThreadForkOptions {
203 pub thread_id: String,
204 #[serde(skip_serializing_if = "Option::is_none")]
205 pub model: Option<String>,
206 #[serde(skip_serializing_if = "Option::is_none")]
207 pub cwd: Option<String>,
208 #[serde(skip_serializing_if = "Option::is_none")]
209 pub last_turn_id: Option<String>,
210 #[serde(skip_serializing_if = "Option::is_none")]
211 pub exclude_turns: Option<bool>,
212}
213
214#[derive(Debug, Clone, Serialize)]
217#[serde(untagged)]
218#[allow(dead_code)] pub enum ThreadListCwdFilter {
220 Single(String),
221 Many(Vec<String>),
222}
223
224#[derive(Debug, Clone, Serialize, Default)]
225#[serde(rename_all = "camelCase")]
226pub struct ThreadListOptions {
227 #[serde(skip_serializing_if = "Option::is_none")]
228 pub archived: Option<bool>,
229 #[serde(skip_serializing_if = "Option::is_none")]
230 pub cursor: Option<String>,
231 #[serde(skip_serializing_if = "Option::is_none")]
232 pub cwd: Option<ThreadListCwdFilter>,
233 #[serde(skip_serializing_if = "Option::is_none")]
234 pub limit: Option<u32>,
235 #[serde(skip_serializing_if = "Option::is_none")]
236 pub search_term: Option<String>,
237}
238
239#[derive(Debug, Clone, Serialize)]
242#[serde(tag = "type", rename_all_fields = "camelCase")]
243#[allow(dead_code)] pub enum AccountLoginOptions {
245 #[serde(rename = "chatgptDeviceCode")]
246 ChatgptDeviceCode,
247 #[serde(rename = "chatgpt")]
248 Chatgpt {
249 #[serde(skip_serializing_if = "Option::is_none")]
250 app_brand: Option<String>,
251 #[serde(skip_serializing_if = "Option::is_none")]
252 codex_streamlined_login: Option<bool>,
253 #[serde(skip_serializing_if = "Option::is_none")]
254 use_hosted_login_success_page: Option<bool>,
255 },
256 #[serde(rename = "apiKey")]
257 ApiKey { api_key: String },
258}
259
260#[derive(Debug, Clone, Serialize)]
261struct TextUserInput {
262 #[serde(rename = "type")]
263 kind: &'static str,
264 text: String,
265}
266
267pub struct CodexAppServerSession {
268 transport: Arc<AppServerTransport>,
269 state: Mutex<SessionState>,
270}
271
272#[derive(Default)]
273struct SessionState {
274 thread_id: Option<String>,
275 turn_id: Option<String>,
276 phase: SessionPhase,
277 items: HashMap<String, ItemSnapshot>,
278 raw_unknown_events: VecDeque<Value>,
279 last_terminated_turn_id: Option<String>,
291 turn_starting: bool,
299 pending_requests: HashMap<RpcId, PendingServerRequest>,
300}
301
302impl CodexAppServerSession {
303 pub fn new(transport: Arc<AppServerTransport>) -> Self {
304 Self {
305 transport,
306 state: Mutex::new(SessionState {
307 phase: SessionPhase::Idle,
308 ..SessionState::default()
309 }),
310 }
311 }
312
313 pub async fn start_thread(
314 &self,
315 options: ThreadStartOptions,
316 timeout: Duration,
317 ) -> Result<String, CodexAppServerProtocolError> {
318 let params = serde_json::to_value(options)
319 .map_err(|error| AppServerError::Protocol(error.to_string()))?;
320 let result = self
321 .transport
322 .request("thread/start", params, timeout)
323 .await?;
324 let thread_id = extract_thread_id(&result)?;
325 let mut state = self.state.lock().unwrap();
326 state.thread_id = Some(thread_id.clone());
327 state.turn_id = None;
328 state.phase = SessionPhase::Idle;
329 state.items.clear();
330 state.pending_requests.clear();
334 Ok(thread_id)
335 }
336
337 pub async fn resume_thread(
338 &self,
339 thread_id: String,
340 model: Option<String>,
341 cwd: Option<String>,
342 timeout: Duration,
343 ) -> Result<String, CodexAppServerProtocolError> {
344 let requested_id = thread_id.clone();
345 let result = self
346 .transport
347 .request(
348 "thread/resume",
349 serde_json::to_value(ThreadResumeParams {
350 thread_id,
351 model,
352 cwd,
353 })
354 .map_err(|error| AppServerError::Protocol(error.to_string()))?,
355 timeout,
356 )
357 .await?;
358 let resumed_id = extract_thread_id(&result)?;
359 if resumed_id != requested_id {
360 return Err(CodexAppServerProtocolError::ResumeMissingThread);
361 }
362 let in_progress_turn_id = extract_in_progress_turn_id(&result);
368 let mut state = self.state.lock().unwrap();
369 state.thread_id = Some(resumed_id.clone());
370 state.turn_id = in_progress_turn_id.clone();
371 state.phase = if in_progress_turn_id.is_some() {
372 SessionPhase::Running
373 } else {
374 SessionPhase::Idle
375 };
376 state.items.clear();
377 state.pending_requests.clear();
380 Ok(resumed_id)
381 }
382
383 pub async fn start_turn(
384 &self,
385 text: String,
386 timeout: Duration,
387 ) -> Result<String, CodexAppServerProtocolError> {
388 let thread_id = {
389 let mut state = self.state.lock().unwrap();
390 if let Some(turn_id) = &state.turn_id {
391 return Err(CodexAppServerProtocolError::TurnAlreadyActive(
392 turn_id.clone(),
393 ));
394 }
395 if state.turn_starting {
396 return Err(CodexAppServerProtocolError::TurnAlreadyActive(
401 "(turn start already in flight)".to_string(),
402 ));
403 }
404 let thread_id = state
405 .thread_id
406 .clone()
407 .ok_or(CodexAppServerProtocolError::ThreadNotLoaded)?;
408 state.turn_starting = true;
415 thread_id
416 };
417
418 let turn_id_result: Result<String, CodexAppServerProtocolError> = async {
421 let result = self
422 .transport
423 .request(
424 "turn/start",
425 serde_json::to_value(TurnStartParams {
426 thread_id,
427 input: vec![TextUserInput { kind: "text", text }],
428 })
429 .map_err(|error| AppServerError::Protocol(error.to_string()))?,
430 timeout,
431 )
432 .await?;
433 let turn = result
434 .get("turn")
435 .and_then(Value::as_object)
436 .ok_or(CodexAppServerProtocolError::MissingObjectField("turn"))?;
437 string_field(turn.get("id"), "turn.id")
438 }
439 .await;
440
441 let mut state = self.state.lock().unwrap();
442 state.turn_starting = false;
443 let turn_id = turn_id_result?;
444 if state.last_terminated_turn_id.as_deref() == Some(turn_id.as_str()) {
445 return Ok(turn_id);
450 }
451 state.turn_id = Some(turn_id.clone());
452 state.phase = SessionPhase::Running;
453 Ok(turn_id)
454 }
455
456 #[allow(dead_code)] pub async fn fork_thread(
461 &self,
462 options: ThreadForkOptions,
463 timeout: Duration,
464 ) -> Result<String, CodexAppServerProtocolError> {
465 let active_thread = self
466 .state
467 .lock()
468 .unwrap()
469 .thread_id
470 .clone()
471 .ok_or(CodexAppServerProtocolError::ThreadNotLoaded)?;
472 if options.thread_id != active_thread {
473 return Err(CodexAppServerProtocolError::ThreadScopeMismatch {
474 expected: active_thread,
475 actual: options.thread_id,
476 });
477 }
478 let result = self
479 .transport
480 .request(
481 "thread/fork",
482 serde_json::to_value(options)
483 .map_err(|error| AppServerError::Protocol(error.to_string()))?,
484 timeout,
485 )
486 .await?;
487 extract_thread_id(&result)
488 }
489
490 #[allow(dead_code)] pub async fn read_thread(
492 &self,
493 include_turns: bool,
494 timeout: Duration,
495 ) -> Result<Value, CodexAppServerProtocolError> {
496 let thread_id = self
497 .state
498 .lock()
499 .unwrap()
500 .thread_id
501 .clone()
502 .ok_or(CodexAppServerProtocolError::ThreadNotLoaded)?;
503 Ok(self
504 .transport
505 .request(
506 "thread/read",
507 json!({"threadId": thread_id, "includeTurns": include_turns}),
508 timeout,
509 )
510 .await?)
511 }
512
513 #[allow(dead_code)] pub async fn list_threads(
515 &self,
516 options: ThreadListOptions,
517 timeout: Duration,
518 ) -> Result<Value, CodexAppServerProtocolError> {
519 Ok(self
520 .transport
521 .request(
522 "thread/list",
523 serde_json::to_value(options)
524 .map_err(|error| AppServerError::Protocol(error.to_string()))?,
525 timeout,
526 )
527 .await?)
528 }
529
530 #[allow(dead_code)] pub async fn archive_thread(
532 &self,
533 timeout: Duration,
534 ) -> Result<Value, CodexAppServerProtocolError> {
535 let thread_id = self
536 .state
537 .lock()
538 .unwrap()
539 .thread_id
540 .clone()
541 .ok_or(CodexAppServerProtocolError::ThreadNotLoaded)?;
542 Ok(self
543 .transport
544 .request("thread/archive", json!({"threadId": thread_id}), timeout)
545 .await?)
546 }
547
548 #[allow(dead_code)] pub async fn compact_thread(
550 &self,
551 timeout: Duration,
552 ) -> Result<Value, CodexAppServerProtocolError> {
553 let thread_id = self
554 .state
555 .lock()
556 .unwrap()
557 .thread_id
558 .clone()
559 .ok_or(CodexAppServerProtocolError::ThreadNotLoaded)?;
560 Ok(self
561 .transport
562 .request(
563 "thread/compact/start",
564 json!({"threadId": thread_id}),
565 timeout,
566 )
567 .await?)
568 }
569
570 #[allow(dead_code)] pub async fn read_account(
573 &self,
574 refresh_token: bool,
575 timeout: Duration,
576 ) -> Result<Value, CodexAppServerProtocolError> {
577 Ok(self
578 .transport
579 .request(
580 "account/read",
581 json!({"refreshToken": refresh_token}),
582 timeout,
583 )
584 .await?)
585 }
586
587 #[allow(dead_code)] pub async fn logout_account(
589 &self,
590 timeout: Duration,
591 ) -> Result<Value, CodexAppServerProtocolError> {
592 Ok(self
593 .transport
594 .request("account/logout", Value::Null, timeout)
595 .await?)
596 }
597
598 #[allow(dead_code)] pub async fn login_account(
600 &self,
601 options: AccountLoginOptions,
602 timeout: Duration,
603 ) -> Result<Value, CodexAppServerProtocolError> {
604 Ok(self
605 .transport
606 .request(
607 "account/login/start",
608 serde_json::to_value(options)
609 .map_err(|error| AppServerError::Protocol(error.to_string()))?,
610 timeout,
611 )
612 .await?)
613 }
614
615 #[allow(dead_code)] pub async fn cancel_login(
617 &self,
618 login_id: String,
619 timeout: Duration,
620 ) -> Result<Value, CodexAppServerProtocolError> {
621 Ok(self
622 .transport
623 .request(
624 "account/login/cancel",
625 json!({"loginId": login_id}),
626 timeout,
627 )
628 .await?)
629 }
630
631 #[allow(dead_code)] pub async fn steer_turn(
635 &self,
636 text: String,
637 expected_turn_id: String,
638 timeout: Duration,
639 ) -> Result<Value, CodexAppServerProtocolError> {
640 let (thread_id, active_turn_id) = self.active_turn()?;
641 if active_turn_id != expected_turn_id {
642 return Err(CodexAppServerProtocolError::TurnScopeMismatch {
643 expected: active_turn_id,
644 actual: expected_turn_id,
645 });
646 }
647 Ok(self
648 .transport
649 .request(
650 "turn/steer",
651 json!({
652 "threadId": thread_id,
653 "expectedTurnId": expected_turn_id,
654 "input": [{"type": "text", "text": text}],
655 }),
656 timeout,
657 )
658 .await?)
659 }
660
661 #[allow(dead_code)] pub async fn interrupt_turn(
665 &self,
666 timeout: Duration,
667 ) -> Result<Value, CodexAppServerProtocolError> {
668 let (thread_id, turn_id) = self.active_turn()?;
669 Ok(self
670 .transport
671 .request(
672 "turn/interrupt",
673 json!({"threadId": thread_id, "turnId": turn_id}),
674 timeout,
675 )
676 .await?)
677 }
678
679 fn active_turn(&self) -> Result<(String, String), CodexAppServerProtocolError> {
680 let state = self.state.lock().unwrap();
681 let thread_id = state
682 .thread_id
683 .clone()
684 .ok_or(CodexAppServerProtocolError::ThreadNotLoaded)?;
685 let turn_id = state
686 .turn_id
687 .clone()
688 .ok_or(CodexAppServerProtocolError::TurnNotActive)?;
689 Ok((thread_id, turn_id))
690 }
691
692 pub fn accept_server_request(
695 &self,
696 incoming: AppServerIncoming,
697 ) -> Result<PendingServerRequest, CodexAppServerProtocolError> {
698 let AppServerIncoming::Request { id, method, params } = incoming else {
699 return Err(CodexAppServerProtocolError::UnsupportedServerRequest(
700 "notifications cannot be queued as requests".to_string(),
701 ));
702 };
703 let kind = match method.as_str() {
704 "item/commandExecution/requestApproval" => ServerRequestKind::CommandApproval,
705 "item/fileChange/requestApproval" => ServerRequestKind::FileChangeApproval,
706 "item/permissions/requestApproval" => ServerRequestKind::PermissionsApproval,
707 "item/tool/requestUserInput" => ServerRequestKind::UserInput,
708 "mcpServer/elicitation/request" => ServerRequestKind::McpElicitation,
709 _ => {
710 return Err(CodexAppServerProtocolError::UnsupportedServerRequest(
711 method,
712 ))
713 }
714 };
715 let (thread_id, turn_id) = if kind == ServerRequestKind::McpElicitation {
724 (
725 params.get("threadId").and_then(Value::as_str).map(str::to_string),
726 params.get("turnId").and_then(Value::as_str).map(str::to_string),
727 )
728 } else {
729 (
730 Some(string_field(params.get("threadId"), "threadId")?),
731 Some(string_field(params.get("turnId"), "turnId")?),
732 )
733 };
734 let item_id = params
735 .get("itemId")
736 .and_then(Value::as_str)
737 .map(str::to_string);
738 let mut state = self.state.lock().unwrap();
739 if state.pending_requests.contains_key(&id) {
740 return Err(CodexAppServerProtocolError::DuplicateServerRequest);
741 }
742 if state.pending_requests.len() >= MAX_PENDING_SERVER_REQUESTS {
743 return Err(CodexAppServerProtocolError::TooManyPendingRequests(
744 MAX_PENDING_SERVER_REQUESTS,
745 ));
746 }
747 match (&thread_id, &turn_id) {
761 (Some(thread), Some(turn)) => ensure_active_scopes(&state, thread, turn)?,
762 (Some(thread), None) => ensure_active_thread_scope(&state, thread)?,
763 (None, Some(turn)) => ensure_active_turn_scope(&state, turn)?,
764 (None, None) => {}
765 }
766 let pending = PendingServerRequest {
767 id: id.clone(),
768 kind,
769 method,
770 params,
771 thread_id,
772 turn_id,
773 item_id,
774 };
775 state.pending_requests.insert(id, pending.clone());
776 Ok(pending)
777 }
778
779 #[allow(dead_code)] pub async fn respond_server_request(
783 &self,
784 id: &RpcId,
785 result: Result<Value, ServerResponseError>,
786 timeout: Duration,
787 ) -> Result<(), CodexAppServerProtocolError> {
788 let pending = self
798 .state
799 .lock()
800 .unwrap()
801 .pending_requests
802 .remove(id)
803 .ok_or(CodexAppServerProtocolError::UnknownServerRequest)?;
804
805 struct RestoreOnDrop<'a> {
819 state: &'a Mutex<SessionState>,
820 id: RpcId,
821 pending: Option<PendingServerRequest>,
822 outcome: WriteOutcomeTracker,
830 }
831 impl Drop for RestoreOnDrop<'_> {
832 fn drop(&mut self) {
833 if !self.outcome.safe_to_retry() {
834 return;
835 }
836 let Some(pending) = self.pending.take() else {
837 return;
838 };
839 let mut state = self.state.lock().unwrap();
840 let still_current = match (&pending.thread_id, &pending.turn_id) {
846 (Some(thread), Some(turn)) => {
847 state.thread_id.as_deref() == Some(thread.as_str())
848 && state.turn_id.as_deref() == Some(turn.as_str())
849 }
850 (Some(thread), None) => state.thread_id.as_deref() == Some(thread.as_str()),
851 (None, Some(turn)) => state.turn_id.as_deref() == Some(turn.as_str()),
852 (None, None) => true,
853 };
854 if still_current {
855 state.pending_requests.insert(self.id.clone(), pending);
856 }
857 }
858 }
859
860 let guard = RestoreOnDrop {
861 state: &self.state,
862 id: id.clone(),
863 pending: Some(pending),
864 outcome: WriteOutcomeTracker::new(),
865 };
866 let outcome = self
867 .transport
868 .respond_tracked(id.clone(), result, timeout, &guard.outcome)
869 .await;
870 outcome.map_err(Into::into)
871 }
872
873 #[allow(dead_code)] pub fn pending_server_requests(&self) -> Vec<PendingServerRequest> {
875 self.state
876 .lock()
877 .unwrap()
878 .pending_requests
879 .values()
880 .cloned()
881 .collect()
882 }
883
884 #[allow(dead_code)] pub fn snapshot(&self) -> SessionSnapshot {
886 let state = self.state.lock().unwrap();
887 SessionSnapshot {
888 thread_id: state.thread_id.clone(),
889 turn_id: state.turn_id.clone(),
890 phase: state.phase,
891 items: state.items.clone(),
892 raw_unknown_events: state.raw_unknown_events.iter().cloned().collect(),
893 }
894 }
895
896 pub fn apply_incoming(
897 &self,
898 incoming: AppServerIncoming,
899 ) -> Result<CodexAppServerEvent, CodexAppServerProtocolError> {
900 let AppServerIncoming::Notification { method, params } = incoming else {
901 return Err(CodexAppServerProtocolError::Transport(
902 AppServerError::Protocol(
903 "server request requires an explicit approval adapter".to_string(),
904 ),
905 ));
906 };
907 let mut state = self.state.lock().unwrap();
908 match method.as_str() {
909 "thread/started" => {
910 let thread = params
911 .get("thread")
912 .and_then(Value::as_object)
913 .ok_or(CodexAppServerProtocolError::MissingObjectField("thread"))?;
914 let thread_id = string_field(thread.get("id"), "thread.id")?;
915 let is_new_thread = state.thread_id.as_deref() != Some(thread_id.as_str());
922 state.thread_id = Some(thread_id.clone());
923 if is_new_thread {
924 state.turn_id = None;
925 state.phase = SessionPhase::Idle;
926 state.items.clear();
927 state.pending_requests.clear();
932 }
933 Ok(CodexAppServerEvent::ThreadStarted { thread_id })
934 }
935 "turn/started" => {
936 let thread_id = string_field(params.get("threadId"), "threadId")?;
937 ensure_thread_scope(&state, &thread_id)?;
938 let turn = params
939 .get("turn")
940 .and_then(Value::as_object)
941 .ok_or(CodexAppServerProtocolError::MissingObjectField("turn"))?;
942 let turn_id = string_field(turn.get("id"), "turn.id")?;
943 state.turn_id = Some(turn_id.clone());
944 state.phase = SessionPhase::Running;
945 Ok(CodexAppServerEvent::TurnStarted { thread_id, turn_id })
946 }
947 "item/agentMessage/delta" => {
948 let thread_id = string_field(params.get("threadId"), "threadId")?;
949 let turn_id = string_field(params.get("turnId"), "turnId")?;
950 ensure_scopes(&state, &thread_id, &turn_id)?;
951 let item_id = string_field(params.get("itemId"), "itemId")?;
952 let delta = string_field(params.get("delta"), "delta")?;
953 let item = state.items.entry(item_id.clone()).or_insert(ItemSnapshot {
954 id: item_id.clone(),
955 kind: "agentMessage".to_string(),
956 preview: String::new(),
957 completed: false,
958 authoritative: None,
959 });
960 item.preview.push_str(&delta);
961 Ok(CodexAppServerEvent::AgentMessageDelta {
962 thread_id,
963 turn_id,
964 item_id,
965 delta,
966 preview: item.preview.clone(),
967 })
968 }
969 "item/started" => {
970 let thread_id = string_field(params.get("threadId"), "threadId")?;
971 let turn_id = string_field(params.get("turnId"), "turnId")?;
972 ensure_scopes(&state, &thread_id, &turn_id)?;
973 let item = params
974 .get("item")
975 .and_then(Value::as_object)
976 .ok_or(CodexAppServerProtocolError::MissingObjectField("item"))?;
977 let item_id = string_field(item.get("id"), "item.id")?;
978 let kind = string_field(item.get("type"), "item.type")?;
979 state.items.insert(
980 item_id.clone(),
981 ItemSnapshot {
982 id: item_id.clone(),
983 kind: kind.clone(),
984 preview: String::new(),
985 completed: false,
986 authoritative: None,
987 },
988 );
989 Ok(CodexAppServerEvent::ItemStarted {
990 thread_id,
991 turn_id,
992 item_id,
993 kind,
994 })
995 }
996 "item/completed" => {
997 let thread_id = string_field(params.get("threadId"), "threadId")?;
998 let turn_id = string_field(params.get("turnId"), "turnId")?;
999 ensure_scopes(&state, &thread_id, &turn_id)?;
1000 let item = params
1001 .get("item")
1002 .and_then(Value::as_object)
1003 .ok_or(CodexAppServerProtocolError::MissingObjectField("item"))?;
1004 let item_id = string_field(item.get("id"), "item.id")?;
1005 let kind = string_field(item.get("type"), "item.type")?;
1006 state.items.insert(
1007 item_id.clone(),
1008 ItemSnapshot {
1009 id: item_id.clone(),
1010 kind: kind.clone(),
1011 preview: String::new(),
1012 completed: true,
1013 authoritative: Some(Value::Object(item.clone())),
1014 },
1015 );
1016 Ok(CodexAppServerEvent::ItemCompleted {
1017 thread_id,
1018 turn_id,
1019 item_id,
1020 kind,
1021 item: Value::Object(item.clone()),
1022 })
1023 }
1024 "turn/completed" => {
1025 let thread_id = string_field(params.get("threadId"), "threadId")?;
1026 ensure_thread_scope(&state, &thread_id)?;
1027 let turn = params
1028 .get("turn")
1029 .and_then(Value::as_object)
1030 .ok_or(CodexAppServerProtocolError::MissingObjectField("turn"))?;
1031 let turn_id = string_field(turn.get("id"), "turn.id")?;
1032 if let Some(expected) = &state.turn_id {
1033 if expected != &turn_id {
1034 return Err(CodexAppServerProtocolError::TurnScopeMismatch {
1035 expected: expected.clone(),
1036 actual: turn_id.clone(),
1037 });
1038 }
1039 }
1040 let status = string_field(turn.get("status"), "turn.status")?;
1041 state.turn_id = None;
1042 state.last_terminated_turn_id = Some(turn_id.clone());
1043 state.phase = match status.as_str() {
1051 "completed" => SessionPhase::Completed,
1052 "interrupted" => SessionPhase::Interrupted,
1053 _ => SessionPhase::Failed,
1054 };
1055 state.pending_requests.clear();
1056 Ok(CodexAppServerEvent::TurnCompleted {
1057 thread_id,
1058 turn_id,
1059 status,
1060 })
1061 }
1062 "thread/status/changed" => {
1063 let thread_id = string_field(params.get("threadId"), "threadId")?;
1064 ensure_thread_scope(&state, &thread_id)?;
1065 let status = params.get("status").cloned().unwrap_or(Value::Null);
1066 Ok(CodexAppServerEvent::ThreadStatusChanged { thread_id, status })
1067 }
1068 "serverRequest/resolved" => {
1077 let thread_id = string_field(params.get("threadId"), "threadId")?;
1078 ensure_thread_scope(&state, &thread_id)?;
1079 let request_id = RpcId::from_value(params.get("requestId").ok_or_else(|| {
1080 CodexAppServerProtocolError::Transport(AppServerError::Protocol(
1081 "serverRequest/resolved notification is missing requestId".to_string(),
1082 ))
1083 })?)?;
1084 state.pending_requests.remove(&request_id);
1085 Ok(CodexAppServerEvent::ServerRequestResolved {
1086 thread_id,
1087 request_id,
1088 })
1089 }
1090 "account/login/completed" => {
1091 let success = params
1092 .get("success")
1093 .and_then(Value::as_bool)
1094 .ok_or(CodexAppServerProtocolError::MissingBoolField("success"))?;
1095 Ok(CodexAppServerEvent::AccountLoginCompleted {
1096 login_id: params
1097 .get("loginId")
1098 .and_then(Value::as_str)
1099 .map(str::to_string),
1100 success,
1101 error: params
1102 .get("error")
1103 .and_then(Value::as_str)
1104 .map(str::to_string),
1105 })
1106 }
1107 _ => {
1108 let raw = json!({ "method": method, "params": params });
1109 if state.raw_unknown_events.len() == MAX_RAW_EVENTS {
1110 state.raw_unknown_events.pop_front();
1111 }
1112 state.raw_unknown_events.push_back(raw.clone());
1113 Ok(CodexAppServerEvent::UnknownNotification {
1114 method: raw["method"].as_str().unwrap_or_default().to_string(),
1115 params: raw["params"].clone(),
1116 })
1117 }
1118 }
1119 }
1120}
1121
1122fn extract_thread_id(result: &Value) -> Result<String, CodexAppServerProtocolError> {
1123 let thread = result
1124 .get("thread")
1125 .and_then(Value::as_object)
1126 .ok_or(CodexAppServerProtocolError::MissingObjectField("thread"))?;
1127 string_field(thread.get("id"), "thread.id")
1128}
1129
1130fn extract_in_progress_turn_id(result: &Value) -> Option<String> {
1137 result
1138 .get("thread")?
1139 .get("turns")?
1140 .as_array()?
1141 .iter()
1142 .find(|turn| turn.get("status").and_then(Value::as_str) == Some("inProgress"))?
1143 .get("id")?
1144 .as_str()
1145 .map(str::to_string)
1146}
1147
1148fn string_field(
1149 value: Option<&Value>,
1150 field: &'static str,
1151) -> Result<String, CodexAppServerProtocolError> {
1152 value
1153 .and_then(Value::as_str)
1154 .map(str::to_string)
1155 .ok_or(CodexAppServerProtocolError::MissingStringField(field))
1156}
1157
1158fn ensure_thread_scope(
1159 state: &SessionState,
1160 actual: &str,
1161) -> Result<(), CodexAppServerProtocolError> {
1162 if let Some(expected) = &state.thread_id {
1163 if expected != actual {
1164 return Err(CodexAppServerProtocolError::ThreadScopeMismatch {
1165 expected: expected.clone(),
1166 actual: actual.to_string(),
1167 });
1168 }
1169 }
1170 Ok(())
1171}
1172
1173fn ensure_scopes(
1174 state: &SessionState,
1175 thread_id: &str,
1176 turn_id: &str,
1177) -> Result<(), CodexAppServerProtocolError> {
1178 ensure_thread_scope(state, thread_id)?;
1179 if let Some(expected) = &state.turn_id {
1180 if expected != turn_id {
1181 return Err(CodexAppServerProtocolError::TurnScopeMismatch {
1182 expected: expected.clone(),
1183 actual: turn_id.to_string(),
1184 });
1185 }
1186 }
1187 Ok(())
1188}
1189
1190fn ensure_active_thread_scope(
1195 state: &SessionState,
1196 actual: &str,
1197) -> Result<(), CodexAppServerProtocolError> {
1198 match &state.thread_id {
1199 Some(expected) if expected == actual => Ok(()),
1200 Some(expected) => Err(CodexAppServerProtocolError::ThreadScopeMismatch {
1201 expected: expected.clone(),
1202 actual: actual.to_string(),
1203 }),
1204 None => Err(CodexAppServerProtocolError::ThreadNotLoaded),
1205 }
1206}
1207
1208fn ensure_active_scopes(
1210 state: &SessionState,
1211 thread_id: &str,
1212 turn_id: &str,
1213) -> Result<(), CodexAppServerProtocolError> {
1214 ensure_active_thread_scope(state, thread_id)?;
1215 ensure_active_turn_scope(state, turn_id)
1216}
1217
1218fn ensure_active_turn_scope(
1223 state: &SessionState,
1224 actual: &str,
1225) -> Result<(), CodexAppServerProtocolError> {
1226 match &state.turn_id {
1227 Some(expected) if expected == actual => Ok(()),
1228 Some(expected) => Err(CodexAppServerProtocolError::TurnScopeMismatch {
1229 expected: expected.clone(),
1230 actual: actual.to_string(),
1231 }),
1232 None => Err(CodexAppServerProtocolError::TurnNotActive),
1233 }
1234}
1235
1236#[cfg(test)]
1237mod tests {
1238 use super::*;
1239 use tokio::io::{duplex, AsyncBufReadExt, AsyncWriteExt, BufReader};
1240
1241 fn session() -> (
1242 Arc<CodexAppServerSession>,
1243 tokio::io::ReadHalf<tokio::io::DuplexStream>,
1244 tokio::io::WriteHalf<tokio::io::DuplexStream>,
1245 ) {
1246 let (client, server) = duplex(8192);
1247 let (client_reader, client_writer) = tokio::io::split(client);
1248 let (server_reader, server_writer) = tokio::io::split(server);
1249 let transport = Arc::new(AppServerTransport::new(
1250 client_reader,
1251 client_writer,
1252 Default::default(),
1253 ));
1254 (
1255 Arc::new(CodexAppServerSession::new(transport)),
1256 server_reader,
1257 server_writer,
1258 )
1259 }
1260
1261 #[tokio::test]
1262 async fn starts_thread_and_turn_with_schema_shaped_requests() {
1263 let (session, server_reader, mut server_writer) = session();
1264 let server = tokio::spawn(async move {
1265 let mut lines = BufReader::new(server_reader).lines();
1266 let start: Value =
1267 serde_json::from_str(&lines.next_line().await.unwrap().unwrap()).unwrap();
1268 assert_eq!(start["method"], "thread/start");
1269 assert_eq!(start["params"]["model"], "gpt-5-codex");
1270 server_writer
1271 .write_all(
1272 br#"{"id":1,"result":{"thread":{"id":"thread-1"}}}
1273"#,
1274 )
1275 .await
1276 .unwrap();
1277 let turn: Value =
1278 serde_json::from_str(&lines.next_line().await.unwrap().unwrap()).unwrap();
1279 assert_eq!(turn["method"], "turn/start");
1280 assert_eq!(turn["params"]["threadId"], "thread-1");
1281 assert_eq!(
1282 turn["params"]["input"][0],
1283 json!({"type":"text","text":"hello"})
1284 );
1285 server_writer
1286 .write_all(
1287 br#"{"id":2,"result":{"turn":{"id":"turn-1"}}}
1288"#,
1289 )
1290 .await
1291 .unwrap();
1292 });
1293
1294 assert_eq!(
1295 session
1296 .start_thread(
1297 ThreadStartOptions {
1298 model: Some("gpt-5-codex".to_string()),
1299 ..Default::default()
1300 },
1301 Duration::from_secs(1),
1302 )
1303 .await
1304 .unwrap(),
1305 "thread-1"
1306 );
1307 assert_eq!(
1308 session
1309 .start_turn("hello".to_string(), Duration::from_secs(1))
1310 .await
1311 .unwrap(),
1312 "turn-1"
1313 );
1314 assert_eq!(session.snapshot().phase, SessionPhase::Running);
1315 server.await.unwrap();
1316 }
1317
1318 #[tokio::test]
1319 async fn resumes_only_the_requested_thread() {
1320 let (session, server_reader, mut server_writer) = session();
1321 let server = tokio::spawn(async move {
1322 let mut lines = BufReader::new(server_reader).lines();
1323 let request: Value =
1324 serde_json::from_str(&lines.next_line().await.unwrap().unwrap()).unwrap();
1325 assert_eq!(request["method"], "thread/resume");
1326 assert_eq!(request["params"]["threadId"], "thread-2");
1327 server_writer
1328 .write_all(
1329 br#"{"id":1,"result":{"thread":{"id":"thread-2"}}}
1330"#,
1331 )
1332 .await
1333 .unwrap();
1334 });
1335 assert_eq!(
1336 session
1337 .resume_thread(
1338 "thread-2".to_string(),
1339 None,
1340 Some("C:\\workspace".to_string()),
1341 Duration::from_secs(1),
1342 )
1343 .await
1344 .unwrap(),
1345 "thread-2"
1346 );
1347 assert_eq!(session.snapshot().thread_id.as_deref(), Some("thread-2"));
1348 server.await.unwrap();
1349 }
1350
1351 #[tokio::test]
1352 async fn builds_thread_lifecycle_and_account_requests_with_exact_shapes() {
1353 let (session, server_reader, mut server_writer) = session();
1354 session.state.lock().unwrap().thread_id = Some("thread-1".to_string());
1355 let server = tokio::spawn(async move {
1356 let mut lines = BufReader::new(server_reader).lines();
1357 let fork: Value =
1358 serde_json::from_str(&lines.next_line().await.unwrap().unwrap()).unwrap();
1359 assert_eq!(fork["method"], "thread/fork");
1360 assert_eq!(
1361 fork["params"],
1362 json!({"threadId":"thread-1","model":"gpt-5-codex","cwd":"C:\\workspace","lastTurnId":"turn-1","excludeTurns":true})
1363 );
1364 server_writer
1365 .write_all(
1366 br#"{"id":1,"result":{"thread":{"id":"thread-2"}}}
1367"#,
1368 )
1369 .await
1370 .unwrap();
1371 let read: Value =
1372 serde_json::from_str(&lines.next_line().await.unwrap().unwrap()).unwrap();
1373 assert_eq!(read["method"], "thread/read");
1374 assert_eq!(
1375 read["params"],
1376 json!({"threadId":"thread-1","includeTurns":true})
1377 );
1378 server_writer
1379 .write_all(
1380 br#"{"id":2,"result":{"thread":{"id":"thread-1"}}}
1381"#,
1382 )
1383 .await
1384 .unwrap();
1385 let list: Value =
1386 serde_json::from_str(&lines.next_line().await.unwrap().unwrap()).unwrap();
1387 assert_eq!(list["method"], "thread/list");
1388 assert_eq!(list["params"], json!({"limit":10,"searchTerm":"codex"}));
1389 server_writer
1390 .write_all(
1391 br#"{"id":3,"result":{"data":[]}}
1392"#,
1393 )
1394 .await
1395 .unwrap();
1396 let archive: Value =
1397 serde_json::from_str(&lines.next_line().await.unwrap().unwrap()).unwrap();
1398 assert_eq!(archive["method"], "thread/archive");
1399 assert_eq!(archive["params"], json!({"threadId":"thread-1"}));
1400 server_writer
1401 .write_all(
1402 br#"{"id":4,"result":{"archived":true}}
1403"#,
1404 )
1405 .await
1406 .unwrap();
1407 let compact: Value =
1408 serde_json::from_str(&lines.next_line().await.unwrap().unwrap()).unwrap();
1409 assert_eq!(compact["method"], "thread/compact/start");
1410 assert_eq!(compact["params"], json!({"threadId":"thread-1"}));
1411 server_writer
1412 .write_all(
1413 br#"{"id":5,"result":{"turn":{"id":"compact-1"}}}
1414"#,
1415 )
1416 .await
1417 .unwrap();
1418 let account: Value =
1419 serde_json::from_str(&lines.next_line().await.unwrap().unwrap()).unwrap();
1420 assert_eq!(account["method"], "account/read");
1421 assert_eq!(account["params"], json!({"refreshToken":false}));
1422 server_writer
1423 .write_all(
1424 br#"{"id":6,"result":{"type":"chatgpt","email":"a@example.com"}}
1425"#,
1426 )
1427 .await
1428 .unwrap();
1429 let logout: Value =
1430 serde_json::from_str(&lines.next_line().await.unwrap().unwrap()).unwrap();
1431 assert_eq!(logout["method"], "account/logout");
1432 assert_eq!(logout["params"], Value::Null);
1433 server_writer
1434 .write_all(
1435 br#"{"id":7,"result":{}}
1436"#,
1437 )
1438 .await
1439 .unwrap();
1440 let login: Value =
1441 serde_json::from_str(&lines.next_line().await.unwrap().unwrap()).unwrap();
1442 assert_eq!(login["method"], "account/login/start");
1443 assert_eq!(login["params"], json!({"type":"chatgptDeviceCode"}));
1444 server_writer
1445 .write_all(
1446 br#"{"id":8,"result":{"loginId":"login-1","userCode":"ABCD"}}
1447"#,
1448 )
1449 .await
1450 .unwrap();
1451 let cancel: Value =
1452 serde_json::from_str(&lines.next_line().await.unwrap().unwrap()).unwrap();
1453 assert_eq!(cancel["method"], "account/login/cancel");
1454 assert_eq!(cancel["params"], json!({"loginId":"login-1"}));
1455 server_writer
1456 .write_all(
1457 br#"{"id":9,"result":{}}
1458"#,
1459 )
1460 .await
1461 .unwrap();
1462 });
1463 assert_eq!(
1464 session
1465 .fork_thread(
1466 ThreadForkOptions {
1467 thread_id: "thread-1".to_string(),
1468 model: Some("gpt-5-codex".to_string()),
1469 cwd: Some("C:\\workspace".to_string()),
1470 last_turn_id: Some("turn-1".to_string()),
1471 exclude_turns: Some(true)
1472 },
1473 Duration::from_secs(1)
1474 )
1475 .await
1476 .unwrap(),
1477 "thread-2"
1478 );
1479 session
1480 .read_thread(true, Duration::from_secs(1))
1481 .await
1482 .unwrap();
1483 session
1484 .list_threads(
1485 ThreadListOptions {
1486 limit: Some(10),
1487 search_term: Some("codex".to_string()),
1488 ..Default::default()
1489 },
1490 Duration::from_secs(1),
1491 )
1492 .await
1493 .unwrap();
1494 session
1495 .archive_thread(Duration::from_secs(1))
1496 .await
1497 .unwrap();
1498 session
1499 .compact_thread(Duration::from_secs(1))
1500 .await
1501 .unwrap();
1502 session
1503 .read_account(false, Duration::from_secs(1))
1504 .await
1505 .unwrap();
1506 session
1507 .logout_account(Duration::from_secs(1))
1508 .await
1509 .unwrap();
1510 session
1511 .login_account(
1512 AccountLoginOptions::ChatgptDeviceCode,
1513 Duration::from_secs(1),
1514 )
1515 .await
1516 .unwrap();
1517 session
1518 .cancel_login("login-1".to_string(), Duration::from_secs(1))
1519 .await
1520 .unwrap();
1521 server.await.unwrap();
1522 }
1523
1524 #[tokio::test]
1525 async fn parses_account_login_completion_without_leaking_credentials() {
1526 let (session, _reader, _writer) = session();
1527 let event = session
1528 .apply_incoming(AppServerIncoming::Notification {
1529 method: "account/login/completed".to_string(),
1530 params: json!({"loginId":"login-1","success":false,"error":"cancelled"}),
1531 })
1532 .unwrap();
1533 assert!(matches!(
1534 event,
1535 CodexAppServerEvent::AccountLoginCompleted {
1536 login_id: Some(ref id),
1537 success: false,
1538 error: Some(ref error),
1539 } if id == "login-1" && error == "cancelled"
1540 ));
1541 }
1542
1543 #[tokio::test]
1549 async fn account_login_completed_missing_success_reports_the_right_field_type() {
1550 let (session, _reader, _writer) = session();
1551 let error = session
1552 .apply_incoming(AppServerIncoming::Notification {
1553 method: "account/login/completed".to_string(),
1554 params: json!({"loginId": "login-1"}),
1555 })
1556 .unwrap_err();
1557 assert!(matches!(
1558 error,
1559 CodexAppServerProtocolError::MissingBoolField("success")
1560 ));
1561 }
1562
1563 #[tokio::test]
1564 async fn fork_rejects_a_thread_id_that_is_not_the_active_thread() {
1565 let (session, _reader, _writer) = session();
1566 session.state.lock().unwrap().thread_id = Some("thread-1".to_string());
1567 let error = session
1568 .fork_thread(
1569 ThreadForkOptions {
1570 thread_id: "thread-2".to_string(),
1571 model: None,
1572 cwd: None,
1573 last_turn_id: None,
1574 exclude_turns: None,
1575 },
1576 Duration::from_secs(1),
1577 )
1578 .await
1579 .unwrap_err();
1580 assert!(matches!(
1581 error,
1582 CodexAppServerProtocolError::ThreadScopeMismatch { .. }
1583 ));
1584 }
1585
1586 #[tokio::test]
1587 async fn steers_and_interrupts_only_the_active_turn() {
1588 let (session, server_reader, mut server_writer) = session();
1589 {
1590 let mut state = session.state.lock().unwrap();
1591 state.thread_id = Some("thread-1".to_string());
1592 state.turn_id = Some("turn-1".to_string());
1593 }
1594 let server = tokio::spawn(async move {
1595 let mut lines = BufReader::new(server_reader).lines();
1596 let steer: Value =
1597 serde_json::from_str(&lines.next_line().await.unwrap().unwrap()).unwrap();
1598 assert_eq!(steer["method"], "turn/steer");
1599 assert_eq!(steer["params"]["expectedTurnId"], "turn-1");
1600 server_writer
1601 .write_all(
1602 br#"{"id":1,"result":{"accepted":true}}
1603"#,
1604 )
1605 .await
1606 .unwrap();
1607 let interrupt: Value =
1608 serde_json::from_str(&lines.next_line().await.unwrap().unwrap()).unwrap();
1609 assert_eq!(interrupt["method"], "turn/interrupt");
1610 assert_eq!(interrupt["params"]["turnId"], "turn-1");
1611 server_writer
1612 .write_all(
1613 br#"{"id":2,"result":{"accepted":true}}
1614"#,
1615 )
1616 .await
1617 .unwrap();
1618 });
1619 assert_eq!(
1620 session
1621 .steer_turn(
1622 "continue".to_string(),
1623 "turn-1".to_string(),
1624 Duration::from_secs(1)
1625 )
1626 .await
1627 .unwrap()["accepted"],
1628 true
1629 );
1630 assert_eq!(
1631 session
1632 .interrupt_turn(Duration::from_secs(1))
1633 .await
1634 .unwrap()["accepted"],
1635 true
1636 );
1637 assert!(matches!(
1638 session
1639 .steer_turn(
1640 "stale".to_string(),
1641 "turn-old".to_string(),
1642 Duration::from_secs(1)
1643 )
1644 .await,
1645 Err(CodexAppServerProtocolError::TurnScopeMismatch { .. })
1646 ));
1647 server.await.unwrap();
1648 }
1649
1650 #[tokio::test]
1651 async fn reconciles_delta_preview_with_authoritative_completion() {
1652 let (session, _reader, _writer) = session();
1653 session.state.lock().unwrap().thread_id = Some("thread-1".to_string());
1654 session.state.lock().unwrap().turn_id = Some("turn-1".to_string());
1655
1656 let delta = session
1657 .apply_incoming(AppServerIncoming::Notification {
1658 method: "item/agentMessage/delta".to_string(),
1659 params: json!({"threadId":"thread-1","turnId":"turn-1","itemId":"item-1","delta":"hel"}),
1660 })
1661 .unwrap();
1662 assert!(
1663 matches!(delta, CodexAppServerEvent::AgentMessageDelta { ref preview, .. } if preview == "hel")
1664 );
1665 session
1666 .apply_incoming(AppServerIncoming::Notification {
1667 method: "item/agentMessage/delta".to_string(),
1668 params: json!({"threadId":"thread-1","turnId":"turn-1","itemId":"item-1","delta":"lo"}),
1669 })
1670 .unwrap();
1671 session
1672 .apply_incoming(AppServerIncoming::Notification {
1673 method: "item/completed".to_string(),
1674 params: json!({"threadId":"thread-1","turnId":"turn-1","item":{"id":"item-1","type":"agentMessage","text":"hello"}}),
1675 })
1676 .unwrap();
1677 let item = session.snapshot().items.remove("item-1").unwrap();
1678 assert_eq!(item.preview, "");
1679 assert!(item.completed);
1680 assert_eq!(item.authoritative.unwrap()["text"], "hello");
1681
1682 session
1683 .apply_incoming(AppServerIncoming::Notification {
1684 method: "turn/completed".to_string(),
1685 params: json!({"threadId":"thread-1","turn":{"id":"turn-1","status":"completed"}}),
1686 })
1687 .unwrap();
1688 let snapshot = session.snapshot();
1689 assert_eq!(snapshot.phase, SessionPhase::Completed);
1690 assert_eq!(snapshot.turn_id, None);
1691 }
1692
1693 #[tokio::test]
1694 async fn rejects_cross_thread_events_and_retains_unknown_notifications_bounded() {
1695 let (session, _reader, _writer) = session();
1696 session.state.lock().unwrap().thread_id = Some("thread-1".to_string());
1697 let error = session
1698 .apply_incoming(AppServerIncoming::Notification {
1699 method: "thread/status/changed".to_string(),
1700 params: json!({"threadId":"thread-2","status":{"type":"idle"}}),
1701 })
1702 .unwrap_err();
1703 assert!(matches!(
1704 error,
1705 CodexAppServerProtocolError::ThreadScopeMismatch { .. }
1706 ));
1707
1708 for _ in 0..(MAX_RAW_EVENTS + 7) {
1709 session
1710 .apply_incoming(AppServerIncoming::Notification {
1711 method: "future/event".to_string(),
1712 params: json!({"ok":true}),
1713 })
1714 .unwrap();
1715 }
1716 assert_eq!(session.snapshot().raw_unknown_events.len(), MAX_RAW_EVENTS);
1717 }
1718
1719 #[tokio::test]
1726 async fn server_request_resolved_clears_only_that_one_pending_request() {
1727 let (session, _reader, _writer) = session();
1728 {
1729 let mut state = session.state.lock().unwrap();
1730 state.thread_id = Some("thread-1".to_string());
1731 state.turn_id = Some("turn-1".to_string());
1732 }
1733 session
1734 .accept_server_request(AppServerIncoming::Request {
1735 id: RpcId::String("resolved-me".to_string()),
1736 method: "item/tool/requestUserInput".to_string(),
1737 params: json!({"threadId": "thread-1", "turnId": "turn-1", "itemId": "a"}),
1738 })
1739 .unwrap();
1740 session
1741 .accept_server_request(AppServerIncoming::Request {
1742 id: RpcId::String("still-pending".to_string()),
1743 method: "item/tool/requestUserInput".to_string(),
1744 params: json!({"threadId": "thread-1", "turnId": "turn-1", "itemId": "b"}),
1745 })
1746 .unwrap();
1747
1748 let event = session
1749 .apply_incoming(AppServerIncoming::Notification {
1750 method: "serverRequest/resolved".to_string(),
1751 params: json!({"threadId": "thread-1", "requestId": "resolved-me"}),
1752 })
1753 .unwrap();
1754 assert_eq!(
1755 event,
1756 CodexAppServerEvent::ServerRequestResolved {
1757 thread_id: "thread-1".to_string(),
1758 request_id: RpcId::String("resolved-me".to_string()),
1759 }
1760 );
1761
1762 let remaining = session.pending_server_requests();
1763 assert_eq!(remaining.len(), 1);
1764 assert_eq!(remaining[0].id, RpcId::String("still-pending".to_string()));
1765 }
1766
1767 #[tokio::test]
1776 async fn a_turn_completed_notification_that_lands_before_the_turn_start_response_is_not_overwritten(
1777 ) {
1778 let (session, server_reader, mut server_writer) = session();
1779 let server = tokio::spawn(async move {
1780 let mut lines = BufReader::new(server_reader).lines();
1781 let _start: Value =
1782 serde_json::from_str(&lines.next_line().await.unwrap().unwrap()).unwrap();
1783 server_writer
1784 .write_all(br#"{"id":1,"result":{"thread":{"id":"thread-1"}}}
1785"#)
1786 .await
1787 .unwrap();
1788 let _turn: Value =
1789 serde_json::from_str(&lines.next_line().await.unwrap().unwrap()).unwrap();
1790 server_writer
1793 .write_all(
1794 b"{\"method\":\"turn/completed\",\"params\":{\"threadId\":\"thread-1\",\"turn\":{\"id\":\"turn-1\",\"status\":\"completed\"}}}\n",
1795 )
1796 .await
1797 .unwrap();
1798 server_writer
1799 .write_all(br#"{"id":2,"result":{"turn":{"id":"turn-1"}}}
1800"#)
1801 .await
1802 .unwrap();
1803 });
1804
1805 session
1806 .start_thread(ThreadStartOptions::default(), Duration::from_secs(1))
1807 .await
1808 .unwrap();
1809
1810 let session_for_turn = session.clone();
1811 let start_turn_task = tokio::spawn(async move {
1812 session_for_turn
1813 .start_turn("hello".to_string(), Duration::from_secs(1))
1814 .await
1815 });
1816
1817 let notification = session.transport.next_incoming().await.unwrap();
1821 session.apply_incoming(notification).unwrap();
1822 assert_eq!(session.snapshot().phase, SessionPhase::Completed);
1823
1824 let turn_id = start_turn_task.await.unwrap().unwrap();
1825 assert_eq!(turn_id, "turn-1", "start_turn still reports success to its own caller");
1826
1827 let snapshot = session.snapshot();
1828 assert_eq!(
1829 snapshot.phase,
1830 SessionPhase::Completed,
1831 "a turn/completed that landed first must not be overwritten back to Running"
1832 );
1833 assert!(snapshot.turn_id.is_none());
1834
1835 server.await.unwrap();
1836 }
1837
1838 #[tokio::test]
1842 async fn resume_thread_derives_an_in_progress_turn_instead_of_dropping_it() {
1843 let (session, server_reader, mut server_writer) = session();
1844 let server = tokio::spawn(async move {
1845 let mut lines = BufReader::new(server_reader).lines();
1846 let _request: Value =
1847 serde_json::from_str(&lines.next_line().await.unwrap().unwrap()).unwrap();
1848 server_writer
1849 .write_all(
1850 br#"{"id":1,"result":{"thread":{"id":"thread-1","turns":[{"id":"turn-old","status":"completed"},{"id":"turn-1","status":"inProgress"}]}}}
1851"#,
1852 )
1853 .await
1854 .unwrap();
1855 });
1856
1857 session
1858 .resume_thread("thread-1".to_string(), None, None, Duration::from_secs(1))
1859 .await
1860 .unwrap();
1861
1862 let snapshot = session.snapshot();
1863 assert_eq!(snapshot.phase, SessionPhase::Running);
1864 assert_eq!(snapshot.turn_id.as_deref(), Some("turn-1"));
1865
1866 server.await.unwrap();
1867 }
1868
1869 #[tokio::test]
1870 async fn resume_thread_with_no_in_progress_turn_still_resets_to_idle() {
1871 let (session, server_reader, mut server_writer) = session();
1872 let server = tokio::spawn(async move {
1873 let mut lines = BufReader::new(server_reader).lines();
1874 let _request: Value =
1875 serde_json::from_str(&lines.next_line().await.unwrap().unwrap()).unwrap();
1876 server_writer
1877 .write_all(
1878 br#"{"id":1,"result":{"thread":{"id":"thread-1","turns":[{"id":"turn-old","status":"completed"}]}}}
1879"#,
1880 )
1881 .await
1882 .unwrap();
1883 });
1884
1885 session
1886 .resume_thread("thread-1".to_string(), None, None, Duration::from_secs(1))
1887 .await
1888 .unwrap();
1889
1890 let snapshot = session.snapshot();
1891 assert_eq!(snapshot.phase, SessionPhase::Idle);
1892 assert!(snapshot.turn_id.is_none());
1893
1894 server.await.unwrap();
1895 }
1896
1897 #[tokio::test]
1902 async fn a_redundant_thread_started_for_the_same_thread_does_not_wipe_an_active_turn() {
1903 let (session, server_reader, mut server_writer) = session();
1904 let server = tokio::spawn(async move {
1905 let mut lines = BufReader::new(server_reader).lines();
1906 let _start: Value =
1907 serde_json::from_str(&lines.next_line().await.unwrap().unwrap()).unwrap();
1908 server_writer
1909 .write_all(br#"{"id":1,"result":{"thread":{"id":"thread-1"}}}
1910"#)
1911 .await
1912 .unwrap();
1913 let _turn: Value =
1914 serde_json::from_str(&lines.next_line().await.unwrap().unwrap()).unwrap();
1915 server_writer
1916 .write_all(br#"{"id":2,"result":{"turn":{"id":"turn-1"}}}
1917"#)
1918 .await
1919 .unwrap();
1920 });
1921
1922 session
1923 .start_thread(ThreadStartOptions::default(), Duration::from_secs(1))
1924 .await
1925 .unwrap();
1926 session
1927 .start_turn("hello".to_string(), Duration::from_secs(1))
1928 .await
1929 .unwrap();
1930 assert_eq!(session.snapshot().phase, SessionPhase::Running);
1931
1932 session
1933 .apply_incoming(AppServerIncoming::Notification {
1934 method: "thread/started".to_string(),
1935 params: json!({"thread": {"id": "thread-1"}}),
1936 })
1937 .unwrap();
1938
1939 let snapshot = session.snapshot();
1940 assert_eq!(
1941 snapshot.phase,
1942 SessionPhase::Running,
1943 "a redundant thread/started for the SAME thread must not wipe the active turn"
1944 );
1945 assert_eq!(snapshot.turn_id.as_deref(), Some("turn-1"));
1946
1947 server.await.unwrap();
1948 }
1949
1950 #[tokio::test]
1951 async fn a_thread_started_for_a_genuinely_new_thread_still_resets_turn_state() {
1952 let (session, _reader, _writer) = session();
1953 {
1954 let mut state = session.state.lock().unwrap();
1955 state.thread_id = Some("thread-1".to_string());
1956 state.turn_id = Some("turn-1".to_string());
1957 state.phase = SessionPhase::Running;
1958 }
1959
1960 session
1961 .apply_incoming(AppServerIncoming::Notification {
1962 method: "thread/started".to_string(),
1963 params: json!({"thread": {"id": "thread-2"}}),
1964 })
1965 .unwrap();
1966
1967 let snapshot = session.snapshot();
1968 assert_eq!(snapshot.thread_id.as_deref(), Some("thread-2"));
1969 assert_eq!(snapshot.phase, SessionPhase::Idle);
1970 assert!(snapshot.turn_id.is_none());
1971 }
1972
1973 #[tokio::test]
1978 async fn turn_completed_with_interrupted_status_maps_to_its_own_phase() {
1979 let (session, _reader, _writer) = session();
1980 {
1981 let mut state = session.state.lock().unwrap();
1982 state.thread_id = Some("thread-1".to_string());
1983 state.turn_id = Some("turn-1".to_string());
1984 state.phase = SessionPhase::Running;
1985 }
1986
1987 session
1988 .apply_incoming(AppServerIncoming::Notification {
1989 method: "turn/completed".to_string(),
1990 params: json!({
1991 "threadId": "thread-1",
1992 "turn": {"id": "turn-1", "status": "interrupted"},
1993 }),
1994 })
1995 .unwrap();
1996
1997 let snapshot = session.snapshot();
1998 assert_eq!(
1999 snapshot.phase,
2000 SessionPhase::Interrupted,
2001 "an interrupted (cancelled) turn must not read the same as a genuine failure"
2002 );
2003 assert!(snapshot.turn_id.is_none());
2004 }
2005
2006 #[tokio::test]
2007 async fn turn_completed_with_a_genuine_failure_still_maps_to_failed() {
2008 let (session, _reader, _writer) = session();
2009 {
2010 let mut state = session.state.lock().unwrap();
2011 state.thread_id = Some("thread-1".to_string());
2012 state.turn_id = Some("turn-1".to_string());
2013 state.phase = SessionPhase::Running;
2014 }
2015
2016 session
2017 .apply_incoming(AppServerIncoming::Notification {
2018 method: "turn/completed".to_string(),
2019 params: json!({
2020 "threadId": "thread-1",
2021 "turn": {"id": "turn-1", "status": "failed"},
2022 }),
2023 })
2024 .unwrap();
2025
2026 assert_eq!(session.snapshot().phase, SessionPhase::Failed);
2027 }
2028
2029 #[tokio::test]
2040 async fn concurrent_start_turn_calls_do_not_both_send_turn_start() {
2041 let (session, server_reader, mut server_writer) = session();
2042 let server = tokio::spawn(async move {
2043 let mut lines = BufReader::new(server_reader).lines();
2044 let _start: Value =
2045 serde_json::from_str(&lines.next_line().await.unwrap().unwrap()).unwrap();
2046 server_writer
2047 .write_all(br#"{"id":1,"result":{"thread":{"id":"thread-1"}}}
2048"#)
2049 .await
2050 .unwrap();
2051
2052 let turn: Value =
2055 serde_json::from_str(&lines.next_line().await.unwrap().unwrap()).unwrap();
2056 assert_eq!(turn["method"], "turn/start");
2057 server_writer
2058 .write_all(br#"{"id":2,"result":{"turn":{"id":"turn-1"}}}
2059"#)
2060 .await
2061 .unwrap();
2062
2063 let extra = tokio::time::timeout(Duration::from_millis(200), lines.next_line()).await;
2066 assert!(
2067 extra.is_err(),
2068 "a second concurrent start_turn call must be rejected locally, not also sent over the wire"
2069 );
2070 });
2071
2072 session
2073 .start_thread(ThreadStartOptions::default(), Duration::from_secs(1))
2074 .await
2075 .unwrap();
2076
2077 let session_a = session.clone();
2078 let session_b = session.clone();
2079 let (result_a, result_b) = tokio::join!(
2080 session_a.start_turn("hello".to_string(), Duration::from_secs(1)),
2081 session_b.start_turn("world".to_string(), Duration::from_secs(1)),
2082 );
2083
2084 let results = [result_a, result_b];
2085 let ok_count = results.iter().filter(|r| r.is_ok()).count();
2086 let rejected_count = results
2087 .iter()
2088 .filter(|r| matches!(r, Err(CodexAppServerProtocolError::TurnAlreadyActive(_))))
2089 .count();
2090 assert_eq!(ok_count, 1, "exactly one of the two concurrent calls should succeed");
2091 assert_eq!(
2092 rejected_count, 1,
2093 "the other must be rejected with TurnAlreadyActive, not also sent over the wire"
2094 );
2095
2096 server.await.unwrap();
2097 }
2098
2099 #[tokio::test]
2100 async fn routes_supported_requests_without_auto_approval_and_scopes_them() {
2101 let (session, _reader, _writer) = session();
2102 {
2103 let mut state = session.state.lock().unwrap();
2104 state.thread_id = Some("thread-1".to_string());
2105 state.turn_id = Some("turn-1".to_string());
2106 }
2107 for (id, method) in [
2108 ("command", "item/commandExecution/requestApproval"),
2109 ("file", "item/fileChange/requestApproval"),
2110 ("permissions", "item/permissions/requestApproval"),
2111 ("input", "item/tool/requestUserInput"),
2112 ] {
2113 let pending = session
2114 .accept_server_request(AppServerIncoming::Request {
2115 id: RpcId::String(id.to_string()),
2116 method: method.to_string(),
2117 params: json!({"threadId":"thread-1","turnId":"turn-1","itemId":id}),
2118 })
2119 .unwrap();
2120 assert_eq!(pending.method, method);
2121 }
2122 let mcp = session
2123 .accept_server_request(AppServerIncoming::Request {
2124 id: RpcId::String("mcp".to_string()),
2125 method: "mcpServer/elicitation/request".to_string(),
2126 params: json!({"message":"confirm","mode":"form","requestedSchema":{}}),
2127 })
2128 .unwrap();
2129 assert_eq!(mcp.kind, ServerRequestKind::McpElicitation);
2130 assert_eq!(session.pending_server_requests().len(), 5);
2131 assert!(matches!(
2132 session.accept_server_request(AppServerIncoming::Request {
2133 id: RpcId::String("unknown".to_string()),
2134 method: "future/request".to_string(),
2135 params: json!({})
2136 }),
2137 Err(CodexAppServerProtocolError::UnsupportedServerRequest(_))
2138 ));
2139 assert!(matches!(
2140 session.accept_server_request(AppServerIncoming::Request {
2141 id: RpcId::String("cross".to_string()),
2142 method: "item/tool/requestUserInput".to_string(),
2143 params: json!({"threadId":"thread-2","turnId":"turn-1","itemId":"x"})
2144 }),
2145 Err(CodexAppServerProtocolError::ThreadScopeMismatch { .. })
2146 ));
2147 }
2148
2149 #[tokio::test]
2150 async fn rejects_duplicate_or_unknown_decisions_and_clears_on_turn_completion() {
2151 let (session, _reader, _writer) = session();
2152 {
2153 let mut state = session.state.lock().unwrap();
2154 state.thread_id = Some("thread-1".to_string());
2155 state.turn_id = Some("turn-1".to_string());
2156 }
2157 let id = RpcId::String("approval".to_string());
2158 let incoming = AppServerIncoming::Request {
2159 id: id.clone(),
2160 method: "item/tool/requestUserInput".to_string(),
2161 params: json!({"threadId":"thread-1","turnId":"turn-1","itemId":"item-1"}),
2162 };
2163 session.accept_server_request(incoming.clone()).unwrap();
2164 assert!(matches!(
2165 session.accept_server_request(incoming),
2166 Err(CodexAppServerProtocolError::DuplicateServerRequest)
2167 ));
2168 assert!(matches!(
2169 session
2170 .respond_server_request(
2171 &RpcId::String("missing".to_string()),
2172 Ok(json!({})),
2173 Duration::from_millis(10)
2174 )
2175 .await,
2176 Err(CodexAppServerProtocolError::UnknownServerRequest)
2177 ));
2178 session
2179 .apply_incoming(AppServerIncoming::Notification {
2180 method: "turn/completed".to_string(),
2181 params: json!({"threadId":"thread-1","turn":{"id":"turn-1","status":"completed"}}),
2182 })
2183 .unwrap();
2184 assert!(session.pending_server_requests().is_empty());
2185 }
2186
2187 #[tokio::test]
2193 async fn an_item_scoped_request_missing_thread_id_is_rejected_not_silently_queued() {
2194 let (session, _reader, _writer) = session();
2195 {
2196 let mut state = session.state.lock().unwrap();
2197 state.thread_id = Some("thread-1".to_string());
2198 state.turn_id = Some("turn-1".to_string());
2199 }
2200 assert!(matches!(
2201 session.accept_server_request(AppServerIncoming::Request {
2202 id: RpcId::String("no-scope".to_string()),
2203 method: "item/tool/requestUserInput".to_string(),
2204 params: json!({"itemId": "item-1"}),
2205 }),
2206 Err(CodexAppServerProtocolError::MissingStringField("threadId"))
2207 ));
2208 assert!(session.pending_server_requests().is_empty());
2209
2210 let mcp = session
2213 .accept_server_request(AppServerIncoming::Request {
2214 id: RpcId::String("mcp".to_string()),
2215 method: "mcpServer/elicitation/request".to_string(),
2216 params: json!({"message": "confirm"}),
2217 })
2218 .unwrap();
2219 assert!(mcp.thread_id.is_none());
2220 }
2221
2222 #[tokio::test]
2228 async fn thread_transitions_clear_stale_pending_requests() {
2229 let (session, server_reader, mut server_writer) = session();
2230 let server = tokio::spawn(async move {
2231 let mut lines = BufReader::new(server_reader).lines();
2232 let _start: Value =
2233 serde_json::from_str(&lines.next_line().await.unwrap().unwrap()).unwrap();
2234 server_writer
2235 .write_all(br#"{"id":1,"result":{"thread":{"id":"thread-2"}}}
2236"#)
2237 .await
2238 .unwrap();
2239 });
2240
2241 {
2242 let mut state = session.state.lock().unwrap();
2243 state.thread_id = Some("thread-1".to_string());
2244 }
2245 session
2246 .accept_server_request(AppServerIncoming::Request {
2247 id: RpcId::String("stale".to_string()),
2248 method: "mcpServer/elicitation/request".to_string(),
2249 params: json!({"message": "confirm"}),
2250 })
2251 .unwrap();
2252 assert_eq!(session.pending_server_requests().len(), 1);
2253
2254 session
2255 .start_thread(ThreadStartOptions::default(), Duration::from_secs(1))
2256 .await
2257 .unwrap();
2258 assert!(
2259 session.pending_server_requests().is_empty(),
2260 "start_thread must clear pending requests queued under the previous thread"
2261 );
2262
2263 server.await.unwrap();
2264 }
2265
2266 #[tokio::test]
2275 async fn concurrent_respond_calls_for_the_same_id_do_not_both_succeed() {
2276 let (session, server_reader, mut server_writer) = session();
2277 let server = tokio::spawn(async move {
2278 let mut lines = BufReader::new(server_reader).lines();
2279 let _line = lines.next_line().await.unwrap().unwrap();
2281 server_writer.write_all(b"ignored\n").await.ok();
2282 let extra = tokio::time::timeout(Duration::from_millis(200), lines.next_line()).await;
2283 assert!(extra.is_err(), "a second concurrent respond call must not also write a response");
2284 });
2285
2286 {
2287 let mut state = session.state.lock().unwrap();
2288 state.thread_id = Some("thread-1".to_string());
2289 }
2290 let id = RpcId::String("approval".to_string());
2291 session
2292 .accept_server_request(AppServerIncoming::Request {
2293 id: id.clone(),
2294 method: "mcpServer/elicitation/request".to_string(),
2295 params: json!({"message": "confirm"}),
2296 })
2297 .unwrap();
2298
2299 let session_a = session.clone();
2300 let session_b = session.clone();
2301 let id_a = id.clone();
2302 let id_b = id.clone();
2303 let (result_a, result_b) = tokio::join!(
2304 session_a.respond_server_request(&id_a, Ok(json!({"approved": true})), Duration::from_secs(1)),
2305 session_b.respond_server_request(&id_b, Ok(json!({"approved": true})), Duration::from_secs(1)),
2306 );
2307 let results = [result_a, result_b];
2308 let ok_count = results.iter().filter(|r| r.is_ok()).count();
2309 let unknown_count = results
2310 .iter()
2311 .filter(|r| matches!(r, Err(CodexAppServerProtocolError::UnknownServerRequest)))
2312 .count();
2313 assert_eq!(ok_count, 1, "exactly one of the two concurrent respond calls should succeed");
2314 assert_eq!(unknown_count, 1, "the other must see it already gone, not also send a response");
2315
2316 server.abort();
2317 }
2318
2319 #[tokio::test]
2327 async fn a_request_claiming_a_thread_or_turn_is_rejected_when_none_is_active() {
2328 let (session, _reader, _writer) = session();
2329 assert!(matches!(
2330 session.accept_server_request(AppServerIncoming::Request {
2331 id: RpcId::String("no-thread".to_string()),
2332 method: "item/tool/requestUserInput".to_string(),
2333 params: json!({"threadId": "thread-1", "turnId": "turn-1", "itemId": "x"}),
2334 }),
2335 Err(CodexAppServerProtocolError::ThreadNotLoaded)
2336 ));
2337
2338 {
2339 let mut state = session.state.lock().unwrap();
2340 state.thread_id = Some("thread-1".to_string());
2341 }
2342 assert!(matches!(
2343 session.accept_server_request(AppServerIncoming::Request {
2344 id: RpcId::String("no-turn".to_string()),
2345 method: "item/tool/requestUserInput".to_string(),
2346 params: json!({"threadId": "thread-1", "turnId": "turn-1", "itemId": "x"}),
2347 }),
2348 Err(CodexAppServerProtocolError::TurnNotActive)
2349 ));
2350 assert!(session.pending_server_requests().is_empty());
2351 }
2352
2353 #[tokio::test]
2359 async fn a_failed_respond_is_not_resurrected_after_the_turn_it_was_queued_under_ends() {
2360 let (session, server_reader, server_writer) = session();
2361
2362 {
2363 let mut state = session.state.lock().unwrap();
2364 state.thread_id = Some("thread-1".to_string());
2365 state.turn_id = Some("turn-1".to_string());
2366 }
2367 let id = RpcId::String("approval".to_string());
2368 session
2369 .accept_server_request(AppServerIncoming::Request {
2370 id: id.clone(),
2371 method: "item/tool/requestUserInput".to_string(),
2372 params: json!({"threadId": "thread-1", "turnId": "turn-1", "itemId": "x"}),
2373 })
2374 .unwrap();
2375
2376 drop(server_reader);
2381 drop(server_writer);
2382
2383 session.state.lock().unwrap().pending_requests.clear();
2387 session.state.lock().unwrap().turn_id = None;
2388
2389 let result = session
2390 .respond_server_request(&id, Ok(json!({"approved": true})), Duration::from_secs(1))
2391 .await;
2392 assert!(result.is_err(), "the write should fail once the transport is closed");
2393 assert!(
2394 session.pending_server_requests().is_empty(),
2395 "a stale approval must not be resurrected once its turn has ended"
2396 );
2397 }
2398
2399 #[tokio::test]
2403 async fn accept_server_request_rejects_once_the_pending_cap_is_reached() {
2404 let (session, _reader, _writer) = session();
2405 {
2406 let mut state = session.state.lock().unwrap();
2407 state.thread_id = Some("thread-1".to_string());
2408 }
2409 for i in 0..MAX_PENDING_SERVER_REQUESTS {
2410 session
2411 .accept_server_request(AppServerIncoming::Request {
2412 id: RpcId::String(format!("mcp-{i}")),
2413 method: "mcpServer/elicitation/request".to_string(),
2414 params: json!({"message": "confirm"}),
2415 })
2416 .unwrap();
2417 }
2418 assert!(matches!(
2419 session.accept_server_request(AppServerIncoming::Request {
2420 id: RpcId::String("one-too-many".to_string()),
2421 method: "mcpServer/elicitation/request".to_string(),
2422 params: json!({"message": "confirm"}),
2423 }),
2424 Err(CodexAppServerProtocolError::TooManyPendingRequests(
2425 MAX_PENDING_SERVER_REQUESTS
2426 ))
2427 ));
2428 }
2429
2430 #[tokio::test]
2437 async fn a_turn_only_request_is_still_scoped_to_the_active_turn() {
2438 let (session, _reader, _writer) = session();
2439 assert!(matches!(
2440 session.accept_server_request(AppServerIncoming::Request {
2441 id: RpcId::String("turn-only-no-turn".to_string()),
2442 method: "mcpServer/elicitation/request".to_string(),
2443 params: json!({"turnId": "turn-1"}),
2444 }),
2445 Err(CodexAppServerProtocolError::TurnNotActive)
2446 ));
2447
2448 {
2449 let mut state = session.state.lock().unwrap();
2450 state.turn_id = Some("turn-1".to_string());
2451 }
2452 assert!(matches!(
2453 session.accept_server_request(AppServerIncoming::Request {
2454 id: RpcId::String("turn-only-stale".to_string()),
2455 method: "mcpServer/elicitation/request".to_string(),
2456 params: json!({"turnId": "turn-stale"}),
2457 }),
2458 Err(CodexAppServerProtocolError::TurnScopeMismatch { .. })
2459 ));
2460 assert!(session.pending_server_requests().is_empty());
2461
2462 let pending = session
2463 .accept_server_request(AppServerIncoming::Request {
2464 id: RpcId::String("turn-only-current".to_string()),
2465 method: "mcpServer/elicitation/request".to_string(),
2466 params: json!({"turnId": "turn-1"}),
2467 })
2468 .unwrap();
2469 assert_eq!(pending.turn_id.as_deref(), Some("turn-1"));
2470 }
2471
2472 #[tokio::test]
2478 async fn a_failed_turn_only_respond_is_not_resurrected_after_its_turn_ends() {
2479 let (session, server_reader, server_writer) = session();
2480 {
2481 let mut state = session.state.lock().unwrap();
2482 state.turn_id = Some("turn-1".to_string());
2483 }
2484 let id = RpcId::String("turn-only".to_string());
2485 session
2486 .accept_server_request(AppServerIncoming::Request {
2487 id: id.clone(),
2488 method: "mcpServer/elicitation/request".to_string(),
2489 params: json!({"turnId": "turn-1"}),
2490 })
2491 .unwrap();
2492
2493 drop(server_reader);
2494 drop(server_writer);
2495 session.state.lock().unwrap().pending_requests.clear();
2496 session.state.lock().unwrap().turn_id = None;
2497
2498 let result = session
2499 .respond_server_request(&id, Ok(json!({"approved": true})), Duration::from_secs(1))
2500 .await;
2501 assert!(result.is_err(), "the write should fail once the transport is closed");
2502 assert!(
2503 session.pending_server_requests().is_empty(),
2504 "a stale turn-only approval must not be resurrected once its turn has ended"
2505 );
2506 }
2507
2508 #[tokio::test]
2533 async fn a_respond_future_cancelled_after_the_write_is_queued_does_not_reinsert() {
2534 let (client, _server) = duplex(1);
2535 let (client_reader, client_writer) = tokio::io::split(client);
2536 let transport = Arc::new(AppServerTransport::new(
2537 client_reader,
2538 client_writer,
2539 Default::default(),
2540 ));
2541 let session = Arc::new(CodexAppServerSession::new(transport));
2542 {
2543 let mut state = session.state.lock().unwrap();
2544 state.thread_id = Some("thread-1".to_string());
2545 state.turn_id = Some("turn-1".to_string());
2546 }
2547 let id = RpcId::String("approval".to_string());
2548 session
2549 .accept_server_request(AppServerIncoming::Request {
2550 id: id.clone(),
2551 method: "item/tool/requestUserInput".to_string(),
2552 params: json!({"threadId": "thread-1", "turnId": "turn-1", "itemId": "x"}),
2553 })
2554 .unwrap();
2555
2556 let task_session = session.clone();
2557 let task_id = id.clone();
2558 let handle = tokio::spawn(async move {
2559 task_session
2560 .respond_server_request(
2561 &task_id,
2562 Ok(json!({"approved": true})),
2563 Duration::from_secs(30),
2564 )
2565 .await
2566 });
2567
2568 for _ in 0..100 {
2573 if session.pending_server_requests().is_empty() {
2574 break;
2575 }
2576 tokio::task::yield_now().await;
2577 }
2578 assert!(
2579 session.pending_server_requests().is_empty(),
2580 "the entry should already be removed while the write is in flight"
2581 );
2582
2583 handle.abort();
2584 let _ = handle.await;
2585
2586 assert!(
2587 session.pending_server_requests().is_empty(),
2588 "the frame was already queued for delivery before cancellation -- reinserting now \
2589 would risk a retry sending a second, duplicate response for the same id"
2590 );
2591 }
2592
2593 #[test]
2600 fn account_login_options_serialize_with_camel_case_fields() {
2601 assert_eq!(
2602 serde_json::to_value(AccountLoginOptions::ApiKey {
2603 api_key: "sk-test".to_string()
2604 })
2605 .unwrap(),
2606 json!({"type": "apiKey", "apiKey": "sk-test"})
2607 );
2608 assert_eq!(
2609 serde_json::to_value(AccountLoginOptions::Chatgpt {
2610 app_brand: Some("codex".to_string()),
2611 codex_streamlined_login: Some(true),
2612 use_hosted_login_success_page: Some(false),
2613 })
2614 .unwrap(),
2615 json!({
2616 "type": "chatgpt",
2617 "appBrand": "codex",
2618 "codexStreamlinedLogin": true,
2619 "useHostedLoginSuccessPage": false,
2620 })
2621 );
2622 assert_eq!(
2623 serde_json::to_value(AccountLoginOptions::ChatgptDeviceCode).unwrap(),
2624 json!({"type": "chatgptDeviceCode"})
2625 );
2626 }
2627
2628 #[test]
2633 fn thread_list_cwd_filter_serializes_as_the_schema_s_string_or_array_union() {
2634 assert_eq!(
2635 serde_json::to_value(ThreadListCwdFilter::Single("C:\\work".to_string())).unwrap(),
2636 json!("C:\\work")
2637 );
2638 assert_eq!(
2639 serde_json::to_value(ThreadListCwdFilter::Many(vec![
2640 "C:\\work".to_string(),
2641 "C:\\other".to_string()
2642 ]))
2643 .unwrap(),
2644 json!(["C:\\work", "C:\\other"])
2645 );
2646 }
2647}