Skip to main content

gate4agent_types/
control.rs

1use crate::{
2    AdapterBinding, AdapterFamily, AgentId, CapabilityModelSummary, CapabilityProbeFailure,
3    CapabilityProbeRequest, CapabilitySnapshot, HistoryCandidateSummary, HistoryOperation,
4    HistoryQuery, HistorySessionRecord, HistorySnapshot, InputAction, InputPrepareError,
5    PreparedInput, PreparedInputKind, ResumeAuthorityTarget, ResumeLaunchRequest,
6    ResumeSessionSummary, ResumeSnapshot, ResumeTarget, SessionOptionSelection,
7};
8use serde::{Deserialize, Deserializer, Serialize};
9use thiserror::Error;
10
11pub const CONTROL_SESSIONS_MAX: usize = 512;
12pub const CONTROL_INSTANCE_IDENTITIES_CAPACITY: u32 = 4_096;
13pub const CONTROL_INSTANCE_IDENTITIES_MAX: usize = CONTROL_INSTANCE_IDENTITIES_CAPACITY as usize;
14pub const TERMINAL_ROWS_MAX: u16 = 1_000;
15pub const TERMINAL_COLUMNS_MAX: u16 = 1_000;
16pub const WORKING_DIRECTORY_MAX_BYTES: usize = 32_768;
17pub const PROVIDER_INGRESS_EVENTS_MAX: usize = 32;
18pub const PROVIDER_EVENT_TEXT_MAX_BYTES: usize = 262_144;
19pub const PROVIDER_EVENT_ID_MAX_BYTES: usize = 512;
20pub const PROVIDER_EVENT_TOOLS_MAX: usize = 256;
21pub const PROVIDER_INTERACTIONS_MAX: usize = 64;
22pub const PROVIDER_INTERACTION_RESPONSE_MAX_BYTES: usize = 32_768;
23pub const PROVIDER_INTERACTION_FAILURE_MAX_BYTES: usize = 4_096;
24/// Bound on `ProviderEvent::InteractionRequested::options` -- same
25/// rationale as `OPERATOR_GATE_OPTIONS_MAX`: an ACP `session/request_
26/// permission` call offers a subset of exactly four `PermissionOptionKind`
27/// values, so this gives headroom over that domain maximum without letting
28/// a garbled or hostile agent inflate the wire payload.
29pub const PROVIDER_INTERACTION_OPTIONS_MAX: usize = 8;
30pub const PROVIDER_SUBAGENTS_MAX: usize = 64;
31pub const PROVIDER_SESSION_LOCATOR_MAX_BYTES: usize = 32_768;
32pub const PROVIDER_PLAN_STEPS_MAX: usize = 256;
33pub const PROVIDER_AVAILABLE_COMMANDS_MAX: usize = 256;
34pub const PROVIDER_CONFIG_OPTIONS_MAX: usize = 256;
35pub const PROVIDER_CONFIG_OPTION_CHOICES_MAX: usize = 256;
36pub const PROVIDER_MODE_CATALOG_MAX: usize = 256;
37
38#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
39#[serde(transparent)]
40pub struct AgentInstanceId(pub u64);
41
42#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
43#[serde(transparent)]
44pub struct CommandId(pub u64);
45
46#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
47#[serde(transparent)]
48pub struct OperationId(pub u64);
49
50#[derive(
51    Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize,
52)]
53#[serde(transparent)]
54pub struct SessionGeneration(pub u64);
55
56/// How much autonomy a freshly spawned provider CLI process is granted at
57/// launch, independent of which transport (PTY, inline/pipe, ACP) execs it --
58/// these are process launch arguments, the same axis regardless of transport.
59///
60/// The mapping from a level to actual CLI flags is per-provider, verified
61/// against vendor documentation (and, for `grok`/`kimi`, against a third-party
62/// harness's observed behavior), and lives in `gate4agent_catalog` -- the
63/// crate that owns launch policy -- not here; this type only names the
64/// levels. Where a provider has no verified intermediate flag (`grok` and
65/// `kimi` do not have one for `Moderate` or `ReadOnly` as of this writing),
66/// the catalog's mapping falls back to `Unmanaged` behavior (no injected
67/// flag) rather than fabricating one -- see
68/// `gate4agent_catalog::approval_level_args`.
69#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
70#[serde(rename_all = "kebab-case")]
71pub enum ApprovalLevel {
72    /// No restriction: the provider CLI is launched with whatever flag
73    /// grants it full autonomy (Claude `--permission-mode bypassPermissions`,
74    /// Codex `--dangerously-bypass-approvals-and-sandbox`, Grok
75    /// `--always-approve` / `--yolo`, Kimi `--auto`). Default --
76    /// restricting is opt-in, not asking a human is the norm.
77    #[default]
78    FullAuto,
79    /// The provider's own middle ground, when one is verified (Claude
80    /// `--permission-mode acceptEdits`, Codex `--sandbox workspace-write
81    /// --ask-for-approval on-request`). A provider with no verified
82    /// intermediate flag falls back to `Unmanaged` -- never a fabricated
83    /// flag.
84    Moderate,
85    /// Read-only: no writes, no command execution (Claude
86    /// `--permission-mode default`, Codex `--sandbox read-only
87    /// --ask-for-approval never`). A provider with no verified read-only
88    /// flag falls back to `Unmanaged`.
89    ReadOnly,
90    /// Impose nothing: launch with no approval-related flag at all and let
91    /// the provider CLI use whatever it is configured with on its own.
92    Unmanaged,
93}
94
95#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
96pub struct StartRequest {
97    pub working_directory: String,
98    pub terminal_size: TerminalSize,
99    #[serde(default)]
100    pub initial_prompt: Option<String>,
101    #[serde(default)]
102    pub session_options: Option<SessionOptionSelection>,
103    /// Approval-level axis for this spawn; defaults to
104    /// `ApprovalLevel::FullAuto` when the caller does not set it, matching
105    /// `ApprovalLevel`'s own default. `#[serde(default)]` so a peer that
106    /// predates this field decodes it as the same default rather than
107    /// failing to deserialize.
108    #[serde(default)]
109    pub approval_level: ApprovalLevel,
110}
111
112#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
113pub struct ProviderRuntimePolicy {
114    pub raw_pty_lifecycle: bool,
115    pub semantic_readiness: bool,
116    pub structured_prompt: bool,
117    pub provider_session_identity: bool,
118    pub semantic_resume: bool,
119    /// Whether this session's provider-event ingestion may come from a
120    /// declared hook adapter -- a native hooks contract the provider CLI
121    /// itself calls over the authenticated loopback ingress route, with the
122    /// node's own adapter normalizing the payload before it ever reaches the
123    /// engine.
124    ///
125    /// This is deliberately NOT `semantic_readiness`, and granting one must
126    /// never imply the other. `semantic_readiness` (and the `structured_
127    /// prompt`/`provider_session_identity`/`semantic_resume` capabilities
128    /// chained off it) authorize INFERRING provider semantics by parsing PTY
129    /// terminal text -- that inference is only sound for a CLI version this
130    /// build has a verified vendor terminal contract for (see
131    /// `gate4agent-runtime-native`'s `VERIFIED_PROFILES`). A hook event is
132    /// not an inference: the CLI is asserting it directly over a route this
133    /// node authenticated, and the node's hook adapter -- not a terminal
134    /// screen scanner -- turns it into a `ProviderEvent`. None of the
135    /// terminal-behaviour verification a vendor contract encodes is
136    /// relevant to that trust story, so `hook_semantics` is derived purely
137    /// from "does the catalog declare a hook adapter for this provider" and
138    /// never from a vendor version probe. Conflating the two would let a
139    /// provider that merely declares a hook adapter silently unlock
140    /// PTY-parsing semantics it was never verified for, or -- the bug this
141    /// field fixes -- let a verified-semantic gate silently swallow every
142    /// hook event a provider with no verified profile at all (grok, codex,
143    /// kimi) sends over a route that is otherwise working end to end.
144    pub hook_semantics: bool,
145}
146
147impl ProviderRuntimePolicy {
148    pub fn new(
149        raw_pty_lifecycle: bool,
150        semantic_readiness: bool,
151        structured_prompt: bool,
152        provider_session_identity: bool,
153        semantic_resume: bool,
154        hook_semantics: bool,
155    ) -> Result<Self, ProviderRuntimePolicyError> {
156        let policy = Self {
157            raw_pty_lifecycle,
158            semantic_readiness,
159            structured_prompt,
160            provider_session_identity,
161            semantic_resume,
162            hook_semantics,
163        };
164        policy.validate()?;
165        Ok(policy)
166    }
167
168    pub const fn raw_pty() -> Self {
169        Self {
170            raw_pty_lifecycle: true,
171            semantic_readiness: false,
172            structured_prompt: false,
173            provider_session_identity: false,
174            semantic_resume: false,
175            hook_semantics: false,
176        }
177    }
178
179    /// The all-false policy: no capability admitted at all. This is the
180    /// correct shape for a transport that has no PTY and whose catalog entry
181    /// declares no contract this build can grant a semantic capability from
182    /// -- e.g. a Pipe-transport provider with no declared Pipe contract.
183    /// Unlike `raw_pty()`, this does NOT claim a PTY lifecycle exists.
184    pub const fn none() -> Self {
185        Self {
186            raw_pty_lifecycle: false,
187            semantic_readiness: false,
188            structured_prompt: false,
189            provider_session_identity: false,
190            semantic_resume: false,
191            hook_semantics: false,
192        }
193    }
194
195    /// Only `semantic_resume` and `hook_semantics` require the raw PTY
196    /// lifecycle. `semantic_readiness`, `structured_prompt`, and
197    /// `provider_session_identity` do NOT, on their own -- an ACP transport
198    /// has no PTY at all, yet `session/prompt` and `session/update` are
199    /// MANDATORY surface of the ACP protocol itself, and `session/new` MUST
200    /// return a `sessionId` under that same specification, none of it an
201    /// inference this build makes by parsing PTY terminal text the way it
202    /// does for a verified PTY vendor contract. `provider_session_identity`
203    /// in particular is not a bare protocol formality here: both shipped ACP
204    /// adapters map that mandatory `sessionId` to the provider's own durable
205    /// session id (claude-agent-acp's is the Claude Code session id and
206    /// on-disk transcript filename; codex-acp's is the Codex thread id), and
207    /// the ACP spawn path publishes it as a `SessionId`-keyed
208    /// `ProviderEvent::SessionIdentityObserved`, which is what carries a
209    /// newly-created record from `IdentityPending` to `Live`. An agent whose
210    /// adapter never emits that event -- because it returns no `sessionId`,
211    /// violating the ACP contract this grant relies on -- correctly stays
212    /// `IdentityPending` rather than being treated as identity-less; that is
213    /// the refusal-by-name this policy is meant to produce for such an
214    /// agent, not a defect in the grant. Granting `semantic_readiness`/
215    /// `structured_prompt`/`provider_session_identity` with
216    /// `raw_pty_lifecycle: false` is therefore a legitimate policy shape (see
217    /// `gate4agent_node::provider_runtime::policy_for_transport`'s
218    /// `TransportKind::Acp` arm), not a defect this validation should catch.
219    ///
220    /// `semantic_resume`/`hook_semantics` keep the old, stricter rule: today
221    /// nothing derives either of the two for a transport other than a
222    /// verified PTY vendor contract -- ACP's spec gives no resume guarantee
223    /// analogous to `session/new`'s `sessionId`, and the engine separately
224    /// refuses ACP resume outright -- so granting one without
225    /// `raw_pty_lifecycle` remains a construction defect rather than a
226    /// legitimate non-PTY policy shape.
227    pub fn validate(self) -> Result<(), ProviderRuntimePolicyError> {
228        if (self.semantic_resume || self.hook_semantics) && !self.raw_pty_lifecycle {
229            return Err(ProviderRuntimePolicyError::SemanticCapabilityRequiresRawPty);
230        }
231        if self.structured_prompt && !self.semantic_readiness {
232            return Err(ProviderRuntimePolicyError::StructuredPromptRequiresReadiness);
233        }
234        if self.semantic_resume && !self.provider_session_identity {
235            return Err(ProviderRuntimePolicyError::ResumeRequiresSessionIdentity);
236        }
237        Ok(())
238    }
239
240    pub const fn admits(self, capability: ProviderRuntimeCapability) -> bool {
241        match capability {
242            ProviderRuntimeCapability::RawPtyLifecycle => self.raw_pty_lifecycle,
243            ProviderRuntimeCapability::SemanticReadiness => self.semantic_readiness,
244            ProviderRuntimeCapability::StructuredPrompt => self.structured_prompt,
245            ProviderRuntimeCapability::ProviderSessionIdentity => {
246                self.provider_session_identity
247            }
248            ProviderRuntimeCapability::SemanticResume => self.semantic_resume,
249            ProviderRuntimeCapability::HookSemantics => self.hook_semantics,
250        }
251    }
252}
253
254impl<'de> Deserialize<'de> for ProviderRuntimePolicy {
255    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
256    where
257        D: Deserializer<'de>,
258    {
259        #[derive(Deserialize)]
260        struct WirePolicy {
261            raw_pty_lifecycle: bool,
262            semantic_readiness: bool,
263            structured_prompt: bool,
264            provider_session_identity: bool,
265            semantic_resume: bool,
266            hook_semantics: bool,
267        }
268
269        let wire = WirePolicy::deserialize(deserializer)?;
270        Self::new(
271            wire.raw_pty_lifecycle,
272            wire.semantic_readiness,
273            wire.structured_prompt,
274            wire.provider_session_identity,
275            wire.semantic_resume,
276            wire.hook_semantics,
277        )
278        .map_err(serde::de::Error::custom)
279    }
280}
281
282#[derive(Clone, Copy, Debug, Eq, Error, PartialEq, Serialize, Deserialize)]
283#[serde(rename_all = "kebab-case")]
284pub enum ProviderRuntimePolicyError {
285    #[error("semantic resume and hook semantics require the raw PTY lifecycle")]
286    SemanticCapabilityRequiresRawPty,
287    #[error("structured prompts require semantic readiness")]
288    StructuredPromptRequiresReadiness,
289    #[error("semantic resume requires provider session identity")]
290    ResumeRequiresSessionIdentity,
291}
292
293#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
294#[serde(rename_all = "kebab-case")]
295pub enum ProviderRuntimeCapability {
296    RawPtyLifecycle,
297    SemanticReadiness,
298    StructuredPrompt,
299    ProviderSessionIdentity,
300    SemanticResume,
301    /// Admits provider-event ingestion sourced from a declared hook adapter.
302    /// Independent of `SemanticReadiness` -- see `ProviderRuntimePolicy::
303    /// hook_semantics` for why the two must never stand in for each other.
304    HookSemantics,
305}
306
307#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
308pub struct TerminalSize {
309    pub rows: u16,
310    pub columns: u16,
311}
312
313#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
314#[serde(rename_all = "kebab-case")]
315pub enum TerminalMouseProtocolEncoding {
316    #[default]
317    Default,
318    Utf8,
319    Sgr,
320}
321
322#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
323pub struct TerminalFrame {
324    pub sequence: u64,
325    pub size: TerminalSize,
326    pub cursor_row: u16,
327    pub cursor_column: u16,
328    pub contents: String,
329    pub formatted: Vec<u8>,
330    #[serde(default)]
331    pub scrollback_formatted: Vec<Vec<u8>>,
332    #[serde(default)]
333    pub alternate_screen: bool,
334    #[serde(default)]
335    pub mouse_protocol_enabled: bool,
336    #[serde(default)]
337    pub mouse_protocol_encoding: TerminalMouseProtocolEncoding,
338    /// Unix-epoch milliseconds when this frame's screen state was
339    /// materialized -- the one instant `PtyEventPublisher::snapshot` turns
340    /// live terminal state into a frame. Every hop after that (the shell's
341    /// `ObservationEnvelope`, the c2 relay, the harness's terminal ring
342    /// buffer, the operator wire) carries this value through unchanged; none
343    /// of them may recompute, refresh, or zero it, because the whole point
344    /// of the field is to let something 200ms downstream in a ring buffer
345    /// still answer "how stale am I". `#[serde(default)]` so a peer that
346    /// predates this field decodes it as 0 -- "age unknown" -- instead of a
347    /// fabricated timestamp. Diffing this value across two hosts also mixes
348    /// in their clock skew, not just transit time, so a consumer comparing
349    /// it against wall-clock time must say so rather than presenting the
350    /// gap as pure network/queue latency.
351    #[serde(default)]
352    pub produced_at_unix_ms: u64,
353    /// The screen classification at the instant THIS frame's screen was
354    /// materialized -- stamped once at the node from the same snapshot that
355    /// produced `contents`/`formatted`, and carried through every hop
356    /// unchanged like `produced_at_unix_ms`; no hop may recompute it against
357    /// its own (possibly staler) copy of the screen. `#[serde(default)]` so a
358    /// peer that predates this field decodes it as `Unknown` -- "not
359    /// classified" -- rather than a fabricated `Ready`; defaulting to
360    /// `Unknown` and not `Ready` is the entire point of that default.
361    #[serde(default)]
362    pub screen_state: PtyScreenState,
363    /// Whether bracketed-paste mode was enabled on the PTY at the instant
364    /// THIS frame's screen was materialized, read straight off the same
365    /// `vt100::Screen` snapshot that produced `contents`/`formatted` --
366    /// `Some(true)` means the terminal application has turned the mode on
367    /// (a paste is delivered to it as one bracketed block instead of
368    /// keystrokes), `Some(false)` means it explicitly has not, and `None`
369    /// means this frame predates the field or the value was never sampled.
370    /// `None` is not a claim that the mode is off; a caller that needs to
371    /// know must treat `None` the same as `PtyScreenState::Unknown` --
372    /// absence of information, not a fabricated default.
373    /// `skip_serializing_if` is load-bearing the same way it is on
374    /// `HarnessRuntimeTerminalFrameV1::screen_state`: the key must be
375    /// ABSENT from the JSON, not `null`, so a peer that predates this field
376    /// decodes it as `None` rather than a fabricated `Some(false)`.
377    #[serde(default, skip_serializing_if = "Option::is_none")]
378    pub bracketed_paste: Option<bool>,
379}
380
381pub const FOREGROUND_PROCESS_NAME_MAX_BYTES: usize = 512;
382pub const PTY_SCREEN_GATE_NAME_MAX_BYTES: usize = 128;
383/// Bound on `OperatorGateState::options` -- large enough for any list a real
384/// prompt has ever been observed to render (2-4 choices), small enough that
385/// a garbled or hostile screen capture cannot inflate the wire payload.
386pub const OPERATOR_GATE_OPTIONS_MAX: usize = 8;
387/// Per-`OperatorGateOption::text` byte bound, same scale as
388/// `PTY_SCREEN_GATE_NAME_MAX_BYTES` -- an option label is a single short
389/// line off the screen, never a paragraph.
390pub const OPERATOR_GATE_OPTION_TEXT_MAX_BYTES: usize = 128;
391/// Bound on `OperatorGateSubject::Directory`'s `path`, matching the scale of
392/// other path-shaped fields carried on this wire (see `WORKING_DIRECTORY_MAX_BYTES`
393/// for the same order of magnitude on a full working-directory string).
394pub const OPERATOR_GATE_PATH_MAX_BYTES: usize = 32_768;
395
396/// What TYPE of blocking question `OperatorGateState` is showing, classified
397/// from the screen's own top-level phrasing (see `startup_operator_gate` in
398/// `gate4agent-shell-native`, the only producer). Distinct kinds exist so a
399/// consumer can react differently to "an update is running" versus "type an
400/// answer" without parsing `OperatorGateSubject`/`options` first.
401#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
402#[serde(rename_all = "kebab-case")]
403pub enum OperatorGateKind {
404    /// Trust the current project directory before the CLI will read or run
405    /// anything in it.
406    WorkspaceTrust,
407    /// Trust a specific set of shell hooks the CLI discovered inside an
408    /// already-trusted directory. Kept distinct from `WorkspaceTrust`: that
409    /// gate is about the directory as a whole, this one is about hooks the
410    /// CLI found inside it -- an operator reading `kind` should be able to
411    /// tell which question is being asked.
412    HookTrust,
413    /// Sign in, or choose how to sign in / which credential to use.
414    Authentication,
415    /// The CLI (or a wrapper script fronting it) is installing or updating
416    /// itself and wants a relaunch, regardless of which mechanism drives the
417    /// update -- an in-app updater or an external package manager.
418    VendorUpdate,
419    /// A first-run welcome/setup screen unrelated to trust or auth (IDE
420    /// integration notice, "press enter to continue" splash, and similar).
421    Onboarding,
422    /// Choosing a terminal color/text style during first-run setup.
423    TerminalAppearance,
424    /// A stored configuration format needs to be migrated/confirmed before
425    /// the CLI continues.
426    ConfigurationMigration,
427}
428
429impl OperatorGateKind {
430    /// Short operator-facing label, one per variant, stable in wording with
431    /// what this module classified as a bare string before `OperatorGateState`
432    /// existed -- existing log lines and error messages that quote this text
433    /// keep reading the same.
434    pub fn label(&self) -> &'static str {
435        match self {
436            Self::WorkspaceTrust => "workspace trust",
437            Self::HookTrust => "hook trust review",
438            Self::Authentication => "authentication",
439            Self::VendorUpdate => "vendor update",
440            Self::Onboarding => "onboarding",
441            Self::TerminalAppearance => "terminal appearance setup",
442            Self::ConfigurationMigration => "configuration migration",
443        }
444    }
445}
446
447/// WHAT entity `OperatorGateState` is gating access to -- narrower than
448/// `kind` (which says what TYPE of question this is): two `WorkspaceTrust`
449/// gates always share `subject: Directory`, but the `path` detail (when a
450/// matcher can read one off the screen) distinguishes which directory.
451/// `Unknown` is the honest reading for a `kind` whose screen text does not
452/// name a concrete subject from this list (a vendor updater or onboarding
453/// splash is not "about" a directory, a hook set, or an account) -- it is
454/// never upgraded into a guess.
455#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
456#[serde(tag = "kind", rename_all = "kebab-case")]
457pub enum OperatorGateSubject {
458    /// A project/workspace directory. `path` is the directory the screen
459    /// names, when a matcher can read one off the text; `None` means the
460    /// screen's phrasing did not carry one, not that there is no directory.
461    Directory { path: Option<String> },
462    /// A set of shell hooks discovered inside an already-trusted directory.
463    /// `count` is the number reported on screen, when readable; `None` means
464    /// unreadable, never zero.
465    Hooks { count: Option<u32> },
466    /// MCP servers configured for the project.
467    McpServers,
468    /// The signed-in account/identity.
469    Account,
470    /// An API key/credential value.
471    ApiKey,
472    /// Terminal color/text-style appearance.
473    Appearance,
474    /// No concrete subject from this list applies to the matched `kind`.
475    Unknown,
476}
477
478/// HOW `OperatorGateState` is controlled -- what a caller resolving it
479/// (typically a human, occasionally a scripted answer) needs to send.
480#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
481#[serde(rename_all = "kebab-case")]
482pub enum OperatorGateInput {
483    /// Choices are numbered (`1.`, `2.`, ...); confirmed with Enter after
484    /// selecting a number.
485    NumberedList,
486    /// Choices are an unnumbered list navigated with arrow keys and a
487    /// cursor glyph; confirmed with Enter.
488    ArrowList,
489    /// A single acknowledgement -- nothing to choose between, just Enter.
490    PressEnter,
491    /// Free text (a pasted code, a typed value) rather than a choice from a
492    /// list.
493    TextEntry,
494    /// The screen's input mechanism was not recognized.
495    Unknown,
496}
497
498/// What choosing a given `OperatorGateOption` does, inferred from the verb
499/// in its own on-screen text (see `classify_operator_gate_option_semantics`
500/// in `gate4agent-shell-native`) -- never from its position or number, since
501/// neither is stable across CLIs or screen wraps.
502#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
503#[serde(rename_all = "kebab-case")]
504pub enum OperatorGateOptionSemantics {
505    /// Grants what the gate is asking for (trust, continue, proceed).
506    Accept,
507    /// Refuses what the gate is asking for (don't trust, continue without
508    /// trusting) while still moving past the prompt.
509    Decline,
510    /// Opens a closer look before deciding (review the hooks/diff) rather
511    /// than accepting or declining outright.
512    Inspect,
513    /// Leaves the CLI entirely rather than answering the prompt.
514    Exit,
515    /// The option's own text used none of the recognized verbs.
516    Unknown,
517}
518
519/// One choice as rendered on screen inside an `OperatorGateState`.
520#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
521pub struct OperatorGateOption {
522    /// The option's label exactly as it appears on screen (list-marker and
523    /// leading whitespace stripped, nothing else altered) -- never
524    /// paraphrased, so an operator reading it sees the same words the CLI
525    /// rendered.
526    pub text: String,
527    pub semantics: OperatorGateOptionSemantics,
528    /// Whether the screen's own cursor/highlight currently sits on this
529    /// option. `false` means either it is not selected or selection is not
530    /// visible on this screen shape -- there is no third state, because a
531    /// consumer deciding "which option is highlighted right now" only ever
532    /// needs to know if THIS one is.
533    pub selected: bool,
534}
535
536impl OperatorGateOption {
537    fn is_valid(&self) -> bool {
538        !self.text.trim().is_empty()
539            && self.text.len() <= OPERATOR_GATE_OPTION_TEXT_MAX_BYTES
540            && !self.text.chars().any(char::is_control)
541    }
542}
543
544/// The full classification of a screen recognized as an `OperatorGate`,
545/// replacing what used to be a bare label string. `kind` is always known (a
546/// matcher only returns this type once it has matched a specific gate
547/// phrase); `subject`, `input`, and `options` degrade independently to
548/// `Unknown`/empty when the screen's specific shape was not recognized --
549/// never invented from `kind` alone.
550#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
551pub struct OperatorGateState {
552    pub kind: OperatorGateKind,
553    pub subject: OperatorGateSubject,
554    pub input: OperatorGateInput,
555    /// Recognized on-screen choices, in on-screen order. Empty means no
556    /// option list was recognized -- NOT that the screen has no choices;
557    /// see `OperatorGateInput::Unknown` for the paired "input mechanism
558    /// unrecognized either" case.
559    pub options: Vec<OperatorGateOption>,
560}
561
562impl OperatorGateState {
563    /// The gate with nothing known past `kind` -- `subject: Unknown`,
564    /// `input: Unknown`, no options. This is the honest shape for a matched
565    /// `kind` whose screen a parser has not (yet) learned to read past the
566    /// phrase that identified it; never upgrade an unread screen into a
567    /// guessed subject or option list.
568    pub fn new(kind: OperatorGateKind) -> Self {
569        Self {
570            kind,
571            subject: OperatorGateSubject::Unknown,
572            input: OperatorGateInput::Unknown,
573            options: Vec::new(),
574        }
575    }
576
577    /// Builder-style: attach a recognized subject.
578    pub fn with_subject(mut self, subject: OperatorGateSubject) -> Self {
579        self.subject = subject;
580        self
581    }
582
583    /// Builder-style: attach a recognized input mechanism and its options
584    /// together, since one is meaningless without the other (an `Unknown`
585    /// input never carries options, and options never accompany an
586    /// unrecognized input mechanism).
587    pub fn with_options(mut self, input: OperatorGateInput, options: Vec<OperatorGateOption>) -> Self {
588        self.input = input;
589        self.options = options;
590        self
591    }
592
593    /// Bounds check matching `PtyScreenState::is_valid`'s own rationale --
594    /// every string this type carries travels over the wire into an
595    /// operator UI and must be non-empty (where required), control-character
596    /// free, and within its per-field byte cap before anything downstream
597    /// trusts it.
598    pub fn is_valid(&self) -> bool {
599        let subject_valid = match &self.subject {
600            OperatorGateSubject::Directory { path: Some(path) } => {
601                !path.trim().is_empty()
602                    && path.len() <= OPERATOR_GATE_PATH_MAX_BYTES
603                    && !path.chars().any(char::is_control)
604            }
605            OperatorGateSubject::Directory { path: None }
606            | OperatorGateSubject::Hooks { .. }
607            | OperatorGateSubject::McpServers
608            | OperatorGateSubject::Account
609            | OperatorGateSubject::ApiKey
610            | OperatorGateSubject::Appearance
611            | OperatorGateSubject::Unknown => true,
612        };
613        subject_valid
614            && self.options.len() <= OPERATOR_GATE_OPTIONS_MAX
615            && self.options.iter().all(OperatorGateOption::is_valid)
616    }
617}
618
619impl std::fmt::Display for OperatorGateState {
620    /// Renders as `kind.label()` alone -- the same text this whole type
621    /// replaced used to carry as its only payload -- so an existing
622    /// `format!("...{gate}...")` call site keeps reading the same message
623    /// after this type lands under it.
624    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
625        f.write_str(self.kind.label())
626    }
627}
628
629impl OperatorGateState {
630    /// Longer operator-facing rendering than `Display`: the kind label,
631    /// plus a compact list of recognized options (currently-selected one
632    /// prefixed `*`) when `options` is non-empty. `Display` stays kind-only
633    /// on purpose (see its own doc comment) -- this is for a surface that
634    /// can afford, and wants, the fuller picture once one was parsed.
635    pub fn describe(&self) -> String {
636        if self.options.is_empty() {
637            return self.kind.label().to_owned();
638        }
639        let options = self
640            .options
641            .iter()
642            .map(|option| {
643                if option.selected {
644                    format!("*{}", option.text)
645                } else {
646                    option.text.clone()
647                }
648            })
649            .collect::<Vec<_>>()
650            .join(", ");
651        format!("{} [{options}]", self.kind.label())
652    }
653}
654
655#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
656#[serde(tag = "kind", rename_all = "kebab-case")]
657pub enum ForegroundProcessKind {
658    Agent { agent_id: AgentId },
659    Shell,
660    Other,
661}
662
663#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
664pub struct ForegroundProcess {
665    pub root_process_id: u32,
666    pub process_id: u32,
667    pub process_name: String,
668    pub kind: ForegroundProcessKind,
669}
670
671impl ForegroundProcess {
672    pub fn is_valid_for(&self, session_agent_id: &AgentId) -> bool {
673        self.root_process_id > 0
674            && self.process_id > 0
675            && !self.process_name.trim().is_empty()
676            && self.process_name.len() <= FOREGROUND_PROCESS_NAME_MAX_BYTES
677            && !self.process_name.chars().any(char::is_control)
678            && match &self.kind {
679                ForegroundProcessKind::Agent { agent_id } => agent_id == session_agent_id,
680                ForegroundProcessKind::Shell | ForegroundProcessKind::Other => true,
681            }
682    }
683}
684
685#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
686#[serde(rename_all = "kebab-case")]
687pub enum ForegroundAuthority {
688    #[default]
689    Unknown,
690    Confirmed,
691    Stale,
692}
693
694#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
695pub struct ForegroundSnapshot {
696    pub authority: ForegroundAuthority,
697    pub process: Option<ForegroundProcess>,
698    pub stale_reason: Option<String>,
699}
700
701/// Whether the screen currently painted at a PTY looks like the agent's own
702/// composer, as classified by the node from the same terminal text a human
703/// would read. This is a screen-content judgement, never a process-liveness
704/// one -- `SessionStatus` already answers "is something running", and a
705/// consumer must not fold the two into a single "is it running" question:
706/// a process can be `Running` while its screen sits on an unrelated
707/// installer prompt, and that combination is exactly the case this type
708/// exists to distinguish.
709#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
710#[serde(tag = "kind", rename_all = "kebab-case")]
711pub enum PtyScreenState {
712    /// No observation has been made for this generation yet, or the most
713    /// recent observation attempt failed. A caller deciding whether to write
714    /// to the PTY blindly must treat this exactly like `NotAgent` -- there is
715    /// no "probably fine" reading of "unclassified". Never optimistic.
716    #[default]
717    Unknown,
718    /// The PTY's foreground process is neither the agent's own binary nor a
719    /// tolerated wrapper that spawns it. `observed_process` records what was
720    /// actually seen there, so an operator reading this gets "an update is
721    /// installing" rather than a bare timeout with no explanation.
722    NotAgent { observed_process: String },
723    /// The foreground process matches, but the screen itself is showing a
724    /// recognized blocking pattern -- workspace trust, authentication,
725    /// a vendor update, first-run onboarding. This is the state that wants a
726    /// human specifically, because resolving it means typing into the pane
727    /// rather than dispatching another agent turn. `gate` is a structured
728    /// `OperatorGateState`, not a bare label -- what TYPE of gate
729    /// (`kind`), what it is gating (`subject`), how it is answered
730    /// (`input`), and which choices were read off the screen (`options`),
731    /// so a consumer can close the gate or ask an operator a real question
732    /// instead of only ever surfacing a tag.
733    OperatorGate { gate: OperatorGateState },
734    /// The foreground process matches, but the screen shows the agent came
735    /// up wrong or fell over -- a crash/stack trace, an expired or rejected
736    /// login, a fatal startup error. Kept distinct from `OperatorGate` on
737    /// purpose: a gate is a screen a human resolves BY typing into it, while
738    /// nothing typed into this screen fixes it. Collapsing the two would
739    /// lose exactly the diagnosis an operator needs -- "waiting for you"
740    /// versus "broken". For write-gating it behaves like every other
741    /// non-`Ready` state (refused); the split buys a correct label, not
742    /// different gating. `reason` is a short classifier label, the same
743    /// shape `OperatorGate::gate` used to carry before it became a
744    /// structured type, never raw terminal text -- nothing in this enum
745    /// carries screen contents. Where a provider has a
746    /// `pty_sidecar` adapter bound, structured signals (rate limits arriving
747    /// as a `ProviderEvent`) remain the authority for those specific
748    /// conditions; this variant is the text-derived fallback, and the only
749    /// signal at all for providers that emit no structured events.
750    Failing { reason: String },
751    /// The foreground process matches AND no known gate or failure pattern
752    /// is showing. This is explicitly NOT a claim that the agent is idle,
753    /// waiting for input, or will do anything useful with a write -- it only
754    /// means the screen is not known to be showing something else. Reading
755    /// `Ready` as "safe to act on" beyond that is the over-read this type
756    /// exists to prevent. Note the asymmetry with the other four variants:
757    /// each of them fires from a single signal (process mismatch, or a
758    /// recognized gate/failure pattern alone), while `Ready` requires both
759    /// the process and text signals to agree -- a screen the text matcher
760    /// does not recognize never reaches `Ready` on that basis alone.
761    Ready,
762}
763
764impl PtyScreenState {
765    /// True unless this screen was READ as an obstacle. Refuses on the three
766    /// states that carry a finding -- a gate the operator must answer, a
767    /// foreign process, a failure -- and admits `Ready` and `Unknown` alike.
768    ///
769    /// `Unknown` admits deliberately. It does not mean "an obstacle we might
770    /// have missed", it means the matcher recognized nothing, and refusing on
771    /// it makes ignorance indistinguishable from a finding. Every provider
772    /// reaches `Ready` within a frame or two of spawn on process identity
773    /// alone, long before anything is on screen, so `Unknown` is mostly just
774    /// the moment before that -- and the screen is no longer where this
775    /// system decides what a session is doing. ACP carries that as protocol
776    /// state and never consults this predicate at all; a PTY is an operator's
777    /// surface first and a control channel second.
778    ///
779    /// What stays refused is what was actually read: writing a task into a
780    /// trust prompt or a login screen puts the text nowhere and leaves Enter
781    /// to pick a menu item blind. That is a finding, and findings still
782    /// count. Answering such a screen is not blocked and never was -- key
783    /// injection does not come through here.
784    pub fn admits_blind_write(&self) -> bool {
785        !matches!(
786            self,
787            Self::OperatorGate { .. } | Self::NotAgent { .. } | Self::Failing { .. }
788        )
789    }
790
791    /// Bounds check matching `ForegroundProcess::is_valid_for`: the carried
792    /// strings must be non-empty, control-character free, and within the
793    /// per-field byte caps, since both travel over the wire into an operator
794    /// UI and a raw process/gate label is not something to trust unbounded.
795    pub fn is_valid(&self) -> bool {
796        match self {
797            Self::Unknown | Self::Ready => true,
798            Self::NotAgent { observed_process } => {
799                !observed_process.trim().is_empty()
800                    && observed_process.len() <= FOREGROUND_PROCESS_NAME_MAX_BYTES
801                    && !observed_process.chars().any(char::is_control)
802            }
803            Self::OperatorGate { gate } => gate.is_valid(),
804            Self::Failing { reason } => {
805                !reason.trim().is_empty()
806                    && reason.len() <= PTY_SCREEN_GATE_NAME_MAX_BYTES
807                    && !reason.chars().any(char::is_control)
808            }
809        }
810    }
811}
812
813impl TerminalSize {
814    pub fn is_valid(self) -> bool {
815        (1..=TERMINAL_ROWS_MAX).contains(&self.rows)
816            && (1..=TERMINAL_COLUMNS_MAX).contains(&self.columns)
817    }
818}
819
820#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
821#[serde(rename_all = "kebab-case")]
822pub enum TransportKind {
823    Pty,
824    Pipe,
825    Acp,
826}
827
828#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
829pub struct CommandEnvelope {
830    pub id: CommandId,
831    pub command: ControlCommand,
832}
833
834#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
835#[serde(tag = "kind", rename_all = "kebab-case")]
836pub enum ControlCommand {
837    Register {
838        instance_id: AgentInstanceId,
839        agent_id: AgentId,
840        transport: TransportKind,
841    },
842    Start {
843        instance_id: AgentInstanceId,
844        runtime_policy: ProviderRuntimePolicy,
845        request: StartRequest,
846    },
847    Stop {
848        instance_id: AgentInstanceId,
849        force: bool,
850    },
851    SendInput {
852        instance_id: AgentInstanceId,
853        action: InputAction,
854    },
855    Resize {
856        instance_id: AgentInstanceId,
857        size: TerminalSize,
858    },
859    RefreshForeground {
860        instance_id: AgentInstanceId,
861    },
862    ProbeCapabilities {
863        instance_id: AgentInstanceId,
864        request: CapabilityProbeRequest,
865    },
866    DiscoverHistory {
867        instance_id: AgentInstanceId,
868        query: HistoryQuery,
869    },
870    LoadHistory {
871        instance_id: AgentInstanceId,
872        candidate_id: String,
873    },
874    Resume {
875        instance_id: AgentInstanceId,
876        target: ResumeTarget,
877        runtime_policy: ProviderRuntimePolicy,
878        request: ResumeLaunchRequest,
879    },
880    ResolveInteraction {
881        instance_id: AgentInstanceId,
882        generation: SessionGeneration,
883        interaction_id: ProviderInteractionId,
884        response: ProviderInteractionResponse,
885    },
886    /// Switch the session's ACP `session/set_mode` mode. ACP-transport only;
887    /// `gate4agent-engine`'s `set_session_mode` refuses any other transport.
888    SetSessionMode {
889        instance_id: AgentInstanceId,
890        mode_id: String,
891    },
892    /// Set one ACP `session/set_config_option` value (model, reasoning
893    /// effort, ...) -- the mechanism that supersedes session modes.
894    /// `value_json` is pre-serialized JSON text, same convention as
895    /// [`ProviderConfigOption::value_json`]: this crate does not depend on
896    /// `serde_json`, so parsing it into a value is the shell executor's job.
897    SetSessionConfigOption {
898        instance_id: AgentInstanceId,
899        option_id: String,
900        value_json: String,
901    },
902    /// Switch the session's active model via a provider vendor extension
903    /// (there is no `session/set_model` in the ACP spec proper). Wired end
904    /// to end on the wire and through this command regardless of whether
905    /// the current build shell can honor it for a given provider -- see
906    /// `gate4agent-shell-native`'s `SetSessionModel` effect arm for what it
907    /// actually does today.
908    SetSessionModel {
909        instance_id: AgentInstanceId,
910        model_id: String,
911    },
912    IngestProvider {
913        instance_id: AgentInstanceId,
914        generation: SessionGeneration,
915        source: ProviderSource,
916        source_sequence: u64,
917        events: Vec<ProviderEvent>,
918    },
919    Remove {
920        instance_id: AgentInstanceId,
921    },
922}
923
924impl ControlCommand {
925    pub fn instance_id(&self) -> AgentInstanceId {
926        match self {
927            Self::Register { instance_id, .. }
928            | Self::Start { instance_id, .. }
929            | Self::Stop { instance_id, .. }
930            | Self::SendInput { instance_id, .. }
931            | Self::Resize { instance_id, .. }
932            | Self::RefreshForeground { instance_id }
933            | Self::ProbeCapabilities { instance_id, .. }
934            | Self::DiscoverHistory { instance_id, .. }
935            | Self::LoadHistory { instance_id, .. }
936            | Self::Resume { instance_id, .. }
937            | Self::ResolveInteraction { instance_id, .. }
938            | Self::SetSessionMode { instance_id, .. }
939            | Self::SetSessionConfigOption { instance_id, .. }
940            | Self::SetSessionModel { instance_id, .. }
941            | Self::IngestProvider { instance_id, .. }
942            | Self::Remove { instance_id } => *instance_id,
943        }
944    }
945}
946
947#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
948pub struct EffectEnvelope {
949    pub operation_id: OperationId,
950    pub instance_id: AgentInstanceId,
951    pub generation: SessionGeneration,
952    pub effect: ControlEffect,
953}
954
955/// Route proof an effect executor must obtain immediately before a PTY write.
956///
957/// This is carried by the effect rather than inferred by a product shell so
958/// local, hosted, and future browser-facing executors enforce the same rule.
959#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
960#[serde(tag = "kind", rename_all = "kebab-case")]
961pub enum ForegroundRequirement {
962    /// Explicit terminal text and controls are direct user terminal input.
963    Any,
964    /// Semantic input must target the session's configured agent.
965    Agent { agent_id: AgentId },
966    /// Intentional shell syntax may be written only while a shell owns the PTY.
967    Shell,
968}
969
970#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
971#[serde(tag = "kind", rename_all = "kebab-case")]
972pub enum ControlEffect {
973    Spawn {
974        agent_id: AgentId,
975        transport: TransportKind,
976        runtime_policy: ProviderRuntimePolicy,
977        request: StartRequest,
978    },
979    Stop {
980        force: bool,
981    },
982    WriteInput {
983        input: PreparedInput,
984        required_foreground: ForegroundRequirement,
985    },
986    SubmitPrompt {
987        prompt: String,
988    },
989    Interrupt,
990    Resize {
991        size: TerminalSize,
992    },
993    ObserveForeground,
994    ProbeCapabilities {
995        agent_id: AgentId,
996        request: CapabilityProbeRequest,
997    },
998    DiscoverHistory {
999        agent_id: AgentId,
1000        query: HistoryQuery,
1001    },
1002    LoadHistory {
1003        agent_id: AgentId,
1004        candidate_id: String,
1005    },
1006    AuthorizeResume {
1007        agent_id: AgentId,
1008        target: ResumeAuthorityTarget,
1009        request: ResumeLaunchRequest,
1010    },
1011    SpawnResume {
1012        agent_id: AgentId,
1013        transport: TransportKind,
1014        provider_session: ProviderSessionIdentity,
1015        runtime_policy: ProviderRuntimePolicy,
1016        request: ResumeLaunchRequest,
1017    },
1018    ResolveInteraction {
1019        target: ProviderInteractionTarget,
1020        response: ProviderInteractionResponse,
1021    },
1022    SetSessionMode {
1023        mode_id: String,
1024    },
1025    SetSessionConfigOption {
1026        option_id: String,
1027        value_json: String,
1028    },
1029    SetSessionModel {
1030        model_id: String,
1031    },
1032}
1033
1034#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1035pub struct ObservationEnvelope {
1036    pub operation_id: Option<OperationId>,
1037    pub instance_id: AgentInstanceId,
1038    pub generation: SessionGeneration,
1039    pub observation: ControlObservation,
1040}
1041
1042#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1043#[serde(tag = "kind", rename_all = "kebab-case")]
1044pub enum ControlObservation {
1045    Spawned {
1046        process_id: Option<u32>,
1047    },
1048    SpawnFailed {
1049        message: String,
1050    },
1051    ProcessExited {
1052        exit_code: Option<i32>,
1053        final_terminal: Option<TerminalFrame>,
1054    },
1055    StopCompleted {
1056        forced: bool,
1057        exit_code: Option<i32>,
1058        final_terminal: Option<TerminalFrame>,
1059    },
1060    StopFailed {
1061        message: String,
1062    },
1063    InputCompleted,
1064    InputFailed {
1065        message: String,
1066    },
1067    ResizeCompleted {
1068        size: TerminalSize,
1069    },
1070    ResizeFailed {
1071        message: String,
1072    },
1073    ForegroundObserved {
1074        process: ForegroundProcess,
1075    },
1076    ForegroundFailed {
1077        message: String,
1078    },
1079    CapabilitiesProbed {
1080        session_option_models: Vec<CapabilityModelSummary>,
1081    },
1082    CapabilityProbeFailed {
1083        failure: CapabilityProbeFailure,
1084    },
1085    HistoryDiscovered {
1086        candidates: Vec<HistoryCandidateSummary>,
1087    },
1088    HistoryLoaded {
1089        session: HistorySessionRecord,
1090    },
1091    HistoryFailed {
1092        message: String,
1093    },
1094    ResumeAuthorized {
1095        provider_session: ProviderSessionIdentity,
1096    },
1097    ResumeDenied {
1098        reason: String,
1099    },
1100    ResumeFailed {
1101        message: String,
1102    },
1103    InteractionResolutionCompleted {
1104        interaction_id: ProviderInteractionId,
1105    },
1106    InteractionResolutionFailed {
1107        interaction_id: ProviderInteractionId,
1108        message: String,
1109    },
1110    /// `ControlEffect::SetSessionMode` succeeded; `mode_id` echoes back the
1111    /// mode the shell executor actually confirmed with the agent (Resize's
1112    /// `ResizeCompleted { size }` is the same convention).
1113    SessionModeSet {
1114        mode_id: String,
1115    },
1116    SessionModeSetFailed {
1117        message: String,
1118    },
1119    SessionConfigOptionSet {
1120        option_id: String,
1121    },
1122    SessionConfigOptionSetFailed {
1123        message: String,
1124    },
1125    SessionModelSet {
1126        model_id: String,
1127    },
1128    SessionModelSetFailed {
1129        message: String,
1130    },
1131    TerminalFrame {
1132        frame: TerminalFrame,
1133    },
1134    TerminalStale {
1135        message: String,
1136    },
1137    /// The node's merged `PtyScreenState` classification changed. Emitted
1138    /// only on an actual change, never per frame and never per foreground
1139    /// probe -- the node compares against its own last-published value
1140    /// before sending this, so a subscriber never has to de-duplicate.
1141    /// This is node-internal (shell -> engine), the same lane as
1142    /// `TerminalFrame`/`TerminalStale`, not a wire type: neither the node
1143    /// nor the c2 protocol carries `ControlObservation` across a process
1144    /// boundary, so this variant needs no version gate.
1145    ScreenState {
1146        state: PtyScreenState,
1147    },
1148    ProviderEvent {
1149        source: ProviderSource,
1150        sequence: u64,
1151        event: ProviderEvent,
1152    },
1153    ProviderGap {
1154        source: ProviderSource,
1155        source_sequence: u64,
1156        missed: u64,
1157    },
1158}
1159
1160impl ControlObservation {
1161    pub fn requires_operation_id(&self) -> bool {
1162        !matches!(
1163            self,
1164            Self::ProcessExited { .. }
1165                | Self::TerminalFrame { .. }
1166                | Self::TerminalStale { .. }
1167                | Self::ScreenState { .. }
1168                | Self::ProviderEvent { .. }
1169                | Self::ProviderGap { .. }
1170        )
1171    }
1172}
1173
1174#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1175#[serde(tag = "kind", rename_all = "kebab-case")]
1176pub enum SessionStatus {
1177    Registered,
1178    Starting,
1179    Running,
1180    Stopping,
1181    Exited { exit_code: Option<i32> },
1182    Failed { message: String },
1183}
1184
1185impl SessionStatus {
1186    /// Whether a `Remove` command for a session in this status will be
1187    /// accepted rather than rejected as an invalid transition.
1188    ///
1189    /// It lives here, on the status itself, because two crates need the
1190    /// same answer and neither may depend on the other: `gate4agent-engine`
1191    /// enforces it in `Gate4AgentEngine::remove`, and `gate4agent-node`
1192    /// consults it in `wait_until_removed` BEFORE re-dispatching, because a
1193    /// rejected command is not free -- it publishes a `ControlEventKind::
1194    /// CommandRejected` to every subscriber and writes a WARN line. Ungated,
1195    /// one ordinary teardown produced 241 rejections in 1.6 seconds: the
1196    /// entire `Stopping` window at one known-doomed dispatch per 2ms tick.
1197    ///
1198    /// Exhaustive on purpose -- a status added to this enum without a
1199    /// decision here fails to compile rather than silently joining one set.
1200    pub fn allows_remove(&self) -> bool {
1201        match self {
1202            Self::Starting | Self::Running | Self::Stopping => false,
1203            Self::Registered | Self::Exited { .. } | Self::Failed { .. } => true,
1204        }
1205    }
1206}
1207
1208#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1209pub struct SessionSnapshot {
1210    pub instance_id: AgentInstanceId,
1211    pub agent_id: AgentId,
1212    pub transport: TransportKind,
1213    pub generation: SessionGeneration,
1214    pub status: SessionStatus,
1215    pub pending_operation: Option<OperationId>,
1216    pub pending_input: Option<PreparedInputKind>,
1217    pub process_id: Option<u32>,
1218    pub terminal_size: Option<TerminalSize>,
1219    pub terminal_frame: Option<TerminalFrame>,
1220    pub terminal_stale: Option<String>,
1221    pub session_options: Option<SessionOptionSelection>,
1222    pub capabilities: CapabilitySnapshot,
1223    pub history: HistorySnapshot,
1224    pub resume: ResumeSnapshot,
1225    pub foreground: ForegroundSnapshot,
1226    pub provider: ProviderSnapshot,
1227    /// The session's CURRENT screen classification, as opposed to
1228    /// `terminal_frame`'s per-frame stamp -- both are read off one value
1229    /// computed at the node, but this one is what a consumer reads when it
1230    /// wants the state now without subscribing to terminal frames, which is
1231    /// what makes gating a dispatch possible for a caller holding only the
1232    /// session inventory.
1233    #[serde(default)]
1234    pub screen_state: PtyScreenState,
1235}
1236
1237#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
1238pub struct TokenUsage {
1239    pub input_tokens: u64,
1240    pub output_tokens: u64,
1241    pub cache_read_tokens: u64,
1242    pub cache_write_tokens: u64,
1243    pub reasoning_tokens: u64,
1244    pub context_window: Option<u64>,
1245}
1246
1247#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
1248pub struct ContextWindowUsage {
1249    pub uncached_input_tokens: u64,
1250    pub cache_read_tokens: u64,
1251    pub cache_write_tokens: u64,
1252    pub output_tokens: u64,
1253    pub unattributed_tokens: u64,
1254    pub used_tokens: u64,
1255    pub capacity_tokens: u64,
1256}
1257
1258impl ContextWindowUsage {
1259    pub fn validate(&self) -> Result<(), ProviderEventValidationError> {
1260        if self.capacity_tokens == 0 {
1261            return Err(ProviderEventValidationError::ZeroContextWindowCapacity);
1262        }
1263        let segment_sum = self
1264            .uncached_input_tokens
1265            .checked_add(self.cache_read_tokens)
1266            .and_then(|sum| sum.checked_add(self.cache_write_tokens))
1267            .and_then(|sum| sum.checked_add(self.output_tokens))
1268            .and_then(|sum| sum.checked_add(self.unattributed_tokens))
1269            .ok_or(ProviderEventValidationError::ContextWindowSegmentsOverflow)?;
1270        if segment_sum != self.used_tokens {
1271            return Err(ProviderEventValidationError::ContextWindowSegmentsMismatch {
1272                segment_sum,
1273                used_tokens: self.used_tokens,
1274            });
1275        }
1276        Ok(())
1277    }
1278}
1279
1280#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
1281#[serde(rename_all = "kebab-case")]
1282pub enum ProviderInteractionKind {
1283    Approval,
1284    Question,
1285}
1286
1287/// Which class of budget a `ProviderEvent::RateLimited` observation
1288/// concerns. This is the wire-typed counterpart of `gate4agent`'s own
1289/// (source-of-truth) `RateLimitType` -- kept as its own type here, rather
1290/// than imported, because this crate's contract forbids depending on
1291/// `gate4agent` (see this crate's `CLAUDE.md`); the conversion from the
1292/// detector's enum lives in the shell that already depends on both.
1293#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
1294#[serde(rename_all = "kebab-case")]
1295pub enum ProviderRateLimitKind {
1296    /// Session/hourly limit (codex: the rolling 5h window).
1297    Session,
1298    /// Daily limit.
1299    Daily,
1300    /// Weekly limit.
1301    Weekly,
1302    /// Limit type could not be determined from the matched text.
1303    Unknown,
1304}
1305
1306#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
1307#[serde(rename_all = "kebab-case")]
1308pub enum ProviderInteractionOutcome {
1309    Approved,
1310    Answered,
1311    Denied,
1312    Interrupted,
1313    TurnEnded,
1314    Superseded,
1315}
1316
1317#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
1318#[serde(rename_all = "kebab-case")]
1319pub enum ProviderInteractionResponseKind {
1320    ApproveOnce,
1321    Deny,
1322    Answer,
1323}
1324
1325#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1326#[serde(tag = "kind", rename_all = "kebab-case")]
1327pub enum ProviderInteractionResponse {
1328    ApproveOnce,
1329    Deny,
1330    Answer { text: String },
1331}
1332
1333impl ProviderInteractionResponse {
1334    pub fn kind(&self) -> ProviderInteractionResponseKind {
1335        match self {
1336            Self::ApproveOnce => ProviderInteractionResponseKind::ApproveOnce,
1337            Self::Deny => ProviderInteractionResponseKind::Deny,
1338            Self::Answer { .. } => ProviderInteractionResponseKind::Answer,
1339        }
1340    }
1341
1342    pub fn outcome(&self) -> ProviderInteractionOutcome {
1343        match self {
1344            Self::ApproveOnce => ProviderInteractionOutcome::Approved,
1345            Self::Deny => ProviderInteractionOutcome::Denied,
1346            Self::Answer { .. } => ProviderInteractionOutcome::Answered,
1347        }
1348    }
1349
1350    pub fn validate_for(
1351        &self,
1352        interaction_kind: ProviderInteractionKind,
1353    ) -> Result<(), ProviderInteractionResponseError> {
1354        match (interaction_kind, self) {
1355            (ProviderInteractionKind::Approval, Self::ApproveOnce)
1356            | (ProviderInteractionKind::Approval, Self::Deny)
1357            | (ProviderInteractionKind::Question, Self::Deny) => Ok(()),
1358            (ProviderInteractionKind::Question, Self::Answer { text }) => {
1359                if text.trim().is_empty() {
1360                    return Err(ProviderInteractionResponseError::EmptyAnswer);
1361                }
1362                let has_unsafe_control = text.chars().any(|character| {
1363                    character.is_control() && !matches!(character, '\n' | '\r' | '\t')
1364                });
1365                if text.len() > PROVIDER_INTERACTION_RESPONSE_MAX_BYTES || has_unsafe_control {
1366                    return Err(ProviderInteractionResponseError::InvalidAnswer {
1367                        max: PROVIDER_INTERACTION_RESPONSE_MAX_BYTES,
1368                    });
1369                }
1370                Ok(())
1371            }
1372            (ProviderInteractionKind::Approval, Self::Answer { .. }) => {
1373                Err(ProviderInteractionResponseError::AnswerRequiresQuestion)
1374            }
1375            (ProviderInteractionKind::Question, Self::ApproveOnce) => {
1376                Err(ProviderInteractionResponseError::ApprovalRequiresApproval)
1377            }
1378        }
1379    }
1380}
1381
1382#[derive(Clone, Debug, Eq, Error, PartialEq)]
1383pub enum ProviderInteractionResponseError {
1384    #[error("interaction answer is required")]
1385    EmptyAnswer,
1386    #[error("interaction answer contains controls or exceeds {max} bytes")]
1387    InvalidAnswer { max: usize },
1388    #[error("an answer response requires a question interaction")]
1389    AnswerRequiresQuestion,
1390    #[error("an approve-once response requires an approval interaction")]
1391    ApprovalRequiresApproval,
1392}
1393
1394#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
1395#[serde(rename_all = "kebab-case")]
1396pub enum ProviderSessionKey {
1397    SessionId,
1398    ConversationId,
1399}
1400
1401#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1402pub struct ProviderSessionIdentity {
1403    pub key: ProviderSessionKey,
1404    pub id: String,
1405    pub transcript_path: Option<String>,
1406}
1407
1408impl ProviderSessionIdentity {
1409    pub fn validate(&self) -> Result<(), ProviderEventValidationError> {
1410        validate_required("provider session id", &self.id, PROVIDER_EVENT_ID_MAX_BYTES)?;
1411        if self.id.starts_with('-') {
1412            return Err(ProviderEventValidationError::InvalidField {
1413                field: "provider session id",
1414                max: PROVIDER_EVENT_ID_MAX_BYTES,
1415            });
1416        }
1417        if let Some(path) = &self.transcript_path {
1418            validate_required(
1419                "provider transcript path",
1420                path,
1421                PROVIDER_SESSION_LOCATOR_MAX_BYTES,
1422            )?;
1423        }
1424        Ok(())
1425    }
1426}
1427
1428#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1429pub struct ProviderSubagent {
1430    pub source: ProviderSource,
1431    pub provider_agent_id: String,
1432    pub agent_type: Option<String>,
1433    pub description: Option<String>,
1434}
1435
1436/// Priority of a single [`ProviderPlanStep`], carried on
1437/// `ProviderEvent::Plan`.
1438#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
1439#[serde(rename_all = "kebab-case")]
1440pub enum ProviderPlanPriority {
1441    High,
1442    Medium,
1443    Low,
1444}
1445
1446/// Status of a single [`ProviderPlanStep`], carried on
1447/// `ProviderEvent::Plan`.
1448#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
1449#[serde(rename_all = "kebab-case")]
1450pub enum ProviderPlanStatus {
1451    Pending,
1452    InProgress,
1453    Completed,
1454}
1455
1456/// One step of the agent's execution plan (ACP transport's `plan`
1457/// update). `ProviderEvent::Plan` always carries the FULL plan snapshot,
1458/// never a delta.
1459#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1460pub struct ProviderPlanStep {
1461    pub content: String,
1462    pub priority: ProviderPlanPriority,
1463    pub status: ProviderPlanStatus,
1464}
1465
1466/// A single slash-style command the agent advertises (ACP transport's
1467/// `available_commands_update`).
1468#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1469pub struct ProviderAvailableCommand {
1470    pub name: String,
1471    pub description: String,
1472    pub input_hint: Option<String>,
1473}
1474
1475/// One mode the agent advertised as selectable, read from ACP's
1476/// `session/new` handshake result (`AcpSession::available_modes()`) and
1477/// carried on [`ProviderEvent::ModeChanged`] alongside the id that changed.
1478/// Mirrors `gate4agent-node-protocol`'s `AgentStreamNamedIdV1`
1479/// field-for-field; this crate does not depend on that one (see this
1480/// crate's own `CLAUDE.md`), so the shape is repeated rather than shared.
1481#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1482pub struct ProviderModeInfo {
1483    pub id: String,
1484    pub name: String,
1485    pub description: Option<String>,
1486}
1487
1488/// The kind of a [`ProviderConfigOption`] -- `select` (choose one of
1489/// `choices`) or `boolean` (toggle the option's current value). `Unknown`
1490/// is the fallback for a kind string this build does not recognize.
1491#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
1492#[serde(rename_all = "kebab-case")]
1493pub enum ProviderConfigOptionKind {
1494    Select,
1495    Boolean,
1496    Unknown,
1497}
1498
1499/// One selectable value of a `select`-kind [`ProviderConfigOption`].
1500/// `value_json` is the choice's value pre-serialized to JSON text (this
1501/// crate is a pure data contract and does not depend on `serde_json`; see
1502/// `ProviderConfigOption::value_json` for the same convention applied to
1503/// the option's own current value).
1504#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1505pub struct ProviderConfigChoice {
1506    pub value_json: String,
1507    pub label: Option<String>,
1508}
1509
1510/// One session configuration setting -- the mechanism ACP uses to change
1511/// model, reasoning effort, and similar settings, superseding session
1512/// modes. `ProviderEvent::ConfigOptionsUpdated` always carries the FULL
1513/// current set, never a delta.
1514#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1515pub struct ProviderConfigOption {
1516    pub id: String,
1517    pub name: String,
1518    pub description: Option<String>,
1519    pub category: Option<String>,
1520    pub kind: ProviderConfigOptionKind,
1521    pub value_json: String,
1522    pub choices: Vec<ProviderConfigChoice>,
1523}
1524
1525/// WHO decided a host request the agent sent to the ACP host -- see
1526/// [`ProviderEvent::HostRequestObserved`]. Mirrors `gate4agent`'s own
1527/// `HostDecisionAuthority` one-for-one; this crate cannot depend on
1528/// `gate4agent` (see this crate's `CLAUDE.md`), so the value is converted at
1529/// the boundary that already depends on both (`gate4agent-shell-native`).
1530#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
1531#[serde(rename_all = "kebab-case")]
1532pub enum HostDecisionAuthority {
1533    /// The dangerous-command gate forced this outcome ahead of `HostPolicy`
1534    /// -- `terminal/create` and `execute`-kind `session/request_permission`
1535    /// only. Always a denial.
1536    Gate,
1537    /// `HostPolicy` (`Yolo`/`Auto`/`ReadOnly`/`Deny`) decided the request
1538    /// the instant it arrived -- the default path for every request that
1539    /// is neither gate-blocked nor deferred.
1540    Policy,
1541    /// An operator answered a `session/request_permission` call that had
1542    /// been left `HostRequestDecision::Deferred`.
1543    Operator,
1544    /// A `session/request_permission` call left `HostRequestDecision::
1545    /// Deferred` reached its deadline with no operator answer, so
1546    /// `HostPolicy` -- the SAME policy that would have answered it
1547    /// immediately had deferral never been enabled -- decided it instead.
1548    /// Deliberately its own variant rather than `Policy`: folding it in
1549    /// would erase the fact that an operator was asked first and nobody
1550    /// answered in time. Equally deliberately not `Operator`: no human
1551    /// made this choice.
1552    DeadlinePolicy,
1553}
1554
1555/// A typed answer to "what happened to this host request" -- see
1556/// [`ProviderEvent::HostRequestObserved`]. Mirrors `gate4agent`'s own
1557/// `HostRequestDecision` one-for-one; see [`HostDecisionAuthority`]'s doc
1558/// comment for why this crate keeps its own copy rather than importing it.
1559#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
1560#[serde(tag = "kind", rename_all = "kebab-case")]
1561pub enum HostRequestDecision {
1562    /// The request was allowed. `by` is who made that call.
1563    Granted { by: HostDecisionAuthority },
1564    /// The request was refused. `by` is who made that call.
1565    Denied { by: HostDecisionAuthority },
1566    /// The request has arrived and been recorded, but nothing has decided
1567    /// it yet. A later `ProviderEvent::HostRequestObserved` reports the
1568    /// eventual `Granted`/`Denied` outcome once one exists.
1569    Deferred,
1570}
1571
1572/// Whether a `Granted` host request's underlying operation actually ran
1573/// without an I/O or execution problem -- see [`ProviderEvent::
1574/// HostRequestObserved`]. Mirrors `gate4agent`'s own `HostRequestOutcome`
1575/// one-for-one; see [`HostDecisionAuthority`]'s doc comment for why this
1576/// crate keeps its own copy rather than importing it.
1577///
1578/// Only meaningful paired with `HostRequestDecision::Granted`: a `Denied` or
1579/// `Deferred` request never attempted its underlying operation, so it is
1580/// always `Executed` there for lack of anything to have failed running.
1581#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1582#[serde(tag = "kind", rename_all = "kebab-case")]
1583pub enum HostRequestOutcome {
1584    /// No execution problem -- either the request ran cleanly, or (for
1585    /// `Denied`/`Deferred`) no execution was ever attempted to fail.
1586    Executed,
1587    /// The request was authorized but failed while running it. `error` is
1588    /// the bounded underlying I/O/execution failure text (e.g. an OS error
1589    /// from a spawn call) -- never a policy/gate refusal message, which
1590    /// stays on `HostRequestObserved::reason` instead, exactly as it did
1591    /// before this variant existed.
1592    Failed { error: String },
1593}
1594
1595impl Default for HostRequestOutcome {
1596    /// Backs `ProviderEvent::HostRequestObserved::outcome`'s
1597    /// `#[serde(default)]` -- mirrors `gate4agent_observation_protocol`'s
1598    /// own `HostRequestOutcomeV1::default`, the same field added at the
1599    /// same commit; see that type's doc comment for why a record from
1600    /// before this field existed always means `Executed`.
1601    fn default() -> Self {
1602        Self::Executed
1603    }
1604}
1605
1606/// One option the agent offered on a `session/request_permission`-style
1607/// interaction, carried on `ProviderEvent::InteractionRequested::options`
1608/// exactly as the agent gave it -- see ACP's `PermissionOption` in
1609/// `src/acp/protocol.rs` (`option_id`, `name`, `kind`). This crate cannot
1610/// depend on `gate4agent` (see this crate's own `CLAUDE.md`), so the shape
1611/// is repeated here rather than shared, the same precedent
1612/// `ProviderRateLimitKind` already sets for a wire-typed mirror of a
1613/// `gate4agent`-side enum.
1614///
1615/// `kind` is carried as the ACP wire's own snake_case string (`allow_once`,
1616/// `allow_always`, `reject_once`, `reject_always`) rather than re-typed
1617/// into an enum here: nothing in this crate interprets `kind`, an operator
1618/// surface only ever displays it, and `gate4agent-node-protocol`'s
1619/// `AgentStreamInteractionOptionV1::kind` -- the wire shape this eventually
1620/// becomes -- is the same plain `String`, so a round trip through an enum
1621/// here would buy no safety, only an extra conversion.
1622#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1623pub struct ProviderInteractionOption {
1624    pub option_id: String,
1625    pub name: String,
1626    pub kind: String,
1627}
1628
1629/// Why an ACP turn stopped -- mirrors `gate4agent`'s own `StopReason`
1630/// (`src/core/types.rs`) one-for-one; this crate cannot depend on
1631/// `gate4agent` (see this crate's own `CLAUDE.md`), the same reason every
1632/// other wire-typed mirror here exists (`ProviderRateLimitKind`,
1633/// `ProviderInteractionOption`, ...). `ProviderError` is synthesized
1634/// locally by `gate4agent`'s ACP session when `session/prompt` itself
1635/// answers with a JSON-RPC error instead of a normal response -- see that
1636/// type's own doc comment for the live Codex fixture this shape was
1637/// measured against.
1638#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1639#[serde(tag = "kind", rename_all = "kebab-case")]
1640pub enum ProviderStopReason {
1641    EndTurn,
1642    MaxTokens,
1643    MaxTurnRequests,
1644    Refusal,
1645    Cancelled,
1646    Other { value: String },
1647    ProviderError {
1648        code: i32,
1649        message: String,
1650        vendor_code: Option<String>,
1651    },
1652}
1653
1654impl ProviderStopReason {
1655    fn validate_ingress(&self) -> Result<(), ProviderEventValidationError> {
1656        match self {
1657            Self::EndTurn | Self::MaxTokens | Self::MaxTurnRequests | Self::Refusal | Self::Cancelled => {
1658                Ok(())
1659            }
1660            Self::Other { value } => validate_text("stop reason", value, PROVIDER_EVENT_ID_MAX_BYTES),
1661            Self::ProviderError { message, vendor_code, .. } => {
1662                validate_text("provider error message", message, PROVIDER_EVENT_TEXT_MAX_BYTES)?;
1663                if let Some(code) = vendor_code {
1664                    validate_text("provider error vendor code", code, PROVIDER_EVENT_ID_MAX_BYTES)?;
1665                }
1666                Ok(())
1667            }
1668        }
1669    }
1670}
1671
1672#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1673#[serde(tag = "kind", rename_all = "kebab-case")]
1674pub enum ProviderEvent {
1675    SessionStarted {
1676        session_id: String,
1677        model: String,
1678        tools: Vec<String>,
1679    },
1680    SessionIdentityObserved {
1681        identity: ProviderSessionIdentity,
1682    },
1683    TurnStarted {
1684        prompt: Option<String>,
1685    },
1686    WorkingObserved,
1687    Text {
1688        text: String,
1689        is_delta: bool,
1690    },
1691    Thinking {
1692        text: String,
1693    },
1694    ToolStarted {
1695        id: String,
1696        name: String,
1697        input_json: String,
1698        agent_id: Option<String>,
1699    },
1700    ToolCompleted {
1701        id: String,
1702        output: String,
1703        is_error: bool,
1704        duration_ms: Option<u64>,
1705        agent_id: Option<String>,
1706        /// ACP `tool_call_update._meta.nonExecutionKind` -- Claude's own
1707        /// vocabulary for WHY the tool never actually ran:
1708        /// `"user-rejected"`, `"permission-rule"`, `"interrupted"`,
1709        /// `"cancelled"`. `None` for a provider that sends no such field,
1710        /// or a call that genuinely ran and either succeeded or failed for
1711        /// real. `#[serde(default)]` reads a durable record written
1712        /// before this field existed as `None` -- honest, since that
1713        /// record could not have carried it either way.
1714        #[serde(default)]
1715        non_execution_kind: Option<String>,
1716    },
1717    TurnCompleted {
1718        usage: TokenUsage,
1719        is_cumulative: bool,
1720    },
1721    ContextWindowUsage {
1722        usage: ContextWindowUsage,
1723    },
1724    TurnInterrupted,
1725    SessionEnded {
1726        result: String,
1727        cost_usd: Option<String>,
1728        is_error: bool,
1729        /// Why the turn stopped, when the transport is ACP and one exists
1730        /// -- mirrors `gate4agent`'s own `StopReason` (`src/core/
1731        /// types.rs`) one-for-one; this crate cannot depend on
1732        /// `gate4agent` (see this crate's own `CLAUDE.md`), the same
1733        /// reason every other wire-typed mirror here exists. `None` for a
1734        /// non-ACP transport, and for a durable record written before
1735        /// this field existed (`#[serde(default)]`).
1736        #[serde(default)]
1737        stop_reason: Option<ProviderStopReason>,
1738    },
1739    Error {
1740        message: String,
1741    },
1742    Ready,
1743    InteractionRequested {
1744        request_id: Option<String>,
1745        interaction_kind: ProviderInteractionKind,
1746        tool_name: String,
1747        /// The ACP `PermissionToolCall.title` -- the human sentence
1748        /// describing this particular call (e.g. "Edit files"), when the
1749        /// source carries one. `None` means this source never carries a
1750        /// title at all (a PTY- or hook-sourced interaction has no such
1751        /// field to read -- see `gate4agent-shell-native`'s construction
1752        /// sites), never that a title was dropped after arriving.
1753        title: Option<String>,
1754        prompt: String,
1755        /// The concrete options the agent is willing to accept a decision
1756        /// from, in the agent's own order -- an ACP `PermissionOption`
1757        /// list verbatim (see `ProviderInteractionOption`). Empty means
1758        /// this source has no option list to offer at all (PTY- and
1759        /// hook-sourced interactions), never that the agent offered zero
1760        /// and one was dropped.
1761        options: Vec<ProviderInteractionOption>,
1762        agent_id: Option<String>,
1763    },
1764    InteractionResolved {
1765        request_id: String,
1766        outcome: ProviderInteractionOutcome,
1767    },
1768    SubagentStarted {
1769        agent_id: String,
1770        agent_type: Option<String>,
1771        description: Option<String>,
1772    },
1773    SubagentStopped {
1774        agent_id: String,
1775    },
1776    RateLimited {
1777        limit_type: ProviderRateLimitKind,
1778        resets_at: Option<String>,
1779        usage_percent: Option<String>,
1780        raw_message: String,
1781    },
1782    /// The agent sent a JSON-RPC request to the ACP host -- `session/
1783    /// request_permission`, `fs/read_text_file`, `fs/write_text_file`,
1784    /// `terminal/create`, `terminal/output`, `terminal/wait_for_exit`,
1785    /// `terminal/kill`, `terminal/release`. `decision` is read off the
1786    /// host's own answer, not re-derived here -- see the source
1787    /// (`gate4agent`'s `AgentEvent::RpcIncomingRequest::decision`) for
1788    /// exactly how. A `session/request_permission` call the host chose to
1789    /// defer to an operator arrives here TWICE under different `method`/
1790    /// `params_json` snapshots but the SAME logical request: once as
1791    /// `HostRequestDecision::Deferred` when it is recorded, then again as
1792    /// `Granted`/`Denied` once `HostPolicy` (`Yolo`/`Auto`/`ReadOnly`/`Deny`)
1793    /// or an operator decides it -- see `HostRequestDecision` and
1794    /// `HostDecisionAuthority` for what each of the four ways a request can
1795    /// end up decided actually means. This event does not change what the
1796    /// host does; it exists purely so an operator sees the request AND the
1797    /// decision instead of the request silently disappearing into a
1798    /// refusal nobody downstream ever hears about.
1799    HostRequestObserved {
1800        method: String,
1801        params_json: String,
1802        decision: HostRequestDecision,
1803        /// Whether an authorized (`Granted`) request actually ran cleanly
1804        /// or failed doing so -- see [`HostRequestOutcome`]'s own doc
1805        /// comment for why this is a separate field from `decision` rather
1806        /// than a third flavor of `Denied`. Always `Executed` for `Denied`/
1807        /// `Deferred` (nothing ran to fail). `#[serde(default)]` reads a
1808        /// record from before this field existed as `Executed` -- see
1809        /// `HostRequestOutcome::default`.
1810        #[serde(default)]
1811        outcome: HostRequestOutcome,
1812        /// The refusal text behind a `Denied` decision, when this session
1813        /// actually computed one -- the dangerous-command gate's own
1814        /// "blocked by dangerous-command gate: rule=…, argument=…", or
1815        /// `HostPolicy`'s fixed "denied by host policy" sentence. `None` for
1816        /// every `Granted`/`Deferred` decision (there is nothing to explain),
1817        /// and for a `Denied` decision this session's host handler did not
1818        /// attach text to -- never a placeholder standing in for a reason
1819        /// nobody computed. Bounded and verbatim, the same
1820        /// `PROVIDER_EVENT_TEXT_MAX_BYTES` bound `ToolCompleted::output`
1821        /// uses -- never summarised or rewritten at the point this is
1822        /// minted (`gate4agent-shell-native`'s `provider_event`).
1823        reason: Option<String>,
1824    },
1825    /// A JSON-RPC notification the reader received but could not classify
1826    /// into any other `ProviderEvent` -- most commonly a `session/update`
1827    /// whose `update` shape none of the known kinds matched, but also any
1828    /// other notification method this build has no mapping for. This is a
1829    /// raw protocol echo, NOT a normal operational event: nothing here has
1830    /// been validated against a known shape, so a consumer must treat
1831    /// `payload_json` as opaque vendor JSON, not a fact to act on.
1832    UnrecognizedNotification {
1833        method: String,
1834        payload_json: String,
1835    },
1836    /// Echo of a user message, replayed when resuming a loaded session
1837    /// (ACP transport's `user_message_chunk`).
1838    UserMessage {
1839        text: String,
1840        is_delta: bool,
1841    },
1842    /// The agent's full execution plan, replacing any plan reported
1843    /// before it (ACP transport's `plan`).
1844    Plan {
1845        steps: Vec<ProviderPlanStep>,
1846    },
1847    /// The agent's slash-command catalog changed (ACP transport's
1848    /// `available_commands_update`).
1849    AvailableCommandsUpdated {
1850        commands: Vec<ProviderAvailableCommand>,
1851    },
1852    /// The session's active mode changed (ACP transport's
1853    /// `current_mode_update`). `available` is the mode catalogue the agent
1854    /// returned at `session/new` (`AcpSession::available_modes()`), read
1855    /// back at the moment this event is minted -- `current_mode_update`
1856    /// itself carries only the new id, never the catalogue. ACP orders
1857    /// `session/new` strictly before any `session/update`, and the
1858    /// catalogue never changes after handshake, so by the time a
1859    /// `ModeChanged` can exist the same session object's catalogue is
1860    /// already the real one: an empty `available` here always means the
1861    /// agent announced zero modes, never "not read yet".
1862    ModeChanged {
1863        mode_id: String,
1864        available: Vec<ProviderModeInfo>,
1865    },
1866    /// Session metadata changed; only the fields that actually changed
1867    /// are populated (ACP transport's `session_info_update`).
1868    SessionInfoUpdated {
1869        title: Option<String>,
1870    },
1871    /// Context-window consumption and, when reported, turn cost (ACP
1872    /// transport's `usage_update`). `cost_amount` is a decimal string, not
1873    /// `f64`, for the same reason `SessionEnded::cost_usd` is -- so this
1874    /// type can keep deriving `Eq`.
1875    UsageUpdated {
1876        used_tokens: Option<u64>,
1877        context_window: Option<u64>,
1878        cost_amount: Option<String>,
1879        cost_currency: Option<String>,
1880    },
1881    /// The full current set of session configuration options (ACP
1882    /// transport's `config_option_update`).
1883    ConfigOptionsUpdated {
1884        options: Vec<ProviderConfigOption>,
1885    },
1886}
1887
1888impl ProviderEvent {
1889    pub fn validate_ingress(&self) -> Result<(), ProviderEventValidationError> {
1890        match self {
1891            Self::SessionStarted {
1892                session_id,
1893                model,
1894                tools,
1895            } => {
1896                validate_required("session_id", session_id, PROVIDER_EVENT_ID_MAX_BYTES)?;
1897                validate_identifier("model", model, PROVIDER_EVENT_ID_MAX_BYTES)?;
1898                if tools.len() > PROVIDER_EVENT_TOOLS_MAX {
1899                    return Err(ProviderEventValidationError::TooManyTools {
1900                        count: tools.len(),
1901                        max: PROVIDER_EVENT_TOOLS_MAX,
1902                    });
1903                }
1904                for tool in tools {
1905                    validate_required("tool", tool, PROVIDER_EVENT_ID_MAX_BYTES)?;
1906                }
1907            }
1908            Self::SessionIdentityObserved { identity } => {
1909                identity.validate()?;
1910            }
1911            Self::TurnStarted { prompt } => {
1912                if let Some(prompt) = prompt {
1913                    validate_text("prompt", prompt, PROVIDER_EVENT_TEXT_MAX_BYTES)?;
1914                }
1915            }
1916            Self::Text { text, .. } | Self::Thinking { text } => {
1917                validate_text("text", text, PROVIDER_EVENT_TEXT_MAX_BYTES)?;
1918            }
1919            Self::ToolStarted {
1920                id,
1921                name,
1922                input_json,
1923                agent_id,
1924            } => {
1925                validate_required("tool id", id, PROVIDER_EVENT_ID_MAX_BYTES)?;
1926                validate_required("tool name", name, PROVIDER_EVENT_ID_MAX_BYTES)?;
1927                validate_text("tool input", input_json, PROVIDER_EVENT_TEXT_MAX_BYTES)?;
1928                validate_optional_agent_id(agent_id)?;
1929            }
1930            Self::ToolCompleted {
1931                id,
1932                output,
1933                agent_id,
1934                non_execution_kind,
1935                ..
1936            } => {
1937                validate_required("tool id", id, PROVIDER_EVENT_ID_MAX_BYTES)?;
1938                validate_text("tool output", output, PROVIDER_EVENT_TEXT_MAX_BYTES)?;
1939                validate_optional_agent_id(agent_id)?;
1940                if let Some(kind) = non_execution_kind {
1941                    validate_text("tool non-execution kind", kind, PROVIDER_EVENT_ID_MAX_BYTES)?;
1942                }
1943            }
1944            Self::SessionEnded {
1945                result,
1946                cost_usd,
1947                stop_reason,
1948                ..
1949            } => {
1950                validate_text("session result", result, PROVIDER_EVENT_TEXT_MAX_BYTES)?;
1951                if let Some(cost) = cost_usd {
1952                    validate_identifier("cost", cost, PROVIDER_EVENT_ID_MAX_BYTES)?;
1953                }
1954                if let Some(stop_reason) = stop_reason {
1955                    stop_reason.validate_ingress()?;
1956                }
1957            }
1958            Self::Error { message } => {
1959                validate_required_text("error", message, PROVIDER_EVENT_TEXT_MAX_BYTES)?;
1960            }
1961            Self::InteractionRequested {
1962                request_id,
1963                interaction_kind,
1964                tool_name,
1965                title,
1966                prompt,
1967                options,
1968                agent_id,
1969            } => {
1970                if let Some(request_id) = request_id {
1971                    validate_required(
1972                        "interaction request id",
1973                        request_id,
1974                        PROVIDER_EVENT_ID_MAX_BYTES,
1975                    )?;
1976                }
1977                validate_required("interaction tool", tool_name, PROVIDER_EVENT_ID_MAX_BYTES)?;
1978                if let Some(title) = title {
1979                    validate_text("interaction title", title, PROVIDER_EVENT_TEXT_MAX_BYTES)?;
1980                }
1981                if *interaction_kind == ProviderInteractionKind::Question {
1982                    validate_required_text(
1983                        "interaction prompt",
1984                        prompt,
1985                        PROVIDER_EVENT_TEXT_MAX_BYTES,
1986                    )?;
1987                } else {
1988                    validate_text("interaction prompt", prompt, PROVIDER_EVENT_TEXT_MAX_BYTES)?;
1989                }
1990                if options.len() > PROVIDER_INTERACTION_OPTIONS_MAX {
1991                    return Err(ProviderEventValidationError::TooManyInteractionOptions {
1992                        count: options.len(),
1993                        max: PROVIDER_INTERACTION_OPTIONS_MAX,
1994                    });
1995                }
1996                for option in options {
1997                    validate_required(
1998                        "interaction option id",
1999                        &option.option_id,
2000                        PROVIDER_EVENT_ID_MAX_BYTES,
2001                    )?;
2002                    validate_required(
2003                        "interaction option name",
2004                        &option.name,
2005                        PROVIDER_EVENT_ID_MAX_BYTES,
2006                    )?;
2007                    validate_required(
2008                        "interaction option kind",
2009                        &option.kind,
2010                        PROVIDER_EVENT_ID_MAX_BYTES,
2011                    )?;
2012                }
2013                validate_optional_agent_id(agent_id)?;
2014            }
2015            Self::InteractionResolved {
2016                request_id,
2017                outcome,
2018            } => {
2019                validate_required(
2020                    "interaction request id",
2021                    request_id,
2022                    PROVIDER_EVENT_ID_MAX_BYTES,
2023                )?;
2024                if !matches!(
2025                    outcome,
2026                    ProviderInteractionOutcome::Approved | ProviderInteractionOutcome::Denied
2027                ) {
2028                    return Err(
2029                        ProviderEventValidationError::InvalidInteractionResolutionOutcome {
2030                            outcome: *outcome,
2031                        },
2032                    );
2033                }
2034            }
2035            Self::SubagentStarted {
2036                agent_id,
2037                agent_type,
2038                description,
2039            } => {
2040                validate_required("subagent id", agent_id, PROVIDER_EVENT_ID_MAX_BYTES)?;
2041                if let Some(agent_type) = agent_type {
2042                    validate_identifier("subagent type", agent_type, PROVIDER_EVENT_ID_MAX_BYTES)?;
2043                }
2044                if let Some(description) = description {
2045                    validate_text(
2046                        "subagent description",
2047                        description,
2048                        PROVIDER_EVENT_TEXT_MAX_BYTES,
2049                    )?;
2050                }
2051            }
2052            Self::SubagentStopped { agent_id } => {
2053                validate_required("subagent id", agent_id, PROVIDER_EVENT_ID_MAX_BYTES)?;
2054            }
2055            Self::RateLimited {
2056                limit_type: _,
2057                resets_at,
2058                usage_percent,
2059                raw_message,
2060            } => {
2061                // `limit_type` is a typed enum now (`ProviderRateLimitKind`),
2062                // not a String -- it has no shape to validate here.
2063                for (field, value) in [
2064                    ("reset time", resets_at.as_deref()),
2065                    ("usage percent", usage_percent.as_deref()),
2066                ] {
2067                    if let Some(value) = value {
2068                        validate_identifier(field, value, PROVIDER_EVENT_ID_MAX_BYTES)?;
2069                    }
2070                }
2071                validate_text(
2072                    "rate limit message",
2073                    raw_message,
2074                    PROVIDER_EVENT_TEXT_MAX_BYTES,
2075                )?;
2076            }
2077            Self::HostRequestObserved {
2078                method,
2079                params_json,
2080                reason,
2081                outcome,
2082                ..
2083            } => {
2084                validate_required("host request method", method, PROVIDER_EVENT_ID_MAX_BYTES)?;
2085                validate_text(
2086                    "host request params",
2087                    params_json,
2088                    PROVIDER_EVENT_TEXT_MAX_BYTES,
2089                )?;
2090                if let Some(reason) = reason {
2091                    validate_text("host request reason", reason, PROVIDER_EVENT_TEXT_MAX_BYTES)?;
2092                }
2093                if let HostRequestOutcome::Failed { error } = outcome {
2094                    validate_text(
2095                        "host request outcome error",
2096                        error,
2097                        PROVIDER_EVENT_TEXT_MAX_BYTES,
2098                    )?;
2099                }
2100            }
2101            Self::UnrecognizedNotification {
2102                method,
2103                payload_json,
2104            } => {
2105                validate_required(
2106                    "unrecognized notification method",
2107                    method,
2108                    PROVIDER_EVENT_ID_MAX_BYTES,
2109                )?;
2110                validate_text(
2111                    "unrecognized notification payload",
2112                    payload_json,
2113                    PROVIDER_EVENT_TEXT_MAX_BYTES,
2114                )?;
2115            }
2116            Self::UserMessage { text, .. } => {
2117                validate_text("text", text, PROVIDER_EVENT_TEXT_MAX_BYTES)?;
2118            }
2119            Self::Plan { steps } => {
2120                if steps.len() > PROVIDER_PLAN_STEPS_MAX {
2121                    return Err(ProviderEventValidationError::TooManyPlanSteps {
2122                        count: steps.len(),
2123                        max: PROVIDER_PLAN_STEPS_MAX,
2124                    });
2125                }
2126                for step in steps {
2127                    validate_required_text(
2128                        "plan step content",
2129                        &step.content,
2130                        PROVIDER_EVENT_TEXT_MAX_BYTES,
2131                    )?;
2132                }
2133            }
2134            Self::AvailableCommandsUpdated { commands } => {
2135                if commands.len() > PROVIDER_AVAILABLE_COMMANDS_MAX {
2136                    return Err(ProviderEventValidationError::TooManyAvailableCommands {
2137                        count: commands.len(),
2138                        max: PROVIDER_AVAILABLE_COMMANDS_MAX,
2139                    });
2140                }
2141                for command in commands {
2142                    validate_required("command name", &command.name, PROVIDER_EVENT_ID_MAX_BYTES)?;
2143                    validate_text(
2144                        "command description",
2145                        &command.description,
2146                        PROVIDER_EVENT_TEXT_MAX_BYTES,
2147                    )?;
2148                    if let Some(hint) = &command.input_hint {
2149                        validate_text("command input hint", hint, PROVIDER_EVENT_TEXT_MAX_BYTES)?;
2150                    }
2151                }
2152            }
2153            Self::ModeChanged { mode_id, available } => {
2154                validate_required("mode id", mode_id, PROVIDER_EVENT_ID_MAX_BYTES)?;
2155                if available.len() > PROVIDER_MODE_CATALOG_MAX {
2156                    return Err(ProviderEventValidationError::TooManyModes {
2157                        count: available.len(),
2158                        max: PROVIDER_MODE_CATALOG_MAX,
2159                    });
2160                }
2161                for mode in available {
2162                    validate_required("available mode id", &mode.id, PROVIDER_EVENT_ID_MAX_BYTES)?;
2163                    validate_required(
2164                        "available mode name",
2165                        &mode.name,
2166                        PROVIDER_EVENT_ID_MAX_BYTES,
2167                    )?;
2168                    if let Some(description) = &mode.description {
2169                        validate_text(
2170                            "available mode description",
2171                            description,
2172                            PROVIDER_EVENT_TEXT_MAX_BYTES,
2173                        )?;
2174                    }
2175                }
2176            }
2177            Self::SessionInfoUpdated { title } => {
2178                if let Some(title) = title {
2179                    validate_text("session title", title, PROVIDER_EVENT_TEXT_MAX_BYTES)?;
2180                }
2181            }
2182            Self::UsageUpdated {
2183                cost_amount,
2184                cost_currency,
2185                ..
2186            } => {
2187                if let Some(amount) = cost_amount {
2188                    validate_identifier("usage cost amount", amount, PROVIDER_EVENT_ID_MAX_BYTES)?;
2189                }
2190                if let Some(currency) = cost_currency {
2191                    validate_identifier(
2192                        "usage cost currency",
2193                        currency,
2194                        PROVIDER_EVENT_ID_MAX_BYTES,
2195                    )?;
2196                }
2197            }
2198            Self::ConfigOptionsUpdated { options } => {
2199                if options.len() > PROVIDER_CONFIG_OPTIONS_MAX {
2200                    return Err(ProviderEventValidationError::TooManyConfigOptions {
2201                        count: options.len(),
2202                        max: PROVIDER_CONFIG_OPTIONS_MAX,
2203                    });
2204                }
2205                for option in options {
2206                    validate_required("config option id", &option.id, PROVIDER_EVENT_ID_MAX_BYTES)?;
2207                    validate_required(
2208                        "config option name",
2209                        &option.name,
2210                        PROVIDER_EVENT_ID_MAX_BYTES,
2211                    )?;
2212                    if let Some(description) = &option.description {
2213                        validate_text(
2214                            "config option description",
2215                            description,
2216                            PROVIDER_EVENT_TEXT_MAX_BYTES,
2217                        )?;
2218                    }
2219                    if let Some(category) = &option.category {
2220                        validate_identifier(
2221                            "config option category",
2222                            category,
2223                            PROVIDER_EVENT_ID_MAX_BYTES,
2224                        )?;
2225                    }
2226                    validate_text(
2227                        "config option value",
2228                        &option.value_json,
2229                        PROVIDER_EVENT_TEXT_MAX_BYTES,
2230                    )?;
2231                    if option.choices.len() > PROVIDER_CONFIG_OPTION_CHOICES_MAX {
2232                        return Err(ProviderEventValidationError::TooManyConfigOptionChoices {
2233                            count: option.choices.len(),
2234                            max: PROVIDER_CONFIG_OPTION_CHOICES_MAX,
2235                        });
2236                    }
2237                    for choice in &option.choices {
2238                        validate_text(
2239                            "config option choice value",
2240                            &choice.value_json,
2241                            PROVIDER_EVENT_TEXT_MAX_BYTES,
2242                        )?;
2243                        if let Some(label) = &choice.label {
2244                            validate_text(
2245                                "config option choice label",
2246                                label,
2247                                PROVIDER_EVENT_TEXT_MAX_BYTES,
2248                            )?;
2249                        }
2250                    }
2251                }
2252            }
2253            Self::WorkingObserved
2254            | Self::TurnCompleted { .. }
2255            | Self::TurnInterrupted
2256            | Self::Ready => {}
2257            Self::ContextWindowUsage { usage } => usage.validate()?,
2258        }
2259        Ok(())
2260    }
2261}
2262
2263fn validate_required(
2264    field: &'static str,
2265    value: &str,
2266    max: usize,
2267) -> Result<(), ProviderEventValidationError> {
2268    if value.trim().is_empty() {
2269        return Err(ProviderEventValidationError::Empty { field });
2270    }
2271    validate_identifier(field, value, max)
2272}
2273
2274fn validate_required_text(
2275    field: &'static str,
2276    value: &str,
2277    max: usize,
2278) -> Result<(), ProviderEventValidationError> {
2279    if value.trim().is_empty() {
2280        return Err(ProviderEventValidationError::Empty { field });
2281    }
2282    validate_text(field, value, max)
2283}
2284
2285fn validate_identifier(
2286    field: &'static str,
2287    value: &str,
2288    max: usize,
2289) -> Result<(), ProviderEventValidationError> {
2290    if value.len() > max || value.chars().any(char::is_control) {
2291        return Err(ProviderEventValidationError::InvalidField { field, max });
2292    }
2293    Ok(())
2294}
2295
2296fn validate_optional_agent_id(
2297    agent_id: &Option<String>,
2298) -> Result<(), ProviderEventValidationError> {
2299    if let Some(agent_id) = agent_id {
2300        validate_required("provider agent id", agent_id, PROVIDER_EVENT_ID_MAX_BYTES)?;
2301    }
2302    Ok(())
2303}
2304
2305fn validate_text(
2306    field: &'static str,
2307    value: &str,
2308    max: usize,
2309) -> Result<(), ProviderEventValidationError> {
2310    let has_unsafe_control = value
2311        .chars()
2312        .any(|character| character.is_control() && !matches!(character, '\n' | '\r' | '\t'));
2313    if value.len() > max || has_unsafe_control {
2314        return Err(ProviderEventValidationError::InvalidField { field, max });
2315    }
2316    Ok(())
2317}
2318
2319/// Bounds check for the `mode_id`/`option_id`/`model_id` an ACP session
2320/// control command (`ControlCommand::SetSessionMode`/
2321/// `SetSessionConfigOption`/`SetSessionModel`) carries -- the same bound as
2322/// any other provider-scoped id (`PROVIDER_EVENT_ID_MAX_BYTES`), required
2323/// and free of control characters. Mirrors what
2324/// `gate4agent-node-protocol`'s wire boundary already enforces
2325/// (`deserialize_acp_control_id`) before a command ever reaches
2326/// `gate4agent-engine`; re-checked here because a `ControlCommand` is
2327/// constructible directly (tests, other embedders), not only through that
2328/// one wire.
2329pub fn validate_session_control_id(
2330    field: &'static str,
2331    value: &str,
2332) -> Result<(), ProviderEventValidationError> {
2333    validate_required(field, value, PROVIDER_EVENT_ID_MAX_BYTES)
2334}
2335
2336/// Bounds check for `ControlCommand::SetSessionConfigOption`'s
2337/// `value_json`: required, bounded the same as any other provider-scoped
2338/// free text (`PROVIDER_EVENT_TEXT_MAX_BYTES`), free of unsafe control
2339/// bytes. Mirrors `gate4agent-node-protocol`'s
2340/// `deserialize_acp_config_value_json` minus the JSON-parseability check --
2341/// this crate is a pure data contract and does not depend on `serde_json`,
2342/// so confirming the text actually parses is the shell executor's job
2343/// (`AcpSession::set_config_option` takes an already-parsed
2344/// `serde_json::Value`).
2345pub fn validate_session_config_value_json(
2346    value: &str,
2347) -> Result<(), ProviderEventValidationError> {
2348    validate_required_text(
2349        "session config option value",
2350        value,
2351        PROVIDER_EVENT_TEXT_MAX_BYTES,
2352    )
2353}
2354
2355#[derive(Clone, Debug, Error, Eq, PartialEq)]
2356pub enum ProviderEventValidationError {
2357    #[error("provider event field '{field}' is required")]
2358    Empty { field: &'static str },
2359    #[error("provider event field '{field}' contains controls or exceeds {max} bytes")]
2360    InvalidField { field: &'static str, max: usize },
2361    #[error("provider event tool count {count} exceeds {max}")]
2362    TooManyTools { count: usize, max: usize },
2363    #[error("provider interaction resolution outcome {outcome:?} is not exact")]
2364    InvalidInteractionResolutionOutcome { outcome: ProviderInteractionOutcome },
2365    #[error("context-window capacity must be non-zero")]
2366    ZeroContextWindowCapacity,
2367    #[error("context-window token segments overflow u64")]
2368    ContextWindowSegmentsOverflow,
2369    #[error("context-window token segments sum to {segment_sum}, not used_tokens {used_tokens}")]
2370    ContextWindowSegmentsMismatch { segment_sum: u64, used_tokens: u64 },
2371    #[error("provider plan step count {count} exceeds {max}")]
2372    TooManyPlanSteps { count: usize, max: usize },
2373    #[error("provider available command count {count} exceeds {max}")]
2374    TooManyAvailableCommands { count: usize, max: usize },
2375    #[error("provider config option count {count} exceeds {max}")]
2376    TooManyConfigOptions { count: usize, max: usize },
2377    #[error("provider config option choice count {count} exceeds {max}")]
2378    TooManyConfigOptionChoices { count: usize, max: usize },
2379    #[error("provider mode catalog count {count} exceeds {max}")]
2380    TooManyModes { count: usize, max: usize },
2381    #[error("provider interaction option count {count} exceeds {max}")]
2382    TooManyInteractionOptions { count: usize, max: usize },
2383}
2384
2385#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
2386pub struct ProviderSource {
2387    pub family: AdapterFamily,
2388    pub binding: AdapterBinding,
2389}
2390
2391#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
2392pub struct ProviderSourceCursor {
2393    pub source: ProviderSource,
2394    pub sequence: u64,
2395    pub gap_count: u64,
2396    pub stale: bool,
2397}
2398
2399#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
2400#[serde(transparent)]
2401pub struct ProviderInteractionId(pub u64);
2402
2403#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
2404pub struct ProviderInteractionTarget {
2405    pub interaction_id: ProviderInteractionId,
2406    pub source: ProviderSource,
2407    pub provider_request_id: Option<String>,
2408    pub interaction_kind: ProviderInteractionKind,
2409    pub tool_name: String,
2410    pub agent_id: Option<String>,
2411}
2412
2413#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
2414#[serde(tag = "kind", rename_all = "kebab-case")]
2415pub enum ProviderInteractionStatus {
2416    Pending,
2417    Resolving {
2418        operation_id: OperationId,
2419        response_kind: ProviderInteractionResponseKind,
2420    },
2421    Resolved {
2422        outcome: ProviderInteractionOutcome,
2423    },
2424}
2425
2426#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
2427pub struct ProviderInteraction {
2428    pub id: ProviderInteractionId,
2429    pub source: ProviderSource,
2430    pub provider_request_id: Option<String>,
2431    pub interaction_kind: ProviderInteractionKind,
2432    pub tool_name: String,
2433    pub prompt: String,
2434    pub agent_id: Option<String>,
2435    pub resume_lead_activity: Option<ProviderActivity>,
2436    pub status: ProviderInteractionStatus,
2437}
2438
2439#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
2440#[serde(rename_all = "kebab-case")]
2441pub enum ProviderActivity {
2442    #[default]
2443    Idle,
2444    Working,
2445    WaitingForInput,
2446    Blocked,
2447}
2448
2449#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
2450pub struct ActiveProviderTool {
2451    pub id: String,
2452    pub name: String,
2453    pub input_json: String,
2454}
2455
2456#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
2457pub struct ProviderSnapshot {
2458    pub sequence: u64,
2459    pub session: Option<ProviderSessionIdentity>,
2460    pub model: Option<String>,
2461    pub tools: Vec<String>,
2462    pub completed_turns: u64,
2463    pub usage: TokenUsage,
2464    pub lead_activity: ProviderActivity,
2465    pub activity: ProviderActivity,
2466    pub current_prompt: Option<String>,
2467    pub active_tools: Vec<ActiveProviderTool>,
2468    pub interactions: Vec<ProviderInteraction>,
2469    pub subagents: Vec<ProviderSubagent>,
2470    pub sources: Vec<ProviderSourceCursor>,
2471    pub last_event: Option<ProviderEvent>,
2472    pub gap_count: u64,
2473    pub stale: bool,
2474}
2475
2476#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
2477pub struct ControlSnapshot {
2478    pub revision: u64,
2479    pub health: ControlHealth,
2480    pub sessions: Vec<SessionSnapshot>,
2481}
2482
2483#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
2484pub struct ControlHealth {
2485    pub operation_id_exhausted: bool,
2486    pub event_sequence_exhausted: bool,
2487    pub revision_exhausted: bool,
2488    pub provider_sequence_exhausted_sessions: u32,
2489    pub retained_instance_identities: u32,
2490    pub retained_instance_identity_capacity: u32,
2491}
2492
2493impl Default for ControlHealth {
2494    fn default() -> Self {
2495        Self {
2496            operation_id_exhausted: false,
2497            event_sequence_exhausted: false,
2498            revision_exhausted: false,
2499            provider_sequence_exhausted_sessions: 0,
2500            retained_instance_identities: 0,
2501            retained_instance_identity_capacity: CONTROL_INSTANCE_IDENTITIES_CAPACITY,
2502        }
2503    }
2504}
2505
2506#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
2507pub struct ControlEvent {
2508    pub sequence: u64,
2509    pub command_id: Option<CommandId>,
2510    pub instance_id: AgentInstanceId,
2511    pub generation: SessionGeneration,
2512    pub event: ControlEventKind,
2513}
2514
2515#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
2516#[serde(tag = "kind", rename_all = "kebab-case")]
2517pub enum ControlEventKind {
2518    CommandRejected {
2519        message: String,
2520    },
2521    Registered,
2522    StartRequested {
2523        operation_id: OperationId,
2524    },
2525    Running {
2526        process_id: Option<u32>,
2527    },
2528    StopRequested {
2529        operation_id: OperationId,
2530        force: bool,
2531    },
2532    InputRequested {
2533        operation_id: OperationId,
2534        input_kind: PreparedInputKind,
2535    },
2536    InputCompleted {
2537        input_kind: PreparedInputKind,
2538    },
2539    InputFailed {
2540        input_kind: PreparedInputKind,
2541        message: String,
2542    },
2543    ResizeRequested {
2544        operation_id: OperationId,
2545        size: TerminalSize,
2546    },
2547    Resized {
2548        size: TerminalSize,
2549    },
2550    ResizeFailed {
2551        message: String,
2552    },
2553    ForegroundRefreshRequested {
2554        operation_id: OperationId,
2555    },
2556    ForegroundObserved {
2557        process: ForegroundProcess,
2558    },
2559    ForegroundFailed {
2560        message: String,
2561    },
2562    CapabilityProbeRequested {
2563        operation_id: OperationId,
2564    },
2565    CapabilitiesProbed {
2566        count: usize,
2567    },
2568    CapabilityProbeFailed {
2569        failure: CapabilityProbeFailure,
2570    },
2571    HistoryRequested {
2572        operation_id: OperationId,
2573        operation: HistoryOperation,
2574    },
2575    HistoryDiscovered {
2576        count: usize,
2577    },
2578    HistoryLoaded {
2579        session_id: String,
2580    },
2581    HistoryFailed {
2582        message: String,
2583    },
2584    ResumeRequested {
2585        operation_id: OperationId,
2586        target: ResumeTarget,
2587    },
2588    ResumeAuthorized {
2589        session: ResumeSessionSummary,
2590    },
2591    Resumed {
2592        session: ResumeSessionSummary,
2593        process_id: Option<u32>,
2594    },
2595    ResumeDenied {
2596        reason: String,
2597    },
2598    ResumeFailed {
2599        message: String,
2600    },
2601    TerminalStale {
2602        message: String,
2603    },
2604    ProviderEvent {
2605        sequence: u64,
2606        source: ProviderSource,
2607        source_sequence: u64,
2608        event: ProviderEvent,
2609    },
2610    ProviderGap {
2611        sequence: u64,
2612        source: ProviderSource,
2613        source_sequence: u64,
2614        missed: u64,
2615    },
2616    InteractionRequested {
2617        interaction: ProviderInteraction,
2618    },
2619    InteractionResolutionRequested {
2620        operation_id: OperationId,
2621        interaction_id: ProviderInteractionId,
2622        response_kind: ProviderInteractionResponseKind,
2623    },
2624    InteractionResolutionFailed {
2625        interaction_id: ProviderInteractionId,
2626        message: String,
2627    },
2628    InteractionResolved {
2629        interaction_id: ProviderInteractionId,
2630        outcome: ProviderInteractionOutcome,
2631    },
2632    SessionModeSetRequested {
2633        operation_id: OperationId,
2634        mode_id: String,
2635    },
2636    SessionModeSet {
2637        mode_id: String,
2638    },
2639    SessionModeSetFailed {
2640        message: String,
2641    },
2642    SessionConfigOptionSetRequested {
2643        operation_id: OperationId,
2644        option_id: String,
2645    },
2646    SessionConfigOptionSet {
2647        option_id: String,
2648    },
2649    SessionConfigOptionSetFailed {
2650        message: String,
2651    },
2652    SessionModelSetRequested {
2653        operation_id: OperationId,
2654        model_id: String,
2655    },
2656    SessionModelSet {
2657        model_id: String,
2658    },
2659    SessionModelSetFailed {
2660        message: String,
2661    },
2662    Exited {
2663        exit_code: Option<i32>,
2664        forced: bool,
2665    },
2666    Failed {
2667        message: String,
2668    },
2669    Removed,
2670    ObservationIgnored {
2671        reason: ObservationIgnoredReason,
2672    },
2673}
2674
2675#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
2676#[serde(rename_all = "kebab-case")]
2677pub enum ObservationIgnoredReason {
2678    UnknownInstance,
2679    StaleGeneration,
2680    GenerationExhausted,
2681    MissingOperation,
2682    OperationMismatch,
2683    InvalidState,
2684    StaleTerminalFrame,
2685    StaleProviderEvent,
2686    InvalidForegroundObservation,
2687    InvalidCapabilityObservation,
2688    InvalidHistoryObservation,
2689    InvalidResumeObservation,
2690    InvalidInteractionObservation,
2691    ProviderRuntimePolicyDenied {
2692        capability: ProviderRuntimeCapability,
2693    },
2694}
2695
2696#[derive(Clone, Debug, Eq, Error, PartialEq, Serialize, Deserialize)]
2697#[serde(tag = "kind", rename_all = "kebab-case")]
2698pub enum ControlError {
2699    #[error("agent instance {instance_id:?} is already registered")]
2700    DuplicateInstance { instance_id: AgentInstanceId },
2701    #[error(
2702        "cannot register agent instance {instance_id:?}: live session capacity {max} is exhausted"
2703    )]
2704    SessionCapacityExceeded {
2705        instance_id: AgentInstanceId,
2706        max: usize,
2707    },
2708    #[error(
2709        "cannot register agent instance {instance_id:?}: retained identity capacity {max} is exhausted"
2710    )]
2711    InstanceIdentityCapacityExceeded {
2712        instance_id: AgentInstanceId,
2713        max: usize,
2714    },
2715    #[error("agent instance {instance_id:?} is not registered")]
2716    UnknownInstance { instance_id: AgentInstanceId },
2717    #[error("agent instance {instance_id:?} exhausted session generation {generation:?}")]
2718    GenerationExhausted {
2719        instance_id: AgentInstanceId,
2720        generation: SessionGeneration,
2721    },
2722    #[error("control operation identifiers are exhausted")]
2723    OperationIdExhausted,
2724    #[error("control event sequences are exhausted")]
2725    EventSequenceExhausted,
2726    #[error("control snapshot revisions are exhausted")]
2727    RevisionExhausted,
2728    #[error(
2729        "agent instance {instance_id:?} generation {generation:?} exhausted provider event sequences"
2730    )]
2731    ProviderSequenceExhausted {
2732        instance_id: AgentInstanceId,
2733        generation: SessionGeneration,
2734    },
2735    #[error(
2736        "agent instance {instance_id:?} generation {generation:?} exhausted source sequence for {provider_source:?}"
2737    )]
2738    ProviderSourceSequenceExhausted {
2739        instance_id: AgentInstanceId,
2740        generation: SessionGeneration,
2741        provider_source: ProviderSource,
2742    },
2743    #[error("agent instance {instance_id:?} already has pending operation {operation_id:?}")]
2744    OperationPending {
2745        instance_id: AgentInstanceId,
2746        operation_id: OperationId,
2747    },
2748    #[error("agent input was rejected: {error}")]
2749    InputRejected { error: InputPrepareError },
2750    #[error("provider runtime policy is invalid: {error}")]
2751    InvalidProviderRuntimePolicy { error: ProviderRuntimePolicyError },
2752    #[error("provider runtime capability {capability:?} is not admitted")]
2753    ProviderRuntimePolicyDenied {
2754        capability: ProviderRuntimeCapability,
2755    },
2756    #[error("terminal size is outside the supported bounded range")]
2757    InvalidTerminalSize,
2758    #[error("working directory is empty, too large, or contains a NUL byte")]
2759    InvalidWorkingDirectory,
2760    #[error("pipe transport requires a non-empty initial prompt")]
2761    MissingInitialPrompt,
2762    #[error("session options are invalid: {message}")]
2763    InvalidSessionOptions { message: String },
2764    #[error("capability probe request is invalid: {message}")]
2765    InvalidCapabilityProbeRequest { message: String },
2766    #[error("capability probe operation {operation_id:?} is already pending")]
2767    CapabilityProbeOperationPending { operation_id: OperationId },
2768    #[error("capability probe already settled for this agent instance")]
2769    CapabilityProbeSettled,
2770    #[error("history request is invalid: {message}")]
2771    InvalidHistoryRequest { message: String },
2772    #[error("history operation {operation_id:?} is already pending")]
2773    HistoryOperationPending { operation_id: OperationId },
2774    #[error("history candidate is not present in the current discovery snapshot")]
2775    UnknownHistoryCandidate,
2776    #[error("resume request is invalid: {message}")]
2777    InvalidResumeRequest { message: String },
2778    #[error("resume requires a canonical provider session identity")]
2779    MissingProviderSession,
2780    #[error("resume history candidate must be the currently loaded candidate")]
2781    HistoryCandidateNotLoaded,
2782    #[error("transport {transport:?} does not support {action}")]
2783    UnsupportedTransportOperation {
2784        transport: TransportKind,
2785        action: String,
2786    },
2787    #[error("agent instance {instance_id:?} cannot {action} while in state {status:?}")]
2788    InvalidTransition {
2789        instance_id: AgentInstanceId,
2790        action: String,
2791        status: SessionStatus,
2792    },
2793    #[error("provider ingress generation {actual:?} is stale; expected {expected:?}")]
2794    StaleProviderGeneration {
2795        expected: SessionGeneration,
2796        actual: SessionGeneration,
2797    },
2798    #[error("provider ingress source sequence must be greater than the current sequence")]
2799    StaleProviderSequence,
2800    #[error("provider ingress batch must contain between 1 and {max} events")]
2801    InvalidProviderBatch { max: usize },
2802    #[error("invalid provider ingress event: {message}")]
2803    InvalidProviderEvent { message: String },
2804    #[error("provider interaction generation {actual:?} is stale; expected {expected:?}")]
2805    StaleProviderInteractionGeneration {
2806        expected: SessionGeneration,
2807        actual: SessionGeneration,
2808    },
2809    #[error("provider interaction {interaction_id:?} is unknown")]
2810    UnknownProviderInteraction {
2811        interaction_id: ProviderInteractionId,
2812    },
2813    #[error("provider interaction {interaction_id:?} is not pending")]
2814    ProviderInteractionNotPending {
2815        interaction_id: ProviderInteractionId,
2816    },
2817    #[error("provider interaction response is invalid: {message}")]
2818    InvalidProviderInteractionResponse { message: String },
2819    #[error("session mode request is invalid: {message}")]
2820    InvalidSessionModeRequest { message: String },
2821    #[error("session config option request is invalid: {message}")]
2822    InvalidSessionConfigOptionRequest { message: String },
2823    #[error("session model request is invalid: {message}")]
2824    InvalidSessionModelRequest { message: String },
2825}
2826
2827impl Default for ControlSnapshot {
2828    fn default() -> Self {
2829        Self {
2830            revision: 0,
2831            health: ControlHealth::default(),
2832            sessions: Vec::new(),
2833        }
2834    }
2835}
2836
2837#[cfg(test)]
2838mod tests {
2839
2840    /// The split `SessionStatus::allows_remove` draws, stated once so the
2841    /// two crates that consult it (`gate4agent-engine`'s own `remove`
2842    /// guard, `gate4agent-node`'s `wait_until_removed` re-dispatch gate)
2843    /// are pinned to the same answer.
2844    ///
2845    /// The three live statuses refuse: a session that is starting, running
2846    /// or stopping still owns a process. The three settled ones allow. The
2847    /// node's retry loop reads this BEFORE dispatching, because a rejected
2848    /// `Remove` fans a `CommandRejected` out to every subscriber -- 241 of
2849    /// them in 1.6 seconds when the loop dispatched blind.
2850    #[test]
2851    fn only_a_settled_session_admits_a_remove() {
2852        use crate::SessionStatus;
2853
2854        for live in [
2855            SessionStatus::Starting,
2856            SessionStatus::Running,
2857            SessionStatus::Stopping,
2858        ] {
2859            assert!(
2860                !live.allows_remove(),
2861                "{live:?} still owns a process; a Remove for it is rejected",
2862            );
2863        }
2864        for settled in [
2865            SessionStatus::Registered,
2866            SessionStatus::Exited { exit_code: Some(0) },
2867            SessionStatus::Exited { exit_code: None },
2868            SessionStatus::Failed { message: "boom".to_owned() },
2869        ] {
2870            assert!(
2871                settled.allows_remove(),
2872                "{settled:?} is settled; a Remove for it must be accepted",
2873            );
2874        }
2875    }
2876
2877    use crate::AgentId;
2878    use super::{
2879        AgentInstanceId, CapabilitySnapshot, ContextWindowUsage, ForegroundProcess,
2880        ForegroundProcessKind, ForegroundSnapshot, HistorySnapshot, HostDecisionAuthority,
2881        HostRequestDecision, HostRequestOutcome, OperatorGateInput,
2882        OperatorGateKind, OperatorGateOption, OperatorGateOptionSemantics, OperatorGateState,
2883        OperatorGateSubject, ProviderAvailableCommand, ProviderConfigChoice, ProviderConfigOption,
2884        ProviderConfigOptionKind, ProviderEvent,
2885        ProviderEventValidationError,
2886        ProviderInteractionKind, ProviderInteractionOption, ProviderInteractionOutcome,
2887        ProviderInteractionResponse,
2888        ProviderInteractionResponseError, ProviderModeInfo, ProviderPlanPriority,
2889        ProviderPlanStatus,
2890        ProviderPlanStep, ProviderRuntimeCapability, ProviderRuntimePolicy,
2891        ProviderRuntimePolicyError,
2892        ProviderSessionIdentity, ProviderSessionKey, ProviderSnapshot, PtyScreenState,
2893        ResumeSnapshot, SessionGeneration, SessionSnapshot, SessionStatus, TerminalFrame,
2894        TerminalMouseProtocolEncoding, TransportKind,
2895        FOREGROUND_PROCESS_NAME_MAX_BYTES, OPERATOR_GATE_OPTIONS_MAX,
2896        OPERATOR_GATE_OPTION_TEXT_MAX_BYTES, OPERATOR_GATE_PATH_MAX_BYTES,
2897        PROVIDER_AVAILABLE_COMMANDS_MAX,
2898        PROVIDER_EVENT_ID_MAX_BYTES, PROVIDER_EVENT_TEXT_MAX_BYTES,
2899        PROVIDER_INTERACTION_OPTIONS_MAX, PROVIDER_INTERACTION_RESPONSE_MAX_BYTES,
2900        PROVIDER_MODE_CATALOG_MAX,
2901        PROVIDER_PLAN_STEPS_MAX,
2902        PTY_SCREEN_GATE_NAME_MAX_BYTES,
2903    };
2904
2905    /// Shared fixture: a fully-known gate (every field populated), used by
2906    /// every test below that needs "some real `OperatorGateState`" without
2907    /// re-deriving one -- one option accepted, one declined, matching the
2908    /// shape `parse_operator_gate_options` actually produces for a numbered
2909    /// list (see `gate4agent-shell-native`'s own tests for the parser
2910    /// itself; this crate only owns the data shape and its bounds).
2911    fn sample_gate() -> OperatorGateState {
2912        OperatorGateState::new(OperatorGateKind::HookTrust)
2913            .with_subject(OperatorGateSubject::Hooks { count: Some(6) })
2914            .with_options(
2915                OperatorGateInput::NumberedList,
2916                vec![
2917                    OperatorGateOption {
2918                        text: "Trust all and continue".to_owned(),
2919                        semantics: OperatorGateOptionSemantics::Accept,
2920                        selected: false,
2921                    },
2922                    OperatorGateOption {
2923                        text: "Continue without trusting".to_owned(),
2924                        semantics: OperatorGateOptionSemantics::Decline,
2925                        selected: true,
2926                    },
2927                ],
2928            )
2929    }
2930
2931    #[test]
2932    fn context_window_usage_ingress_requires_exact_bounded_segments() {
2933        let event = |usage| ProviderEvent::ContextWindowUsage { usage };
2934        let valid = ContextWindowUsage {
2935            uncached_input_tokens: 70,
2936            cache_read_tokens: 20,
2937            cache_write_tokens: 0,
2938            output_tokens: 10,
2939            unattributed_tokens: 5,
2940            used_tokens: 105,
2941            capacity_tokens: 100,
2942        };
2943        assert_eq!(event(valid).validate_ingress(), Ok(()));
2944        assert_eq!(
2945            event(ContextWindowUsage { capacity_tokens: 0, ..valid }).validate_ingress(),
2946            Err(ProviderEventValidationError::ZeroContextWindowCapacity)
2947        );
2948        assert_eq!(
2949            event(ContextWindowUsage { used_tokens: 104, ..valid }).validate_ingress(),
2950            Err(ProviderEventValidationError::ContextWindowSegmentsMismatch {
2951                segment_sum: 105,
2952                used_tokens: 104,
2953            })
2954        );
2955        assert_eq!(
2956            event(ContextWindowUsage {
2957                uncached_input_tokens: u64::MAX,
2958                cache_read_tokens: 1,
2959                cache_write_tokens: 0,
2960                output_tokens: 0,
2961                unattributed_tokens: 0,
2962                used_tokens: u64::MAX,
2963                capacity_tokens: 1,
2964            })
2965            .validate_ingress(),
2966            Err(ProviderEventValidationError::ContextWindowSegmentsOverflow)
2967        );
2968    }
2969
2970    #[test]
2971    fn provider_runtime_policy_enforces_semantic_invariants() {
2972        let raw = ProviderRuntimePolicy::raw_pty();
2973        assert!(raw.admits(ProviderRuntimeCapability::RawPtyLifecycle));
2974        assert!(!raw.admits(ProviderRuntimeCapability::SemanticReadiness));
2975        assert!(!raw.admits(ProviderRuntimeCapability::HookSemantics));
2976        assert_eq!(raw.validate(), Ok(()));
2977
2978        let none = ProviderRuntimePolicy::none();
2979        assert!(!none.admits(ProviderRuntimeCapability::RawPtyLifecycle));
2980        assert!(!none.admits(ProviderRuntimeCapability::SemanticReadiness));
2981        assert_eq!(none.validate(), Ok(()));
2982
2983        // The ACP shape: `session/prompt`/`session/update` are mandatory ACP
2984        // protocol surface, and `session/new` returns a `sessionId` under
2985        // that same specification -- none of it a PTY-terminal-text
2986        // inference, so this transport grants `semantic_readiness`/
2987        // `structured_prompt`/`provider_session_identity` with no raw PTY
2988        // lifecycle at all, and that is now a VALID policy, not the
2989        // `SemanticCapabilityRequiresRawPty` defect it used to be.
2990        assert_eq!(
2991            ProviderRuntimePolicy::new(false, true, true, true, false, false),
2992            Ok(ProviderRuntimePolicy {
2993                raw_pty_lifecycle: false,
2994                semantic_readiness: true,
2995                structured_prompt: true,
2996                provider_session_identity: true,
2997                semantic_resume: false,
2998                hook_semantics: false,
2999            }),
3000        );
3001        // `structured_prompt` still requires `semantic_readiness`, and that
3002        // rule holds independent of `raw_pty_lifecycle` -- it is not the rule
3003        // the ACP shape above loosened.
3004        assert_eq!(
3005            ProviderRuntimePolicy::new(false, false, true, false, false, false),
3006            Err(ProviderRuntimePolicyError::StructuredPromptRequiresReadiness),
3007        );
3008        assert_eq!(
3009            ProviderRuntimePolicy::new(true, false, true, false, false, false),
3010            Err(ProviderRuntimePolicyError::StructuredPromptRequiresReadiness),
3011        );
3012        assert_eq!(
3013            ProviderRuntimePolicy::new(true, true, true, false, true, false),
3014            Err(ProviderRuntimePolicyError::ResumeRequiresSessionIdentity),
3015        );
3016        assert_eq!(
3017            ProviderRuntimePolicy::new(false, false, false, false, false, true),
3018            Err(ProviderRuntimePolicyError::SemanticCapabilityRequiresRawPty),
3019        );
3020        // Granting `semantic_readiness`/`structured_prompt`/
3021        // `provider_session_identity` without a raw PTY lifecycle (the ACP
3022        // shape) must NOT silently unlock `semantic_resume`/`hook_semantics`
3023        // -- those two keep the old, stricter rule, even with every other
3024        // field in the ACP shape already granted.
3025        assert_eq!(
3026            ProviderRuntimePolicy::new(false, true, true, true, true, false),
3027            Err(ProviderRuntimePolicyError::SemanticCapabilityRequiresRawPty),
3028        );
3029        assert_eq!(
3030            ProviderRuntimePolicy::new(false, true, true, true, false, true),
3031            Err(ProviderRuntimePolicyError::SemanticCapabilityRequiresRawPty),
3032        );
3033        assert!(ProviderRuntimePolicy::new(true, true, true, true, true, true).is_ok());
3034    }
3035
3036    /// The pair that makes hook ingestion work for a provider like grok: a
3037    /// hook adapter with no verified vendor terminal contract still admits
3038    /// its own events, and that admission never leaks into the PTY-parsing
3039    /// `SemanticReadiness` capability it is deliberately independent from.
3040    #[test]
3041    fn hook_semantics_and_semantic_readiness_are_independently_grantable() {
3042        let hook_only = ProviderRuntimePolicy::new(true, false, false, false, false, true)
3043            .expect("hook semantics alone requires only the raw PTY lifecycle");
3044        assert!(hook_only.admits(ProviderRuntimeCapability::HookSemantics));
3045        assert!(!hook_only.admits(ProviderRuntimeCapability::SemanticReadiness));
3046
3047        let semantic_only = ProviderRuntimePolicy::new(true, true, false, false, false, false)
3048            .expect("semantic readiness alone requires only the raw PTY lifecycle");
3049        assert!(semantic_only.admits(ProviderRuntimeCapability::SemanticReadiness));
3050        assert!(!semantic_only.admits(ProviderRuntimeCapability::HookSemantics));
3051    }
3052
3053    #[test]
3054    fn provider_runtime_policy_serde_requires_every_field_and_revalidates() {
3055        let raw = ProviderRuntimePolicy::raw_pty();
3056        let encoded = serde_json::to_string(&raw).unwrap();
3057        assert_eq!(
3058            encoded,
3059            r#"{"raw_pty_lifecycle":true,"semantic_readiness":false,"structured_prompt":false,"provider_session_identity":false,"semantic_resume":false,"hook_semantics":false}"#,
3060        );
3061        assert_eq!(
3062            serde_json::from_str::<ProviderRuntimePolicy>(&encoded).unwrap(),
3063            raw,
3064        );
3065        assert!(serde_json::from_str::<ProviderRuntimePolicy>(
3066            r#"{"raw_pty_lifecycle":true,"semantic_readiness":false,"structured_prompt":false,"provider_session_identity":false,"semantic_resume":false}"#,
3067        )
3068        .is_err());
3069        assert!(serde_json::from_str::<ProviderRuntimePolicy>(
3070            r#"{"raw_pty_lifecycle":true,"semantic_readiness":false,"structured_prompt":true,"provider_session_identity":false,"semantic_resume":false,"hook_semantics":false}"#,
3071        )
3072        .is_err());
3073        assert!(serde_json::from_str::<super::ControlCommand>(
3074            r#"{"kind":"start","instance_id":1,"request":{"working_directory":"C:\\repo","terminal_size":{"rows":24,"columns":80}}}"#,
3075        )
3076        .is_err());
3077        assert!(serde_json::from_str::<super::ControlEffect>(
3078            r#"{"kind":"spawn","agent_id":"claude","transport":"pty","request":{"working_directory":"C:\\repo","terminal_size":{"rows":24,"columns":80}}}"#,
3079        )
3080        .is_err());
3081    }
3082
3083    #[test]
3084    fn foreground_process_is_bounded_and_agent_bound() {
3085        let claude = AgentId::new("claude").unwrap();
3086        let process = ForegroundProcess {
3087            root_process_id: 1,
3088            process_id: 2,
3089            process_name: "claude".to_owned(),
3090            kind: ForegroundProcessKind::Agent {
3091                agent_id: claude.clone(),
3092            },
3093        };
3094        assert!(process.is_valid_for(&claude));
3095        assert!(!process.is_valid_for(&AgentId::new("codex").unwrap()));
3096        assert!(!ForegroundProcess {
3097            process_name: "x".repeat(FOREGROUND_PROCESS_NAME_MAX_BYTES + 1),
3098            ..process
3099        }
3100        .is_valid_for(&claude));
3101    }
3102
3103    #[test]
3104    fn terminal_frame_metadata_defaults_for_older_serialized_frames() {
3105        let frame: TerminalFrame = serde_json::from_str(
3106            r#"{"sequence":1,"size":{"rows":24,"columns":80},"cursor_row":0,"cursor_column":0,"contents":"ready","formatted":[114]}"#,
3107        )
3108        .expect("legacy terminal frame");
3109
3110        assert!(frame.scrollback_formatted.is_empty());
3111        assert!(!frame.alternate_screen);
3112        assert!(!frame.mouse_protocol_enabled);
3113        assert_eq!(frame.mouse_protocol_encoding, TerminalMouseProtocolEncoding::Default);
3114        assert_eq!(frame.produced_at_unix_ms, 0);
3115    }
3116
3117    #[test]
3118    fn a_terminal_frame_that_omits_screen_state_decodes_as_unknown_not_ready() {
3119        let frame: TerminalFrame = serde_json::from_str(
3120            r#"{"sequence":1,"size":{"rows":24,"columns":80},"cursor_row":0,"cursor_column":0,"contents":"ready","formatted":[114]}"#,
3121        )
3122        .expect("legacy terminal frame");
3123
3124        assert_eq!(frame.screen_state, PtyScreenState::Unknown);
3125        // A peer that predates this field carries no classification at all --
3126        // reading that silence as `Ready` would hand a blind writer a green
3127        // light nobody ever actually gave.
3128        assert_ne!(frame.screen_state, PtyScreenState::Ready);
3129    }
3130
3131    #[test]
3132    fn a_session_snapshot_that_omits_screen_state_decodes_as_unknown_not_ready() {
3133        let populated = SessionSnapshot {
3134            instance_id: AgentInstanceId(1),
3135            agent_id: AgentId::new("claude").unwrap(),
3136            transport: TransportKind::Pty,
3137            generation: SessionGeneration(1),
3138            status: SessionStatus::Running,
3139            pending_operation: None,
3140            pending_input: None,
3141            process_id: None,
3142            terminal_size: None,
3143            terminal_frame: None,
3144            terminal_stale: None,
3145            session_options: None,
3146            capabilities: CapabilitySnapshot::default(),
3147            history: HistorySnapshot::default(),
3148            resume: ResumeSnapshot::default(),
3149            foreground: ForegroundSnapshot::default(),
3150            provider: ProviderSnapshot::default(),
3151            screen_state: PtyScreenState::Ready,
3152        };
3153        let mut wire = serde_json::to_value(&populated).unwrap();
3154        wire.as_object_mut().unwrap().remove("screen_state");
3155        let decoded: SessionSnapshot = serde_json::from_value(wire).unwrap();
3156
3157        assert_eq!(decoded.screen_state, PtyScreenState::Unknown);
3158        // Same reasoning as the terminal-frame case: an old peer's silence
3159        // on this field must never be upgraded into a claim it never made.
3160        assert_ne!(decoded.screen_state, PtyScreenState::Ready);
3161    }
3162
3163    #[test]
3164    fn every_pty_screen_state_variant_round_trips_through_serde() {
3165        let variants = [
3166            PtyScreenState::Unknown,
3167            PtyScreenState::NotAgent {
3168                observed_process: "npm".to_owned(),
3169            },
3170            PtyScreenState::OperatorGate { gate: sample_gate() },
3171            PtyScreenState::Failing {
3172                reason: "startup-crash".to_owned(),
3173            },
3174            PtyScreenState::Ready,
3175        ];
3176        for variant in variants {
3177            let json = serde_json::to_string(&variant).unwrap();
3178            assert_eq!(
3179                serde_json::from_str::<PtyScreenState>(&json).unwrap(),
3180                variant,
3181            );
3182        }
3183    }
3184
3185    /// Refusal follows a FINDING, not the absence of one. The three states
3186    /// that carry something the matcher actually read still refuse; `Ready`
3187    /// and `Unknown` both admit, because "recognized nothing" is not a
3188    /// reason to treat a screen as an obstacle -- and every provider sits in
3189    /// `Unknown` for the frame or two before process identity resolves,
3190    /// which is not a state worth refusing.
3191    #[test]
3192    fn admits_blind_write_refuses_a_finding_and_admits_the_absence_of_one() {
3193        assert!(PtyScreenState::Ready.admits_blind_write());
3194        assert!(PtyScreenState::Unknown.admits_blind_write());
3195        assert!(!PtyScreenState::NotAgent {
3196            observed_process: "npm".to_owned(),
3197        }
3198        .admits_blind_write());
3199        assert!(!PtyScreenState::OperatorGate { gate: sample_gate() }.admits_blind_write());
3200        assert!(!PtyScreenState::Failing {
3201            reason: "startup-crash".to_owned(),
3202        }
3203        .admits_blind_write());
3204    }
3205
3206    #[test]
3207    fn pty_screen_state_is_valid_rejects_oversized_empty_and_control_carrying_fields() {
3208        assert!(PtyScreenState::NotAgent {
3209            observed_process: "npm install".to_owned(),
3210        }
3211        .is_valid());
3212        assert!(!PtyScreenState::NotAgent {
3213            observed_process: "x".repeat(FOREGROUND_PROCESS_NAME_MAX_BYTES + 1),
3214        }
3215        .is_valid());
3216        assert!(!PtyScreenState::NotAgent {
3217            observed_process: String::new(),
3218        }
3219        .is_valid());
3220        assert!(!PtyScreenState::NotAgent {
3221            observed_process: "bad\u{0000}process".to_owned(),
3222        }
3223        .is_valid());
3224
3225        assert!(PtyScreenState::OperatorGate { gate: sample_gate() }.is_valid());
3226        assert!(!PtyScreenState::OperatorGate {
3227            gate: sample_gate().with_subject(OperatorGateSubject::Directory {
3228                path: Some("x".repeat(OPERATOR_GATE_PATH_MAX_BYTES + 1)),
3229            }),
3230        }
3231        .is_valid());
3232        assert!(!PtyScreenState::OperatorGate {
3233            gate: sample_gate().with_subject(OperatorGateSubject::Directory {
3234                path: Some(String::new()),
3235            }),
3236        }
3237        .is_valid());
3238        assert!(!PtyScreenState::OperatorGate {
3239            gate: sample_gate().with_subject(OperatorGateSubject::Directory {
3240                path: Some("bad\u{0000}path".to_owned()),
3241            }),
3242        }
3243        .is_valid());
3244        assert!(!PtyScreenState::OperatorGate {
3245            gate: sample_gate().with_options(
3246                OperatorGateInput::NumberedList,
3247                vec![OperatorGateOption {
3248                    text: "x".repeat(OPERATOR_GATE_OPTION_TEXT_MAX_BYTES + 1),
3249                    semantics: OperatorGateOptionSemantics::Accept,
3250                    selected: false,
3251                }],
3252            ),
3253        }
3254        .is_valid());
3255        assert!(!PtyScreenState::OperatorGate {
3256            gate: sample_gate().with_options(
3257                OperatorGateInput::NumberedList,
3258                vec![OperatorGateOption {
3259                    text: String::new(),
3260                    semantics: OperatorGateOptionSemantics::Accept,
3261                    selected: false,
3262                }],
3263            ),
3264        }
3265        .is_valid());
3266        assert!(!PtyScreenState::OperatorGate {
3267            gate: sample_gate().with_options(
3268                OperatorGateInput::NumberedList,
3269                vec![OperatorGateOption {
3270                    text: "bad\u{0000}option".to_owned(),
3271                    semantics: OperatorGateOptionSemantics::Accept,
3272                    selected: false,
3273                }],
3274            ),
3275        }
3276        .is_valid());
3277        assert!(!PtyScreenState::OperatorGate {
3278            gate: sample_gate().with_options(
3279                OperatorGateInput::NumberedList,
3280                (0..=OPERATOR_GATE_OPTIONS_MAX)
3281                    .map(|index| OperatorGateOption {
3282                        text: format!("option {index}"),
3283                        semantics: OperatorGateOptionSemantics::Unknown,
3284                        selected: false,
3285                    })
3286                    .collect(),
3287            ),
3288        }
3289        .is_valid());
3290
3291        assert!(PtyScreenState::Failing {
3292            reason: "startup-crash".to_owned(),
3293        }
3294        .is_valid());
3295        assert!(!PtyScreenState::Failing {
3296            reason: "x".repeat(PTY_SCREEN_GATE_NAME_MAX_BYTES + 1),
3297        }
3298        .is_valid());
3299        assert!(!PtyScreenState::Failing {
3300            reason: String::new(),
3301        }
3302        .is_valid());
3303        assert!(!PtyScreenState::Failing {
3304            reason: "bad\u{0000}reason".to_owned(),
3305        }
3306        .is_valid());
3307
3308        assert!(PtyScreenState::Unknown.is_valid());
3309        assert!(PtyScreenState::Ready.is_valid());
3310    }
3311
3312    #[test]
3313    fn provider_interactions_require_bounded_identity_and_question_payloads() {
3314        let question = ProviderEvent::InteractionRequested {
3315            request_id: Some("question-1".to_owned()),
3316            interaction_kind: ProviderInteractionKind::Question,
3317            tool_name: "AskUserQuestion".to_owned(),
3318            title: Some("Continue?".to_owned()),
3319            prompt: "{\"question\":\"Continue?\"}".to_owned(),
3320            options: vec![ProviderInteractionOption {
3321                option_id: "yes".to_owned(),
3322                name: "Yes".to_owned(),
3323                kind: "allow_once".to_owned(),
3324            }],
3325            agent_id: Some("child-1".to_owned()),
3326        };
3327        assert_eq!(question.validate_ingress(), Ok(()));
3328
3329        assert!(matches!(
3330            ProviderEvent::InteractionRequested {
3331                request_id: Some("bad\nrequest".to_owned()),
3332                interaction_kind: ProviderInteractionKind::Approval,
3333                tool_name: "shell".to_owned(),
3334                title: None,
3335                prompt: String::new(),
3336                options: Vec::new(),
3337                agent_id: None,
3338            }
3339            .validate_ingress(),
3340            Err(ProviderEventValidationError::InvalidField {
3341                field: "interaction request id",
3342                ..
3343            })
3344        ));
3345        assert!(matches!(
3346            ProviderEvent::InteractionRequested {
3347                request_id: None,
3348                interaction_kind: ProviderInteractionKind::Question,
3349                tool_name: "AskUserQuestion".to_owned(),
3350                title: None,
3351                prompt: String::new(),
3352                options: Vec::new(),
3353                agent_id: None,
3354            }
3355            .validate_ingress(),
3356            Err(ProviderEventValidationError::Empty {
3357                field: "interaction prompt"
3358            })
3359        ));
3360        assert!(matches!(
3361            ProviderEvent::InteractionRequested {
3362                request_id: None,
3363                interaction_kind: ProviderInteractionKind::Approval,
3364                tool_name: "shell".to_owned(),
3365                title: None,
3366                prompt: String::new(),
3367                options: (0..PROVIDER_INTERACTION_OPTIONS_MAX + 1)
3368                    .map(|index| ProviderInteractionOption {
3369                        option_id: format!("option-{index}"),
3370                        name: format!("Option {index}"),
3371                        kind: "allow_once".to_owned(),
3372                    })
3373                    .collect(),
3374                agent_id: None,
3375            }
3376            .validate_ingress(),
3377            Err(ProviderEventValidationError::TooManyInteractionOptions {
3378                max: PROVIDER_INTERACTION_OPTIONS_MAX,
3379                ..
3380            })
3381        ));
3382
3383        for outcome in [
3384            ProviderInteractionOutcome::Approved,
3385            ProviderInteractionOutcome::Denied,
3386        ] {
3387            assert_eq!(
3388                ProviderEvent::InteractionResolved {
3389                    request_id: "approval-1".to_owned(),
3390                    outcome,
3391                }
3392                .validate_ingress(),
3393                Ok(())
3394            );
3395        }
3396        assert!(matches!(
3397            ProviderEvent::InteractionResolved {
3398                request_id: "bad\nrequest".to_owned(),
3399                outcome: ProviderInteractionOutcome::Approved,
3400            }
3401            .validate_ingress(),
3402            Err(ProviderEventValidationError::InvalidField {
3403                field: "interaction request id",
3404                ..
3405            })
3406        ));
3407        assert_eq!(
3408            ProviderEvent::InteractionResolved {
3409                request_id: "approval-1".to_owned(),
3410                outcome: ProviderInteractionOutcome::TurnEnded,
3411            }
3412            .validate_ingress(),
3413            Err(
3414                ProviderEventValidationError::InvalidInteractionResolutionOutcome {
3415                    outcome: ProviderInteractionOutcome::TurnEnded,
3416                }
3417            )
3418        );
3419    }
3420
3421    #[test]
3422    fn provider_interaction_responses_are_kind_checked_and_bounded() {
3423        assert_eq!(
3424            ProviderInteractionResponse::ApproveOnce
3425                .validate_for(ProviderInteractionKind::Approval),
3426            Ok(())
3427        );
3428        assert_eq!(
3429            ProviderInteractionResponse::Deny.validate_for(ProviderInteractionKind::Question),
3430            Ok(())
3431        );
3432        assert_eq!(
3433            ProviderInteractionResponse::Answer {
3434                text: "continue".to_owned(),
3435            }
3436            .validate_for(ProviderInteractionKind::Question),
3437            Ok(())
3438        );
3439        assert_eq!(
3440            ProviderInteractionResponse::ApproveOnce
3441                .validate_for(ProviderInteractionKind::Question),
3442            Err(ProviderInteractionResponseError::ApprovalRequiresApproval)
3443        );
3444        assert_eq!(
3445            ProviderInteractionResponse::Answer {
3446                text: String::new(),
3447            }
3448            .validate_for(ProviderInteractionKind::Question),
3449            Err(ProviderInteractionResponseError::EmptyAnswer)
3450        );
3451        assert_eq!(
3452            ProviderInteractionResponse::Answer {
3453                text: "x".repeat(PROVIDER_INTERACTION_RESPONSE_MAX_BYTES + 1),
3454            }
3455            .validate_for(ProviderInteractionKind::Question),
3456            Err(ProviderInteractionResponseError::InvalidAnswer {
3457                max: PROVIDER_INTERACTION_RESPONSE_MAX_BYTES,
3458            })
3459        );
3460    }
3461
3462    #[test]
3463    fn provider_ingress_allows_multiline_text_but_rejects_control_bytes() {
3464        ProviderEvent::Text {
3465            text: "first line\n\tsecond line".to_owned(),
3466            is_delta: false,
3467        }
3468        .validate_ingress()
3469        .unwrap();
3470
3471        assert!(matches!(
3472            ProviderEvent::Text {
3473                text: "unsafe\u{0000}text".to_owned(),
3474                is_delta: false,
3475            }
3476            .validate_ingress(),
3477            Err(ProviderEventValidationError::InvalidField { field: "text", .. })
3478        ));
3479        assert!(ProviderEvent::SessionStarted {
3480            session_id: "session\nother".to_owned(),
3481            model: "model".to_owned(),
3482            tools: Vec::new(),
3483        }
3484        .validate_ingress()
3485        .is_err());
3486    }
3487
3488    #[test]
3489    fn provider_session_identity_is_typed_and_bounded_at_ingress() {
3490        let valid = ProviderEvent::SessionIdentityObserved {
3491            identity: ProviderSessionIdentity {
3492                key: ProviderSessionKey::ConversationId,
3493                id: "conversation-1".to_owned(),
3494                transcript_path: Some("C:/sessions/conversation-1.jsonl".to_owned()),
3495            },
3496        };
3497        assert_eq!(valid.validate_ingress(), Ok(()));
3498
3499        for identity in [
3500            ProviderSessionIdentity {
3501                key: ProviderSessionKey::SessionId,
3502                id: "--help".to_owned(),
3503                transcript_path: None,
3504            },
3505            ProviderSessionIdentity {
3506                key: ProviderSessionKey::SessionId,
3507                id: "session-1".to_owned(),
3508                transcript_path: Some("bad\npath".to_owned()),
3509            },
3510        ] {
3511            assert!(ProviderEvent::SessionIdentityObserved { identity }
3512                .validate_ingress()
3513                .is_err());
3514        }
3515    }
3516
3517    /// The agent-to-host request event must carry a non-empty, bounded
3518    /// method and a bounded params payload -- the same shape and bounds as
3519    /// `ToolStarted`'s `id`/`input_json`, since this event carries the same
3520    /// kind of vendor-controlled JSON.
3521    #[test]
3522    fn host_request_observed_is_bounded_at_ingress() {
3523        let valid = ProviderEvent::HostRequestObserved {
3524            method: "session/request_permission".to_owned(),
3525            params_json: "{\"toolName\":\"bash\"}".to_owned(),
3526            decision: HostRequestDecision::Denied { by: HostDecisionAuthority::Policy },
3527            outcome: HostRequestOutcome::Executed,
3528            reason: Some("session/request_permission denied by host policy".to_owned()),
3529        };
3530        assert_eq!(valid.validate_ingress(), Ok(()));
3531
3532        assert!(matches!(
3533            ProviderEvent::HostRequestObserved {
3534                method: String::new(),
3535                params_json: String::new(),
3536                decision: HostRequestDecision::Denied { by: HostDecisionAuthority::Policy },
3537                outcome: HostRequestOutcome::Executed,
3538                reason: None,
3539            }
3540            .validate_ingress(),
3541            Err(ProviderEventValidationError::Empty { field: "host request method" })
3542        ));
3543
3544        let oversized_method = "m".repeat(PROVIDER_EVENT_ID_MAX_BYTES + 1);
3545        assert!(matches!(
3546            ProviderEvent::HostRequestObserved {
3547                method: oversized_method,
3548                params_json: String::new(),
3549                decision: HostRequestDecision::Granted { by: HostDecisionAuthority::Policy },
3550                outcome: HostRequestOutcome::Executed,
3551                reason: None,
3552            }
3553            .validate_ingress(),
3554            Err(ProviderEventValidationError::InvalidField {
3555                field: "host request method",
3556                ..
3557            })
3558        ));
3559
3560        let oversized_params = "p".repeat(PROVIDER_EVENT_TEXT_MAX_BYTES + 1);
3561        assert!(matches!(
3562            ProviderEvent::HostRequestObserved {
3563                method: "fs/read_text_file".to_owned(),
3564                params_json: oversized_params,
3565                decision: HostRequestDecision::Granted { by: HostDecisionAuthority::Policy },
3566                outcome: HostRequestOutcome::Executed,
3567                reason: None,
3568            }
3569            .validate_ingress(),
3570            Err(ProviderEventValidationError::InvalidField {
3571                field: "host request params",
3572                ..
3573            })
3574        ));
3575
3576        let oversized_reason = "r".repeat(PROVIDER_EVENT_TEXT_MAX_BYTES + 1);
3577        assert!(matches!(
3578            ProviderEvent::HostRequestObserved {
3579                method: "terminal/create".to_owned(),
3580                params_json: String::new(),
3581                decision: HostRequestDecision::Denied { by: HostDecisionAuthority::Gate },
3582                outcome: HostRequestOutcome::Executed,
3583                reason: Some(oversized_reason),
3584            }
3585            .validate_ingress(),
3586            Err(ProviderEventValidationError::InvalidField {
3587                field: "host request reason",
3588                ..
3589            })
3590        ));
3591
3592        let oversized_error = "e".repeat(PROVIDER_EVENT_TEXT_MAX_BYTES + 1);
3593        assert!(matches!(
3594            ProviderEvent::HostRequestObserved {
3595                method: "terminal/create".to_owned(),
3596                params_json: String::new(),
3597                decision: HostRequestDecision::Granted { by: HostDecisionAuthority::Policy },
3598                outcome: HostRequestOutcome::Failed { error: oversized_error },
3599                reason: None,
3600            }
3601            .validate_ingress(),
3602            Err(ProviderEventValidationError::InvalidField {
3603                field: "host request outcome error",
3604                ..
3605            })
3606        ));
3607    }
3608
3609    /// A `Granted` request that failed WHILE EXECUTING (a spawn error, an
3610    /// I/O failure) carries `HostRequestOutcome::Failed` -- distinct from,
3611    /// and never collapsed into, a `Denied` decision: the request was
3612    /// authorized, it just did not run cleanly.
3613    #[test]
3614    fn host_request_execution_failure_is_a_distinct_bounded_outcome_not_a_denial() {
3615        let spawn_error = "terminal/create spawn failed: os error 3";
3616        let granted_but_failed = ProviderEvent::HostRequestObserved {
3617            method: "terminal/create".to_owned(),
3618            params_json: String::new(),
3619            decision: HostRequestDecision::Granted { by: HostDecisionAuthority::Policy },
3620            outcome: HostRequestOutcome::Failed { error: spawn_error.to_owned() },
3621            reason: None,
3622        };
3623        assert_eq!(granted_but_failed.validate_ingress(), Ok(()));
3624        assert!(matches!(
3625            granted_but_failed,
3626            ProviderEvent::HostRequestObserved {
3627                decision: HostRequestDecision::Granted { .. },
3628                outcome: HostRequestOutcome::Failed { .. },
3629                ..
3630            }
3631        ));
3632    }
3633
3634    /// A `HostRequestObserved` record written before `outcome` existed
3635    /// (pre-K1d) carries no `outcome` field at all -- `#[serde(default)]`
3636    /// must still read it back, as `HostRequestOutcome::Executed`: before
3637    /// this field existed, an observed `Granted` request always meant it
3638    /// ran, so that is the only honest default for the old shape.
3639    #[test]
3640    fn host_request_observed_outcome_defaults_to_executed_for_pre_k1d_json() {
3641        let pre_k1d = serde_json::json!({
3642            "kind": "host-request-observed",
3643            "method": "session/request_permission",
3644            "params_json": "{\"toolName\":\"bash\"}",
3645            "decision": { "kind": "granted", "by": "policy" },
3646            "reason": null,
3647        });
3648        let decoded: ProviderEvent = serde_json::from_value(pre_k1d)
3649            .expect("pre-K1d shape without outcome must still deserialize");
3650        assert!(matches!(
3651            decoded,
3652            ProviderEvent::HostRequestObserved {
3653                decision: HostRequestDecision::Granted { by: HostDecisionAuthority::Policy },
3654                outcome: HostRequestOutcome::Executed,
3655                ..
3656            }
3657        ));
3658    }
3659
3660    /// The refusal text a `Denied` decision carries is bounded, verbatim
3661    /// free text -- never the categorical `Error::detail` slug (see this
3662    /// module's own doc comment for the incident that rule fixes). `None`
3663    /// for a `Granted`/`Deferred` decision, or a `Denied` one nobody
3664    /// attached text to, is equally valid: this is the honest absence of a
3665    /// reason, not a value to reject.
3666    #[test]
3667    fn host_request_observed_reason_is_bounded_free_text_and_optional() {
3668        let gate_text =
3669            "blocked by dangerous-command gate: rule=filesystem-wipe, argument=rm -rf /";
3670        let with_reason = ProviderEvent::HostRequestObserved {
3671            method: "terminal/create".to_owned(),
3672            params_json: String::new(),
3673            decision: HostRequestDecision::Denied { by: HostDecisionAuthority::Gate },
3674            outcome: HostRequestOutcome::Executed,
3675            reason: Some(gate_text.to_owned()),
3676        };
3677        assert_eq!(with_reason.validate_ingress(), Ok(()));
3678
3679        let without_reason = ProviderEvent::HostRequestObserved {
3680            method: "terminal/create".to_owned(),
3681            params_json: String::new(),
3682            decision: HostRequestDecision::Granted { by: HostDecisionAuthority::Policy },
3683            outcome: HostRequestOutcome::Executed,
3684            reason: None,
3685        };
3686        assert_eq!(without_reason.validate_ingress(), Ok(()));
3687    }
3688
3689    /// The catch-all "protocol said something we don't parse" event must
3690    /// still enforce the same bounds as every other provider event carrying
3691    /// vendor JSON -- a hostile or garbled `session/update` payload cannot
3692    /// ride this fallback path past `PROVIDER_EVENT_TEXT_MAX_BYTES`.
3693    #[test]
3694    fn unrecognized_notification_is_bounded_at_ingress() {
3695        let valid = ProviderEvent::UnrecognizedNotification {
3696            method: "session/some_future_update".to_owned(),
3697            payload_json: "{\"unknown\":true}".to_owned(),
3698        };
3699        assert_eq!(valid.validate_ingress(), Ok(()));
3700
3701        assert!(matches!(
3702            ProviderEvent::UnrecognizedNotification {
3703                method: String::new(),
3704                payload_json: String::new(),
3705            }
3706            .validate_ingress(),
3707            Err(ProviderEventValidationError::Empty {
3708                field: "unrecognized notification method"
3709            })
3710        ));
3711
3712        let oversized_payload = "p".repeat(PROVIDER_EVENT_TEXT_MAX_BYTES + 1);
3713        assert!(matches!(
3714            ProviderEvent::UnrecognizedNotification {
3715                method: "session/update".to_owned(),
3716                payload_json: oversized_payload,
3717            }
3718            .validate_ingress(),
3719            Err(ProviderEventValidationError::InvalidField {
3720                field: "unrecognized notification payload",
3721                ..
3722            })
3723        ));
3724    }
3725
3726    // -----------------------------------------------------------------------
3727    // ACP session/update coverage — Plan, AvailableCommandsUpdated,
3728    // ModeChanged, SessionInfoUpdated, UsageUpdated, ConfigOptionsUpdated,
3729    // UserMessage
3730    // -----------------------------------------------------------------------
3731
3732    #[test]
3733    fn provider_user_message_is_bounded_at_ingress_like_text() {
3734        let valid = ProviderEvent::UserMessage { text: "hi".to_owned(), is_delta: true };
3735        assert_eq!(valid.validate_ingress(), Ok(()));
3736
3737        assert!(matches!(
3738            ProviderEvent::UserMessage {
3739                text: "unsafe\u{0000}text".to_owned(),
3740                is_delta: true,
3741            }
3742            .validate_ingress(),
3743            Err(ProviderEventValidationError::InvalidField { field: "text", .. })
3744        ));
3745    }
3746
3747    #[test]
3748    fn provider_plan_accepts_a_valid_snapshot_and_rejects_empty_step_content() {
3749        let valid = ProviderEvent::Plan {
3750            steps: vec![ProviderPlanStep {
3751                content: "read the file".to_owned(),
3752                priority: ProviderPlanPriority::High,
3753                status: ProviderPlanStatus::Completed,
3754            }],
3755        };
3756        assert_eq!(valid.validate_ingress(), Ok(()));
3757
3758        assert!(matches!(
3759            ProviderEvent::Plan {
3760                steps: vec![ProviderPlanStep {
3761                    content: String::new(),
3762                    priority: ProviderPlanPriority::Low,
3763                    status: ProviderPlanStatus::Pending,
3764                }],
3765            }
3766            .validate_ingress(),
3767            Err(ProviderEventValidationError::Empty { field: "plan step content" })
3768        ));
3769    }
3770
3771    #[test]
3772    fn provider_plan_rejects_too_many_steps() {
3773        let steps = (0..=PROVIDER_PLAN_STEPS_MAX)
3774            .map(|i| ProviderPlanStep {
3775                content: format!("step {i}"),
3776                priority: ProviderPlanPriority::Medium,
3777                status: ProviderPlanStatus::Pending,
3778            })
3779            .collect();
3780        assert!(matches!(
3781            ProviderEvent::Plan { steps }.validate_ingress(),
3782            Err(ProviderEventValidationError::TooManyPlanSteps {
3783                max: PROVIDER_PLAN_STEPS_MAX,
3784                ..
3785            })
3786        ));
3787    }
3788
3789    #[test]
3790    fn provider_available_commands_updated_is_bounded_at_ingress() {
3791        let valid = ProviderEvent::AvailableCommandsUpdated {
3792            commands: vec![ProviderAvailableCommand {
3793                name: "review".to_owned(),
3794                description: "Review the diff".to_owned(),
3795                input_hint: Some("<file>".to_owned()),
3796            }],
3797        };
3798        assert_eq!(valid.validate_ingress(), Ok(()));
3799
3800        assert!(matches!(
3801            ProviderEvent::AvailableCommandsUpdated {
3802                commands: vec![ProviderAvailableCommand {
3803                    name: String::new(),
3804                    description: String::new(),
3805                    input_hint: None,
3806                }],
3807            }
3808            .validate_ingress(),
3809            Err(ProviderEventValidationError::Empty { field: "command name" })
3810        ));
3811
3812        let too_many = (0..=PROVIDER_AVAILABLE_COMMANDS_MAX)
3813            .map(|i| ProviderAvailableCommand {
3814                name: format!("cmd{i}"),
3815                description: String::new(),
3816                input_hint: None,
3817            })
3818            .collect();
3819        assert!(matches!(
3820            ProviderEvent::AvailableCommandsUpdated { commands: too_many }.validate_ingress(),
3821            Err(ProviderEventValidationError::TooManyAvailableCommands {
3822                max: PROVIDER_AVAILABLE_COMMANDS_MAX,
3823                ..
3824            })
3825        ));
3826    }
3827
3828    #[test]
3829    fn provider_mode_changed_requires_a_mode_id() {
3830        assert_eq!(
3831            ProviderEvent::ModeChanged {
3832                mode_id: "architect".to_owned(),
3833                available: Vec::new(),
3834            }
3835            .validate_ingress(),
3836            Ok(())
3837        );
3838        assert!(matches!(
3839            ProviderEvent::ModeChanged { mode_id: String::new(), available: Vec::new() }
3840                .validate_ingress(),
3841            Err(ProviderEventValidationError::Empty { field: "mode id" })
3842        ));
3843    }
3844
3845    #[test]
3846    fn provider_mode_changed_available_catalog_is_bounded_at_ingress() {
3847        let valid = ProviderEvent::ModeChanged {
3848            mode_id: "architect".to_owned(),
3849            available: vec![ProviderModeInfo {
3850                id: "architect".to_owned(),
3851                name: "Architect".to_owned(),
3852                description: Some("Plans before it edits".to_owned()),
3853            }],
3854        };
3855        assert_eq!(valid.validate_ingress(), Ok(()));
3856
3857        assert!(matches!(
3858            ProviderEvent::ModeChanged {
3859                mode_id: "architect".to_owned(),
3860                available: vec![ProviderModeInfo {
3861                    id: String::new(),
3862                    name: "Architect".to_owned(),
3863                    description: None,
3864                }],
3865            }
3866            .validate_ingress(),
3867            Err(ProviderEventValidationError::Empty { field: "available mode id" })
3868        ));
3869
3870        let too_many = (0..=PROVIDER_MODE_CATALOG_MAX)
3871            .map(|i| ProviderModeInfo {
3872                id: format!("mode{i}"),
3873                name: format!("Mode {i}"),
3874                description: None,
3875            })
3876            .collect();
3877        assert!(matches!(
3878            ProviderEvent::ModeChanged {
3879                mode_id: "architect".to_owned(),
3880                available: too_many,
3881            }
3882            .validate_ingress(),
3883            Err(ProviderEventValidationError::TooManyModes {
3884                max: PROVIDER_MODE_CATALOG_MAX,
3885                ..
3886            })
3887        ));
3888    }
3889
3890    #[test]
3891    fn provider_session_info_updated_allows_no_title_and_rejects_control_bytes() {
3892        assert_eq!(
3893            ProviderEvent::SessionInfoUpdated { title: None }.validate_ingress(),
3894            Ok(())
3895        );
3896        assert!(matches!(
3897            ProviderEvent::SessionInfoUpdated {
3898                title: Some("bad\u{0000}title".to_owned()),
3899            }
3900            .validate_ingress(),
3901            Err(ProviderEventValidationError::InvalidField { field: "session title", .. })
3902        ));
3903    }
3904
3905    #[test]
3906    fn provider_usage_updated_accepts_optional_cost_and_bounds_currency() {
3907        let valid = ProviderEvent::UsageUpdated {
3908            used_tokens: Some(100),
3909            context_window: Some(200_000),
3910            cost_amount: Some("0.42".to_owned()),
3911            cost_currency: Some("USD".to_owned()),
3912        };
3913        assert_eq!(valid.validate_ingress(), Ok(()));
3914
3915        let no_cost = ProviderEvent::UsageUpdated {
3916            used_tokens: None,
3917            context_window: None,
3918            cost_amount: None,
3919            cost_currency: None,
3920        };
3921        assert_eq!(no_cost.validate_ingress(), Ok(()));
3922
3923        assert!(matches!(
3924            ProviderEvent::UsageUpdated {
3925                used_tokens: None,
3926                context_window: None,
3927                cost_amount: None,
3928                cost_currency: Some("bad\ncurrency".to_owned()),
3929            }
3930            .validate_ingress(),
3931            Err(ProviderEventValidationError::InvalidField { field: "usage cost currency", .. })
3932        ));
3933    }
3934
3935    #[test]
3936    fn provider_config_options_updated_is_typed_and_bounded_at_ingress() {
3937        let valid = ProviderEvent::ConfigOptionsUpdated {
3938            options: vec![ProviderConfigOption {
3939                id: "model".to_owned(),
3940                name: "Model".to_owned(),
3941                description: Some("Which model to use".to_owned()),
3942                category: Some("generation".to_owned()),
3943                kind: ProviderConfigOptionKind::Select,
3944                value_json: "\"opus\"".to_owned(),
3945                choices: vec![ProviderConfigChoice {
3946                    value_json: "\"opus\"".to_owned(),
3947                    label: Some("Opus".to_owned()),
3948                }],
3949            }],
3950        };
3951        assert_eq!(valid.validate_ingress(), Ok(()));
3952
3953        assert!(matches!(
3954            ProviderEvent::ConfigOptionsUpdated {
3955                options: vec![ProviderConfigOption {
3956                    id: String::new(),
3957                    name: "Model".to_owned(),
3958                    description: None,
3959                    category: None,
3960                    kind: ProviderConfigOptionKind::Boolean,
3961                    value_json: "true".to_owned(),
3962                    choices: Vec::new(),
3963                }],
3964            }
3965            .validate_ingress(),
3966            Err(ProviderEventValidationError::Empty { field: "config option id" })
3967        ));
3968    }
3969
3970    #[test]
3971    fn tool_completed_non_execution_kind_is_optional_and_bounded() {
3972        let none = ProviderEvent::ToolCompleted {
3973            id: "t1".to_owned(),
3974            output: "denied".to_owned(),
3975            is_error: true,
3976            duration_ms: None,
3977            agent_id: None,
3978            non_execution_kind: None,
3979        };
3980        assert_eq!(none.validate_ingress(), Ok(()));
3981
3982        let typed = ProviderEvent::ToolCompleted {
3983            id: "t1".to_owned(),
3984            output: "denied".to_owned(),
3985            is_error: true,
3986            duration_ms: None,
3987            agent_id: None,
3988            non_execution_kind: Some("permission-rule".to_owned()),
3989        };
3990        assert_eq!(typed.validate_ingress(), Ok(()));
3991
3992        let oversized = "k".repeat(PROVIDER_EVENT_ID_MAX_BYTES + 1);
3993        assert!(matches!(
3994            ProviderEvent::ToolCompleted {
3995                id: "t1".to_owned(),
3996                output: "denied".to_owned(),
3997                is_error: true,
3998                duration_ms: None,
3999                agent_id: None,
4000                non_execution_kind: Some(oversized),
4001            }
4002            .validate_ingress(),
4003            Err(ProviderEventValidationError::InvalidField {
4004                field: "tool non-execution kind",
4005                ..
4006            })
4007        ));
4008    }
4009
4010    #[test]
4011    fn session_ended_stop_reason_is_optional_and_each_variant_validates() {
4012        use super::ProviderStopReason;
4013        for stop_reason in [
4014            None,
4015            Some(ProviderStopReason::EndTurn),
4016            Some(ProviderStopReason::MaxTokens),
4017            Some(ProviderStopReason::MaxTurnRequests),
4018            Some(ProviderStopReason::Refusal),
4019            Some(ProviderStopReason::Cancelled),
4020            Some(ProviderStopReason::Other { value: "some-vendor-code".to_owned() }),
4021            Some(ProviderStopReason::ProviderError {
4022                code: -32603,
4023                message: "You've hit your usage limit.".to_owned(),
4024                vendor_code: Some("usageLimitExceeded".to_owned()),
4025            }),
4026        ] {
4027            let event = ProviderEvent::SessionEnded {
4028                result: "end_turn".to_owned(),
4029                cost_usd: None,
4030                is_error: false,
4031                stop_reason,
4032            };
4033            assert_eq!(event.validate_ingress(), Ok(()));
4034            let encoded = serde_json::to_vec(&event).expect("serialize");
4035            let decoded: ProviderEvent = serde_json::from_slice(&encoded).expect("deserialize");
4036            assert_eq!(decoded, event);
4037        }
4038    }
4039
4040    #[test]
4041    fn session_ended_provider_error_message_and_vendor_code_are_bounded() {
4042        use super::ProviderStopReason;
4043        let oversized_message = "m".repeat(PROVIDER_EVENT_TEXT_MAX_BYTES + 1);
4044        assert!(matches!(
4045            ProviderEvent::SessionEnded {
4046                result: "provider_error".to_owned(),
4047                cost_usd: None,
4048                is_error: true,
4049                stop_reason: Some(ProviderStopReason::ProviderError {
4050                    code: -32603,
4051                    message: oversized_message,
4052                    vendor_code: None,
4053                }),
4054            }
4055            .validate_ingress(),
4056            Err(ProviderEventValidationError::InvalidField {
4057                field: "provider error message",
4058                ..
4059            })
4060        ));
4061
4062        let oversized_code = "c".repeat(PROVIDER_EVENT_ID_MAX_BYTES + 1);
4063        assert!(matches!(
4064            ProviderEvent::SessionEnded {
4065                result: "provider_error".to_owned(),
4066                cost_usd: None,
4067                is_error: true,
4068                stop_reason: Some(ProviderStopReason::ProviderError {
4069                    code: -32603,
4070                    message: "usage limit exceeded".to_owned(),
4071                    vendor_code: Some(oversized_code),
4072                }),
4073            }
4074            .validate_ingress(),
4075            Err(ProviderEventValidationError::InvalidField {
4076                field: "provider error vendor code",
4077                ..
4078            })
4079        ));
4080    }
4081}