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                .map_or(true, |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            | "local"
534    ) || crate::runtime::openai::registry::providers()
535        .iter()
536        .any(|p| p.key == provider)
537}
538
539fn missing_account(message: &str) -> bool {
540    let message = message.strip_prefix("Config error: ").unwrap_or(message);
541    let Some(message) = message.strip_prefix("openai provider: ") else {
542        return false;
543    };
544    let message = message
545        .strip_prefix("openai request failed: ")
546        .or_else(|| message.strip_prefix("codex request failed: "))
547        .unwrap_or(message);
548    missing_account_template(message)
549}
550
551fn missing_account_template(message: &str) -> bool {
552    let Some(provider) = message
553        .strip_prefix("no credential configured for '")
554        .and_then(|rest| rest.strip_suffix("'. Run `synaps login` to add one."))
555    else {
556        return false;
557    };
558    known_provider(provider)
559}
560
561fn status_kind(status: u16) -> (Outcome, String) {
562    match status {
563        401 => provider_error("auth"),
564        402 => provider_error("quota"),
565        429 => provider_error("rate_limit"),
566        408 | 500 | 502 | 503 | 504 | 529 => provider_error("transient"),
567        // Generic 403 can be a model/permission/policy restriction. No failover.
568        _ => blocked(),
569    }
570}
571
572/// Recognize only anchored host-generated API status forms. Never search
573/// arbitrary error/transcript text for "auth", "429", "quota" etc. Raw strings
574/// are not copied into the return value. Prefer `classify_turn_error` externally.
575pub fn classify_error(error: &str) -> (Outcome, String) {
576    if error.strip_prefix("API error: ").unwrap_or(error)
577        == crate::runtime::openai::stream::STREAM_INTERRUPTED
578    {
579        return provider_error("transient");
580    }
581
582    let error = error.strip_prefix("API error: ").unwrap_or(error);
583    // Some provider routes wrap broker NotConfigured in ApiStatus instead.
584    if ["openai request failed: ", "codex request failed: "]
585        .iter()
586        .any(|prefix| {
587            error
588                .strip_prefix(prefix)
589                .is_some_and(missing_account_template)
590        })
591    {
592        return provider_error("auth");
593    }
594    for prefix in [
595        "codex request failed: ",
596        "openai request failed: ",
597        "provider request failed: ",
598    ] {
599        if let Some(rest) = error.strip_prefix(prefix) {
600            // Broker errors have a fixed nesting prefix; no substring search.
601            let rest = rest
602                .strip_prefix("broker transport error: ")
603                .unwrap_or(rest);
604            let rest = rest
605                .strip_prefix("provider request failed: ")
606                .unwrap_or(rest);
607            let bytes = rest.as_bytes();
608            if bytes.len() >= 3
609                && bytes[..3].iter().all(u8::is_ascii_digit)
610                && (bytes.len() == 3 || matches!(bytes[3], b' ' | b':' | b'('))
611            {
612                return status_kind(rest[..3].parse().unwrap_or(0));
613            }
614            return blocked();
615        }
616    }
617    // Exact host-authored Anthropic SSE templates survive as api_status after
618    // provider retries. Never match provider free-text or policy refusals.
619    if let Some(kind) = error.strip_prefix("API stream error (").and_then(|s| {
620        s.strip_suffix("). Provider error details withheld — they can echo request content.")
621    }) {
622        return match kind {
623            "authentication_error" => provider_error("auth"),
624            "billing_error" => provider_error("quota"),
625            "rate_limit_error" => provider_error("rate_limit"),
626            "overloaded_error" | "api_error" | "timeout_error" => provider_error("transient"),
627            _ => blocked(),
628        };
629    }
630    if matches!(error,
631        "Request to api.anthropic.com timed out. Check your connection and try again."
632        | "Could not reach api.anthropic.com (connection failed). Check your network, DNS, or proxy settings."
633        | "Connection lost mid-response. Partial reply kept — send again to continue."
634    ) { return provider_error("transient"); }
635    // These messages are authored by core/error.rs, not remote response bodies.
636    if error == "Authentication rejected. Run `synaps login` to re-authenticate." {
637        return provider_error("auth");
638    }
639    if status_template(error, "Rate limited by Anthropic (HTTP 429")
640        || error.starts_with(
641            "Rate limit exhausted — retries used up while waiting for reset (next window in ",
642        )
643    {
644        return provider_error("rate_limit");
645    }
646    if error
647        == "Anthropic is overloaded right now. Retries exhausted — wait a minute and try again."
648    {
649        return provider_error("transient");
650    }
651    for status in [500, 502, 503] {
652        if status_template(error, &format!("Anthropic server error (HTTP {status}")) {
653            return provider_error("transient");
654        }
655    }
656    for label in ["Codex", "OpenAI", "xAI"] {
657        use crate::runtime::openai::stream::{
658            RESPONSES_AUTH_SUFFIX, RESPONSES_CAPACITY_SUFFIX, RESPONSES_EMPTY_SUFFIX,
659            RESPONSES_MISSING_TERMINAL_SUFFIX, RESPONSES_QUOTA_SUFFIX,
660        };
661        for (suffix, kind) in [
662            (RESPONSES_AUTH_SUFFIX, "auth"),
663            (RESPONSES_QUOTA_SUFFIX, "quota"),
664            (RESPONSES_CAPACITY_SUFFIX, "transient"),
665            (RESPONSES_EMPTY_SUFFIX, "transient"),
666            (RESPONSES_MISSING_TERMINAL_SUFFIX, "transient"),
667        ] {
668            if error == format!("{label}{suffix}") {
669                return provider_error(kind);
670            }
671        }
672    }
673    // Host-authored network templates have a single bounded hostname slot.
674    // Do not inspect the bracketed underlying error chain (it may be secret).
675    for (prefix, suffix) in [
676        (
677            "Request to ",
678            " timed out — usually transient; check your connection and try again. [",
679        ),
680        (
681            "Could not reach ",
682            " (connection failed). Check your network, DNS, or proxy settings. [",
683        ),
684        (
685            "Connection to ",
686            " lost mid-response — usually transient; try again. [",
687        ),
688    ] {
689        if let Some(rest) = error.strip_prefix(prefix) {
690            if let Some((host, _)) = rest.split_once(suffix) {
691                if valid_network_host(host) && error.ends_with(']') {
692                    return provider_error("transient");
693                }
694            }
695        }
696    }
697    if let Some((provider, rest)) = error.split_once(": ") {
698        if known_provider(provider) {
699            if rest.starts_with("usage quota exhausted (HTTP 403). ")
700                || rest.starts_with("usage quota exhausted (HTTP 429). ")
701            {
702                return provider_error("quota");
703            }
704            if rest.starts_with("authentication rejected (HTTP 401). ") {
705                return provider_error("auth");
706            }
707            if rest.starts_with("rate limited (HTTP 429). ") {
708                return provider_error("rate_limit");
709            }
710        }
711    }
712    blocked()
713}
714
715fn status_template(error: &str, prefix: &str) -> bool {
716    error
717        .strip_prefix(prefix)
718        .is_some_and(|rest| rest.starts_with("). ") || rest.starts_with(" ["))
719}
720
721fn valid_network_host(host: &str) -> bool {
722    host == "the provider endpoint"
723        || (!host.is_empty()
724            && host.len() <= 253
725            && host.bytes().all(|b| {
726                b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b':' | b'[' | b']')
727            }))
728}
729
730/// Bounded asynchronous poll using the existing command.invoke frame contract.
731/// No spawned task or manager lock is retained here: dropping this future drops
732/// both invocation and collector. The frontend owns cancellation/generation.
733pub async fn poll(
734    handler: Arc<dyn ExtensionHandler>,
735    request: PollRequest,
736) -> Result<Reply, String> {
737    if !valid_id(&request.run_id) || !valid_id(&request.decision_id) {
738        return Err("session driver poll identifiers are invalid".into());
739    }
740    validate_selection(&Selection {
741        model: request.model.clone(),
742        effort: request.effort.clone(),
743    })?;
744    if !matches!(
745        request.error_kind.as_str(),
746        "none" | "auth" | "quota" | "rate_limit" | "transient" | "wall_clock" | "unknown"
747    ) {
748        return Err("session driver poll requires a coarse error kind".into());
749    }
750    if (request.outcome == Outcome::TimeCheckpoint) != (request.error_kind == "wall_clock") {
751        return Err("inconsistent time checkpoint metadata".into());
752    }
753    if request.feedback.as_deref().is_some_and(|f| {
754        !matches!(f, "unknown" | "changed" | "repeated" | "empty")
755            || (request.outcome != Outcome::Success && f != "unknown")
756    }) {
757        return Err("invalid session driver feedback".into());
758    }
759    let args = vec![serde_json::to_string(&request)
760        .map_err(|_| "invalid session driver poll request".to_string())?];
761    let request_id = uuid::Uuid::new_v4().to_string();
762    let result = tokio::time::timeout(POLL_TIMEOUT, async {
763        let (sink, collector) = invoke_event_channel(InvokeOutputBudget::default());
764        tokio::join!(
765            handler.invoke_command("__session_driver__", args, &request_id, sink),
766            collector.collect()
767        )
768    })
769    .await
770    .map_err(|_| "session driver poll timed out".to_string())?;
771    let (value, report) = result;
772    // Display output cannot drive a session. Errors and overflow stop visibly,
773    // even if the final structured response otherwise proposes a valid next.
774    if report.is_limited() {
775        return Err("session driver poll output exceeded its bounded frame budget".into());
776    }
777    if report.events.iter().any(|event| {
778        matches!(
779            event,
780            InvokeCommandEvent::Output(CommandOutputEvent::Error { .. })
781        )
782    }) {
783        return Err("session driver plugin reported an error".into());
784    }
785    let value = value.map_err(|_| "session driver plugin invocation failed".to_string())?;
786    let reply = parse_reply(&value)?
787        .ok_or_else(|| "session driver poll omitted its structured response".to_string())?;
788    match reply {
789        Reply::Next { .. } | Reply::Stop { .. } => Ok(reply),
790        _ => Err("session driver polls accept only next or stop".into()),
791    }
792}
793
794#[derive(Debug, Clone, PartialEq, Eq)]
795pub enum PrepareError {
796    Selection(String),
797    Blocked(String),
798}
799impl std::fmt::Display for PrepareError {
800    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
801        match self {
802            Self::Selection(error) | Self::Blocked(error) => f.write_str(error),
803        }
804    }
805}
806impl std::error::Error for PrepareError {}
807
808/// Atomic session-only model/effort preparation. `history` is the EXISTING
809/// history: this function adds the proposed user message for full preflight.
810/// The caller commits returned Runtime plus its own session mirrors only on
811/// success, after rechecking cancellation/deadline/handler/session identity.
812/// No config writes, credential refresh, provider calls, or history mutation.
813pub async fn prepare(
814    runtime: &Runtime,
815    proposal: &Proposal,
816    history: &[SharedMessage],
817) -> Result<Runtime, PrepareError> {
818    use agent_core::orchestration::CompletionGate;
819    if let Some(policy) = runtime.orchestration() {
820        if matches!(policy.completion_gate(), CompletionGate::Blocked { .. }) {
821            return Err(PrepareError::Blocked(
822                "session driver blocked: workers require collection/reconciliation".into(),
823            ));
824        }
825        if proposal.selection.model != runtime.model()
826            && !policy.unreconciled_runtime_handles().is_empty()
827        {
828            return Err(PrepareError::Blocked(
829                "session driver cannot change model while workers are outstanding".into(),
830            ));
831        }
832    }
833    if let Some(prompt) = runtime.effective_prompt() {
834        if prompt.provenance("").foreground_model != proposal.selection.model {
835            return Err(PrepareError::Blocked(
836                "session driver cannot change a typed prompt's pinned foreground model".into(),
837            ));
838        }
839    }
840    if proposal.prompt.trim().is_empty() || proposal.prompt.len() > MAX_PROMPT_BYTES {
841        return Err(PrepareError::Blocked(
842            "invalid session driver proposed prompt".into(),
843        ));
844    }
845    let level = validate_selection(&proposal.selection).map_err(PrepareError::Selection)?;
846    let model = &proposal.selection.model;
847    let (provider, model_id) = model.split_once('/').expect("validated qualified model");
848    let route = crate::runtime::openai::resolve_route(model).ok_or_else(|| {
849        PrepareError::Selection("session driver model has no exact supported provider route".into())
850    })?;
851    if route.provider != provider || route.model != model_id {
852        return Err(PrepareError::Selection(
853            "session driver refuses provider or model alias substitution".into(),
854        ));
855    }
856    // A media/history incompatibility is a hard stop, even when the same
857    // proposal also requested an unsupported effort. Never fail over retained
858    // multimodal work as though it were an account or selection failure.
859    let mut proposed_history = history.to_vec();
860    proposed_history.push(Arc::new(json!({"role":"user", "content":proposal.prompt})));
861    crate::runtime::attachments::validate_history_for_request(model, &proposed_history)
862        .map_err(PrepareError::Blocked)?;
863    validate_model_capability(model, level).map_err(PrepareError::Selection)?;
864    let mut candidate = runtime.clone();
865    // Same-model changes MUST preserve the existing policy Arc, including
866    // session-only worker grants. try_set_model replaces manifestless policy.
867    if candidate.model() != model {
868        candidate.try_set_model(model.clone()).map_err(|_| {
869            PrepareError::Blocked("session driver model change blocked by host policy".into())
870        })?;
871    }
872    // Exact model/effort validity already passed above. Remaining checked-set
873    // failures are host prerequisites (e.g. ultracode lifecycle tools), not
874    // permission to fail over to another model and evade that prerequisite.
875    candidate.set_reasoning_level_checked(level).map_err(|_| {
876        PrepareError::Blocked(
877            "session driver reasoning change blocked by host prerequisites".into(),
878        )
879    })?;
880    if candidate.model() != model || candidate.reasoning_level() != level {
881        return Err(PrepareError::Selection(
882            "session driver requires the exact model and effort without downgrades".into(),
883        ));
884    }
885    candidate
886        .validate_session_driver_preflight()
887        .await
888        .map_err(|_| {
889            PrepareError::Blocked(
890                "session driver blocked by request policy or execution prerequisites".into(),
891            )
892        })?;
893    Ok(candidate)
894}
895
896/// Recheck dynamic catalog/media evidence immediately before frontend commit.
897/// Async credential preflight may have yielded while a capability was revoked.
898pub fn validate_prepared(
899    runtime: &Runtime,
900    proposal: &Proposal,
901    history: &[SharedMessage],
902) -> Result<(), String> {
903    let level = validate_selection(&proposal.selection)?;
904    if runtime.model() != proposal.selection.model || runtime.reasoning_level() != level {
905        return Err("prepared runtime no longer matches the exact proposal".into());
906    }
907    validate_model_capability(&proposal.selection.model, level)?;
908    let mut messages = history.to_vec();
909    messages.push(Arc::new(json!({"role":"user", "content":proposal.prompt})));
910    crate::runtime::attachments::validate_history_for_request(&proposal.selection.model, &messages)
911}
912
913/// Mutation validation is deliberately permissive for arbitrary OpenAI-compatible
914/// model IDs. A driver is stricter: require an exact known catalog row and named
915/// effort evidence; unknown generic capability only allows provider-default
916/// `adaptive`, never a silently omitted named effort or off toggle.
917fn validate_model_capability(model: &str, level: ReasoningLevel) -> Result<(), String> {
918    use crate::runtime::openai::{catalog, registry};
919    use catalog::ReasoningSupport;
920    let (provider, id) = model.split_once('/').ok_or("invalid qualified model")?;
921    let cached = catalog::capability_cache::get(model);
922    let known = cached
923        .as_ref()
924        .is_some_and(|entry| entry.runtime_id() == model)
925        || match provider {
926            "anthropic" => agent_core::models::KNOWN_MODELS
927                .iter()
928                .any(|(known, _)| *known == id),
929            "openai-codex" => catalog::codex_static_capability(id).is_some(),
930            "xai-auth" => catalog::xai_model(id).is_some(),
931            "kimi-code" => catalog::kimi_code_model(id).is_some(),
932            "github-copilot" => catalog::github_copilot_runtime_model(id).is_some(),
933            "google-gemini" => catalog::google_gemini_model(id).is_some(),
934            _ => registry::providers()
935                .iter()
936                .find(|p| p.key == provider)
937                .is_some_and(|p| p.models.iter().any(|(known, _, _)| *known == id)),
938        };
939    if !known {
940        return Err("session driver model has no exact known capability descriptor".into());
941    }
942    if !matches!(
943        provider,
944        "anthropic" | "openai-codex" | "xai-auth" | "kimi" | "kimi-code"
945    ) && level != ReasoningLevel::Adaptive
946    {
947        let exact_effort = matches!(
948            cached.as_ref().map(|entry| &entry.reasoning),
949            Some(ReasoningSupport::OpenRouter { effort: true, .. })
950        ) && matches!(
951            level,
952            ReasoningLevel::Low | ReasoningLevel::Medium | ReasoningLevel::High
953        );
954        if !exact_effort {
955            return Err("session driver effort has no exact supported capability evidence".into());
956        }
957    }
958    catalog::validation::validate_reasoning_mutation(model, level)
959}
960
961#[cfg(test)]
962mod tests {
963    use super::*;
964    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
965
966    fn selection() -> Selection {
967        Selection {
968            model: "anthropic/claude-fable-5".into(),
969            effort: "high".into(),
970        }
971    }
972    fn start() -> Value {
973        json!({"session_driver": {"action":"start", "run_id":"run-1", "models":[selection()], "prompt":"Inspect retained work", "delay_ms":1000}})
974    }
975    fn next() -> Reply {
976        Reply::Next {
977            run_id: "run-1".into(),
978            selection: selection(),
979            prompt: "Continue retained work".into(),
980            delay_ms: 1000,
981            notice: String::new(),
982        }
983    }
984    fn grant() -> (Grant, Proposal) {
985        Grant::from_start("driver", "session", parse_reply(&start()).unwrap().unwrap()).unwrap()
986    }
987    fn request() -> PollRequest {
988        PollRequest {
989            run_id: "run-1".into(),
990            decision_id: "decision-1".into(),
991            outcome: Outcome::Success,
992            error_kind: "none".into(),
993            model: selection().model,
994            effort: selection().effort,
995            feedback: None,
996            session_id: None,
997            session_cost_so_far: None,
998        }
999    }
1000
1001    #[test]
1002    fn parse_only_structured_driver_and_optional_start_selection() {
1003        assert_eq!(
1004            parse_reply(&json!({"text":"session_driver start"})).unwrap(),
1005            None
1006        );
1007        let mut value = start();
1008        value["session_driver"]["selection"] = json!(selection());
1009        assert!(
1010            matches!(parse_reply(&value).unwrap(), Some(Reply::Start { notice, max_duration_ms: None, .. }) if notice.is_empty())
1011        );
1012        value["session_driver"]["selection"]["effort"] = json!("low");
1013        assert!(parse_reply(&value).is_err());
1014        assert!(parse_reply(&json!({"session_driver":null})).is_err());
1015    }
1016
1017    #[test]
1018    fn parser_rejects_unknown_fields_types_and_noncanonical_efforts() {
1019        for effort in [
1020            "HIGH", " high", "med", "none", "x-high", "x_high", "maximum", "1000",
1021        ] {
1022            let mut value = start();
1023            value["session_driver"]["models"][0]["effort"] = json!(effort);
1024            assert!(parse_reply(&value).is_err(), "{effort}");
1025        }
1026        for effort in [
1027            "off",
1028            "adaptive",
1029            "low",
1030            "medium",
1031            "high",
1032            "xhigh",
1033            "max",
1034            "ultra",
1035            "ultracode",
1036        ] {
1037            assert!(exact_effort(effort).is_ok());
1038        }
1039        for (key, bad) in [
1040            ("unexpected", json!(true)),
1041            ("delay_ms", json!("1000")),
1042            ("max_duration_ms", json!(-1)),
1043            ("notice", json!(false)),
1044            ("run_id", json!("bad/id")),
1045        ] {
1046            let mut value = start();
1047            value["session_driver"][key] = bad;
1048            assert!(parse_reply(&value).is_err(), "{key}");
1049        }
1050        let mut value = start();
1051        value["session_driver"]["models"][0]["secret"] = json!("must not echo");
1052        let err = parse_reply(&value).unwrap_err();
1053        assert!(!err.contains("must not echo"));
1054        for model in [
1055            "claude-fable-5-1",
1056            "anthropic/",
1057            "anthropic//model",
1058            " anthropic/model",
1059            "anthropic/a\nb",
1060        ] {
1061            value = start();
1062            value["session_driver"]["models"][0]["model"] = json!(model);
1063            assert!(parse_reply(&value).is_err());
1064        }
1065    }
1066
1067    #[test]
1068    fn parser_enforces_all_byte_count_and_time_bounds() {
1069        for (key, good, bad) in [
1070            (
1071                "prompt",
1072                json!("p".repeat(MAX_PROMPT_BYTES)),
1073                json!("p".repeat(MAX_PROMPT_BYTES + 1)),
1074            ),
1075            (
1076                "notice",
1077                json!("n".repeat(MAX_NOTICE_BYTES)),
1078                json!("n".repeat(MAX_NOTICE_BYTES + 1)),
1079            ),
1080            (
1081                "run_id",
1082                json!("r".repeat(MAX_ID_BYTES)),
1083                json!("r".repeat(MAX_ID_BYTES + 1)),
1084            ),
1085            ("delay_ms", json!(MAX_DELAY_MS), json!(MAX_DELAY_MS + 1)),
1086            (
1087                "max_duration_ms",
1088                json!(MAX_DURATION_MS),
1089                json!(MAX_DURATION_MS + 1),
1090            ),
1091        ] {
1092            let mut value = start();
1093            value["session_driver"][key] = good;
1094            assert!(parse_reply(&value).is_ok(), "{key}");
1095            value["session_driver"][key] = bad;
1096            assert!(parse_reply(&value).is_err(), "{key}");
1097        }
1098        for (key, bad) in [
1099            ("delay_ms", json!(999)),
1100            ("max_duration_ms", json!(0)),
1101            ("prompt", json!(" \n\t")),
1102            ("run_id", json!("")),
1103        ] {
1104            let mut value = start();
1105            value["session_driver"][key] = bad;
1106            assert!(parse_reply(&value).is_err());
1107        }
1108        let mut value = start();
1109        value["session_driver"]["prompt"] = json!("é".repeat(MAX_PROMPT_BYTES / 2 + 1));
1110        assert!(parse_reply(&value).is_err());
1111        value = start();
1112        value["unrelated"] = json!("x".repeat(MAX_REPLY_BYTES));
1113        assert!(parse_reply(&value).is_err());
1114        value = start();
1115        value["session_driver"]["models"][0]["model"] =
1116            json!(format!("p/{}", "m".repeat(MAX_MODEL_BYTES - 2)));
1117        assert!(parse_reply(&value).is_ok());
1118        value["session_driver"]["models"][0]["model"] =
1119            json!(format!("p/{}", "m".repeat(MAX_MODEL_BYTES - 1)));
1120        assert!(parse_reply(&value).is_err());
1121        value = start();
1122        let models: Vec<_> = (0..MAX_MODELS)
1123            .map(|i| json!({"model":format!("p/m{i}"),"effort":"high"}))
1124            .collect();
1125        value["session_driver"]["models"] = json!(models);
1126        assert!(parse_reply(&value).is_ok());
1127        value["session_driver"]["models"]
1128            .as_array_mut()
1129            .unwrap()
1130            .push(json!({"model":"p/extra","effort":"high"}));
1131        assert!(parse_reply(&value).is_err());
1132        value["session_driver"]["models"] = json!([]);
1133        assert!(parse_reply(&value).is_err());
1134        value["session_driver"]["models"] = json!([
1135            selection(),
1136            Selection {
1137                effort: "low".into(),
1138                ..selection()
1139            }
1140        ]);
1141        assert!(parse_reply(&value).is_err());
1142    }
1143
1144    #[test]
1145    fn grant_pins_exact_run_model_effort_and_rejects_rearming() {
1146        let (mut grant, first) = grant();
1147        assert_eq!(first.selection, selection());
1148        assert_eq!(grant.plugin_id, "driver");
1149        assert_eq!(grant.session_id, "session");
1150        assert!(grant.deadline().is_none());
1151        assert!(!grant.expired());
1152        assert!(grant.accept(next()).unwrap().is_some());
1153        let Reply::Next {
1154            selection: first_selection,
1155            prompt,
1156            delay_ms,
1157            notice,
1158            ..
1159        } = next()
1160        else {
1161            unreachable!()
1162        };
1163        assert!(grant
1164            .accept(Reply::Next {
1165                run_id: "different-run".into(),
1166                selection: first_selection,
1167                prompt,
1168                delay_ms,
1169                notice
1170            })
1171            .is_err());
1172        let Reply::Next {
1173            run_id,
1174            prompt,
1175            delay_ms,
1176            notice,
1177            ..
1178        } = next()
1179        else {
1180            unreachable!()
1181        };
1182        for selected in [
1183            Selection {
1184                effort: "low".into(),
1185                ..selection()
1186            },
1187            Selection {
1188                model: "openai-codex/gpt-6-astra".into(),
1189                effort: "ultra".into(),
1190            },
1191        ] {
1192            assert!(grant
1193                .accept(Reply::Next {
1194                    run_id: run_id.clone(),
1195                    selection: selected,
1196                    prompt: prompt.clone(),
1197                    delay_ms,
1198                    notice: notice.clone()
1199                })
1200                .is_err());
1201        }
1202        assert!(grant
1203            .accept(parse_reply(&start()).unwrap().unwrap())
1204            .is_err());
1205        assert!(grant
1206            .accept(Reply::Status {
1207                notice: String::new()
1208            })
1209            .is_err());
1210        assert!(grant
1211            .accept(Reply::Stop {
1212                notice: String::new()
1213            })
1214            .unwrap()
1215            .is_none());
1216        grant.run_id = "different-run".into();
1217        assert!(
1218            grant.accept(next()).is_ok(),
1219            "public bookkeeping must not change pinned authority"
1220        );
1221    }
1222
1223    #[test]
1224    fn grant_revalidates_direct_constructors_and_deadline_scheduling() {
1225        let (mut grant, _) = grant();
1226        grant.deadline = Some(Instant::now() + Duration::from_millis(500));
1227        assert!(!grant.expired());
1228        assert!(
1229            grant.accept(next()).is_err(),
1230            "do not schedule beyond deadline"
1231        );
1232        grant.deadline = Some(Instant::now() - Duration::from_millis(1));
1233        assert!(grant.expired());
1234        assert!(grant.accept(next()).is_err());
1235        let mut value = start();
1236        value["session_driver"]["max_duration_ms"] = json!(500);
1237        assert!(Grant::from_start("p", "s", parse_reply(&value).unwrap().unwrap()).is_err());
1238        value["session_driver"]["max_duration_ms"] = json!(10_000);
1239        assert!(
1240            Grant::from_start("p", "s", parse_reply(&value).unwrap().unwrap())
1241                .unwrap()
1242                .0
1243                .deadline()
1244                .is_some()
1245        );
1246        let bad = Reply::Start {
1247            run_id: "x".into(),
1248            models: vec![],
1249            prompt: "hello".into(),
1250            delay_ms: 1000,
1251            max_duration_ms: None,
1252            feedback_version: None,
1253            time_checkpoint_version: None,
1254            context_mode: None,
1255            notice: String::new(),
1256            max_cost_usd: None,
1257        };
1258        assert!(Grant::from_start("p", "s", bad).is_err());
1259    }
1260
1261    #[test]
1262    fn time_checkpoint_support_is_versioned_and_not_provider_failure() {
1263        let legacy = parse_reply(&start()).unwrap().unwrap();
1264        assert!(!Grant::from_start("p", "s", legacy)
1265            .unwrap()
1266            .0
1267            .time_checkpoints_enabled());
1268        let mut value = start();
1269        value["session_driver"]["time_checkpoint_version"] = json!(1);
1270        let grant = Grant::from_start("p", "s", parse_reply(&value).unwrap().unwrap())
1271            .unwrap()
1272            .0;
1273        assert!(grant.time_checkpoints_enabled());
1274        for bad in [json!(0), json!(2), json!(true), json!("1")] {
1275            value["session_driver"]["time_checkpoint_version"] = bad;
1276            assert!(parse_reply(&value).is_err());
1277        }
1278        assert_eq!(
1279            classify_turn_error(&agent_core::TurnError::budget(
1280                agent_core::BudgetDimension::WallClock
1281            )),
1282            (Outcome::TimeCheckpoint, "wall_clock".into())
1283        );
1284        for dimension in [
1285            agent_core::BudgetDimension::CostUsd,
1286            agent_core::BudgetDimension::ToolCalls,
1287            agent_core::BudgetDimension::ProviderRounds,
1288        ] {
1289            assert_eq!(
1290                classify_turn_error(&agent_core::TurnError::budget(dimension)),
1291                blocked()
1292            );
1293        }
1294    }
1295
1296    #[test]
1297    fn context_mode_is_optional_strict_and_start_only() {
1298        let legacy = start();
1299        assert!(matches!(
1300            parse_reply(&legacy).unwrap(),
1301            Some(Reply::Start {
1302                context_mode: None,
1303                ..
1304            })
1305        ));
1306        for (name, expected) in [("auto", ContextMode::Auto), ("off", ContextMode::Off)] {
1307            let mut value = start();
1308            value["session_driver"]["context_mode"] = json!(name);
1309            let reply = parse_reply(&value).unwrap().unwrap();
1310            assert!(
1311                matches!(&reply, Reply::Start { context_mode:Some(mode), .. } if *mode == expected)
1312            );
1313            let encoded = serde_json::to_value(&reply).unwrap();
1314            assert_eq!(encoded["context_mode"], name);
1315            let (grant, _) = Grant::from_start("fixture", "session", reply).unwrap();
1316            assert_eq!(grant.context_mode, Some(expected));
1317        }
1318        for bad in [
1319            json!(null),
1320            json!(true),
1321            json!(1),
1322            json!("AUTO"),
1323            json!("inherit"),
1324            json!({}),
1325            json!([]),
1326        ] {
1327            let mut value = start();
1328            value["session_driver"]["context_mode"] = bad;
1329            assert!(parse_reply(&value).is_err());
1330        }
1331        for action in ["next", "stop", "status"] {
1332            let value = json!({"session_driver":{
1333                "action":action, "context_mode":"auto"
1334            }});
1335            assert!(parse_reply(&value).is_err());
1336        }
1337    }
1338
1339    #[test]
1340    #[serial_test::serial(synaps_base_dir)]
1341    fn context_mode_uses_session_command_without_recall_or_global_config_changes() {
1342        let env = crate::test_env::BaseDirGuard::new();
1343        let runtime = Runtime::new_headless();
1344        let (legacy, _) = Grant::from_start(
1345            "fixture",
1346            "session",
1347            parse_reply(&start()).unwrap().unwrap(),
1348        )
1349        .unwrap();
1350        assert!(legacy.apply_context_mode(&runtime).unwrap().is_none());
1351        assert!(!runtime.context_management_enabled());
1352        let mut value = start();
1353        value["session_driver"]["context_mode"] = json!("auto");
1354        let (auto, _) =
1355            Grant::from_start("fixture", "session", parse_reply(&value).unwrap().unwrap()).unwrap();
1356        let notice = auto.apply_context_mode(&runtime).unwrap().unwrap();
1357        assert!(runtime.context_management_enabled());
1358        assert!(notice.contains("Session-only"));
1359        assert!(notice.contains("excludes private reasoning"));
1360        assert!(legacy.apply_context_mode(&runtime).unwrap().is_none());
1361        assert!(
1362            runtime.context_management_enabled(),
1363            "legacy mode cannot undo an explicit choice"
1364        );
1365        value["session_driver"]["context_mode"] = json!("off");
1366        let (off, _) =
1367            Grant::from_start("fixture", "session", parse_reply(&value).unwrap().unwrap()).unwrap();
1368        off.apply_context_mode(&runtime).unwrap();
1369        assert!(!runtime.context_management_enabled());
1370        assert!(!env.path().join("config").exists());
1371        assert!(!env.path().join("context-archives").exists());
1372    }
1373
1374    #[test]
1375    #[serial_test::serial(synaps_base_dir)]
1376    fn context_auto_backend_failure_preserves_previous_mode() {
1377        let _env = crate::test_env::BaseDirGuard::new();
1378        let mut runtime = Runtime::new_headless();
1379        runtime.apply_memory_backend_config(&agent_core::config::MemoryBackendConfig {
1380            kind: agent_core::config::MemoryBackendKind::Unavailable,
1381            ..Default::default()
1382        });
1383        let mut value = start();
1384        value["session_driver"]["context_mode"] = json!("auto");
1385        let (grant, _) =
1386            Grant::from_start("fixture", "session", parse_reply(&value).unwrap().unwrap()).unwrap();
1387        assert!(grant
1388            .apply_context_mode(&runtime)
1389            .unwrap_err()
1390            .contains("unavailable"));
1391        assert!(!runtime.context_management_enabled());
1392    }
1393
1394    #[test]
1395    fn known_stream_failures_reach_fallback_without_promoting_policy_or_context() {
1396        for (kind, expected) in [
1397            ("authentication_error", "auth"),
1398            ("billing_error", "quota"),
1399            ("rate_limit_error", "rate_limit"),
1400            ("api_error", "transient"),
1401            ("overloaded_error", "transient"),
1402            ("timeout_error", "transient"),
1403        ] {
1404            let msg = format!("API stream error ({kind}). Provider error details withheld — they can echo request content.");
1405            assert_eq!(
1406                classify_turn_error(&agent_core::TurnError::provider(&msg, "api_status", "t")),
1407                provider_error(expected)
1408            );
1409            assert_eq!(
1410                classify_turn_error(&agent_core::TurnError::provider(&msg, "tool_error", "t")),
1411                blocked()
1412            );
1413            assert_eq!(classify_error(&format!("arbitrary text {msg}")), blocked());
1414        }
1415        for kind in [
1416            "permission_error",
1417            "invalid_request_error",
1418            "request_too_large",
1419            "unrecognized_error",
1420        ] {
1421            assert_eq!(classify_error(&format!("API stream error ({kind}). Provider error details withheld — they can echo request content.")), blocked());
1422        }
1423        use crate::runtime::openai::stream::*;
1424        for label in ["Codex", "OpenAI", "xAI"] {
1425            for (suffix, expected) in [
1426                (RESPONSES_AUTH_SUFFIX, "auth"),
1427                (RESPONSES_QUOTA_SUFFIX, "quota"),
1428                (RESPONSES_EMPTY_SUFFIX, "transient"),
1429                (RESPONSES_MISSING_TERMINAL_SUFFIX, "transient"),
1430            ] {
1431                assert_eq!(
1432                    classify_error(&format!("{label}{suffix}")),
1433                    provider_error(expected)
1434                );
1435            }
1436            for suffix in [
1437                RESPONSES_CONTEXT_SUFFIX,
1438                RESPONSES_INCOMPLETE_SUFFIX,
1439                RESPONSES_FAILED_SUFFIX,
1440            ] {
1441                assert_eq!(classify_error(&format!("{label}{suffix}")), blocked());
1442            }
1443        }
1444        for msg in ["Request to api.anthropic.com timed out. Check your connection and try again.",
1445            "Could not reach api.anthropic.com (connection failed). Check your network, DNS, or proxy settings.",
1446            "Connection lost mid-response. Partial reply kept — send again to continue."] {
1447            assert_eq!(classify_error(msg), provider_error("transient"));
1448        }
1449    }
1450
1451    #[test]
1452    fn feedback_version_is_explicit_pinned_and_bounded() {
1453        let (grant, _) =
1454            Grant::from_start("p", "s", parse_reply(&start()).unwrap().unwrap()).unwrap();
1455        assert!(!grant.feedback_enabled());
1456        let legacy = serde_json::to_value(request()).unwrap();
1457        assert!(legacy.get("feedback").is_none());
1458        let mut value = start();
1459        value["session_driver"]["feedback_version"] = json!(1);
1460        let (grant, _) =
1461            Grant::from_start("p", "s", parse_reply(&value).unwrap().unwrap()).unwrap();
1462        assert!(grant.feedback_enabled());
1463        for bad in [
1464            json!(0),
1465            json!(2),
1466            json!(true),
1467            json!("1"),
1468            json!(256),
1469            json!(-1),
1470        ] {
1471            value["session_driver"]["feedback_version"] = bad;
1472            assert!(parse_reply(&value).is_err());
1473        }
1474    }
1475
1476    #[test]
1477    fn typed_error_classification_uses_host_templates_not_arbitrary_substrings() {
1478        for (message, kind) in [
1479            ("API error: Authentication rejected. Run `synaps login` to re-authenticate.", "auth"),
1480            ("API error: codex request failed: 401: SECRET", "auth"),
1481            ("API error: openai request failed: 503: SECRET", "transient"),
1482            ("openai request failed: provider request failed: 429 Too Many Requests", "rate_limit"),
1483            ("Rate limited by Anthropic (HTTP 429 [rate_limit_error]). Wait for reset", "rate_limit"),
1484            ("Rate limit exhausted — retries used up while waiting for reset (next window in 5s). Try again shortly", "rate_limit"),
1485            ("Anthropic is overloaded right now. Retries exhausted — wait a minute and try again.", "transient"),
1486            ("Anthropic server error (HTTP 503 [api_error]). Retries exhausted", "transient"),
1487            ("kimi-code: usage quota exhausted (HTTP 403). Your plan's usage window is used up", "quota"),
1488            ("xAI reports the model is at capacity (retries exhausted). Try again in a few minutes or switch models with /model.", "transient"),
1489            ("Request to chatgpt.com timed out — usually transient; check your connection and try again. [SECRET]", "transient"),
1490            ("Could not reach api.x.ai (connection failed). Check your network, DNS, or proxy settings. [SECRET]", "transient"),
1491        ] {
1492            let error = agent_core::TurnError::provider(message, "api_status", "not-forwarded");
1493            assert_eq!(classify_turn_error(&error), provider_error(kind), "{message}");
1494        }
1495        for code in ["config_error", "session_error", "tool_error", "unknown"] {
1496            let error = agent_core::TurnError::provider(
1497                "API error: codex request failed: 401: SECRET",
1498                code,
1499                "private",
1500            );
1501            assert_eq!(classify_turn_error(&error), blocked());
1502        }
1503        for message in [
1504            "transcript says auth 401 quota",
1505            "Tool execution failed: openai request failed: 429",
1506            "codex request failed: 4290: fake",
1507            "Access denied (HTTP 403). Your account may not have access to this model.",
1508            "Context window exceeded (HTTP 400)",
1509            "Request too large",
1510            "invalid media auth error",
1511        ] {
1512            assert_eq!(
1513                classify_turn_error(&agent_core::TurnError::provider(
1514                    message,
1515                    "api_status",
1516                    "private"
1517                )),
1518                blocked(),
1519                "{message}"
1520            );
1521        }
1522        for code in ["network_error", "timeout"] {
1523            assert_eq!(
1524                classify_turn_error(&agent_core::TurnError::provider("SECRET", code, "private")),
1525                provider_error("transient")
1526            );
1527        }
1528        for message in [
1529            "SECRET",
1530            "Auth error: Token refresh failed: broker rejected machine auth",
1531            "Auth error: Token refresh failed: broker denied request: policy",
1532            "Auth error: Token refresh failed: broker transport error: broker HTTP 403",
1533            "Auth error: Token refresh failed: credential error: storage unavailable",
1534        ] {
1535            assert_eq!(
1536                classify_turn_error(&agent_core::TurnError::provider(
1537                    message,
1538                    "auth_error",
1539                    "private"
1540                )),
1541                blocked()
1542            );
1543        }
1544        assert_eq!(
1545            classify_turn_error(&agent_core::TurnError::provider(
1546                "Auth error: No API key or OAuth token found. Run `synaps login` to authenticate.",
1547                "auth_error",
1548                "t"
1549            )),
1550            provider_error("auth")
1551        );
1552        for outcome in [
1553            agent_core::TurnOutcome::Canceled,
1554            agent_core::TurnOutcome::Completed,
1555            agent_core::TurnOutcome::InterruptedAfterSideEffect {
1556                call_id: "private".into(),
1557            },
1558        ] {
1559            assert_eq!(
1560                classify_turn_error(&agent_core::TurnError {
1561                    message: "openai request failed: 429".into(),
1562                    outcome
1563                }),
1564                blocked()
1565            );
1566        }
1567    }
1568
1569    #[test]
1570    fn missing_accounts_are_exact_exceptions_not_generic_config_failover() {
1571        for (code, message) in [
1572            ("config_error", "Config error: openai provider: no credential configured for 'openai-codex'. Run `synaps login` to add one."),
1573            ("config_error", "Config error: openai provider: openai request failed: no credential configured for 'kimi-code'. Run `synaps login` to add one."),
1574            ("api_status", "API error: openai request failed: no credential configured for 'kimi-code'. Run `synaps login` to add one."),
1575        ] {
1576            assert_eq!(classify_turn_error(&agent_core::TurnError::provider(message, code, "private")), provider_error("auth"));
1577        }
1578        for message in [
1579            "Config error: policy auth denied",
1580            "Config error: openai provider: no credential configured for 'invented'. Run `synaps login` to add one.",
1581            "Config error: openai provider: no credential configured for 'kimi-code'. Run `synaps login` to add one. SECRET",
1582            "Config error: openai provider: broker denied request: no credential configured for 'kimi-code'. Run `synaps login` to add one.",
1583        ] {
1584            assert_eq!(classify_turn_error(&agent_core::TurnError::provider(message, "config_error", "private")), blocked());
1585        }
1586        assert_eq!(classify_error("API error: openai request failed: broker transport error: provider request failed: 503 Service Unavailable [server_error]"), provider_error("transient"));
1587        let message =
1588            crate::RuntimeError::ApiStatus(crate::core::error::humanize_api_error(429, ""))
1589                .to_string();
1590        assert_eq!(
1591            classify_turn_error(&agent_core::TurnError::provider(
1592                message,
1593                "api_status",
1594                "private"
1595            )),
1596            provider_error("rate_limit")
1597        );
1598    }
1599
1600    struct FakeHandler {
1601        reply: Result<Value, String>,
1602        calls: AtomicUsize,
1603        flood: usize,
1604        delay: Duration,
1605        dropped: Arc<AtomicBool>,
1606    }
1607    impl FakeHandler {
1608        fn new(reply: Value) -> Self {
1609            Self {
1610                reply: Ok(reply),
1611                calls: AtomicUsize::new(0),
1612                flood: 0,
1613                delay: Duration::ZERO,
1614                dropped: Arc::new(AtomicBool::new(false)),
1615            }
1616        }
1617    }
1618    struct DropMarker(Arc<AtomicBool>);
1619    impl Drop for DropMarker {
1620        fn drop(&mut self) {
1621            self.0.store(true, Ordering::SeqCst);
1622        }
1623    }
1624    #[async_trait::async_trait]
1625    impl ExtensionHandler for FakeHandler {
1626        fn id(&self) -> &str {
1627            "driver"
1628        }
1629        async fn handle(
1630            &self,
1631            _: &crate::extensions::hooks::events::HookEvent,
1632        ) -> crate::extensions::hooks::events::HookResult {
1633            Default::default()
1634        }
1635        async fn shutdown(&self) {}
1636        async fn invoke_command(
1637            &self,
1638            command: &str,
1639            args: Vec<String>,
1640            request_id: &str,
1641            sink: super::super::invoke_output::InvokeEventSink,
1642        ) -> Result<Value, String> {
1643            let _drop = DropMarker(self.dropped.clone());
1644            self.calls.fetch_add(1, Ordering::SeqCst);
1645            assert_eq!(command, "__session_driver__");
1646            assert!(uuid::Uuid::parse_str(request_id).is_ok());
1647            assert_eq!(args.len(), 1);
1648            let request: Value = serde_json::from_str(&args[0]).unwrap();
1649            assert_eq!(request["decision_id"], "decision-1");
1650            assert!(request.get("messages").is_none());
1651            for _ in 0..self.flood {
1652                let _ = sink
1653                    .send(InvokeCommandEvent::Output(CommandOutputEvent::Text {
1654                        content: "f".repeat(4096),
1655                    }))
1656                    .await;
1657            }
1658            if !self.delay.is_zero() {
1659                tokio::time::sleep(self.delay).await;
1660            }
1661            self.reply.clone()
1662        }
1663    }
1664
1665    #[tokio::test]
1666    async fn poll_collector_is_concurrent_bounded_and_ignores_display_authority() {
1667        let handler = Arc::new(FakeHandler::new(json!({"session_driver":next()})));
1668        assert!(matches!(
1669            poll(handler.clone(), request()).await.unwrap(),
1670            Reply::Next { .. }
1671        ));
1672        assert_eq!(handler.calls.load(Ordering::SeqCst), 1);
1673        let mut flooding = FakeHandler::new(json!({"session_driver":next()}));
1674        flooding.flood = 256;
1675        let result = poll(Arc::new(flooding), request()).await;
1676        assert!(result.unwrap_err().contains("budget"));
1677        let display_only = Arc::new(FakeHandler::new(
1678            json!({"text":"{\"session_driver\":{\"action\":\"next\"}}"}),
1679        ));
1680        assert!(poll(display_only, request()).await.is_err());
1681        let mut secret_error = FakeHandler::new(json!({}));
1682        secret_error.reply = Err("SECRET transport error".into());
1683        assert!(!poll(Arc::new(secret_error), request())
1684            .await
1685            .unwrap_err()
1686            .contains("SECRET"));
1687        let mut invalid = request();
1688        invalid.error_kind = "raw secret error".into();
1689        assert!(poll(handler.clone(), invalid).await.is_err());
1690        assert_eq!(
1691            handler.calls.load(Ordering::SeqCst),
1692            1,
1693            "validate before RPC"
1694        );
1695    }
1696
1697    #[tokio::test(start_paused = true)]
1698    async fn poll_timeout_and_cancellation_drop_invocation_without_detached_work() {
1699        let mut handler = FakeHandler::new(json!({"session_driver":next()}));
1700        handler.delay = Duration::from_secs(30);
1701        let marker = handler.dropped.clone();
1702        assert!(poll(Arc::new(handler), request())
1703            .await
1704            .unwrap_err()
1705            .contains("timed out"));
1706        assert!(marker.load(Ordering::SeqCst));
1707        let mut handler = FakeHandler::new(json!({"session_driver":next()}));
1708        handler.delay = Duration::from_secs(30);
1709        let handler = Arc::new(handler);
1710        let marker = handler.dropped.clone();
1711        let task = tokio::spawn(poll(handler.clone(), request()));
1712        tokio::task::yield_now().await;
1713        assert_eq!(handler.calls.load(Ordering::SeqCst), 1);
1714        task.abort();
1715        assert!(task.await.unwrap_err().is_cancelled());
1716        assert!(marker.load(Ordering::SeqCst));
1717    }
1718
1719    fn runtime_with_policy() -> Runtime {
1720        let mut runtime = Runtime::new_headless();
1721        runtime.try_set_model(selection().model).unwrap();
1722        runtime
1723            .set_reasoning_level_checked(ReasoningLevel::High)
1724            .unwrap();
1725        let model = agent_core::prompt::QualifiedModelId::parse(runtime.model()).unwrap();
1726        let policy = crate::orchestration::OrchestrationRuntime::baseline(model, 4, 16).unwrap();
1727        runtime.install_orchestration(Arc::new(policy));
1728        runtime
1729    }
1730    #[tokio::test]
1731    async fn prepared_commit_rechecks_exact_selection_and_history() {
1732        let runtime = runtime_with_policy();
1733        let (_, mut proposal) = grant();
1734        let candidate = prepare(&runtime, &proposal, &[]).await.unwrap();
1735        assert!(validate_prepared(&candidate, &proposal, &[]).is_ok());
1736        proposal.selection.effort = "low".into();
1737        assert!(validate_prepared(&candidate, &proposal, &[]).is_err());
1738        proposal.selection = selection();
1739        let history = vec![Arc::new(
1740            json!({"role":"user", "content":[{"type":"image", "source":{"type":"base64", "media_type":"image/png", "data":"corrupt"}}]}),
1741        )];
1742        assert!(validate_prepared(&candidate, &proposal, &history).is_err());
1743    }
1744
1745    #[tokio::test]
1746    async fn prepare_preserves_same_model_policy_and_original_state() {
1747        let runtime = runtime_with_policy();
1748        let policy = runtime.orchestration().unwrap();
1749        policy
1750            .grant_worker_model("openai-codex/gpt-6-astra")
1751            .unwrap();
1752        let choices = policy.effective_choices();
1753        let (_, mut proposal) = grant();
1754        proposal.selection.effort = "low".into();
1755        let result = prepare(&runtime, &proposal, &[]).await.unwrap();
1756        assert!(Arc::ptr_eq(policy, result.orchestration().unwrap()));
1757        assert_eq!(choices, result.orchestration().unwrap().effective_choices());
1758        assert_eq!(runtime.reasoning_level(), ReasoningLevel::High);
1759        assert_eq!(result.reasoning_level(), ReasoningLevel::Low);
1760        assert_eq!(
1761            runtime.host_tool_session_id(),
1762            result.host_tool_session_id()
1763        );
1764        proposal.selection.effort = "ultra".into();
1765        assert!(matches!(
1766            prepare(&runtime, &proposal, &[]).await,
1767            Err(PrepareError::Selection(_))
1768        ));
1769        assert_eq!(runtime.reasoning_level(), ReasoningLevel::High);
1770        assert_eq!(runtime.model(), selection().model);
1771    }
1772
1773    #[tokio::test]
1774    async fn prepare_blocks_workers_before_selection_and_pinned_prompt_changes() {
1775        let mut runtime = runtime_with_policy();
1776        let policy = runtime.orchestration().unwrap().clone();
1777        policy.authorize("sa_driver_test", runtime.model()).unwrap();
1778        policy
1779            .terminal_and_collect(
1780                "sa_driver_test",
1781                agent_core::orchestration::WorkerTerminal::Completed,
1782            )
1783            .unwrap();
1784        let (_, mut proposal) = grant();
1785        proposal.selection.model = "invalid".into();
1786        assert!(matches!(
1787            prepare(&runtime, &proposal, &[]).await,
1788            Err(PrepareError::Blocked(_))
1789        ));
1790        proposal.selection = selection();
1791        assert!(matches!(
1792            prepare(&runtime, &proposal, &[]).await,
1793            Err(PrepareError::Blocked(_))
1794        ));
1795        policy.reconcile("sa_driver_test").unwrap();
1796        let context = agent_core::prompt::SelectionContext::new(
1797            agent_core::prompt::QualifiedModelId::parse(runtime.model()).unwrap(),
1798            None,
1799        )
1800        .unwrap();
1801        runtime
1802            .apply_prompt_stack(agent_core::prompt::PromptStack::new(vec![], context).unwrap())
1803            .unwrap();
1804        proposal.selection = Selection {
1805            model: "openai-codex/gpt-6-astra".into(),
1806            effort: "ultra".into(),
1807        };
1808        assert!(matches!(
1809            prepare(&runtime, &proposal, &[]).await,
1810            Err(PrepareError::Blocked(_))
1811        ));
1812    }
1813
1814    #[tokio::test]
1815    async fn prepare_validates_complete_history_and_never_aliases_models() {
1816        let runtime = runtime_with_policy();
1817        let (_, mut proposal) = grant();
1818        for model in [
1819            "x-ai/grok-4.6",
1820            "anthropic/claude-invented",
1821            "codex/gpt-6-astra",
1822            "groq/invented",
1823        ] {
1824            proposal.selection.model = model.into();
1825            assert!(
1826                matches!(
1827                    prepare(&runtime, &proposal, &[]).await,
1828                    Err(PrepareError::Selection(_))
1829                ),
1830                "{model}"
1831            );
1832        }
1833        proposal.selection = selection();
1834        let history = vec![
1835            Arc::new(
1836                json!({"role":"user","content":[{"type":"image","source":{"type":"base64","media_type":"image/png","data":"invalid base64!"}}]}),
1837            ),
1838            Arc::new(json!({"role":"assistant","content":"retained"})),
1839        ];
1840        let snapshot = history.clone();
1841        assert!(matches!(
1842            prepare(&runtime, &proposal, &history).await,
1843            Err(PrepareError::Blocked(_))
1844        ));
1845        assert_eq!(history, snapshot);
1846        // Unsupported model history must not masquerade as unsupported effort.
1847        proposal.selection.effort = "ultra".into();
1848        assert!(matches!(
1849            prepare(&runtime, &proposal, &history).await,
1850            Err(PrepareError::Blocked(_))
1851        ));
1852        assert_eq!(runtime.reasoning_level(), ReasoningLevel::High);
1853    }
1854
1855    #[tokio::test]
1856    async fn manager_rejects_unknown_and_deferred_without_rpc() {
1857        let mut manager = super::super::manager::ExtensionManager::new(Arc::new(
1858            super::super::hooks::HookBus::new(),
1859        ));
1860        assert!(manager.session_driver_handler("unknown").is_err());
1861        manager.set_progressive_deferral(true);
1862        let manifest = serde_json::from_value(json!({"runtime":"process", "command":"/must/not/spawn", "permissions":["session.drive"], "deferred":{"lifecycle":"user"}})).unwrap();
1863        manager.load("driver", &manifest).await.unwrap();
1864        assert!(manager
1865            .session_driver_handler("driver")
1866            .err()
1867            .unwrap()
1868            .contains("deferred"));
1869        manager.unload("driver").await.unwrap();
1870        assert!(manager.session_driver_handler("driver").is_err());
1871    }
1872
1873    #[tokio::test]
1874    async fn eager_permission_lifecycle_revokes_and_failed_reload_cannot_reuse_authority() {
1875        // Self-contained offline process: no filesystem fixture, user config,
1876        // provider access, optional capabilities or sidecar activation.
1877        const PROCESS: &str = r#"
1878import sys, json
1879while True:
1880    n = None
1881    while True:
1882        line = sys.stdin.buffer.readline()
1883        if not line: sys.exit(0)
1884        if line in (b'\r\n', b'\n'): break
1885        if line.lower().startswith(b'content-length:'): n = int(line.split(b':', 1)[1])
1886    if n is None: sys.exit(1)
1887    request = json.loads(sys.stdin.buffer.read(n))
1888    if 'id' not in request:
1889        if request.get('method') == 'shutdown': sys.exit(0)
1890        continue
1891    result = {'protocol_version': 1, 'capabilities': {}} if request['method'] == 'initialize' else {}
1892    body = json.dumps({'jsonrpc':'2.0', 'id':request['id'], 'result':result}).encode()
1893    sys.stdout.buffer.write(b'Content-Length: ' + str(len(body)).encode() + b'\r\n\r\n' + body)
1894    sys.stdout.buffer.flush()
1895    if request['method'] == 'shutdown': sys.exit(0)
1896"#;
1897        let mut manager = super::super::manager::ExtensionManager::new(Arc::new(
1898            super::super::hooks::HookBus::new(),
1899        ));
1900        let mut manifest: super::super::manifest::ExtensionManifest = serde_json::from_value(json!({
1901            "runtime":"process", "command":"python3", "args":["-u", "-c", PROCESS], "permissions":["session.drive"]
1902        })).unwrap();
1903        manager.load("driver", &manifest).await.unwrap();
1904        let original = manager.session_driver_handler("driver").unwrap();
1905        assert!(Arc::ptr_eq(
1906            &original,
1907            &manager.session_driver_handler("driver").unwrap()
1908        ));
1909        manager.reload("driver", &manifest, None).await.unwrap();
1910        let replacement = manager.session_driver_handler("driver").unwrap();
1911        assert!(!Arc::ptr_eq(&original, &replacement));
1912        manifest.permissions = vec!["tools.register".into()];
1913        manager.reload("driver", &manifest, None).await.unwrap();
1914        assert!(manager.session_driver_handler("driver").is_err());
1915        manifest.permissions.push("session.drive".into());
1916        manager.reload("driver", &manifest, None).await.unwrap();
1917        manifest.command = "/must/not/spawn".into();
1918        assert!(manager.reload("driver", &manifest, None).await.is_err());
1919        assert!(manager.session_driver_handler("driver").is_err());
1920        manifest.permissions.push("not.a.permission".into());
1921        assert!(manager
1922            .load("driver", &manifest)
1923            .await
1924            .unwrap_err()
1925            .contains("permission"));
1926        assert!(manager.session_driver_handler("driver").is_err());
1927    }
1928
1929    #[test]
1930    fn poll_request_session_id_is_optional_and_round_trips() {
1931        let mut req = request();
1932        // session_id absent → serialises without the field.
1933        let json = serde_json::to_string(&req).unwrap();
1934        assert!(!json.contains("session_id"), "should be skipped when None: {json}");
1935        let back: PollRequest = serde_json::from_str(&json).unwrap();
1936        assert_eq!(back.session_id, None);
1937
1938        // session_id present → round-trips.
1939        req.session_id = Some("test-session-42".into());
1940        let json = serde_json::to_string(&req).unwrap();
1941        assert!(json.contains("session_id"), "{json}");
1942        let back: PollRequest = serde_json::from_str(&json).unwrap();
1943        assert_eq!(back.session_id.as_deref(), Some("test-session-42"));
1944    }
1945
1946    #[test]
1947    fn reply_start_max_cost_usd_optional_and_validated() {
1948        // Legacy start without max_cost_usd → None (backward-compat).
1949        let value = start();
1950        let reply = parse_reply(&value).unwrap().unwrap();
1951        assert!(matches!(&reply, Reply::Start { max_cost_usd: None, .. }));
1952
1953        // Explicit max_cost_usd round-trips.
1954        let mut value = start();
1955        value["session_driver"]["max_cost_usd"] = json!(3.50);
1956        let reply = parse_reply(&value).unwrap().unwrap();
1957        assert!(matches!(&reply, Reply::Start { max_cost_usd: Some(c), .. } if (*c - 3.5).abs() < f64::EPSILON));
1958        let (grant, _) = Grant::from_start("p", "s", reply).unwrap();
1959        assert_eq!(grant.max_cost_usd, Some(3.50));
1960
1961        // Zero or negative rejected at parse_reply (validate_reply gate).
1962        let mut value = start();
1963        value["session_driver"]["max_cost_usd"] = json!(0.0);
1964        assert!(parse_reply(&value).is_err());
1965
1966        let mut value = start();
1967        value["session_driver"]["max_cost_usd"] = json!(-1.0);
1968        assert!(parse_reply(&value).is_err());
1969    }
1970}