Skip to main content

agent_engine/extensions/
session_driver.rs

1//! Generic, explicitly user-authorized foreground session driving.
2//!
3//! This module grants no authority by itself: the local frontend must obtain a
4//! permission-gated eager handler, bind it to an explicit user action/session,
5//! and recheck its identity before polling or committing a prepared proposal.
6//! Timers, terminal boundaries, cancellation and user-input arbitration belong
7//! to that frontend. Loop policy and prompts belong exclusively to the plugin.
8
9use std::collections::HashSet;
10use std::io::{self, Write};
11use std::sync::Arc;
12use std::time::{Duration, Instant};
13
14use agent_core::reasoning::ReasoningLevel;
15use serde::{Deserialize, Serialize};
16use serde_json::{json, Value};
17
18use super::commands::CommandOutputEvent;
19use super::invoke_output::{invoke_event_channel, InvokeOutputBudget};
20use super::runtime::{ExtensionHandler, InvokeCommandEvent};
21use crate::{Runtime, SharedMessage};
22
23const MAX_REPLY_BYTES: usize = 64 * 1024;
24const MAX_PROMPT_BYTES: usize = 16 * 1024;
25const MAX_NOTICE_BYTES: usize = 2 * 1024;
26const MAX_ID_BYTES: usize = 128;
27const MAX_MODEL_BYTES: usize = 512;
28const MAX_EFFORT_BYTES: usize = 16;
29const MAX_MODELS: usize = 16;
30const MIN_DELAY_MS: u64 = 1_000;
31const MAX_DELAY_MS: u64 = 300_000;
32const MAX_DURATION_MS: u64 = 365 * 24 * 60 * 60 * 1_000;
33const POLL_TIMEOUT: Duration = Duration::from_secs(5);
34
35#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
36#[serde(deny_unknown_fields)]
37pub struct Selection {
38    pub model: String,
39    pub effort: String,
40}
41
42/// Optional session-local context policy requested by an explicit start only.
43/// Absent on older drivers means leave the current session preference unchanged.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
45#[serde(rename_all = "snake_case")]
46pub enum ContextMode {
47    Auto,
48    Off,
49}
50impl ContextMode {
51    pub fn as_str(self) -> &'static str {
52        match self {
53            Self::Auto => "auto",
54            Self::Off => "off",
55        }
56    }
57}
58
59// Missing is compatible; an explicitly null/unknown/mistyped mode is not.
60fn deserialize_context_mode<'de, D: serde::Deserializer<'de>>(
61    deserializer: D,
62) -> Result<Option<ContextMode>, D::Error> {
63    ContextMode::deserialize(deserializer).map(Some)
64}
65
66#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
67#[serde(tag = "action", rename_all = "snake_case", deny_unknown_fields)]
68pub enum Reply {
69    Start {
70        run_id: String,
71        models: Vec<Selection>,
72        prompt: String,
73        delay_ms: u64,
74        #[serde(default)]
75        max_duration_ms: Option<u64>,
76        /// Opt into bounded, content-free completed-turn feedback.
77        #[serde(default)]
78        feedback_version: Option<u8>,
79        /// Explicit support for typed wall-clock continuation checkpoints.
80        #[serde(default)]
81        time_checkpoint_version: Option<u8>,
82        #[serde(
83            default,
84            deserialize_with = "deserialize_context_mode",
85            skip_serializing_if = "Option::is_none"
86        )]
87        context_mode: Option<ContextMode>,
88        #[serde(default)]
89        notice: String,
90        /// (E-P0, §5 decision 3) Plugin-proposed cost cap; effective cap is
91        /// `min(this, config.driver.max_cost_usd)`. May only lower, never
92        /// raise the host-imposed limit.
93        #[serde(default, skip_serializing_if = "Option::is_none")]
94        max_cost_usd: Option<f64>,
95    },
96    Next {
97        run_id: String,
98        selection: Selection,
99        prompt: String,
100        delay_ms: u64,
101        #[serde(default)]
102        notice: String,
103    },
104    Stop {
105        #[serde(default)]
106        notice: String,
107    },
108    Status {
109        #[serde(default)]
110        notice: String,
111    },
112}
113
114/// Count serialized bytes without allocating an unbounded serialization buffer.
115struct SizeLimit(usize);
116impl Write for SizeLimit {
117    fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
118        self.0 = self.0.checked_sub(bytes.len()).ok_or_else(|| {
119            io::Error::new(io::ErrorKind::InvalidData, "session driver reply too large")
120        })?;
121        Ok(bytes.len())
122    }
123    fn flush(&mut self) -> io::Result<()> {
124        Ok(())
125    }
126}
127
128fn bounded_serialization(value: &impl Serialize) -> Result<(), String> {
129    serde_json::to_writer(SizeLimit(MAX_REPLY_BYTES), value)
130        .map_err(|_| "session driver reply exceeds 64 KiB".into())
131}
132
133fn valid_id(value: &str) -> bool {
134    !value.is_empty()
135        && value.len() <= MAX_ID_BYTES
136        && value
137            .bytes()
138            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_'))
139}
140
141fn exact_effort(effort: &str) -> Result<ReasoningLevel, String> {
142    ReasoningLevel::parse(effort)
143        .filter(|level| effort.len() <= MAX_EFFORT_BYTES && level.as_str() == effort)
144        .ok_or_else(|| "session driver effort must be an exact canonical reasoning name".into())
145}
146
147fn validate_selection(selection: &Selection) -> Result<ReasoningLevel, String> {
148    if selection.model.len() > MAX_MODEL_BYTES
149        || agent_core::prompt::QualifiedModelId::parse(selection.model.clone()).is_err()
150    {
151        return Err("session driver model must be a bounded exact provider/model identity".into());
152    }
153    exact_effort(&selection.effort)
154}
155
156fn validate_text(prompt: &str, notice: &str, delay_ms: u64) -> Result<(), String> {
157    if prompt.trim().is_empty() || prompt.len() > MAX_PROMPT_BYTES {
158        return Err("session driver prompt must be nonempty and at most 16 KiB".into());
159    }
160    validate_notice(notice)?;
161    if !(MIN_DELAY_MS..=MAX_DELAY_MS).contains(&delay_ms) {
162        return Err("session driver delay must be 1000..300000 ms".into());
163    }
164    Ok(())
165}
166
167fn validate_notice(notice: &str) -> Result<(), String> {
168    if notice.len() > MAX_NOTICE_BYTES {
169        return Err("session driver notice exceeds 2 KiB".into());
170    }
171    Ok(())
172}
173
174fn validate_reply(reply: &Reply) -> Result<(), String> {
175    // Public enum constructors and serde consumers cannot bypass grant bounds.
176    bounded_serialization(reply)?;
177    match reply {
178        Reply::Start {
179            run_id,
180            models,
181            prompt,
182            delay_ms,
183            max_duration_ms,
184            feedback_version,
185            time_checkpoint_version,
186            context_mode: _,
187            notice,
188            max_cost_usd,
189        } => {
190            if !valid_id(run_id) {
191                return Err("session driver run_id must be 1..128 ASCII letters/digits/-/_".into());
192            }
193            if models.is_empty() || models.len() > MAX_MODELS {
194                return Err("session driver requires 1..16 model selections".into());
195            }
196            let mut unique = HashSet::new();
197            for selection in models {
198                validate_selection(selection)?;
199                if !unique.insert(&selection.model) {
200                    return Err("session driver model identities must be unique".into());
201                }
202            }
203            if max_duration_ms.is_some_and(|duration| duration == 0 || duration > MAX_DURATION_MS) {
204                return Err("session driver duration must be positive and at most 365 days".into());
205            }
206            if feedback_version.is_some_and(|v| v != 1) {
207                return Err("unsupported session driver feedback version".into());
208            }
209            if time_checkpoint_version.is_some_and(|v| v != 1) {
210                return Err("unsupported session driver time checkpoint version".into());
211            }
212            if max_cost_usd.is_some_and(|c| !c.is_finite() || c <= 0.0) {
213                return Err("session driver max_cost_usd must be positive and finite".into());
214            }
215            validate_text(prompt, notice, *delay_ms)
216        }
217        Reply::Next {
218            run_id,
219            selection,
220            prompt,
221            delay_ms,
222            notice,
223        } => {
224            if !valid_id(run_id) {
225                return Err("session driver run_id must be 1..128 ASCII letters/digits/-/_".into());
226            }
227            validate_selection(selection)?;
228            validate_text(prompt, notice, *delay_ms)
229        }
230        Reply::Stop { notice } | Reply::Status { notice } => validate_notice(notice),
231    }
232}
233
234/// Parse only the structured top-level response, never printed output.
235/// Unknown fields inside the driver object/selections fail closed. Unrelated
236/// command response fields are allowed, but count toward the 64 KiB bound.
237/// Optional start `selection` is redundant and must exactly equal models[0].
238/// It is removed here so the coordinated public Start enum has no extra field.
239pub fn parse_reply(value: &Value) -> Result<Option<Reply>, String> {
240    let Some(driver) = value.get("session_driver") else {
241        return Ok(None);
242    };
243    bounded_serialization(value)?;
244    let mut driver = driver.clone();
245    if driver.get("action").and_then(Value::as_str) == Some("start") {
246        if let Some(selection) = driver
247            .as_object_mut()
248            .and_then(|map| map.remove("selection"))
249        {
250            let selection: Selection = serde_json::from_value(selection)
251                .map_err(|_| "invalid session driver start selection".to_string())?;
252            let first: Selection = serde_json::from_value(
253                driver
254                    .get("models")
255                    .and_then(Value::as_array)
256                    .and_then(|m| m.first())
257                    .cloned()
258                    .ok_or_else(|| "session driver start has no initial model".to_string())?,
259            )
260            .map_err(|_| "invalid session driver initial selection".to_string())?;
261            if selection != first {
262                return Err("session driver start selection must equal models[0]".into());
263            }
264        }
265    }
266    let reply: Reply = serde_json::from_value(driver).map_err(|_| {
267        "malformed session driver response (unknown field, action or type)".to_string()
268    })?;
269    validate_reply(&reply)?;
270    Ok(Some(reply))
271}
272
273#[derive(Debug, Clone, PartialEq, Eq)]
274pub struct Proposal {
275    pub selection: Selection,
276    pub prompt: String,
277    pub delay: Duration,
278    pub notice: String,
279}
280
281/// An ephemeral allowlist and deadline, never persisted. Public identity fields
282/// are host bookkeeping, not values the plugin may update via subsequent replies.
283#[derive(Debug)]
284pub struct Grant {
285    pub plugin_id: String,
286    pub session_id: String,
287    pub run_id: String,
288    models: Vec<Selection>,
289    // Retain the authoritative ID privately: changing a public bookkeeping
290    // field must not silently authorize a different poll run.
291    pinned_run_id: String,
292    feedback_version: Option<u8>,
293    time_checkpoint_version: Option<u8>,
294    context_mode: Option<ContextMode>,
295    deadline: Option<Instant>,
296    /// (E-P0, §5 decision 3) Plugin-proposed per-run USD cap. The effective
297    /// cap is `min(this, config)` — enforced by P7.
298    pub max_cost_usd: Option<f64>,
299}
300
301impl Grant {
302    /// The caller MUST establish explicit user intent and permission first.
303    pub fn from_start(
304        plugin_id: &str,
305        session_id: &str,
306        reply: Reply,
307    ) -> Result<(Self, Proposal), String> {
308        validate_reply(&reply)?;
309        if plugin_id.is_empty() || session_id.is_empty() {
310            return Err("session driver requires an owning plugin and session".into());
311        }
312        let Reply::Start {
313            run_id,
314            models,
315            prompt,
316            delay_ms,
317            max_duration_ms,
318            feedback_version,
319            time_checkpoint_version,
320            context_mode,
321            notice,
322            max_cost_usd,
323        } = reply
324        else {
325            return Err("session driver can only be armed by start".into());
326        };
327        let deadline = max_duration_ms
328            .map(|ms| {
329                Instant::now()
330                    .checked_add(Duration::from_millis(ms))
331                    .ok_or_else(|| "session driver deadline is not representable".to_string())
332            })
333            .transpose()?;
334        let proposal = Proposal {
335            selection: models[0].clone(),
336            prompt,
337            delay: Duration::from_millis(delay_ms),
338            notice,
339        };
340        let grant = Self {
341            plugin_id: plugin_id.into(),
342            session_id: session_id.into(),
343            pinned_run_id: run_id.clone(),
344            feedback_version,
345            time_checkpoint_version,
346            context_mode,
347            run_id,
348            models,
349            deadline,
350            max_cost_usd,
351        };
352        grant.check_delay(proposal.delay)?;
353        Ok((grant, proposal))
354    }
355
356    /// Apply only after the frontend accepted an explicit start and all grant
357    /// checks passed. Uses exactly the ordinary /context command's validation,
358    /// not a raw config mutation or permission/recall bypass.
359    pub fn apply_context_mode(&self, runtime: &Runtime) -> Result<Option<String>, String> {
360        self.context_mode
361            .map(|mode| runtime.context_management_command(mode.as_str()))
362            .transpose()
363    }
364
365    pub fn feedback_enabled(&self) -> bool {
366        self.feedback_version == Some(1)
367    }
368
369    pub fn time_checkpoints_enabled(&self) -> bool {
370        self.time_checkpoint_version == Some(1)
371    }
372
373    pub fn expired(&self) -> bool {
374        self.deadline
375            .is_some_and(|deadline| Instant::now() >= deadline)
376    }
377
378    pub fn deadline(&self) -> Option<Instant> {
379        self.deadline
380    }
381
382    pub fn models(&self) -> &[Selection] {
383        &self.models
384    }
385
386    /// (E-P7, §S3 decision 3) The effective per-run cost ceiling: the *lower* of
387    /// the plugin-proposed `max_cost_usd` and a host-imposed cap. The plugin may
388    /// only lower, never raise, the host limit. `None` on either side means "no
389    /// limit from that side"; the result is `None` only when both are absent.
390    pub fn effective_cost_cap(&self, host_cap: Option<f64>) -> Option<f64> {
391        match (self.max_cost_usd, host_cap) {
392            (Some(a), Some(b)) => Some(a.min(b)),
393            (Some(a), None) => Some(a),
394            (None, b) => b,
395        }
396    }
397
398    fn check_delay(&self, delay: Duration) -> Result<(), String> {
399        if self.deadline.is_some_and(|deadline| {
400            Instant::now()
401                .checked_add(delay)
402                .is_none_or(|at| at >= deadline)
403        }) {
404            return Err("session driver deadline expires before the proposed turn".into());
405        }
406        Ok(())
407    }
408
409    pub fn accept(&self, reply: Reply) -> Result<Option<Proposal>, String> {
410        validate_reply(&reply)?;
411        if self.expired() {
412            return Err("session driver deadline expired".into());
413        }
414        match reply {
415            Reply::Stop { .. } => Ok(None),
416            Reply::Next {
417                run_id,
418                selection,
419                prompt,
420                delay_ms,
421                notice,
422            } => {
423                if run_id != self.pinned_run_id {
424                    return Err("session driver response has a stale or incorrect run_id".into());
425                }
426                if !self.models.contains(&selection) {
427                    return Err(
428                        "session driver selection is outside the original exact allowlist".into(),
429                    );
430                }
431                let delay = Duration::from_millis(delay_ms);
432                self.check_delay(delay)?;
433                Ok(Some(Proposal {
434                    selection,
435                    prompt,
436                    delay,
437                    notice,
438                }))
439            }
440            Reply::Start { .. } | Reply::Status { .. } => {
441                Err("session driver polls accept only next or stop".into())
442            }
443        }
444    }
445}
446
447#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
448#[serde(rename_all = "snake_case")]
449pub enum Outcome {
450    Success,
451    ProviderError,
452    SelectionRejected,
453    TimeCheckpoint,
454    Blocked,
455}
456
457/// Coarse host metadata only. `decision_id` is stable across transport retries;
458/// the independent command request_id is always fresh. Neither contains secrets.
459#[derive(Debug, Clone, Serialize, Deserialize)]
460pub struct PollRequest {
461    pub run_id: String,
462    pub decision_id: String,
463    pub outcome: Outcome,
464    pub error_kind: String,
465    pub model: String,
466    pub effort: String,
467    /// Only emitted for a grant explicitly opting into feedback v1.
468    #[serde(skip_serializing_if = "Option::is_none")]
469    pub feedback: Option<String>,
470    /// (E-P0, §5 decision 2) Multi-session safety belt; additive, old
471    /// plugins ignore it.
472    #[serde(default, skip_serializing_if = "Option::is_none")]
473    pub session_id: Option<String>,
474    /// (E-P7, §S3) Total USD spent on this session so far, so the plugin can
475    /// make cost-aware next-turn decisions. Additive; old plugins ignore it.
476    #[serde(default, skip_serializing_if = "Option::is_none")]
477    pub session_cost_so_far: Option<f64>,
478}
479
480fn blocked() -> (Outcome, String) {
481    (Outcome::Blocked, "unknown".into())
482}
483fn provider_error(kind: &str) -> (Outcome, String) {
484    (Outcome::ProviderError, kind.into())
485}
486
487/// Typed provenance is mandatory at the frontend boundary. Even `ProviderFailed`
488/// includes local config/session/tool failures: these are NOT failover signals.
489/// Cancellation is Blocked here; the frontend must revoke without polling.
490pub fn classify_turn_error(error: &agent_core::TurnError) -> (Outcome, String) {
491    if matches!(
492        error.outcome,
493        agent_core::TurnOutcome::BudgetExceeded {
494            dimension: agent_core::BudgetDimension::WallClock
495        }
496    ) {
497        return (Outcome::TimeCheckpoint, "wall_clock".into());
498    }
499    let agent_core::TurnOutcome::ProviderFailed { code, .. } = &error.outcome else {
500        return blocked();
501    };
502    match code.as_str() {
503        // Auth also wraps broker machine authorization/storage/transport errors.
504        // Only exact host missing-account templates establish provider provenance.
505        "auth_error" if missing_anthropic_account(&error.message) => provider_error("auth"),
506        "network_error" | "timeout" => provider_error("transient"),
507        "api_status" => classify_error(&error.message),
508        // Narrow exception for the broker's exact NotConfigured template. No
509        // generic Config error, policy denial or freeform credential text is
510        // promoted. Provider keys are verified against host routing metadata.
511        "config_error" if missing_account(&error.message) => provider_error("auth"),
512        _ => blocked(),
513    }
514}
515
516fn missing_anthropic_account(message: &str) -> bool {
517    let message = message.strip_prefix("Auth error: ").unwrap_or(message);
518    matches!(message,
519        "No Anthropic credentials. Run `synaps login`, or switch to a provider model with `/model groq/llama-3.3-70b-versatile`."
520        | "No API key or OAuth token found. Run `synaps login` to authenticate."
521    ) || message == "Token refresh failed: no credential configured for 'anthropic'. Run `synaps login` to add one.. Run `synaps login` to re-authenticate, or check auth.remote_endpoint / broker reachability."
522}
523
524fn known_provider(provider: &str) -> bool {
525    matches!(
526        provider,
527        "anthropic"
528            | "openai-codex"
529            | "xai-auth"
530            | "kimi-code"
531            | "github-copilot"
532            | "google-gemini"
533            | "muse"
534            | "local"
535    ) || crate::runtime::openai::registry::providers()
536        .iter()
537        .any(|p| p.key == provider)
538}
539
540fn missing_account(message: &str) -> bool {
541    let message = message.strip_prefix("Config error: ").unwrap_or(message);
542    let Some(message) = message.strip_prefix("openai provider: ") else {
543        return false;
544    };
545    let message = message
546        .strip_prefix("openai request failed: ")
547        .or_else(|| message.strip_prefix("codex request failed: "))
548        .unwrap_or(message);
549    missing_account_template(message)
550}
551
552fn missing_account_template(message: &str) -> bool {
553    let Some(provider) = message
554        .strip_prefix("no credential configured for '")
555        .and_then(|rest| rest.strip_suffix("'. Run `synaps login` to add one."))
556    else {
557        return false;
558    };
559    known_provider(provider)
560}
561
562fn status_kind(status: u16) -> (Outcome, String) {
563    match status {
564        401 => provider_error("auth"),
565        402 => provider_error("quota"),
566        429 => provider_error("rate_limit"),
567        408 | 500 | 502 | 503 | 504 | 529 => provider_error("transient"),
568        // Generic 403 can be a model/permission/policy restriction. No failover.
569        _ => blocked(),
570    }
571}
572
573/// Recognize only anchored host-generated API status forms. Never search
574/// arbitrary error/transcript text for "auth", "429", "quota" etc. Raw strings
575/// are not copied into the return value. Prefer `classify_turn_error` externally.
576pub fn classify_error(error: &str) -> (Outcome, String) {
577    if error.strip_prefix("API error: ").unwrap_or(error)
578        == crate::runtime::openai::stream::STREAM_INTERRUPTED
579    {
580        return provider_error("transient");
581    }
582
583    let error = error.strip_prefix("API error: ").unwrap_or(error);
584    // Some provider routes wrap broker NotConfigured in ApiStatus instead.
585    if ["openai request failed: ", "codex request failed: "]
586        .iter()
587        .any(|prefix| {
588            error
589                .strip_prefix(prefix)
590                .is_some_and(missing_account_template)
591        })
592    {
593        return provider_error("auth");
594    }
595    for prefix in [
596        "codex request failed: ",
597        "openai request failed: ",
598        "provider request failed: ",
599    ] {
600        if let Some(rest) = error.strip_prefix(prefix) {
601            // Broker errors have a fixed nesting prefix; no substring search.
602            let rest = rest
603                .strip_prefix("broker transport error: ")
604                .unwrap_or(rest);
605            let rest = rest
606                .strip_prefix("provider request failed: ")
607                .unwrap_or(rest);
608            let bytes = rest.as_bytes();
609            if bytes.len() >= 3
610                && bytes[..3].iter().all(u8::is_ascii_digit)
611                && (bytes.len() == 3 || matches!(bytes[3], b' ' | b':' | b'('))
612            {
613                return status_kind(rest[..3].parse().unwrap_or(0));
614            }
615            return blocked();
616        }
617    }
618    // Exact host-authored Anthropic SSE templates survive as api_status after
619    // provider retries. Never match provider free-text or policy refusals.
620    if let Some(kind) = error.strip_prefix("API stream error (").and_then(|s| {
621        s.strip_suffix("). Provider error details withheld — they can echo request content.")
622    }) {
623        return match kind {
624            "authentication_error" => provider_error("auth"),
625            "billing_error" => provider_error("quota"),
626            "rate_limit_error" => provider_error("rate_limit"),
627            "overloaded_error" | "api_error" | "timeout_error" => provider_error("transient"),
628            _ => blocked(),
629        };
630    }
631    if matches!(error,
632        "Request to api.anthropic.com timed out. Check your connection and try again."
633        | "Could not reach api.anthropic.com (connection failed). Check your network, DNS, or proxy settings."
634        | "Connection lost mid-response. Partial reply kept — send again to continue."
635    ) { return provider_error("transient"); }
636    // These messages are authored by core/error.rs, not remote response bodies.
637    if error == "Authentication rejected. Run `synaps login` to re-authenticate." {
638        return provider_error("auth");
639    }
640    if status_template(error, "Rate limited by Anthropic (HTTP 429")
641        || error.starts_with(
642            "Rate limit exhausted — retries used up while waiting for reset (next window in ",
643        )
644    {
645        return provider_error("rate_limit");
646    }
647    if error
648        == "Anthropic is overloaded right now. Retries exhausted — wait a minute and try again."
649    {
650        return provider_error("transient");
651    }
652    for status in [500, 502, 503] {
653        if status_template(error, &format!("Anthropic server error (HTTP {status}")) {
654            return provider_error("transient");
655        }
656    }
657    for label in ["Codex", "OpenAI", "xAI"] {
658        use crate::runtime::openai::stream::{
659            RESPONSES_AUTH_SUFFIX, RESPONSES_CAPACITY_SUFFIX, RESPONSES_EMPTY_SUFFIX,
660            RESPONSES_MISSING_TERMINAL_SUFFIX, RESPONSES_QUOTA_SUFFIX,
661        };
662        for (suffix, kind) in [
663            (RESPONSES_AUTH_SUFFIX, "auth"),
664            (RESPONSES_QUOTA_SUFFIX, "quota"),
665            (RESPONSES_CAPACITY_SUFFIX, "transient"),
666            (RESPONSES_EMPTY_SUFFIX, "transient"),
667            (RESPONSES_MISSING_TERMINAL_SUFFIX, "transient"),
668        ] {
669            if error == format!("{label}{suffix}") {
670                return provider_error(kind);
671            }
672        }
673    }
674    // Host-authored network templates have a single bounded hostname slot.
675    // Do not inspect the bracketed underlying error chain (it may be secret).
676    for (prefix, suffix) in [
677        (
678            "Request to ",
679            " timed out — usually transient; check your connection and try again. [",
680        ),
681        (
682            "Could not reach ",
683            " (connection failed). Check your network, DNS, or proxy settings. [",
684        ),
685        (
686            "Connection to ",
687            " lost mid-response — usually transient; try again. [",
688        ),
689    ] {
690        if let Some(rest) = error.strip_prefix(prefix) {
691            if let Some((host, _)) = rest.split_once(suffix) {
692                if valid_network_host(host) && error.ends_with(']') {
693                    return provider_error("transient");
694                }
695            }
696        }
697    }
698    if let Some((provider, rest)) = error.split_once(": ") {
699        if known_provider(provider) {
700            if rest.starts_with("usage quota exhausted (HTTP 403). ")
701                || rest.starts_with("usage quota exhausted (HTTP 429). ")
702            {
703                return provider_error("quota");
704            }
705            if rest.starts_with("authentication rejected (HTTP 401). ") {
706                return provider_error("auth");
707            }
708            if rest.starts_with("rate limited (HTTP 429). ") {
709                return provider_error("rate_limit");
710            }
711        }
712    }
713    blocked()
714}
715
716fn status_template(error: &str, prefix: &str) -> bool {
717    error
718        .strip_prefix(prefix)
719        .is_some_and(|rest| rest.starts_with("). ") || rest.starts_with(" ["))
720}
721
722fn valid_network_host(host: &str) -> bool {
723    host == "the provider endpoint"
724        || (!host.is_empty()
725            && host.len() <= 253
726            && host.bytes().all(|b| {
727                b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b':' | b'[' | b']')
728            }))
729}
730
731/// Bounded asynchronous poll using the existing command.invoke frame contract.
732/// No spawned task or manager lock is retained here: dropping this future drops
733/// both invocation and collector. The frontend owns cancellation/generation.
734pub async fn poll(
735    handler: Arc<dyn ExtensionHandler>,
736    request: PollRequest,
737) -> Result<Reply, String> {
738    if !valid_id(&request.run_id) || !valid_id(&request.decision_id) {
739        return Err("session driver poll identifiers are invalid".into());
740    }
741    validate_selection(&Selection {
742        model: request.model.clone(),
743        effort: request.effort.clone(),
744    })?;
745    if !matches!(
746        request.error_kind.as_str(),
747        "none" | "auth" | "quota" | "rate_limit" | "transient" | "wall_clock" | "unknown"
748    ) {
749        return Err("session driver poll requires a coarse error kind".into());
750    }
751    if (request.outcome == Outcome::TimeCheckpoint) != (request.error_kind == "wall_clock") {
752        return Err("inconsistent time checkpoint metadata".into());
753    }
754    if request.feedback.as_deref().is_some_and(|f| {
755        !matches!(f, "unknown" | "changed" | "repeated" | "empty")
756            || (request.outcome != Outcome::Success && f != "unknown")
757    }) {
758        return Err("invalid session driver feedback".into());
759    }
760    let args = vec![serde_json::to_string(&request)
761        .map_err(|_| "invalid session driver poll request".to_string())?];
762    let request_id = uuid::Uuid::new_v4().to_string();
763    let result = tokio::time::timeout(POLL_TIMEOUT, async {
764        let (sink, collector) = invoke_event_channel(InvokeOutputBudget::default());
765        tokio::join!(
766            handler.invoke_command("__session_driver__", args, &request_id, sink),
767            collector.collect()
768        )
769    })
770    .await
771    .map_err(|_| "session driver poll timed out".to_string())?;
772    let (value, report) = result;
773    // Display output cannot drive a session. Errors and overflow stop visibly,
774    // even if the final structured response otherwise proposes a valid next.
775    if report.is_limited() {
776        return Err("session driver poll output exceeded its bounded frame budget".into());
777    }
778    if report.events.iter().any(|event| {
779        matches!(
780            event,
781            InvokeCommandEvent::Output(CommandOutputEvent::Error { .. })
782        )
783    }) {
784        return Err("session driver plugin reported an error".into());
785    }
786    let value = value.map_err(|_| "session driver plugin invocation failed".to_string())?;
787    let reply = parse_reply(&value)?
788        .ok_or_else(|| "session driver poll omitted its structured response".to_string())?;
789    match reply {
790        Reply::Next { .. } | Reply::Stop { .. } => Ok(reply),
791        _ => Err("session driver polls accept only next or stop".into()),
792    }
793}
794
795#[derive(Debug, Clone, PartialEq, Eq)]
796pub enum PrepareError {
797    Selection(String),
798    Blocked(String),
799}
800impl std::fmt::Display for PrepareError {
801    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
802        match self {
803            Self::Selection(error) | Self::Blocked(error) => f.write_str(error),
804        }
805    }
806}
807impl std::error::Error for PrepareError {}
808
809/// Atomic session-only model/effort preparation. `history` is the EXISTING
810/// history: this function adds the proposed user message for full preflight.
811/// The caller commits returned Runtime plus its own session mirrors only on
812/// success, after rechecking cancellation/deadline/handler/session identity.
813/// No config writes, credential refresh, provider calls, or history mutation.
814pub async fn prepare(
815    runtime: &Runtime,
816    proposal: &Proposal,
817    history: &[SharedMessage],
818) -> Result<Runtime, PrepareError> {
819    use agent_core::orchestration::CompletionGate;
820    if let Some(policy) = runtime.orchestration() {
821        if matches!(policy.completion_gate(), CompletionGate::Blocked { .. }) {
822            return Err(PrepareError::Blocked(
823                "session driver blocked: workers require collection/reconciliation".into(),
824            ));
825        }
826        if proposal.selection.model != runtime.model()
827            && !policy.unreconciled_runtime_handles().is_empty()
828        {
829            return Err(PrepareError::Blocked(
830                "session driver cannot change model while workers are outstanding".into(),
831            ));
832        }
833    }
834    if let Some(prompt) = runtime.effective_prompt() {
835        if prompt.provenance("").foreground_model != proposal.selection.model {
836            return Err(PrepareError::Blocked(
837                "session driver cannot change a typed prompt's pinned foreground model".into(),
838            ));
839        }
840    }
841    if proposal.prompt.trim().is_empty() || proposal.prompt.len() > MAX_PROMPT_BYTES {
842        return Err(PrepareError::Blocked(
843            "invalid session driver proposed prompt".into(),
844        ));
845    }
846    let level = validate_selection(&proposal.selection).map_err(PrepareError::Selection)?;
847    let model = &proposal.selection.model;
848    let (provider, model_id) = model.split_once('/').expect("validated qualified model");
849    let route = crate::runtime::openai::resolve_route(model).ok_or_else(|| {
850        PrepareError::Selection("session driver model has no exact supported provider route".into())
851    })?;
852    if route.provider != provider || route.model != model_id {
853        return Err(PrepareError::Selection(
854            "session driver refuses provider or model alias substitution".into(),
855        ));
856    }
857    // A media/history incompatibility is a hard stop, even when the same
858    // proposal also requested an unsupported effort. Never fail over retained
859    // multimodal work as though it were an account or selection failure.
860    let mut proposed_history = history.to_vec();
861    proposed_history.push(Arc::new(json!({"role":"user", "content":proposal.prompt})));
862    crate::runtime::attachments::validate_history_for_request(model, &proposed_history)
863        .map_err(PrepareError::Blocked)?;
864    validate_model_capability(model, level).map_err(PrepareError::Selection)?;
865    let mut candidate = runtime.clone();
866    // Same-model changes MUST preserve the existing policy Arc, including
867    // session-only worker grants. try_set_model replaces manifestless policy.
868    if candidate.model() != model {
869        candidate.try_set_model(model.clone()).map_err(|_| {
870            PrepareError::Blocked("session driver model change blocked by host policy".into())
871        })?;
872    }
873    // Exact model/effort validity already passed above. Remaining checked-set
874    // failures are host prerequisites (e.g. ultracode lifecycle tools), not
875    // permission to fail over to another model and evade that prerequisite.
876    candidate.set_reasoning_level_checked(level).map_err(|_| {
877        PrepareError::Blocked(
878            "session driver reasoning change blocked by host prerequisites".into(),
879        )
880    })?;
881    if candidate.model() != model || candidate.reasoning_level() != level {
882        return Err(PrepareError::Selection(
883            "session driver requires the exact model and effort without downgrades".into(),
884        ));
885    }
886    candidate
887        .validate_session_driver_preflight()
888        .await
889        .map_err(|_| {
890            PrepareError::Blocked(
891                "session driver blocked by request policy or execution prerequisites".into(),
892            )
893        })?;
894    Ok(candidate)
895}
896
897/// Recheck dynamic catalog/media evidence immediately before frontend commit.
898/// Async credential preflight may have yielded while a capability was revoked.
899pub fn validate_prepared(
900    runtime: &Runtime,
901    proposal: &Proposal,
902    history: &[SharedMessage],
903) -> Result<(), String> {
904    let level = validate_selection(&proposal.selection)?;
905    if runtime.model() != proposal.selection.model || runtime.reasoning_level() != level {
906        return Err("prepared runtime no longer matches the exact proposal".into());
907    }
908    validate_model_capability(&proposal.selection.model, level)?;
909    let mut messages = history.to_vec();
910    messages.push(Arc::new(json!({"role":"user", "content":proposal.prompt})));
911    crate::runtime::attachments::validate_history_for_request(&proposal.selection.model, &messages)
912}
913
914/// Mutation validation is deliberately permissive for arbitrary OpenAI-compatible
915/// model IDs. A driver is stricter: require an exact known catalog row and named
916/// effort evidence; unknown generic capability only allows provider-default
917/// `adaptive`, never a silently omitted named effort or off toggle.
918fn validate_model_capability(model: &str, level: ReasoningLevel) -> Result<(), String> {
919    use crate::runtime::openai::{catalog, registry};
920    use catalog::ReasoningSupport;
921    let (provider, id) = model.split_once('/').ok_or("invalid qualified model")?;
922    let cached = catalog::capability_cache::get(model);
923    let known = cached
924        .as_ref()
925        .is_some_and(|entry| entry.runtime_id() == model)
926        || match provider {
927            "anthropic" => agent_core::models::KNOWN_MODELS
928                .iter()
929                .any(|(known, _)| *known == id),
930            "openai-codex" => catalog::codex_static_capability(id).is_some(),
931            "xai-auth" => catalog::xai_model(id).is_some(),
932            "kimi-code" => catalog::kimi_code_model(id).is_some(),
933            "github-copilot" => catalog::github_copilot_runtime_model(id).is_some(),
934            "google-gemini" => catalog::google_gemini_model(id).is_some(),
935            "muse" => catalog::muse_runtime_model(id),
936            _ => registry::providers()
937                .iter()
938                .find(|p| p.key == provider)
939                .is_some_and(|p| p.models.iter().any(|(known, _, _)| *known == id)),
940        };
941    if !known {
942        return Err("session driver model has no exact known capability descriptor".into());
943    }
944    if !matches!(
945        provider,
946        "anthropic" | "openai-codex" | "xai-auth" | "kimi" | "kimi-code"
947    ) && level != ReasoningLevel::Adaptive
948    {
949        let exact_effort = match cached.as_ref().map(|entry| &entry.reasoning) {
950            Some(ReasoningSupport::OpenRouter { effort: true, .. }) => matches!(
951                level,
952                ReasoningLevel::Low | ReasoningLevel::Medium | ReasoningLevel::High
953            ),
954            // Live catalog's exact advertised set (Meta Muse).
955            Some(ReasoningSupport::NamedEffort { supported, .. }) => supported.contains(&level),
956            _ => false,
957        };
958        if !exact_effort {
959            return Err("session driver effort has no exact supported capability evidence".into());
960        }
961    }
962    catalog::validation::validate_reasoning_mutation(model, level)
963}
964
965#[cfg(test)]
966mod tests {
967    use super::*;
968    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
969
970    fn selection() -> Selection {
971        Selection {
972            model: "anthropic/claude-fable-5".into(),
973            effort: "high".into(),
974        }
975    }
976    fn start() -> Value {
977        json!({"session_driver": {"action":"start", "run_id":"run-1", "models":[selection()], "prompt":"Inspect retained work", "delay_ms":1000}})
978    }
979    fn next() -> Reply {
980        Reply::Next {
981            run_id: "run-1".into(),
982            selection: selection(),
983            prompt: "Continue retained work".into(),
984            delay_ms: 1000,
985            notice: String::new(),
986        }
987    }
988    fn grant() -> (Grant, Proposal) {
989        Grant::from_start("driver", "session", parse_reply(&start()).unwrap().unwrap()).unwrap()
990    }
991    fn request() -> PollRequest {
992        PollRequest {
993            run_id: "run-1".into(),
994            decision_id: "decision-1".into(),
995            outcome: Outcome::Success,
996            error_kind: "none".into(),
997            model: selection().model,
998            effort: selection().effort,
999            feedback: None,
1000            session_id: None,
1001            session_cost_so_far: None,
1002        }
1003    }
1004
1005    #[test]
1006    fn parse_only_structured_driver_and_optional_start_selection() {
1007        assert_eq!(
1008            parse_reply(&json!({"text":"session_driver start"})).unwrap(),
1009            None
1010        );
1011        let mut value = start();
1012        value["session_driver"]["selection"] = json!(selection());
1013        assert!(
1014            matches!(parse_reply(&value).unwrap(), Some(Reply::Start { notice, max_duration_ms: None, .. }) if notice.is_empty())
1015        );
1016        value["session_driver"]["selection"]["effort"] = json!("low");
1017        assert!(parse_reply(&value).is_err());
1018        assert!(parse_reply(&json!({"session_driver":null})).is_err());
1019    }
1020
1021    #[test]
1022    fn parser_rejects_unknown_fields_types_and_noncanonical_efforts() {
1023        for effort in [
1024            "HIGH", " high", "med", "none", "x-high", "x_high", "maximum", "1000",
1025        ] {
1026            let mut value = start();
1027            value["session_driver"]["models"][0]["effort"] = json!(effort);
1028            assert!(parse_reply(&value).is_err(), "{effort}");
1029        }
1030        for effort in [
1031            "off",
1032            "adaptive",
1033            "low",
1034            "medium",
1035            "high",
1036            "xhigh",
1037            "max",
1038            "ultra",
1039            "ultracode",
1040        ] {
1041            assert!(exact_effort(effort).is_ok());
1042        }
1043        for (key, bad) in [
1044            ("unexpected", json!(true)),
1045            ("delay_ms", json!("1000")),
1046            ("max_duration_ms", json!(-1)),
1047            ("notice", json!(false)),
1048            ("run_id", json!("bad/id")),
1049        ] {
1050            let mut value = start();
1051            value["session_driver"][key] = bad;
1052            assert!(parse_reply(&value).is_err(), "{key}");
1053        }
1054        let mut value = start();
1055        value["session_driver"]["models"][0]["secret"] = json!("must not echo");
1056        let err = parse_reply(&value).unwrap_err();
1057        assert!(!err.contains("must not echo"));
1058        for model in [
1059            "claude-fable-5-1",
1060            "anthropic/",
1061            "anthropic//model",
1062            " anthropic/model",
1063            "anthropic/a\nb",
1064        ] {
1065            value = start();
1066            value["session_driver"]["models"][0]["model"] = json!(model);
1067            assert!(parse_reply(&value).is_err());
1068        }
1069    }
1070
1071    #[test]
1072    fn parser_enforces_all_byte_count_and_time_bounds() {
1073        for (key, good, bad) in [
1074            (
1075                "prompt",
1076                json!("p".repeat(MAX_PROMPT_BYTES)),
1077                json!("p".repeat(MAX_PROMPT_BYTES + 1)),
1078            ),
1079            (
1080                "notice",
1081                json!("n".repeat(MAX_NOTICE_BYTES)),
1082                json!("n".repeat(MAX_NOTICE_BYTES + 1)),
1083            ),
1084            (
1085                "run_id",
1086                json!("r".repeat(MAX_ID_BYTES)),
1087                json!("r".repeat(MAX_ID_BYTES + 1)),
1088            ),
1089            ("delay_ms", json!(MAX_DELAY_MS), json!(MAX_DELAY_MS + 1)),
1090            (
1091                "max_duration_ms",
1092                json!(MAX_DURATION_MS),
1093                json!(MAX_DURATION_MS + 1),
1094            ),
1095        ] {
1096            let mut value = start();
1097            value["session_driver"][key] = good;
1098            assert!(parse_reply(&value).is_ok(), "{key}");
1099            value["session_driver"][key] = bad;
1100            assert!(parse_reply(&value).is_err(), "{key}");
1101        }
1102        for (key, bad) in [
1103            ("delay_ms", json!(999)),
1104            ("max_duration_ms", json!(0)),
1105            ("prompt", json!(" \n\t")),
1106            ("run_id", json!("")),
1107        ] {
1108            let mut value = start();
1109            value["session_driver"][key] = bad;
1110            assert!(parse_reply(&value).is_err());
1111        }
1112        let mut value = start();
1113        value["session_driver"]["prompt"] = json!("é".repeat(MAX_PROMPT_BYTES / 2 + 1));
1114        assert!(parse_reply(&value).is_err());
1115        value = start();
1116        value["unrelated"] = json!("x".repeat(MAX_REPLY_BYTES));
1117        assert!(parse_reply(&value).is_err());
1118        value = start();
1119        value["session_driver"]["models"][0]["model"] =
1120            json!(format!("p/{}", "m".repeat(MAX_MODEL_BYTES - 2)));
1121        assert!(parse_reply(&value).is_ok());
1122        value["session_driver"]["models"][0]["model"] =
1123            json!(format!("p/{}", "m".repeat(MAX_MODEL_BYTES - 1)));
1124        assert!(parse_reply(&value).is_err());
1125        value = start();
1126        let models: Vec<_> = (0..MAX_MODELS)
1127            .map(|i| json!({"model":format!("p/m{i}"),"effort":"high"}))
1128            .collect();
1129        value["session_driver"]["models"] = json!(models);
1130        assert!(parse_reply(&value).is_ok());
1131        value["session_driver"]["models"]
1132            .as_array_mut()
1133            .unwrap()
1134            .push(json!({"model":"p/extra","effort":"high"}));
1135        assert!(parse_reply(&value).is_err());
1136        value["session_driver"]["models"] = json!([]);
1137        assert!(parse_reply(&value).is_err());
1138        value["session_driver"]["models"] = json!([
1139            selection(),
1140            Selection {
1141                effort: "low".into(),
1142                ..selection()
1143            }
1144        ]);
1145        assert!(parse_reply(&value).is_err());
1146    }
1147
1148    #[test]
1149    fn grant_pins_exact_run_model_effort_and_rejects_rearming() {
1150        let (mut grant, first) = grant();
1151        assert_eq!(first.selection, selection());
1152        assert_eq!(grant.plugin_id, "driver");
1153        assert_eq!(grant.session_id, "session");
1154        assert!(grant.deadline().is_none());
1155        assert!(!grant.expired());
1156        assert!(grant.accept(next()).unwrap().is_some());
1157        let Reply::Next {
1158            selection: first_selection,
1159            prompt,
1160            delay_ms,
1161            notice,
1162            ..
1163        } = next()
1164        else {
1165            unreachable!()
1166        };
1167        assert!(grant
1168            .accept(Reply::Next {
1169                run_id: "different-run".into(),
1170                selection: first_selection,
1171                prompt,
1172                delay_ms,
1173                notice
1174            })
1175            .is_err());
1176        let Reply::Next {
1177            run_id,
1178            prompt,
1179            delay_ms,
1180            notice,
1181            ..
1182        } = next()
1183        else {
1184            unreachable!()
1185        };
1186        for selected in [
1187            Selection {
1188                effort: "low".into(),
1189                ..selection()
1190            },
1191            Selection {
1192                model: "openai-codex/gpt-6-astra".into(),
1193                effort: "ultra".into(),
1194            },
1195        ] {
1196            assert!(grant
1197                .accept(Reply::Next {
1198                    run_id: run_id.clone(),
1199                    selection: selected,
1200                    prompt: prompt.clone(),
1201                    delay_ms,
1202                    notice: notice.clone()
1203                })
1204                .is_err());
1205        }
1206        assert!(grant
1207            .accept(parse_reply(&start()).unwrap().unwrap())
1208            .is_err());
1209        assert!(grant
1210            .accept(Reply::Status {
1211                notice: String::new()
1212            })
1213            .is_err());
1214        assert!(grant
1215            .accept(Reply::Stop {
1216                notice: String::new()
1217            })
1218            .unwrap()
1219            .is_none());
1220        grant.run_id = "different-run".into();
1221        assert!(
1222            grant.accept(next()).is_ok(),
1223            "public bookkeeping must not change pinned authority"
1224        );
1225    }
1226
1227    #[test]
1228    fn grant_revalidates_direct_constructors_and_deadline_scheduling() {
1229        let (mut grant, _) = grant();
1230        grant.deadline = Some(Instant::now() + Duration::from_millis(500));
1231        assert!(!grant.expired());
1232        assert!(
1233            grant.accept(next()).is_err(),
1234            "do not schedule beyond deadline"
1235        );
1236        grant.deadline = Some(Instant::now() - Duration::from_millis(1));
1237        assert!(grant.expired());
1238        assert!(grant.accept(next()).is_err());
1239        let mut value = start();
1240        value["session_driver"]["max_duration_ms"] = json!(500);
1241        assert!(Grant::from_start("p", "s", parse_reply(&value).unwrap().unwrap()).is_err());
1242        value["session_driver"]["max_duration_ms"] = json!(10_000);
1243        assert!(
1244            Grant::from_start("p", "s", parse_reply(&value).unwrap().unwrap())
1245                .unwrap()
1246                .0
1247                .deadline()
1248                .is_some()
1249        );
1250        let bad = Reply::Start {
1251            run_id: "x".into(),
1252            models: vec![],
1253            prompt: "hello".into(),
1254            delay_ms: 1000,
1255            max_duration_ms: None,
1256            feedback_version: None,
1257            time_checkpoint_version: None,
1258            context_mode: None,
1259            notice: String::new(),
1260            max_cost_usd: None,
1261        };
1262        assert!(Grant::from_start("p", "s", bad).is_err());
1263    }
1264
1265    #[test]
1266    fn time_checkpoint_support_is_versioned_and_not_provider_failure() {
1267        let legacy = parse_reply(&start()).unwrap().unwrap();
1268        assert!(!Grant::from_start("p", "s", legacy)
1269            .unwrap()
1270            .0
1271            .time_checkpoints_enabled());
1272        let mut value = start();
1273        value["session_driver"]["time_checkpoint_version"] = json!(1);
1274        let grant = Grant::from_start("p", "s", parse_reply(&value).unwrap().unwrap())
1275            .unwrap()
1276            .0;
1277        assert!(grant.time_checkpoints_enabled());
1278        for bad in [json!(0), json!(2), json!(true), json!("1")] {
1279            value["session_driver"]["time_checkpoint_version"] = bad;
1280            assert!(parse_reply(&value).is_err());
1281        }
1282        assert_eq!(
1283            classify_turn_error(&agent_core::TurnError::budget(
1284                agent_core::BudgetDimension::WallClock
1285            )),
1286            (Outcome::TimeCheckpoint, "wall_clock".into())
1287        );
1288        for dimension in [
1289            agent_core::BudgetDimension::CostUsd,
1290            agent_core::BudgetDimension::ToolCalls,
1291            agent_core::BudgetDimension::ProviderRounds,
1292        ] {
1293            assert_eq!(
1294                classify_turn_error(&agent_core::TurnError::budget(dimension)),
1295                blocked()
1296            );
1297        }
1298    }
1299
1300    #[test]
1301    fn context_mode_is_optional_strict_and_start_only() {
1302        let legacy = start();
1303        assert!(matches!(
1304            parse_reply(&legacy).unwrap(),
1305            Some(Reply::Start {
1306                context_mode: None,
1307                ..
1308            })
1309        ));
1310        for (name, expected) in [("auto", ContextMode::Auto), ("off", ContextMode::Off)] {
1311            let mut value = start();
1312            value["session_driver"]["context_mode"] = json!(name);
1313            let reply = parse_reply(&value).unwrap().unwrap();
1314            assert!(
1315                matches!(&reply, Reply::Start { context_mode:Some(mode), .. } if *mode == expected)
1316            );
1317            let encoded = serde_json::to_value(&reply).unwrap();
1318            assert_eq!(encoded["context_mode"], name);
1319            let (grant, _) = Grant::from_start("fixture", "session", reply).unwrap();
1320            assert_eq!(grant.context_mode, Some(expected));
1321        }
1322        for bad in [
1323            json!(null),
1324            json!(true),
1325            json!(1),
1326            json!("AUTO"),
1327            json!("inherit"),
1328            json!({}),
1329            json!([]),
1330        ] {
1331            let mut value = start();
1332            value["session_driver"]["context_mode"] = bad;
1333            assert!(parse_reply(&value).is_err());
1334        }
1335        for action in ["next", "stop", "status"] {
1336            let value = json!({"session_driver":{
1337                "action":action, "context_mode":"auto"
1338            }});
1339            assert!(parse_reply(&value).is_err());
1340        }
1341    }
1342
1343    #[test]
1344    #[serial_test::serial(synaps_base_dir)]
1345    fn context_mode_uses_session_command_without_recall_or_global_config_changes() {
1346        let env = crate::test_env::BaseDirGuard::new();
1347        let runtime = Runtime::new_headless();
1348        let (legacy, _) = Grant::from_start(
1349            "fixture",
1350            "session",
1351            parse_reply(&start()).unwrap().unwrap(),
1352        )
1353        .unwrap();
1354        assert!(legacy.apply_context_mode(&runtime).unwrap().is_none());
1355        assert!(!runtime.context_management_enabled());
1356        let mut value = start();
1357        value["session_driver"]["context_mode"] = json!("auto");
1358        let (auto, _) =
1359            Grant::from_start("fixture", "session", parse_reply(&value).unwrap().unwrap()).unwrap();
1360        let notice = auto.apply_context_mode(&runtime).unwrap().unwrap();
1361        assert!(runtime.context_management_enabled());
1362        assert!(notice.contains("Session-only"));
1363        assert!(notice.contains("excludes private reasoning"));
1364        assert!(legacy.apply_context_mode(&runtime).unwrap().is_none());
1365        assert!(
1366            runtime.context_management_enabled(),
1367            "legacy mode cannot undo an explicit choice"
1368        );
1369        value["session_driver"]["context_mode"] = json!("off");
1370        let (off, _) =
1371            Grant::from_start("fixture", "session", parse_reply(&value).unwrap().unwrap()).unwrap();
1372        off.apply_context_mode(&runtime).unwrap();
1373        assert!(!runtime.context_management_enabled());
1374        assert!(!env.path().join("config").exists());
1375        assert!(!env.path().join("context-archives").exists());
1376    }
1377
1378    #[test]
1379    #[serial_test::serial(synaps_base_dir)]
1380    fn context_auto_backend_failure_preserves_previous_mode() {
1381        let _env = crate::test_env::BaseDirGuard::new();
1382        let mut runtime = Runtime::new_headless();
1383        runtime.apply_memory_backend_config(&agent_core::config::MemoryBackendConfig {
1384            kind: agent_core::config::MemoryBackendKind::Unavailable,
1385            ..Default::default()
1386        });
1387        let mut value = start();
1388        value["session_driver"]["context_mode"] = json!("auto");
1389        let (grant, _) =
1390            Grant::from_start("fixture", "session", parse_reply(&value).unwrap().unwrap()).unwrap();
1391        assert!(grant
1392            .apply_context_mode(&runtime)
1393            .unwrap_err()
1394            .contains("unavailable"));
1395        assert!(!runtime.context_management_enabled());
1396    }
1397
1398    #[test]
1399    fn known_stream_failures_reach_fallback_without_promoting_policy_or_context() {
1400        for (kind, expected) in [
1401            ("authentication_error", "auth"),
1402            ("billing_error", "quota"),
1403            ("rate_limit_error", "rate_limit"),
1404            ("api_error", "transient"),
1405            ("overloaded_error", "transient"),
1406            ("timeout_error", "transient"),
1407        ] {
1408            let msg = format!("API stream error ({kind}). Provider error details withheld — they can echo request content.");
1409            assert_eq!(
1410                classify_turn_error(&agent_core::TurnError::provider(&msg, "api_status", "t")),
1411                provider_error(expected)
1412            );
1413            assert_eq!(
1414                classify_turn_error(&agent_core::TurnError::provider(&msg, "tool_error", "t")),
1415                blocked()
1416            );
1417            assert_eq!(classify_error(&format!("arbitrary text {msg}")), blocked());
1418        }
1419        for kind in [
1420            "permission_error",
1421            "invalid_request_error",
1422            "request_too_large",
1423            "unrecognized_error",
1424        ] {
1425            assert_eq!(classify_error(&format!("API stream error ({kind}). Provider error details withheld — they can echo request content.")), blocked());
1426        }
1427        use crate::runtime::openai::stream::*;
1428        for label in ["Codex", "OpenAI", "xAI"] {
1429            for (suffix, expected) in [
1430                (RESPONSES_AUTH_SUFFIX, "auth"),
1431                (RESPONSES_QUOTA_SUFFIX, "quota"),
1432                (RESPONSES_EMPTY_SUFFIX, "transient"),
1433                (RESPONSES_MISSING_TERMINAL_SUFFIX, "transient"),
1434            ] {
1435                assert_eq!(
1436                    classify_error(&format!("{label}{suffix}")),
1437                    provider_error(expected)
1438                );
1439            }
1440            for suffix in [
1441                RESPONSES_CONTEXT_SUFFIX,
1442                RESPONSES_INCOMPLETE_SUFFIX,
1443                RESPONSES_FAILED_SUFFIX,
1444            ] {
1445                assert_eq!(classify_error(&format!("{label}{suffix}")), blocked());
1446            }
1447        }
1448        for msg in ["Request to api.anthropic.com timed out. Check your connection and try again.",
1449            "Could not reach api.anthropic.com (connection failed). Check your network, DNS, or proxy settings.",
1450            "Connection lost mid-response. Partial reply kept — send again to continue."] {
1451            assert_eq!(classify_error(msg), provider_error("transient"));
1452        }
1453    }
1454
1455    #[test]
1456    fn feedback_version_is_explicit_pinned_and_bounded() {
1457        let (grant, _) =
1458            Grant::from_start("p", "s", parse_reply(&start()).unwrap().unwrap()).unwrap();
1459        assert!(!grant.feedback_enabled());
1460        let legacy = serde_json::to_value(request()).unwrap();
1461        assert!(legacy.get("feedback").is_none());
1462        let mut value = start();
1463        value["session_driver"]["feedback_version"] = json!(1);
1464        let (grant, _) =
1465            Grant::from_start("p", "s", parse_reply(&value).unwrap().unwrap()).unwrap();
1466        assert!(grant.feedback_enabled());
1467        for bad in [
1468            json!(0),
1469            json!(2),
1470            json!(true),
1471            json!("1"),
1472            json!(256),
1473            json!(-1),
1474        ] {
1475            value["session_driver"]["feedback_version"] = bad;
1476            assert!(parse_reply(&value).is_err());
1477        }
1478    }
1479
1480    #[test]
1481    fn typed_error_classification_uses_host_templates_not_arbitrary_substrings() {
1482        for (message, kind) in [
1483            ("API error: Authentication rejected. Run `synaps login` to re-authenticate.", "auth"),
1484            ("API error: codex request failed: 401: SECRET", "auth"),
1485            ("API error: openai request failed: 503: SECRET", "transient"),
1486            ("openai request failed: provider request failed: 429 Too Many Requests", "rate_limit"),
1487            ("Rate limited by Anthropic (HTTP 429 [rate_limit_error]). Wait for reset", "rate_limit"),
1488            ("Rate limit exhausted — retries used up while waiting for reset (next window in 5s). Try again shortly", "rate_limit"),
1489            ("Anthropic is overloaded right now. Retries exhausted — wait a minute and try again.", "transient"),
1490            ("Anthropic server error (HTTP 503 [api_error]). Retries exhausted", "transient"),
1491            ("kimi-code: usage quota exhausted (HTTP 403). Your plan's usage window is used up", "quota"),
1492            ("xAI reports the model is at capacity (retries exhausted). Try again in a few minutes or switch models with /model.", "transient"),
1493            ("Request to chatgpt.com timed out — usually transient; check your connection and try again. [SECRET]", "transient"),
1494            ("Could not reach api.x.ai (connection failed). Check your network, DNS, or proxy settings. [SECRET]", "transient"),
1495        ] {
1496            let error = agent_core::TurnError::provider(message, "api_status", "not-forwarded");
1497            assert_eq!(classify_turn_error(&error), provider_error(kind), "{message}");
1498        }
1499        for code in ["config_error", "session_error", "tool_error", "unknown"] {
1500            let error = agent_core::TurnError::provider(
1501                "API error: codex request failed: 401: SECRET",
1502                code,
1503                "private",
1504            );
1505            assert_eq!(classify_turn_error(&error), blocked());
1506        }
1507        for message in [
1508            "transcript says auth 401 quota",
1509            "Tool execution failed: openai request failed: 429",
1510            "codex request failed: 4290: fake",
1511            "Access denied (HTTP 403). Your account may not have access to this model.",
1512            "Context window exceeded (HTTP 400)",
1513            "Request too large",
1514            "invalid media auth error",
1515        ] {
1516            assert_eq!(
1517                classify_turn_error(&agent_core::TurnError::provider(
1518                    message,
1519                    "api_status",
1520                    "private"
1521                )),
1522                blocked(),
1523                "{message}"
1524            );
1525        }
1526        for code in ["network_error", "timeout"] {
1527            assert_eq!(
1528                classify_turn_error(&agent_core::TurnError::provider("SECRET", code, "private")),
1529                provider_error("transient")
1530            );
1531        }
1532        for message in [
1533            "SECRET",
1534            "Auth error: Token refresh failed: broker rejected machine auth",
1535            "Auth error: Token refresh failed: broker denied request: policy",
1536            "Auth error: Token refresh failed: broker transport error: broker HTTP 403",
1537            "Auth error: Token refresh failed: credential error: storage unavailable",
1538        ] {
1539            assert_eq!(
1540                classify_turn_error(&agent_core::TurnError::provider(
1541                    message,
1542                    "auth_error",
1543                    "private"
1544                )),
1545                blocked()
1546            );
1547        }
1548        assert_eq!(
1549            classify_turn_error(&agent_core::TurnError::provider(
1550                "Auth error: No API key or OAuth token found. Run `synaps login` to authenticate.",
1551                "auth_error",
1552                "t"
1553            )),
1554            provider_error("auth")
1555        );
1556        for outcome in [
1557            agent_core::TurnOutcome::Canceled,
1558            agent_core::TurnOutcome::Completed,
1559            agent_core::TurnOutcome::InterruptedAfterSideEffect {
1560                call_id: "private".into(),
1561            },
1562        ] {
1563            assert_eq!(
1564                classify_turn_error(&agent_core::TurnError {
1565                    message: "openai request failed: 429".into(),
1566                    outcome
1567                }),
1568                blocked()
1569            );
1570        }
1571    }
1572
1573    #[test]
1574    fn missing_accounts_are_exact_exceptions_not_generic_config_failover() {
1575        for (code, message) in [
1576            ("config_error", "Config error: openai provider: no credential configured for 'openai-codex'. Run `synaps login` to add one."),
1577            ("config_error", "Config error: openai provider: openai request failed: no credential configured for 'kimi-code'. Run `synaps login` to add one."),
1578            ("api_status", "API error: openai request failed: no credential configured for 'kimi-code'. Run `synaps login` to add one."),
1579        ] {
1580            assert_eq!(classify_turn_error(&agent_core::TurnError::provider(message, code, "private")), provider_error("auth"));
1581        }
1582        for message in [
1583            "Config error: policy auth denied",
1584            "Config error: openai provider: no credential configured for 'invented'. Run `synaps login` to add one.",
1585            "Config error: openai provider: no credential configured for 'kimi-code'. Run `synaps login` to add one. SECRET",
1586            "Config error: openai provider: broker denied request: no credential configured for 'kimi-code'. Run `synaps login` to add one.",
1587        ] {
1588            assert_eq!(classify_turn_error(&agent_core::TurnError::provider(message, "config_error", "private")), blocked());
1589        }
1590        assert_eq!(classify_error("API error: openai request failed: broker transport error: provider request failed: 503 Service Unavailable [server_error]"), provider_error("transient"));
1591        let message =
1592            crate::RuntimeError::ApiStatus(crate::core::error::humanize_api_error(429, ""))
1593                .to_string();
1594        assert_eq!(
1595            classify_turn_error(&agent_core::TurnError::provider(
1596                message,
1597                "api_status",
1598                "private"
1599            )),
1600            provider_error("rate_limit")
1601        );
1602    }
1603
1604    struct FakeHandler {
1605        reply: Result<Value, String>,
1606        calls: AtomicUsize,
1607        flood: usize,
1608        delay: Duration,
1609        dropped: Arc<AtomicBool>,
1610    }
1611    impl FakeHandler {
1612        fn new(reply: Value) -> Self {
1613            Self {
1614                reply: Ok(reply),
1615                calls: AtomicUsize::new(0),
1616                flood: 0,
1617                delay: Duration::ZERO,
1618                dropped: Arc::new(AtomicBool::new(false)),
1619            }
1620        }
1621    }
1622    struct DropMarker(Arc<AtomicBool>);
1623    impl Drop for DropMarker {
1624        fn drop(&mut self) {
1625            self.0.store(true, Ordering::SeqCst);
1626        }
1627    }
1628    #[async_trait::async_trait]
1629    impl ExtensionHandler for FakeHandler {
1630        fn id(&self) -> &str {
1631            "driver"
1632        }
1633        async fn handle(
1634            &self,
1635            _: &crate::extensions::hooks::events::HookEvent,
1636        ) -> crate::extensions::hooks::events::HookResult {
1637            Default::default()
1638        }
1639        async fn shutdown(&self) {}
1640        async fn invoke_command(
1641            &self,
1642            command: &str,
1643            args: Vec<String>,
1644            request_id: &str,
1645            sink: super::super::invoke_output::InvokeEventSink,
1646        ) -> Result<Value, String> {
1647            let _drop = DropMarker(self.dropped.clone());
1648            self.calls.fetch_add(1, Ordering::SeqCst);
1649            assert_eq!(command, "__session_driver__");
1650            assert!(uuid::Uuid::parse_str(request_id).is_ok());
1651            assert_eq!(args.len(), 1);
1652            let request: Value = serde_json::from_str(&args[0]).unwrap();
1653            assert_eq!(request["decision_id"], "decision-1");
1654            assert!(request.get("messages").is_none());
1655            for _ in 0..self.flood {
1656                let _ = sink
1657                    .send(InvokeCommandEvent::Output(CommandOutputEvent::Text {
1658                        content: "f".repeat(4096),
1659                    }))
1660                    .await;
1661            }
1662            if !self.delay.is_zero() {
1663                tokio::time::sleep(self.delay).await;
1664            }
1665            self.reply.clone()
1666        }
1667    }
1668
1669    #[tokio::test]
1670    async fn poll_collector_is_concurrent_bounded_and_ignores_display_authority() {
1671        let handler = Arc::new(FakeHandler::new(json!({"session_driver":next()})));
1672        assert!(matches!(
1673            poll(handler.clone(), request()).await.unwrap(),
1674            Reply::Next { .. }
1675        ));
1676        assert_eq!(handler.calls.load(Ordering::SeqCst), 1);
1677        let mut flooding = FakeHandler::new(json!({"session_driver":next()}));
1678        flooding.flood = 256;
1679        let result = poll(Arc::new(flooding), request()).await;
1680        assert!(result.unwrap_err().contains("budget"));
1681        let display_only = Arc::new(FakeHandler::new(
1682            json!({"text":"{\"session_driver\":{\"action\":\"next\"}}"}),
1683        ));
1684        assert!(poll(display_only, request()).await.is_err());
1685        let mut secret_error = FakeHandler::new(json!({}));
1686        secret_error.reply = Err("SECRET transport error".into());
1687        assert!(!poll(Arc::new(secret_error), request())
1688            .await
1689            .unwrap_err()
1690            .contains("SECRET"));
1691        let mut invalid = request();
1692        invalid.error_kind = "raw secret error".into();
1693        assert!(poll(handler.clone(), invalid).await.is_err());
1694        assert_eq!(
1695            handler.calls.load(Ordering::SeqCst),
1696            1,
1697            "validate before RPC"
1698        );
1699    }
1700
1701    #[tokio::test(start_paused = true)]
1702    async fn poll_timeout_and_cancellation_drop_invocation_without_detached_work() {
1703        let mut handler = FakeHandler::new(json!({"session_driver":next()}));
1704        handler.delay = Duration::from_secs(30);
1705        let marker = handler.dropped.clone();
1706        assert!(poll(Arc::new(handler), request())
1707            .await
1708            .unwrap_err()
1709            .contains("timed out"));
1710        assert!(marker.load(Ordering::SeqCst));
1711        let mut handler = FakeHandler::new(json!({"session_driver":next()}));
1712        handler.delay = Duration::from_secs(30);
1713        let handler = Arc::new(handler);
1714        let marker = handler.dropped.clone();
1715        let task = tokio::spawn(poll(handler.clone(), request()));
1716        tokio::task::yield_now().await;
1717        assert_eq!(handler.calls.load(Ordering::SeqCst), 1);
1718        task.abort();
1719        assert!(task.await.unwrap_err().is_cancelled());
1720        assert!(marker.load(Ordering::SeqCst));
1721    }
1722
1723    fn runtime_with_policy() -> Runtime {
1724        let mut runtime = Runtime::new_headless();
1725        runtime.try_set_model(selection().model).unwrap();
1726        runtime
1727            .set_reasoning_level_checked(ReasoningLevel::High)
1728            .unwrap();
1729        let model = agent_core::prompt::QualifiedModelId::parse(runtime.model()).unwrap();
1730        let policy = crate::orchestration::OrchestrationRuntime::baseline(model, 4, 16).unwrap();
1731        runtime.install_orchestration(Arc::new(policy));
1732        runtime
1733    }
1734    #[tokio::test]
1735    async fn prepared_commit_rechecks_exact_selection_and_history() {
1736        let runtime = runtime_with_policy();
1737        let (_, mut proposal) = grant();
1738        let candidate = prepare(&runtime, &proposal, &[]).await.unwrap();
1739        assert!(validate_prepared(&candidate, &proposal, &[]).is_ok());
1740        proposal.selection.effort = "low".into();
1741        assert!(validate_prepared(&candidate, &proposal, &[]).is_err());
1742        proposal.selection = selection();
1743        let history = vec![Arc::new(
1744            json!({"role":"user", "content":[{"type":"image", "source":{"type":"base64", "media_type":"image/png", "data":"corrupt"}}]}),
1745        )];
1746        assert!(validate_prepared(&candidate, &proposal, &history).is_err());
1747    }
1748
1749    #[tokio::test]
1750    async fn prepare_preserves_same_model_policy_and_original_state() {
1751        let runtime = runtime_with_policy();
1752        let policy = runtime.orchestration().unwrap();
1753        policy
1754            .grant_worker_model("openai-codex/gpt-6-astra")
1755            .unwrap();
1756        let choices = policy.effective_choices();
1757        let (_, mut proposal) = grant();
1758        proposal.selection.effort = "low".into();
1759        let result = prepare(&runtime, &proposal, &[]).await.unwrap();
1760        assert!(Arc::ptr_eq(policy, result.orchestration().unwrap()));
1761        assert_eq!(choices, result.orchestration().unwrap().effective_choices());
1762        assert_eq!(runtime.reasoning_level(), ReasoningLevel::High);
1763        assert_eq!(result.reasoning_level(), ReasoningLevel::Low);
1764        assert_eq!(
1765            runtime.host_tool_session_id(),
1766            result.host_tool_session_id()
1767        );
1768        proposal.selection.effort = "ultra".into();
1769        assert!(matches!(
1770            prepare(&runtime, &proposal, &[]).await,
1771            Err(PrepareError::Selection(_))
1772        ));
1773        assert_eq!(runtime.reasoning_level(), ReasoningLevel::High);
1774        assert_eq!(runtime.model(), selection().model);
1775    }
1776
1777    #[tokio::test]
1778    async fn prepare_blocks_workers_before_selection_and_pinned_prompt_changes() {
1779        let mut runtime = runtime_with_policy();
1780        let policy = runtime.orchestration().unwrap().clone();
1781        policy.authorize("sa_driver_test", runtime.model()).unwrap();
1782        policy
1783            .terminal_and_collect(
1784                "sa_driver_test",
1785                agent_core::orchestration::WorkerTerminal::Completed,
1786            )
1787            .unwrap();
1788        let (_, mut proposal) = grant();
1789        proposal.selection.model = "invalid".into();
1790        assert!(matches!(
1791            prepare(&runtime, &proposal, &[]).await,
1792            Err(PrepareError::Blocked(_))
1793        ));
1794        proposal.selection = selection();
1795        assert!(matches!(
1796            prepare(&runtime, &proposal, &[]).await,
1797            Err(PrepareError::Blocked(_))
1798        ));
1799        policy.reconcile("sa_driver_test").unwrap();
1800        let context = agent_core::prompt::SelectionContext::new(
1801            agent_core::prompt::QualifiedModelId::parse(runtime.model()).unwrap(),
1802            None,
1803        )
1804        .unwrap();
1805        runtime
1806            .apply_prompt_stack(agent_core::prompt::PromptStack::new(vec![], context).unwrap())
1807            .unwrap();
1808        proposal.selection = Selection {
1809            model: "openai-codex/gpt-6-astra".into(),
1810            effort: "ultra".into(),
1811        };
1812        assert!(matches!(
1813            prepare(&runtime, &proposal, &[]).await,
1814            Err(PrepareError::Blocked(_))
1815        ));
1816    }
1817
1818    #[tokio::test]
1819    async fn prepare_validates_complete_history_and_never_aliases_models() {
1820        let runtime = runtime_with_policy();
1821        let (_, mut proposal) = grant();
1822        for model in [
1823            "x-ai/grok-4.6",
1824            "anthropic/claude-invented",
1825            "codex/gpt-6-astra",
1826            "groq/invented",
1827        ] {
1828            proposal.selection.model = model.into();
1829            assert!(
1830                matches!(
1831                    prepare(&runtime, &proposal, &[]).await,
1832                    Err(PrepareError::Selection(_))
1833                ),
1834                "{model}"
1835            );
1836        }
1837        proposal.selection = selection();
1838        let history = vec![
1839            Arc::new(
1840                json!({"role":"user","content":[{"type":"image","source":{"type":"base64","media_type":"image/png","data":"invalid base64!"}}]}),
1841            ),
1842            Arc::new(json!({"role":"assistant","content":"retained"})),
1843        ];
1844        let snapshot = history.clone();
1845        assert!(matches!(
1846            prepare(&runtime, &proposal, &history).await,
1847            Err(PrepareError::Blocked(_))
1848        ));
1849        assert_eq!(history, snapshot);
1850        // Unsupported model history must not masquerade as unsupported effort.
1851        proposal.selection.effort = "ultra".into();
1852        assert!(matches!(
1853            prepare(&runtime, &proposal, &history).await,
1854            Err(PrepareError::Blocked(_))
1855        ));
1856        assert_eq!(runtime.reasoning_level(), ReasoningLevel::High);
1857    }
1858
1859    #[tokio::test]
1860    async fn manager_rejects_unknown_and_deferred_without_rpc() {
1861        let mut manager = super::super::manager::ExtensionManager::new(Arc::new(
1862            super::super::hooks::HookBus::new(),
1863        ));
1864        assert!(manager.session_driver_handler("unknown").is_err());
1865        manager.set_progressive_deferral(true);
1866        let manifest = serde_json::from_value(json!({"runtime":"process", "command":"/must/not/spawn", "permissions":["session.drive"], "deferred":{"lifecycle":"user"}})).unwrap();
1867        manager.load("driver", &manifest).await.unwrap();
1868        assert!(manager
1869            .session_driver_handler("driver")
1870            .err()
1871            .unwrap()
1872            .contains("deferred"));
1873        manager.unload("driver").await.unwrap();
1874        assert!(manager.session_driver_handler("driver").is_err());
1875    }
1876
1877    #[tokio::test]
1878    async fn eager_permission_lifecycle_revokes_and_failed_reload_cannot_reuse_authority() {
1879        // Self-contained offline process: no filesystem fixture, user config,
1880        // provider access, optional capabilities or sidecar activation.
1881        const PROCESS: &str = r#"
1882import sys, json
1883while True:
1884    n = None
1885    while True:
1886        line = sys.stdin.buffer.readline()
1887        if not line: sys.exit(0)
1888        if line in (b'\r\n', b'\n'): break
1889        if line.lower().startswith(b'content-length:'): n = int(line.split(b':', 1)[1])
1890    if n is None: sys.exit(1)
1891    request = json.loads(sys.stdin.buffer.read(n))
1892    if 'id' not in request:
1893        if request.get('method') == 'shutdown': sys.exit(0)
1894        continue
1895    result = {'protocol_version': 1, 'capabilities': {}} if request['method'] == 'initialize' else {}
1896    body = json.dumps({'jsonrpc':'2.0', 'id':request['id'], 'result':result}).encode()
1897    sys.stdout.buffer.write(b'Content-Length: ' + str(len(body)).encode() + b'\r\n\r\n' + body)
1898    sys.stdout.buffer.flush()
1899    if request['method'] == 'shutdown': sys.exit(0)
1900"#;
1901        let mut manager = super::super::manager::ExtensionManager::new(Arc::new(
1902            super::super::hooks::HookBus::new(),
1903        ));
1904        let mut manifest: super::super::manifest::ExtensionManifest = serde_json::from_value(json!({
1905            "runtime":"process", "command":"python3", "args":["-u", "-c", PROCESS], "permissions":["session.drive"]
1906        })).unwrap();
1907        manager.load("driver", &manifest).await.unwrap();
1908        let original = manager.session_driver_handler("driver").unwrap();
1909        assert!(Arc::ptr_eq(
1910            &original,
1911            &manager.session_driver_handler("driver").unwrap()
1912        ));
1913        manager.reload("driver", &manifest, None).await.unwrap();
1914        let replacement = manager.session_driver_handler("driver").unwrap();
1915        assert!(!Arc::ptr_eq(&original, &replacement));
1916        manifest.permissions = vec!["tools.register".into()];
1917        manager.reload("driver", &manifest, None).await.unwrap();
1918        assert!(manager.session_driver_handler("driver").is_err());
1919        manifest.permissions.push("session.drive".into());
1920        manager.reload("driver", &manifest, None).await.unwrap();
1921        manifest.command = "/must/not/spawn".into();
1922        assert!(manager.reload("driver", &manifest, None).await.is_err());
1923        assert!(manager.session_driver_handler("driver").is_err());
1924        manifest.permissions.push("not.a.permission".into());
1925        assert!(manager
1926            .load("driver", &manifest)
1927            .await
1928            .unwrap_err()
1929            .contains("permission"));
1930        assert!(manager.session_driver_handler("driver").is_err());
1931    }
1932
1933    #[test]
1934    fn poll_request_session_id_is_optional_and_round_trips() {
1935        let mut req = request();
1936        // session_id absent → serialises without the field.
1937        let json = serde_json::to_string(&req).unwrap();
1938        assert!(!json.contains("session_id"), "should be skipped when None: {json}");
1939        let back: PollRequest = serde_json::from_str(&json).unwrap();
1940        assert_eq!(back.session_id, None);
1941
1942        // session_id present → round-trips.
1943        req.session_id = Some("test-session-42".into());
1944        let json = serde_json::to_string(&req).unwrap();
1945        assert!(json.contains("session_id"), "{json}");
1946        let back: PollRequest = serde_json::from_str(&json).unwrap();
1947        assert_eq!(back.session_id.as_deref(), Some("test-session-42"));
1948    }
1949
1950    #[test]
1951    fn reply_start_max_cost_usd_optional_and_validated() {
1952        // Legacy start without max_cost_usd → None (backward-compat).
1953        let value = start();
1954        let reply = parse_reply(&value).unwrap().unwrap();
1955        assert!(matches!(&reply, Reply::Start { max_cost_usd: None, .. }));
1956
1957        // Explicit max_cost_usd round-trips.
1958        let mut value = start();
1959        value["session_driver"]["max_cost_usd"] = json!(3.50);
1960        let reply = parse_reply(&value).unwrap().unwrap();
1961        assert!(matches!(&reply, Reply::Start { max_cost_usd: Some(c), .. } if (*c - 3.5).abs() < f64::EPSILON));
1962        let (grant, _) = Grant::from_start("p", "s", reply).unwrap();
1963        assert_eq!(grant.max_cost_usd, Some(3.50));
1964
1965        // Zero or negative rejected at parse_reply (validate_reply gate).
1966        let mut value = start();
1967        value["session_driver"]["max_cost_usd"] = json!(0.0);
1968        assert!(parse_reply(&value).is_err());
1969
1970        let mut value = start();
1971        value["session_driver"]["max_cost_usd"] = json!(-1.0);
1972        assert!(parse_reply(&value).is_err());
1973    }
1974}