Skip to main content

gate4agent_shell_native/
lib.rs

1//! Native effect execution for gate4agent control-plane sessions.
2
3mod efficiency;
4mod provider_supervisor;
5
6pub use efficiency::ShellEfficiencyFacts;
7// Re-exported so a caller of `ShellEfficiencyFacts::record_foreground_probe`
8// (e.g. `gate4agent-runtime-native`, which does not itself depend on the
9// `gate4agent` crate) can name the type of the value it is handing in
10// without picking up a new dependency edge for it.
11pub use gate4agent::pty::ForegroundProbeTiming;
12pub use provider_supervisor::{
13    NativeProviderExecutor, NativeProviderExit, NativeProviderOperation,
14    NativeProviderOperationError, NativeProviderResultPoll, PhysicalExitAck,
15    ProviderOperationKey, ProviderOperationSnapshot, ProviderSupervisor,
16    ProviderSupervisorBuildError, ProviderSupervisorFault, ProviderSupervisorFaultKind,
17    ProviderStopCause, ProviderSupervisorSnapshot, ProviderSupervisorState,
18    ProviderSupervisorTick, DEFAULT_PROVIDER_STOP_GRACE,
19    MAX_PROVIDER_FORCE_STOP_ATTEMPTS, MAX_PROVIDER_STOP_SIGNAL_ATTEMPTS,
20    MAX_PROVIDER_SUPERVISOR_EVENTS, MAX_PROVIDER_SUPERVISOR_OPERATIONS,
21    MAX_PROVIDER_SUPERVISOR_OUTCOMES_PER_TICK, MAX_PROVIDER_SUPERVISOR_TOMBSTONES,
22    MAX_PROVIDER_SUPERVISOR_WORK_PER_TICK,
23};
24
25use gate4agent::acp::protocol::{McpServerConfig, SessionMode};
26use gate4agent::agent::{is_agent_foreground_wrapper, is_expected_agent_process, ReadinessStatus};
27use gate4agent::pty::cli::codex::strip_ansi_codes;
28use gate4agent::pty::cli::{create_pipeline, ClassificationPipeline, MessageClass, ParsedMessage};
29use gate4agent::pty::event::PtyMouseProtocolEncoding;
30use gate4agent::pty::{
31    PtyAttachment, PtyEvent, PtyEventEnvelope, PtyEventReceiver, PtyForegroundObservation,
32    PtyReplayCursor, PtySession, PtyTerminalSnapshot, RateLimitDetector, VteParser,
33};
34use gate4agent::{
35    AcpSession, AcpSessionOptions, AgentEvent, CliTool, HostDecisionAuthority, HostPolicy,
36    HostRequestDecision, HostRequestOutcome, LaunchRequest, OperatorPermissionChoice,
37    PipeProcessOptions, PipeSession, PromptFraming,
38    ReadinessIntent, ReadinessPermit, ReadinessTracker, RpcId, RuntimePlatform, SessionConfig,
39    StopReason,
40};
41use gate4agent_adapters::{
42    build_resume_plan_for_identity, builtin_adapter_registry, AdapterRuntimeRegistry,
43    CodexPtySessionIdentityExtractor, KimiPtySessionIdentityExtractor, OneShotSessionPersistence,
44};
45use gate4agent_catalog::{
46    approval_level_resolution, ApprovalLevelResolution, AgentRegistry, AgentSpec, EnvMutation,
47    McpServerSpec, ModeId,
48};
49use gate4agent_shell_one_shot::NativeOneShotSession;
50use gate4agent_types::{
51    AdapterFamily, AgentCommand, AgentId, AgentInstanceId, ApprovalLevel, CapabilityProbeFailure,
52    ContextWindowUsage as ProviderContextWindowUsage, ControlEffect,
53    ControlObservation, EffectEnvelope, ForegroundProcess, ForegroundProcessKind,
54    ForegroundRequirement, HostDecisionAuthority as ProviderHostDecisionAuthority,
55    HostRequestDecision as ProviderHostRequestDecision,
56    HostRequestOutcome as ProviderHostRequestOutcome, InputAction, ObservationEnvelope,
57    OperationId, OperatorGateInput,
58    OperatorGateKind, OperatorGateOption, OperatorGateOptionSemantics, OperatorGateState,
59    OperatorGateSubject, PipeProtocol,
60    PreparedInputKind, PromptPayload, ProviderAvailableCommand, ProviderConfigChoice,
61    ProviderConfigOption, ProviderConfigOptionKind, ProviderEvent, ProviderInteractionKind,
62    ProviderInteractionOption, ProviderInteractionResponse, ProviderInteractionTarget,
63    ProviderModeInfo, ProviderPlanPriority,
64    ProviderPlanStatus, ProviderPlanStep,
65    ProviderRateLimitKind, ProviderRuntimeCapability, ProviderRuntimePolicy,
66    ProviderSessionIdentity, ProviderSessionKey, ProviderStopReason,
67    ProviderSource, PtyScreenState, ResumeLaunchRequest, SessionGeneration, StartRequest,
68    TerminalFrame, TerminalMouseProtocolEncoding, TerminalSize, TokenUsage, TransportKind,
69    OPERATOR_GATE_OPTIONS_MAX, WORKING_DIRECTORY_MAX_BYTES,
70};
71use std::collections::{BTreeMap, VecDeque};
72use std::ffi::{OsStr, OsString};
73use std::path::PathBuf;
74use std::sync::Mutex;
75use std::time::{Duration, Instant};
76use tokio::sync::broadcast;
77use uuid::Uuid;
78
79const INSTANCE_LAUNCH_ARGS_MAX: usize = 128;
80const INSTANCE_LAUNCH_ARG_MAX_BYTES: usize = 65_536;
81const INSTANCE_LAUNCH_ARGS_TOTAL_MAX_BYTES: usize = 262_144;
82const RESERVED_CLAUDE_LAUNCH_FLAGS: &[&str] = &[
83    "--continue",
84    "--print",
85    "--prompt",
86    "--prompt-interactive",
87    "--resume",
88    "--session-id",
89    "-c",
90    "-p",
91    "-r",
92];
93
94/// Translates a resolved [`McpServerSpec`] into the one ACP `session/new`
95/// stdio entry the agent should register
96/// (`gate4agent::acp::protocol::McpServerConfig`). This is the ACP half of
97/// the same door a PTY child gets by installing the identical spec's `env`
98/// pairs into its own OS environment instead
99/// (`gate4agent-runtime-native::launch_profiles::NativeMcpServerLaunchOverlay`):
100/// the spec is resolved once, in-process, by `gate4agent-runtime-native` and
101/// handed down here as a typed value through `NativeSpawnOverlay`/
102/// `NativeEffectRequest` -- never a wire type, and never re-read out of a
103/// well-known environment key. This crate cannot depend on
104/// `gate4agent-runtime-native` (the dependency runs the other way), which is
105/// why the spec type itself lives one level below both, in
106/// `gate4agent-catalog`.
107///
108/// Server naming, program path, args, and every environment entry are
109/// entirely the caller's concern; this function does not interpret them.
110fn mcp_server_acp_entry(spec: &McpServerSpec) -> McpServerConfig {
111    McpServerConfig::stdio(
112        spec.name().to_owned(),
113        spec.program().to_string_lossy().into_owned(),
114        spec.args()
115            .iter()
116            .map(|arg| arg.to_string_lossy().into_owned())
117            .collect::<Vec<_>>(),
118        spec.env()
119            .iter()
120            .map(|(key, value)| (key.to_string_lossy().into_owned(), value.to_string_lossy().into_owned()))
121            .collect::<Vec<_>>(),
122    )
123}
124
125#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
126pub struct NativeSessionKey {
127    pub instance_id: AgentInstanceId,
128    pub generation: SessionGeneration,
129}
130
131struct NativeSpawnRequest {
132    agent_id: AgentId,
133    transport: TransportKind,
134    request: StartRequest,
135    runtime_policy: ProviderRuntimePolicy,
136    launch_extra_args: Vec<OsString>,
137    instance_extra_args: Vec<OsString>,
138    resumed_provider_session: Option<gate4agent_types::ProviderSessionIdentity>,
139    one_shot_session_persistence: OneShotSessionPersistence,
140}
141
142struct OwnedPtySession {
143    session: PtySession,
144    spawn_operation_id: OperationId,
145    last_terminal_sequence: u64,
146    terminal_stale_published: bool,
147    runtime_policy: ProviderRuntimePolicy,
148    provider: Option<OwnedPtyProvider>,
149    /// Set once at the spawn site and never mutated -- this is what lets
150    /// `reclassify_foreground` resolve the session's `AgentSpec` from
151    /// `NativeEffectShell::catalog` without holding a borrow of `session`
152    /// across the same loop iteration it awaits `observe_foreground` on.
153    agent_id: AgentId,
154    last_screen_gate: Option<OperatorGateState>,
155    last_screen_failure: Option<&'static str>,
156    last_foreground_verdict: Option<ForegroundVerdict>,
157    last_screen_state: PtyScreenState,
158    /// Whether this generation's merged state has ever been `Ready` WITH
159    /// something on screen. Text crash/missing-command markers are only
160    /// trustworthy before this flips -- see the gate in
161    /// `collect_terminal_frames`. The screen-content half is load-bearing:
162    /// `Ready` is proved by foreground process identity and so arrives
163    /// within a frame or two of spawn, while the terminal is still blank,
164    /// and arming on a blank frame retires the failure detector before any
165    /// failure text can exist. Never reset in place; a new generation gets
166    /// a fresh `OwnedPtySession`, so `false` is simply this field's initial
167    /// value at the spawn site.
168    ever_reached_ready: bool,
169    /// Whether any frame of this generation has carried non-blank screen
170    /// text yet. Read by the process-only path in `reclassify_foreground`,
171    /// which has no snapshot of its own to test and would otherwise arm
172    /// `ever_reached_ready` on a blank screen.
173    screen_had_content: bool,
174    /// `None` means disarmed -- the session reached `Ready` and stays
175    /// unprobed until its text disagrees again. See
176    /// `NativeEffectShell::reclassify_foreground` for the cadence this
177    /// drives.
178    next_foreground_probe: Option<Instant>,
179}
180
181struct OwnedPtyProvider {
182    source: ProviderSource,
183    receiver: PtyEventReceiver,
184    replay: VecDeque<PtyEventEnvelope>,
185    pending_events: VecDeque<ProviderEvent>,
186    utf8: Utf8ChunkDecoder,
187    pipeline: Mutex<ClassificationPipeline>,
188    rate_limits: RateLimitFeed,
189    kimi_identity: Option<KimiPtySessionIdentityExtractor>,
190    semantic_events: bool,
191    provider_session_started: bool,
192    next_provider_sequence: u64,
193}
194
195/// Buffers ANSI-stripped PTY output across output chunks before running
196/// rate-limit detection over it.
197///
198/// PTY reads are chunked arbitrarily -- neither the OS nor this crate's
199/// own reader loop promises "one call per terminal row" or even "one call
200/// per escape sequence". Two separate problems follow from that, and this
201/// type exists to solve both rather than hoping a caller's chunk
202/// boundaries are ever polite:
203///
204/// - An ANSI escape sequence split across two chunks must still be
205///   recognized as one escape, not leak its tail bytes into the visible
206///   text as a false line-break in the middle of `\x1b[m`. `VteParser`
207///   already solves this -- its own `vte::Parser` state machine persists
208///   across calls, so an escape completes correctly regardless of where
209///   a chunk boundary falls -- this type just keeps ONE `VteParser` alive
210///   for the provider's whole lifetime instead of a fresh, stateless one
211///   per chunk (which would forget an escape's first half).
212/// - A LOGICAL LINE (e.g. codex's `/status` quota-state row) can itself
213///   be split across chunks at a point that is not inside any escape.
214///   `VteParser::parse` only returns the printable text produced by the
215///   bytes handed to THAT call, so two separate calls can each return one
216///   half of the same row, and neither half alone matches the quota-state
217///   pattern. This type accumulates cleaned text across calls and runs
218///   detection over everything seen so far, keeping whatever has not yet
219///   been confirmed complete (the tail after the last newline) instead of
220///   discarding a still-forming row.
221struct RateLimitFeed {
222    detector: RateLimitDetector,
223    vte: VteParser,
224    buffer: String,
225}
226
227impl RateLimitFeed {
228    fn new_for_tool(tool: CliTool) -> Self {
229        Self {
230            detector: RateLimitDetector::new_for_tool(tool),
231            vte: VteParser::new(),
232            buffer: String::new(),
233        }
234    }
235
236    /// Strip ANSI from one more raw PTY chunk, fold it into the pending
237    /// line buffer, and run detection over everything accumulated so far
238    /// -- not just the newest chunk, since a match can straddle a chunk
239    /// boundary. Lines already terminated by `\n` are then dropped from
240    /// the buffer regardless of whether they matched: a later chunk
241    /// cannot retroactively extend a row that has already ended. The
242    /// unterminated tail (a row still in progress) is always kept -- but
243    /// see `RATE_LIMIT_FEED_BUFFER_MAX_BYTES` for what happens when that
244    /// tail itself grows unreasonably large.
245    fn detect(&mut self, raw: &str) -> Option<gate4agent::core::types::RateLimitInfo> {
246        let cleaned = self.vte.parse(raw);
247        self.buffer.push_str(&cleaned);
248        let info = self.detector.detect(&self.buffer);
249        if let Some(last_newline) = self.buffer.rfind('\n') {
250            self.buffer.drain(..=last_newline);
251        } else if self.buffer.len() > RATE_LIMIT_FEED_BUFFER_MAX_BYTES {
252            // A full-screen redraw (codex's main screen, not `/status`)
253            // can run for a long time using only cursor-addressing escapes
254            // and never emit a `\n` at all, so "no newline yet" cannot be
255            // trusted to mean "a row is still in progress" indefinitely.
256            // No real quota-state or refusal line approaches this length;
257            // past it, keep only the tail so an indefinitely long redraw
258            // cannot grow this buffer for the life of the session.
259            let mut cut = self.buffer.len() - RATE_LIMIT_FEED_BUFFER_MAX_BYTES;
260            while !self.buffer.is_char_boundary(cut) {
261                cut += 1;
262            }
263            self.buffer.drain(..cut);
264        }
265        info
266    }
267}
268
269/// Well past the longest quota-state or refusal line this detector
270/// recognizes (all under 200 bytes); see `RateLimitFeed::detect`.
271const RATE_LIMIT_FEED_BUFFER_MAX_BYTES: usize = 8192;
272
273#[derive(Default)]
274struct Utf8ChunkDecoder {
275    pending: Vec<u8>,
276}
277
278impl Utf8ChunkDecoder {
279    fn push(&mut self, bytes: &[u8]) -> String {
280        self.pending.extend_from_slice(bytes);
281        let mut decoded = String::new();
282        loop {
283            match std::str::from_utf8(&self.pending) {
284                Ok(text) => {
285                    decoded.push_str(text);
286                    self.pending.clear();
287                    break;
288                }
289                Err(error) => {
290                    let valid = error.valid_up_to();
291                    if valid > 0 {
292                        let text = std::str::from_utf8(&self.pending[..valid])
293                            .expect("validated UTF-8 prefix");
294                        decoded.push_str(text);
295                        self.pending.drain(..valid);
296                        continue;
297                    }
298                    let Some(invalid) = error.error_len() else {
299                        break;
300                    };
301                    decoded.push('\u{fffd}');
302                    self.pending.drain(..invalid.min(self.pending.len()));
303                }
304            }
305        }
306        decoded
307    }
308
309    fn clear(&mut self) {
310        self.pending.clear();
311    }
312}
313
314struct OwnedProviderSession<S> {
315    source: ProviderSource,
316    session: S,
317    events: broadcast::Receiver<AgentEvent>,
318    pending_events: VecDeque<AgentEvent>,
319    /// Already wire-typed `ProviderEvent`s seeded at the spawn site with no
320    /// `AgentEvent` counterpart to carry them through `pending_events` --
321    /// today, only `ProviderEvent::SessionIdentityObserved` for a freshly
322    /// spawned ACP session (see the `TransportKind::Acp` arm of
323    /// `spawn_native`). Drained by `drain_provider_stream` immediately after
324    /// `pending_events` empties and before the live broadcast stream, so an
325    /// event seeded here lands right after whatever `pending_events` seeded
326    /// (`AgentEvent::SessionStart`, chiefly) rather than racing it.
327    pending_provider_events: VecDeque<ProviderEvent>,
328    next_provider_sequence: u64,
329    observed_exit_code: Option<i32>,
330    runtime_policy: ProviderRuntimePolicy,
331}
332
333/// Executes native effects and returns exactly one completion observation for
334/// each accepted effect. Logical lifecycle state remains owned by the engine.
335pub struct NativeEffectShell {
336    catalog: AgentRegistry,
337    legacy_adapters: AdapterRuntimeRegistry<CliTool>,
338    pty_sessions: BTreeMap<NativeSessionKey, OwnedPtySession>,
339    pipe_sessions: BTreeMap<NativeSessionKey, OwnedProviderSession<PipeSession>>,
340    one_shot_sessions: BTreeMap<NativeSessionKey, OwnedProviderSession<NativeOneShotSession>>,
341    acp_sessions: BTreeMap<NativeSessionKey, OwnedProviderSession<AcpSession>>,
342    pending_observations: VecDeque<ObservationEnvelope>,
343    /// Plain efficiency facts from `collect_terminal_frames` and
344    /// `reclassify_foreground` -- see `ShellEfficiencyFacts`'s own doc
345    /// comment for why this crate stops at plain facts rather than
346    /// computing a distribution itself.
347    efficiency_facts: ShellEfficiencyFacts,
348}
349
350impl NativeEffectShell {
351    pub fn new(catalog: AgentRegistry) -> Self {
352        Self::new_with_runtime_adapters(catalog, builtin_legacy_adapter_runtimes())
353    }
354
355    /// Builds a native shell with consumer-provided compatibility runtimes.
356    ///
357    /// The runtime registry is deliberately separate from the declarative
358    /// catalog: both the adapter family and revision must resolve before a
359    /// process is spawned.
360    pub fn new_with_runtime_adapters(
361        catalog: AgentRegistry,
362        legacy_adapters: AdapterRuntimeRegistry<CliTool>,
363    ) -> Self {
364        Self {
365            catalog,
366            legacy_adapters,
367            pty_sessions: BTreeMap::new(),
368            pipe_sessions: BTreeMap::new(),
369            one_shot_sessions: BTreeMap::new(),
370            acp_sessions: BTreeMap::new(),
371            pending_observations: VecDeque::new(),
372            efficiency_facts: ShellEfficiencyFacts::default(),
373        }
374    }
375
376    /// Hand the caller everything `collect_terminal_frames` and
377    /// `reclassify_foreground` recorded since the last call, and reset the
378    /// facts back to empty. Called once per worker-loop iteration by
379    /// `gate4agent-runtime-native::publish_shell_observations`, the only
380    /// place with somewhere to fold these into a distribution.
381    pub fn take_efficiency_facts(&mut self) -> ShellEfficiencyFacts {
382        self.efficiency_facts.take()
383    }
384
385    pub fn active_session_count(&self) -> usize {
386        self.pty_sessions.len()
387            + self.pipe_sessions.len()
388            + self.one_shot_sessions.len()
389            + self.acp_sessions.len()
390    }
391
392    pub fn spawn_operation_id(&self, key: NativeSessionKey) -> Option<OperationId> {
393        self.pty_sessions
394            .get(&key)
395            .map(|owned| owned.spawn_operation_id)
396    }
397
398    pub fn terminal_snapshot(&self, key: NativeSessionKey) -> Result<PtyTerminalSnapshot, String> {
399        self.pty_sessions
400            .get(&key)
401            .ok_or_else(|| missing_session_message(key))?
402            .session
403            .terminal_snapshot()
404            .map_err(|error| error.to_string())
405    }
406
407    pub async fn execute(&mut self, envelope: EffectEnvelope) -> ObservationEnvelope {
408        self.execute_with_environment(envelope, Vec::new()).await
409    }
410
411    /// Backward-compatible name for PTY-only callers. OneShot launches now
412    /// receive the same shell-owned mutations.
413    pub async fn execute_with_pty_env(
414        &mut self,
415        envelope: EffectEnvelope,
416        pty_env: Vec<EnvMutation>,
417    ) -> ObservationEnvelope {
418        self.execute_with_environment(envelope, pty_env).await
419    }
420
421    /// Execute an effect with shell-owned environment injected only into a
422    /// newly spawned provider process. The canonical start request cannot set
423    /// these authority variables itself.
424    pub async fn execute_with_environment(
425        &mut self,
426        envelope: EffectEnvelope,
427        environment: Vec<EnvMutation>,
428    ) -> ObservationEnvelope {
429        self.execute_with_launch_overlay(envelope, environment, Vec::new())
430            .await
431    }
432
433    /// Execute an effect with host-only environment and optional PTY argv
434    /// applied only to a newly spawned provider process.
435    pub async fn execute_with_launch_overlay(
436        &mut self,
437        envelope: EffectEnvelope,
438        environment: Vec<EnvMutation>,
439        extra_args: Vec<OsString>,
440    ) -> ObservationEnvelope {
441        self.execute_with_launch_overlay_and_persistence(
442            envelope,
443            environment,
444            extra_args,
445            OneShotSessionPersistence::Ephemeral,
446        )
447        .await
448    }
449
450    /// Execute an effect with host-only launch mutations and an explicit
451    /// OneShotText session persistence policy.
452    pub async fn execute_with_launch_overlay_and_persistence(
453        &mut self,
454        envelope: EffectEnvelope,
455        environment: Vec<EnvMutation>,
456        extra_args: Vec<OsString>,
457        one_shot_session_persistence: OneShotSessionPersistence,
458    ) -> ObservationEnvelope {
459        self.execute_with_launch_context(
460            envelope,
461            environment,
462            extra_args,
463            one_shot_session_persistence,
464            None,
465        )
466        .await
467    }
468
469    pub async fn execute_with_launch_context(
470        &mut self,
471        envelope: EffectEnvelope,
472        environment: Vec<EnvMutation>,
473        extra_args: Vec<OsString>,
474        one_shot_session_persistence: OneShotSessionPersistence,
475        mcp_server: Option<McpServerSpec>,
476    ) -> ObservationEnvelope {
477        let EffectEnvelope {
478            operation_id,
479            instance_id,
480            generation,
481            effect,
482        } = envelope;
483        let key = NativeSessionKey {
484            instance_id,
485            generation,
486        };
487
488        let observation = {
489            match effect {
490                ControlEffect::Spawn {
491                    agent_id,
492                    transport,
493                    runtime_policy,
494                    request,
495                } => {
496                    self.spawn_native(
497                        key,
498                        operation_id,
499                        NativeSpawnRequest {
500                            agent_id,
501                            transport,
502                            request,
503                            runtime_policy,
504                            launch_extra_args: Vec::new(),
505                            instance_extra_args: extra_args,
506                            resumed_provider_session: None,
507                            one_shot_session_persistence,
508                        },
509                        environment,
510                        mcp_server,
511                    )
512                    .await
513                }
514                ControlEffect::SpawnResume {
515                    agent_id,
516                    transport,
517                    provider_session,
518                    runtime_policy,
519                    request,
520                } => {
521                    self.spawn_resume(
522                        key,
523                        operation_id,
524                        agent_id,
525                        transport,
526                        provider_session,
527                        runtime_policy,
528                        request,
529                        environment,
530                        extra_args,
531                        one_shot_session_persistence,
532                    )
533                    .await
534                }
535                ControlEffect::Stop { force } => self.stop_native(key, force).await,
536                ControlEffect::WriteInput {
537                    input,
538                    required_foreground,
539                } => match self.pty_sessions.get(&key) {
540                    Some(owned) => match required_foreground {
541                        ForegroundRequirement::Any
542                            if matches!(
543                                input.kind(),
544                                PreparedInputKind::TerminalText
545                                    | PreparedInputKind::TerminalBytes
546                                    | PreparedInputKind::TerminalControl
547                            ) =>
548                        {
549                            match owned.session.send_terminal_input(input).await {
550                                Ok(()) => ControlObservation::InputCompleted,
551                                Err(error) => ControlObservation::InputFailed {
552                                    message: error.to_string(),
553                                },
554                            }
555                        }
556                        ForegroundRequirement::Shell
557                            if input.kind() == PreparedInputKind::ShellCommand =>
558                        {
559                            if !owned.runtime_policy.semantic_readiness {
560                                ControlObservation::InputFailed {
561                                    message: "semantic shell input is not admitted by the provider runtime policy"
562                                        .to_owned(),
563                                }
564                            } else {
565                                match owned.session.send_shell_input(input).await {
566                                    Ok(()) => ControlObservation::InputCompleted,
567                                    Err(error) => ControlObservation::InputFailed {
568                                        message: error.to_string(),
569                                    },
570                                }
571                            }
572                        }
573                        ForegroundRequirement::Agent { agent_id }
574                            if matches!(
575                                input.kind(),
576                                PreparedInputKind::InsertDraft
577                                    | PreparedInputKind::SubmitPrompt
578                                    | PreparedInputKind::AgentCommand
579                            ) && &agent_id == owned.session.agent_id() =>
580                        {
581                            if !owned.runtime_policy.semantic_readiness
582                                || !owned.runtime_policy.structured_prompt
583                            {
584                                return completion_observation(
585                                    operation_id,
586                                    instance_id,
587                                    generation,
588                                    ControlObservation::InputFailed {
589                                        message: "semantic input is not admitted by the provider runtime policy"
590                                            .to_owned(),
591                                    },
592                                );
593                            }
594                            let intent = match input.kind() {
595                                PreparedInputKind::InsertDraft
596                                | PreparedInputKind::AgentCommand => ReadinessIntent::DraftPaste,
597                                PreparedInputKind::SubmitPrompt => ReadinessIntent::FollowupPrompt,
598                                PreparedInputKind::ShellCommand
599                                | PreparedInputKind::TerminalText
600                                | PreparedInputKind::TerminalBytes
601                                | PreparedInputKind::TerminalControl => unreachable!(),
602                            };
603                            let Some(spec) = self.catalog.get(&agent_id).cloned() else {
604                                return completion_observation(
605                                    operation_id,
606                                    instance_id,
607                                    generation,
608                                    ControlObservation::InputFailed {
609                                        message: "session agent disappeared from native catalog"
610                                            .to_owned(),
611                                    },
612                                );
613                            };
614                            match wait_for_readiness(&owned.session, &spec, intent, false).await {
615                                Ok(permit) => {
616                                    let result = if input.kind() == PreparedInputKind::AgentCommand
617                                    {
618                                        owned.session.send_agent_command_input(input, permit).await
619                                    } else {
620                                        owned.session.send_prepared_input(input, permit).await
621                                    };
622                                    match result {
623                                        Ok(()) => ControlObservation::InputCompleted,
624                                        Err(error) => ControlObservation::InputFailed {
625                                            message: error.to_string(),
626                                        },
627                                    }
628                                }
629                                Err(message) => ControlObservation::InputFailed { message },
630                            }
631                        }
632                        required_foreground => ControlObservation::InputFailed {
633                            message: format!(
634                                "prepared input kind {:?} does not satisfy route {:?}",
635                                input.kind(),
636                                required_foreground
637                            ),
638                        },
639                    },
640                    None => ControlObservation::InputFailed {
641                        message: "typed PTY input requires a PTY session".to_owned(),
642                    },
643                },
644                ControlEffect::SubmitPrompt { prompt } => match self.acp_sessions.get(&key) {
645                    Some(owned) if !owned.runtime_policy.structured_prompt => {
646                        ControlObservation::InputFailed {
647                            message: "structured prompt is not admitted by the provider runtime policy"
648                                .to_owned(),
649                        }
650                    }
651                    Some(owned) => match owned.session.start_prompt(&prompt).await {
652                        Ok(()) => ControlObservation::InputCompleted,
653                        Err(error) => ControlObservation::InputFailed {
654                            message: error.to_string(),
655                        },
656                    },
657                    None => ControlObservation::InputFailed {
658                        message: "semantic follow-up prompts require an ACP session".to_owned(),
659                    },
660                },
661                ControlEffect::Interrupt => match self.acp_sessions.get(&key) {
662                    Some(owned) => match owned.session.cancel().await {
663                        Ok(()) => ControlObservation::InputCompleted,
664                        Err(error) => ControlObservation::InputFailed {
665                            message: error.to_string(),
666                        },
667                    },
668                    None => ControlObservation::InputFailed {
669                        message: "semantic interrupt requires an ACP session".to_owned(),
670                    },
671                },
672                ControlEffect::ResolveInteraction { target, response } => {
673                    resolve_acp_interaction_observation(
674                        self.acp_sessions.get(&key).map(|owned| &owned.session),
675                        target,
676                        response,
677                    )
678                }
679                ControlEffect::SetSessionMode { mode_id } => {
680                    set_acp_session_mode_observation(
681                        self.acp_sessions.get(&key).map(|owned| &owned.session),
682                        mode_id,
683                    )
684                    .await
685                }
686                ControlEffect::SetSessionConfigOption {
687                    option_id,
688                    value_json,
689                } => {
690                    set_acp_session_config_option_observation(
691                        self.acp_sessions.get(&key).map(|owned| &owned.session),
692                        option_id,
693                        value_json,
694                    )
695                    .await
696                }
697                ControlEffect::SetSessionModel { model_id } => {
698                    set_acp_session_model_observation(
699                        self.acp_sessions.get(&key).map(|owned| &owned.session),
700                        model_id,
701                    )
702                }
703                ControlEffect::Resize { size } if !size.is_valid() => {
704                    ControlObservation::ResizeFailed {
705                        message: "terminal size is outside the supported range".to_owned(),
706                    }
707                }
708                ControlEffect::Resize { size } => match self.pty_sessions.get(&key) {
709                    Some(owned) => match owned.session.resize(size.rows, size.columns).await {
710                        Ok(()) => ControlObservation::ResizeCompleted { size },
711                        Err(error) => ControlObservation::ResizeFailed {
712                            message: error.to_string(),
713                        },
714                    },
715                    None => ControlObservation::ResizeFailed {
716                        message: "terminal resize requires a PTY session".to_owned(),
717                    },
718                },
719                ControlEffect::ObserveForeground => match self.pty_sessions.get(&key) {
720                    Some(owned) => match owned.session.observe_foreground().await {
721                        Ok(observation) => ControlObservation::ForegroundObserved {
722                            process: canonical_foreground(
723                                owned.session.agent_id().clone(),
724                                &observation,
725                            ),
726                        },
727                        Err(error) => ControlObservation::ForegroundFailed {
728                            message: error.to_string(),
729                        },
730                    },
731                    None => ControlObservation::ForegroundFailed {
732                        message: "foreground observation requires a PTY session".to_owned(),
733                    },
734                },
735                ControlEffect::ProbeCapabilities { .. } => {
736                    ControlObservation::CapabilityProbeFailed {
737                        failure: CapabilityProbeFailure::ExecutorUnavailable,
738                    }
739                }
740                ControlEffect::DiscoverHistory { .. } | ControlEffect::LoadHistory { .. } => {
741                    ControlObservation::HistoryFailed {
742                        message: "history effects require the dedicated native history authority"
743                            .to_owned(),
744                    }
745                }
746                ControlEffect::AuthorizeResume { .. } => ControlObservation::ResumeFailed {
747                    message: "resume authorization requires the dedicated native authority"
748                        .to_owned(),
749                },
750            }
751        };
752
753        completion_observation(operation_id, instance_id, generation, observation)
754    }
755
756    async fn spawn_native(
757        &mut self,
758        key: NativeSessionKey,
759        operation_id: OperationId,
760        spawn: NativeSpawnRequest,
761        pty_env: Vec<EnvMutation>,
762        mcp_server: Option<McpServerSpec>,
763    ) -> ControlObservation {
764        let NativeSpawnRequest {
765            agent_id,
766            transport,
767            request,
768            runtime_policy,
769            mut launch_extra_args,
770            instance_extra_args,
771            resumed_provider_session,
772            one_shot_session_persistence,
773        } = spawn;
774        if let Err(message) =
775            validate_instance_launch_arguments(&agent_id, transport, &instance_extra_args)
776        {
777            return ControlObservation::SpawnFailed { message };
778        }
779        if let Err(message) = validate_spawn_runtime_policy(
780            runtime_policy,
781            transport,
782            request.initial_prompt.is_some(),
783            resumed_provider_session.is_some(),
784        ) {
785            return ControlObservation::SpawnFailed { message };
786        }
787        if self.session_exists(key) {
788            return ControlObservation::SpawnFailed {
789                message: format!(
790                    "native session {:?}/{:?} already exists",
791                    key.instance_id, key.generation
792                ),
793            };
794        }
795        if !request.terminal_size.is_valid() {
796            return ControlObservation::SpawnFailed {
797                message: "terminal size is outside the supported range".to_owned(),
798            };
799        }
800        if request.working_directory.is_empty()
801            || request.working_directory.len() > WORKING_DIRECTORY_MAX_BYTES
802            || request.working_directory.contains('\0')
803        {
804            return ControlObservation::SpawnFailed {
805                message: "working directory is invalid".to_owned(),
806            };
807        }
808        let Some(spec) = self.catalog.get(&agent_id).cloned() else {
809            return ControlObservation::SpawnFailed {
810                message: format!("agent '{agent_id}' is absent from native catalog"),
811            };
812        };
813        let working_dir = PathBuf::from(&request.working_directory);
814
815        match transport {
816            TransportKind::Pty if !spec.capabilities.transports.pty => {
817                ControlObservation::SpawnFailed {
818                    message: format!("agent '{agent_id}' does not support PTY transport"),
819                }
820            }
821            TransportKind::Pty => {
822                // Only the interactive PTY path renders anything to color;
823                // the Pipe/OneShotText arm below spawns a plain pipe with
824                // no terminal device behind it, so `pty_env` is left as the
825                // caller passed it there.
826                let pty_env = with_pty_terminal_capability_defaults(pty_env);
827                let fresh_provider_session = prepare_fresh_pty_provider_session(
828                    spec.capabilities.transports.pty_adapter.as_ref(),
829                    resumed_provider_session.is_some(),
830                    runtime_policy.provider_session_identity,
831                    &mut launch_extra_args,
832                );
833                launch_extra_args.extend(instance_extra_args);
834                let mut authoritative_provider_session = resumed_provider_session
835                    .clone()
836                    .or(fresh_provider_session);
837                let probe_kimi_identity = should_probe_pty_identity(
838                    runtime_policy,
839                    spec.capabilities.transports.pty_adapter.as_ref(),
840                    authoritative_provider_session.is_some(),
841                    "kimi",
842                );
843                let probe_codex_identity = should_probe_pty_identity(
844                    runtime_policy,
845                    spec.capabilities.transports.pty_adapter.as_ref(),
846                    authoritative_provider_session.is_some(),
847                    "codex",
848                );
849                // This is the last point before the OS-level PTY spawn where
850                // the program and its arguments are still plain, structured
851                // data (`spec.launch.program`/`fixed_args` are catalog
852                // constants; `launch_extra_args` is the dynamic, per-session
853                // portion). Provider secrets are environment-only by this
854                // repo's own convention (never argv, see `gate4agent/CLAUDE.md`),
855                // so `fixed_args` needs no redaction; `launch_extra_args`
856                // still gets a defensive per-token credential-shape check
857                // in case a provider CLI's own argv convention differs.
858                // The initial prompt itself is arbitrary-length user text,
859                // not an operational argument, so only its presence is
860                // logged, never its contents.
861                tracing::info!(
862                    agent_id = %agent_id,
863                    instance_id = ?key.instance_id,
864                    generation = ?key.generation,
865                    terminal_size = ?request.terminal_size,
866                    program = %spec.launch.program,
867                    fixed_args = ?spec.launch.fixed_args,
868                    extra_args = ?redact_provider_arguments(&launch_extra_args),
869                    has_initial_prompt = request.initial_prompt.is_some(),
870                    "spawning provider process over a PTY",
871                );
872                match PtySession::spawn_agent_with_size(
873                    &spec,
874                    LaunchRequest {
875                        working_dir,
876                        env: pty_env,
877                        platform: RuntimePlatform::current(),
878                        prompt: request.initial_prompt,
879                        session_options: request.session_options,
880                        extra_args: launch_extra_args,
881                    },
882                    request.terminal_size.rows,
883                    request.terminal_size.columns,
884                )
885                .await
886                {
887                Ok(mut session) => {
888                    if probe_kimi_identity {
889                        match probe_fresh_kimi_session_identity(&session, &spec).await {
890                            Ok(Some(identity)) => authoritative_provider_session = Some(identity),
891                            Ok(None) => {}
892                            Err(error) => {
893                                let message = match session.shutdown().await {
894                                    Ok(_) => error,
895                                    Err(shutdown_error) => {
896                                        format!("{error}; PTY cleanup failed: {shutdown_error}")
897                                    }
898                                };
899                                return ControlObservation::SpawnFailed { message };
900                            }
901                        }
902                    }
903                    if probe_codex_identity {
904                        match probe_fresh_codex_session_identity(&session, &spec).await {
905                            Ok(Some(identity)) => authoritative_provider_session = Some(identity),
906                            Ok(None) => {}
907                            Err(error) => {
908                                let message = match session.shutdown().await {
909                                    Ok(_) => error,
910                                    Err(shutdown_error) => {
911                                        format!("{error}; PTY cleanup failed: {shutdown_error}")
912                                    }
913                                };
914                                return ControlObservation::SpawnFailed { message };
915                            }
916                        }
917                    }
918                    if let Err(error) = deliver_pending_initial_prompt(&mut session, &spec).await {
919                        let message = match session.shutdown().await {
920                            Ok(_) => error,
921                            Err(shutdown_error) => {
922                                format!("{error}; PTY cleanup failed: {shutdown_error}")
923                            }
924                        };
925                        return ControlObservation::SpawnFailed { message };
926                    }
927                    let process_id = session.root_pid();
928                    let provider = match spec.capabilities.transports.pty_adapter.as_ref() {
929                        _ if !should_attach_pty_provider_stream(runtime_policy) => None,
930                        Some(adapter) => {
931                            let tool = match self
932                                .legacy_adapters
933                                .resolve(AdapterFamily::PtySemantic, adapter)
934                            {
935                                Ok(tool) => *tool,
936                                Err(error) => {
937                                    let _ = session.shutdown().await;
938                                    return ControlObservation::SpawnFailed {
939                                        message: error.to_string(),
940                                    };
941                                }
942                            };
943                            match session.attach_events(session.beginning_cursor()) {
944                                Ok(attachment) => {
945                                    let mut pending_events = VecDeque::new();
946                                    let mut provider_session_started = false;
947                                    if let Some(identity) = authoritative_provider_session {
948                                        pending_events.push_back(ProviderEvent::SessionStarted {
949                                            session_id: identity.id.clone(),
950                                            model: String::new(),
951                                            tools: Vec::new(),
952                                        });
953                                        pending_events.push_back(
954                                            ProviderEvent::SessionIdentityObserved { identity },
955                                        );
956                                        provider_session_started = true;
957                                    }
958                                    let is_kimi = tool == CliTool::KimiCode;
959                                    Some(OwnedPtyProvider {
960                                        source: ProviderSource {
961                                            family: AdapterFamily::PtySemantic,
962                                            binding: adapter.clone(),
963                                        },
964                                        receiver: attachment.receiver,
965                                        replay: attachment.replay.into(),
966                                        pending_events,
967                                        utf8: Utf8ChunkDecoder::default(),
968                                        pipeline: Mutex::new(create_pipeline(tool)),
969                                        rate_limits: RateLimitFeed::new_for_tool(tool),
970                                        kimi_identity: (runtime_policy.provider_session_identity
971                                            && is_kimi
972                                            && !provider_session_started)
973                                            .then(KimiPtySessionIdentityExtractor::default),
974                                        semantic_events: runtime_policy.semantic_readiness,
975                                        provider_session_started,
976                                        next_provider_sequence: 1,
977                                    })
978                                }
979                                Err(error) => {
980                                    let _ = session.shutdown().await;
981                                    return ControlObservation::SpawnFailed {
982                                        message: error.to_string(),
983                                    };
984                                }
985                            }
986                        }
987                        None => None,
988                    };
989                    self.pty_sessions.insert(
990                        key,
991                        OwnedPtySession {
992                            session,
993                            spawn_operation_id: operation_id,
994                            last_terminal_sequence: 0,
995                            terminal_stale_published: false,
996                            runtime_policy,
997                            provider,
998                            agent_id,
999                            last_screen_gate: None,
1000                            last_screen_failure: None,
1001                            last_foreground_verdict: None,
1002                            last_screen_state: PtyScreenState::default(),
1003                            ever_reached_ready: false,
1004                            screen_had_content: false,
1005                            // Armed immediately -- the first
1006                            // `reclassify_foreground` tick after spawn
1007                            // probes this session right away rather than
1008                            // waiting a full `FOREGROUND_RECLASSIFY_INTERVAL`.
1009                            next_foreground_probe: Some(Instant::now()),
1010                        },
1011                    );
1012                    ControlObservation::Spawned { process_id }
1013                }
1014                    Err(error) => {
1015                        let message = error.to_string();
1016                        tracing::warn!(
1017                            agent_id = %agent_id,
1018                            instance_id = ?key.instance_id,
1019                            generation = ?key.generation,
1020                            program = %spec.launch.program,
1021                            fixed_args = ?spec.launch.fixed_args,
1022                            cause = %message,
1023                            "provider process failed to start",
1024                        );
1025                        ControlObservation::SpawnFailed { message }
1026                    }
1027                }
1028            }
1029            TransportKind::Pipe => {
1030                let Some(pipe_spec) = spec.capabilities.transports.pipe.as_ref() else {
1031                    return ControlObservation::SpawnFailed {
1032                        message: format!("agent '{agent_id}' does not support Pipe transport"),
1033                    };
1034                };
1035                let prompt = request.initial_prompt.unwrap_or_default();
1036                if pipe_spec.protocol == PipeProtocol::OneShotText {
1037                    let Some(binding) = spec.capabilities.adapters.one_shot.as_ref() else {
1038                        return ControlObservation::SpawnFailed {
1039                            message: format!(
1040                                "agent '{agent_id}' does not declare OneShot capability"
1041                            ),
1042                        };
1043                    };
1044                    if binding != &pipe_spec.adapter {
1045                        return ControlObservation::SpawnFailed {
1046                            message: format!(
1047                                "agent '{agent_id}' has mismatched OneShot transport bindings"
1048                            ),
1049                        };
1050                    }
1051                    return match NativeOneShotSession::spawn_with_environment_and_persistence(
1052                        &spec,
1053                        binding,
1054                        &prompt,
1055                        request.session_options.as_ref(),
1056                        &working_dir,
1057                        &pty_env,
1058                        one_shot_session_persistence,
1059                    )
1060                    .await
1061                    {
1062                        Ok(session) => {
1063                            let process_id = session.process_id();
1064                            let events = session.subscribe();
1065                            self.one_shot_sessions.insert(
1066                                key,
1067                                OwnedProviderSession {
1068                                    source: ProviderSource {
1069                                        family: AdapterFamily::OneShot,
1070                                        binding: binding.clone(),
1071                                    },
1072                                    session,
1073                                    events,
1074                                    pending_events: VecDeque::new(),
1075                                    pending_provider_events: VecDeque::new(),
1076                                    next_provider_sequence: 1,
1077                                    observed_exit_code: None,
1078                                    runtime_policy,
1079                                },
1080                            );
1081                            ControlObservation::Spawned { process_id }
1082                        }
1083                        Err(error) => ControlObservation::SpawnFailed {
1084                            message: error.to_string(),
1085                        },
1086                    };
1087                }
1088                let tool = match self
1089                    .legacy_adapters
1090                    .resolve(AdapterFamily::Pipe, &pipe_spec.adapter)
1091                {
1092                    Ok(tool) => *tool,
1093                    Err(error) => {
1094                        return ControlObservation::SpawnFailed {
1095                            message: error.to_string(),
1096                        }
1097                    }
1098                };
1099                let source = ProviderSource {
1100                    family: AdapterFamily::Pipe,
1101                    binding: pipe_spec.adapter.clone(),
1102                };
1103                let config = SessionConfig {
1104                    tool,
1105                    working_dir,
1106                    env_vars: Vec::new(),
1107                    name: None,
1108                };
1109                if resumed_provider_session.is_some() && pipe_spec.launch_override.is_some() {
1110                    return ControlObservation::SpawnFailed {
1111                        message: format!(
1112                            "agent '{agent_id}' cannot resume through a catalog launch override"
1113                        ),
1114                    };
1115                }
1116                let mut options = PipeProcessOptions::default();
1117                if let Some(identity) = resumed_provider_session.as_ref() {
1118                    options.claude.resume_session_id = Some(identity.id.clone());
1119                }
1120                let spawned = match pipe_spec.launch_override.as_ref() {
1121                    Some(launch) => {
1122                        PipeSession::spawn_with_launch(
1123                            config,
1124                            &prompt,
1125                            launch,
1126                            pipe_spec.prompt_delivery,
1127                        )
1128                        .await
1129                    }
1130                    None => {
1131                        PipeSession::spawn(config, &prompt, options).await
1132                    }
1133                };
1134                match spawned {
1135                    Ok(session) => {
1136                        let process_id = session.process_id();
1137                        let events = session.subscribe();
1138                        let pending_events = if pipe_spec.protocol == PipeProtocol::SemanticNdjson {
1139                            VecDeque::from([AgentEvent::SessionStart {
1140                                session_id: session.session_id().to_owned(),
1141                                model: String::new(),
1142                                tools: Vec::new(),
1143                            }])
1144                        } else {
1145                            VecDeque::new()
1146                        };
1147                        self.pipe_sessions.insert(
1148                            key,
1149                            OwnedProviderSession {
1150                                source,
1151                                session,
1152                                events,
1153                                pending_events,
1154                                pending_provider_events: VecDeque::new(),
1155                                next_provider_sequence: 1,
1156                                observed_exit_code: None,
1157                                runtime_policy,
1158                            },
1159                        );
1160                        ControlObservation::Spawned { process_id }
1161                    }
1162                    Err(error) => ControlObservation::SpawnFailed {
1163                        message: error.to_string(),
1164                    },
1165                }
1166            }
1167            TransportKind::Acp => {
1168                let Some(acp_spec) = spec.capabilities.transports.acp else {
1169                    return ControlObservation::SpawnFailed {
1170                        message: format!("agent '{agent_id}' does not support ACP transport"),
1171                    };
1172                };
1173                let tool = match self
1174                    .legacy_adapters
1175                    .resolve(AdapterFamily::Acp, &acp_spec.adapter)
1176                {
1177                    Ok(tool) => *tool,
1178                    Err(error) => {
1179                        return ControlObservation::SpawnFailed {
1180                            message: error.to_string(),
1181                        }
1182                    }
1183                };
1184                let source = ProviderSource {
1185                    family: AdapterFamily::Acp,
1186                    binding: acp_spec.adapter.clone(),
1187                };
1188                // ACP is the transport this project opens programmatically --
1189                // unlike a PTY, there is no human at a terminal to pick
1190                // permission flags. Measured live: both running ACP adapters
1191                // (claude, codex) came up with no `--permission-mode` flag
1192                // reaching them at all and no permission request ever
1193                // appearing, because `npx`-wrapped specs never receive one
1194                // (`applicable_approval_args`, `src/acp/spawn.rs`) -- the
1195                // adapter simply started in its own default, confirmed on
1196                // the wire as `mode:Mode="auto"`, which approves itself.
1197                // Argv was fiction for ACP; the level lives in `session/
1198                // set_mode` instead now, for every provider, npx-wrapped or
1199                // the vendor's own binary alike -- one mechanism per
1200                // transport (`applicable_approval_args` never forwards an
1201                // approval flag to an ACP-spawned process any more).
1202                //
1203                // `required_acp_mode` resolves the concrete ACP mode id this
1204                // level needs from `gate4agent_catalog::approval_level_
1205                // resolution`'s own `acp_mode_id` column, and refuses BEFORE
1206                // a process is even spawned whenever no mechanism exists to
1207                // enforce the request: `ApprovalLevelResolution::Unsupported`
1208                // (today, `grok`/`kimi` at `ReadOnly`), or a `Supported` row
1209                // this catalog has no sourced ACP mode id for yet (today,
1210                // every level on `codex`/`grok`/`kimi` except `Unmanaged`).
1211                // Silently proceeding either way would spawn the vendor's
1212                // own default instead -- a wider authority than requested,
1213                // the exact defect this rewrite closes. `Unmanaged` is the
1214                // one level that legitimately resolves to "apply nothing" --
1215                // it imposes nothing by definition.
1216                let acp_mode_id = match required_acp_mode(&agent_id, request.approval_level) {
1217                    Ok(mode_id) => mode_id,
1218                    Err(message) => return ControlObservation::SpawnFailed { message },
1219                };
1220                // The MCP-server door (gate4agent-arc-mailbox-and-task-layer
1221                // Slice A(ii)): `mcp_server` carries the node's resolved MCP
1222                // server overlay whenever one was prepared for this spawn,
1223                // transport-agnostic same as every other launch-profile
1224                // mutation -- see `mcp_server_acp_entry`'s own doc comment.
1225                // `None` when the node prepared none, which keeps
1226                // `mcp_servers` empty and changes nothing else.
1227                let mcp_servers: Vec<McpServerConfig> =
1228                    mcp_server.as_ref().map(mcp_server_acp_entry).into_iter().collect();
1229                if !mcp_servers.is_empty() {
1230                    // State-change line: this is the point measured live to
1231                    // be silently empty on the wire before the ACP v1 shape
1232                    // fix (`McpServerConfig`, `src/acp/protocol.rs`) -- a
1233                    // nameless, map-shaped entry the agent's own adapter
1234                    // dropped before ever registering it, so `tools/list`
1235                    // never ran. Names only, never `env` (may carry
1236                    // tokens/endpoints).
1237                    tracing::info!(
1238                        agent_id = %agent_id,
1239                        instance_id = ?key.instance_id,
1240                        mcp_server_names = ?mcp_servers.iter().map(McpServerConfig::name).collect::<Vec<_>>(),
1241                        "acp session/new carries {} mcp server(s)",
1242                        mcp_servers.len(),
1243                    );
1244                }
1245                // Codex station network_access first slice: argv-only `-c`
1246                // overlays arrive as instance_extra_args (same channel as
1247                // catalog windows_wsl_setup). Merge after approval flags.
1248                let mut approval_level_args =
1249                    acp_approval_level_args(&agent_id, request.approval_level);
1250                for argument in &instance_extra_args {
1251                    approval_level_args.push(argument.to_string_lossy().into_owned());
1252                }
1253                let acp_options = AcpSessionOptions {
1254                    host_policy: host_policy_for_approval_level(request.approval_level),
1255                    // Empty for an agent that announced an `acp_mode_id` for
1256                    // this level: that one is applied through
1257                    // `session/set_mode` below, once the handshake is up,
1258                    // and its argv stays clean (see this branch's own
1259                    // comment above and `applicable_approval_args`,
1260                    // `src/acp/spawn.rs`). An agent that announced NO mode
1261                    // has no such mechanism -- measured 2026-09-09, kimi
1262                    // announces none at any level, so an empty argv left it
1263                    // running at the vendor's own default and it refused
1264                    // every `g4a_*` MCP tool call at its OWN approval
1265                    // prompt without ever sending the host a
1266                    // `session/request_permission`. For that shape argv is
1267                    // the only lever there is. Codex `-c` network overlays
1268                    // may still append via `instance_extra_args` above.
1269                    approval_level_args,
1270                    defer_permission_requests: defers_permission_requests(
1271                        &agent_id,
1272                        request.approval_level,
1273                    ),
1274                    mcp_servers,
1275                    ..AcpSessionOptions::default()
1276                };
1277                let spawned = match acp_spec.launch_override.as_ref() {
1278                    Some(launch) => {
1279                        AcpSession::spawn_with_launch(tool, &working_dir, acp_options, launch)
1280                            .await
1281                    }
1282                    None => AcpSession::spawn(tool, &working_dir, acp_options).await,
1283                };
1284                match spawned {
1285                    Ok(session) => {
1286                        if let Some(mode_id) = acp_mode_id.as_ref() {
1287                            if let Err(message) =
1288                                apply_acp_approval_mode(&session, mode_id, request.approval_level)
1289                                    .await
1290                            {
1291                                let _ = session.kill().await;
1292                                return ControlObservation::SpawnFailed { message };
1293                            }
1294                        }
1295                        let process_id = session.process_id();
1296                        let events = session.subscribe();
1297                        let session_id = session
1298                            .acp_session_id()
1299                            .await
1300                            .unwrap_or_else(|| session.session_id().to_owned());
1301                        // Binds a freshly created record from `IdentityPending`
1302                        // to `Live` (`gate4agent-node`'s `reconcile_managed_
1303                        // record` on `ProviderEvent::SessionIdentityObserved`)
1304                        // as soon as the session exists, mirroring the PTY
1305                        // path's own identity seeding
1306                        // (`prepare_fresh_pty_provider_session`/
1307                        // `probe_fresh_*_session_identity`) for the transport
1308                        // ACP's own spec makes this a protocol MUST for:
1309                        // `session/new` MUST return a `sessionId`, and both
1310                        // shipped adapters map it onto the provider's own
1311                        // durable session id (`AcpSession::
1312                        // provider_reported_session_id`'s own doc comment).
1313                        //
1314                        // Gated on `runtime_policy.provider_session_identity`
1315                        // -- the same capability the PTY path checks before
1316                        // treating any identity as authoritative -- so a
1317                        // spawn that never asked for identity tracking stays
1318                        // silent rather than warning on every ACP session
1319                        // whose caller has no interest in one.
1320                        //
1321                        // `provider_reported_session_id` (NOT `session_id`
1322                        // above) is the signal here: `session_id` already
1323                        // falls back to the host-local id the moment the
1324                        // agent's `session/new` response carries no
1325                        // `sessionId`, so it can never itself distinguish "the
1326                        // agent reported one" from "the agent violated ACP's
1327                        // MUST and this build filled in its own" -- exactly
1328                        // the distinction the `IdentityPending` refusal
1329                        // downstream depends on.
1330                        let identity_observed = if runtime_policy.provider_session_identity {
1331                            match session.provider_reported_session_id().await {
1332                                Some(id) => Some(ProviderEvent::SessionIdentityObserved {
1333                                    identity: ProviderSessionIdentity {
1334                                        key: ProviderSessionKey::SessionId,
1335                                        id,
1336                                        transcript_path: None,
1337                                    },
1338                                }),
1339                                None => {
1340                                    tracing::warn!(
1341                                        agent_id = %agent_id,
1342                                        adapter = %acp_spec.adapter.id,
1343                                        "ACP session/new returned no sessionId; provider \
1344                                         session identity stays IdentityPending",
1345                                    );
1346                                    None
1347                                }
1348                            }
1349                        } else {
1350                            None
1351                        };
1352                        if let Some(prompt) = request.initial_prompt {
1353                            if let Err(error) = session.start_prompt(&prompt).await {
1354                                let _ = session.kill().await;
1355                                return ControlObservation::SpawnFailed {
1356                                    message: error.to_string(),
1357                                };
1358                            }
1359                        }
1360                        // Read before `session` moves into the map below.
1361                        let announced_mode = session.current_mode_id();
1362                        self.acp_sessions.insert(
1363                            key,
1364                            OwnedProviderSession {
1365                                source,
1366                                session,
1367                                events,
1368                                pending_events: {
1369                                    let mut seeded = VecDeque::from([AgentEvent::SessionStart {
1370                                        session_id,
1371                                        model: String::new(),
1372                                        tools: Vec::new(),
1373                                    }]);
1374                                    // Announce the mode catalogue once, at
1375                                    // session start.
1376                                    //
1377                                    // `ModeCatalog` reaches the operator only
1378                                    // from a `ModeChanged`, so without this it
1379                                    // is emitted solely when a mode CHANGES --
1380                                    // and an operator cannot change a mode
1381                                    // without knowing an id, which is what the
1382                                    // catalogue carries. That is a closed
1383                                    // loop: the one thing needed to ask is
1384                                    // only published in reply to asking.
1385                                    //
1386                                    // Both halves are handshake facts, not
1387                                    // inventions: `available_modes` is the
1388                                    // list the agent returned from
1389                                    // `session/new`, and `current_mode_id` is
1390                                    // what it said it is in right now. Seeding
1391                                    // them as a `ModeChanged` is a small
1392                                    // misnomer -- nothing changed -- but the
1393                                    // payload is the state, and the operator
1394                                    // needs the state before it can ever
1395                                    // change.
1396                                    if let Some(mode_id) = announced_mode {
1397                                        seeded.push_back(AgentEvent::ModeChanged { mode_id });
1398                                    }
1399                                    seeded
1400                                },
1401                                pending_provider_events: VecDeque::from_iter(identity_observed),
1402                                next_provider_sequence: 1,
1403                                observed_exit_code: None,
1404                                runtime_policy,
1405                            },
1406                        );
1407                        ControlObservation::Spawned { process_id }
1408                    }
1409                    Err(error) => ControlObservation::SpawnFailed {
1410                        message: error.to_string(),
1411                    },
1412                }
1413            }
1414        }
1415    }
1416
1417    async fn spawn_resume(
1418        &mut self,
1419        key: NativeSessionKey,
1420        operation_id: OperationId,
1421        agent_id: AgentId,
1422        transport: TransportKind,
1423        provider_session: gate4agent_types::ProviderSessionIdentity,
1424        runtime_policy: ProviderRuntimePolicy,
1425        request: ResumeLaunchRequest,
1426        pty_env: Vec<EnvMutation>,
1427        instance_extra_args: Vec<OsString>,
1428        one_shot_session_persistence: OneShotSessionPersistence,
1429    ) -> ControlObservation {
1430        if let Err(message) = validate_spawn_runtime_policy(
1431            runtime_policy,
1432            transport,
1433            request.initial_prompt.is_some(),
1434            true,
1435        ) {
1436            return ControlObservation::SpawnFailed { message };
1437        }
1438        if let Err(error) = request.validate() {
1439            return ControlObservation::SpawnFailed {
1440                message: error.to_string(),
1441            };
1442        }
1443        let Some(spec) = self.catalog.get(&agent_id) else {
1444            return ControlObservation::SpawnFailed {
1445                message: format!("agent '{agent_id}' is absent from native catalog"),
1446            };
1447        };
1448        let Some(binding) = spec.capabilities.adapters.resume.as_ref() else {
1449            return ControlObservation::SpawnFailed {
1450                message: format!("agent '{agent_id}' does not declare Resume capability"),
1451            };
1452        };
1453        let plan = match build_resume_plan_for_identity(&binding.id, &provider_session) {
1454            Ok(Some(plan)) => plan,
1455            Ok(None) => {
1456                return ControlObservation::SpawnFailed {
1457                    message: format!("agent '{agent_id}' has no live Resume plan"),
1458                }
1459            }
1460            Err(error) => {
1461                return ControlObservation::SpawnFailed {
1462                    message: error.to_string(),
1463                }
1464            }
1465        };
1466        if transport == TransportKind::Pipe {
1467            let Some(pipe) = spec.capabilities.transports.pipe.as_ref() else {
1468                return ControlObservation::SpawnFailed {
1469                    message: format!("agent '{agent_id}' does not support Pipe transport"),
1470                };
1471            };
1472            if pipe.protocol != PipeProtocol::StructuredJsonl {
1473                return ControlObservation::SpawnFailed {
1474                    message: format!(
1475                        "agent '{agent_id}' does not expose a resumable structured Pipe contract"
1476                    ),
1477                };
1478            }
1479        }
1480        let start = StartRequest {
1481            working_directory: request.working_directory,
1482            terminal_size: request.terminal_size,
1483            initial_prompt: request.initial_prompt,
1484            // A resume reattaches to a provider session that already has a
1485            // process running with whatever argv it was originally spawned
1486            // with -- there is no fresh CLI invocation here to apply an
1487            // approval level to, the same reasoning that leaves
1488            // `session_options` unset on a resume above.
1489            session_options: None,
1490            approval_level: ApprovalLevel::default(),
1491        };
1492        self.spawn_native(
1493            key,
1494            operation_id,
1495            NativeSpawnRequest {
1496                agent_id,
1497                transport,
1498                request: start,
1499                runtime_policy,
1500                launch_extra_args: if transport == TransportKind::Pty {
1501                    plan.args.into_iter().map(OsString::from).collect()
1502                } else {
1503                    Vec::new()
1504                },
1505                instance_extra_args,
1506                resumed_provider_session: Some(provider_session),
1507                one_shot_session_persistence,
1508            },
1509            pty_env,
1510            None,
1511        )
1512        .await
1513    }
1514
1515    async fn stop_native(&mut self, key: NativeSessionKey, force: bool) -> ControlObservation {
1516        if let Some(owned) = self.pty_sessions.remove(&key) {
1517            let shutdown = owned.session.shutdown().await;
1518            return match shutdown {
1519                Ok(outcome) => ControlObservation::StopCompleted {
1520                    forced: force || outcome.termination.is_some(),
1521                    exit_code: outcome.exit_code,
1522                    // The last classification this session ever computed --
1523                    // stamped through unchanged, same as every other frame;
1524                    // nothing observes this session again after this point.
1525                    final_terminal: Some(terminal_frame(outcome.terminal, owned.last_screen_state.clone())),
1526                },
1527                Err(error) => {
1528                    eprintln!(
1529                        "[gate4agent-shell-native] PTY stop failed for {key:?} (force={force}): {error}",
1530                    );
1531                    ControlObservation::StopFailed {
1532                        message: error.to_string(),
1533                    }
1534                }
1535            };
1536        }
1537        if let Some(owned) = self.pipe_sessions.remove(&key) {
1538            // `PipeSession::stop` reads `force`: `false` waits up to
1539            // `PIPE_GRACEFUL_STOP_BOUND_SECS` for the process to exit on its
1540            // own (stdin is already closed by spawn time for every
1541            // supported CLI, so this is "let the run finish" rather than a
1542            // new signal) before falling back to a kill; `true` kills
1543            // immediately, same as the old unconditional `kill()` call this
1544            // replaces. `Err(_) if reader_finished()` below is the race
1545            // where a `force = true` kill loses to the process already
1546            // having exited on its own between the map lookup and the kill
1547            // attempt -- `stop`'s own graceful path already reports that
1548            // case as `Ok` with `forced: false`, so this arm is only ever
1549            // reached on the `force = true` side of `stop`.
1550            return match owned.session.stop(force).await {
1551                Ok(outcome) => ControlObservation::StopCompleted {
1552                    forced: outcome.forced,
1553                    exit_code: outcome.exit_code.or(owned.observed_exit_code),
1554                    final_terminal: None,
1555                },
1556                Err(_) if owned.session.reader_finished() => ControlObservation::StopCompleted {
1557                    forced: false,
1558                    exit_code: owned.observed_exit_code,
1559                    final_terminal: None,
1560                },
1561                Err(error) => ControlObservation::StopFailed {
1562                    message: error.to_string(),
1563                },
1564            };
1565        }
1566        if let Some(mut owned) = self.one_shot_sessions.remove(&key) {
1567            return match owned.session.kill().await {
1568                Ok(()) => ControlObservation::StopCompleted {
1569                    forced: true,
1570                    exit_code: owned.observed_exit_code,
1571                    final_terminal: None,
1572                },
1573                Err(_) if owned.session.reader_finished() => ControlObservation::StopCompleted {
1574                    forced: false,
1575                    exit_code: owned.observed_exit_code,
1576                    final_terminal: None,
1577                },
1578                Err(error) => ControlObservation::StopFailed {
1579                    message: error.to_string(),
1580                },
1581            };
1582        }
1583        if let Some(owned) = self.acp_sessions.remove(&key) {
1584            // `AcpSession::stop` reads `force`: `false` closes stdin and
1585            // waits up to `ACP_GRACEFUL_STOP_BOUND_SECS` for the adapter to
1586            // exit on its own before falling back to a kill; `true` kills
1587            // immediately, same as the old unconditional `kill()` call this
1588            // replaces. `Err(_) if reader_finished()` below is the race
1589            // where a `force = true` kill loses to the process already
1590            // having exited on its own between the map lookup and the kill
1591            // attempt -- `stop`'s own graceful path already reports that
1592            // case as `Ok` with `forced: false`, so this arm is only ever
1593            // reached on the `force = true` side of `stop`.
1594            return match owned.session.stop(force).await {
1595                Ok(outcome) => ControlObservation::StopCompleted {
1596                    forced: outcome.forced,
1597                    exit_code: outcome.exit_code.or(owned.observed_exit_code),
1598                    final_terminal: None,
1599                },
1600                Err(_) if owned.session.reader_finished() => ControlObservation::StopCompleted {
1601                    forced: false,
1602                    exit_code: owned.observed_exit_code,
1603                    final_terminal: None,
1604                },
1605                Err(error) => ControlObservation::StopFailed {
1606                    message: error.to_string(),
1607                },
1608            };
1609        }
1610        // A kill this function skips entirely -- nothing owns `key` in any
1611        // transport map -- must say so out loud: silently returning
1612        // `StopFailed` here left no trail at the point where the skip
1613        // actually happened, only a message string several layers removed
1614        // from anyone watching this process's own output.
1615        eprintln!("[gate4agent-shell-native] stop skipped: no session owns {key:?} in any transport map");
1616        ControlObservation::StopFailed {
1617            message: missing_session_message(key),
1618        }
1619    }
1620
1621    fn session_exists(&self, key: NativeSessionKey) -> bool {
1622        self.pty_sessions.contains_key(&key)
1623            || self.pipe_sessions.contains_key(&key)
1624            || self.one_shot_sessions.contains_key(&key)
1625            || self.acp_sessions.contains_key(&key)
1626    }
1627
1628    /// Convert naturally exited PTY children into generation-bound lifecycle
1629    /// observations. Runtime ticks call this before accepting new commands.
1630    pub async fn collect_exits(&mut self) -> Vec<ObservationEnvelope> {
1631        let completed: Vec<_> = self
1632            .pty_sessions
1633            .iter()
1634            .filter_map(|(key, owned)| owned.session.reader_finished().then_some(*key))
1635            .collect();
1636        let mut observations = Vec::with_capacity(completed.len());
1637        for key in completed {
1638            let owned = self
1639                .pty_sessions
1640                .remove(&key)
1641                .expect("completed key came from the owned session map");
1642            let last_screen_state = owned.last_screen_state.clone();
1643            let (exit_code, final_terminal) = match owned.session.shutdown().await {
1644                Ok(outcome) => (
1645                    outcome.exit_code,
1646                    Some(terminal_frame(outcome.terminal, last_screen_state)),
1647                ),
1648                Err(_) => (None, None),
1649            };
1650            observations.push(ObservationEnvelope {
1651                operation_id: None,
1652                instance_id: key.instance_id,
1653                generation: key.generation,
1654                observation: ControlObservation::ProcessExited {
1655                    exit_code,
1656                    final_terminal,
1657                },
1658            });
1659        }
1660        collect_provider_exits(&mut self.pipe_sessions, &mut observations, |session| {
1661            session.reader_finished()
1662        });
1663        collect_provider_exits(&mut self.one_shot_sessions, &mut observations, |session| {
1664            session.reader_finished()
1665        });
1666        collect_provider_exits(&mut self.acp_sessions, &mut observations, |session| {
1667            session.reader_finished()
1668        });
1669        observations
1670    }
1671
1672    /// Drain normalized provider events without mixing them with replaceable
1673    /// terminal frames. Broadcast lag is converted into an explicit stale gap.
1674    pub fn collect_provider_events(&mut self) -> Vec<ObservationEnvelope> {
1675        let mut observations = self.pending_observations.drain(..).collect::<Vec<_>>();
1676        for (key, owned) in &mut self.pty_sessions {
1677            if let Some(provider) = &mut owned.provider {
1678                drain_pty_provider(*key, provider, &mut observations);
1679            }
1680        }
1681        collect_provider_map(&mut self.pipe_sessions, &mut observations, |_| Vec::new());
1682        collect_provider_map(&mut self.one_shot_sessions, &mut observations, |_| Vec::new());
1683        // Retire deferred permission requests whose deadline has passed,
1684        // BEFORE draining -- so the `RpcIncomingRequest` each expiry emits is
1685        // picked up by the very same drain rather than waiting a whole tick
1686        // to be reported.
1687        //
1688        // This call is what makes the deadline real. Without it a deferred
1689        // request has a `deadline` field nobody ever reads, and a session
1690        // whose operator never answers waits forever on a promise the code
1691        // makes and never keeps -- the agent blocked on a permission it will
1692        // never be told about. Deferral is only safe because this runs on
1693        // every tick; nothing else in the process calls it.
1694        for owned in self.acp_sessions.values() {
1695            owned.session.expire_deadlines();
1696        }
1697        collect_provider_map(&mut self.acp_sessions, &mut observations, AcpSession::available_modes);
1698        observations
1699    }
1700
1701    /// Capture only changed terminal frames. Snapshot failures become an
1702    /// explicit stale observation once, until a later successful frame heals it.
1703    ///
1704    /// The sequence is checked BEFORE the capture, not after. This runs on
1705    /// the per-session worker tick (20ms, so ~50 times a second per live
1706    /// PTY session), and `terminal_state` is not a cheap read: it renders
1707    /// the visible screen twice and clones the whole `vt100::Screen` to
1708    /// walk its scrollback row by row. Asking it first and comparing
1709    /// sequences afterwards meant every idle session rebuilt its entire
1710    /// screen fifty times a second purely to discover nothing had changed,
1711    /// and then dropped the result -- with a couple of dozen sessions open
1712    /// that is the machine's time, all of it wasted. `terminal_sequence`
1713    /// answers the same question by reading one integer.
1714    pub fn collect_terminal_frames(&mut self) -> Vec<ObservationEnvelope> {
1715        let mut observations = Vec::new();
1716        // Split borrows taken up front: the loop below needs a mutable
1717        // borrow of `pty_sessions` for its whole body, and `efficiency_facts`
1718        // is a disjoint field this function also writes to on every branch
1719        // -- see `ShellEfficiencyFacts`'s own doc comment for why this crate
1720        // is the one recording facts rather than a distribution.
1721        let pty_sessions = &mut self.pty_sessions;
1722        let efficiency_facts = &mut self.efficiency_facts;
1723        for (key, owned) in pty_sessions {
1724            if terminal_state_capture_should_skip(
1725                owned.session.terminal_sequence(),
1726                owned.last_terminal_sequence,
1727            ) {
1728                efficiency_facts.record_terminal_state_skip();
1729                continue;
1730            }
1731            let capture_start = Instant::now();
1732            let terminal_state = owned.session.terminal_state();
1733            efficiency_facts.record_terminal_state_capture(capture_start.elapsed());
1734            match terminal_state {
1735                Ok(snapshot) if snapshot.sequence > owned.last_terminal_sequence => {
1736                    owned.last_terminal_sequence = snapshot.sequence;
1737                    owned.terminal_stale_published = false;
1738
1739                    // `snapshot.contents` is already in memory for the
1740                    // `TerminalFrame` below, so both text matchers run for
1741                    // free here -- no extra syscall, no extra capture.
1742                    owned.last_screen_gate = startup_operator_gate(&snapshot.contents);
1743                    // `screen_failure`'s markers are crash SHAPES, and as
1744                    // raw bytes those are indistinguishable from an agent
1745                    // choosing to render the same text while explaining or
1746                    // running someone else's failure. Before this
1747                    // generation has ever rendered its own UI, nothing else
1748                    // could have put a crash banner on the screen, so the
1749                    // marker genuinely means the CLI failed to come up;
1750                    // once it has been `Ready`, the identical bytes are
1751                    // ordinary content, not state, so the matcher is not
1752                    // even run.
1753                    owned.last_screen_failure =
1754                        screen_failure_for_generation(&snapshot.contents, owned.ever_reached_ready);
1755                    let merged = classify_pty_screen_state(
1756                        owned.last_foreground_verdict.as_ref(),
1757                        owned.last_screen_gate.as_ref(),
1758                        owned.last_screen_failure,
1759                    );
1760                    // Arm the crash-marker window only on a `Ready` that had
1761                    // SOMETHING on screen. `Ready` is proved by foreground
1762                    // process identity, so it lands within a frame or two of
1763                    // spawn, while the terminal is still blank -- measured at
1764                    // frame 2 for all four providers. Arming on that blank
1765                    // frame closed the window before any failure text could
1766                    // exist: Grok without a key prints `API key required` at
1767                    // frame 4 and sat at `Ready` forever, because the matcher
1768                    // for it was never reached. A blank screen is not evidence
1769                    // the agent came up, so it must not retire the detector.
1770                    if !snapshot.contents.trim().is_empty() {
1771                        owned.screen_had_content = true;
1772                    }
1773                    if merged == PtyScreenState::Ready && owned.screen_had_content {
1774                        owned.ever_reached_ready = true;
1775                    }
1776                    if merged != owned.last_screen_state {
1777                        if foreground_probe_rearms_immediately(&owned.last_screen_state, &merged) {
1778                            owned.next_foreground_probe = Some(Instant::now());
1779                        }
1780                        owned.last_screen_state = merged.clone();
1781                        observations.push(ObservationEnvelope {
1782                            operation_id: None,
1783                            instance_id: key.instance_id,
1784                            generation: key.generation,
1785                            observation: ControlObservation::ScreenState {
1786                                state: merged.clone(),
1787                            },
1788                        });
1789                    }
1790
1791                    let frame = terminal_frame(snapshot, merged);
1792                    efficiency_facts.record_terminal_frame_published(terminal_frame_byte_len(&frame));
1793                    observations.push(ObservationEnvelope {
1794                        operation_id: None,
1795                        instance_id: key.instance_id,
1796                        generation: key.generation,
1797                        observation: ControlObservation::TerminalFrame { frame },
1798                    });
1799                }
1800                Ok(_) => {}
1801                Err(error) if !owned.terminal_stale_published => {
1802                    owned.terminal_stale_published = true;
1803                    observations.push(ObservationEnvelope {
1804                        operation_id: None,
1805                        instance_id: key.instance_id,
1806                        generation: key.generation,
1807                        observation: ControlObservation::TerminalStale {
1808                            message: error.to_string(),
1809                        },
1810                    });
1811                }
1812                Err(_) => {}
1813            }
1814        }
1815        observations
1816    }
1817
1818    /// Refresh the foreground half of `PtyScreenState` for whichever
1819    /// sessions are due, per `next_foreground_probe`.
1820    ///
1821    /// Deliberately not folded into `collect_terminal_frames`: that method
1822    /// is synchronous and only pays for a real capture when the terminal
1823    /// sequence says the screen changed, but
1824    /// `PtySession::observe_foreground_timed` is async and walks the live OS
1825    /// process tree
1826    /// (`CreateToolhelp32Snapshot` on Windows) unconditionally every time
1827    /// it is called. Running that walk once per changed frame would scale
1828    /// a syscall with output rate -- exactly the cost
1829    /// `collect_terminal_frames`'s own sequence gate exists to avoid. The
1830    /// cost here is bounded by the count of sessions NOT currently
1831    /// `Ready`, never by output rate and never by total session count: a
1832    /// session that reaches `Ready` disarms itself (see
1833    /// `foreground_probe_schedule`) and is never probed again until its
1834    /// text disagrees.
1835    pub async fn reclassify_foreground(&mut self) -> Vec<ObservationEnvelope> {
1836        let catalog = &self.catalog;
1837        let now = Instant::now();
1838        let due: Vec<NativeSessionKey> = self
1839            .pty_sessions
1840            .iter()
1841            .filter(|(_, owned)| owned.next_foreground_probe.is_some_and(|at| now >= at))
1842            .map(|(key, _)| *key)
1843            .collect();
1844
1845        let mut observations = Vec::new();
1846        for key in due {
1847            let Some(owned) = self.pty_sessions.get_mut(&key) else {
1848                continue;
1849            };
1850            let probe_result = owned.session.observe_foreground_timed().await;
1851            match probe_result {
1852                Ok((observation, timing)) => {
1853                    // Recorded on success only: `timing` covers all three
1854                    // components (queue, lock wait, walk) exactly when the
1855                    // walk itself ran to completion and returned a real
1856                    // observation. On error (see below) the walk may have
1857                    // failed partway through, in the mutex, or never been
1858                    // dispatched at all (`spawn_blocking` panicked), so
1859                    // there is no single component that is reliably "the
1860                    // cost of this probe" to attribute a failure to -- unlike
1861                    // `terminal_state_capture`, which always completes or
1862                    // never starts, a foreground probe can fail after
1863                    // dispatch, after acquiring the lock, or during the walk,
1864                    // and only the success path knows which.
1865                    self.efficiency_facts.record_foreground_probe(timing);
1866                    let verdict = match catalog.get(&owned.agent_id) {
1867                        Some(spec) => resolve_foreground_verdict(
1868                            spec,
1869                            &observation,
1870                            RuntimePlatform::current(),
1871                        ),
1872                        // The catalog is loaded once at startup and does not
1873                        // shrink at runtime; this branch exists only so a
1874                        // hypothetical gap fails toward the conservative
1875                        // "not confirmed as the agent" reading rather than
1876                        // panicking or silently keeping a stale verdict.
1877                        None => ForegroundVerdict::Foreign {
1878                            process: observation.observed_process.clone(),
1879                        },
1880                    };
1881                    owned.last_foreground_verdict = Some(verdict);
1882                    let merged = classify_pty_screen_state(
1883                        owned.last_foreground_verdict.as_ref(),
1884                        owned.last_screen_gate.as_ref(),
1885                        owned.last_screen_failure,
1886                    );
1887                    // A foreground-only transition into `Ready` (text was
1888                    // already clean; only the process signal was missing)
1889                    // must ALSO close the `screen_failure` window for
1890                    // future text passes -- see `collect_terminal_frames`.
1891                    if merged == PtyScreenState::Ready && owned.screen_had_content {
1892                        owned.ever_reached_ready = true;
1893                    }
1894                    if merged != owned.last_screen_state {
1895                        owned.last_screen_state = merged.clone();
1896                        observations.push(ObservationEnvelope {
1897                            operation_id: None,
1898                            instance_id: key.instance_id,
1899                            generation: key.generation,
1900                            observation: ControlObservation::ScreenState { state: merged },
1901                        });
1902                    }
1903                    owned.next_foreground_probe =
1904                        match foreground_probe_schedule(&owned.last_screen_state) {
1905                            ForegroundProbeSchedule::Disarmed => None,
1906                            ForegroundProbeSchedule::Armed => {
1907                                Some(now + FOREGROUND_RECLASSIFY_INTERVAL)
1908                            }
1909                        };
1910                }
1911                Err(_) => {
1912                    // A failed OS process-tree walk is `Unknown`'s territory
1913                    // -- it says nothing was confirmed, not that the
1914                    // process is wrong. Leave whatever verdict/state is
1915                    // already recorded alone and simply try again next
1916                    // cadence rather than fabricating a `NotAgent` verdict
1917                    // or a `Ready` one.
1918                    owned.next_foreground_probe = Some(now + FOREGROUND_RECLASSIFY_INTERVAL);
1919                }
1920            }
1921        }
1922        observations
1923    }
1924}
1925
1926/// ConPTY does not populate `TERM`/`COLORTERM` in the environment it hands
1927/// to a spawned child; a provider CLI reads exactly those two variables to
1928/// decide whether the terminal in front of it renders color, so a
1929/// Windows-hosted PTY session comes up monochrome unless something else
1930/// supplies them. These are the values truecolor-capable terminals
1931/// (xterm.js, WezTerm, ...) advertise about themselves.
1932const PTY_TERM_DEFAULT_KEY: &str = "TERM";
1933const PTY_TERM_DEFAULT_VALUE: &str = "xterm-256color";
1934const PTY_COLORTERM_DEFAULT_KEY: &str = "COLORTERM";
1935const PTY_COLORTERM_DEFAULT_VALUE: &str = "truecolor";
1936
1937/// Fill in the PTY terminal-capability defaults above for whichever of the
1938/// two keys the caller has not already mutated. A caller-supplied
1939/// `EnvMutation` for `TERM`/`COLORTERM` -- whether it sets or removes the
1940/// variable -- always wins: this only appends a default into a gap, it
1941/// never overwrites or reorders an existing entry.
1942fn with_pty_terminal_capability_defaults(mut pty_env: Vec<EnvMutation>) -> Vec<EnvMutation> {
1943    let already_mutated = |env: &[EnvMutation], key: &str| {
1944        env.iter()
1945            .any(|mutation| mutation.key.as_os_str() == OsStr::new(key))
1946    };
1947    if !already_mutated(&pty_env, PTY_TERM_DEFAULT_KEY) {
1948        pty_env.push(EnvMutation {
1949            key: OsString::from(PTY_TERM_DEFAULT_KEY),
1950            value: Some(OsString::from(PTY_TERM_DEFAULT_VALUE)),
1951        });
1952    }
1953    if !already_mutated(&pty_env, PTY_COLORTERM_DEFAULT_KEY) {
1954        pty_env.push(EnvMutation {
1955            key: OsString::from(PTY_COLORTERM_DEFAULT_KEY),
1956            value: Some(OsString::from(PTY_COLORTERM_DEFAULT_VALUE)),
1957        });
1958    }
1959    pty_env
1960}
1961
1962fn missing_session_message(key: NativeSessionKey) -> String {
1963    format!(
1964        "native session {:?}/{:?} does not exist",
1965        key.instance_id, key.generation
1966    )
1967}
1968
1969/// Map the spawn request's `ApprovalLevel` to the ACP host authority mode
1970/// that answers `session/request_permission` for the SAME session -- the
1971/// PTY transport lets a human at the keyboard pick their own agent's
1972/// permission flags (see `gate4agent_catalog::LaunchRequest`, which carries
1973/// no approval axis at all), but ACP is opened by this process with nobody
1974/// at a terminal, so it is the one transport where `approval_level` also has
1975/// to steer the host side of the session, not just the spawned process's own
1976/// argv (`approval_level_args`).
1977///
1978/// `FullAuto` must never land on a refusing `HostPolicy` (`ReadOnly` or
1979/// `Deny`): that combination launches the process believing it holds full
1980/// autonomy while every host permission request it makes gets silently
1981/// refused, which is worse than either policy alone -- so `FullAuto` maps to
1982/// `HostPolicy::Yolo`, the one mode that grants everything without asking.
1983/// `ReadOnly` maps to `HostPolicy::ReadOnly` for the same reason in reverse.
1984///
1985/// `Moderate` has no dedicated `HostPolicy` variant -- ACP's four host modes
1986/// are coarser than the three managed `ApprovalLevel` steps -- so it falls
1987/// back to `HostPolicy::Auto`, the same mode `Unmanaged` ("impose nothing")
1988/// uses. Both already mean "allow reads, writes, and execution, but decide
1989/// per request rather than blanket-granting" (see `HostPolicy::Auto`'s own
1990/// doc comment), which is the correct non-extreme default whether the
1991/// request asked for a deliberate middle ground or asked for nothing to be
1992/// imposed at all.
1993fn host_policy_for_approval_level(level: ApprovalLevel) -> HostPolicy {
1994    match level {
1995        ApprovalLevel::FullAuto => HostPolicy::Yolo,
1996        ApprovalLevel::ReadOnly => HostPolicy::ReadOnly,
1997        ApprovalLevel::Moderate | ApprovalLevel::Unmanaged => HostPolicy::Auto,
1998    }
1999}
2000
2001/// Whether a session at this approval level parks a `session/request_
2002/// permission` for an operator instead of answering it from policy on the
2003/// spot (`AcpSessionOptions::defer_permission_requests`).
2004///
2005/// Reads `asks_for_permission` straight off
2006/// `gate4agent_catalog::approval_level_resolution` -- the provider's own
2007/// vendor mode, declared beside its flag -- rather than matching on the
2008/// `ApprovalLevel`'s name. That distinction used to matter in practice: this
2009/// function previously matched on the level alone and assumed `ReadOnly`
2010/// never asks, but claude's `ReadOnly` launched `--permission-mode default`,
2011/// claude's own **interactive** mode, which asks about everything. A live
2012/// run under that flag produced a completed tool call and zero
2013/// `host-request-observed` because deferral was off for the one level that
2014/// was actually asking. `approval_level_resolution`'s `ReadOnly` row for
2015/// claude now launches `plan` (which genuinely never asks) instead, so the
2016/// caller (this transport's own ACP spawn branch) must already have refused
2017/// the spawn outright for any provider `approval_level_resolution` marks
2018/// `Unsupported` for this level -- see that call site -- before this
2019/// function is reached; `Unsupported` here is defensive-only and defers,
2020/// the conservative choice, rather than assuming silence.
2021///
2022/// `Unmanaged` always resolves `asks_for_permission: false` -- it decides
2023/// `session/request_permission` immediately (auto-approve) rather than
2024/// parking it, since some providers' ACP clients won't wait out a deferred
2025/// decision (kimi reports the call as refused). The managed levels'
2026/// `asks_for_permission` still varies per provider and per level -- that is
2027/// a fact about today's four providers' vendor modes, not a rule this
2028/// function encodes; a fifth provider or a vendor mode change could shift it
2029/// without this function's logic changing at all, which is the point of
2030/// reading the catalog instead of the level.
2031fn defers_permission_requests(agent_id: &AgentId, level: ApprovalLevel) -> bool {
2032    match approval_level_resolution(agent_id, level) {
2033        ApprovalLevelResolution::Supported {
2034            asks_for_permission,
2035            ..
2036        } => asks_for_permission,
2037        ApprovalLevelResolution::Unsupported => true,
2038    }
2039}
2040
2041/// How long [`apply_acp_approval_mode`] waits, after a successful
2042/// `session/set_mode` RPC ack, for the agent's own `current_mode_update`
2043/// notification to corroborate it. Bounded because the notification is
2044/// documented as optional -- `AcpSession::set_mode`'s own doc comment: "an
2045/// agent is not required to also send one after acking this call" -- so an
2046/// agent that never sends it must never stall a spawn indefinitely. The RPC
2047/// ack itself (checked before this wait starts) is the authoritative
2048/// confirmation; this wait is corroboration only, and a timeout here does
2049/// not undo it.
2050const ACP_MODE_CONFIRMATION_TIMEOUT: Duration = Duration::from_secs(5);
2051
2052/// Resolve the concrete ACP mode id [`apply_acp_approval_mode`] must confirm
2053/// and apply for `agent_id` at `level`, or the exact refusal message when no
2054/// mechanism exists to enforce it at all.
2055///
2056/// Outcomes:
2057/// - `Ok(Some(id))` — catalog named a sourced `acp_mode_id`; spawn applies it
2058///   via `session/set_mode` (claude/codex managed levels today).
2059/// - `Ok(None)` for [`ApprovalLevel::Unmanaged`] — imposes nothing.
2060/// - `Ok(None)` when the level is `Supported` with `acp_mode_id: None` **and**
2061///   non-empty vendor `args` — argv is the only lever left (grok FullAuto /
2062///   Moderate; kimi FullAuto). `acp_approval_level_args` carries those flags
2063///   into the process ahead of the ACP subcommand.
2064/// - `Err` for [`ApprovalLevelResolution::Unsupported`] (e.g. grok ReadOnly)
2065///   or a `Supported` row with neither mode id nor flags (kimi Moderate /
2066///   ReadOnly today): refuse rather than launch at the vendor default
2067///   (`mode:Mode="auto"`), which approves itself.
2068fn required_acp_mode(agent_id: &AgentId, level: ApprovalLevel) -> Result<Option<ModeId>, String> {
2069    let (acp_mode_id, carries_argv_flags) = match approval_level_resolution(agent_id, level) {
2070        ApprovalLevelResolution::Unsupported => (None, false),
2071        ApprovalLevelResolution::Supported { acp_mode_id, args, .. } => {
2072            (acp_mode_id, !args.is_empty())
2073        }
2074    };
2075    match (acp_mode_id, level) {
2076        (Some(mode_id), _) => Ok(Some(mode_id)),
2077        (None, ApprovalLevel::Unmanaged) => Ok(None),
2078        // No mode, but the level carries vendor flags: `acp_approval_level_args`
2079        // puts those in the agent's own argv, so the level IS applied and this
2080        // is not a launch at the vendor's default. The refusal below exists to
2081        // stop a session running at wider authority than asked for -- it must
2082        // not also block the one mechanism that narrows it. Measured
2083        // 2026-09-09: kimi announces no ACP modes at all, so before this every
2084        // level but `Unmanaged` refused outright and its own `--yolo` was
2085        // unreachable.
2086        (None, _) if carries_argv_flags => Ok(None),
2087        (None, _) => Err(format!(
2088            "agent '{agent_id}' has no verified ACP mode for {level:?} and the level carries no \
2089             vendor flags to apply through argv either; refusing rather than launching at the \
2090             vendor's own (wider-authority) default"
2091        )),
2092    }
2093}
2094
2095/// The vendor approval flags an ACP-spawned agent needs in its own argv.
2096///
2097/// Exactly the complement of [`required_acp_mode`]: a level that resolved an
2098/// `acp_mode_id` is applied over the wire by `session/set_mode`, so that
2099/// agent's argv stays clean and this returns nothing. A level that resolved
2100/// NO mode has no wire mechanism at all, and returning nothing there means
2101/// launching at the vendor's own default -- the exact wider-authority defect
2102/// the mode machinery exists to close, just reached from the other side.
2103///
2104/// Measured 2026-09-09: every kimi row carries `acp_mode_id: None` (kimi.exe
2105/// 0.29.0 announces no modes), so kimi ran at its own default and refused
2106/// every `g4a_*` MCP tool call at its internal approval prompt without ever
2107/// sending the host a `session/request_permission` -- unreachable by policy,
2108/// by deferral, and by `set_mode` alike. Its `FullAuto` row already carries
2109/// the flag that turns that gate off (`--yolo`); it simply never reached the
2110/// process.
2111fn acp_approval_level_args(agent_id: &AgentId, level: ApprovalLevel) -> Vec<String> {
2112    match approval_level_resolution(agent_id, level) {
2113        ApprovalLevelResolution::Supported { args, acp_mode_id: None, .. } => args,
2114        ApprovalLevelResolution::Supported { acp_mode_id: Some(_), .. } => Vec::new(),
2115        ApprovalLevelResolution::Unsupported => Vec::new(),
2116    }
2117}
2118
2119/// Named refusal for [`apply_acp_approval_mode`]: `level` resolved a real
2120/// catalog `acp_mode_id`, but this agent's own `session/new` handshake never
2121/// announced a mode with that id -- so the mechanism that would apply it
2122/// (`session/set_mode`) has nothing to call. Refusing by name, carrying
2123/// exactly what the agent DID offer, is the alternative to the defect this
2124/// whole rewrite exists to close: silently leaving the session running at
2125/// whichever mode the agent came up in on its own.
2126#[derive(Debug)]
2127struct ApprovalLevelNotOfferedByAgent {
2128    level: ApprovalLevel,
2129    offered: Vec<String>,
2130}
2131
2132impl std::fmt::Display for ApprovalLevelNotOfferedByAgent {
2133    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2134        write!(
2135            formatter,
2136            "ApprovalLevelNotOfferedByAgent {{ level: {:?}, offered: {:?} }}",
2137            self.level, self.offered
2138        )
2139    }
2140}
2141
2142/// `None` when `offered` (the agent's own `session/new` mode catalog) names
2143/// `mode_id`; `Some(message)` -- an [`ApprovalLevelNotOfferedByAgent`],
2144/// stringified -- when it does not. A free function, independent of a live
2145/// [`AcpSession`], so the refusal can be exercised directly against a
2146/// fixture mode list rather than only through a real spawn.
2147fn approval_level_not_offered_message(
2148    level: ApprovalLevel,
2149    mode_id: &ModeId,
2150    offered: &[SessionMode],
2151) -> Option<String> {
2152    if offered.iter().any(|mode| mode.id == mode_id.as_str()) {
2153        None
2154    } else {
2155        Some(
2156            ApprovalLevelNotOfferedByAgent {
2157                level,
2158                offered: offered.iter().map(|mode| mode.id.clone()).collect(),
2159            }
2160            .to_string(),
2161        )
2162    }
2163}
2164
2165/// Apply `mode_id` to a freshly handshaken ACP session (Item 2 of the
2166/// `session/set_mode` approval-level rewrite): refuse by name
2167/// (`approval_level_not_offered_message`) when the agent's own `session/new`
2168/// mode catalog never named this id, otherwise call `session/set_mode` and
2169/// wait, bounded by [`ACP_MODE_CONFIRMATION_TIMEOUT`], for the agent's own
2170/// `current_mode_update` to corroborate it -- only then is the session
2171/// considered up at the requested level.
2172///
2173/// The RPC ack from `AcpSession::set_mode` is the authoritative
2174/// confirmation (it already updates the session's own cached
2175/// `current_mode_id` synchronously on success, and an agent rejecting an
2176/// unknown mode id returns an RPC error there, not a silent no-op); the wait
2177/// on this function's own subscription is corroboration only. A provider
2178/// that never sends `current_mode_update` at all -- permitted by the ACP
2179/// spec -- times out here and the spawn still proceeds, because the RPC ack
2180/// that already succeeded is not undone by a notification that was never
2181/// guaranteed in the first place.
2182async fn apply_acp_approval_mode(
2183    session: &AcpSession,
2184    mode_id: &ModeId,
2185    level: ApprovalLevel,
2186) -> Result<(), String> {
2187    if let Some(message) =
2188        approval_level_not_offered_message(level, mode_id, &session.available_modes())
2189    {
2190        return Err(message);
2191    }
2192
2193    // Subscribed BEFORE `set_mode` is sent, so a fast `current_mode_update`
2194    // cannot race ahead of this receiver's creation and be missed.
2195    let mut confirmation = session.subscribe();
2196    session
2197        .set_mode(mode_id.as_str())
2198        .await
2199        .map_err(|error| format!("session/set_mode to '{mode_id}' failed: {error}"))?;
2200
2201    let _ = tokio::time::timeout(ACP_MODE_CONFIRMATION_TIMEOUT, async {
2202        loop {
2203            match confirmation.recv().await {
2204                Ok(AgentEvent::ModeChanged { mode_id: changed }) if changed == mode_id.as_str() => {
2205                    return;
2206                }
2207                Ok(_) => continue,
2208                Err(_) => return,
2209            }
2210        }
2211    })
2212    .await;
2213
2214    Ok(())
2215}
2216
2217fn validate_spawn_runtime_policy(
2218    policy: ProviderRuntimePolicy,
2219    transport: TransportKind,
2220    has_initial_prompt: bool,
2221    is_resume: bool,
2222) -> Result<(), String> {
2223    policy
2224        .validate()
2225        .map_err(|error| format!("provider runtime policy is invalid: {error}"))?;
2226    // ACP speaks a structured protocol over stdio, not a PTY -- none of the
2227    // capabilities below describe anything that exists for it; they all
2228    // gate inferring provider state from terminal text. The transport-
2229    // support gate for ACP already lives in the kernel
2230    // (`spec.capabilities.transports.acp.is_some()`), so this PTY-semantic
2231    // policy simply does not apply here. Mirrors `gate4agent-runtime-native`'s
2232    // `validate_effect_runtime_policy`, which enforces the same rule one
2233    // layer up.
2234    if transport != TransportKind::Acp {
2235        require_runtime_capability(policy, ProviderRuntimeCapability::RawPtyLifecycle)?;
2236        if transport != TransportKind::Pty {
2237            require_runtime_capability(policy, ProviderRuntimeCapability::SemanticReadiness)?;
2238        }
2239        if has_initial_prompt {
2240            require_runtime_capability(policy, ProviderRuntimeCapability::SemanticReadiness)?;
2241            require_runtime_capability(policy, ProviderRuntimeCapability::StructuredPrompt)?;
2242        }
2243        if is_resume && has_initial_prompt {
2244            require_runtime_capability(policy, ProviderRuntimeCapability::ProviderSessionIdentity)?;
2245            require_runtime_capability(policy, ProviderRuntimeCapability::SemanticResume)?;
2246        }
2247    }
2248    Ok(())
2249}
2250
2251
2252fn is_codex_config_c_overlay_args(arguments: &[OsString]) -> bool {
2253    if arguments.is_empty() || arguments.len() % 2 != 0 {
2254        return false;
2255    }
2256    arguments.chunks_exact(2).all(|pair| {
2257        pair[0].as_os_str() == "-c"
2258            && pair[1]
2259                .to_str()
2260                .is_some_and(|value| !value.is_empty() && value.contains('=') && !value.contains('\0'))
2261    })
2262}
2263
2264fn validate_instance_launch_arguments(
2265    agent_id: &AgentId,
2266    transport: TransportKind,
2267    arguments: &[OsString],
2268) -> Result<(), String> {
2269    if arguments.is_empty() {
2270        return Ok(());
2271    }
2272    let acp_codex_c_overlay = transport == TransportKind::Acp
2273        && agent_id.as_str() == "codex"
2274        && is_codex_config_c_overlay_args(arguments);
2275    if transport != TransportKind::Pty && !acp_codex_c_overlay {
2276        return Err(
2277            "native instance launch arguments require PTY transport (or Codex -c overlays on ACP)"
2278                .to_owned(),
2279        );
2280    }
2281    if arguments.len() > INSTANCE_LAUNCH_ARGS_MAX {
2282        return Err("native instance launch argument count exceeds its bound".to_owned());
2283    }
2284    let mut total_bytes = 0usize;
2285    for argument in arguments {
2286        let argument = argument.to_string_lossy();
2287        if argument.contains('\0') {
2288            return Err("native instance launch argument is invalid".to_owned());
2289        }
2290        if argument.len() > INSTANCE_LAUNCH_ARG_MAX_BYTES {
2291            return Err("native instance launch argument exceeds its bound".to_owned());
2292        }
2293        total_bytes = total_bytes.saturating_add(argument.len());
2294        if total_bytes > INSTANCE_LAUNCH_ARGS_TOTAL_MAX_BYTES {
2295            return Err("native instance launch argument payload exceeds its bound".to_owned());
2296        }
2297        if agent_id.as_str() == "claude"
2298            && RESERVED_CLAUDE_LAUNCH_FLAGS.iter().any(|reserved| {
2299                argument == *reserved
2300                    || (reserved.starts_with("--")
2301                        && argument
2302                            .strip_prefix(reserved)
2303                            .is_some_and(|suffix| suffix.starts_with('=')))
2304                    || (reserved.len() == 2
2305                        && argument
2306                            .strip_prefix(reserved)
2307                            .is_some_and(|suffix| {
2308                                !suffix.is_empty() && !suffix.starts_with('-')
2309                            }))
2310            })
2311        {
2312            return Err(
2313                "native instance launch arguments conflict with Claude session, resume, or prompt authority"
2314                    .to_owned(),
2315            );
2316        }
2317    }
2318    Ok(())
2319}
2320
2321/// True if `value` has the shape of a bearer credential rather than an
2322/// ordinary CLI flag, path, or session identifier.
2323///
2324/// Gate4Agent's own secrets are environment-only and never argv (see
2325/// `gate4agent/CLAUDE.md`), so this should not fire for anything this repo
2326/// itself constructs. It exists as defense-in-depth against a provider CLI
2327/// whose own argv convention accepts a credential positionally. The check is
2328/// deliberately keyword/prefix-based rather than an entropy heuristic: a
2329/// generic "long hex/base64 string" rule would also catch legitimate,
2330/// diagnostically valuable arguments such as a resume session UUID -- which
2331/// is also why [`has_prefixed_hex64_credential_shape`] requires the leading
2332/// `<prefix>_` before it will call a bare hex string a credential at all.
2333fn argument_looks_like_credential(value: &str) -> bool {
2334    const CREDENTIAL_PREFIXES: &[&str] = &["sk-", "sk_", "ghp_", "gho_", "ghs_", "xox", "bearer "];
2335    const CREDENTIAL_MARKERS: &[&str] = &[
2336        "apikey", "api_key", "api-key", "secret", "password", "passwd", "token=",
2337    ];
2338    let lower = value.to_ascii_lowercase();
2339    CREDENTIAL_PREFIXES
2340        .iter()
2341        .any(|prefix| lower.starts_with(prefix))
2342        || CREDENTIAL_MARKERS
2343            .iter()
2344            .any(|marker| lower.contains(marker))
2345        || has_prefixed_hex64_credential_shape(&lower)
2346}
2347
2348/// True if `value` is exactly a short lowercase-alphanumeric prefix (2-8
2349/// characters), an underscore, and 64 lowercase hex characters -- the shape
2350/// every gate4agent-owned or caller-owned bearer credential uses (e.g. the
2351/// hatchery operator token, `g4aho_` + 64 hex, or the H3B local-capability
2352/// token, `g4ah3_` + 64 hex), regardless of which prefix a particular caller
2353/// picks. A bare 64-hex digest with no prefix is deliberately excluded: a
2354/// commit hash, a SHA-256 checksum, or a session digest is legitimate,
2355/// diagnostically valuable argument text, not a credential.
2356fn has_prefixed_hex64_credential_shape(lower: &str) -> bool {
2357    let Some((prefix, digest)) = lower.split_once('_') else {
2358        return false;
2359    };
2360    (2..=8).contains(&prefix.len())
2361        && prefix
2362            .bytes()
2363            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit())
2364        && digest.len() == 64
2365        && digest
2366            .bytes()
2367            .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
2368}
2369
2370/// Renders one provider-CLI argument for a log line: verbatim unless it
2371/// matches [`argument_looks_like_credential`], in which case it is replaced
2372/// with a fixed placeholder rather than printed.
2373fn redact_provider_argument(value: &OsStr) -> String {
2374    let text = value.to_string_lossy();
2375    if argument_looks_like_credential(&text) {
2376        "[redacted-credential-shaped-argument]".to_owned()
2377    } else {
2378        text.into_owned()
2379    }
2380}
2381
2382/// Renders a full provider-CLI argument list for a log line, redacting any
2383/// individual argument that looks like a credential.
2384fn redact_provider_arguments(values: &[OsString]) -> Vec<String> {
2385    values.iter().map(|value| redact_provider_argument(value)).collect()
2386}
2387
2388fn require_runtime_capability(
2389    policy: ProviderRuntimePolicy,
2390    capability: ProviderRuntimeCapability,
2391) -> Result<(), String> {
2392    if policy.admits(capability) {
2393        Ok(())
2394    } else {
2395        Err(format!(
2396            "provider runtime capability {capability:?} is not admitted"
2397        ))
2398    }
2399}
2400
2401fn should_attach_pty_provider_stream(policy: ProviderRuntimePolicy) -> bool {
2402    policy.semantic_readiness || policy.provider_session_identity
2403}
2404
2405fn should_probe_pty_identity(
2406    policy: ProviderRuntimePolicy,
2407    adapter: Option<&gate4agent_types::AdapterBinding>,
2408    authoritative_identity_present: bool,
2409    expected_adapter: &str,
2410) -> bool {
2411    !authoritative_identity_present
2412        && policy.semantic_readiness
2413        && policy.structured_prompt
2414        && policy.provider_session_identity
2415        && adapter.is_some_and(|adapter| adapter.id.as_str() == expected_adapter)
2416}
2417
2418fn prepare_fresh_pty_provider_session(
2419    adapter: Option<&gate4agent_types::AdapterBinding>,
2420    is_resume: bool,
2421    identity_permitted: bool,
2422    launch_extra_args: &mut Vec<OsString>,
2423) -> Option<ProviderSessionIdentity> {
2424    let adapter = adapter?;
2425    if is_resume || !identity_permitted || adapter.id.as_str() != "claude-code" {
2426        return None;
2427    }
2428    let identity = ProviderSessionIdentity {
2429        key: ProviderSessionKey::SessionId,
2430        id: Uuid::new_v4().to_string(),
2431        transcript_path: None,
2432    };
2433    launch_extra_args.push(OsString::from("--session-id"));
2434    launch_extra_args.push(OsString::from(&identity.id));
2435    Some(identity)
2436}
2437
2438fn drain_pty_provider(
2439    key: NativeSessionKey,
2440    provider: &mut OwnedPtyProvider,
2441    observations: &mut Vec<ObservationEnvelope>,
2442) {
2443    while let Some(event) = provider.pending_events.pop_front() {
2444        push_provider_observation(key, provider, event, observations);
2445    }
2446
2447    loop {
2448        let envelope = match provider.replay.pop_front() {
2449            Some(envelope) => Some(envelope),
2450            None => provider.receiver.try_recv().unwrap_or_default(),
2451        };
2452        let Some(envelope) = envelope else {
2453            break;
2454        };
2455        match envelope.event {
2456            PtyEvent::Output(data) => {
2457                let raw = provider.utf8.push(&data);
2458                if raw.is_empty() {
2459                    continue;
2460                }
2461                if provider.semantic_events {
2462                    if let Some(info) = provider.rate_limits.detect(&raw) {
2463                        push_provider_observation(
2464                            key,
2465                            provider,
2466                            rate_limit_event(info),
2467                            observations,
2468                        );
2469                    }
2470                }
2471                let identity = provider
2472                    .kimi_identity
2473                    .as_mut()
2474                    .and_then(|extractor| extractor.push(&raw));
2475                if let Some(identity) = identity {
2476                    if !provider.provider_session_started {
2477                        push_provider_observation(
2478                            key,
2479                            provider,
2480                            ProviderEvent::SessionStarted {
2481                                session_id: identity.id.clone(),
2482                                model: String::new(),
2483                                tools: Vec::new(),
2484                            },
2485                            observations,
2486                        );
2487                        provider.provider_session_started = true;
2488                    }
2489                    push_provider_observation(
2490                        key,
2491                        provider,
2492                        ProviderEvent::SessionIdentityObserved { identity },
2493                        observations,
2494                    );
2495                }
2496                if provider.semantic_events {
2497                    let messages = provider
2498                        .pipeline
2499                        .get_mut()
2500                        .unwrap_or_else(|poisoned| poisoned.into_inner())
2501                        .process(&raw);
2502                    for message in messages {
2503                        if let Some(event) = parsed_provider_event(message) {
2504                            push_provider_observation(key, provider, event, observations);
2505                        }
2506                    }
2507                }
2508            }
2509            PtyEvent::DataGap {
2510                from_sequence,
2511                to_sequence,
2512                ..
2513            } => {
2514                provider.utf8.clear();
2515                if let Some(extractor) = &mut provider.kimi_identity {
2516                    extractor.reset_stream();
2517                }
2518                provider
2519                    .pipeline
2520                    .get_mut()
2521                    .unwrap_or_else(|poisoned| poisoned.into_inner())
2522                    .clear();
2523                let missed = to_sequence
2524                    .checked_sub(from_sequence)
2525                    .and_then(|difference| difference.checked_add(1));
2526                if let Some((source_sequence, missed)) = missed.and_then(|missed| {
2527                    reserve_provider_gap_sequence(&mut provider.next_provider_sequence, missed)
2528                        .map(|source_sequence| (source_sequence, missed))
2529                }) {
2530                    observations.push(ObservationEnvelope {
2531                        operation_id: None,
2532                        instance_id: key.instance_id,
2533                        generation: key.generation,
2534                        observation: ControlObservation::ProviderGap {
2535                            source: provider.source.clone(),
2536                            source_sequence,
2537                            missed,
2538                        },
2539                    });
2540                }
2541            }
2542            PtyEvent::ReaderError { message } | PtyEvent::OperatorActionRequired { message } => {
2543                push_provider_observation(
2544                    key,
2545                    provider,
2546                    ProviderEvent::Error { message },
2547                    observations,
2548                );
2549            }
2550            PtyEvent::Started
2551            | PtyEvent::Resized(_)
2552            | PtyEvent::ForegroundProcess(_)
2553            | PtyEvent::SnapshotAvailable { .. }
2554            | PtyEvent::Exited { .. } => {}
2555        }
2556    }
2557}
2558
2559fn push_provider_observation(
2560    key: NativeSessionKey,
2561    provider: &mut OwnedPtyProvider,
2562    event: ProviderEvent,
2563    observations: &mut Vec<ObservationEnvelope>,
2564) {
2565    let sequence = provider.next_provider_sequence;
2566    provider.next_provider_sequence = provider.next_provider_sequence.saturating_add(1);
2567    observations.push(ObservationEnvelope {
2568        operation_id: None,
2569        instance_id: key.instance_id,
2570        generation: key.generation,
2571        observation: ControlObservation::ProviderEvent {
2572            source: provider.source.clone(),
2573            sequence,
2574            event,
2575        },
2576    });
2577}
2578
2579fn reserve_provider_gap_sequence(next_sequence: &mut u64, missed: u64) -> Option<u64> {
2580    if missed == 0 {
2581        return None;
2582    }
2583    let Some(source_sequence) = missed
2584        .checked_sub(1)
2585        .and_then(|offset| next_sequence.checked_add(offset))
2586    else {
2587        *next_sequence = u64::MAX;
2588        return None;
2589    };
2590    let Some(next) = source_sequence.checked_add(1) else {
2591        *next_sequence = u64::MAX;
2592        return None;
2593    };
2594    *next_sequence = next;
2595    Some(source_sequence)
2596}
2597
2598fn parsed_provider_event(message: ParsedMessage) -> Option<ProviderEvent> {
2599    match message.class {
2600        MessageClass::AiResponse => Some(ProviderEvent::Text {
2601            text: message.content,
2602            is_delta: message.metadata.is_partial,
2603        }),
2604        MessageClass::ThinkingIndicator => Some(ProviderEvent::Thinking {
2605            text: message.content,
2606        }),
2607        MessageClass::Error => Some(ProviderEvent::Error {
2608            message: message.content,
2609        }),
2610        MessageClass::PromptReady => Some(ProviderEvent::Ready),
2611        MessageClass::ToolApproval => Some(ProviderEvent::InteractionRequested {
2612            request_id: None,
2613            interaction_kind: ProviderInteractionKind::Approval,
2614            tool_name: message
2615                .metadata
2616                .tool_name
2617                .unwrap_or_else(|| "unknown".to_owned()),
2618            // A PTY screen has no structured title or option list to read
2619            // off it the way ACP's `session/request_permission` does --
2620            // this source has none, not a dropped one.
2621            title: None,
2622            prompt: message.content,
2623            options: Vec::new(),
2624            agent_id: None,
2625        }),
2626        MessageClass::InfoMessage
2627        | MessageClass::UiElement
2628        | MessageClass::UserEcho
2629        | MessageClass::Menu
2630        | MessageClass::Raw => None,
2631    }
2632}
2633
2634fn rate_limit_event(info: gate4agent::core::types::RateLimitInfo) -> ProviderEvent {
2635    ProviderEvent::RateLimited {
2636        limit_type: provider_rate_limit_kind(info.limit_type),
2637        resets_at: info.resets_at.map(|value| value.to_rfc3339()),
2638        usage_percent: info.usage_percent.map(|value| value.to_string()),
2639        raw_message: info.raw_message,
2640    }
2641}
2642
2643/// Map `gate4agent`'s `HostRequestDecision`/`HostDecisionAuthority` onto
2644/// this crate's wire-typed `ProviderHostRequestDecision`/
2645/// `ProviderHostDecisionAuthority` -- `gate4agent-types` cannot depend on
2646/// `gate4agent` (see that crate's own `CLAUDE.md`), so the conversion lives
2647/// here, the shell that already depends on both. A plain match, not
2648/// `format!("{:?}", ..)`: the wire carries the typed value itself.
2649fn provider_host_request_decision(decision: HostRequestDecision) -> ProviderHostRequestDecision {
2650    match decision {
2651        HostRequestDecision::Granted { by } => {
2652            ProviderHostRequestDecision::Granted { by: provider_host_decision_authority(by) }
2653        }
2654        HostRequestDecision::Denied { by } => {
2655            ProviderHostRequestDecision::Denied { by: provider_host_decision_authority(by) }
2656        }
2657        HostRequestDecision::Deferred => ProviderHostRequestDecision::Deferred,
2658    }
2659}
2660
2661fn provider_host_decision_authority(by: HostDecisionAuthority) -> ProviderHostDecisionAuthority {
2662    match by {
2663        HostDecisionAuthority::Gate => ProviderHostDecisionAuthority::Gate,
2664        HostDecisionAuthority::Policy => ProviderHostDecisionAuthority::Policy,
2665        HostDecisionAuthority::Operator => ProviderHostDecisionAuthority::Operator,
2666        HostDecisionAuthority::DeadlinePolicy => ProviderHostDecisionAuthority::DeadlinePolicy,
2667    }
2668}
2669
2670/// Map `gate4agent`'s `HostRequestOutcome` onto this crate's wire-typed
2671/// `ProviderHostRequestOutcome` -- same reason `provider_host_request_
2672/// decision` exists rather than a `From` impl on either side. Carried
2673/// through verbatim, the same convention `reason` already follows on this
2674/// mapping: bounding happens at `ProviderEvent::validate_ingress`
2675/// (`PROVIDER_EVENT_TEXT_MAX_BYTES`, rejecting rather than truncating), not
2676/// here.
2677fn provider_host_request_outcome(outcome: HostRequestOutcome) -> ProviderHostRequestOutcome {
2678    match outcome {
2679        HostRequestOutcome::Executed => ProviderHostRequestOutcome::Executed,
2680        HostRequestOutcome::Failed { error } => ProviderHostRequestOutcome::Failed { error },
2681    }
2682}
2683
2684/// Map the detector's own `RateLimitType` onto the wire-typed
2685/// `ProviderRateLimitKind`. A plain match, not `format!("{:?}", ..)`: the
2686/// wire carries the typed value itself, not a Debug-rendering of it.
2687fn provider_rate_limit_kind(
2688    limit_type: gate4agent::core::types::RateLimitType,
2689) -> ProviderRateLimitKind {
2690    use gate4agent::core::types::RateLimitType;
2691    match limit_type {
2692        RateLimitType::Session => ProviderRateLimitKind::Session,
2693        RateLimitType::Daily => ProviderRateLimitKind::Daily,
2694        RateLimitType::Weekly => ProviderRateLimitKind::Weekly,
2695        RateLimitType::Unknown => ProviderRateLimitKind::Unknown,
2696    }
2697}
2698
2699/// `mode_catalogue` reads the mode catalogue off `owned.session` for
2700/// transports that have one -- only `AcpSession::available_modes()` does
2701/// (see `provider_event`'s doc comment); `collect_provider_map`'s two
2702/// non-ACP call sites pass `|_| Vec::new()` since their `AgentEvent` stream
2703/// never carries `ModeChanged` in the first place.
2704fn collect_provider_map<S>(
2705    sessions: &mut BTreeMap<NativeSessionKey, OwnedProviderSession<S>>,
2706    observations: &mut Vec<ObservationEnvelope>,
2707    mode_catalogue: impl Fn(&S) -> Vec<SessionMode>,
2708) {
2709    for (key, owned) in sessions {
2710        let available_modes = mode_catalogue(&owned.session);
2711        drain_provider_stream(
2712            *key,
2713            &owned.source,
2714            &mut owned.events,
2715            &mut owned.pending_events,
2716            &mut owned.pending_provider_events,
2717            &mut owned.next_provider_sequence,
2718            Some(&mut owned.observed_exit_code),
2719            observations,
2720            &available_modes,
2721        );
2722    }
2723}
2724
2725/// Pushes one already-sequenced `ProviderEvent` onto `observations`, sharing
2726/// the same per-session `next_provider_sequence` counter regardless of which
2727/// of `drain_provider_stream`'s three sources (`pending_events` mapped
2728/// through `provider_event`, raw `pending_provider_events`, or the live
2729/// broadcast stream) produced it -- so sequence numbers stay contiguous
2730/// across all three.
2731fn push_sequenced_provider_event(
2732    key: NativeSessionKey,
2733    source: &ProviderSource,
2734    next_provider_sequence: &mut u64,
2735    observations: &mut Vec<ObservationEnvelope>,
2736    event: ProviderEvent,
2737) {
2738    let sequence = *next_provider_sequence;
2739    *next_provider_sequence = next_provider_sequence.saturating_add(1);
2740    observations.push(ObservationEnvelope {
2741        operation_id: None,
2742        instance_id: key.instance_id,
2743        generation: key.generation,
2744        observation: ControlObservation::ProviderEvent {
2745            source: source.clone(),
2746            sequence,
2747            event,
2748        },
2749    });
2750}
2751
2752fn drain_provider_stream(
2753    key: NativeSessionKey,
2754    source: &ProviderSource,
2755    events: &mut broadcast::Receiver<AgentEvent>,
2756    pending_events: &mut VecDeque<AgentEvent>,
2757    pending_provider_events: &mut VecDeque<ProviderEvent>,
2758    next_provider_sequence: &mut u64,
2759    mut observed_exit_code: Option<&mut Option<i32>>,
2760    observations: &mut Vec<ObservationEnvelope>,
2761    available_modes: &[SessionMode],
2762) {
2763    loop {
2764        // `pending_events` (seeded `AgentEvent`s, e.g. `SessionStart`) drains
2765        // first and fully, in order; only once it is empty does
2766        // `pending_provider_events` (seeded raw `ProviderEvent`s, e.g.
2767        // `SessionIdentityObserved`, with no `AgentEvent` counterpart to
2768        // carry them) get a turn, and only once THAT is empty does this fall
2769        // through to the live broadcast stream -- so anything seeded at the
2770        // spawn site always reaches `observations` ahead of anything the
2771        // provider itself emits afterwards, and in the exact relative order
2772        // the spawn site seeded it in.
2773        if let Some(event) = pending_events.pop_front() {
2774            match event {
2775                AgentEvent::Exited { code } => {
2776                    if let Some(exit_code) = observed_exit_code.as_deref_mut() {
2777                        *exit_code = Some(code);
2778                    }
2779                }
2780                event => {
2781                    if let Some(event) = provider_event(event, available_modes) {
2782                        push_sequenced_provider_event(
2783                            key,
2784                            source,
2785                            next_provider_sequence,
2786                            observations,
2787                            event,
2788                        );
2789                    }
2790                }
2791            }
2792            continue;
2793        }
2794        if let Some(event) = pending_provider_events.pop_front() {
2795            push_sequenced_provider_event(key, source, next_provider_sequence, observations, event);
2796            continue;
2797        }
2798        match events.try_recv() {
2799            Ok(AgentEvent::Exited { code }) => {
2800                if let Some(exit_code) = observed_exit_code.as_deref_mut() {
2801                    *exit_code = Some(code);
2802                }
2803            }
2804            Ok(event) => {
2805                if let Some(event) = provider_event(event, available_modes) {
2806                    push_sequenced_provider_event(
2807                        key,
2808                        source,
2809                        next_provider_sequence,
2810                        observations,
2811                        event,
2812                    );
2813                }
2814            }
2815            Err(broadcast::error::TryRecvError::Lagged(missed)) => {
2816                if let Some(source_sequence) =
2817                    reserve_provider_gap_sequence(next_provider_sequence, missed)
2818                {
2819                    observations.push(ObservationEnvelope {
2820                        operation_id: None,
2821                        instance_id: key.instance_id,
2822                        generation: key.generation,
2823                        observation: ControlObservation::ProviderGap {
2824                            source: source.clone(),
2825                            source_sequence,
2826                            missed,
2827                        },
2828                    });
2829                }
2830            }
2831            Err(broadcast::error::TryRecvError::Empty | broadcast::error::TryRecvError::Closed) => {
2832                break
2833            }
2834        }
2835    }
2836}
2837
2838fn collect_provider_exits<S>(
2839    sessions: &mut BTreeMap<NativeSessionKey, OwnedProviderSession<S>>,
2840    observations: &mut Vec<ObservationEnvelope>,
2841    finished: impl Fn(&S) -> bool,
2842) {
2843    let completed: Vec<_> = sessions
2844        .iter()
2845        .filter_map(|(key, owned)| {
2846            (owned.observed_exit_code.is_some() || finished(&owned.session)).then_some(*key)
2847        })
2848        .collect();
2849    for key in completed {
2850        let owned = sessions
2851            .remove(&key)
2852            .expect("completed provider key came from the owned session map");
2853        observations.push(ObservationEnvelope {
2854            operation_id: None,
2855            instance_id: key.instance_id,
2856            generation: key.generation,
2857            observation: ControlObservation::ProcessExited {
2858                exit_code: owned.observed_exit_code,
2859                final_terminal: None,
2860            },
2861        });
2862    }
2863}
2864
2865/// Prefix tags [`encode_rpc_request_id`] / [`decode_rpc_request_id`] use to
2866/// keep an `RpcId::Number` and an `RpcId::String` from colliding once both
2867/// are flattened into the bare `Option<String>` that
2868/// `ProviderEvent::InteractionRequested::request_id` and
2869/// `ProviderInteractionTarget::provider_request_id` carry.
2870const RPC_REQUEST_ID_NUMBER_PREFIX: &str = "number:";
2871const RPC_REQUEST_ID_STRING_PREFIX: &str = "string:";
2872
2873/// Encode a JSON-RPC id as the `request_id` string carried on
2874/// `ProviderEvent::InteractionRequested` -- minted once, in `provider_event`'s
2875/// `AgentEvent::RpcIncomingRequest` arm, the moment a `session/request_
2876/// permission` call is first reported `HostRequestDecision::Deferred`. Read
2877/// back by [`decode_rpc_request_id`] when a later `ResolveInteraction`
2878/// effect needs to find this exact pending request again in
2879/// `AcpSession::resolve_pending_request` (`gate4agent::acp::session`).
2880///
2881/// Tagged rather than a bare `to_string()`: an agent-issued
2882/// `RpcId::Number(42)` and `RpcId::String("42".into())` are different ids on
2883/// the wire and must not collide into the same encoded string.
2884fn encode_rpc_request_id(id: &RpcId) -> String {
2885    match id {
2886        RpcId::Number(number) => format!("{RPC_REQUEST_ID_NUMBER_PREFIX}{number}"),
2887        RpcId::String(value) => format!("{RPC_REQUEST_ID_STRING_PREFIX}{value}"),
2888    }
2889}
2890
2891/// Inverse of [`encode_rpc_request_id`]. `None` covers both an unrecognized
2892/// tag and a `number:` tag whose remainder does not parse as a `u64` --
2893/// either way, not an id this process ever minted, so there is nothing to
2894/// look up in `AcpSession`'s pending-request map.
2895fn decode_rpc_request_id(raw: &str) -> Option<RpcId> {
2896    if let Some(number) = raw.strip_prefix(RPC_REQUEST_ID_NUMBER_PREFIX) {
2897        return number.parse::<u64>().ok().map(RpcId::Number);
2898    }
2899    raw.strip_prefix(RPC_REQUEST_ID_STRING_PREFIX)
2900        .map(|value| RpcId::String(value.to_owned()))
2901}
2902
2903/// Resolve a `ControlEffect::ResolveInteraction` naming exactly why it
2904/// could not be routed, when it could not be -- see
2905/// `docs/gate4agent/plans/gate4agent-acp-control-plane-on-the-wire-2026-09-02.md`
2906/// §4a-§5, where this is called "the deferral organ, not the plumbing" that
2907/// was still missing.
2908///
2909/// `session` is `None` for every transport this crate owns other than a
2910/// live ACP session for `key` -- PTY and pipe sessions never defer a
2911/// `session/request_permission` call in the first place (only
2912/// `AcpSessionOptions::defer_permission_requests` does, and that field only
2913/// exists on `AcpSessionOptions`), so there is never anything for them to
2914/// resolve here.
2915fn resolve_acp_interaction_observation(
2916    session: Option<&AcpSession>,
2917    target: ProviderInteractionTarget,
2918    response: ProviderInteractionResponse,
2919) -> ControlObservation {
2920    let interaction_id = target.interaction_id;
2921    let fail = |message: String| ControlObservation::InteractionResolutionFailed {
2922        interaction_id,
2923        message,
2924    };
2925    let Some(session) = session else {
2926        return fail("semantic interaction resolution requires an ACP session".to_owned());
2927    };
2928    let Some(provider_request_id) = target.provider_request_id.as_deref() else {
2929        return fail("ACP interaction resolution requires a provider request id".to_owned());
2930    };
2931    let Some(id) = decode_rpc_request_id(provider_request_id) else {
2932        return fail(format!(
2933            "ACP interaction resolution request id {provider_request_id:?} is not a valid JSON-RPC id"
2934        ));
2935    };
2936    match resolve_acp_permission_interaction(session, &id, response) {
2937        Ok(()) => ControlObservation::InteractionResolutionCompleted { interaction_id },
2938        Err(message) => fail(message),
2939    }
2940}
2941
2942/// Route an operator's `ProviderInteractionResponse` for a deferred ACP
2943/// `session/request_permission` call into
2944/// `AcpSession::resolve_pending_request_as` (`gate4agent::acp::session`).
2945///
2946/// The operator states an intent, not an option id, and that is deliberate:
2947/// an ACP agent offers whatever subset of the four `PermissionOptionKind`
2948/// values it likes, and an `optionId` it never offered is ignored rather
2949/// than refused -- so a fabricated one would grant nothing while reporting
2950/// success. Selecting a REAL offered option is therefore
2951/// `AcpSession`'s job, since it is the side that holds the pending request's
2952/// own params; it walks the same preference order `HostPolicy` walks,
2953/// through one shared selector, so an approval means the same thing whether
2954/// policy or a human produced it. If the agent offered nothing in the
2955/// requested direction the answer is `Cancelled`, which every ACP agent
2956/// accepts as complete.
2957///
2958/// This is also why the wire verb carries a `ProviderInteractionResponse` --
2959/// the same approve/deny vocabulary a PTY interaction uses -- rather than an
2960/// ACP-specific option id: all three chains say the same thing to an
2961/// operator, and only the ACP path has to know what an option id is.
2962///
2963/// `Answer` never applies here: ACP `session/request_permission` only ever
2964/// offers allow/deny options, never a free-text one.
2965fn resolve_acp_permission_interaction(
2966    session: &AcpSession,
2967    id: &RpcId,
2968    response: ProviderInteractionResponse,
2969) -> Result<(), String> {
2970    let choice = match response {
2971        ProviderInteractionResponse::ApproveOnce => OperatorPermissionChoice::Approve,
2972        ProviderInteractionResponse::Deny => OperatorPermissionChoice::Reject,
2973        ProviderInteractionResponse::Answer { .. } => {
2974            return Err("ACP permission interactions do not accept free-text answers".to_owned())
2975        }
2976    };
2977    session.resolve_pending_request_as(id, choice).map_err(|error| error.to_string())
2978}
2979
2980/// Resolve a `ControlEffect::SetSessionMode` against a live ACP session's
2981/// own advertised mode catalogue, naming exactly why it could not be
2982/// routed when it could not be -- same refusal-by-name discipline as
2983/// [`resolve_acp_interaction_observation`].
2984///
2985/// There is no `supports_*` probe for session modes (unlike
2986/// `AcpSession::supports_session_list` and its siblings): the ACP
2987/// `sessionCapabilities` flags block carries no `modes` key at all, so the
2988/// mode catalogue seeded at handshake (`AcpSession::available_modes`) IS
2989/// the capability signal -- empty means the agent never advertised any
2990/// modes, non-empty-but-missing-this-id means it advertised modes but not
2991/// this one.
2992async fn set_acp_session_mode_observation(
2993    session: Option<&AcpSession>,
2994    mode_id: String,
2995) -> ControlObservation {
2996    let Some(session) = session else {
2997        return ControlObservation::SessionModeSetFailed {
2998            message: "session mode switch requires an ACP session".to_owned(),
2999        };
3000    };
3001    let catalogue = session.available_modes();
3002    if catalogue.is_empty() {
3003        return ControlObservation::SessionModeSetFailed {
3004            message: "the provider announces no session-mode capability".to_owned(),
3005        };
3006    }
3007    if !catalogue.iter().any(|mode| mode.id == mode_id) {
3008        return ControlObservation::SessionModeSetFailed {
3009            message: format!("the provider never offered mode {mode_id:?}"),
3010        };
3011    }
3012    match session.set_mode(&mode_id).await {
3013        Ok(()) => ControlObservation::SessionModeSet { mode_id },
3014        Err(error) => ControlObservation::SessionModeSetFailed {
3015            message: error.to_string(),
3016        },
3017    }
3018}
3019
3020/// Resolve a `ControlEffect::SetSessionConfigOption` against a live ACP
3021/// session's own advertised config-option catalogue -- same capability
3022/// reasoning as [`set_acp_session_mode_observation`] (no dedicated
3023/// `supports_*` probe; the catalogue seeded at handshake, `AcpSession::
3024/// config_options`, is the capability signal).
3025///
3026/// `value_json` arrives already validated as parseable JSON at the wire
3027/// boundary (`gate4agent-node-protocol`'s `deserialize_acp_config_value_
3028/// json`) and bounds-checked again at the engine
3029/// (`gate4agent_types::validate_session_config_value_json`), but is only
3030/// ever carried as text up to here -- `gate4agent-types` does not depend on
3031/// `serde_json`. Parsing it into the `serde_json::Value` `AcpSession::
3032/// set_config_option` actually takes is this function's job; the target
3033/// type is inferred from that call rather than named directly, so this
3034/// crate never needs its own `serde_json` dependency.
3035async fn set_acp_session_config_option_observation(
3036    session: Option<&AcpSession>,
3037    option_id: String,
3038    value_json: String,
3039) -> ControlObservation {
3040    let Some(session) = session else {
3041        return ControlObservation::SessionConfigOptionSetFailed {
3042            message: "session config-option switch requires an ACP session".to_owned(),
3043        };
3044    };
3045    let catalogue = session.config_options();
3046    if catalogue.is_empty() {
3047        return ControlObservation::SessionConfigOptionSetFailed {
3048            message: "the provider announces no session config-option capability".to_owned(),
3049        };
3050    }
3051    if !catalogue.iter().any(|option| option.id == option_id) {
3052        return ControlObservation::SessionConfigOptionSetFailed {
3053            message: format!("the provider never offered config option {option_id:?}"),
3054        };
3055    }
3056    let value = match value_json.parse() {
3057        Ok(value) => value,
3058        Err(error) => {
3059            return ControlObservation::SessionConfigOptionSetFailed {
3060                message: format!("session config option value is not valid JSON: {error}"),
3061            }
3062        }
3063    };
3064    match session.set_config_option(&option_id, value).await {
3065        Ok(()) => ControlObservation::SessionConfigOptionSet { option_id },
3066        Err(error) => ControlObservation::SessionConfigOptionSetFailed {
3067            message: error.to_string(),
3068        },
3069    }
3070}
3071
3072/// Resolve a `ControlEffect::SetSessionModel`. Unlike session modes and
3073/// config options, `AcpSession` exposes no setter at all for this --
3074/// `available_models`/`current_model_id` observe Grok's vendor `_x.ai/
3075/// models/update` catalogue, but no live capture has ever shown a
3076/// host-invocable RPC to request a switch (ACP proper has no `session/
3077/// set_model`, and no vendor extension for one has been verified). Refuses
3078/// by name rather than fabricate a call the crate documented it would
3079/// never invent -- see `ControlCommand::SetSessionModel`'s doc comment.
3080fn set_acp_session_model_observation(
3081    session: Option<&AcpSession>,
3082    model_id: String,
3083) -> ControlObservation {
3084    if session.is_none() {
3085        return ControlObservation::SessionModelSetFailed {
3086            message: "session model switch requires an ACP session".to_owned(),
3087        };
3088    }
3089    ControlObservation::SessionModelSetFailed {
3090        message: format!(
3091            "this provider offers no host-invocable model-switch method (requested model {model_id:?})"
3092        ),
3093    }
3094}
3095
3096/// Maps one `AgentEvent` onto the wire-typed `ProviderEvent`. `available_
3097/// modes` is used ONLY by the `AgentEvent::ModeChanged` arm -- it carries
3098/// the mode catalogue `AcpSession::available_modes()` read at handshake
3099/// (`session/new`), so a mode change reports the SAME catalogue the agent
3100/// actually offered, not just the id that changed. Every other arm ignores
3101/// it; passing `&[]` from a non-ACP transport (which never emits
3102/// `ModeChanged` in the first place -- see `update_to_event`'s
3103/// `CurrentModeUpdate` arm, `gate4agent`'s `src/acp/protocol.rs`) changes
3104/// nothing observable.
3105/// Maps `gate4agent`'s own `StopReason` onto the wire-typed
3106/// `ProviderStopReason` mirror -- see that type's own doc comment for why
3107/// this crate keeps a second copy rather than sharing one.
3108fn map_provider_stop_reason(reason: StopReason) -> ProviderStopReason {
3109    match reason {
3110        StopReason::EndTurn => ProviderStopReason::EndTurn,
3111        StopReason::MaxTokens => ProviderStopReason::MaxTokens,
3112        StopReason::MaxTurnRequests => ProviderStopReason::MaxTurnRequests,
3113        StopReason::Refusal => ProviderStopReason::Refusal,
3114        StopReason::Cancelled => ProviderStopReason::Cancelled,
3115        StopReason::Other(value) => ProviderStopReason::Other { value },
3116        StopReason::ProviderError { code, message, vendor_code } => {
3117            ProviderStopReason::ProviderError { code, message, vendor_code }
3118        }
3119    }
3120}
3121
3122fn provider_event(event: AgentEvent, available_modes: &[SessionMode]) -> Option<ProviderEvent> {
3123    match event {
3124        AgentEvent::SessionStart {
3125            session_id,
3126            model,
3127            tools,
3128        } => Some(ProviderEvent::SessionStarted {
3129            session_id,
3130            model,
3131            tools,
3132        }),
3133        AgentEvent::Text { text, is_delta } => Some(ProviderEvent::Text { text, is_delta }),
3134        AgentEvent::Thinking { text } => Some(ProviderEvent::Thinking { text }),
3135        AgentEvent::ToolStart { id, name, input } => Some(ProviderEvent::ToolStarted {
3136            id,
3137            name,
3138            input_json: input.to_string(),
3139            agent_id: None,
3140        }),
3141        AgentEvent::ToolResult {
3142            id,
3143            output,
3144            is_error,
3145            duration_ms,
3146            non_execution_kind,
3147        } => Some(ProviderEvent::ToolCompleted {
3148            id,
3149            output,
3150            is_error,
3151            duration_ms,
3152            agent_id: None,
3153            non_execution_kind,
3154        }),
3155        AgentEvent::TurnComplete {
3156            input_tokens,
3157            output_tokens,
3158            cache_read_tokens,
3159            cache_write_tokens,
3160            reasoning_tokens,
3161            context_window,
3162            is_cumulative,
3163        } => Some(ProviderEvent::TurnCompleted {
3164            usage: TokenUsage {
3165                input_tokens,
3166                output_tokens,
3167                cache_read_tokens,
3168                cache_write_tokens,
3169                reasoning_tokens,
3170                context_window,
3171            },
3172            is_cumulative,
3173        }),
3174        AgentEvent::ContextWindowUsage { usage } => {
3175            Some(ProviderEvent::ContextWindowUsage {
3176                usage: ProviderContextWindowUsage {
3177                    uncached_input_tokens: usage.uncached_input_tokens,
3178                    cache_read_tokens: usage.cache_read_tokens,
3179                    cache_write_tokens: usage.cache_write_tokens,
3180                    output_tokens: usage.output_tokens,
3181                    unattributed_tokens: usage.unattributed_tokens,
3182                    used_tokens: usage.used_tokens,
3183                    capacity_tokens: usage.capacity_tokens,
3184                },
3185            })
3186        }
3187        AgentEvent::SessionEnd {
3188            result,
3189            cost_usd,
3190            is_error,
3191            stop_reason,
3192        } => Some(ProviderEvent::SessionEnded {
3193            result,
3194            cost_usd: cost_usd.map(|cost| cost.to_string()),
3195            is_error,
3196            stop_reason: stop_reason.map(map_provider_stop_reason),
3197        }),
3198        AgentEvent::Error { message } => Some(ProviderEvent::Error { message }),
3199        // ACP-only: `acp::session::AcpSession::start_prompt` synthesizes
3200        // this locally when a `session/prompt` call ends without a success
3201        // response (an agent RPC error, the session closing mid-call, or
3202        // `prompt_timeout` elapsing) -- see that event's own doc comment.
3203        // `ProviderEvent::TurnInterrupted` carries no `reason` field (it
3204        // predates this source and is also used by a PTY-side interrupt
3205        // with no text of its own to attach), so the reason is logged here
3206        // rather than silently dropped -- this is the one place downstream
3207        // of the ACP session that ever sees it.
3208        AgentEvent::TurnInterrupted { reason } => {
3209            tracing::warn!(reason = %reason, "acp turn interrupted");
3210            Some(ProviderEvent::TurnInterrupted)
3211        }
3212        AgentEvent::PtyParsed(message) => parsed_provider_event(message),
3213        AgentEvent::PtyReady => Some(ProviderEvent::Ready),
3214        AgentEvent::PtyToolApproval {
3215            tool_name,
3216            description,
3217        } => Some(ProviderEvent::InteractionRequested {
3218            request_id: None,
3219            interaction_kind: ProviderInteractionKind::Approval,
3220            tool_name,
3221            // A PTY screen has no structured title or option list to read
3222            // off it the way ACP's `session/request_permission` does --
3223            // this source has none, not a dropped one.
3224            title: None,
3225            prompt: description.unwrap_or_default(),
3226            options: Vec::new(),
3227            agent_id: None,
3228        }),
3229        AgentEvent::RateLimit(info) => Some(rate_limit_event(info)),
3230        // A `session/request_permission` call the host left `Deferred` (see
3231        // `AcpSessionOptions::defer_permission_requests`) is the one host
3232        // request an operator can actually act on -- so, ONLY for that
3233        // first, deferred sighting of a given id, this becomes an
3234        // `InteractionRequested` carrying the id (`encode_rpc_request_id`)
3235        // instead of the audit-only `HostRequestObserved` every other host
3236        // request becomes. The SAME id's eventual `Granted`/`Denied`
3237        // (`HostRequestDecision`'s doc comment on `Deferred`) still falls
3238        // through to `HostRequestObserved` below, exactly as before -- see
3239        // `docs/gate4agent/plans/gate4agent-acp-control-plane-on-the-wire-2026-09-02.md`
3240        // §4a.2, which is what this replaces (`id: _`, the id silently
3241        // dropped, so an ACP permission request never became an
3242        // `InteractionRequested` at all).
3243        AgentEvent::RpcIncomingRequest {
3244            id,
3245            method,
3246            params,
3247            decision,
3248            // A `Deferred` sighting never carries a reason -- nothing has
3249            // decided this request yet (see `AgentEvent::
3250            // RpcIncomingRequest`'s own doc comment).
3251            reason: _,
3252            // Nothing has run yet either -- always `Executed` for a
3253            // `Deferred` sighting (see `HostRequestOutcome`'s own doc
3254            // comment).
3255            outcome: _,
3256        } if method == "session/request_permission"
3257            && matches!(decision, HostRequestDecision::Deferred) =>
3258        {
3259            let tool_call = params.as_ref().and_then(|params| params.get("toolCall"));
3260            let field = |name: &str| {
3261                tool_call
3262                    .and_then(|tool_call| tool_call.get(name))
3263                    .and_then(|value| value.as_str())
3264                    .filter(|value| !value.is_empty())
3265                    .map(str::to_owned)
3266            };
3267            // `kind` is the tool's CLASS (`execute`, `edit`, `read`, ...) and
3268            // `title` is the human sentence describing this particular call.
3269            // They are two different things and the operator needs both: the
3270            // class is what the node hashes into `tool_class` for the
3271            // observation, and the title is the actual question. Putting the
3272            // title in `tool_name` and leaving `prompt` empty -- which is
3273            // what this did -- showed the operator a correlation id and a
3274            // sentence-shaped tool name, and never the question itself.
3275            let title = field("title");
3276            // The agent's own offered options -- read straight off the top-
3277            // level `options` array `PermissionRequestParams::options`
3278            // names in `src/acp/protocol.rs` (`option_id`/`name`/`kind`,
3279            // each `PermissionOption` field's own verified wire name:
3280            // `optionId`/`name`/`kind`), not `toolCall` -- `options` is a
3281            // sibling of `toolCall` on the request, not nested under it.
3282            // `kind` is carried verbatim as the ACP wire's own snake_case
3283            // string (`allow_once`, `reject_always`, ...); nothing here
3284            // reinterprets it. Without this the operator is choosing
3285            // approve/deny from a list they cannot see -- survivable for
3286            // two options, a guess for three or more.
3287            let options: Vec<ProviderInteractionOption> = params
3288                .as_ref()
3289                .and_then(|params| params.get("options"))
3290                .and_then(|options| options.as_array())
3291                .map(|options| {
3292                    options
3293                        .iter()
3294                        .filter_map(|option| {
3295                            let option_id = option.get("optionId")?.as_str()?.to_owned();
3296                            let name = option.get("name")?.as_str()?.to_owned();
3297                            let kind = option.get("kind")?.as_str()?.to_owned();
3298                            Some(ProviderInteractionOption { option_id, name, kind })
3299                        })
3300                        .collect()
3301                })
3302                .unwrap_or_default();
3303            Some(ProviderEvent::InteractionRequested {
3304                request_id: Some(encode_rpc_request_id(&id)),
3305                interaction_kind: ProviderInteractionKind::Approval,
3306                tool_name: field("kind").unwrap_or(method),
3307                title,
3308                prompt: field("title").unwrap_or_default(),
3309                options,
3310                agent_id: None,
3311            })
3312        }
3313        AgentEvent::RpcIncomingRequest {
3314            id: _,
3315            method,
3316            params,
3317            decision,
3318            // `outcome` distinguishes a `Granted` request that ran cleanly
3319            // from one that was authorized and then failed WHILE EXECUTING
3320            // (`HostRequestOutcome::Failed` -- e.g. a `terminal/create`
3321            // spawn error; see that type's own doc comment). Carried
3322            // through, never dropped: an execution failure must reach the
3323            // operator as `Granted` + `Failed { error }`, not an
3324            // undifferentiated `Granted` that silently swallows the
3325            // failure text.
3326            outcome,
3327            reason,
3328        } => Some(ProviderEvent::HostRequestObserved {
3329            method,
3330            params_json: params.map(|value| value.to_string()).unwrap_or_default(),
3331            decision: provider_host_request_decision(decision),
3332            outcome: provider_host_request_outcome(outcome),
3333            reason,
3334        }),
3335        AgentEvent::RpcNotification { method, params } => {
3336            Some(ProviderEvent::UnrecognizedNotification {
3337                method,
3338                payload_json: params.to_string(),
3339            })
3340        }
3341        AgentEvent::Started { .. } | AgentEvent::Exited { .. } | AgentEvent::PtyRaw { .. } => None,
3342        AgentEvent::UserMessage { text, is_delta } => {
3343            Some(ProviderEvent::UserMessage { text, is_delta })
3344        }
3345        AgentEvent::Plan { steps } => Some(ProviderEvent::Plan {
3346            steps: steps.into_iter().map(provider_plan_step).collect(),
3347        }),
3348        AgentEvent::AvailableCommandsUpdate { commands } => {
3349            Some(ProviderEvent::AvailableCommandsUpdated {
3350                commands: commands
3351                    .into_iter()
3352                    .map(|command| ProviderAvailableCommand {
3353                        name: command.name,
3354                        description: command.description,
3355                        input_hint: command.input_hint,
3356                    })
3357                    .collect(),
3358            })
3359        }
3360        AgentEvent::ModeChanged { mode_id } => Some(ProviderEvent::ModeChanged {
3361            mode_id,
3362            available: available_modes.iter().cloned().map(provider_mode_info).collect(),
3363        }),
3364        AgentEvent::SessionInfoUpdate { title } => {
3365            Some(ProviderEvent::SessionInfoUpdated { title })
3366        }
3367        AgentEvent::UsageUpdate {
3368            used_tokens,
3369            context_window,
3370            cost_amount,
3371            cost_currency,
3372        } => Some(ProviderEvent::UsageUpdated {
3373            used_tokens,
3374            context_window,
3375            cost_amount: cost_amount.map(|amount| amount.to_string()),
3376            cost_currency,
3377        }),
3378        AgentEvent::ConfigOptionsUpdate { options } => Some(ProviderEvent::ConfigOptionsUpdated {
3379            options: options.into_iter().map(provider_config_option).collect(),
3380        }),
3381        // Grok vendor `_x.ai/*` extensions (see `gate4agent::acp::protocol::
3382        // parse_vendor_notification`) -- not yet bridged to `ProviderEvent`.
3383        // No `ProviderEvent` variant exists for any of these today; adding
3384        // one is a separate, larger change across this crate's validation
3385        // (`ProviderEventValidationError`), the observation pipeline, and
3386        // the TUI, not part of parsing the ACP wire itself.
3387        AgentEvent::ModelsUpdate { .. }
3388        | AgentEvent::ProviderModelChanged { .. }
3389        | AgentEvent::SettingsUpdate { .. }
3390        | AgentEvent::HookExecutionUpdate { .. }
3391        | AgentEvent::McpServersUpdate { .. }
3392        | AgentEvent::McpInitProgress { .. }
3393        | AgentEvent::McpInitialized { .. }
3394        | AgentEvent::AnnouncementsUpdate { .. } => None,
3395    }
3396}
3397
3398/// Map the ACP wire priority onto the typed `ProviderPlanPriority`. A
3399/// plain match, not `format!("{:?}", ..)` -- see `provider_rate_limit_kind`
3400/// for why: the wire carries the typed value itself.
3401fn provider_plan_priority(priority: gate4agent::core::types::PlanStepPriority) -> ProviderPlanPriority {
3402    use gate4agent::core::types::PlanStepPriority;
3403    match priority {
3404        PlanStepPriority::High => ProviderPlanPriority::High,
3405        PlanStepPriority::Medium => ProviderPlanPriority::Medium,
3406        PlanStepPriority::Low => ProviderPlanPriority::Low,
3407    }
3408}
3409
3410/// Map the ACP wire status onto the typed `ProviderPlanStatus`.
3411fn provider_plan_status(status: gate4agent::core::types::PlanStepStatus) -> ProviderPlanStatus {
3412    use gate4agent::core::types::PlanStepStatus;
3413    match status {
3414        PlanStepStatus::Pending => ProviderPlanStatus::Pending,
3415        PlanStepStatus::InProgress => ProviderPlanStatus::InProgress,
3416        PlanStepStatus::Completed => ProviderPlanStatus::Completed,
3417    }
3418}
3419
3420fn provider_plan_step(step: gate4agent::core::types::PlanStep) -> ProviderPlanStep {
3421    ProviderPlanStep {
3422        content: step.content,
3423        priority: provider_plan_priority(step.priority),
3424        status: provider_plan_status(step.status),
3425    }
3426}
3427
3428/// Maps one ACP `SessionMode` (`gate4agent::acp::protocol::SessionMode`,
3429/// `session/new`'s `modes.availableModes`) onto the wire-typed
3430/// `ProviderModeInfo` carried on `ProviderEvent::ModeChanged::available`.
3431fn provider_mode_info(mode: SessionMode) -> ProviderModeInfo {
3432    ProviderModeInfo {
3433        id: mode.id,
3434        name: mode.name,
3435        description: mode.description,
3436    }
3437}
3438
3439/// Map the ACP wire config-option kind onto the typed
3440/// `ProviderConfigOptionKind`.
3441fn provider_config_option_kind(
3442    kind: gate4agent::core::types::ConfigOptionKind,
3443) -> ProviderConfigOptionKind {
3444    use gate4agent::core::types::ConfigOptionKind;
3445    match kind {
3446        ConfigOptionKind::Select => ProviderConfigOptionKind::Select,
3447        ConfigOptionKind::Boolean => ProviderConfigOptionKind::Boolean,
3448        ConfigOptionKind::Unknown => ProviderConfigOptionKind::Unknown,
3449    }
3450}
3451
3452/// `gate4agent-types` is a pure data contract and does not depend on
3453/// `serde_json`, so the option's current value and each choice's value --
3454/// both `serde_json::Value` in `gate4agent::core::types::ConfigOptionInfo`
3455/// -- cross the boundary pre-serialized to JSON text, same convention as
3456/// `ToolStarted::input_json` above.
3457fn provider_config_option(option: gate4agent::core::types::ConfigOptionInfo) -> ProviderConfigOption {
3458    ProviderConfigOption {
3459        id: option.id,
3460        name: option.name,
3461        description: option.description,
3462        category: option.category,
3463        kind: provider_config_option_kind(option.kind),
3464        value_json: option.value.to_string(),
3465        choices: option
3466            .choices
3467            .into_iter()
3468            .map(|choice| ProviderConfigChoice {
3469                value_json: choice.value.to_string(),
3470                label: choice.label,
3471            })
3472            .collect(),
3473    }
3474}
3475
3476fn builtin_legacy_adapter_runtimes() -> AdapterRuntimeRegistry<CliTool> {
3477    // Each of the four fleet providers registers only the transport families
3478    // it actually declares in the built-in adapter registry -- Grok, for
3479    // instance, is ACP-only and has no `PtySemantic`/`Pipe` descriptor.
3480    // Absence of a family for a given provider is not an error; only a
3481    // duplicate registration is.
3482    let definitions = [
3483        ("claude-code", CliTool::ClaudeCode),
3484        ("codex", CliTool::Codex),
3485        ("kimi", CliTool::KimiCode),
3486        ("grok", CliTool::Grok),
3487    ];
3488    let mut runtimes = AdapterRuntimeRegistry::default();
3489    for (id, tool) in definitions {
3490        for family in [
3491            AdapterFamily::PtySemantic,
3492            AdapterFamily::Pipe,
3493            AdapterFamily::Acp,
3494        ] {
3495            let Some(binding) = builtin_adapter_registry().binding(family, id) else {
3496                continue;
3497            };
3498            runtimes
3499                .insert(family, binding.clone(), tool)
3500                .expect("built-in native adapter runtime must be unique");
3501        }
3502    }
3503
3504    runtimes
3505}
3506
3507fn terminal_frame(snapshot: PtyTerminalSnapshot, screen_state: PtyScreenState) -> TerminalFrame {
3508    TerminalFrame {
3509        sequence: snapshot.sequence,
3510        size: TerminalSize {
3511            rows: snapshot.size.rows,
3512            columns: snapshot.size.cols,
3513        },
3514        cursor_row: snapshot.cursor.0,
3515        cursor_column: snapshot.cursor.1,
3516        contents: snapshot.contents,
3517        formatted: snapshot.formatted,
3518        scrollback_formatted: snapshot.scrollback_formatted,
3519        alternate_screen: snapshot.alternate_screen,
3520        mouse_protocol_enabled: snapshot.mouse_protocol_enabled,
3521        mouse_protocol_encoding: match snapshot.mouse_protocol_encoding {
3522            PtyMouseProtocolEncoding::Default => TerminalMouseProtocolEncoding::Default,
3523            PtyMouseProtocolEncoding::Utf8 => TerminalMouseProtocolEncoding::Utf8,
3524            PtyMouseProtocolEncoding::Sgr => TerminalMouseProtocolEncoding::Sgr,
3525        },
3526        // Carried through unchanged from the PTY snapshot -- this crate does
3527        // not restamp it, see `TerminalFrame::produced_at_unix_ms`'s own doc.
3528        produced_at_unix_ms: snapshot.produced_at_unix_ms,
3529        screen_state,
3530        // Known at capture time: `PtyTerminalSnapshot::bracketed_paste` is
3531        // read off the same `vt100::Screen` as `contents`/`formatted`, so
3532        // `Some` here always means "sampled", never "unknown" -- see
3533        // `TerminalFrame::bracketed_paste`'s own doc for what `None` means.
3534        bracketed_paste: Some(snapshot.bracketed_paste),
3535    }
3536}
3537
3538/// Whether the cheap `terminal_sequence()` gate should skip the expensive
3539/// `terminal_state()` call outright -- true exactly when the sequence read
3540/// is no newer than what this session already captured. An `Err` from the
3541/// cheap read does NOT skip: it falls through so the real call is attempted
3542/// (and reports its own failure through the normal `terminal_state()`
3543/// error path), matching the pre-instrumentation behaviour of the
3544/// `matches!` this replaces.
3545fn terminal_state_capture_should_skip<E>(sequence: Result<u64, E>, last_captured: u64) -> bool {
3546    matches!(sequence, Ok(sequence) if sequence <= last_captured)
3547}
3548
3549/// Total wire-relevant byte size of one [`TerminalFrame`] -- `formatted`
3550/// plus every row of `scrollback_formatted` summed. This is exactly what
3551/// gets handed to the c2 relay/harness/operator wire per frame (`contents`
3552/// never leaves the node), which is why it is the number the frame-bytes
3553/// distribution reports.
3554fn terminal_frame_byte_len(frame: &TerminalFrame) -> u64 {
3555    let scrollback_bytes: usize = frame.scrollback_formatted.iter().map(Vec::len).sum();
3556    (frame.formatted.len() + scrollback_bytes) as u64
3557}
3558
3559fn canonical_foreground(
3560    agent_id: AgentId,
3561    observation: &PtyForegroundObservation,
3562) -> ForegroundProcess {
3563    let kind = if observation.readiness.process_name.as_deref() == Some(agent_id.as_str()) {
3564        ForegroundProcessKind::Agent { agent_id }
3565    } else if observation.readiness.is_shell {
3566        ForegroundProcessKind::Shell
3567    } else {
3568        ForegroundProcessKind::Other
3569    };
3570    ForegroundProcess {
3571        root_process_id: observation.root_pid,
3572        process_id: observation.observed_pid,
3573        process_name: observation.observed_process.clone(),
3574        kind,
3575    }
3576}
3577
3578fn completion_observation(
3579    operation_id: OperationId,
3580    instance_id: AgentInstanceId,
3581    generation: SessionGeneration,
3582    observation: ControlObservation,
3583) -> ObservationEnvelope {
3584    ObservationEnvelope {
3585        operation_id: Some(operation_id),
3586        instance_id,
3587        generation,
3588        observation,
3589    }
3590}
3591
3592async fn wait_for_readiness(
3593    session: &PtySession,
3594    spec: &AgentSpec,
3595    intent: ReadinessIntent,
3596    detect_startup_gates: bool,
3597) -> Result<ReadinessPermit, String> {
3598    let started = Instant::now();
3599    let (terminal, attachment) = attach_readiness_boundary(session)?;
3600    let mut receiver = attachment.receiver;
3601    let mut tracker = ReadinessTracker::new(spec, RuntimePlatform::current(), intent);
3602    let mut diagnostics = ReadinessDiagnostics {
3603        draft_signal: Some(spec.readiness.draft_signal),
3604        ..ReadinessDiagnostics::default()
3605    };
3606    seed_readiness_from_terminal(
3607        &terminal,
3608        &mut tracker,
3609        &mut diagnostics,
3610        elapsed_ms(started),
3611    )?;
3612    let interval = Duration::from_millis(spec.readiness.poll_interval_ms.max(1));
3613    let mut next_probe = Instant::now();
3614
3615    for event in attachment.replay {
3616        observe_readiness_event(
3617            &mut tracker,
3618            &mut diagnostics,
3619            event.event,
3620            elapsed_ms(started),
3621        )?;
3622        if detect_startup_gates {
3623            ensure_no_readiness_operator_gate(&diagnostics, spec)?;
3624            ensure_no_startup_operator_gate(session, spec)?;
3625        }
3626    }
3627
3628    loop {
3629        if detect_startup_gates {
3630            ensure_no_startup_operator_gate(session, spec)?;
3631        }
3632        if Instant::now() >= next_probe {
3633            let foreground = session
3634                .observe_foreground()
3635                .await
3636                .map_err(|error| error.to_string())?;
3637            diagnostics.observe_foreground(&foreground.readiness);
3638            tracker.observe_foreground(&foreground.readiness, elapsed_ms(started));
3639            next_probe = Instant::now() + interval;
3640        }
3641        tracker.poll(elapsed_ms(started));
3642        if readiness_complete(tracker.status(), &diagnostics)? {
3643            if detect_startup_gates {
3644                ensure_no_startup_operator_gate(session, spec)?;
3645            }
3646            return tracker
3647                .into_permit()
3648                .ok_or_else(|| "ready tracker did not issue a permit".to_owned());
3649        }
3650
3651        let wait = next_probe.saturating_duration_since(Instant::now());
3652        match tokio::time::timeout(wait, receiver.recv()).await {
3653            Ok(Ok(event)) => {
3654                if matches!(&event.event, PtyEvent::DataGap { .. }) {
3655                    let (terminal, attachment) = attach_readiness_boundary(session)?;
3656                    receiver = attachment.receiver;
3657                    tracker = ReadinessTracker::new(
3658                        spec,
3659                        RuntimePlatform::current(),
3660                        intent,
3661                    );
3662                    diagnostics = ReadinessDiagnostics {
3663                        draft_signal: Some(spec.readiness.draft_signal),
3664                        ..ReadinessDiagnostics::default()
3665                    };
3666                    seed_readiness_from_terminal(
3667                        &terminal,
3668                        &mut tracker,
3669                        &mut diagnostics,
3670                        elapsed_ms(started),
3671                    )?;
3672                    for retained in attachment.replay {
3673                        observe_readiness_event(
3674                            &mut tracker,
3675                            &mut diagnostics,
3676                            retained.event,
3677                            elapsed_ms(started),
3678                        )?;
3679                    }
3680                } else {
3681                    observe_readiness_event(
3682                        &mut tracker,
3683                        &mut diagnostics,
3684                        event.event,
3685                        elapsed_ms(started),
3686                    )?;
3687                }
3688                if detect_startup_gates {
3689                    ensure_no_readiness_operator_gate(&diagnostics, spec)?;
3690                    ensure_no_startup_operator_gate(session, spec)?;
3691                }
3692            }
3693            Ok(Err(error)) => return Err(error.to_string()),
3694            Err(_) => {
3695                tracker.poll(elapsed_ms(started));
3696            }
3697        }
3698        if readiness_complete(tracker.status(), &diagnostics)? {
3699            if detect_startup_gates {
3700                ensure_no_startup_operator_gate(session, spec)?;
3701            }
3702            return tracker
3703                .into_permit()
3704                .ok_or_else(|| "ready tracker did not issue a permit".to_owned());
3705        }
3706    }
3707}
3708
3709fn attach_readiness_boundary(
3710    session: &PtySession,
3711) -> Result<(PtyTerminalSnapshot, PtyAttachment), String> {
3712    let terminal = session.terminal_state().map_err(|error| error.to_string())?;
3713    let cursor = PtyReplayCursor {
3714        provider_revision: terminal.provider_revision.clone(),
3715        generation: terminal.generation,
3716        next_sequence: terminal.sequence.saturating_add(1).max(1),
3717    };
3718    let attachment = session
3719        .attach_events(cursor)
3720        .map_err(|error| error.to_string())?;
3721    Ok((terminal, attachment))
3722}
3723
3724fn seed_readiness_from_terminal(
3725    terminal: &PtyTerminalSnapshot,
3726    tracker: &mut ReadinessTracker<'_>,
3727    diagnostics: &mut ReadinessDiagnostics,
3728    elapsed_ms: u64,
3729) -> Result<(), String> {
3730    const ENABLE_BRACKETED_PASTE: &[u8] = b"\x1b[?2004h";
3731    if terminal.bracketed_paste {
3732        diagnostics.observe_output(ENABLE_BRACKETED_PASTE);
3733        tracker.observe_output(ENABLE_BRACKETED_PASTE, elapsed_ms);
3734    }
3735    if !terminal.formatted.is_empty() {
3736        diagnostics.observe_output(&terminal.formatted);
3737        tracker.observe_output(&terminal.formatted, elapsed_ms);
3738    }
3739    Ok(())
3740}
3741
3742const STARTUP_GATE_SETTLE_MS: u64 = 350;
3743const STARTUP_GATE_POLL_MS: u64 = 25;
3744/// Steady-state cadence for `NativeEffectShell::reclassify_foreground`.
3745///
3746/// `wait_for_readiness` already polls the foreground far tighter than this
3747/// during startup (`spec.readiness.poll_interval_ms`, 150ms by default),
3748/// because a session takes at most a few seconds to come up and getting
3749/// that window right matters. This constant is not that -- it governs the
3750/// STEADY STATE: a session that has been sitting non-`Ready` for a long
3751/// time, which is exactly the incident this module fixes (an update
3752/// screen with nobody watching it). A session that reaches `Ready` stops
3753/// being probed at all (see `foreground_probe_schedule`), so the cost of
3754/// this interval is bounded by the count of sessions that are NOT
3755/// `Ready`, never by output rate and never by total session count. One
3756/// process-tree walk a second for a handful of stuck sessions is ample.
3757const FOREGROUND_RECLASSIFY_INTERVAL: Duration = Duration::from_secs(1);
3758// Codex rust-v0.144.0 keeps Enter in newline mode for 120 ms after
3759// Windows paste-burst activity. Wait beyond that window only after the TUI
3760// visibly incorporates the deferred initial prompt.
3761const CODEX_PASTE_ENTER_SUPPRESSION_MS: u64 = 120;
3762const CODEX_POST_RENDER_MARGIN_MS: u64 = 30;
3763const CODEX_SESSION_STATUS_PROBE_TIMEOUT_MS: u64 = 5_000;
3764const KIMI_SESSION_STATUS_PROBE_TIMEOUT_MS: u64 = 5_000;
3765
3766async fn probe_fresh_codex_session_identity(
3767    session: &PtySession,
3768    spec: &AgentSpec,
3769) -> Result<Option<ProviderSessionIdentity>, String> {
3770    let permit = match wait_for_readiness(session, spec, ReadinessIntent::DraftPaste, true).await {
3771        Ok(permit) => permit,
3772        Err(_) => return Ok(None),
3773    };
3774    if wait_for_startup_operator_gate(session, spec).await.is_err() {
3775        return Ok(None);
3776    }
3777    let baseline = session
3778        .terminal_state()
3779        .map_err(|error| error.to_string())?;
3780    let mut extractor = CodexPtySessionIdentityExtractor::default();
3781    session
3782        .send_input_action(
3783            InputAction::AgentCommand(AgentCommand {
3784                agent_id: spec.id.clone(),
3785                name: "status".to_owned(),
3786                arguments: Vec::new(),
3787            }),
3788            permit,
3789        )
3790        .await
3791        .map_err(|error| format!("Codex /status identity probe failed: {error}"))?;
3792
3793    let deadline = Instant::now()
3794        + Duration::from_millis(
3795            spec.readiness
3796                .timeout_ms
3797                .min(CODEX_SESSION_STATUS_PROBE_TIMEOUT_MS)
3798                .max(1),
3799        );
3800    loop {
3801        let snapshot = session
3802            .terminal_state()
3803            .map_err(|error| error.to_string())?;
3804        if snapshot.sequence > baseline.sequence {
3805            if let Some(identity) = extractor.observe_screen(&snapshot.contents) {
3806                return Ok(Some(identity));
3807            }
3808        }
3809        if Instant::now() >= deadline {
3810            return Ok(None);
3811        }
3812        tokio::time::sleep(Duration::from_millis(STARTUP_GATE_POLL_MS)).await;
3813    }
3814}
3815
3816async fn probe_fresh_kimi_session_identity(
3817    session: &PtySession,
3818    spec: &AgentSpec,
3819) -> Result<Option<ProviderSessionIdentity>, String> {
3820    let permit = match wait_for_readiness(
3821        session,
3822        spec,
3823        ReadinessIntent::FollowupPrompt,
3824        true,
3825    )
3826    .await
3827    {
3828        Ok(permit) => permit,
3829        Err(_) => return Ok(None),
3830    };
3831    if wait_for_startup_operator_gate(session, spec).await.is_err() {
3832        return Ok(None);
3833    }
3834    let baseline = session
3835        .terminal_state()
3836        .map_err(|error| error.to_string())?;
3837    let mut extractor = KimiPtySessionIdentityExtractor::default();
3838    if let Some(identity) = extractor.observe_screen(&baseline.contents) {
3839        return Ok(Some(identity));
3840    }
3841    session
3842        .send_input_action(
3843            InputAction::SubmitPrompt(PromptPayload {
3844                text: "/status".to_owned(),
3845                framing: PromptFraming::BracketedPaste,
3846            }),
3847            permit,
3848        )
3849        .await
3850        .map_err(|error| format!("Kimi /status identity probe failed: {error}"))?;
3851
3852    let deadline = Instant::now()
3853        + Duration::from_millis(
3854            spec.readiness
3855                .timeout_ms
3856                .min(KIMI_SESSION_STATUS_PROBE_TIMEOUT_MS)
3857                .max(1),
3858        );
3859    loop {
3860        let snapshot = session
3861            .terminal_state()
3862            .map_err(|error| error.to_string())?;
3863        if snapshot.sequence > baseline.sequence {
3864            if let Some(identity) = extractor.observe_screen(&snapshot.contents) {
3865                return Ok(Some(identity));
3866            }
3867        }
3868        if Instant::now() >= deadline {
3869            return Ok(None);
3870        }
3871        tokio::time::sleep(Duration::from_millis(STARTUP_GATE_POLL_MS)).await;
3872    }
3873}
3874
3875async fn deliver_pending_initial_prompt(
3876    session: &mut PtySession,
3877    spec: &AgentSpec,
3878) -> Result<(), String> {
3879    if session.pending_followup_prompt().is_none() {
3880        return Ok(());
3881    }
3882    let mut permit =
3883        wait_for_readiness(session, spec, ReadinessIntent::FollowupPrompt, true).await?;
3884    wait_for_startup_operator_gate(session, spec).await?;
3885    let render_confirmed_submit = spec.id.as_str() == "claude"
3886        || (RuntimePlatform::current() == RuntimePlatform::Windows
3887            && spec.id.as_str() == "codex");
3888    if render_confirmed_submit {
3889        let prompt = session
3890            .pending_followup_prompt()
3891            .ok_or_else(|| "deferred initial prompt disappeared before paste".to_owned())?
3892            .to_owned();
3893        let baseline = session
3894            .terminal_state()
3895            .map_err(|error| error.to_string())?;
3896        let inserted = session
3897            .insert_pending_followup_prompt(PromptFraming::BracketedPaste, &permit)
3898            .await
3899            .map_err(|error| error.to_string())?;
3900        if !inserted {
3901            return Err("deferred initial prompt was not inserted".to_owned());
3902        }
3903        wait_for_prompt_render(
3904            session,
3905            spec,
3906            &prompt,
3907            &baseline,
3908            spec.readiness.timeout_ms,
3909        )
3910        .await?;
3911        if RuntimePlatform::current() == RuntimePlatform::Windows && spec.id.as_str() == "codex" {
3912            tokio::time::sleep(Duration::from_millis(
3913                CODEX_PASTE_ENTER_SUPPRESSION_MS + CODEX_POST_RENDER_MARGIN_MS,
3914            ))
3915            .await;
3916        }
3917        permit = wait_for_readiness(session, spec, ReadinessIntent::FollowupPrompt, true).await?;
3918        wait_for_startup_operator_gate(session, spec).await?;
3919    }
3920    ensure_no_startup_operator_gate(session, spec)?;
3921    let submitted = session
3922        .submit_pending_followup(PromptFraming::BracketedPaste, permit)
3923        .await
3924        .map_err(|error| error.to_string())?;
3925    if !submitted {
3926        return Err("deferred initial prompt disappeared before delivery".to_owned());
3927    }
3928    Ok(())
3929}
3930
3931async fn wait_for_prompt_render(
3932    session: &PtySession,
3933    spec: &AgentSpec,
3934    prompt: &str,
3935    baseline: &PtyTerminalSnapshot,
3936    timeout_ms: u64,
3937) -> Result<(), String> {
3938    let probe = prompt_render_probe(&gate4agent_types::sanitize_prompt_text(prompt));
3939    let deadline = Instant::now() + Duration::from_millis(timeout_ms.max(1));
3940    loop {
3941        let snapshot = session.terminal_state().map_err(|error| error.to_string())?;
3942        if let Some(gate) = startup_operator_gate(&snapshot.contents) {
3943            return Err(startup_operator_error(spec, &gate));
3944        }
3945        if prompt_rendered(&snapshot, baseline, &probe) {
3946            return Ok(());
3947        }
3948        if Instant::now() >= deadline {
3949            let tail = terminal_tail(&snapshot.contents);
3950            let compact_tail = compact_alphanumeric(&tail);
3951            let baseline_tail = terminal_tail(&baseline.contents).to_ascii_lowercase();
3952            let normalized_tail = tail.to_ascii_lowercase();
3953            let retained = render_ack_event_summary(session, baseline.sequence);
3954            let foreground = session.observe_foreground().await.ok();
3955            let foreground_name = foreground
3956                .as_ref()
3957                .and_then(|observation| observation.readiness.process_name.as_deref())
3958                .map(safe_process_label)
3959                .unwrap_or_else(|| "none".to_owned());
3960            let child_live = retained.exit_code.is_none() && foreground.is_some();
3961            return Err(format!(
3962                "agent '{}' initial prompt paste was not rendered before submit; Enter was not sent (baseline_sequence={} current_sequence={} sequence_delta={} cursor_changed={} bracketed_baseline={} bracketed_current={} tail_chars={} probe_chars={} probe_match={} placeholder_baseline={} placeholder_current={} child_live={} foreground={} retained_output={} retained_foreground={} retained_snapshots={} retained_resized={} retained_gaps={} retained_reader_errors={} retained_operator_actions={} retained_exit_code={} output_flags={})",
3963                spec.id,
3964                baseline.sequence,
3965                snapshot.sequence,
3966                snapshot.sequence.saturating_sub(baseline.sequence),
3967                snapshot.cursor != baseline.cursor,
3968                baseline.bracketed_paste,
3969                snapshot.bracketed_paste,
3970                tail.chars().count(),
3971                probe.chars().count(),
3972                !probe.is_empty() && compact_tail.contains(&probe),
3973                paste_placeholder_visible(&baseline_tail),
3974                paste_placeholder_visible(&normalized_tail),
3975                child_live,
3976                foreground_name,
3977                retained.output,
3978                retained.foreground,
3979                retained.snapshots,
3980                retained.resized,
3981                retained.gaps,
3982                retained.reader_errors,
3983                retained.operator_actions,
3984                retained
3985                    .exit_code
3986                    .map_or_else(|| "none".to_owned(), |code| code.to_string()),
3987                if retained.output_flags.is_empty() {
3988                    "none".to_owned()
3989                } else {
3990                    retained.output_flags.join(",")
3991                },
3992            ));
3993        }
3994        tokio::time::sleep(Duration::from_millis(STARTUP_GATE_POLL_MS)).await;
3995    }
3996}
3997
3998#[derive(Default)]
3999struct RenderAckEventSummary {
4000    output: usize,
4001    foreground: usize,
4002    snapshots: usize,
4003    resized: usize,
4004    gaps: usize,
4005    reader_errors: usize,
4006    operator_actions: usize,
4007    exit_code: Option<i32>,
4008    output_flags: Vec<&'static str>,
4009}
4010
4011fn render_ack_event_summary(session: &PtySession, baseline_sequence: u64) -> RenderAckEventSummary {
4012    let mut summary = RenderAckEventSummary::default();
4013    let Ok(attachment) = session.attach_retained_events() else {
4014        return summary;
4015    };
4016    for envelope in attachment
4017        .replay
4018        .into_iter()
4019        .filter(|envelope| envelope.sequence > baseline_sequence)
4020    {
4021        match envelope.event {
4022            PtyEvent::Output(bytes) => {
4023                summary.output = summary.output.saturating_add(1);
4024                observe_render_ack_output_flags(&mut summary.output_flags, &bytes);
4025            }
4026            PtyEvent::ForegroundProcess(_) => {
4027                summary.foreground = summary.foreground.saturating_add(1);
4028            }
4029            PtyEvent::SnapshotAvailable { .. } => {
4030                summary.snapshots = summary.snapshots.saturating_add(1);
4031            }
4032            PtyEvent::Resized(_) => {
4033                summary.resized = summary.resized.saturating_add(1);
4034            }
4035            PtyEvent::DataGap { .. } => {
4036                summary.gaps = summary.gaps.saturating_add(1);
4037            }
4038            PtyEvent::ReaderError { .. } => {
4039                summary.reader_errors = summary.reader_errors.saturating_add(1);
4040            }
4041            PtyEvent::OperatorActionRequired { .. } => {
4042                summary.operator_actions = summary.operator_actions.saturating_add(1);
4043            }
4044            PtyEvent::Exited { code } => summary.exit_code = Some(code),
4045            PtyEvent::Started => {}
4046        }
4047    }
4048    summary
4049}
4050
4051fn observe_render_ack_output_flags(flags: &mut Vec<&'static str>, bytes: &[u8]) {
4052    let normalized = String::from_utf8_lossy(bytes).to_ascii_lowercase();
4053    for (label, marker) in [
4054        ("error", "error"),
4055        ("panic", "panic"),
4056        ("fatal", "fatal"),
4057        ("login", "login"),
4058        ("auth", "auth"),
4059        ("permission", "permission"),
4060        ("rate-limit", "rate limit"),
4061        ("usage-limit", "usage limit"),
4062        ("update", "update"),
4063        ("working", "working"),
4064        ("thinking", "thinking"),
4065    ] {
4066        if normalized.contains(marker) && !flags.contains(&label) {
4067            flags.push(label);
4068        }
4069    }
4070}
4071
4072fn safe_process_label(process_name: &str) -> String {
4073    process_name
4074        .chars()
4075        .take(64)
4076        .map(|character| {
4077            if character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-') {
4078                character
4079            } else {
4080                '?'
4081            }
4082        })
4083        .collect()
4084}
4085
4086fn prompt_rendered(
4087    snapshot: &PtyTerminalSnapshot,
4088    baseline: &PtyTerminalSnapshot,
4089    probe: &str,
4090) -> bool {
4091    if snapshot.sequence <= baseline.sequence {
4092        return false;
4093    }
4094    let tail = terminal_tail(&snapshot.contents);
4095    let compact = compact_alphanumeric(&tail);
4096    if !probe.is_empty() && compact.contains(probe) {
4097        return true;
4098    }
4099    let normalized_tail = tail.to_ascii_lowercase();
4100    let normalized_baseline = terminal_tail(&baseline.contents).to_ascii_lowercase();
4101    paste_placeholder_visible(&normalized_tail)
4102        && !paste_placeholder_visible(&normalized_baseline)
4103}
4104
4105fn paste_placeholder_visible(normalized_tail: &str) -> bool {
4106    // Codex collapses larger pastes as `[Pasted Content ...]`; Claude 2.1.223
4107    // uses `[Pasted text #N ...]`. Treat only those vendor-owned render
4108    // markers as proof that the TUI consumed the bracketed paste.
4109    normalized_tail.contains("[pasted content")
4110        || normalized_tail.contains("[pasted text #")
4111}
4112
4113fn terminal_tail(contents: &str) -> String {
4114    let mut lines = contents.lines().rev().take(12).collect::<Vec<_>>();
4115    lines.reverse();
4116    lines.join("\n")
4117}
4118
4119fn prompt_render_probe(prompt: &str) -> String {
4120    let compact = compact_alphanumeric(prompt);
4121    let chars = compact.chars().collect::<Vec<_>>();
4122    chars[chars.len().saturating_sub(32)..].iter().collect()
4123}
4124
4125fn compact_alphanumeric(value: &str) -> String {
4126    value
4127        .chars()
4128        .filter(|character| character.is_alphanumeric())
4129        .flat_map(char::to_lowercase)
4130        .collect()
4131}
4132
4133async fn wait_for_startup_operator_gate(
4134    session: &PtySession,
4135    spec: &AgentSpec,
4136) -> Result<(), String> {
4137    let deadline = Instant::now() + Duration::from_millis(STARTUP_GATE_SETTLE_MS);
4138    loop {
4139        ensure_no_startup_operator_gate(session, spec)?;
4140        let now = Instant::now();
4141        if now >= deadline {
4142            return Ok(());
4143        }
4144        tokio::time::sleep(
4145            deadline
4146                .saturating_duration_since(now)
4147                .min(Duration::from_millis(STARTUP_GATE_POLL_MS)),
4148        )
4149        .await;
4150    }
4151}
4152
4153/// Refuses a prompt submission while a known operator gate is on screen.
4154///
4155/// Deliberately checks gates ONLY, never `screen_failure`. A crash banner
4156/// means "the CLI fell over" solely before the agent has rendered its own
4157/// UI; afterwards the identical bytes mean "the agent is showing you a
4158/// crash" -- a test it ran, a traceback it was asked to explain. What
4159/// separates the two is not the text but WHEN it appeared, and this
4160/// function has no way to date its evidence: it holds a screen snapshot
4161/// and a spec, and is called from seven sites spanning both the startup
4162/// window and long-running follow-up delivery. A check that cannot tell
4163/// those apart would refuse work from a perfectly healthy session every
4164/// time its pane happened to show a failing subprocess.
4165///
4166/// The `Failing` classification is not lost by this -- it is computed
4167/// where the window IS known (`collect_terminal_frames`, gated on
4168/// `OwnedPtySession::ever_reached_ready`), published upstream, and enforced
4169/// by the callers that gate dispatch on `PtyScreenState`. Gates are
4170/// different in kind and stay here: a trust or update prompt is legitimate
4171/// at any point in a session's life, so it needs no window to be believed.
4172fn ensure_no_startup_operator_gate(session: &PtySession, spec: &AgentSpec) -> Result<(), String> {
4173    let snapshot = session.terminal_state().map_err(|error| error.to_string())?;
4174    match startup_operator_gate(&snapshot.contents) {
4175        Some(gate) => Err(startup_operator_error(spec, &gate)),
4176        None => Ok(()),
4177    }
4178}
4179
4180fn startup_operator_error(spec: &AgentSpec, gate: &OperatorGateState) -> String {
4181    format!(
4182        "agent '{}' requires operator action at startup ({gate}); initial prompt was not submitted",
4183        spec.id
4184    )
4185}
4186
4187fn ensure_no_readiness_operator_gate(
4188    diagnostics: &ReadinessDiagnostics,
4189    spec: &AgentSpec,
4190) -> Result<(), String> {
4191    match &diagnostics.operator_gate {
4192        Some(gate) => Err(startup_operator_error(spec, gate)),
4193        None => Ok(()),
4194    }
4195}
4196
4197/// Whitespace-collapse and lowercase raw PTY screen text before pattern
4198/// matching. Shared by `startup_operator_gate` and `screen_failure` so the
4199/// two agree on what "the same phrase" means -- if they normalized
4200/// independently, a line-wrap difference or a run of extra spaces could
4201/// make one matcher see a phrase the other misses for no reason connected
4202/// to the actual screen content.
4203fn normalize_screen_text(contents: &str) -> String {
4204    contents
4205        .split_whitespace()
4206        .collect::<Vec<_>>()
4207        .join(" ")
4208        .to_ascii_lowercase()
4209}
4210
4211/// Phrases showing a package manager actively installing or updating a
4212/// package. Generic across npm/pip/yarn/pnpm rather than any one vendor's
4213/// package name, so a wrapper script fronting any provider CLI is
4214/// recognized the same way -- see the `PACKAGE_MANAGER_UPDATE_COMPLETION_MARKERS`
4215/// doc below for why a bare occurrence of one of these alone is not enough.
4216const PACKAGE_MANAGER_INSTALL_MARKERS: &[&str] = &[
4217    "npm install -g",
4218    "npm i -g",
4219    "npm update -g",
4220    "pip install --upgrade",
4221    "pip install -u",
4222    "yarn global add",
4223    "pnpm add -g",
4224];
4225
4226/// Phrases showing the install/update above reached a terminal state and
4227/// wants the CLI relaunched. Required to co-occur with a marker from
4228/// `PACKAGE_MANAGER_INSTALL_MARKERS` so a screen that merely MENTIONS a
4229/// package-manager command (an agent explaining how to install something,
4230/// for instance) never matches on the install phrase alone.
4231const PACKAGE_MANAGER_UPDATE_COMPLETION_MARKERS: &[&str] = &[
4232    "update ran successfully",
4233    "please restart",
4234    "update complete",
4235    "updated successfully",
4236];
4237
4238/// Builds the classified `OperatorGateState` for a matched `kind`/`subject`
4239/// pair, filling `input`/`options` from whatever `parse_operator_gate_options`
4240/// can read off the RAW (un-normalized, multi-line) screen text. Every
4241/// `startup_operator_gate` match arm goes through this one function so
4242/// option-list recognition is uniform across every gate kind rather than
4243/// hand-wired per branch -- a kind this module has not yet seen an option
4244/// layout for simply gets `OperatorGateInput::Unknown` and no options,
4245/// exactly like a kind whose options this parser fails to recognize on a
4246/// given screen; there is no special-casing between "not implemented yet"
4247/// and "not recognized this time".
4248fn operator_gate(kind: OperatorGateKind, subject: OperatorGateSubject, contents: &str) -> OperatorGateState {
4249    let (input, options) = parse_operator_gate_options(contents);
4250    OperatorGateState::new(kind)
4251        .with_subject(subject)
4252        .with_options(input, options)
4253}
4254
4255fn startup_operator_gate(contents: &str) -> Option<OperatorGateState> {
4256    let normalized = normalize_screen_text(contents);
4257    if [
4258        "trust this folder",
4259        "trust the files in this folder",
4260        "trust the contents of this directory",
4261        "do you trust this directory",
4262    ]
4263    .iter()
4264    .any(|marker| normalized.contains(marker))
4265    {
4266        return Some(operator_gate(
4267            OperatorGateKind::WorkspaceTrust,
4268            OperatorGateSubject::Directory { path: None },
4269            contents,
4270        ));
4271    }
4272    if normalized.contains("quick safety check")
4273        && (normalized.contains("yes, i trust this folder")
4274            || normalized.contains("continue without these permissions"))
4275    {
4276        return Some(operator_gate(
4277            OperatorGateKind::WorkspaceTrust,
4278            OperatorGateSubject::Directory { path: None },
4279            contents,
4280        ));
4281    }
4282    // A CLI's own hook-trust prompt at startup (seen from Codex): a set of
4283    // shell hooks it discovered need to be reviewed/trusted before they are
4284    // allowed to run, distinct from `WorkspaceTrust` above -- that gate is
4285    // about trusting the PROJECT DIRECTORY, this one is about trusting
4286    // SHELL HOOKS the CLI found inside it, and an operator reading `kind`
4287    // should be able to tell which question is being asked. Required to
4288    // co-occur with the screen's own "decline" option rather than matching
4289    // on "hooks need review" alone, so an agent's ordinary narration that
4290    // merely uses the word "hooks" (explaining a git hook, a React hook, a
4291    // build hook) never matches: real narration essentially never also
4292    // contains the literal refusal phrasing "continue without trusting"
4293    // this same prompt renders. The number of hooks reported (which varies
4294    // run to run, and is not read into `subject`'s `count` -- no matcher
4295    // here parses it off the screen) plays no part in the match.
4296    if normalized.contains("hooks need review") && normalized.contains("continue without trusting")
4297    {
4298        return Some(operator_gate(
4299            OperatorGateKind::HookTrust,
4300            OperatorGateSubject::Hooks { count: None },
4301            contents,
4302        ));
4303    }
4304    if [
4305        "select authentication method",
4306        "choose how to authenticate",
4307        "no auth type is selected",
4308    ]
4309    .iter()
4310    .any(|marker| normalized.contains(marker))
4311    {
4312        return Some(operator_gate(
4313            OperatorGateKind::Authentication,
4314            OperatorGateSubject::Account,
4315            contents,
4316        ));
4317    }
4318    if normalized.contains("sign in")
4319        && (normalized.contains("openai")
4320            || normalized.contains("chatgpt")
4321            || normalized.contains("codex"))
4322    {
4323        return Some(operator_gate(
4324            OperatorGateKind::Authentication,
4325            OperatorGateSubject::Account,
4326            contents,
4327        ));
4328    }
4329    // Claude's own login-method chooser: a numbered list ("1. Claude ...",
4330    // "2. API usage billing", "3. 3rd-party platform ...") rendered under
4331    // "Select login method:". It shares neither "sign in" nor
4332    // "openai"/"chatgpt"/"codex" with the branch above, so that branch
4333    // never catches it. The phrase itself is specific enough (no ordinary
4334    // agent narration renders this exact prompt line) to stand alone,
4335    // matching the precedent set by "select authentication method" and
4336    // "choose how to authenticate" above.
4337    if normalized.contains("select login method") {
4338        return Some(operator_gate(
4339            OperatorGateKind::Authentication,
4340            OperatorGateSubject::Account,
4341            contents,
4342        ));
4343    }
4344    // Claude's OAuth wait screen: a URL to open in a browser plus a slot to
4345    // paste back the authorization code once that flow completes. Required
4346    // to co-occur with "paste code" (the screen's own field label) rather
4347    // than matching on "sign in" alone -- "sign in" by itself is common
4348    // enough in an agent's ordinary narration to be untrustworthy, the same
4349    // reasoning the branch above already applies via its own companion
4350    // words. No option list is rendered here -- it is one free-text slot,
4351    // not a choice from a list -- so `input` is set directly to
4352    // `TextEntry` rather than routed through `operator_gate`'s option-list
4353    // parser, which would find nothing on this screen shape and fall back
4354    // to `Unknown`.
4355    if normalized.contains("sign in") && normalized.contains("paste code") {
4356        return Some(
4357            OperatorGateState::new(OperatorGateKind::Authentication)
4358                .with_subject(OperatorGateSubject::Account)
4359                .with_options(OperatorGateInput::TextEntry, Vec::new()),
4360        );
4361    }
4362    if normalized.contains("kimi code update available")
4363        && normalized.contains("install update now")
4364    {
4365        return Some(operator_gate(
4366            OperatorGateKind::VendorUpdate,
4367            OperatorGateSubject::Unknown,
4368            contents,
4369        ));
4370    }
4371    // A wrapper script self-updating via a package manager before the real
4372    // CLI ever launches -- the case that produced the incident this module
4373    // fixes: an npm-driven wrapper ran an update to completion and printed
4374    // nothing that looked like the agent, while every consumer still saw a
4375    // live, `status: running` PTY. This shares `VendorUpdate` with the Kimi
4376    // in-app case just above on purpose, not by omission: both are "the CLI
4377    // is updating itself", the only difference is WHICH process drives it
4378    // (the agent's own composer vs. a wrapper script's package-manager
4379    // install), and an operator reading `kind` should see one meaning for
4380    // that fact regardless of which vendor's update mechanism produced it.
4381    if PACKAGE_MANAGER_INSTALL_MARKERS
4382        .iter()
4383        .any(|marker| normalized.contains(marker))
4384        && PACKAGE_MANAGER_UPDATE_COMPLETION_MARKERS
4385            .iter()
4386            .any(|marker| normalized.contains(marker))
4387    {
4388        return Some(operator_gate(
4389            OperatorGateKind::VendorUpdate,
4390            OperatorGateSubject::Unknown,
4391            contents,
4392        ));
4393    }
4394    if normalized.contains("choose the text style that looks best with your terminal") {
4395        return Some(operator_gate(
4396            OperatorGateKind::TerminalAppearance,
4397            OperatorGateSubject::Appearance,
4398            contents,
4399        ));
4400    }
4401    if normalized.contains("welcome to claude code for")
4402        && (normalized.contains("open files") || normalized.contains("selected lines"))
4403    {
4404        return Some(operator_gate(
4405            OperatorGateKind::Onboarding,
4406            OperatorGateSubject::Unknown,
4407            contents,
4408        ));
4409    }
4410    if normalized.contains("welcome to claude code")
4411        && (normalized.contains("press enter") || normalized.contains("enter to continue"))
4412    {
4413        return Some(operator_gate(
4414            OperatorGateKind::Onboarding,
4415            OperatorGateSubject::Unknown,
4416            contents,
4417        ));
4418    }
4419    if normalized.contains("migration") && normalized.contains("enter confirm") && normalized.contains("esc")
4420    {
4421        return Some(operator_gate(
4422            OperatorGateKind::ConfigurationMigration,
4423            OperatorGateSubject::Unknown,
4424            contents,
4425        ));
4426    }
4427    None
4428}
4429
4430/// Reads the recognized on-screen choice list, if any, off the RAW
4431/// (multi-line, un-normalized) screen text -- called uniformly by every
4432/// `startup_operator_gate` match arm through `operator_gate`. Recognizes
4433/// exactly two rendered shapes, both observed verbatim on a live stand:
4434///
4435/// - a NUMBERED list (Codex's hook-trust prompt): each option line starts,
4436///   after an optional leading cursor glyph (`›`) and whitespace, with
4437///   `N.`; `Some` lines from `parse_numbered_gate_option_line` win outright
4438///   over the arrow shape below, so a screen that happens to also contain an
4439///   arrow glyph elsewhere (a Codex composer prompt sharing the pane, say)
4440///   is still read as the numbered list it actually is.
4441/// - an ARROW list (Kimi's workspace-trust prompt): the currently selected
4442///   option carries a leading `❯`; the other options carry no glyph at all,
4443///   so they are told apart from the description line rendered under each
4444///   one (`"Enable project MCP servers. Remembered for this folder."` under
4445///   `"Trust this folder"`, for instance) by `parse_arrow_gate_option_line`'s
4446///   own filter -- see that function's doc comment for the exact rule.
4447///
4448/// Neither line is stable across CLIs, and this parser recognizes nothing
4449/// else: a screen matching neither shape returns `(Unknown, vec![])`, never
4450/// a guess built from partial matches.
4451fn parse_operator_gate_options(contents: &str) -> (OperatorGateInput, Vec<OperatorGateOption>) {
4452    let numbered: Vec<OperatorGateOption> = contents
4453        .lines()
4454        .filter_map(parse_numbered_gate_option_line)
4455        .take(OPERATOR_GATE_OPTIONS_MAX)
4456        .collect();
4457    if !numbered.is_empty() {
4458        return (OperatorGateInput::NumberedList, numbered);
4459    }
4460    let arrow: Vec<OperatorGateOption> = contents
4461        .lines()
4462        .filter_map(parse_arrow_gate_option_line)
4463        .take(OPERATOR_GATE_OPTIONS_MAX)
4464        .collect();
4465    if !arrow.is_empty() {
4466        return (OperatorGateInput::ArrowList, arrow);
4467    }
4468    (OperatorGateInput::Unknown, Vec::new())
4469}
4470
4471/// Parses one line of a numbered option list -- `"› 1. Review hooks"`,
4472/// `"  2. Trust all and continue"` -- into `(selected, text)`. `selected` is
4473/// true only when the line carries the leading `›` cursor glyph Codex draws
4474/// on the highlighted row; every other line in the same list has none.
4475/// Returns `None` for any line that, once a leading glyph is stripped, does
4476/// not start with `<digits>.` -- an instruction line like `"Press enter to
4477/// confirm or esc to go back"` is exactly this shape and is meant to fall
4478/// through untouched.
4479/// Cursor glyphs vendors draw ahead of the selected row of an option list.
4480/// Kept as one set shared by both list parsers: which glyph a vendor picks
4481/// says nothing about whether its list is numbered or marker-only, and
4482/// teaching one parser a glyph the other does not know is exactly how the
4483/// selected row went missing before.
4484const GATE_CURSOR_GLYPHS: [char; 2] = ['\u{203a}', '\u{276f}'];
4485
4486fn parse_numbered_gate_option_line(line: &str) -> Option<OperatorGateOption> {
4487    let trimmed = line.trim_start();
4488    // Vendors mark the cursor row of a NUMBERED list with the marker BEFORE
4489    // the number (`> 2. Dark mode`), and they do not agree on the glyph:
4490    // Codex draws U+203A, Claude draws U+276F, and both appear ahead of the
4491    // digit rather than ahead of the text. Recognizing only one of them cost
4492    // the whole row -- the cursor line failed to parse as an option at all,
4493    // so the list came back one item short and with nothing marked selected,
4494    // observed live on Claude's theme chooser and Codex's login chooser.
4495    let (selected, rest) = match trimmed.strip_prefix(GATE_CURSOR_GLYPHS) {
4496        Some(stripped) => (true, stripped.trim_start()),
4497        None => (false, trimmed),
4498    };
4499    let digits_end = rest.find(|character: char| !character.is_ascii_digit()).unwrap_or(0);
4500    if digits_end == 0 {
4501        return None;
4502    }
4503    let text = rest[digits_end..].strip_prefix('.')?.trim();
4504    if text.is_empty() {
4505        return None;
4506    }
4507    Some(OperatorGateOption {
4508        semantics: classify_operator_gate_option_semantics(text),
4509        text: text.to_owned(),
4510        selected,
4511    })
4512}
4513
4514/// Parses one line of an arrow-navigated option list -- `"❯ Don't trust"`,
4515/// `"  Trust this folder"` -- into `(selected, text)`. `selected` is true
4516/// only when the line carries the leading `❯` cursor glyph.
4517///
4518/// Distinguishing an option TITLE from the description/header/instruction
4519/// prose around it (`"Enable project MCP servers. Remembered for this
4520/// folder."` under `"Trust this folder"`; `"Do you trust the files in this
4521/// folder?"` above it; `"Press enter to confirm or esc to go back"` below a
4522/// DIFFERENT screen's numbered list entirely) cannot rely on the glyph
4523/// alone, since only the currently-selected title ever carries one. A
4524/// candidate line is accepted as an option only if EITHER it carries the
4525/// `❯` marker (the screen's own cursor is unambiguous proof this is a real,
4526/// currently-selected option, whatever its wording), OR it both looks like
4527/// a short menu label rather than a sentence (no `.`, `?`, `!`, or `:`
4528/// anywhere in it -- a description or instruction is a full sentence
4529/// carrying one of these; an option label like `"Trust this folder"` or
4530/// `"Don't trust"` never does) AND its own text uses one of the recognized
4531/// option verbs (`classify_operator_gate_option_semantics` returns
4532/// something other than `Unknown`) -- an unmarked line whose wording this
4533/// module does not recognize as a choice-verb is left as prose rather than
4534/// guessed into an option with `semantics: Unknown`. The arrow-key legend
4535/// itself (`"↑↓ navigate · Enter select · Esc exit"`) is excluded
4536/// explicitly rather than relying on either rule catching it.
4537fn parse_arrow_gate_option_line(line: &str) -> Option<OperatorGateOption> {
4538    let trimmed = line.trim();
4539    if trimmed.is_empty()
4540        || trimmed
4541            .chars()
4542            .any(|character| matches!(character, '.' | '?' | '!' | ':'))
4543    {
4544        return None;
4545    }
4546    let lower = trimmed.to_ascii_lowercase();
4547    if lower.contains('\u{2191}') || lower.contains('\u{2193}') || lower.contains("navigate") {
4548        return None;
4549    }
4550    let (selected, rest) = match trimmed.strip_prefix(GATE_CURSOR_GLYPHS) {
4551        Some(stripped) => (true, stripped.trim()),
4552        None => (false, trimmed),
4553    };
4554    if rest.is_empty() || !rest.chars().any(|character| character.is_alphabetic()) {
4555        return None;
4556    }
4557    let semantics = classify_operator_gate_option_semantics(rest);
4558    if !selected && semantics == OperatorGateOptionSemantics::Unknown {
4559        return None;
4560    }
4561    Some(OperatorGateOption {
4562        semantics,
4563        text: rest.to_owned(),
4564        selected,
4565    })
4566}
4567
4568/// Infers what choosing a given on-screen option does from the verb in its
4569/// OWN text -- never from its position or number, since neither is stable
4570/// across CLIs or screen wraps. Decline phrasing is checked before accept
4571/// phrasing so `"Continue without trusting"` (contains both "continue" and
4572/// "without trusting") reads as `Decline`, matching what the option
4573/// actually does; an option whose text uses none of these verbs classifies
4574/// `Unknown` rather than a guessed default.
4575fn classify_operator_gate_option_semantics(text: &str) -> OperatorGateOptionSemantics {
4576    let lower = text.to_ascii_lowercase();
4577    if lower.contains("review") {
4578        OperatorGateOptionSemantics::Inspect
4579    } else if lower.contains("don't trust")
4580        || lower.contains("do not trust")
4581        || lower.contains("without trusting")
4582        || lower.contains("decline")
4583    {
4584        OperatorGateOptionSemantics::Decline
4585    } else if lower.contains("trust") || lower.contains("continue") || lower.contains("proceed") {
4586        OperatorGateOptionSemantics::Accept
4587    } else if lower.contains("exit") || lower.contains("quit") || lower.contains("cancel") {
4588        OperatorGateOptionSemantics::Exit
4589    } else {
4590        OperatorGateOptionSemantics::Unknown
4591    }
4592}
4593
4594/// True if `line` ends in a bare shell-prompt character -- used only to
4595/// raise confidence on the one `screen_failure` marker
4596/// (`"no such file or directory"`) that is otherwise too generic to trust
4597/// alone; see that bucket's own comment.
4598fn looks_like_shell_prompt_line(line: &str) -> bool {
4599    matches!(line.trim_end().chars().last(), Some('$' | '%' | '#' | '>'))
4600}
4601
4602/// Screens where the foreground process already matches the agent (this is
4603/// NOT the `NotAgent` process-mismatch case) but the screen itself shows
4604/// the process came up wrong or fell over. Nothing typed into THIS screen
4605/// fixes it, which is the line that separates `Failing` from
4606/// `OperatorGate`: a gate is resolved by typing an answer into the pane, a
4607/// failure is not.
4608///
4609/// Every marker here is a crash SHAPE -- a stack-trace banner, a shell's
4610/// own "command not found" line -- and as raw bytes a crash shape is
4611/// indistinguishable from an agent CHOOSING to render that same text while
4612/// explaining, or running, someone else's failure (`cargo test` hitting a
4613/// failing assertion, a script the agent ran that threw, a bug report it
4614/// was asked to read). Text alone cannot tell "the CLI crashed" from "the
4615/// CLI is showing you a crash" -- both put identical bytes on screen. The
4616/// one window where these markers ARE trustworthy is before this
4617/// generation's session has ever reached `Ready`: with no prior render of
4618/// the agent's own UI, there is nothing else on the screen that could have
4619/// produced a crash banner, so it genuinely means the launch failed. That
4620/// is why this function's caller in `collect_terminal_frames` stops
4621/// calling it at all once `Ready`, rather than trying to make the matching
4622/// itself tell healthy narration apart from a real crash -- no marker set
4623/// can do that from bytes alone.
4624fn screen_failure(contents: &str) -> Option<&'static str> {
4625    let normalized = normalize_screen_text(contents);
4626
4627    // A language runtime's own uncaught-exception banner -- fixed strings
4628    // a runtime prints verbatim at the head of a crash dump, not phrasing
4629    // a chat transcript would casually reproduce in this exact shape.
4630    // `"fatal error:"` is deliberately NOT included: it is also what a
4631    // C/C++ compiler prints for an ordinary missing-header build error, and
4632    // a wrapper script's build step can emit that while the real CLI still
4633    // comes up fine afterwards -- untrustworthy even in the startup window
4634    // without a second signal this function does not have.
4635    if ["panicked at", "unhandled exception", "traceback (most recent call last)"]
4636        .iter()
4637        .any(|marker| normalized.contains(marker))
4638    {
4639        return Some("crash");
4640    }
4641
4642    // A shell reporting that the launch command itself does not exist.
4643    // The first two are OS/shell-owned sentence shapes on their own. The
4644    // third, `"no such file or directory"`, is common enough in ordinary
4645    // ENOENT discussion that it needs a companion signal -- a line that
4646    // itself ends in a bare shell prompt -- before it counts.
4647    if normalized.contains("command not found")
4648        || normalized.contains("is not recognized as an internal or external command")
4649        || (normalized.contains("no such file or directory")
4650            && contents.lines().any(looks_like_shell_prompt_line))
4651    {
4652        return Some("missing command");
4653    }
4654
4655    // A provider CLI's own missing-credential banner at launch -- Grok with
4656    // no key configured prints exactly this one line and never renders
4657    // anything else again, which makes it a crash SHAPE in the same sense
4658    // as the buckets above: nothing typed into this screen fixes it, only a
4659    // restart with a credential configured. Required to co-occur with the
4660    // literal `GROK_API_KEY` env var name rather than matching "api key
4661    // required" alone -- that phrase in the abstract is common enough in an
4662    // agent's own prose (explaining a DIFFERENT provider's setup, say) to be
4663    // untrustworthy alone; the exact env var name is specific to this one
4664    // banner.
4665    if normalized.contains("api key required") && normalized.contains("grok_api_key") {
4666        return Some("missing api key");
4667    }
4668
4669    None
4670}
4671
4672/// The window gate on `screen_failure`, factored into its own pure
4673/// function so the "stop trusting crash markers once this generation has
4674/// ever been `Ready`" rule is unit-testable without constructing a live
4675/// `OwnedPtySession` (which owns a real PTY and cannot be built in a unit
4676/// test). `collect_terminal_frames` calls this, not `screen_failure`
4677/// directly.
4678fn screen_failure_for_generation(contents: &str, ever_reached_ready: bool) -> Option<&'static str> {
4679    if ever_reached_ready {
4680        None
4681    } else {
4682        screen_failure(contents)
4683    }
4684}
4685
4686/// The foreground half of the classification, resolved by the caller so
4687/// the merge itself stays a pure function.
4688#[derive(Clone, Debug, Eq, PartialEq)]
4689enum ForegroundVerdict {
4690    /// Foreground is the agent's own binary, or a tolerated wrapper.
4691    Agent,
4692    /// Foreground is something else. Carries what was actually seen.
4693    Foreign { process: String },
4694}
4695
4696/// Merge the foreground and text signals into one screen classification.
4697///
4698/// The ORDER below, not the branches, is the part worth reading closely:
4699///
4700/// 1. A foreign foreground wins outright, even over a matching gate or
4701///    failure text pattern, because the process signal is structurally
4702///    exhaustive in the one way that matters -- it never needs to
4703///    recognize an updater's specific wording to say "this is not the
4704///    agent". A vendor wrapper whose text this module's matchers do not
4705///    yet know about still gets caught here.
4706/// 2. With no foreign foreground, `Failing` outranks `OperatorGate`: a
4707///    crashed screen can still have a leftover gate prompt sitting in
4708///    view above the crash dump, and "broken" is the more urgent of the
4709///    two truths for an operator to hear -- reporting "waiting for input"
4710///    about a session that has actually fallen over sends someone to type
4711///    into a pane that cannot use it.
4712/// 3. `OperatorGate` next, for the "resolvable by typing" reason
4713///    documented on `PtyScreenState` itself.
4714/// 4. With no foreground observation at all, the answer is `Unknown`, not
4715///    `Ready` -- a clean text read is NEVER, by itself, proof the screen
4716///    is the agent's. `Ready` requires the foreground to have been
4717///    checked AND to have matched, per the asymmetry documented on
4718///    `PtyScreenState::Ready`.
4719/// 5. Only once foreground is confirmed AND no gate/failure text matched
4720///    does the merge land on `Ready`.
4721fn classify_pty_screen_state(
4722    foreground: Option<&ForegroundVerdict>,
4723    gate: Option<&OperatorGateState>,
4724    failure: Option<&'static str>,
4725) -> PtyScreenState {
4726    if let Some(ForegroundVerdict::Foreign { process }) = foreground {
4727        return PtyScreenState::NotAgent {
4728            observed_process: process.clone(),
4729        };
4730    }
4731    if let Some(reason) = failure {
4732        return PtyScreenState::Failing {
4733            reason: reason.to_owned(),
4734        };
4735    }
4736    if let Some(gate) = gate {
4737        return PtyScreenState::OperatorGate { gate: gate.clone() };
4738    }
4739    if foreground.is_some() {
4740        PtyScreenState::Ready
4741    } else {
4742        PtyScreenState::Unknown
4743    }
4744}
4745
4746/// Resolve what an OS process-tree observation means for classification,
4747/// reusing the exact matchers `ReadinessTracker::observe_foreground` trusts
4748/// at startup so this module's tolerance for a spawning wrapper
4749/// (`node`/`python`/`python3`) never drifts from readiness's own -- a false
4750/// `NotAgent` on a legitimate wrapper would be a regression on what
4751/// readiness already tolerates.
4752///
4753/// Deliberately looser than `ReadinessTracker::observe_foreground` in one
4754/// respect: it does not gate the wrapper tolerance behind
4755/// `has_child_processes`/a poll-count threshold. Those exist there to
4756/// decide "confident enough to flip session status to Running" from a
4757/// process signal ALONE. Here the text signal still has to independently
4758/// agree before the merge can reach `Ready` (see `classify_pty_screen_state`),
4759/// so being more permissive about which process counts as plausible never
4760/// lets a bad screen through on the process signal by itself.
4761fn resolve_foreground_verdict(
4762    spec: &AgentSpec,
4763    observation: &PtyForegroundObservation,
4764    platform: RuntimePlatform,
4765) -> ForegroundVerdict {
4766    let process_name = observation
4767        .readiness
4768        .process_name
4769        .as_deref()
4770        .unwrap_or(observation.observed_process.as_str());
4771    if is_expected_agent_process(spec, process_name, platform)
4772        || is_agent_foreground_wrapper(process_name, platform)
4773    {
4774        ForegroundVerdict::Agent
4775    } else {
4776        ForegroundVerdict::Foreign {
4777            process: observation.observed_process.clone(),
4778        }
4779    }
4780}
4781
4782/// Whether `NativeEffectShell::reclassify_foreground` should schedule
4783/// another probe after a fresh classification, factored out as a small
4784/// pure function so the decision is testable without constructing a live
4785/// `OwnedPtySession` (which owns a real PTY and cannot be built in a unit
4786/// test).
4787#[derive(Clone, Copy, Debug, Eq, PartialEq)]
4788enum ForegroundProbeSchedule {
4789    /// The session reached `Ready` -- stop probing until text disagrees.
4790    Disarmed,
4791    /// Still unresolved, or freshly non-ready -- probe again after the interval.
4792    Armed,
4793}
4794
4795fn foreground_probe_schedule(state: &PtyScreenState) -> ForegroundProbeSchedule {
4796    if matches!(state, PtyScreenState::Ready) {
4797        ForegroundProbeSchedule::Disarmed
4798    } else {
4799        ForegroundProbeSchedule::Armed
4800    }
4801}
4802
4803/// Whether a text-only reclassification that just changed the merged state
4804/// (inside `collect_terminal_frames`) should force the foreground probe due
4805/// immediately, rather than waiting for its normal cadence. Factored out
4806/// for the same testability reason as `foreground_probe_schedule`: a
4807/// session that was `Ready` and whose text just started matching a gate or
4808/// failure pattern needs its foreground re-checked promptly, since the
4809/// last thing anyone probed was clean and the screen no longer agrees.
4810fn foreground_probe_rearms_immediately(previous: &PtyScreenState, next: &PtyScreenState) -> bool {
4811    matches!(previous, PtyScreenState::Ready) && !matches!(next, PtyScreenState::Ready)
4812}
4813
4814fn observe_readiness_event(
4815    tracker: &mut ReadinessTracker<'_>,
4816    diagnostics: &mut ReadinessDiagnostics,
4817    event: PtyEvent,
4818    elapsed_ms: u64,
4819) -> Result<(), String> {
4820    match event {
4821        PtyEvent::Output(data) => {
4822            diagnostics.observe_output(&data);
4823            tracker.observe_output(&data, elapsed_ms);
4824        }
4825        PtyEvent::ForegroundProcess(observation) => {
4826            diagnostics.observe_foreground(&observation.readiness);
4827            tracker.observe_foreground(&observation.readiness, elapsed_ms);
4828        }
4829        PtyEvent::DataGap { .. } => {
4830            return Err("PTY replay gap prevents positive readiness proof".to_owned());
4831        }
4832        PtyEvent::ReaderError { message } | PtyEvent::OperatorActionRequired { message } => {
4833            return Err(message);
4834        }
4835        PtyEvent::Exited { code } => {
4836            return Err(format!("PTY exited with code {code} before readiness"));
4837        }
4838        PtyEvent::Started | PtyEvent::Resized(_) | PtyEvent::SnapshotAvailable { .. } => {}
4839    }
4840    Ok(())
4841}
4842
4843#[derive(Default)]
4844struct ReadinessDiagnostics {
4845    draft_signal: Option<gate4agent_types::DraftReadySignal>,
4846    output_bytes: usize,
4847    output_chunks: usize,
4848    tail: Vec<u8>,
4849    saw_bracketed_paste: bool,
4850    saw_cursor_show: bool,
4851    saw_cursor_hide: bool,
4852    saw_alternate_screen: bool,
4853    saw_clear_screen: bool,
4854    saw_claude_composer: bool,
4855    saw_codex_composer: bool,
4856    saw_named_foreground: bool,
4857    operator_gate: Option<OperatorGateState>,
4858}
4859
4860impl ReadinessDiagnostics {
4861    fn observe_output(&mut self, data: &[u8]) {
4862        const SIGNAL_TAIL_BYTES: usize = 4_096;
4863        self.output_bytes = self.output_bytes.saturating_add(data.len());
4864        self.output_chunks = self.output_chunks.saturating_add(1);
4865        let mut combined = Vec::with_capacity(self.tail.len().saturating_add(data.len()));
4866        combined.extend_from_slice(&self.tail);
4867        combined.extend_from_slice(data);
4868        self.saw_bracketed_paste |= readiness_bytes_contain(&combined, b"\x1b[?2004h");
4869        self.saw_cursor_show |= readiness_bytes_contain(&combined, b"\x1b[?25h");
4870        self.saw_cursor_hide |= readiness_bytes_contain(&combined, b"\x1b[?25l");
4871        self.saw_alternate_screen |= readiness_bytes_contain(&combined, b"\x1b[?1049h");
4872        self.saw_clear_screen |= readiness_bytes_contain(&combined, b"\x1b[2J");
4873        self.saw_claude_composer |= readiness_bytes_contain(&combined, "❯".as_bytes());
4874        self.saw_codex_composer |= readiness_bytes_contain(&combined, "›".as_bytes());
4875        let text = String::from_utf8_lossy(&combined);
4876        self.operator_gate = self
4877            .operator_gate
4878            .take()
4879            .or_else(|| startup_operator_gate(&strip_ansi_codes(&text)));
4880        self.tail = combined[combined.len().saturating_sub(SIGNAL_TAIL_BYTES)..].to_vec();
4881    }
4882
4883    fn observe_foreground(&mut self, foreground: &gate4agent::agent::ForegroundObservation) {
4884        self.saw_named_foreground |= foreground.process_name.is_some();
4885    }
4886
4887    fn summary(&self) -> String {
4888        format!(
4889            "draft_signal={:?} output_bytes={} output_chunks={} named_foreground={} bracketed_paste={} cursor_show={} cursor_hide={} alternate_screen={} clear_screen={} claude_composer={} codex_composer={} csi={}",
4890            self.draft_signal,
4891            self.output_bytes,
4892            self.output_chunks,
4893            self.saw_named_foreground,
4894            self.saw_bracketed_paste,
4895            self.saw_cursor_show,
4896            self.saw_cursor_hide,
4897            self.saw_alternate_screen,
4898            self.saw_clear_screen,
4899            self.saw_claude_composer,
4900            self.saw_codex_composer,
4901            readiness_csi_signatures(&self.tail),
4902        )
4903    }
4904}
4905
4906fn readiness_csi_signatures(bytes: &[u8]) -> String {
4907    let mut signatures = Vec::new();
4908    let mut index = 0;
4909    while index + 2 < bytes.len() && signatures.len() < 32 {
4910        if bytes[index] != 0x1b || bytes[index + 1] != b'[' {
4911            index += 1;
4912            continue;
4913        }
4914        let mut end = index + 2;
4915        while end < bytes.len() && end.saturating_sub(index) <= 24 {
4916            let byte = bytes[end];
4917            if (0x40..=0x7e).contains(&byte) {
4918                let signature = String::from_utf8_lossy(&bytes[index + 2..=end]).into_owned();
4919                if !signatures.iter().any(|existing| existing == &signature) {
4920                    signatures.push(signature);
4921                }
4922                index = end;
4923                break;
4924            }
4925            if !(0x20..=0x3f).contains(&byte) {
4926                break;
4927            }
4928            end += 1;
4929        }
4930        index += 1;
4931    }
4932    if signatures.is_empty() {
4933        "none".to_owned()
4934    } else {
4935        signatures.join("|")
4936    }
4937}
4938
4939fn readiness_bytes_contain(haystack: &[u8], needle: &[u8]) -> bool {
4940    haystack
4941        .windows(needle.len())
4942        .any(|window| window == needle)
4943}
4944
4945fn readiness_complete(
4946    status: ReadinessStatus,
4947    diagnostics: &ReadinessDiagnostics,
4948) -> Result<bool, String> {
4949    match status {
4950        ReadinessStatus::Waiting => Ok(false),
4951        ReadinessStatus::Ready(_) => Ok(true),
4952        ReadinessStatus::TimedOut => Err(format!(
4953            "PTY readiness timed out ({})",
4954            diagnostics.summary()
4955        )),
4956    }
4957}
4958
4959fn elapsed_ms(started: Instant) -> u64 {
4960    u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX)
4961}
4962
4963#[cfg(test)]
4964mod tests {
4965    use super::{
4966        acp_approval_level_args,
4967        approval_level_not_offered_message,
4968        classify_operator_gate_option_semantics,
4969        classify_pty_screen_state,
4970        defers_permission_requests,
4971        foreground_probe_rearms_immediately, foreground_probe_schedule,
4972        argument_looks_like_credential, host_policy_for_approval_level,
4973        mcp_server_acp_entry,
4974        parse_operator_gate_options, prepare_fresh_pty_provider_session,
4975        prompt_render_probe, prompt_rendered, required_acp_mode,
4976        reserve_provider_gap_sequence, resolve_foreground_verdict, screen_failure,
4977        screen_failure_for_generation, with_pty_terminal_capability_defaults,
4978        should_attach_pty_provider_stream, should_probe_pty_identity, startup_operator_gate,
4979        terminal_frame, terminal_frame_byte_len, terminal_state_capture_should_skip,
4980        validate_instance_launch_arguments, validate_spawn_runtime_policy,
4981        ForegroundProbeSchedule, ForegroundVerdict, ReadinessDiagnostics, RateLimitFeed,
4982        Utf8ChunkDecoder,
4983    };
4984    use gate4agent::acp::protocol::{McpServerConfig, SessionMode};
4985    use gate4agent::HostPolicy;
4986    use gate4agent_adapters::builtin_adapter_registry;
4987    use gate4agent_catalog::{EnvMutation, McpServerSpec, ModeId};
4988    use gate4agent::agent::ForegroundObservation;
4989    use gate4agent::core::types::{
4990        AgentEvent, ContextWindowUsage as AgentContextWindowUsage, HostDecisionAuthority,
4991        HostRequestDecision, HostRequestOutcome, RateLimitType,
4992    };
4993    use gate4agent::pty::event::PtyMouseProtocolEncoding;
4994    use gate4agent::pty::{PtyForegroundObservation, PtyForegroundSource, RateLimitDetector};
4995    use gate4agent::CliTool;
4996    use gate4agent_types::{
4997        AdapterFamily, AgentId, ApprovalLevel,
4998        HostDecisionAuthority as ProviderHostDecisionAuthority,
4999        HostRequestDecision as ProviderHostRequestDecision,
5000        HostRequestOutcome as ProviderHostRequestOutcome, OperatorGateInput,
5001        OperatorGateKind, OperatorGateOptionSemantics, OperatorGateState, OperatorGateSubject,
5002        ProviderEvent, ProviderInteractionKind, ProviderInteractionOption, ProviderRuntimePolicy,
5003        PtyScreenState, RuntimePlatform, TerminalMouseProtocolEncoding, TransportKind,
5004    };
5005    use std::ffi::{OsStr, OsString};
5006
5007    fn snapshot(sequence: u64, contents: &str) -> super::PtyTerminalSnapshot {
5008        super::PtyTerminalSnapshot {
5009            pty_id: "fixture".to_owned(),
5010            provider_revision: "fixture-r1".to_owned(),
5011            generation: 1,
5012            sequence,
5013            size: gate4agent::pty::PtySize { rows: 24, cols: 80 },
5014            cursor: (0, 0),
5015            bracketed_paste: false,
5016            contents: contents.to_owned(),
5017            formatted: Vec::new(),
5018            scrollback_formatted: Vec::new(),
5019            alternate_screen: false,
5020            mouse_protocol_enabled: false,
5021            mouse_protocol_encoding: PtyMouseProtocolEncoding::Default,
5022            produced_at_unix_ms: 0,
5023        }
5024    }
5025
5026    #[test]
5027    fn terminal_frame_preserves_scrollback_and_terminal_input_metadata() {
5028        let mut snapshot = snapshot(7, "visible");
5029        snapshot.scrollback_formatted = vec![b"older".to_vec()];
5030        snapshot.alternate_screen = true;
5031        snapshot.mouse_protocol_enabled = true;
5032        snapshot.mouse_protocol_encoding = PtyMouseProtocolEncoding::Sgr;
5033        snapshot.produced_at_unix_ms = 1_700_000_000_000;
5034
5035        let frame = terminal_frame(snapshot, PtyScreenState::default());
5036        assert_eq!(frame.scrollback_formatted, vec![b"older".to_vec()]);
5037        assert!(frame.alternate_screen);
5038        assert!(frame.mouse_protocol_enabled);
5039        assert_eq!(frame.mouse_protocol_encoding, TerminalMouseProtocolEncoding::Sgr);
5040        // `terminal_frame` must carry the stamp through, never recompute it.
5041        assert_eq!(frame.produced_at_unix_ms, 1_700_000_000_000);
5042    }
5043
5044    #[test]
5045    fn terminal_frame_byte_len_sums_formatted_and_every_scrollback_row() {
5046        let mut snapshot = snapshot(9, "visible");
5047        snapshot.formatted = b"\x1b[2Jvisible".to_vec();
5048        snapshot.scrollback_formatted =
5049            vec![b"row one".to_vec(), b"row two, longer".to_vec()];
5050        let expected = snapshot.formatted.len()
5051            + snapshot.scrollback_formatted[0].len()
5052            + snapshot.scrollback_formatted[1].len();
5053
5054        let frame = terminal_frame(snapshot, PtyScreenState::default());
5055        assert_eq!(terminal_frame_byte_len(&frame), expected as u64);
5056    }
5057
5058    #[test]
5059    fn the_sequence_gate_skips_only_when_the_sequence_did_not_advance() {
5060        assert!(terminal_state_capture_should_skip(Ok::<u64, ()>(5), 5));
5061        assert!(terminal_state_capture_should_skip(Ok::<u64, ()>(4), 5));
5062        assert!(!terminal_state_capture_should_skip(Ok::<u64, ()>(6), 5));
5063        // A failed cheap read never skips -- the real call is attempted so
5064        // its own error path (stale-published bookkeeping) still runs.
5065        assert!(!terminal_state_capture_should_skip(Err::<u64, ()>(()), 5));
5066    }
5067
5068    #[test]
5069    fn structured_context_usage_maps_without_pty_inference() {
5070        let mapped = super::provider_event(
5071            AgentEvent::ContextWindowUsage {
5072                usage: AgentContextWindowUsage {
5073                    uncached_input_tokens: 70,
5074                    cache_read_tokens: 20,
5075                    cache_write_tokens: 0,
5076                    output_tokens: 10,
5077                    unattributed_tokens: 5,
5078                    used_tokens: 105,
5079                    capacity_tokens: 100,
5080                },
5081            },
5082            &[],
5083        );
5084        assert_eq!(
5085            mapped,
5086            Some(ProviderEvent::ContextWindowUsage {
5087                usage: gate4agent_types::ContextWindowUsage {
5088                    uncached_input_tokens: 70,
5089                    cache_read_tokens: 20,
5090                    cache_write_tokens: 0,
5091                    output_tokens: 10,
5092                    unattributed_tokens: 5,
5093                    used_tokens: 105,
5094                    capacity_tokens: 100,
5095                }
5096            })
5097        );
5098        assert_eq!(
5099            ProviderEvent::ContextWindowUsage {
5100                usage: gate4agent_types::ContextWindowUsage {
5101                    uncached_input_tokens: 70,
5102                    cache_read_tokens: 20,
5103                    cache_write_tokens: 0,
5104                    output_tokens: 10,
5105                    unattributed_tokens: 5,
5106                    used_tokens: 104,
5107                    capacity_tokens: 100,
5108                },
5109            }
5110            .validate_ingress(),
5111            Err(gate4agent_types::ProviderEventValidationError::ContextWindowSegmentsMismatch {
5112                segment_sum: 105,
5113                used_tokens: 104,
5114            })
5115        );
5116        assert!(
5117            super::provider_event(AgentEvent::PtyRaw { data: b"105/100".to_vec() }, &[]).is_none()
5118        );
5119    }
5120
5121    /// `AgentEvent::TurnInterrupted` (synthesized by `acp::session::
5122    /// AcpSession::start_prompt` when a `session/prompt` call ends without a
5123    /// success response) must reach `ProviderEvent::TurnInterrupted` --
5124    /// `gate4agent-engine`'s snapshot reducer is the one place that resets
5125    /// `ProviderActivity` away from `Blocked` back to `Idle` for it, closing
5126    /// the stuck-forever "turn in flight" bug measured live against
5127    /// codex-acp 1.10.0 (a `session/prompt` RPC error left every subsequent
5128    /// prompt refused for the rest of the session).
5129    #[test]
5130    fn turn_interrupted_maps_to_the_provider_event_that_unblocks_the_next_prompt() {
5131        let mapped = super::provider_event(
5132            AgentEvent::TurnInterrupted {
5133                reason: "session/prompt timed out after 120s".to_owned(),
5134            },
5135            &[],
5136        );
5137        assert_eq!(mapped, Some(ProviderEvent::TurnInterrupted));
5138    }
5139
5140    #[test]
5141    fn tool_result_non_execution_kind_carries_through_to_tool_completed() {
5142        let mapped = super::provider_event(
5143            AgentEvent::ToolResult {
5144                id: "t1".to_owned(),
5145                output: "denied".to_owned(),
5146                is_error: true,
5147                duration_ms: None,
5148                non_execution_kind: Some("permission-rule".to_owned()),
5149            },
5150            &[],
5151        );
5152        assert_eq!(
5153            mapped,
5154            Some(ProviderEvent::ToolCompleted {
5155                id: "t1".to_owned(),
5156                output: "denied".to_owned(),
5157                is_error: true,
5158                duration_ms: None,
5159                agent_id: None,
5160                non_execution_kind: Some("permission-rule".to_owned()),
5161            })
5162        );
5163    }
5164
5165    #[test]
5166    fn session_end_stop_reason_carries_through_to_session_ended() {
5167        use gate4agent::StopReason;
5168
5169        let refusal = super::provider_event(
5170            AgentEvent::SessionEnd {
5171                result: "refusal".to_owned(),
5172                cost_usd: None,
5173                is_error: true,
5174                stop_reason: Some(StopReason::Refusal),
5175            },
5176            &[],
5177        );
5178        assert_eq!(
5179            refusal,
5180            Some(ProviderEvent::SessionEnded {
5181                result: "refusal".to_owned(),
5182                cost_usd: None,
5183                is_error: true,
5184                stop_reason: Some(gate4agent_types::ProviderStopReason::Refusal),
5185            })
5186        );
5187
5188        let quota = super::provider_event(
5189            AgentEvent::SessionEnd {
5190                result: "JSON-RPC error -32603: Internal error".to_owned(),
5191                cost_usd: None,
5192                is_error: true,
5193                stop_reason: Some(StopReason::ProviderError {
5194                    code: -32603,
5195                    message: "You've hit your usage limit.".to_owned(),
5196                    vendor_code: Some("usageLimitExceeded".to_owned()),
5197                }),
5198            },
5199            &[],
5200        );
5201        assert_eq!(
5202            quota,
5203            Some(ProviderEvent::SessionEnded {
5204                result: "JSON-RPC error -32603: Internal error".to_owned(),
5205                cost_usd: None,
5206                is_error: true,
5207                stop_reason: Some(gate4agent_types::ProviderStopReason::ProviderError {
5208                    code: -32603,
5209                    message: "You've hit your usage limit.".to_owned(),
5210                    vendor_code: Some("usageLimitExceeded".to_owned()),
5211                }),
5212            })
5213        );
5214    }
5215
5216    #[test]
5217    fn host_request_reaches_the_operator_with_method_and_host_decision() {
5218        let denied = super::provider_event(
5219            AgentEvent::RpcIncomingRequest {
5220                id: gate4agent::rpc::message::RpcId::Number(1),
5221                method: "fs/read_text_file".to_owned(),
5222                params: None,
5223                decision: HostRequestDecision::Denied { by: HostDecisionAuthority::Policy },
5224                outcome: HostRequestOutcome::Executed,
5225                reason: None,
5226            },
5227            &[],
5228        );
5229        assert_eq!(
5230            denied,
5231            Some(ProviderEvent::HostRequestObserved {
5232                method: "fs/read_text_file".to_owned(),
5233                params_json: String::new(),
5234                decision: ProviderHostRequestDecision::Denied {
5235                    by: ProviderHostDecisionAuthority::Policy
5236                },
5237                outcome: ProviderHostRequestOutcome::Executed,
5238                reason: None,
5239            })
5240        );
5241
5242        let granted = super::provider_event(
5243            AgentEvent::RpcIncomingRequest {
5244                id: gate4agent::rpc::message::RpcId::Number(2),
5245                method: "terminal/create".to_owned(),
5246                params: None,
5247                decision: HostRequestDecision::Granted { by: HostDecisionAuthority::Policy },
5248                outcome: HostRequestOutcome::Executed,
5249                reason: None,
5250            },
5251            &[],
5252        );
5253        assert_eq!(
5254            granted,
5255            Some(ProviderEvent::HostRequestObserved {
5256                method: "terminal/create".to_owned(),
5257                params_json: String::new(),
5258                decision: ProviderHostRequestDecision::Granted {
5259                    by: ProviderHostDecisionAuthority::Policy
5260                },
5261                outcome: ProviderHostRequestOutcome::Executed,
5262                reason: None,
5263            })
5264        );
5265
5266        // Every authority carries through, not just the outcome -- a gate
5267        // block and an operator's answer must remain distinguishable on the
5268        // wire, and a deadline expiry must never be mistaken for one.
5269        let gate_denied = super::provider_event(
5270            AgentEvent::RpcIncomingRequest {
5271                id: gate4agent::rpc::message::RpcId::Number(3),
5272                method: "terminal/create".to_owned(),
5273                params: None,
5274                decision: HostRequestDecision::Denied { by: HostDecisionAuthority::Gate },
5275                outcome: HostRequestOutcome::Executed,
5276                reason: None,
5277            },
5278            &[],
5279        );
5280        assert_eq!(
5281            gate_denied,
5282            Some(ProviderEvent::HostRequestObserved {
5283                method: "terminal/create".to_owned(),
5284                params_json: String::new(),
5285                decision: ProviderHostRequestDecision::Denied {
5286                    by: ProviderHostDecisionAuthority::Gate
5287                },
5288                outcome: ProviderHostRequestOutcome::Executed,
5289                reason: None,
5290            })
5291        );
5292
5293        let operator_granted = super::provider_event(
5294            AgentEvent::RpcIncomingRequest {
5295                id: gate4agent::rpc::message::RpcId::Number(4),
5296                method: "session/request_permission".to_owned(),
5297                params: None,
5298                decision: HostRequestDecision::Granted { by: HostDecisionAuthority::Operator },
5299                outcome: HostRequestOutcome::Executed,
5300                reason: None,
5301            },
5302            &[],
5303        );
5304        assert_eq!(
5305            operator_granted,
5306            Some(ProviderEvent::HostRequestObserved {
5307                method: "session/request_permission".to_owned(),
5308                params_json: String::new(),
5309                decision: ProviderHostRequestDecision::Granted {
5310                    by: ProviderHostDecisionAuthority::Operator
5311                },
5312                outcome: ProviderHostRequestOutcome::Executed,
5313                reason: None,
5314            })
5315        );
5316
5317        let deadline_denied = super::provider_event(
5318            AgentEvent::RpcIncomingRequest {
5319                id: gate4agent::rpc::message::RpcId::Number(5),
5320                method: "session/request_permission".to_owned(),
5321                params: None,
5322                decision: HostRequestDecision::Denied { by: HostDecisionAuthority::DeadlinePolicy },
5323                outcome: HostRequestOutcome::Executed,
5324                reason: None,
5325            },
5326            &[],
5327        );
5328        assert_eq!(
5329            deadline_denied,
5330            Some(ProviderEvent::HostRequestObserved {
5331                method: "session/request_permission".to_owned(),
5332                params_json: String::new(),
5333                decision: ProviderHostRequestDecision::Denied {
5334                    by: ProviderHostDecisionAuthority::DeadlinePolicy
5335                },
5336                outcome: ProviderHostRequestOutcome::Executed,
5337                reason: None,
5338            })
5339        );
5340
5341        // `session/request_permission` + `Deferred` is deliberately absent
5342        // here -- that combination becomes an `InteractionRequested`, not a
5343        // `HostRequestObserved`; see
5344        // `deferred_acp_permission_request_becomes_an_interaction_with_its_id`
5345        // below.
5346    }
5347
5348    /// K1c's split: an authorized (`Granted`) host request that failed
5349    /// WHILE EXECUTING (e.g. a `terminal/create` spawn error) must carry
5350    /// `HostRequestOutcome::Failed` through to `ProviderEvent::
5351    /// HostRequestObserved` verbatim -- never dropped, and never collapsed
5352    /// into `Denied`: the request WAS authorized, only its execution
5353    /// failed.
5354    #[test]
5355    fn granted_host_request_execution_failure_carries_through_as_a_failed_outcome() {
5356        let spawn_error = "terminal/create spawn failed: os error 3";
5357        let mapped = super::provider_event(
5358            AgentEvent::RpcIncomingRequest {
5359                id: gate4agent::rpc::message::RpcId::Number(10),
5360                method: "terminal/create".to_owned(),
5361                params: None,
5362                decision: HostRequestDecision::Granted { by: HostDecisionAuthority::Policy },
5363                outcome: HostRequestOutcome::Failed { error: spawn_error.to_owned() },
5364                reason: None,
5365            },
5366            &[],
5367        );
5368        assert_eq!(
5369            mapped,
5370            Some(ProviderEvent::HostRequestObserved {
5371                method: "terminal/create".to_owned(),
5372                params_json: String::new(),
5373                decision: ProviderHostRequestDecision::Granted {
5374                    by: ProviderHostDecisionAuthority::Policy
5375                },
5376                outcome: ProviderHostRequestOutcome::Failed { error: spawn_error.to_owned() },
5377                reason: None,
5378            })
5379        );
5380        mapped.expect("mapped").validate_ingress().expect("bounded free-text error validates");
5381    }
5382
5383    /// The bug this change fixes: the gate's own refusal text used to be
5384    /// computed, then thrown away -- an observer got the categorical
5385    /// `Denied { by: Gate }` and nothing naming WHY. `reason` now rides the
5386    /// SAME `AgentEvent::RpcIncomingRequest` → `ProviderEvent::
5387    /// HostRequestObserved` mapping every other field already used,
5388    /// verbatim, bounded by `validate_ingress`.
5389    #[test]
5390    fn host_request_reason_carries_through_to_the_operator() {
5391        let gate_text =
5392            "blocked by dangerous-command gate: rule=filesystem-wipe, argument=rm -rf /";
5393        let denied = super::provider_event(
5394            AgentEvent::RpcIncomingRequest {
5395                id: gate4agent::rpc::message::RpcId::Number(9),
5396                method: "terminal/create".to_owned(),
5397                params: None,
5398                decision: HostRequestDecision::Denied { by: HostDecisionAuthority::Gate },
5399                outcome: HostRequestOutcome::Executed,
5400                reason: Some(gate_text.to_owned()),
5401            },
5402            &[],
5403        );
5404        assert_eq!(
5405            denied,
5406            Some(ProviderEvent::HostRequestObserved {
5407                method: "terminal/create".to_owned(),
5408                params_json: String::new(),
5409                decision: ProviderHostRequestDecision::Denied {
5410                    by: ProviderHostDecisionAuthority::Gate
5411                },
5412                outcome: ProviderHostRequestOutcome::Executed,
5413                reason: Some(gate_text.to_owned()),
5414            })
5415        );
5416
5417        // `validate_ingress` accepts the reason as ordinary bounded free
5418        // text -- the same bound `ToolCompleted::output` uses, proving this
5419        // is not smuggled through the categorical `Error::detail` field the
5420        // owner's incident named (that validator only accepts `a-z0-9-`).
5421        denied.expect("mapped").validate_ingress().expect("bounded free-text reason validates");
5422    }
5423
5424    #[test]
5425    fn deferred_acp_permission_request_becomes_an_interaction_with_its_id() {
5426        let with_title = super::provider_event(
5427            AgentEvent::RpcIncomingRequest {
5428                id: gate4agent::rpc::message::RpcId::Number(6),
5429                method: "session/request_permission".to_owned(),
5430                params: Some(serde_json::json!({
5431                    "sessionId": "s1",
5432                    "toolCall": {"toolCallId": "t1", "kind": "edit", "title": "Edit src/main.rs"},
5433                    "options": [
5434                        {"optionId": "allow", "name": "Allow", "kind": "allow_once"},
5435                        {"optionId": "reject", "name": "Reject", "kind": "reject_once"},
5436                    ],
5437                })),
5438                decision: HostRequestDecision::Deferred,
5439                outcome: HostRequestOutcome::Executed,
5440                reason: None,
5441            },
5442            &[],
5443        );
5444        // `kind` is the tool CLASS and `title` is the question. They are two
5445        // different things and the operator needs both: putting the title in
5446        // `tool_name` and leaving `prompt` empty -- which this asserted
5447        // before -- showed a correlation id and a sentence-shaped tool name,
5448        // and never the question itself. `title`/`options` now carry the
5449        // agent's own values instead of arriving empty -- the operator's
5450        // approve/deny answer must resolve against an option list they can
5451        // actually see.
5452        assert_eq!(
5453            with_title,
5454            Some(ProviderEvent::InteractionRequested {
5455                request_id: Some("number:6".to_owned()),
5456                interaction_kind: ProviderInteractionKind::Approval,
5457                tool_name: "edit".to_owned(),
5458                title: Some("Edit src/main.rs".to_owned()),
5459                prompt: "Edit src/main.rs".to_owned(),
5460                options: vec![
5461                    ProviderInteractionOption {
5462                        option_id: "allow".to_owned(),
5463                        name: "Allow".to_owned(),
5464                        kind: "allow_once".to_owned(),
5465                    },
5466                    ProviderInteractionOption {
5467                        option_id: "reject".to_owned(),
5468                        name: "Reject".to_owned(),
5469                        kind: "reject_once".to_owned(),
5470                    },
5471                ],
5472                agent_id: None,
5473            })
5474        );
5475
5476        // No `kind` on the wire (or no params at all) -- falls back to the
5477        // method name rather than an empty `tool_name`, which
5478        // `ProviderEvent::validate_ingress` rejects. An absent `title`
5479        // leaves `prompt` empty rather than inventing a question, and no
5480        // params at all means no option list to read, not a dropped one.
5481        let without_title = super::provider_event(
5482            AgentEvent::RpcIncomingRequest {
5483                id: gate4agent::rpc::message::RpcId::String("agent-7".to_owned()),
5484                method: "session/request_permission".to_owned(),
5485                params: None,
5486                decision: HostRequestDecision::Deferred,
5487                outcome: HostRequestOutcome::Executed,
5488                reason: None,
5489            },
5490            &[],
5491        );
5492        assert_eq!(
5493            without_title,
5494            Some(ProviderEvent::InteractionRequested {
5495                request_id: Some("string:agent-7".to_owned()),
5496                interaction_kind: ProviderInteractionKind::Approval,
5497                tool_name: "session/request_permission".to_owned(),
5498                title: None,
5499                prompt: String::new(),
5500                options: Vec::new(),
5501                agent_id: None,
5502            })
5503        );
5504
5505        // The SAME id's eventual outcome (not itself `Deferred` anymore)
5506        // still reaches the operator as the ordinary audit trail, exactly
5507        // as every other host request does.
5508        let resolved = super::provider_event(
5509            AgentEvent::RpcIncomingRequest {
5510                id: gate4agent::rpc::message::RpcId::Number(6),
5511                method: "session/request_permission".to_owned(),
5512                params: None,
5513                decision: HostRequestDecision::Denied { by: HostDecisionAuthority::Operator },
5514                outcome: HostRequestOutcome::Executed,
5515                reason: None,
5516            },
5517            &[],
5518        );
5519        assert_eq!(
5520            resolved,
5521            Some(ProviderEvent::HostRequestObserved {
5522                method: "session/request_permission".to_owned(),
5523                params_json: String::new(),
5524                decision: ProviderHostRequestDecision::Denied {
5525                    by: ProviderHostDecisionAuthority::Operator
5526                },
5527                outcome: ProviderHostRequestOutcome::Executed,
5528                reason: None,
5529            })
5530        );
5531    }
5532
5533    #[test]
5534    fn unrecognized_notification_reaches_the_operator_as_a_raw_event_instead_of_vanishing() {
5535        let mapped = super::provider_event(
5536            AgentEvent::RpcNotification {
5537                method: "session/update".to_owned(),
5538                params: Default::default(),
5539            },
5540            &[],
5541        );
5542        assert_eq!(
5543            mapped,
5544            Some(ProviderEvent::UnrecognizedNotification {
5545                method: "session/update".to_owned(),
5546                payload_json: "null".to_owned(),
5547            })
5548        );
5549    }
5550
5551    #[test]
5552    fn pty_terminal_capability_defaults_never_override_a_caller_supplied_term() {
5553        let caller_supplied = vec![EnvMutation {
5554            key: OsString::from("TERM"),
5555            value: Some(OsString::from("dumb")),
5556        }];
5557        let filled = with_pty_terminal_capability_defaults(caller_supplied);
5558
5559        let term_values: Vec<_> = filled
5560            .iter()
5561            .filter(|mutation| mutation.key.as_os_str() == OsStr::new("TERM"))
5562            .collect();
5563        assert_eq!(term_values.len(), 1, "TERM must not be duplicated");
5564        assert_eq!(term_values[0].value.as_deref(), Some(OsStr::new("dumb")));
5565
5566        let colorterm = filled
5567            .iter()
5568            .find(|mutation| mutation.key.as_os_str() == OsStr::new("COLORTERM"))
5569            .expect("COLORTERM default is filled in when the caller left it unset");
5570        assert_eq!(colorterm.value.as_deref(), Some(OsStr::new("truecolor")));
5571    }
5572
5573    // -----------------------------------------------------------------------
5574    // mcp_server_acp_entry — the ACP half of the door
5575    // (gate4agent-arc-mailbox-and-task-layer Slice A(ii))
5576    // -----------------------------------------------------------------------
5577
5578    #[test]
5579    fn mcp_server_acp_entry_translates_a_spec_into_one_stdio_entry() {
5580        let spec = McpServerSpec::new(
5581            "test-mcp-server",
5582            OsString::from("C:\\example\\example-mcp-server.exe"),
5583            vec![OsString::from("--session-proxy")],
5584            vec![
5585                (
5586                    OsString::from("TEST_MCP_SESSION_ENDPOINT"),
5587                    OsString::from("\\\\.\\pipe\\example-mcp-server-s1"),
5588                ),
5589                (
5590                    OsString::from("TEST_MCP_SESSION_TOKEN"),
5591                    OsString::from("tok-abc"),
5592                ),
5593            ],
5594        )
5595        .unwrap();
5596
5597        let server = mcp_server_acp_entry(&spec);
5598        match server {
5599            McpServerConfig::Stdio { name, command, args, env } => {
5600                assert_eq!(name, "test-mcp-server", "ACP v1 requires a name; the adapter registers by it");
5601                assert_eq!(command, "C:\\example\\example-mcp-server.exe");
5602                assert_eq!(args, vec!["--session-proxy".to_string()]);
5603                assert_eq!(env.len(), 2, "exactly the entries the spec was given, nothing else");
5604                assert_eq!(
5605                    env.iter().find(|pair| pair.name == "TEST_MCP_SESSION_ENDPOINT").map(|pair| pair.value.as_str()),
5606                    Some("\\\\.\\pipe\\example-mcp-server-s1")
5607                );
5608                assert_eq!(
5609                    env.iter().find(|pair| pair.name == "TEST_MCP_SESSION_TOKEN").map(|pair| pair.value.as_str()),
5610                    Some("tok-abc")
5611                );
5612            }
5613            McpServerConfig::Sse { .. } => panic!("expected Stdio variant"),
5614        }
5615    }
5616
5617    #[test]
5618    fn mcp_server_acp_entry_carries_zero_env_pairs_when_the_spec_carries_none() {
5619        let spec = McpServerSpec::new(
5620            "test-mcp-server",
5621            OsString::from("C:\\example\\example-mcp-server.exe"),
5622            vec![OsString::from("--session-proxy")],
5623            Vec::new(),
5624        )
5625        .unwrap();
5626
5627        let server = mcp_server_acp_entry(&spec);
5628        match server {
5629            McpServerConfig::Stdio { env, .. } => assert!(env.is_empty()),
5630            McpServerConfig::Sse { .. } => panic!("expected Stdio variant"),
5631        }
5632    }
5633
5634    #[test]
5635    fn credential_shape_rule_matches_prefixed_hex64_not_bare_digest_or_uuid() {
5636        let hex64 = "a1b2c3d4e5f6".repeat(5) + "a1b2";
5637        assert_eq!(hex64.len(), 64);
5638
5639        let prefixed_token = format!("g4aho_{hex64}");
5640        assert!(
5641            argument_looks_like_credential(&prefixed_token),
5642            "a short lowercase prefix plus '_' plus 64 lowercase hex chars is a credential"
5643        );
5644
5645        assert!(
5646            !argument_looks_like_credential(&hex64),
5647            "a bare 64-hex digest with no prefix is a legitimate diagnostic, not a credential"
5648        );
5649
5650        assert!(
5651            !argument_looks_like_credential("550e8400-e29b-41d4-a716-446655440000"),
5652            "a UUID must not be mistaken for a prefixed-hex64 credential"
5653        );
5654    }
5655
5656    #[test]
5657    fn native_instance_launch_arguments_fail_closed_before_shell_spawn() {
5658        let claude = AgentId::new("claude").unwrap();
5659        assert_eq!(
5660            validate_instance_launch_arguments(
5661                &claude,
5662                TransportKind::Pipe,
5663                &[OsString::from("--bundle-mode")],
5664            )
5665            .unwrap_err(),
5666            "native instance launch arguments require PTY transport"
5667        );
5668        assert_eq!(
5669            validate_instance_launch_arguments(
5670                &claude,
5671                TransportKind::Pty,
5672                &[OsString::from("--resume=session-secret")],
5673            )
5674            .unwrap_err(),
5675            "native instance launch arguments conflict with Claude session, resume, or prompt authority"
5676        );
5677        assert!(validate_instance_launch_arguments(
5678            &claude,
5679            TransportKind::Pty,
5680            &[
5681                OsString::from("--permission-mode"),
5682                OsString::from("default"),
5683            ],
5684        )
5685        .is_ok());
5686    }
5687
5688    /// Classifies `contents` and returns just the `kind` -- the same check
5689    /// every case below cares about, without repeating `.map(|gate|
5690    /// gate.kind)` at every call site.
5691    fn kind_of(contents: &str) -> Option<OperatorGateKind> {
5692        startup_operator_gate(contents).map(|gate| gate.kind)
5693    }
5694
5695    #[test]
5696    fn startup_operator_gates_are_classified_without_returning_terminal_text() {
5697        assert_eq!(kind_of(" Trust this\nfolder? "), Some(OperatorGateKind::WorkspaceTrust));
5698        assert_eq!(
5699            kind_of("No auth type is selected"),
5700            Some(OperatorGateKind::Authentication)
5701        );
5702        assert_eq!(
5703            kind_of("Sign in with OpenAI to use Codex"),
5704            Some(OperatorGateKind::Authentication)
5705        );
5706        // Claude's login-method chooser and its OAuth code-paste wait
5707        // screen -- both previously misread as `Ready` because neither
5708        // contains "openai"/"chatgpt"/"codex", so the branch above never
5709        // caught them. Verbatim shape from `terminal-read` on a clean
5710        // macOS arm64 stand with no vendor login.
5711        assert_eq!(
5712            kind_of(
5713                "Claude Code can be used with your Claude subscription or billed based on \
5714                 API usage through your Console account.\n\
5715                 Select login method:\n\
5716                 \u{276f} 1. Claude ...\n\
5717                 2. API usage billing\n\
5718                 3. 3rd-party platform \u{b7} Amazon Bedrock ..."
5719            ),
5720            Some(OperatorGateKind::Authentication)
5721        );
5722        assert_eq!(
5723            kind_of(
5724                "Browser didn't open? Use the url below to sign in (c to copy)\n\
5725                 https://claude.com/oauth/authorize?client_id=abc&redirect_uri=https%3A%2F%2Fconsole.anthropic.com&code_challenge=xyz\n\
5726                 Paste code here"
5727            ),
5728            Some(OperatorGateKind::Authentication)
5729        );
5730        // Ordinary narration reusing "sign in" and "paste" separately, but
5731        // never as the screen's own "paste code" field label, must not
5732        // false-positive -- the same companion-word discipline the
5733        // OpenAI/Codex sign-in branch already applies above.
5734        assert_eq!(
5735            kind_of(
5736                "Once you sign in, copy the generated token and paste it into your .env file; \
5737                 no code entry happens on this screen."
5738            ),
5739            None
5740        );
5741        assert_eq!(
5742            kind_of("Kimi Code Update Available\nInstall update now (0.32.0)\nEnter confirm"),
5743            Some(OperatorGateKind::VendorUpdate)
5744        );
5745        assert_eq!(
5746            kind_of(
5747                "Welcome to Claude Code\nChoose the text style that looks best with your terminal"
5748            ),
5749            Some(OperatorGateKind::TerminalAppearance)
5750        );
5751        assert_eq!(
5752            kind_of(
5753                "Welcome to Claude Code for VS Code\nClaude has context of open files and selected lines"
5754            ),
5755            Some(OperatorGateKind::Onboarding)
5756        );
5757        assert_eq!(
5758            kind_of("Welcome to Claude Code\n❯ Press Enter to continue"),
5759            Some(OperatorGateKind::Onboarding)
5760        );
5761        assert_eq!(kind_of("Welcome to Claude Code\n❯ ready\nEnter to send"), None);
5762        assert_eq!(
5763            kind_of(
5764                "Quick safety check: Is this a project you trust?\nYes, I trust this folder\nNo, continue without these permissions"
5765            ),
5766            Some(OperatorGateKind::WorkspaceTrust)
5767        );
5768        assert_eq!(kind_of("ready for a prompt"), None);
5769        // Regression test for the incident this module fixes: a provider
5770        // CLI's wrapper self-updated via npm before the real agent ever
5771        // launched, and every consumer saw `status: running` on a live PTY
5772        // that was actually showing this text. Verbatim transcript.
5773        assert_eq!(
5774            kind_of(
5775                "Updating Codex via `npm install -g @openai/codex`...\n\
5776                 npm warn cleanup Failed to remove some directories\n\
5777                 Update ran successfully! Please restart Codex."
5778            ),
5779            Some(OperatorGateKind::VendorUpdate)
5780        );
5781        // Regression test for the live-stand incident this branch fixes:
5782        // Codex's startup hook-trust prompt was classified `Ready` because
5783        // no marker recognized it, so a prompt injected into the PTY landed
5784        // in this blocking select-list instead of the agent. Verbatim
5785        // transcript from `terminal-read`, 40x120 -- note this capture wraps
5786        // the `› 1. Review hooks` marker onto the end of the preceding
5787        // sentence, so it is deliberately NOT the clean list shape the
5788        // dedicated option-parsing tests below assert on; this test only
5789        // pins the `kind`, same as it always has.
5790        assert_eq!(
5791            kind_of(
5792                "  Hooks need review\n\
5793                   6 hooks are new or changed.\n\
5794                   Hooks can run outside the sandbox after you trust them.\u{203a} 1. Review hooks\n\
5795                   2. Trust all and continue\n\
5796                   3. Continue without trusting (hooks won't run)  Press enter to confirm or esc to go back"
5797            ),
5798            Some(OperatorGateKind::HookTrust)
5799        );
5800        // Same screen, different hook count -- the match must not depend on
5801        // the number.
5802        assert_eq!(
5803            kind_of(
5804                "  Hooks need review\n\
5805                   42 hooks are new or changed.\n\
5806                   Hooks can run outside the sandbox after you trust them.\u{203a} 1. Review hooks\n\
5807                   2. Trust all and continue\n\
5808                   3. Continue without trusting (hooks won't run)  Press enter to confirm or esc to go back"
5809            ),
5810            Some(OperatorGateKind::HookTrust)
5811        );
5812        // Ordinary agent narration that happens to mention hooks and trust
5813        // must NOT be classified as this gate -- it lacks the screen's own
5814        // "continue without trusting" refusal phrasing.
5815        assert_eq!(
5816            kind_of(
5817                "I reviewed the pre-commit hooks in this repo and they look safe to trust; \
5818                 I'll leave the hooks config as-is and continue with the refactor."
5819            ),
5820            None
5821        );
5822    }
5823
5824    /// Codex's hook-trust prompt rendered as a clean numbered list (the
5825    /// shape actually described by the task this parser was written for,
5826    /// as opposed to the line-wrapped capture pinned by `kind` alone
5827    /// above): `parse_operator_gate_options` reads all three choices, marks
5828    /// the `›`-prefixed one selected, and infers accept/inspect/decline
5829    /// from each option's own verb -- never from its number or position.
5830    #[test]
5831    fn startup_operator_gate_parses_a_numbered_hook_trust_option_list() {
5832        let contents = "Hooks need review\n\
5833             \u{203a} 1. Review hooks\n  \
5834             2. Trust all and continue\n  \
5835             3. Continue without trusting (hooks won't run)\n\
5836             Press enter to confirm or esc to go back";
5837        let gate = startup_operator_gate(contents).expect("hook trust must classify");
5838        assert_eq!(gate.kind, OperatorGateKind::HookTrust);
5839        assert_eq!(gate.input, OperatorGateInput::NumberedList);
5840        assert_eq!(
5841            gate.options,
5842            vec![
5843                gate4agent_types::OperatorGateOption {
5844                    text: "Review hooks".to_owned(),
5845                    semantics: OperatorGateOptionSemantics::Inspect,
5846                    selected: true,
5847                },
5848                gate4agent_types::OperatorGateOption {
5849                    text: "Trust all and continue".to_owned(),
5850                    semantics: OperatorGateOptionSemantics::Accept,
5851                    selected: false,
5852                },
5853                gate4agent_types::OperatorGateOption {
5854                    text: "Continue without trusting (hooks won't run)".to_owned(),
5855                    semantics: OperatorGateOptionSemantics::Decline,
5856                    selected: false,
5857                },
5858            ],
5859        );
5860    }
5861
5862    /// Kimi's workspace-trust prompt rendered as an arrow-navigated list
5863    /// with a description line under each title: `parse_operator_gate_options`
5864    /// reads the two TITLES ("Trust this folder", "Don't trust") as options,
5865    /// marks the `❯`-prefixed one selected, and does NOT mistake either
5866    /// description line (both full sentences, ending in `.`) for a third
5867    /// and fourth option.
5868    #[test]
5869    fn startup_operator_gate_parses_an_arrow_workspace_trust_option_list() {
5870        let contents = "Do you trust the files in this folder?\n  \
5871             Trust this folder\n  \
5872             Enable project MCP servers. Remembered for this folder.\n\u{276f} \
5873             Don't trust\n  \
5874             Exit Kimi Code. Asked again next launch.\n  \
5875             \u{2191}\u{2193} navigate \u{b7} Enter select \u{b7} Esc exit";
5876        let gate = startup_operator_gate(contents).expect("workspace trust must classify");
5877        assert_eq!(gate.kind, OperatorGateKind::WorkspaceTrust);
5878        assert_eq!(gate.input, OperatorGateInput::ArrowList);
5879        assert_eq!(
5880            gate.options,
5881            vec![
5882                gate4agent_types::OperatorGateOption {
5883                    text: "Trust this folder".to_owned(),
5884                    semantics: OperatorGateOptionSemantics::Accept,
5885                    selected: false,
5886                },
5887                gate4agent_types::OperatorGateOption {
5888                    text: "Don't trust".to_owned(),
5889                    semantics: OperatorGateOptionSemantics::Decline,
5890                    selected: true,
5891                },
5892            ],
5893        );
5894    }
5895
5896    /// A matched gate whose screen carries no recognized option list (every
5897    /// other `kind` this module classifies today) must NOT invent one --
5898    /// `input` stays `Unknown` and `options` stays empty, never a guess.
5899    #[test]
5900    fn startup_operator_gate_without_a_recognized_option_list_reports_unknown_input() {
5901        let gate = startup_operator_gate("No auth type is selected")
5902            .expect("authentication must classify");
5903        assert_eq!(gate.kind, OperatorGateKind::Authentication);
5904        assert_eq!(gate.input, OperatorGateInput::Unknown);
5905        assert!(gate.options.is_empty());
5906    }
5907
5908    /// Claude's login-method chooser renders its selected row with a `❯`
5909    /// cursor glyph where Codex draws `›`, and both sit AHEAD of the number rather
5910    /// than ahead of the text. `parse_numbered_gate_option_line` knew only
5911    /// Codex's glyph, so the cursor row failed to parse as an option at
5912    /// all: the list came back one item short AND with nothing marked
5913    /// selected -- observed live on this screen and on Codex's own login
5914    /// chooser. Both glyphs are read now, so all three options are present
5915    /// and the cursor row carries `selected`.
5916    #[test]
5917    fn startup_operator_gate_parses_claudes_login_method_chooser_including_its_cursor_row() {
5918        let contents = "Select login method:\n\
5919             \u{276f} 1. Claude ...\n\
5920             2. API usage billing\n\
5921             3. 3rd-party platform \u{b7} Amazon Bedrock ...";
5922        let gate = startup_operator_gate(contents).expect("authentication must classify");
5923        assert_eq!(gate.kind, OperatorGateKind::Authentication);
5924        assert_eq!(gate.subject, OperatorGateSubject::Account);
5925        assert_eq!(gate.input, OperatorGateInput::NumberedList);
5926        assert_eq!(
5927            gate.options,
5928            vec![
5929                gate4agent_types::OperatorGateOption {
5930                    text: "Claude ...".to_owned(),
5931                    semantics: OperatorGateOptionSemantics::Unknown,
5932                    selected: true,
5933                },
5934                gate4agent_types::OperatorGateOption {
5935                    text: "API usage billing".to_owned(),
5936                    semantics: OperatorGateOptionSemantics::Unknown,
5937                    selected: false,
5938                },
5939                gate4agent_types::OperatorGateOption {
5940                    text: "3rd-party platform \u{b7} Amazon Bedrock ...".to_owned(),
5941                    semantics: OperatorGateOptionSemantics::Unknown,
5942                    selected: false,
5943                },
5944            ],
5945        );
5946    }
5947
5948    /// Claude's OAuth wait screen has no option list at all -- a URL to
5949    /// open and one free-text slot to paste the resulting code back into --
5950    /// so `input` is `TextEntry` with no options, never routed through the
5951    /// numbered/arrow option-list parser.
5952    #[test]
5953    fn startup_operator_gate_oauth_wait_screen_reports_text_entry_input() {
5954        let contents = "Browser didn't open? Use the url below to sign in (c to copy)\n\
5955             https://claude.com/oauth/authorize?client_id=abc&redirect_uri=https%3A%2F%2Fconsole.anthropic.com&code_challenge=xyz\n\
5956             Paste code here";
5957        let gate = startup_operator_gate(contents).expect("oauth wait screen must classify");
5958        assert_eq!(gate.kind, OperatorGateKind::Authentication);
5959        assert_eq!(gate.subject, OperatorGateSubject::Account);
5960        assert_eq!(gate.input, OperatorGateInput::TextEntry);
5961        assert!(gate.options.is_empty());
5962    }
5963
5964    /// `parse_operator_gate_options` is the parser both dedicated tests
5965    /// above exercise indirectly through `startup_operator_gate`; this pins
5966    /// it directly against the same two shapes so a regression in the
5967    /// standalone parser is caught even if some future `kind` branch stops
5968    /// calling it through `operator_gate`.
5969    #[test]
5970    fn parse_operator_gate_options_recognizes_numbered_and_arrow_shapes_and_nothing_else() {
5971        assert_eq!(
5972            parse_operator_gate_options("Press enter to confirm or esc to go back"),
5973            (OperatorGateInput::Unknown, Vec::new()),
5974        );
5975        let (input, options) = parse_operator_gate_options(
5976            "\u{203a} 1. Review hooks\n  2. Trust all and continue",
5977        );
5978        assert_eq!(input, OperatorGateInput::NumberedList);
5979        assert_eq!(options.len(), 2);
5980        let (input, options) = parse_operator_gate_options(
5981            "  Trust this folder\n  Enable project MCP servers. Remembered for this folder.\n\u{276f} Don't trust",
5982        );
5983        assert_eq!(input, OperatorGateInput::ArrowList);
5984        assert_eq!(options.len(), 2);
5985    }
5986
5987    #[test]
5988    fn classify_operator_gate_option_semantics_reads_the_verb_not_the_position() {
5989        assert_eq!(
5990            classify_operator_gate_option_semantics("Review hooks"),
5991            OperatorGateOptionSemantics::Inspect,
5992        );
5993        assert_eq!(
5994            classify_operator_gate_option_semantics("Trust all and continue"),
5995            OperatorGateOptionSemantics::Accept,
5996        );
5997        assert_eq!(
5998            classify_operator_gate_option_semantics("Continue without trusting (hooks won't run)"),
5999            OperatorGateOptionSemantics::Decline,
6000        );
6001        assert_eq!(
6002            classify_operator_gate_option_semantics("Don't trust"),
6003            OperatorGateOptionSemantics::Decline,
6004        );
6005        assert_eq!(
6006            classify_operator_gate_option_semantics("Exit Kimi Code"),
6007            OperatorGateOptionSemantics::Exit,
6008        );
6009        assert_eq!(
6010            classify_operator_gate_option_semantics("Something unrecognized"),
6011            OperatorGateOptionSemantics::Unknown,
6012        );
6013    }
6014
6015    #[test]
6016    fn screen_failure_recognizes_crash_and_missing_command_banners() {
6017        assert_eq!(
6018            screen_failure("thread 'main' panicked at 'index out of bounds', src/main.rs:12:5"),
6019            Some("crash")
6020        );
6021        assert_eq!(
6022            screen_failure("Traceback (most recent call last):\n  File \"a.py\", line 1"),
6023            Some("crash")
6024        );
6025        assert_eq!(
6026            screen_failure("bash: fooagent: command not found"),
6027            Some("missing command")
6028        );
6029        assert_eq!(
6030            screen_failure("C:\\workspace> fooagent\n'fooagent' is not recognized as an internal or external command"),
6031            Some("missing command")
6032        );
6033        assert_eq!(
6034            screen_failure(
6035                "workspace/project $ fooagent: no such file or directory\nworkspace/project $"
6036            ),
6037            Some("missing command")
6038        );
6039        // Bare ENOENT prose with no trailing shell-prompt line is exactly
6040        // the generic case this bucket must NOT fire on alone.
6041        assert_eq!(
6042            screen_failure("The build log mentions no such file or directory near line 40."),
6043            None
6044        );
6045        // `"fatal error:"` was deliberately dropped: a build step inside a
6046        // wrapper script can print this while the real CLI still comes up
6047        // fine, so it is not a crash SHAPE this function trusts at all.
6048        assert_eq!(
6049            screen_failure("fooagent-installer: fatal error: missing header <stdio.h>"),
6050            None
6051        );
6052    }
6053
6054    #[test]
6055    fn screen_failure_recognizes_grok_missing_api_key_banner() {
6056        // Verbatim transcript: Grok launched with no credential configured
6057        // prints exactly this one line and never renders anything else --
6058        // no gate to answer, no crash trace, just a dead PTY a blind
6059        // `Ready` write would silently swallow.
6060        assert_eq!(
6061            screen_failure(
6062                "\u{274c} Error: API key required. Set GROK_API_KEY environment variable, \
6063                 use --api-key flag, or set \"apiKey\" field in ~/.grok/user-settings.json"
6064            ),
6065            Some("missing api key")
6066        );
6067        // Generic prose about needing an API key, with no vendor-specific
6068        // env var name, must not false-positive -- an agent explaining a
6069        // DIFFERENT provider's setup routinely says exactly this.
6070        assert_eq!(
6071            screen_failure(
6072                "You'll need an API key for this provider before it works; check the docs \
6073                 for how to configure one."
6074            ),
6075            None
6076        );
6077    }
6078
6079    #[test]
6080    fn screen_failure_no_longer_has_an_authentication_expired_bucket() {
6081        // Two common words matched anywhere on one 80x24 screen is not a
6082        // signal -- an agent's own prose explaining an auth bug ("the
6083        // token has expired") used to flip a healthy session to `Failing`.
6084        // The bucket is gone entirely, not narrowed.
6085        assert_eq!(
6086            screen_failure("Your session token has expired. Please re-authenticate."),
6087            None
6088        );
6089        assert_eq!(
6090            screen_failure("The stored credential was revoked by the workspace admin."),
6091            None
6092        );
6093    }
6094
6095    #[test]
6096    fn screen_failure_does_not_false_positive_on_an_agent_narrating_an_error() {
6097        // The false-positive direction matters more than coverage here: an
6098        // agent CLI renders arbitrary text back at a human, including logs
6099        // and error messages it was asked to explain. None of the phrases
6100        // below are in the exact failure SHAPE this module matches on.
6101        let narration = "I looked at the traceback you pasted -- it's a Python \
6102             exception, a plain ValueError from a retry loop, not a real crash. \
6103             Our harness logs the word fatal in its own banner for visibility, \
6104             but nothing actually panicked. The command definitely exists; it \
6105             just needed different flags, and your token is still valid.";
6106        assert_eq!(screen_failure(narration), None);
6107    }
6108
6109    #[test]
6110    fn screen_failure_for_generation_ignores_every_marker_once_the_session_was_ever_ready() {
6111        // Each of these is a screen from a HEALTHY, already-`Ready` session
6112        // whose own routine work happens to render a crash-shaped string.
6113        // This is the regression this whole gate exists to close: before
6114        // it, every one of these flipped a working session to `Failing`.
6115        let shell_command_not_found = "$ frobnicate --help\nbash: frobnicate: command not found\n$";
6116        assert_eq!(
6117            screen_failure_for_generation(shell_command_not_found, true),
6118            None
6119        );
6120
6121        let python_traceback_from_a_ran_script = "$ python broken.py\n\
6122             Traceback (most recent call last):\n  File \"broken.py\", line 3, in <module>\n\
6123             ValueError: bad input\n$";
6124        assert_eq!(
6125            screen_failure_for_generation(python_traceback_from_a_ran_script, true),
6126            None
6127        );
6128
6129        let cargo_test_panic = "running 1 test\n\
6130             error[E0308]: mismatched types\n\
6131             thread 'tests::it_fails' panicked at 'assertion failed', src/lib.rs:9:5\n\
6132             test result: FAILED. 0 passed; 1 failed";
6133        assert_eq!(screen_failure_for_generation(cargo_test_panic, true), None);
6134
6135        let agent_narrating_an_expired_token = "The API token has expired; \
6136             I revoked the old credential and issued a new one for you.";
6137        assert_eq!(
6138            screen_failure_for_generation(agent_narrating_an_expired_token, true),
6139            None
6140        );
6141
6142        // Pin the narrowing from the other side too: the exact same
6143        // screens are still real evidence of a broken LAUNCH inside the
6144        // startup window, before this generation has ever rendered its
6145        // own UI.
6146        assert_eq!(
6147            screen_failure_for_generation(shell_command_not_found, false),
6148            Some("missing command")
6149        );
6150        assert_eq!(
6151            screen_failure_for_generation(python_traceback_from_a_ran_script, false),
6152            Some("crash")
6153        );
6154        assert_eq!(screen_failure_for_generation(cargo_test_panic, false), Some("crash"));
6155    }
6156
6157    #[test]
6158    fn classify_pty_screen_state_lets_a_foreign_process_win_over_a_matching_gate_text() {
6159        // The process signal outranks the text signal outright: it does not
6160        // need to recognize an updater's specific wording to know the
6161        // screen is not the agent's, so it wins even when the text ALSO
6162        // happens to match a known gate pattern.
6163        let foreign = ForegroundVerdict::Foreign {
6164            process: "npm".to_owned(),
6165        };
6166        assert_eq!(
6167            classify_pty_screen_state(Some(&foreign), None, None),
6168            PtyScreenState::NotAgent {
6169                observed_process: "npm".to_owned()
6170            }
6171        );
6172        let vendor_update_gate = OperatorGateState::new(OperatorGateKind::VendorUpdate);
6173        assert_eq!(
6174            classify_pty_screen_state(Some(&foreign), Some(&vendor_update_gate), None),
6175            PtyScreenState::NotAgent {
6176                observed_process: "npm".to_owned()
6177            }
6178        );
6179    }
6180
6181    #[test]
6182    fn classify_pty_screen_state_reports_a_gate_only_when_foreground_matches() {
6183        let authentication_gate = OperatorGateState::new(OperatorGateKind::Authentication);
6184        assert_eq!(
6185            classify_pty_screen_state(Some(&ForegroundVerdict::Agent), Some(&authentication_gate), None),
6186            PtyScreenState::OperatorGate {
6187                gate: authentication_gate.clone()
6188            }
6189        );
6190    }
6191
6192    #[test]
6193    fn classify_pty_screen_state_reports_failing_for_a_matched_foreground() {
6194        assert_eq!(
6195            classify_pty_screen_state(Some(&ForegroundVerdict::Agent), None, Some("crash")),
6196            PtyScreenState::Failing {
6197                reason: "crash".to_owned()
6198            }
6199        );
6200    }
6201
6202    #[test]
6203    fn classify_pty_screen_state_prefers_failing_over_a_co_occurring_gate() {
6204        // Pins the precedence: a crashed screen can still carry a leftover
6205        // gate prompt above the crash dump, and `Failing` is the more
6206        // urgent of the two truths.
6207        let authentication_gate = OperatorGateState::new(OperatorGateKind::Authentication);
6208        assert_eq!(
6209            classify_pty_screen_state(
6210                Some(&ForegroundVerdict::Agent),
6211                Some(&authentication_gate),
6212                Some("crash")
6213            ),
6214            PtyScreenState::Failing {
6215                reason: "crash".to_owned()
6216            }
6217        );
6218    }
6219
6220    #[test]
6221    fn classify_pty_screen_state_reaches_ready_only_with_matched_foreground_and_clean_text() {
6222        assert_eq!(
6223            classify_pty_screen_state(Some(&ForegroundVerdict::Agent), None, None),
6224            PtyScreenState::Ready
6225        );
6226    }
6227
6228    #[test]
6229    fn classify_pty_screen_state_never_reaches_ready_without_a_foreground_observation() {
6230        // The one case that must never regress: a clean text read alone is
6231        // never proof of `Ready`.
6232        assert_eq!(classify_pty_screen_state(None, None, None), PtyScreenState::Unknown);
6233    }
6234
6235    #[test]
6236    fn resolve_foreground_verdict_tolerates_a_spawning_wrapper_like_readiness_does() {
6237        // The fixture spec's `expected_processes` does not name "node", so
6238        // this only passes if the wrapper-tolerance branch (mirroring
6239        // `ReadinessTracker::observe_foreground`'s own `node`/`python`/
6240        // `python3` tolerance) is actually what resolves it -- proving the
6241        // existing wrapper tolerance is not regressed. Fed into
6242        // `classify_pty_screen_state` with clean text, this is what reaches
6243        // `Ready` rather than `NotAgent` for a legitimate spawning wrapper.
6244        let spec = gate4agent_testkit::interactive_agent_spec();
6245        let observation = PtyForegroundObservation {
6246            root_pid: 1,
6247            observed_pid: 2,
6248            observed_process: "node".to_owned(),
6249            readiness: ForegroundObservation {
6250                process_name: Some("node".to_owned()),
6251                has_child_processes: true,
6252                is_shell: false,
6253            },
6254            source: PtyForegroundSource::ProcessTree,
6255        };
6256        let verdict = resolve_foreground_verdict(&spec, &observation, RuntimePlatform::current());
6257        assert_eq!(verdict, ForegroundVerdict::Agent);
6258        assert_eq!(
6259            classify_pty_screen_state(Some(&verdict), None, None),
6260            PtyScreenState::Ready
6261        );
6262    }
6263
6264    #[test]
6265    fn foreground_probe_disarms_only_once_ready_and_rearms_on_a_fresh_gate() {
6266        assert_eq!(
6267            foreground_probe_schedule(&PtyScreenState::Ready),
6268            ForegroundProbeSchedule::Disarmed
6269        );
6270        assert_eq!(
6271            foreground_probe_schedule(&PtyScreenState::Unknown),
6272            ForegroundProbeSchedule::Armed
6273        );
6274        assert_eq!(
6275            foreground_probe_schedule(&PtyScreenState::OperatorGate {
6276                gate: OperatorGateState::new(OperatorGateKind::VendorUpdate)
6277            }),
6278            ForegroundProbeSchedule::Armed
6279        );
6280    }
6281
6282    #[test]
6283    fn text_only_gate_transition_rearms_the_probe_only_when_leaving_ready() {
6284        let gate = PtyScreenState::OperatorGate {
6285            gate: OperatorGateState::new(OperatorGateKind::VendorUpdate),
6286        };
6287        assert!(foreground_probe_rearms_immediately(
6288            &PtyScreenState::Ready,
6289            &gate
6290        ));
6291        // Was never `Ready` in the first place -- nothing to rearm early
6292        // for, the session's normal cadence already has it covered.
6293        assert!(!foreground_probe_rearms_immediately(
6294            &PtyScreenState::Unknown,
6295            &gate
6296        ));
6297    }
6298
6299    #[test]
6300    fn readiness_diagnostics_detects_an_ansi_split_gate_without_exposing_text() {
6301        let mut diagnostics = ReadinessDiagnostics::default();
6302        diagnostics.observe_output(b"\x1b[31mNo auth ");
6303        diagnostics.observe_output(b"\x1b[0mtype is selected");
6304        assert_eq!(
6305            diagnostics.operator_gate.as_ref().map(|gate| gate.kind),
6306            Some(OperatorGateKind::Authentication)
6307        );
6308        assert!(!diagnostics.summary().contains("No auth"));
6309    }
6310
6311    #[test]
6312    fn semantic_utf8_decoder_preserves_codepoints_split_across_pty_reads() {
6313        let mut decoder = Utf8ChunkDecoder::default();
6314        let bytes = "ready Привет".as_bytes();
6315        let split = bytes
6316            .windows(2)
6317            .position(|window| window[0] >= 0x80 && window[1] >= 0x80)
6318            .expect("Cyrillic text contains adjacent UTF-8 bytes")
6319            + 1;
6320        let first = decoder.push(&bytes[..split]);
6321        let second = decoder.push(&bytes[split..]);
6322        assert_eq!(format!("{first}{second}"), "ready Привет");
6323        assert!(!first.contains('\u{fffd}'));
6324        assert!(!second.contains('\u{fffd}'));
6325    }
6326
6327    #[test]
6328    fn semantic_utf8_decoder_replaces_invalid_bytes_without_stalling() {
6329        let mut decoder = Utf8ChunkDecoder::default();
6330        assert_eq!(decoder.push(b"ok\xfftail"), "ok\u{fffd}tail");
6331        assert_eq!(decoder.push(" Привет".as_bytes()), " Привет");
6332    }
6333
6334    #[test]
6335    fn fresh_claude_pty_preassigns_the_exact_vendor_session_id_argv() {
6336        let claude = builtin_adapter_registry()
6337            .binding(AdapterFamily::PtySemantic, "claude-code")
6338            .expect("Claude PTY binding");
6339        let mut args = Vec::new();
6340        let identity = prepare_fresh_pty_provider_session(Some(claude), false, true, &mut args)
6341            .expect("fresh Claude provider identity");
6342        let parsed = uuid::Uuid::parse_str(&identity.id).expect("valid Claude UUID");
6343        assert_eq!(parsed.get_version_num(), 4);
6344        assert_eq!(identity.key, gate4agent_types::ProviderSessionKey::SessionId);
6345        assert!(identity.transcript_path.is_none());
6346        assert_eq!(args, [OsString::from("--session-id"), OsString::from(identity.id)]);
6347    }
6348
6349    #[test]
6350    fn fresh_codex_and_resumed_claude_do_not_preassign_a_second_identity() {
6351        let adapters = builtin_adapter_registry();
6352        let codex = adapters
6353            .binding(AdapterFamily::PtySemantic, "codex")
6354            .expect("Codex PTY binding");
6355        let claude = adapters
6356            .binding(AdapterFamily::PtySemantic, "claude-code")
6357            .expect("Claude PTY binding");
6358        let mut codex_args = Vec::new();
6359        assert!(prepare_fresh_pty_provider_session(Some(codex), false, true, &mut codex_args)
6360            .is_none());
6361        assert!(codex_args.is_empty());
6362
6363        let mut resume_args = vec![OsString::from("--resume"), OsString::from("vendor-id")];
6364        assert!(prepare_fresh_pty_provider_session(Some(claude), true, true, &mut resume_args)
6365            .is_none());
6366        assert_eq!(
6367            resume_args,
6368            [OsString::from("--resume"), OsString::from("vendor-id")]
6369        );
6370    }
6371
6372    #[test]
6373    fn raw_pty_policy_omits_all_identity_and_semantic_startup_paths() {
6374        let adapters = builtin_adapter_registry();
6375        let claude = adapters
6376            .binding(AdapterFamily::PtySemantic, "claude-code")
6377            .expect("Claude PTY binding");
6378        let codex = adapters
6379            .binding(AdapterFamily::PtySemantic, "codex")
6380            .expect("Codex PTY binding");
6381        let kimi = adapters
6382            .binding(AdapterFamily::PtySemantic, "kimi")
6383            .expect("Kimi PTY binding");
6384        let policy = ProviderRuntimePolicy::raw_pty();
6385        let mut args = Vec::new();
6386
6387        assert!(prepare_fresh_pty_provider_session(Some(claude), false, false, &mut args)
6388            .is_none());
6389        assert!(args.is_empty());
6390        assert!(!should_probe_pty_identity(policy, Some(codex), false, "codex"));
6391        assert!(!should_probe_pty_identity(policy, Some(kimi), false, "kimi"));
6392        assert!(!should_attach_pty_provider_stream(policy));
6393    }
6394
6395    #[test]
6396    fn identity_probe_requires_structured_prompt_for_codex_and_kimi() {
6397        let adapters = builtin_adapter_registry();
6398        let codex = adapters
6399            .binding(AdapterFamily::PtySemantic, "codex")
6400            .expect("Codex PTY binding");
6401        let kimi = adapters
6402            .binding(AdapterFamily::PtySemantic, "kimi")
6403            .expect("Kimi PTY binding");
6404        let without_structured_prompt =
6405            ProviderRuntimePolicy::new(true, true, false, true, false, false)
6406                .expect("identity observation policy without structured prompt");
6407
6408        assert!(!should_probe_pty_identity(
6409            without_structured_prompt,
6410            Some(codex),
6411            false,
6412            "codex",
6413        ));
6414        assert!(!should_probe_pty_identity(
6415            without_structured_prompt,
6416            Some(kimi),
6417            false,
6418            "kimi",
6419        ));
6420
6421        let with_structured_prompt =
6422            ProviderRuntimePolicy::new(true, true, true, true, false, false)
6423                .expect("identity probe policy");
6424        assert!(should_probe_pty_identity(
6425            with_structured_prompt,
6426            Some(codex),
6427            false,
6428            "codex",
6429        ));
6430        assert!(should_probe_pty_identity(
6431            with_structured_prompt,
6432            Some(kimi),
6433            false,
6434            "kimi",
6435        ));
6436    }
6437
6438    #[test]
6439    fn runtime_policy_admits_raw_native_resume_but_denies_unverified_prompt_injection() {
6440        let raw = ProviderRuntimePolicy::raw_pty();
6441        assert!(validate_spawn_runtime_policy(raw, TransportKind::Pty, false, false).is_ok());
6442        assert!(validate_spawn_runtime_policy(raw, TransportKind::Pty, true, false)
6443            .unwrap_err()
6444            .contains("SemanticReadiness"));
6445        assert!(validate_spawn_runtime_policy(raw, TransportKind::Pty, false, true).is_ok());
6446        assert!(validate_spawn_runtime_policy(raw, TransportKind::Pty, true, true)
6447            .unwrap_err()
6448            .contains("SemanticReadiness"));
6449
6450        let resume_without_prompt =
6451            ProviderRuntimePolicy::new(true, false, false, true, true, false)
6452                .expect("identity and resume policy");
6453        assert!(validate_spawn_runtime_policy(
6454            resume_without_prompt,
6455            TransportKind::Pty,
6456            false,
6457            true,
6458        )
6459        .is_ok());
6460        assert!(validate_spawn_runtime_policy(
6461            resume_without_prompt,
6462            TransportKind::Pty,
6463            true,
6464            true,
6465        )
6466        .unwrap_err()
6467        .contains("SemanticReadiness"));
6468    }
6469
6470    #[test]
6471    fn acp_transport_bypasses_the_pty_semantic_policy_gate_pty_and_pipe_still_enforce_it() {
6472        // A provider that speaks ACP and nothing else (no raw PTY, no
6473        // verified terminal semantics) admits none of these capabilities --
6474        // exactly grok's real policy. `TransportKind::Acp` must not care:
6475        // ACP has no terminal to infer state from, so none of this policy
6476        // applies to it.
6477        let no_pty_capabilities_at_all = ProviderRuntimePolicy::new(
6478            false, false, false, false, false, false,
6479        )
6480        .expect("an all-false policy is internally valid");
6481        assert!(validate_spawn_runtime_policy(
6482            no_pty_capabilities_at_all,
6483            TransportKind::Acp,
6484            false,
6485            false,
6486        )
6487        .is_ok());
6488        // The same all-false policy is still correctly refused for Pty and
6489        // Pipe -- this fix narrows the gate to skip Acp specifically, it
6490        // does not weaken it for the transports that do need it.
6491        assert!(validate_spawn_runtime_policy(
6492            no_pty_capabilities_at_all,
6493            TransportKind::Pty,
6494            false,
6495            false,
6496        )
6497        .unwrap_err()
6498        .contains("RawPtyLifecycle"));
6499        assert!(validate_spawn_runtime_policy(
6500            no_pty_capabilities_at_all,
6501            TransportKind::Pipe,
6502            false,
6503            false,
6504        )
6505        .unwrap_err()
6506        .contains("RawPtyLifecycle"));
6507    }
6508
6509    #[test]
6510    fn prompt_render_probe_ignores_terminal_wrapping_and_uses_the_tail() {
6511        let prompt = "prefix with spaces\nand punctuation: final-render-token-1234567890";
6512        let probe = prompt_render_probe(prompt);
6513        assert!("screen prefix with spaces and punctuation final render token 1234567890"
6514            .chars()
6515            .filter(|character| character.is_alphanumeric())
6516            .flat_map(char::to_lowercase)
6517            .collect::<String>()
6518            .contains(&probe));
6519        assert!(probe.chars().count() <= 32);
6520    }
6521
6522    #[test]
6523    fn prompt_render_requires_new_sequence_and_visible_tail_evidence() {
6524        let probe = prompt_render_probe("final render token");
6525        let baseline = snapshot(7, "old composer");
6526        assert!(!prompt_rendered(
6527            &snapshot(7, "final\nrender\ntoken"),
6528            &baseline,
6529            &probe
6530        ));
6531        assert!(!prompt_rendered(
6532            &snapshot(8, "unrelated redraw"),
6533            &baseline,
6534            &probe
6535        ));
6536        assert!(prompt_rendered(
6537            &snapshot(8, "composer\nfinal\nrender\ntoken"),
6538            &baseline,
6539            &probe
6540        ));
6541        assert!(prompt_rendered(
6542            &snapshot(8, "composer [Pasted Content 4096 chars]"),
6543            &baseline,
6544            &probe
6545        ));
6546        assert!(prompt_rendered(
6547            &snapshot(8, "composer [Pasted text #1 +6 lines]"),
6548            &baseline,
6549            &probe
6550        ));
6551    }
6552
6553    #[test]
6554    fn an_existing_paste_placeholder_cannot_pass_on_an_unrelated_redraw() {
6555        let probe = prompt_render_probe("a new long prompt");
6556        assert!(!prompt_rendered(
6557            &snapshot(8, "composer [Pasted Content 4096 chars]\nunrelated redraw"),
6558            &snapshot(7, "composer [Pasted Content 4096 chars]"),
6559            &probe
6560        ));
6561        assert!(!prompt_rendered(
6562            &snapshot(8, "composer [Pasted text #1 +6 lines]\nunrelated redraw"),
6563            &snapshot(7, "composer [Pasted text #1 +6 lines]"),
6564            &probe
6565        ));
6566    }
6567
6568    #[test]
6569    fn punctuation_only_prompt_cannot_pass_on_an_unrelated_redraw() {
6570        let probe = prompt_render_probe("!?---");
6571        assert!(probe.is_empty());
6572        assert!(!prompt_rendered(
6573            &snapshot(2, "unrelated redraw"),
6574            &snapshot(1, "old composer"),
6575            &probe
6576        ));
6577    }
6578
6579    #[test]
6580    fn prompt_probe_matches_the_sanitized_terminal_payload() {
6581        let prompt = "payload\u{1b}tail";
6582        let sanitized = gate4agent_types::sanitize_prompt_text(prompt);
6583        let probe = prompt_render_probe(&sanitized);
6584        assert!(prompt_rendered(
6585            &snapshot(2, "composer payload<ESC>tail"),
6586            &snapshot(1, "old composer"),
6587            &probe
6588        ));
6589    }
6590
6591    #[test]
6592    fn lag_and_data_gap_reserve_missed_provider_source_positions() {
6593        let mut next = 7;
6594        assert_eq!(reserve_provider_gap_sequence(&mut next, 3), Some(9));
6595        assert_eq!(next, 10);
6596        assert_eq!(reserve_provider_gap_sequence(&mut next, 0), None);
6597        assert_eq!(next, 10);
6598
6599        next = u64::MAX;
6600        assert_eq!(reserve_provider_gap_sequence(&mut next, 1), None);
6601        assert_eq!(next, u64::MAX);
6602    }
6603
6604    // --- RateLimitFeed: ANSI-stripping and chunk reassembly ---
6605    //
6606    // Regression coverage for a live-stand defect: `provider.rate_limits`
6607    // used to be a bare `RateLimitDetector` fed the RAW PTY stream, escape
6608    // codes and all. codex's own `/status` render puts `\x1b[m` between
6609    // `limit:` and the progress bar, and `\x1b[2m` between `left` and
6610    // `(resets ...)` -- both inside gaps the quota-state regex's `\s*`
6611    // cannot see through -- so not one quota-state fact was ever observed
6612    // on a real run, despite the pattern being correct on clean text.
6613
6614    /// The exact live bytes from the coordinator's capture (real `\x1b[m`
6615    /// and `\x1b[2m`, real `\r\n`), transcribed verbatim.
6616    const CODEX_STATUS_RAW_ANSI: &str = "│  5h limit:             \x1b[m[████████████████████] 100% left\x1b[2m (resets 00:08 on 31 Aug) │\r\n│  Weekly limit:         \x1b[m[████████████████████] 100% left\x1b[2m (resets 19:08 on 6 Sep)  │";
6617
6618    #[test]
6619    fn bare_detector_misses_the_live_ansi_sample_that_rate_limit_feed_catches() {
6620        // This is the defect itself: matching the untouched raw stream
6621        // fails on real codex output, exactly as observed live.
6622        let bare = RateLimitDetector::new_for_tool(CliTool::Codex);
6623        assert!(bare.detect(CODEX_STATUS_RAW_ANSI).is_none());
6624
6625        // `RateLimitFeed` strips ANSI (via `VteParser`) before matching,
6626        // so the same bytes must now be recognized.
6627        let mut feed = RateLimitFeed::new_for_tool(CliTool::Codex);
6628        let info = feed
6629            .detect(CODEX_STATUS_RAW_ANSI)
6630            .expect("quota-state line must be recognized once ANSI is stripped");
6631        assert_eq!(info.limit_type, RateLimitType::Session);
6632        assert_eq!(info.usage_percent, Some(0.0));
6633        assert_eq!(info.resets_at_text.as_deref(), Some("00:08 on 31 Aug"));
6634    }
6635
6636    #[test]
6637    fn rate_limit_feed_reassembles_a_quota_state_line_split_mid_escape_and_mid_row() {
6638        // A live PTY chunk boundary has no reason to land anywhere
6639        // convenient -- here it falls INSIDE the `\x1b[2m` escape itself
6640        // (right after the ESC byte), on top of splitting the row.
6641        let first =
6642            "│  5h limit:             \x1b[m[████████████████████] 100% left\x1b";
6643        let second = "[2m (resets 00:08 on 31 Aug) │\r\n";
6644
6645        let mut feed = RateLimitFeed::new_for_tool(CliTool::Codex);
6646        assert!(
6647            feed.detect(first).is_none(),
6648            "the row is not complete yet, so nothing should match on the first chunk"
6649        );
6650        let info = feed
6651            .detect(second)
6652            .expect("the row completes once the second chunk arrives");
6653        assert_eq!(info.limit_type, RateLimitType::Session);
6654        assert_eq!(info.resets_at_text.as_deref(), Some("00:08 on 31 Aug"));
6655    }
6656
6657    #[test]
6658    fn rate_limit_feed_strips_ansi_before_matching_a_colored_failure_message() {
6659        // Failure patterns run through the same `RateLimitFeed::detect`
6660        // entry point as quota-state ones, so a vendor coloring its own
6661        // refusal text must not defeat detection either.
6662        let raw = "\x1b[31mError: rate limit exceeded\x1b[0m. Please wait.";
6663        let mut feed = RateLimitFeed::new_for_tool(CliTool::Codex);
6664        let info = feed
6665            .detect(raw)
6666            .expect("a colored refusal message must still be recognized");
6667        assert_eq!(info.limit_type, RateLimitType::Unknown);
6668    }
6669
6670    #[test]
6671    fn rate_limit_feed_bounds_its_buffer_across_a_newline_free_redraw() {
6672        // A full-screen redraw with no `\n` at all (cursor-addressing
6673        // only) must not grow the pending-line buffer without bound for
6674        // as long as it runs -- and a real line must still be caught once
6675        // it finally arrives.
6676        let mut feed = RateLimitFeed::new_for_tool(CliTool::Codex);
6677        let noise = "x".repeat(super::RATE_LIMIT_FEED_BUFFER_MAX_BYTES * 3);
6678        assert!(feed.detect(&noise).is_none());
6679        assert!(feed.buffer.len() <= super::RATE_LIMIT_FEED_BUFFER_MAX_BYTES);
6680
6681        let info = feed
6682            .detect(CODEX_STATUS_RAW_ANSI)
6683            .expect("a real quota-state line must still be recognized after the noise");
6684        assert_eq!(info.limit_type, RateLimitType::Session);
6685    }
6686
6687    // -----------------------------------------------------------------------
6688    // host_policy_for_approval_level -- ApprovalLevel -> HostPolicy for ACP
6689    // -----------------------------------------------------------------------
6690
6691    #[test]
6692    fn full_auto_maps_to_the_permissive_host_policy() {
6693        // The one combination that must never happen is `FullAuto` paired
6694        // with a refusing `HostPolicy` -- the process believes it has full
6695        // autonomy while the host silently refuses it.
6696        assert_eq!(host_policy_for_approval_level(ApprovalLevel::FullAuto), HostPolicy::Yolo);
6697    }
6698
6699    #[test]
6700    fn read_only_maps_to_the_read_only_host_policy() {
6701        assert_eq!(
6702            host_policy_for_approval_level(ApprovalLevel::ReadOnly),
6703            HostPolicy::ReadOnly
6704        );
6705    }
6706
6707    #[test]
6708    fn moderate_and_unmanaged_both_fall_back_to_the_default_host_policy() {
6709        assert_eq!(host_policy_for_approval_level(ApprovalLevel::Moderate), HostPolicy::Auto);
6710        assert_eq!(host_policy_for_approval_level(ApprovalLevel::Unmanaged), HostPolicy::Auto);
6711        assert_eq!(HostPolicy::default(), HostPolicy::Auto);
6712    }
6713
6714    #[test]
6715    fn no_approval_level_ever_maps_to_a_refusing_policy_except_read_only_itself() {
6716        // Guards the "never full auto + refusing host" invariant generally:
6717        // `Deny` must never be reachable from this mapping at all -- it is
6718        // not one of the four `ApprovalLevel` outcomes, only `ReadOnly`'s
6719        // own deliberate restriction is.
6720        for level in [
6721            ApprovalLevel::FullAuto,
6722            ApprovalLevel::Moderate,
6723            ApprovalLevel::ReadOnly,
6724            ApprovalLevel::Unmanaged,
6725        ] {
6726            assert_ne!(
6727                host_policy_for_approval_level(level),
6728                HostPolicy::Deny,
6729                "{level:?} must never map to HostPolicy::Deny"
6730            );
6731        }
6732    }
6733
6734    // -----------------------------------------------------------------------
6735    // defers_permission_requests -- reads `asks_for_permission` off the
6736    // catalog's per-(provider, level) vendor-mode table instead of matching
6737    // on the ApprovalLevel's own name (the bug this whole axis exists to
6738    // remove -- see the function's own doc comment).
6739    // -----------------------------------------------------------------------
6740
6741    #[test]
6742    fn defers_permission_requests_matches_the_verified_provider_table() {
6743        // Source of truth: `gate4agent_catalog::launch::approval_level_resolution`
6744        // at HEAD (commit 7b6a8d2) and
6745        // `docs/gate4agent/audits/gate4agent-acp-slice1-proof-2026-09-02.md`
6746        // (session/new modes measured live, 2026-09-02).
6747        let claude = AgentId::new("claude").unwrap();
6748        assert!(!defers_permission_requests(&claude, ApprovalLevel::FullAuto));
6749        assert!(defers_permission_requests(&claude, ApprovalLevel::Moderate));
6750        // claude's `ReadOnly` now launches `default` (`plan` neither refuses
6751        // nor asks over ACP, measured twice) -- claude's own interactive
6752        // mode, which does ask about every write.
6753        assert!(defers_permission_requests(&claude, ApprovalLevel::ReadOnly));
6754        // `Unmanaged` never defers: it decides `session/request_permission`
6755        // immediately (auto-approve), since some providers' ACP clients
6756        // won't wait out a parked decision.
6757        assert!(!defers_permission_requests(&claude, ApprovalLevel::Unmanaged));
6758
6759        let codex = AgentId::new("codex").unwrap();
6760        assert!(!defers_permission_requests(&codex, ApprovalLevel::FullAuto));
6761        // codex's `Moderate` ACP mode (`agent`, "Approve for me") wrote and
6762        // reported success with no `session/request_permission` at all --
6763        // measured live, unlike the PTY `on-request` flag it shares a row
6764        // with.
6765        assert!(!defers_permission_requests(&codex, ApprovalLevel::Moderate));
6766        assert!(defers_permission_requests(&codex, ApprovalLevel::ReadOnly));
6767        // `Unmanaged` never defers -- see the claude row's comment above.
6768        assert!(!defers_permission_requests(&codex, ApprovalLevel::Unmanaged));
6769
6770        let grok = AgentId::new("grok").unwrap();
6771        assert!(!defers_permission_requests(&grok, ApprovalLevel::FullAuto));
6772        assert!(defers_permission_requests(&grok, ApprovalLevel::Moderate));
6773        // `ReadOnly` is `Unsupported` for grok -- the ACP spawn branch must
6774        // already have refused the session before this function would ever
6775        // see it live; the conservative fallback here still defers.
6776        assert!(defers_permission_requests(&grok, ApprovalLevel::ReadOnly));
6777        // `Unmanaged` never defers -- see the claude row's comment above.
6778        assert!(!defers_permission_requests(&grok, ApprovalLevel::Unmanaged));
6779
6780        let kimi = AgentId::new("kimi").unwrap();
6781        assert!(!defers_permission_requests(&kimi, ApprovalLevel::FullAuto));
6782        // kimi's `Moderate` ACP mode is genuinely named `auto` and wrote
6783        // silently, measured live -- it does not ask.
6784        assert!(!defers_permission_requests(&kimi, ApprovalLevel::Moderate));
6785        // kimi's `ReadOnly` ACP mode (`plan`) refuses silently rather than
6786        // asking -- measured live, a write outside the working directory
6787        // produced no question at all.
6788        assert!(!defers_permission_requests(&kimi, ApprovalLevel::ReadOnly));
6789        // `Unmanaged` never defers -- see the claude row's comment above.
6790        assert!(!defers_permission_requests(&kimi, ApprovalLevel::Unmanaged));
6791    }
6792
6793    // -----------------------------------------------------------------------
6794    // required_acp_mode / approval_level_not_offered_message -- Item 3's
6795    // table: one row per provider x level, plus the regression guard that
6796    // `auto` is unreachable without an explicit choice.
6797    // -----------------------------------------------------------------------
6798
6799    fn session_mode(id: &str) -> SessionMode {
6800        SessionMode { id: id.to_owned(), name: id.to_owned(), description: None }
6801    }
6802
6803    #[test]
6804    fn required_acp_mode_matches_the_sourced_table() {
6805        // Source of truth: `gate4agent_catalog::launch::approval_level_resolution`
6806        // at HEAD (commit 7b6a8d2) and
6807        // `docs/gate4agent/audits/gate4agent-acp-slice1-proof-2026-09-02.md`
6808        // (session/new modes measured live, 2026-09-02).
6809        let claude = AgentId::new("claude").unwrap();
6810        assert_eq!(
6811            required_acp_mode(&claude, ApprovalLevel::FullAuto),
6812            Ok(Some(ModeId::new("bypassPermissions")))
6813        );
6814        assert_eq!(
6815            required_acp_mode(&claude, ApprovalLevel::Moderate),
6816            Ok(Some(ModeId::new("acceptEdits")))
6817        );
6818        // measured live: `plan` neither refuses nor asks over ACP, so
6819        // `ReadOnly` moved to `default`, the one claude mode where both an
6820        // in-cwd and an out-of-cwd write raised `session/request_permission`.
6821        assert_eq!(
6822            required_acp_mode(&claude, ApprovalLevel::ReadOnly),
6823            Ok(Some(ModeId::new("default")))
6824        );
6825        assert_eq!(required_acp_mode(&claude, ApprovalLevel::Unmanaged), Ok(None));
6826
6827        let codex = AgentId::new("codex").unwrap();
6828        assert_eq!(
6829            required_acp_mode(&codex, ApprovalLevel::FullAuto),
6830            Ok(Some(ModeId::new("agent-full-access")))
6831        );
6832        assert_eq!(
6833            required_acp_mode(&codex, ApprovalLevel::Moderate),
6834            Ok(Some(ModeId::new("agent")))
6835        );
6836        assert_eq!(
6837            required_acp_mode(&codex, ApprovalLevel::ReadOnly),
6838            Ok(Some(ModeId::new("read-only")))
6839        );
6840        assert_eq!(required_acp_mode(&codex, ApprovalLevel::Unmanaged), Ok(None));
6841
6842        // kimi: `acp_mode_id` is `None` for every managed level -- measured
6843        // 2026-09-05, `kimi.exe` 0.29.0's `session/new` result carries
6844        // `configOptions` (a `model` select) and no `modes` field at all.
6845        // The 2026-09-02 `yolo`/`auto`/`plan` measurement this table used to
6846        // pin was a different build: the npm shell shim running under WSL
6847        // interop, not this native binary.
6848        // `FullAuto` is the one kimi level that carries vendor flags
6849        // (`--yolo`), so argv applies it and there is nothing left to refuse;
6850        // `Moderate`/`ReadOnly` carry none (their `--auto`/`--plan` remain
6851        // UNCONFIRMED under `kimi acp`) and still refuse, because for those
6852        // no mechanism exists at all.
6853        let kimi = AgentId::new("kimi").unwrap();
6854        assert_eq!(required_acp_mode(&kimi, ApprovalLevel::FullAuto), Ok(None));
6855        assert_eq!(
6856            acp_approval_level_args(&kimi, ApprovalLevel::FullAuto),
6857            vec!["--auto".to_owned()],
6858            "kimi's FullAuto must reach the process through argv, since it has no ACP mode"
6859        );
6860        for level in [ApprovalLevel::Moderate, ApprovalLevel::ReadOnly] {
6861            assert!(
6862                required_acp_mode(&kimi, level).is_err(),
6863                "kimi at {level:?} has neither a sourced ACP mode id nor vendor flags, so it must refuse"
6864            );
6865        }
6866        assert_eq!(required_acp_mode(&kimi, ApprovalLevel::Unmanaged), Ok(None));
6867        assert!(
6868            acp_approval_level_args(&kimi, ApprovalLevel::Unmanaged).is_empty(),
6869            "Unmanaged imposes nothing, so it must add no argv flags either"
6870        );
6871
6872        // grok: `acp_mode_id` is `None` for every managed level (no live
6873        // mode catalogue sourced yet — slice-1 skipped grok). `FullAuto` and
6874        // `Moderate` are `Supported` with argv flags, so they resolve
6875        // `Ok(None)` and apply through argv; `ReadOnly` is outright
6876        // `Unsupported` and refuses. Flag spellings for FullAuto remain
6877        // UNCONFIRMED against Grok Build's own CLI surface (catalog docs).
6878        let grok = AgentId::new("grok").unwrap();
6879        assert_eq!(required_acp_mode(&grok, ApprovalLevel::FullAuto), Ok(None));
6880        assert_eq!(
6881            acp_approval_level_args(&grok, ApprovalLevel::FullAuto),
6882            vec!["--always-approve".to_owned()],
6883        );
6884        assert_eq!(required_acp_mode(&grok, ApprovalLevel::Moderate), Ok(None));
6885        assert_eq!(
6886            acp_approval_level_args(&grok, ApprovalLevel::Moderate),
6887            vec!["--permission-mode".to_owned(), "auto".to_owned()],
6888        );
6889        assert!(
6890            required_acp_mode(&grok, ApprovalLevel::ReadOnly).is_err(),
6891            "grok at ReadOnly is Unsupported outright, so it must refuse"
6892        );
6893        assert_eq!(required_acp_mode(&grok, ApprovalLevel::Unmanaged), Ok(None));
6894    }
6895
6896    /// The regression guard for the whole change: no provider x level may
6897    /// silently resolve to nothing (which would leave the session running
6898    /// at the agent's own default, e.g. the live-measured `auto`) unless the
6899    /// level itself is `Unmanaged`. Every other level is either a concrete
6900    /// mode id or an outright refusal. kimi no longer has an exception here:
6901    /// measured 2026-09-05, `kimi.exe` 0.29.0 offers no ACP modes at all, so
6902    /// every one of its managed levels now refuses (`Err`) rather than
6903    /// sourcing a mode id -- the `Moderate` row's `auto` exception this
6904    /// guard used to carry was the 2026-09-02 npm-shim-under-WSL-interop
6905    /// measurement, a different build.
6906    #[test]
6907    fn required_acp_mode_never_silently_permits_auto_except_for_unmanaged() {
6908        for id in ["claude", "codex", "grok", "kimi"] {
6909            let agent = AgentId::new(id).unwrap();
6910            for level in [ApprovalLevel::FullAuto, ApprovalLevel::Moderate, ApprovalLevel::ReadOnly] {
6911                match required_acp_mode(&agent, level) {
6912                    Ok(Some(mode_id)) => assert_ne!(
6913                        mode_id.as_str(),
6914                        "auto",
6915                        "{id} at {level:?} must never silently resolve the vendor's own default 'auto'"
6916                    ),
6917                    Ok(None) => assert!(
6918                        !acp_approval_level_args(&agent, level).is_empty(),
6919                        "{id} at {level:?} resolved Ok(None) while carrying no vendor flags -- \
6920                         that leaves the session at the agent's own default. Only Unmanaged, or a \
6921                         level argv actually applies, may resolve to no mode"
6922                    ),
6923                    Err(_) => {} // refused outright -- also never reaches `auto`
6924                }
6925            }
6926            assert_eq!(
6927                required_acp_mode(&agent, ApprovalLevel::Unmanaged),
6928                Ok(None),
6929                "{id}: Unmanaged is the one level allowed to apply nothing"
6930            );
6931        }
6932    }
6933
6934    #[test]
6935    fn approval_level_not_offered_message_refuses_by_name_and_lists_what_was_offered() {
6936        let offered = vec![session_mode("bypassPermissions"), session_mode("default")];
6937        let mode_id = ModeId::new("plan");
6938        let message = approval_level_not_offered_message(ApprovalLevel::ReadOnly, &mode_id, &offered)
6939            .expect("'plan' was not among the offered modes");
6940        assert!(message.contains("ApprovalLevelNotOfferedByAgent"));
6941        assert!(message.contains("bypassPermissions"));
6942        assert!(message.contains("default"));
6943        assert!(message.contains("ReadOnly"));
6944    }
6945
6946    #[test]
6947    fn approval_level_not_offered_message_is_none_when_the_agent_announced_it() {
6948        let offered = vec![session_mode("plan")];
6949        let mode_id = ModeId::new("plan");
6950        assert_eq!(
6951            approval_level_not_offered_message(ApprovalLevel::ReadOnly, &mode_id, &offered),
6952            None
6953        );
6954    }
6955
6956    #[test]
6957    fn approval_level_not_offered_message_handles_an_agent_that_announces_no_modes_at_all() {
6958        let mode_id = ModeId::new("bypassPermissions");
6959        let message = approval_level_not_offered_message(ApprovalLevel::FullAuto, &mode_id, &[])
6960            .expect("an empty mode catalog never offers anything");
6961        assert!(message.contains("ApprovalLevelNotOfferedByAgent"));
6962        assert!(message.contains("offered: []"));
6963    }
6964
6965    // -----------------------------------------------------------------------
6966    // stop_native — pipe_sessions branch reads `force`.
6967    //
6968    // Regression coverage: this branch used to call
6969    // `owned.session.kill().await` unconditionally, so no headless Pipe run
6970    // could ever end any way other than a forced kill regardless of what the
6971    // caller asked for. `PipeSession::stop`'s own unit tests (`gate4agent`'s
6972    // `pipe::session::tests`) cover the graceful-wait/bound-elapse timing in
6973    // depth; these two only prove the NEW wiring in `stop_native` itself:
6974    // `force` reaches `PipeSession::stop`, and its `PipeStopOutcome` maps
6975    // onto `ControlObservation::StopCompleted` correctly. Built by inserting
6976    // directly into `pipe_sessions` (private field, visible to this child
6977    // module) rather than through `execute()`/`EffectEnvelope`, so this does
6978    // not depend on the catalog/spawn-plan machinery at all.
6979    // -----------------------------------------------------------------------
6980
6981    use super::{NativeEffectShell, NativeSessionKey, OwnedProviderSession};
6982    use gate4agent_catalog::{AgentRegistry as FixtureAgentRegistry, AgentSpec as FixtureAgentSpec};
6983    use gate4agent_types::ControlObservation;
6984    use std::collections::VecDeque;
6985
6986    fn fixture_pipe_key() -> NativeSessionKey {
6987        NativeSessionKey {
6988            instance_id: gate4agent_types::AgentInstanceId(9_001),
6989            generation: gate4agent_types::SessionGeneration(1),
6990        }
6991    }
6992
6993    fn fixture_pipe_source() -> gate4agent_types::ProviderSource {
6994        gate4agent_types::ProviderSource {
6995            family: AdapterFamily::Pipe,
6996            binding: gate4agent_types::AdapterBinding::new(
6997                gate4agent_types::AdapterId::new("fixture").unwrap(),
6998                "1",
6999                gate4agent_types::AdapterVerification::SyntheticFixture,
7000            )
7001            .unwrap(),
7002        }
7003    }
7004
7005    #[cfg(windows)]
7006    fn fixture_graceful_exit_launch() -> gate4agent_types::LaunchSpec {
7007        gate4agent_types::LaunchSpec {
7008            program: "powershell.exe".to_owned(),
7009            fixed_args: vec![
7010                "-NoProfile".to_owned(),
7011                "-NonInteractive".to_owned(),
7012                "-Command".to_owned(),
7013                "[Console]::In.ReadToEnd() | Out-Null; exit 0".to_owned(),
7014            ],
7015        }
7016    }
7017
7018    #[cfg(not(windows))]
7019    fn fixture_graceful_exit_launch() -> gate4agent_types::LaunchSpec {
7020        gate4agent_types::LaunchSpec {
7021            program: "sh".to_owned(),
7022            fixed_args: vec!["-c".to_owned(), "cat >/dev/null; exit 0".to_owned()],
7023        }
7024    }
7025
7026    #[cfg(windows)]
7027    fn fixture_ignore_stdin_launch() -> gate4agent_types::LaunchSpec {
7028        gate4agent_types::LaunchSpec {
7029            program: "powershell.exe".to_owned(),
7030            fixed_args: vec![
7031                "-NoProfile".to_owned(),
7032                "-NonInteractive".to_owned(),
7033                "-Command".to_owned(),
7034                "Start-Sleep -Seconds 30".to_owned(),
7035            ],
7036        }
7037    }
7038
7039    #[cfg(not(windows))]
7040    fn fixture_ignore_stdin_launch() -> gate4agent_types::LaunchSpec {
7041        gate4agent_types::LaunchSpec {
7042            program: "sh".to_owned(),
7043            fixed_args: vec!["-c".to_owned(), "sleep 30".to_owned()],
7044        }
7045    }
7046
7047    async fn spawn_fixture_pipe_shell(
7048        launch: gate4agent_types::LaunchSpec,
7049    ) -> (NativeEffectShell, NativeSessionKey) {
7050        let session = gate4agent::PipeSession::spawn_with_launch(
7051            gate4agent::core::types::SessionConfig::default(),
7052            "prompt",
7053            &launch,
7054            gate4agent_types::PipePromptDelivery::StdinClose,
7055        )
7056        .await
7057        .expect("fixture pipe process must spawn");
7058        let events = session.subscribe();
7059        let key = fixture_pipe_key();
7060        let mut shell = NativeEffectShell::new(
7061            FixtureAgentRegistry::new(std::iter::empty::<FixtureAgentSpec>())
7062                .expect("empty fixture catalog"),
7063        );
7064        shell.pipe_sessions.insert(
7065            key,
7066            OwnedProviderSession {
7067                source: fixture_pipe_source(),
7068                session,
7069                events,
7070                pending_events: VecDeque::new(),
7071                pending_provider_events: VecDeque::new(),
7072                next_provider_sequence: 1,
7073                observed_exit_code: None,
7074                runtime_policy: gate4agent_types::ProviderRuntimePolicy::none(),
7075            },
7076        );
7077        (shell, key)
7078    }
7079
7080    #[tokio::test]
7081    async fn stop_native_pipe_session_reports_the_real_exit_code_when_not_forced() {
7082        let (mut shell, key) = spawn_fixture_pipe_shell(fixture_graceful_exit_launch()).await;
7083
7084        match shell.stop_native(key, false).await {
7085            ControlObservation::StopCompleted {
7086                forced, exit_code, ..
7087            } => {
7088                assert!(!forced, "a process that exits on its own must not be forced");
7089                assert_eq!(exit_code, Some(0));
7090            }
7091            other => panic!("expected StopCompleted, got {other:?}"),
7092        }
7093    }
7094
7095    #[tokio::test]
7096    async fn stop_native_pipe_session_force_true_kills_immediately_without_waiting() {
7097        let (mut shell, key) = spawn_fixture_pipe_shell(fixture_ignore_stdin_launch()).await;
7098
7099        let start = std::time::Instant::now();
7100        match shell.stop_native(key, true).await {
7101            ControlObservation::StopCompleted { forced, .. } => {
7102                assert!(forced, "force=true must always report forced");
7103            }
7104            other => panic!("expected StopCompleted, got {other:?}"),
7105        }
7106        assert!(
7107            start.elapsed()
7108                < std::time::Duration::from_secs(gate4agent::pipe::PIPE_GRACEFUL_STOP_BOUND_SECS),
7109            "force=true must not wait out the graceful bound"
7110        );
7111    }
7112
7113    // -------------------------------------------------------------------
7114    // stop_native -- acp_sessions branch reads `force`.
7115    //
7116    // Regression coverage: this branch used to call
7117    // `owned.session.kill().await` unconditionally after an optional
7118    // `session/cancel`, so `forced` was always `true` regardless of what
7119    // the caller asked for. Fixture mirrors `gate4agent::acp::session`'s
7120    // own `AcpSession::stop` unit tests (which cover the graceful-wait/
7121    // bound-elapse timing in depth): a minimal handshake responder that
7122    // answers `initialize`/`session/new` and exits 0 on stdin EOF. This
7123    // test only proves the NEW wiring in `stop_native` itself: `force`
7124    // reaches `AcpSession::stop`, and its `AcpStopOutcome` maps onto
7125    // `ControlObservation::StopCompleted` correctly.
7126    // -------------------------------------------------------------------
7127
7128    #[cfg(windows)]
7129    fn acp_fixture_launch() -> gate4agent_types::LaunchSpec {
7130        gate4agent_types::LaunchSpec {
7131            program: "powershell.exe".to_owned(),
7132            fixed_args: vec![
7133                "-NoLogo".to_owned(),
7134                "-NoProfile".to_owned(),
7135                "-NonInteractive".to_owned(),
7136                "-ExecutionPolicy".to_owned(),
7137                "Bypass".to_owned(),
7138                "-Command".to_owned(),
7139                ACP_HANDSHAKE_SCRIPT.to_owned(),
7140            ],
7141        }
7142    }
7143
7144    #[cfg(not(windows))]
7145    fn acp_fixture_launch() -> gate4agent_types::LaunchSpec {
7146        gate4agent_types::LaunchSpec {
7147            program: "python3".to_owned(),
7148            fixed_args: vec!["-u".to_owned(), "-c".to_owned(), ACP_HANDSHAKE_SCRIPT.to_owned()],
7149        }
7150    }
7151
7152    #[cfg(windows)]
7153    const ACP_HANDSHAKE_SCRIPT: &str = r#"[Console]::OutputEncoding=[Text.Encoding]::UTF8
7154function Write-JsonLine($value) { [Console]::WriteLine(($value | ConvertTo-Json -Compress -Depth 12)) }
7155$initialize = [Console]::ReadLine() | ConvertFrom-Json
7156Write-JsonLine @{jsonrpc='2.0';id=$initialize.id;result=@{}}
7157$newSession = [Console]::ReadLine() | ConvertFrom-Json
7158Write-JsonLine @{jsonrpc='2.0';id=$newSession.id;result=@{sessionId='fixture-acp-session'}}
7159while ($true) {
7160    $line = [Console]::ReadLine()
7161    if ($null -eq $line) { exit 0 }
7162}"#;
7163    #[cfg(not(windows))]
7164    const ACP_HANDSHAKE_SCRIPT: &str = r#"import json,sys
7165def read_message():
7166 line=sys.stdin.readline()
7167 if not line: return None
7168 return json.loads(line)
7169def write_message(message):
7170 print(json.dumps(message),flush=True)
7171
7172initialize=read_message()
7173write_message({'jsonrpc':'2.0','id':initialize.get('id'),'result':{}})
7174new_session=read_message()
7175write_message({'jsonrpc':'2.0','id':new_session.get('id'),'result':{'sessionId':'fixture-acp-session'}})
7176while True:
7177 msg=read_message()
7178 if msg is None:
7179  sys.exit(0)"#;
7180
7181    fn fixture_acp_key() -> NativeSessionKey {
7182        NativeSessionKey {
7183            instance_id: gate4agent_types::AgentInstanceId(9_002),
7184            generation: gate4agent_types::SessionGeneration(1),
7185        }
7186    }
7187
7188    fn fixture_acp_source() -> gate4agent_types::ProviderSource {
7189        gate4agent_types::ProviderSource {
7190            family: AdapterFamily::Acp,
7191            binding: gate4agent_types::AdapterBinding::new(
7192                gate4agent_types::AdapterId::new("fixture").unwrap(),
7193                "1",
7194                gate4agent_types::AdapterVerification::SyntheticFixture,
7195            )
7196            .unwrap(),
7197        }
7198    }
7199
7200    async fn spawn_fixture_acp_shell() -> (NativeEffectShell, NativeSessionKey) {
7201        let session = gate4agent::acp::AcpSession::spawn_with_launch(
7202            gate4agent::CliTool::ClaudeCode,
7203            &std::env::current_dir().expect("cwd"),
7204            gate4agent::acp::AcpSessionOptions::default(),
7205            &acp_fixture_launch(),
7206        )
7207        .await
7208        .expect("fixture ACP handshake must succeed");
7209        let events = session.subscribe();
7210        let key = fixture_acp_key();
7211        let mut shell = NativeEffectShell::new(
7212            FixtureAgentRegistry::new(std::iter::empty::<FixtureAgentSpec>())
7213                .expect("empty fixture catalog"),
7214        );
7215        shell.acp_sessions.insert(
7216            key,
7217            OwnedProviderSession {
7218                source: fixture_acp_source(),
7219                session,
7220                events,
7221                pending_events: VecDeque::new(),
7222                pending_provider_events: VecDeque::new(),
7223                next_provider_sequence: 1,
7224                observed_exit_code: None,
7225                runtime_policy: gate4agent_types::ProviderRuntimePolicy::none(),
7226            },
7227        );
7228        (shell, key)
7229    }
7230
7231    #[tokio::test]
7232    async fn stop_native_acp_session_reports_the_real_exit_code_when_not_forced() {
7233        let (mut shell, key) = spawn_fixture_acp_shell().await;
7234
7235        match shell.stop_native(key, false).await {
7236            ControlObservation::StopCompleted {
7237                forced, exit_code, ..
7238            } => {
7239                assert!(!forced, "a process that exits on its own must not be forced");
7240                assert_eq!(exit_code, Some(0));
7241            }
7242            other => panic!("expected StopCompleted, got {other:?}"),
7243        }
7244    }
7245
7246    // -------------------------------------------------------------------
7247    // spawn_native -- ACP branch: `ProviderEvent::SessionIdentityObserved`.
7248    //
7249    // Regression coverage for the defect this carries fixed: nothing on the
7250    // ACP path ever emitted `SessionIdentityObserved`, so a managed session
7251    // record for an ACP-spawned agent could never leave
7252    // `ManagedSessionState::IdentityPending` (`gate4agent-node`'s
7253    // `reconcile_managed_record` binds the identity ONLY on that event).
7254    // Built through the full `execute()`/`EffectEnvelope` path (unlike
7255    // `spawn_fixture_acp_shell` above, which inserts an already-spawned
7256    // session directly into `acp_sessions`) so these tests exercise the
7257    // ACTUAL `TransportKind::Acp` arm of `spawn_native`, not a stand-in for
7258    // it -- the same synthetic stdio handshake fixture, extended with a
7259    // second script that omits `sessionId` to name the ACP-MUST violation
7260    // this whole feature exists to survive.
7261    // -------------------------------------------------------------------
7262
7263    #[cfg(windows)]
7264    fn acp_fixture_launch_no_session_id() -> gate4agent_types::LaunchSpec {
7265        gate4agent_types::LaunchSpec {
7266            program: "powershell.exe".to_owned(),
7267            fixed_args: vec![
7268                "-NoLogo".to_owned(),
7269                "-NoProfile".to_owned(),
7270                "-NonInteractive".to_owned(),
7271                "-ExecutionPolicy".to_owned(),
7272                "Bypass".to_owned(),
7273                "-Command".to_owned(),
7274                ACP_HANDSHAKE_SCRIPT_NO_SESSION_ID.to_owned(),
7275            ],
7276        }
7277    }
7278
7279    #[cfg(not(windows))]
7280    fn acp_fixture_launch_no_session_id() -> gate4agent_types::LaunchSpec {
7281        gate4agent_types::LaunchSpec {
7282            program: "python3".to_owned(),
7283            fixed_args: vec![
7284                "-u".to_owned(),
7285                "-c".to_owned(),
7286                ACP_HANDSHAKE_SCRIPT_NO_SESSION_ID.to_owned(),
7287            ],
7288        }
7289    }
7290
7291    #[cfg(windows)]
7292    const ACP_HANDSHAKE_SCRIPT_NO_SESSION_ID: &str = r#"[Console]::OutputEncoding=[Text.Encoding]::UTF8
7293function Write-JsonLine($value) { [Console]::WriteLine(($value | ConvertTo-Json -Compress -Depth 12)) }
7294$initialize = [Console]::ReadLine() | ConvertFrom-Json
7295Write-JsonLine @{jsonrpc='2.0';id=$initialize.id;result=@{}}
7296$newSession = [Console]::ReadLine() | ConvertFrom-Json
7297Write-JsonLine @{jsonrpc='2.0';id=$newSession.id;result=@{}}
7298while ($true) {
7299    $line = [Console]::ReadLine()
7300    if ($null -eq $line) { exit 0 }
7301}"#;
7302    #[cfg(not(windows))]
7303    const ACP_HANDSHAKE_SCRIPT_NO_SESSION_ID: &str = r#"import json,sys
7304def read_message():
7305 line=sys.stdin.readline()
7306 if not line: return None
7307 return json.loads(line)
7308def write_message(message):
7309 print(json.dumps(message),flush=True)
7310
7311initialize=read_message()
7312write_message({'jsonrpc':'2.0','id':initialize.get('id'),'result':{}})
7313new_session=read_message()
7314write_message({'jsonrpc':'2.0','id':new_session.get('id'),'result':{}})
7315while True:
7316 msg=read_message()
7317 if msg is None:
7318  sys.exit(0)"#;
7319
7320    fn fixture_acp_spawn_agent_id() -> AgentId {
7321        AgentId::new("claude").expect("builtin catalog names 'claude'")
7322    }
7323
7324    /// Clones the REAL built-in `claude` spec (so `capabilities.transports.acp`
7325    /// carries the same `AdapterBinding` the default `legacy_adapters`
7326    /// registry (`NativeEffectShell::new`) already resolves) and overrides
7327    /// only `launch_override`, so `spawn_native` execs the synthetic stdio
7328    /// fixture above instead of a real `claude` binary.
7329    fn fixture_acp_spawn_agent_spec(launch: gate4agent_types::LaunchSpec) -> FixtureAgentSpec {
7330        let mut spec = gate4agent_catalog::builtin_registry()
7331            .get(&fixture_acp_spawn_agent_id())
7332            .cloned()
7333            .expect("builtin catalog must declare claude");
7334        spec.capabilities
7335            .transports
7336            .acp
7337            .as_mut()
7338            .expect("claude must declare ACP capability")
7339            .launch_override = Some(launch);
7340        spec
7341    }
7342
7343    fn fixture_acp_spawn_key() -> NativeSessionKey {
7344        NativeSessionKey {
7345            instance_id: gate4agent_types::AgentInstanceId(9_101),
7346            generation: gate4agent_types::SessionGeneration(1),
7347        }
7348    }
7349
7350    /// Spawns through the full `execute()`/`EffectEnvelope` path with
7351    /// `ApprovalLevel::Unmanaged` -- the one level `required_acp_mode`
7352    /// resolves to `Ok(None)` for every agent, so the spawn never calls
7353    /// `session/set_mode` against a fixture that only answers `initialize`/
7354    /// `session/new`.
7355    async fn spawn_native_acp_fixture(
7356        launch: gate4agent_types::LaunchSpec,
7357        provider_session_identity: bool,
7358    ) -> (NativeEffectShell, NativeSessionKey, ControlObservation) {
7359        let mut policy = ProviderRuntimePolicy::none();
7360        policy.provider_session_identity = provider_session_identity;
7361        let key = fixture_acp_spawn_key();
7362        let mut shell = NativeEffectShell::new(
7363            FixtureAgentRegistry::new([fixture_acp_spawn_agent_spec(launch)])
7364                .expect("single-agent fixture catalog"),
7365        );
7366        let envelope = gate4agent_types::EffectEnvelope {
7367            operation_id: gate4agent_types::OperationId(1),
7368            instance_id: key.instance_id,
7369            generation: key.generation,
7370            effect: gate4agent_types::ControlEffect::Spawn {
7371                agent_id: fixture_acp_spawn_agent_id(),
7372                transport: TransportKind::Acp,
7373                runtime_policy: policy,
7374                request: gate4agent_types::StartRequest {
7375                    working_directory: std::env::current_dir()
7376                        .expect("cwd")
7377                        .to_string_lossy()
7378                        .into_owned(),
7379                    terminal_size: gate4agent_types::TerminalSize {
7380                        rows: 24,
7381                        columns: 80,
7382                    },
7383                    initial_prompt: None,
7384                    session_options: None,
7385                    approval_level: ApprovalLevel::Unmanaged,
7386                },
7387            },
7388        };
7389        let observation = shell.execute(envelope).await.observation;
7390        (shell, key, observation)
7391    }
7392
7393    #[tokio::test]
7394    async fn acp_spawn_with_a_real_sessionid_emits_session_identity_observed_after_session_start() {
7395        let (mut shell, key, spawned) =
7396            spawn_native_acp_fixture(acp_fixture_launch(), true).await;
7397        match spawned {
7398            ControlObservation::Spawned { .. } => {}
7399            other => panic!("expected Spawned, got {other:?}"),
7400        }
7401
7402        let mut provider_events = shell
7403            .collect_provider_events()
7404            .into_iter()
7405            .filter_map(|envelope| match envelope.observation {
7406                ControlObservation::ProviderEvent { event, .. } => Some(event),
7407                _ => None,
7408            });
7409
7410        match provider_events.next() {
7411            Some(ProviderEvent::SessionStarted { .. }) => {}
7412            other => panic!("expected SessionStarted first, got {other:?}"),
7413        }
7414        match provider_events.next() {
7415            Some(ProviderEvent::SessionIdentityObserved { identity }) => {
7416                assert_eq!(identity.key, gate4agent_types::ProviderSessionKey::SessionId);
7417                assert_eq!(identity.id, "fixture-acp-session");
7418                assert!(identity.transcript_path.is_none());
7419            }
7420            other => panic!("expected SessionIdentityObserved second, got {other:?}"),
7421        }
7422        assert!(
7423            provider_events.next().is_none(),
7424            "expected exactly one SessionIdentityObserved and nothing after it"
7425        );
7426
7427        let _ = shell.stop_native(key, true).await;
7428    }
7429
7430    #[tokio::test]
7431    async fn acp_spawn_with_no_sessionid_emits_no_session_identity_observed() {
7432        let (mut shell, key, spawned) =
7433            spawn_native_acp_fixture(acp_fixture_launch_no_session_id(), true).await;
7434        match spawned {
7435            ControlObservation::Spawned { .. } => {}
7436            other => panic!("expected Spawned, got {other:?}"),
7437        }
7438
7439        let provider_events: Vec<_> = shell
7440            .collect_provider_events()
7441            .into_iter()
7442            .filter_map(|envelope| match envelope.observation {
7443                ControlObservation::ProviderEvent { event, .. } => Some(event),
7444                _ => None,
7445            })
7446            .collect();
7447
7448        assert!(
7449            provider_events
7450                .iter()
7451                .any(|event| matches!(event, ProviderEvent::SessionStarted { .. })),
7452            "expected SessionStarted even when the agent reported no sessionId, got {provider_events:?}"
7453        );
7454        assert!(
7455            !provider_events
7456                .iter()
7457                .any(|event| matches!(event, ProviderEvent::SessionIdentityObserved { .. })),
7458            "an agent that violates ACP's sessionId MUST must not be granted an identity: \
7459             got {provider_events:?}"
7460        );
7461
7462        let _ = shell.stop_native(key, true).await;
7463    }
7464}