Skip to main content

agentmux_srv/backend/blockcontroller/
app_server_protocol.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Codex App Server's stable thread/turn adapter.
5//!
6//! The transport in [`super::app_server`] owns framing and request lifecycle.
7//! This module owns the first protocol-semantic layer: typed request shapes,
8//! thread identity, turn identity, and notification reconciliation. It is
9//! intentionally not registered as a production block controller yet.
10
11use 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    /// The turn ended via `TurnStatus::interrupted` (user cancellation) —
33    /// deliberately distinct from `Failed`, which was collapsing an
34    /// expected cancellation with a genuine provider/execution failure
35    /// (ReAgent P1, PR #3210). Consumers care about the difference: a
36    /// cancelled turn isn't an error to surface the same way.
37    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    /// Preview text accumulated from delta notifications. This is cleared
52    /// when the authoritative item completion is observed.
53    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/// A thread/list cwd filter: either a single path or a set of paths, per the
215/// pinned schema's `ThreadListCwdFilter` (`string | string[]`).
216#[derive(Debug, Clone, Serialize)]
217#[serde(untagged)]
218#[allow(dead_code)] // Unwired until SPEC_CODEX_APP_SERVER_FIRST_CLASS_PROVIDER_2026_09_12.md §15 Slice 6.
219pub 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/// Login choices accepted by the pinned App Server schema. API keys are kept
240/// in this typed request only and are never included in protocol events/logs.
241#[derive(Debug, Clone, Serialize)]
242#[serde(tag = "type", rename_all_fields = "camelCase")]
243#[allow(dead_code)] // Unwired until SPEC_CODEX_APP_SERVER_FIRST_CLASS_PROVIDER_2026_09_12.md §15 Slice 4.
244pub 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    /// The most recently `turn/completed`-notified turn id. `start_turn`
280    /// checks this after its own `turn/start` response resolves — the
281    /// response and the completion notification arrive on independent
282    /// channels (the request/response oneshot vs. run_reader's
283    /// notification stream), so a `turn/completed` for the SAME turn can
284    /// be applied before `start_turn`'s continuation resumes. Without this
285    /// check, `start_turn`'s unconditional post-await write would resurrect
286    /// an already-terminal turn as `Running`, permanently stuck (muxreview
287    /// P1, PR #3210). One id is enough, not a growing set: only one turn is
288    /// ever active at a time, so at most one termination can be "pending
289    /// recognition" by a not-yet-resumed `start_turn` call.
290    last_terminated_turn_id: Option<String>,
291    /// Reserved atomically under the same lock as the `turn_id.is_some()`
292    /// check, before the `turn/start` request is even sent. Without this,
293    /// two concurrent `start_turn` calls both observe `turn_id == None`
294    /// and both release the lock before either gets a response/notification
295    /// back — nothing was reserved under the lock, so both send `turn/start`
296    /// (ReAgent P1, PR #3210). Cleared once the response (or an error)
297    /// resolves.
298    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        // A pending approval queued under a prior thread/turn must not
331        // remain answerable once that context is gone (ReAgent P1, PR
332        // #3212) -- only turn/completed cleared this before.
333        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        // The 0.154.0 contract can resume a thread that has a turn already
363        // `inProgress` (ThreadResumeResponse's `thread.turns`, populated on
364        // resume specifically) — unconditionally clearing turn_id/phase
365        // here silently dropped that live turn state instead of deriving
366        // it from what the response actually reports (ReAgent P1, PR #3210).
367        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        // Same as start_thread: a pending approval from a prior thread/turn
378        // must not survive into this one (ReAgent P1, PR #3212).
379        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                // A different start_turn call already reserved the slot and
397                // hasn't gotten its response/error back yet — reject this
398                // one the same way an already-active turn would be
399                // rejected, rather than also sending a second turn/start.
400                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            // Reserved under the SAME lock as the checks above, before the
409            // request is even sent — this is what makes the check-then-act
410            // atomic. Without it, two concurrent callers could both observe
411            // turn_id == None and turn_starting == false before either
412            // released the lock, and both would send turn/start (muxreview
413            // P1, PR #3210).
414            state.turn_starting = true;
415            thread_id
416        };
417
418        // Scoped so the fallible network round-trip is entirely separate
419        // from the state updates before/after it.
420        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            // A `turn/completed` notification for THIS turn already landed
446            // (on the independent notification channel) while we were
447            // awaiting this same turn's `turn/start` response. The turn is
448            // already terminal — don't resurrect it as Running.
449            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    /// Fork the active thread without changing this session's thread identity.
457    /// The returned id is independent and must be persisted by the caller that
458    /// creates the new AgentMux pane.
459    #[allow(dead_code)] // Unwired until SPEC_CODEX_APP_SERVER_FIRST_CLASS_PROVIDER_2026_09_12.md §15 Slice 6.
460    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)] // Unwired until SPEC_CODEX_APP_SERVER_FIRST_CLASS_PROVIDER_2026_09_12.md §15 Slice 6.
491    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)] // Unwired until SPEC_CODEX_APP_SERVER_FIRST_CLASS_PROVIDER_2026_09_12.md §15 Slice 6.
514    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)] // Unwired until SPEC_CODEX_APP_SERVER_FIRST_CLASS_PROVIDER_2026_09_12.md §15 Slice 6.
531    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)] // Unwired until SPEC_CODEX_APP_SERVER_FIRST_CLASS_PROVIDER_2026_09_12.md §15 Slice 6.
549    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    /// Verify the account selected by the caller's isolated CODEX_HOME.
571    #[allow(dead_code)] // Unwired until SPEC_CODEX_APP_SERVER_FIRST_CLASS_PROVIDER_2026_09_12.md §15 Slice 4.
572    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)] // Unwired until SPEC_CODEX_APP_SERVER_FIRST_CLASS_PROVIDER_2026_09_12.md §15 Slice 4.
588    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)] // Unwired until SPEC_CODEX_APP_SERVER_FIRST_CLASS_PROVIDER_2026_09_12.md §15 Slice 4.
599    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)] // Unwired until SPEC_CODEX_APP_SERVER_FIRST_CLASS_PROVIDER_2026_09_12.md §15 Slice 4.
616    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    /// Add input to the active turn only when the caller supplies the exact
632    /// turn precondition. Ordinary queued messages should not call this API.
633    #[allow(dead_code)] // Unwired until SPEC_CODEX_APP_SERVER_FIRST_CLASS_PROVIDER_2026_09_12.md §15 Slice 3.
634    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    /// Interrupt the active turn. The turn remains active locally until its
662    /// authoritative `turn/completed` notification arrives.
663    #[allow(dead_code)] // Unwired until SPEC_CODEX_APP_SERVER_FIRST_CLASS_PROVIDER_2026_09_12.md §15 Slice 3.
664    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    /// Validate and queue a server request for an explicit UI/policy decision.
693    /// No request is approved implicitly.
694    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        // Every kind except McpElicitation is item-scoped and MUST carry a
716        // threadId/turnId — silently treating an omitted field the same as
717        // "no scope claimed" (rather than erroring) contradicted this API's
718        // own "scoped to active thread/turn" fail-closed claim: a
719        // malformed/unexpected request missing these fields would have been
720        // queued unconditionally instead of rejected (ReAgent P1, PR #3212).
721        // McpElicitation genuinely has no thread/turn context (see its own
722        // test fixture), so it alone stays optional.
723        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        // Strict, not the notification-reconciliation ensure_thread_scope/
748        // ensure_scopes: those trivially pass when state.thread_id/turn_id
749        // is None, which is correct for a notification establishing state
750        // (e.g. the first thread/started) but wrong here — a request
751        // claiming a threadId/turnId while no thread/turn is actually
752        // active must be rejected, not queued as if it were in scope
753        // (ReAgent P1, PR #3212 3rd review). Matched on both fields
754        // together, not two independent `if let Some`s: a request with
755        // turnId but no threadId is a real, distinct case (McpElicitation
756        // may carry either field alone) and must still validate the turn
757        // it claims — two independent checks silently skip it whenever
758        // thread_id is None, whatever turn_id says (ReAgent P1, PR #3212
759        // 4th review).
760        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    /// Send a decision for a queued request. The request remains pending if the
780    /// transport write fails, allowing a retry; successful responses consume it.
781    #[allow(dead_code)] // Unwired until SPEC_CODEX_APP_SERVER_FIRST_CLASS_PROVIDER_2026_09_12.md §15 Slice 3.
782    pub async fn respond_server_request(
783        &self,
784        id: &RpcId,
785        result: Result<Value, ServerResponseError>,
786        timeout: Duration,
787    ) -> Result<(), CodexAppServerProtocolError> {
788        // Removed atomically (check-and-take in one lock acquisition)
789        // rather than checked-then-removed-later — the old
790        // check-then-await-then-remove sequence let two concurrent calls
791        // for the same id both pass the membership check before either
792        // removed it, so both would send a JSON-RPC response for the same
793        // id and both report success (ReAgent P2, PR #3212). Re-inserted
794        // below on a transport failure so the documented retry-on-failure
795        // behavior is preserved — only one caller can ever be "holding" the
796        // entry between the take and a possible reinsert.
797        let pending = self
798            .state
799            .lock()
800            .unwrap()
801            .pending_requests
802            .remove(id)
803            .ok_or(CodexAppServerProtocolError::UnknownServerRequest)?;
804
805        // RestoreOnDrop reinserts the removed entry (if still scoped to the
806        // active thread/turn AND the frame was never actually queued for
807        // delivery) whenever it is dropped still armed — which covers an
808        // explicit transport-write failure below AND this future being
809        // dropped/cancelled while still suspended at the `.await` (e.g. a
810        // caller-side select!/timeout racing the write). A plain
811        // check-then-await-then-reinsert-on-Err only runs that reinsert
812        // code if the future resolves; drop the future mid-await instead
813        // and Rust never runs it at all, silently stranding the entry with
814        // no response ever sent and no error surfaced to anyone (muxreview
815        // P1, PR #3212 5th review). Folding the ordinary failure path into
816        // the same Drop impl means both cases are handled by one piece of
817        // logic instead of two copies of it.
818        struct RestoreOnDrop<'a> {
819            state: &'a Mutex<SessionState>,
820            id: RpcId,
821            pending: Option<PendingServerRequest>,
822            // See WriteOutcomeTracker. A transport-level timeout/
823            // cancellation while waiting on the write-confirmation ack
824            // does not mean the frame was never sent — it may already be
825            // irrevocably queued for delivery — so reinserting on any
826            // non-success outcome (as a bare Result would suggest) risks
827            // a subsequent retry sending a second, duplicate response for
828            // the same id (ReAgent P1, PR #3212 6th review).
829            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                // Matched on both fields together, not `(None, _) =>
841                // true`: a pending entry with turn_id but no thread_id is
842                // still turn-scoped and must not be resurrected once that
843                // turn has ended, whatever thread_id says (ReAgent P1,
844                // PR #3212 4th review).
845                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)] // Unwired until SPEC_CODEX_APP_SERVER_FIRST_CLASS_PROVIDER_2026_09_12.md §15 Slice 3.
874    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)] // Unwired until SPEC_CODEX_APP_SERVER_FIRST_CLASS_PROVIDER_2026_09_12.md §15 Slice 6.
885    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                // Only reset turn state for a GENUINELY new thread. If this
916                // notification is for the thread we already have loaded, a
917                // turn may already be active on it (e.g. this notification
918                // was delayed and arrives after `start_turn` already ran) —
919                // resetting unconditionally would wipe that live state out
920                // from under it (ReAgent P1, PR #3210).
921                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                    // Same as start_thread/resume_thread: a pending
928                    // approval from the previous thread must not remain
929                    // answerable once it's been replaced (ReAgent P1, PR
930                    // #3212).
931                    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                // TurnStatus's terminal values: "completed", "interrupted"
1044                // (user cancellation), "failed". Collapsing "interrupted"
1045                // into Failed hid an expected cancellation behind the same
1046                // signal as a genuine provider/execution failure (muxreview
1047                // P1, PR #3210). Anything else unrecognized still maps to
1048                // Failed rather than erroring — this notification has
1049                // already been accepted; the phase is best-effort.
1050                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            // The App Server can resolve/cancel an individual pending
1069            // request on its own (e.g. superseded by a newer one, or the
1070            // item it concerned went away) without ending the whole turn.
1071            // Previously only turn/completed or a thread transition ever
1072            // cleared pending_requests, so a request resolved this way
1073            // stayed in pending_server_requests() and answerable via
1074            // respond_server_request until the turn ended (ReAgent P2,
1075            // PR #3212). Removes only this one entry, not the whole map.
1076            "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
1130/// The id of whichever turn in `thread.turns` (a `ThreadResumeResponse`
1131/// field, populated on `thread/resume`) has `status: "inProgress"`, if any.
1132/// `TurnStatus`'s only other values (`completed`/`interrupted`/`failed`)
1133/// are all terminal, so at most one entry can ever match. Malformed/missing
1134/// shapes intentionally fall through to `None` rather than erroring — this
1135/// is best-effort derivation of pre-existing state, not a required field.
1136fn 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
1190/// Unlike ensure_thread_scope, fails closed when no thread is active — a
1191/// server request claiming a threadId must be scoped to a *real, active*
1192/// thread, not merely "no conflicting thread claimed yet" the way an
1193/// incoming notification establishing state is allowed to be.
1194fn 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
1208/// See ensure_active_thread_scope — fails closed when no turn is active.
1209fn 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
1218/// See ensure_active_thread_scope — fails closed when no turn is active.
1219/// Split out from ensure_active_scopes so a turn can be validated on its
1220/// own when a request carries a turnId without a threadId (ReAgent P1,
1221/// PR #3212 4th review).
1222fn 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    /// ReAgent P2, PR #3214: a missing/wrong-typed `success` (a bool) was
1544    /// reported via MissingStringField, whose Display text ("...missing a
1545    /// string field: success") is inaccurate -- `success` is a bool, not a
1546    /// string, and every other use of that variant in this file is for an
1547    /// actual string field.
1548    #[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    /// ReAgent P2, PR #3212: only turn/completed or a thread transition ever
1720    /// cleared pending_requests -- an individual request the App Server
1721    /// resolves/cancels on its own (serverRequest/resolved, real in the
1722    /// pinned 0.154.0 schema) stayed answerable via respond_server_request
1723    /// until the whole turn ended. Removes only the resolved entry, not
1724    /// every pending request for the turn.
1725    #[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    /// ReAgent P1, PR #3210: the `turn/start` response and a `turn/completed`
1768    /// notification for the SAME turn arrive on independent channels (the
1769    /// request/response oneshot vs. run_reader's notification stream), so
1770    /// the notification can be applied before `start_turn`'s own
1771    /// continuation resumes. Before the fix, `start_turn`'s unconditional
1772    /// post-await write would resurrect the already-terminal turn as
1773    /// `Running`, permanently stuck (every later `start_turn` call would
1774    /// see a phantom `TurnAlreadyActive`).
1775    #[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            // Completion notification written and processed BEFORE the
1791            // turn/start response, on purpose.
1792            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        // Drain and apply the notification BEFORE start_turn's own response
1818        // resolves — the reader processes frames in wire order, so this
1819        // happens-before the response gets processed.
1820        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    /// ReAgent P1, PR #3210: `resume_thread` used to unconditionally clear
1839    /// turn_id/phase/items, silently dropping a turn the 0.154.0 contract
1840    /// reports as still `inProgress` in `ThreadResumeResponse.thread.turns`.
1841    #[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    /// ReAgent P1, PR #3210: a delayed/redundant `thread/started` for a
1898    /// thread that's already loaded (and already has an active turn) must
1899    /// not wipe that turn state — only a genuinely NEW thread id should
1900    /// reset it.
1901    #[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    /// ReAgent P1, PR #3210 (2nd review): collapsing "interrupted" (user
1974    /// cancellation) into the same `Failed` phase as a genuine
1975    /// provider/execution error hid an expected outcome behind an error
1976    /// signal.
1977    #[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    /// ReAgent P1, PR #3210 (2nd review): start_turn's check-then-act
2030    /// (read turn_id, release the lock, THEN await the request) wasn't
2031    /// atomic — nothing was reserved under the lock, so two concurrent
2032    /// calls before either got a response could both observe turn_id ==
2033    /// None and both send turn/start. `tokio::join!` polls its futures in
2034    /// argument order on their first poll; neither future reaches an
2035    /// `.await` point before the synchronous turn_starting check/set, so
2036    /// this is deterministic, not timing-dependent: the first call's
2037    /// synchronous prefix runs to completion (reserving the slot) before
2038    /// the second call's synchronous prefix ever runs.
2039    #[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            // Exactly one turn/start line — if the fix regressed, a second
2053            // concurrent call would send a second one here.
2054            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            // Confirm nothing further arrives — a regression would send a
2064            // second turn/start request here.
2065            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    /// ReAgent P1, PR #3212 (2nd review): item-scoped kinds (everything
2188    /// except McpElicitation) must require threadId/turnId rather than
2189    /// silently treating an omitted field as "no scope claimed" -- that
2190    /// contradicted this API's own "scoped to active thread/turn"
2191    /// fail-closed claim.
2192    #[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        // McpElicitation alone stays exempt -- it genuinely has no
2211        // thread/turn context.
2212        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    /// ReAgent P1, PR #3212 (2nd review): a pending approval queued under a
2223    /// prior thread/turn must not remain answerable after that thread has
2224    /// been replaced -- only turn/completed cleared pending_requests
2225    /// before; start_thread, resume_thread, and a genuinely-new
2226    /// thread/started notification did not.
2227    #[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    /// ReAgent P2, PR #3212 (2nd review): respond_server_request checked
2267    /// pending_requests membership, then awaited the transport write,
2268    /// THEN removed the entry -- two concurrent calls for the same id
2269    /// could both pass the membership check before either removed it, so
2270    /// both would send a response and both report success. Now removed
2271    /// atomically up front; deterministic via tokio::join! the same way
2272    /// the concurrent-start_turn test is (neither future reaches an
2273    /// await point before the synchronous remove).
2274    #[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            // Exactly one respond frame should be written.
2280            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    /// ReAgent P1, PR #3212 (3rd review): before any turn had started (or
2320    /// after turn/completed cleared turn_id back to None), a request
2321    /// claiming an arbitrary/stale turnId passed the old ensure_scopes
2322    /// check trivially, since that helper only compares when state.turn_id
2323    /// is Some. accept_server_request must fail closed when there is no
2324    /// active thread/turn to scope the request to, not merely skip the
2325    /// check.
2326    #[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    /// ReAgent P1, PR #3212 (3rd review): respond_server_request's
2354    /// failure-path reinsert didn't check whether the thread/turn context
2355    /// was still current, so a stale approval could be resurrected into a
2356    /// brand-new session/turn after a transition raced the in-flight
2357    /// transport write.
2358    #[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 the entire peer side of the transport so the write inside
2377        // respond_server_request fails, standing in for "the transport
2378        // write raced a state transition and lost" without a timing-
2379        // dependent sleep.
2380        drop(server_reader);
2381        drop(server_writer);
2382
2383        // Simulate the turn ending while that write is in flight: clear
2384        // pending_requests and the active turn the same way turn/completed
2385        // does.
2386        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    /// ReAgent P2, PR #3212 (3rd review): pending_requests had no size cap,
2400    /// so a buggy or adversarial App Server could grow it unboundedly by
2401    /// sending validly-scoped approval requests.
2402    #[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    /// ReAgent P1, PR #3212 (4th review): a request carrying turnId but no
2431    /// threadId (McpElicitation may supply either field alone) fell through
2432    /// both `if let Some(thread) = &thread_id` and the `(Some, Some)` tuple
2433    /// match untouched, so a claimed-but-stale turnId was never validated
2434    /// and the request was queued unconditionally. Same asymmetry mirrored
2435    /// in respond_server_request's reinsert-on-failure check.
2436    #[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    /// See a_turn_only_request_is_still_scoped_to_the_active_turn -- the
2473    /// same asymmetry existed on the reinsert side: a pending entry with
2474    /// turn_id but no thread_id was unconditionally resurrected on a
2475    /// transport-write failure (`(None, _) => true`) regardless of
2476    /// whether that turn was still active.
2477    #[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    /// ReAgent P1, PR #3212 (5th review): respond_server_request removed the
2509    /// pending entry synchronously, then awaited the transport write. If
2510    /// the calling future were dropped/cancelled while suspended at that
2511    /// await (a caller-side select!/timeout racing the write), Rust never
2512    /// runs code after an await point that's never resumed -- neither the
2513    /// success path nor the old failure-path reinsert would run, silently
2514    /// stranding the entry with no response ever sent. A 1-byte duplex
2515    /// buffer with nothing reading the other side guarantees the write
2516    /// stays suspended, so aborting the task deterministically exercises
2517    /// the drop-mid-await path rather than racing a timing window.
2518    ///
2519    /// Superseded by the 6th review's WriteOutcomeTracker (see
2520    /// app_server.rs): by the time the write is stuck on this 1-byte
2521    /// buffer, the frame has already been handed to run_writer, which will
2522    /// deliver it regardless of the cancellation below -- so the correct
2523    /// behavior is now the opposite of what this test originally asserted:
2524    /// the entry must NOT be reinserted, or a retry could send a second,
2525    /// duplicate response for the same id. The pre-queue case (cancelled
2526    /// before the frame is ever handed to the writer, still genuinely safe
2527    /// to retry) is covered directly at the transport level by
2528    /// write_outcome_tracker_stays_safe_to_retry_when_the_write_never_starts
2529    /// in app_server.rs, since reaching it deterministically from here
2530    /// would require blocking the transport's internal outbound queue,
2531    /// which this layer has no access to.
2532    #[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        // The synchronous remove() runs on the task's first poll, before
2569        // it ever reaches the write's await point -- give it a bounded
2570        // number of scheduling turns to get there rather than assuming
2571        // one yield is always enough.
2572        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    /// AccountLoginOptions lacked `rename_all_fields = "camelCase"`, so
2594    /// ApiKey and Chatgpt fields serialized as api_key/app_brand/etc
2595    /// instead of the pinned schema's apiKey/appBrand/etc -- API-key login
2596    /// was guaranteed to fail against the real App Server despite passing
2597    /// every existing test, since none of them inspected the serialized
2598    /// JSON shape.
2599    #[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    /// The pinned schema's ThreadListCwdFilter is `string | string[]`, not
2629    /// an arbitrary JSON value -- typed as the actual union instead of a
2630    /// bare `Value` so a caller can't hand it something the App Server
2631    /// will reject.
2632    #[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}