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        // A chat with no project: there is nowhere to archive a window.
1384        runtime.inherit_memory_binding(&crate::memory_backend::MemoryBinding::for_cwd(None));
1385        let mut value = start();
1386        value["session_driver"]["context_mode"] = json!("auto");
1387        let (grant, _) =
1388            Grant::from_start("fixture", "session", parse_reply(&value).unwrap().unwrap()).unwrap();
1389        assert!(grant
1390            .apply_context_mode(&runtime)
1391            .unwrap_err()
1392            .contains("host project scope unavailable"));
1393        assert!(!runtime.context_management_enabled());
1394    }
1395
1396    #[test]
1397    fn known_stream_failures_reach_fallback_without_promoting_policy_or_context() {
1398        for (kind, expected) in [
1399            ("authentication_error", "auth"),
1400            ("billing_error", "quota"),
1401            ("rate_limit_error", "rate_limit"),
1402            ("api_error", "transient"),
1403            ("overloaded_error", "transient"),
1404            ("timeout_error", "transient"),
1405        ] {
1406            let msg = format!("API stream error ({kind}). Provider error details withheld — they can echo request content.");
1407            assert_eq!(
1408                classify_turn_error(&agent_core::TurnError::provider(&msg, "api_status", "t")),
1409                provider_error(expected)
1410            );
1411            assert_eq!(
1412                classify_turn_error(&agent_core::TurnError::provider(&msg, "tool_error", "t")),
1413                blocked()
1414            );
1415            assert_eq!(classify_error(&format!("arbitrary text {msg}")), blocked());
1416        }
1417        for kind in [
1418            "permission_error",
1419            "invalid_request_error",
1420            "request_too_large",
1421            "unrecognized_error",
1422        ] {
1423            assert_eq!(classify_error(&format!("API stream error ({kind}). Provider error details withheld — they can echo request content.")), blocked());
1424        }
1425        use crate::runtime::openai::stream::*;
1426        for label in ["Codex", "OpenAI", "xAI"] {
1427            for (suffix, expected) in [
1428                (RESPONSES_AUTH_SUFFIX, "auth"),
1429                (RESPONSES_QUOTA_SUFFIX, "quota"),
1430                (RESPONSES_EMPTY_SUFFIX, "transient"),
1431                (RESPONSES_MISSING_TERMINAL_SUFFIX, "transient"),
1432            ] {
1433                assert_eq!(
1434                    classify_error(&format!("{label}{suffix}")),
1435                    provider_error(expected)
1436                );
1437            }
1438            for suffix in [
1439                RESPONSES_CONTEXT_SUFFIX,
1440                RESPONSES_INCOMPLETE_SUFFIX,
1441                RESPONSES_FAILED_SUFFIX,
1442            ] {
1443                assert_eq!(classify_error(&format!("{label}{suffix}")), blocked());
1444            }
1445        }
1446        for msg in ["Request to api.anthropic.com timed out. Check your connection and try again.",
1447            "Could not reach api.anthropic.com (connection failed). Check your network, DNS, or proxy settings.",
1448            "Connection lost mid-response. Partial reply kept — send again to continue."] {
1449            assert_eq!(classify_error(msg), provider_error("transient"));
1450        }
1451    }
1452
1453    #[test]
1454    fn feedback_version_is_explicit_pinned_and_bounded() {
1455        let (grant, _) =
1456            Grant::from_start("p", "s", parse_reply(&start()).unwrap().unwrap()).unwrap();
1457        assert!(!grant.feedback_enabled());
1458        let legacy = serde_json::to_value(request()).unwrap();
1459        assert!(legacy.get("feedback").is_none());
1460        let mut value = start();
1461        value["session_driver"]["feedback_version"] = json!(1);
1462        let (grant, _) =
1463            Grant::from_start("p", "s", parse_reply(&value).unwrap().unwrap()).unwrap();
1464        assert!(grant.feedback_enabled());
1465        for bad in [
1466            json!(0),
1467            json!(2),
1468            json!(true),
1469            json!("1"),
1470            json!(256),
1471            json!(-1),
1472        ] {
1473            value["session_driver"]["feedback_version"] = bad;
1474            assert!(parse_reply(&value).is_err());
1475        }
1476    }
1477
1478    #[test]
1479    fn typed_error_classification_uses_host_templates_not_arbitrary_substrings() {
1480        for (message, kind) in [
1481            ("API error: Authentication rejected. Run `synaps login` to re-authenticate.", "auth"),
1482            ("API error: codex request failed: 401: SECRET", "auth"),
1483            ("API error: openai request failed: 503: SECRET", "transient"),
1484            ("openai request failed: provider request failed: 429 Too Many Requests", "rate_limit"),
1485            ("Rate limited by Anthropic (HTTP 429 [rate_limit_error]). Wait for reset", "rate_limit"),
1486            ("Rate limit exhausted — retries used up while waiting for reset (next window in 5s). Try again shortly", "rate_limit"),
1487            ("Anthropic is overloaded right now. Retries exhausted — wait a minute and try again.", "transient"),
1488            ("Anthropic server error (HTTP 503 [api_error]). Retries exhausted", "transient"),
1489            ("kimi-code: usage quota exhausted (HTTP 403). Your plan's usage window is used up", "quota"),
1490            ("xAI reports the model is at capacity (retries exhausted). Try again in a few minutes or switch models with /model.", "transient"),
1491            ("Request to chatgpt.com timed out — usually transient; check your connection and try again. [SECRET]", "transient"),
1492            ("Could not reach api.x.ai (connection failed). Check your network, DNS, or proxy settings. [SECRET]", "transient"),
1493        ] {
1494            let error = agent_core::TurnError::provider(message, "api_status", "not-forwarded");
1495            assert_eq!(classify_turn_error(&error), provider_error(kind), "{message}");
1496        }
1497        for code in ["config_error", "session_error", "tool_error", "unknown"] {
1498            let error = agent_core::TurnError::provider(
1499                "API error: codex request failed: 401: SECRET",
1500                code,
1501                "private",
1502            );
1503            assert_eq!(classify_turn_error(&error), blocked());
1504        }
1505        for message in [
1506            "transcript says auth 401 quota",
1507            "Tool execution failed: openai request failed: 429",
1508            "codex request failed: 4290: fake",
1509            "Access denied (HTTP 403). Your account may not have access to this model.",
1510            "Context window exceeded (HTTP 400)",
1511            "Request too large",
1512            "invalid media auth error",
1513        ] {
1514            assert_eq!(
1515                classify_turn_error(&agent_core::TurnError::provider(
1516                    message,
1517                    "api_status",
1518                    "private"
1519                )),
1520                blocked(),
1521                "{message}"
1522            );
1523        }
1524        for code in ["network_error", "timeout"] {
1525            assert_eq!(
1526                classify_turn_error(&agent_core::TurnError::provider("SECRET", code, "private")),
1527                provider_error("transient")
1528            );
1529        }
1530        for message in [
1531            "SECRET",
1532            "Auth error: Token refresh failed: broker rejected machine auth",
1533            "Auth error: Token refresh failed: broker denied request: policy",
1534            "Auth error: Token refresh failed: broker transport error: broker HTTP 403",
1535            "Auth error: Token refresh failed: credential error: storage unavailable",
1536        ] {
1537            assert_eq!(
1538                classify_turn_error(&agent_core::TurnError::provider(
1539                    message,
1540                    "auth_error",
1541                    "private"
1542                )),
1543                blocked()
1544            );
1545        }
1546        assert_eq!(
1547            classify_turn_error(&agent_core::TurnError::provider(
1548                "Auth error: No API key or OAuth token found. Run `synaps login` to authenticate.",
1549                "auth_error",
1550                "t"
1551            )),
1552            provider_error("auth")
1553        );
1554        for outcome in [
1555            agent_core::TurnOutcome::Canceled,
1556            agent_core::TurnOutcome::Completed,
1557            agent_core::TurnOutcome::InterruptedAfterSideEffect {
1558                call_id: "private".into(),
1559            },
1560        ] {
1561            assert_eq!(
1562                classify_turn_error(&agent_core::TurnError {
1563                    message: "openai request failed: 429".into(),
1564                    outcome
1565                }),
1566                blocked()
1567            );
1568        }
1569    }
1570
1571    #[test]
1572    fn missing_accounts_are_exact_exceptions_not_generic_config_failover() {
1573        for (code, message) in [
1574            ("config_error", "Config error: openai provider: no credential configured for 'openai-codex'. Run `synaps login` to add one."),
1575            ("config_error", "Config error: openai provider: openai request failed: no credential configured for 'kimi-code'. Run `synaps login` to add one."),
1576            ("api_status", "API error: openai request failed: no credential configured for 'kimi-code'. Run `synaps login` to add one."),
1577        ] {
1578            assert_eq!(classify_turn_error(&agent_core::TurnError::provider(message, code, "private")), provider_error("auth"));
1579        }
1580        for message in [
1581            "Config error: policy auth denied",
1582            "Config error: openai provider: no credential configured for 'invented'. Run `synaps login` to add one.",
1583            "Config error: openai provider: no credential configured for 'kimi-code'. Run `synaps login` to add one. SECRET",
1584            "Config error: openai provider: broker denied request: no credential configured for 'kimi-code'. Run `synaps login` to add one.",
1585        ] {
1586            assert_eq!(classify_turn_error(&agent_core::TurnError::provider(message, "config_error", "private")), blocked());
1587        }
1588        assert_eq!(classify_error("API error: openai request failed: broker transport error: provider request failed: 503 Service Unavailable [server_error]"), provider_error("transient"));
1589        let message =
1590            crate::RuntimeError::ApiStatus(crate::core::error::humanize_api_error(429, ""))
1591                .to_string();
1592        assert_eq!(
1593            classify_turn_error(&agent_core::TurnError::provider(
1594                message,
1595                "api_status",
1596                "private"
1597            )),
1598            provider_error("rate_limit")
1599        );
1600    }
1601
1602    struct FakeHandler {
1603        reply: Result<Value, String>,
1604        calls: AtomicUsize,
1605        flood: usize,
1606        delay: Duration,
1607        dropped: Arc<AtomicBool>,
1608    }
1609    impl FakeHandler {
1610        fn new(reply: Value) -> Self {
1611            Self {
1612                reply: Ok(reply),
1613                calls: AtomicUsize::new(0),
1614                flood: 0,
1615                delay: Duration::ZERO,
1616                dropped: Arc::new(AtomicBool::new(false)),
1617            }
1618        }
1619    }
1620    struct DropMarker(Arc<AtomicBool>);
1621    impl Drop for DropMarker {
1622        fn drop(&mut self) {
1623            self.0.store(true, Ordering::SeqCst);
1624        }
1625    }
1626    #[async_trait::async_trait]
1627    impl ExtensionHandler for FakeHandler {
1628        fn id(&self) -> &str {
1629            "driver"
1630        }
1631        async fn handle(
1632            &self,
1633            _: &crate::extensions::hooks::events::HookEvent,
1634        ) -> crate::extensions::hooks::events::HookResult {
1635            Default::default()
1636        }
1637        async fn shutdown(&self) {}
1638        async fn invoke_command(
1639            &self,
1640            command: &str,
1641            args: Vec<String>,
1642            request_id: &str,
1643            sink: super::super::invoke_output::InvokeEventSink,
1644        ) -> Result<Value, String> {
1645            let _drop = DropMarker(self.dropped.clone());
1646            self.calls.fetch_add(1, Ordering::SeqCst);
1647            assert_eq!(command, "__session_driver__");
1648            assert!(uuid::Uuid::parse_str(request_id).is_ok());
1649            assert_eq!(args.len(), 1);
1650            let request: Value = serde_json::from_str(&args[0]).unwrap();
1651            assert_eq!(request["decision_id"], "decision-1");
1652            assert!(request.get("messages").is_none());
1653            for _ in 0..self.flood {
1654                let _ = sink
1655                    .send(InvokeCommandEvent::Output(CommandOutputEvent::Text {
1656                        content: "f".repeat(4096),
1657                    }))
1658                    .await;
1659            }
1660            if !self.delay.is_zero() {
1661                tokio::time::sleep(self.delay).await;
1662            }
1663            self.reply.clone()
1664        }
1665    }
1666
1667    #[tokio::test]
1668    async fn poll_collector_is_concurrent_bounded_and_ignores_display_authority() {
1669        let handler = Arc::new(FakeHandler::new(json!({"session_driver":next()})));
1670        assert!(matches!(
1671            poll(handler.clone(), request()).await.unwrap(),
1672            Reply::Next { .. }
1673        ));
1674        assert_eq!(handler.calls.load(Ordering::SeqCst), 1);
1675        let mut flooding = FakeHandler::new(json!({"session_driver":next()}));
1676        flooding.flood = 256;
1677        let result = poll(Arc::new(flooding), request()).await;
1678        assert!(result.unwrap_err().contains("budget"));
1679        let display_only = Arc::new(FakeHandler::new(
1680            json!({"text":"{\"session_driver\":{\"action\":\"next\"}}"}),
1681        ));
1682        assert!(poll(display_only, request()).await.is_err());
1683        let mut secret_error = FakeHandler::new(json!({}));
1684        secret_error.reply = Err("SECRET transport error".into());
1685        assert!(!poll(Arc::new(secret_error), request())
1686            .await
1687            .unwrap_err()
1688            .contains("SECRET"));
1689        let mut invalid = request();
1690        invalid.error_kind = "raw secret error".into();
1691        assert!(poll(handler.clone(), invalid).await.is_err());
1692        assert_eq!(
1693            handler.calls.load(Ordering::SeqCst),
1694            1,
1695            "validate before RPC"
1696        );
1697    }
1698
1699    #[tokio::test(start_paused = true)]
1700    async fn poll_timeout_and_cancellation_drop_invocation_without_detached_work() {
1701        let mut handler = FakeHandler::new(json!({"session_driver":next()}));
1702        handler.delay = Duration::from_secs(30);
1703        let marker = handler.dropped.clone();
1704        assert!(poll(Arc::new(handler), request())
1705            .await
1706            .unwrap_err()
1707            .contains("timed out"));
1708        assert!(marker.load(Ordering::SeqCst));
1709        let mut handler = FakeHandler::new(json!({"session_driver":next()}));
1710        handler.delay = Duration::from_secs(30);
1711        let handler = Arc::new(handler);
1712        let marker = handler.dropped.clone();
1713        let task = tokio::spawn(poll(handler.clone(), request()));
1714        tokio::task::yield_now().await;
1715        assert_eq!(handler.calls.load(Ordering::SeqCst), 1);
1716        task.abort();
1717        assert!(task.await.unwrap_err().is_cancelled());
1718        assert!(marker.load(Ordering::SeqCst));
1719    }
1720
1721    fn runtime_with_policy() -> Runtime {
1722        let mut runtime = Runtime::new_headless();
1723        runtime.try_set_model(selection().model).unwrap();
1724        runtime
1725            .set_reasoning_level_checked(ReasoningLevel::High)
1726            .unwrap();
1727        let model = agent_core::prompt::QualifiedModelId::parse(runtime.model()).unwrap();
1728        let policy = crate::orchestration::OrchestrationRuntime::baseline(model, 4, 16).unwrap();
1729        runtime.install_orchestration(Arc::new(policy));
1730        runtime
1731    }
1732    #[tokio::test]
1733    async fn prepared_commit_rechecks_exact_selection_and_history() {
1734        let runtime = runtime_with_policy();
1735        let (_, mut proposal) = grant();
1736        let candidate = prepare(&runtime, &proposal, &[]).await.unwrap();
1737        assert!(validate_prepared(&candidate, &proposal, &[]).is_ok());
1738        proposal.selection.effort = "low".into();
1739        assert!(validate_prepared(&candidate, &proposal, &[]).is_err());
1740        proposal.selection = selection();
1741        let history = vec![Arc::new(
1742            json!({"role":"user", "content":[{"type":"image", "source":{"type":"base64", "media_type":"image/png", "data":"corrupt"}}]}),
1743        )];
1744        assert!(validate_prepared(&candidate, &proposal, &history).is_err());
1745    }
1746
1747    #[tokio::test]
1748    async fn prepare_preserves_same_model_policy_and_original_state() {
1749        let runtime = runtime_with_policy();
1750        let policy = runtime.orchestration().unwrap();
1751        policy
1752            .grant_worker_model("openai-codex/gpt-6-astra")
1753            .unwrap();
1754        let choices = policy.effective_choices();
1755        let (_, mut proposal) = grant();
1756        proposal.selection.effort = "low".into();
1757        let result = prepare(&runtime, &proposal, &[]).await.unwrap();
1758        assert!(Arc::ptr_eq(policy, result.orchestration().unwrap()));
1759        assert_eq!(choices, result.orchestration().unwrap().effective_choices());
1760        assert_eq!(runtime.reasoning_level(), ReasoningLevel::High);
1761        assert_eq!(result.reasoning_level(), ReasoningLevel::Low);
1762        assert_eq!(
1763            runtime.host_tool_session_id(),
1764            result.host_tool_session_id()
1765        );
1766        proposal.selection.effort = "ultra".into();
1767        assert!(matches!(
1768            prepare(&runtime, &proposal, &[]).await,
1769            Err(PrepareError::Selection(_))
1770        ));
1771        assert_eq!(runtime.reasoning_level(), ReasoningLevel::High);
1772        assert_eq!(runtime.model(), selection().model);
1773    }
1774
1775    #[tokio::test]
1776    async fn prepare_blocks_workers_before_selection_and_pinned_prompt_changes() {
1777        let mut runtime = runtime_with_policy();
1778        let policy = runtime.orchestration().unwrap().clone();
1779        policy.authorize("sa_driver_test", runtime.model()).unwrap();
1780        policy
1781            .terminal_and_collect(
1782                "sa_driver_test",
1783                agent_core::orchestration::WorkerTerminal::Completed,
1784            )
1785            .unwrap();
1786        let (_, mut proposal) = grant();
1787        proposal.selection.model = "invalid".into();
1788        assert!(matches!(
1789            prepare(&runtime, &proposal, &[]).await,
1790            Err(PrepareError::Blocked(_))
1791        ));
1792        proposal.selection = selection();
1793        assert!(matches!(
1794            prepare(&runtime, &proposal, &[]).await,
1795            Err(PrepareError::Blocked(_))
1796        ));
1797        policy.reconcile("sa_driver_test").unwrap();
1798        let context = agent_core::prompt::SelectionContext::new(
1799            agent_core::prompt::QualifiedModelId::parse(runtime.model()).unwrap(),
1800            None,
1801        )
1802        .unwrap();
1803        runtime
1804            .apply_prompt_stack(agent_core::prompt::PromptStack::new(vec![], context).unwrap())
1805            .unwrap();
1806        proposal.selection = Selection {
1807            model: "openai-codex/gpt-6-astra".into(),
1808            effort: "ultra".into(),
1809        };
1810        assert!(matches!(
1811            prepare(&runtime, &proposal, &[]).await,
1812            Err(PrepareError::Blocked(_))
1813        ));
1814    }
1815
1816    #[tokio::test]
1817    async fn prepare_validates_complete_history_and_never_aliases_models() {
1818        let runtime = runtime_with_policy();
1819        let (_, mut proposal) = grant();
1820        for model in [
1821            "x-ai/grok-4.6",
1822            "anthropic/claude-invented",
1823            "codex/gpt-6-astra",
1824            "groq/invented",
1825        ] {
1826            proposal.selection.model = model.into();
1827            assert!(
1828                matches!(
1829                    prepare(&runtime, &proposal, &[]).await,
1830                    Err(PrepareError::Selection(_))
1831                ),
1832                "{model}"
1833            );
1834        }
1835        proposal.selection = selection();
1836        let history = vec![
1837            Arc::new(
1838                json!({"role":"user","content":[{"type":"image","source":{"type":"base64","media_type":"image/png","data":"invalid base64!"}}]}),
1839            ),
1840            Arc::new(json!({"role":"assistant","content":"retained"})),
1841        ];
1842        let snapshot = history.clone();
1843        assert!(matches!(
1844            prepare(&runtime, &proposal, &history).await,
1845            Err(PrepareError::Blocked(_))
1846        ));
1847        assert_eq!(history, snapshot);
1848        // Unsupported model history must not masquerade as unsupported effort.
1849        proposal.selection.effort = "ultra".into();
1850        assert!(matches!(
1851            prepare(&runtime, &proposal, &history).await,
1852            Err(PrepareError::Blocked(_))
1853        ));
1854        assert_eq!(runtime.reasoning_level(), ReasoningLevel::High);
1855    }
1856
1857    #[tokio::test]
1858    async fn manager_rejects_unknown_and_deferred_without_rpc() {
1859        let mut manager = super::super::manager::ExtensionManager::new(Arc::new(
1860            super::super::hooks::HookBus::new(),
1861        ));
1862        assert!(manager.session_driver_handler("unknown").is_err());
1863        manager.set_progressive_deferral(true);
1864        let manifest = serde_json::from_value(json!({"runtime":"process", "command":"/must/not/spawn", "permissions":["session.drive"], "deferred":{"lifecycle":"user"}})).unwrap();
1865        manager.load("driver", &manifest).await.unwrap();
1866        assert!(manager
1867            .session_driver_handler("driver")
1868            .err()
1869            .unwrap()
1870            .contains("deferred"));
1871        manager.unload("driver").await.unwrap();
1872        assert!(manager.session_driver_handler("driver").is_err());
1873    }
1874
1875    #[tokio::test]
1876    async fn eager_permission_lifecycle_revokes_and_failed_reload_cannot_reuse_authority() {
1877        // Self-contained offline process: no filesystem fixture, user config,
1878        // provider access, optional capabilities or sidecar activation.
1879        const PROCESS: &str = r#"
1880import sys, json
1881while True:
1882    n = None
1883    while True:
1884        line = sys.stdin.buffer.readline()
1885        if not line: sys.exit(0)
1886        if line in (b'\r\n', b'\n'): break
1887        if line.lower().startswith(b'content-length:'): n = int(line.split(b':', 1)[1])
1888    if n is None: sys.exit(1)
1889    request = json.loads(sys.stdin.buffer.read(n))
1890    if 'id' not in request:
1891        if request.get('method') == 'shutdown': sys.exit(0)
1892        continue
1893    result = {'protocol_version': 1, 'capabilities': {}} if request['method'] == 'initialize' else {}
1894    body = json.dumps({'jsonrpc':'2.0', 'id':request['id'], 'result':result}).encode()
1895    sys.stdout.buffer.write(b'Content-Length: ' + str(len(body)).encode() + b'\r\n\r\n' + body)
1896    sys.stdout.buffer.flush()
1897    if request['method'] == 'shutdown': sys.exit(0)
1898"#;
1899        let mut manager = super::super::manager::ExtensionManager::new(Arc::new(
1900            super::super::hooks::HookBus::new(),
1901        ));
1902        let mut manifest: super::super::manifest::ExtensionManifest = serde_json::from_value(json!({
1903            "runtime":"process", "command":"python3", "args":["-u", "-c", PROCESS], "permissions":["session.drive"]
1904        })).unwrap();
1905        manager.load("driver", &manifest).await.unwrap();
1906        let original = manager.session_driver_handler("driver").unwrap();
1907        assert!(Arc::ptr_eq(
1908            &original,
1909            &manager.session_driver_handler("driver").unwrap()
1910        ));
1911        manager.reload("driver", &manifest, None).await.unwrap();
1912        let replacement = manager.session_driver_handler("driver").unwrap();
1913        assert!(!Arc::ptr_eq(&original, &replacement));
1914        manifest.permissions = vec!["tools.register".into()];
1915        manager.reload("driver", &manifest, None).await.unwrap();
1916        assert!(manager.session_driver_handler("driver").is_err());
1917        manifest.permissions.push("session.drive".into());
1918        manager.reload("driver", &manifest, None).await.unwrap();
1919        manifest.command = "/must/not/spawn".into();
1920        assert!(manager.reload("driver", &manifest, None).await.is_err());
1921        assert!(manager.session_driver_handler("driver").is_err());
1922        manifest.permissions.push("not.a.permission".into());
1923        assert!(manager
1924            .load("driver", &manifest)
1925            .await
1926            .unwrap_err()
1927            .contains("permission"));
1928        assert!(manager.session_driver_handler("driver").is_err());
1929    }
1930
1931    #[test]
1932    fn poll_request_session_id_is_optional_and_round_trips() {
1933        let mut req = request();
1934        // session_id absent → serialises without the field.
1935        let json = serde_json::to_string(&req).unwrap();
1936        assert!(!json.contains("session_id"), "should be skipped when None: {json}");
1937        let back: PollRequest = serde_json::from_str(&json).unwrap();
1938        assert_eq!(back.session_id, None);
1939
1940        // session_id present → round-trips.
1941        req.session_id = Some("test-session-42".into());
1942        let json = serde_json::to_string(&req).unwrap();
1943        assert!(json.contains("session_id"), "{json}");
1944        let back: PollRequest = serde_json::from_str(&json).unwrap();
1945        assert_eq!(back.session_id.as_deref(), Some("test-session-42"));
1946    }
1947
1948    #[test]
1949    fn reply_start_max_cost_usd_optional_and_validated() {
1950        // Legacy start without max_cost_usd → None (backward-compat).
1951        let value = start();
1952        let reply = parse_reply(&value).unwrap().unwrap();
1953        assert!(matches!(&reply, Reply::Start { max_cost_usd: None, .. }));
1954
1955        // Explicit max_cost_usd round-trips.
1956        let mut value = start();
1957        value["session_driver"]["max_cost_usd"] = json!(3.50);
1958        let reply = parse_reply(&value).unwrap().unwrap();
1959        assert!(matches!(&reply, Reply::Start { max_cost_usd: Some(c), .. } if (*c - 3.5).abs() < f64::EPSILON));
1960        let (grant, _) = Grant::from_start("p", "s", reply).unwrap();
1961        assert_eq!(grant.max_cost_usd, Some(3.50));
1962
1963        // Zero or negative rejected at parse_reply (validate_reply gate).
1964        let mut value = start();
1965        value["session_driver"]["max_cost_usd"] = json!(0.0);
1966        assert!(parse_reply(&value).is_err());
1967
1968        let mut value = start();
1969        value["session_driver"]["max_cost_usd"] = json!(-1.0);
1970        assert!(parse_reply(&value).is_err());
1971    }
1972}