1use 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#[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
59fn 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 #[serde(default)]
78 feedback_version: Option<u8>,
79 #[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 #[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
114struct 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 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
234pub 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#[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 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 pub max_cost_usd: Option<f64>,
299}
300
301impl Grant {
302 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 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 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#[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 #[serde(skip_serializing_if = "Option::is_none")]
469 pub feedback: Option<String>,
470 #[serde(default, skip_serializing_if = "Option::is_none")]
473 pub session_id: Option<String>,
474 #[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
487pub 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_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 "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 _ => blocked(),
570 }
571}
572
573pub 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 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 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 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 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 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
731pub 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 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
809pub 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 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 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 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
897pub 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
914fn 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 Some(ReasoningSupport::NamedEffort { supported, .. }) => supported.contains(&level),
956 _ => false,
957 };
958 if !exact_effort {
959 return Err("session driver effort has no exact supported capability evidence".into());
960 }
961 }
962 catalog::validation::validate_reasoning_mutation(model, level)
963}
964
965#[cfg(test)]
966mod tests {
967 use super::*;
968 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
969
970 fn selection() -> Selection {
971 Selection {
972 model: "anthropic/claude-fable-5".into(),
973 effort: "high".into(),
974 }
975 }
976 fn start() -> Value {
977 json!({"session_driver": {"action":"start", "run_id":"run-1", "models":[selection()], "prompt":"Inspect retained work", "delay_ms":1000}})
978 }
979 fn next() -> Reply {
980 Reply::Next {
981 run_id: "run-1".into(),
982 selection: selection(),
983 prompt: "Continue retained work".into(),
984 delay_ms: 1000,
985 notice: String::new(),
986 }
987 }
988 fn grant() -> (Grant, Proposal) {
989 Grant::from_start("driver", "session", parse_reply(&start()).unwrap().unwrap()).unwrap()
990 }
991 fn request() -> PollRequest {
992 PollRequest {
993 run_id: "run-1".into(),
994 decision_id: "decision-1".into(),
995 outcome: Outcome::Success,
996 error_kind: "none".into(),
997 model: selection().model,
998 effort: selection().effort,
999 feedback: None,
1000 session_id: None,
1001 session_cost_so_far: None,
1002 }
1003 }
1004
1005 #[test]
1006 fn parse_only_structured_driver_and_optional_start_selection() {
1007 assert_eq!(
1008 parse_reply(&json!({"text":"session_driver start"})).unwrap(),
1009 None
1010 );
1011 let mut value = start();
1012 value["session_driver"]["selection"] = json!(selection());
1013 assert!(
1014 matches!(parse_reply(&value).unwrap(), Some(Reply::Start { notice, max_duration_ms: None, .. }) if notice.is_empty())
1015 );
1016 value["session_driver"]["selection"]["effort"] = json!("low");
1017 assert!(parse_reply(&value).is_err());
1018 assert!(parse_reply(&json!({"session_driver":null})).is_err());
1019 }
1020
1021 #[test]
1022 fn parser_rejects_unknown_fields_types_and_noncanonical_efforts() {
1023 for effort in [
1024 "HIGH", " high", "med", "none", "x-high", "x_high", "maximum", "1000",
1025 ] {
1026 let mut value = start();
1027 value["session_driver"]["models"][0]["effort"] = json!(effort);
1028 assert!(parse_reply(&value).is_err(), "{effort}");
1029 }
1030 for effort in [
1031 "off",
1032 "adaptive",
1033 "low",
1034 "medium",
1035 "high",
1036 "xhigh",
1037 "max",
1038 "ultra",
1039 "ultracode",
1040 ] {
1041 assert!(exact_effort(effort).is_ok());
1042 }
1043 for (key, bad) in [
1044 ("unexpected", json!(true)),
1045 ("delay_ms", json!("1000")),
1046 ("max_duration_ms", json!(-1)),
1047 ("notice", json!(false)),
1048 ("run_id", json!("bad/id")),
1049 ] {
1050 let mut value = start();
1051 value["session_driver"][key] = bad;
1052 assert!(parse_reply(&value).is_err(), "{key}");
1053 }
1054 let mut value = start();
1055 value["session_driver"]["models"][0]["secret"] = json!("must not echo");
1056 let err = parse_reply(&value).unwrap_err();
1057 assert!(!err.contains("must not echo"));
1058 for model in [
1059 "claude-fable-5-1",
1060 "anthropic/",
1061 "anthropic//model",
1062 " anthropic/model",
1063 "anthropic/a\nb",
1064 ] {
1065 value = start();
1066 value["session_driver"]["models"][0]["model"] = json!(model);
1067 assert!(parse_reply(&value).is_err());
1068 }
1069 }
1070
1071 #[test]
1072 fn parser_enforces_all_byte_count_and_time_bounds() {
1073 for (key, good, bad) in [
1074 (
1075 "prompt",
1076 json!("p".repeat(MAX_PROMPT_BYTES)),
1077 json!("p".repeat(MAX_PROMPT_BYTES + 1)),
1078 ),
1079 (
1080 "notice",
1081 json!("n".repeat(MAX_NOTICE_BYTES)),
1082 json!("n".repeat(MAX_NOTICE_BYTES + 1)),
1083 ),
1084 (
1085 "run_id",
1086 json!("r".repeat(MAX_ID_BYTES)),
1087 json!("r".repeat(MAX_ID_BYTES + 1)),
1088 ),
1089 ("delay_ms", json!(MAX_DELAY_MS), json!(MAX_DELAY_MS + 1)),
1090 (
1091 "max_duration_ms",
1092 json!(MAX_DURATION_MS),
1093 json!(MAX_DURATION_MS + 1),
1094 ),
1095 ] {
1096 let mut value = start();
1097 value["session_driver"][key] = good;
1098 assert!(parse_reply(&value).is_ok(), "{key}");
1099 value["session_driver"][key] = bad;
1100 assert!(parse_reply(&value).is_err(), "{key}");
1101 }
1102 for (key, bad) in [
1103 ("delay_ms", json!(999)),
1104 ("max_duration_ms", json!(0)),
1105 ("prompt", json!(" \n\t")),
1106 ("run_id", json!("")),
1107 ] {
1108 let mut value = start();
1109 value["session_driver"][key] = bad;
1110 assert!(parse_reply(&value).is_err());
1111 }
1112 let mut value = start();
1113 value["session_driver"]["prompt"] = json!("é".repeat(MAX_PROMPT_BYTES / 2 + 1));
1114 assert!(parse_reply(&value).is_err());
1115 value = start();
1116 value["unrelated"] = json!("x".repeat(MAX_REPLY_BYTES));
1117 assert!(parse_reply(&value).is_err());
1118 value = start();
1119 value["session_driver"]["models"][0]["model"] =
1120 json!(format!("p/{}", "m".repeat(MAX_MODEL_BYTES - 2)));
1121 assert!(parse_reply(&value).is_ok());
1122 value["session_driver"]["models"][0]["model"] =
1123 json!(format!("p/{}", "m".repeat(MAX_MODEL_BYTES - 1)));
1124 assert!(parse_reply(&value).is_err());
1125 value = start();
1126 let models: Vec<_> = (0..MAX_MODELS)
1127 .map(|i| json!({"model":format!("p/m{i}"),"effort":"high"}))
1128 .collect();
1129 value["session_driver"]["models"] = json!(models);
1130 assert!(parse_reply(&value).is_ok());
1131 value["session_driver"]["models"]
1132 .as_array_mut()
1133 .unwrap()
1134 .push(json!({"model":"p/extra","effort":"high"}));
1135 assert!(parse_reply(&value).is_err());
1136 value["session_driver"]["models"] = json!([]);
1137 assert!(parse_reply(&value).is_err());
1138 value["session_driver"]["models"] = json!([
1139 selection(),
1140 Selection {
1141 effort: "low".into(),
1142 ..selection()
1143 }
1144 ]);
1145 assert!(parse_reply(&value).is_err());
1146 }
1147
1148 #[test]
1149 fn grant_pins_exact_run_model_effort_and_rejects_rearming() {
1150 let (mut grant, first) = grant();
1151 assert_eq!(first.selection, selection());
1152 assert_eq!(grant.plugin_id, "driver");
1153 assert_eq!(grant.session_id, "session");
1154 assert!(grant.deadline().is_none());
1155 assert!(!grant.expired());
1156 assert!(grant.accept(next()).unwrap().is_some());
1157 let Reply::Next {
1158 selection: first_selection,
1159 prompt,
1160 delay_ms,
1161 notice,
1162 ..
1163 } = next()
1164 else {
1165 unreachable!()
1166 };
1167 assert!(grant
1168 .accept(Reply::Next {
1169 run_id: "different-run".into(),
1170 selection: first_selection,
1171 prompt,
1172 delay_ms,
1173 notice
1174 })
1175 .is_err());
1176 let Reply::Next {
1177 run_id,
1178 prompt,
1179 delay_ms,
1180 notice,
1181 ..
1182 } = next()
1183 else {
1184 unreachable!()
1185 };
1186 for selected in [
1187 Selection {
1188 effort: "low".into(),
1189 ..selection()
1190 },
1191 Selection {
1192 model: "openai-codex/gpt-6-astra".into(),
1193 effort: "ultra".into(),
1194 },
1195 ] {
1196 assert!(grant
1197 .accept(Reply::Next {
1198 run_id: run_id.clone(),
1199 selection: selected,
1200 prompt: prompt.clone(),
1201 delay_ms,
1202 notice: notice.clone()
1203 })
1204 .is_err());
1205 }
1206 assert!(grant
1207 .accept(parse_reply(&start()).unwrap().unwrap())
1208 .is_err());
1209 assert!(grant
1210 .accept(Reply::Status {
1211 notice: String::new()
1212 })
1213 .is_err());
1214 assert!(grant
1215 .accept(Reply::Stop {
1216 notice: String::new()
1217 })
1218 .unwrap()
1219 .is_none());
1220 grant.run_id = "different-run".into();
1221 assert!(
1222 grant.accept(next()).is_ok(),
1223 "public bookkeeping must not change pinned authority"
1224 );
1225 }
1226
1227 #[test]
1228 fn grant_revalidates_direct_constructors_and_deadline_scheduling() {
1229 let (mut grant, _) = grant();
1230 grant.deadline = Some(Instant::now() + Duration::from_millis(500));
1231 assert!(!grant.expired());
1232 assert!(
1233 grant.accept(next()).is_err(),
1234 "do not schedule beyond deadline"
1235 );
1236 grant.deadline = Some(Instant::now() - Duration::from_millis(1));
1237 assert!(grant.expired());
1238 assert!(grant.accept(next()).is_err());
1239 let mut value = start();
1240 value["session_driver"]["max_duration_ms"] = json!(500);
1241 assert!(Grant::from_start("p", "s", parse_reply(&value).unwrap().unwrap()).is_err());
1242 value["session_driver"]["max_duration_ms"] = json!(10_000);
1243 assert!(
1244 Grant::from_start("p", "s", parse_reply(&value).unwrap().unwrap())
1245 .unwrap()
1246 .0
1247 .deadline()
1248 .is_some()
1249 );
1250 let bad = Reply::Start {
1251 run_id: "x".into(),
1252 models: vec![],
1253 prompt: "hello".into(),
1254 delay_ms: 1000,
1255 max_duration_ms: None,
1256 feedback_version: None,
1257 time_checkpoint_version: None,
1258 context_mode: None,
1259 notice: String::new(),
1260 max_cost_usd: None,
1261 };
1262 assert!(Grant::from_start("p", "s", bad).is_err());
1263 }
1264
1265 #[test]
1266 fn time_checkpoint_support_is_versioned_and_not_provider_failure() {
1267 let legacy = parse_reply(&start()).unwrap().unwrap();
1268 assert!(!Grant::from_start("p", "s", legacy)
1269 .unwrap()
1270 .0
1271 .time_checkpoints_enabled());
1272 let mut value = start();
1273 value["session_driver"]["time_checkpoint_version"] = json!(1);
1274 let grant = Grant::from_start("p", "s", parse_reply(&value).unwrap().unwrap())
1275 .unwrap()
1276 .0;
1277 assert!(grant.time_checkpoints_enabled());
1278 for bad in [json!(0), json!(2), json!(true), json!("1")] {
1279 value["session_driver"]["time_checkpoint_version"] = bad;
1280 assert!(parse_reply(&value).is_err());
1281 }
1282 assert_eq!(
1283 classify_turn_error(&agent_core::TurnError::budget(
1284 agent_core::BudgetDimension::WallClock
1285 )),
1286 (Outcome::TimeCheckpoint, "wall_clock".into())
1287 );
1288 for dimension in [
1289 agent_core::BudgetDimension::CostUsd,
1290 agent_core::BudgetDimension::ToolCalls,
1291 agent_core::BudgetDimension::ProviderRounds,
1292 ] {
1293 assert_eq!(
1294 classify_turn_error(&agent_core::TurnError::budget(dimension)),
1295 blocked()
1296 );
1297 }
1298 }
1299
1300 #[test]
1301 fn context_mode_is_optional_strict_and_start_only() {
1302 let legacy = start();
1303 assert!(matches!(
1304 parse_reply(&legacy).unwrap(),
1305 Some(Reply::Start {
1306 context_mode: None,
1307 ..
1308 })
1309 ));
1310 for (name, expected) in [("auto", ContextMode::Auto), ("off", ContextMode::Off)] {
1311 let mut value = start();
1312 value["session_driver"]["context_mode"] = json!(name);
1313 let reply = parse_reply(&value).unwrap().unwrap();
1314 assert!(
1315 matches!(&reply, Reply::Start { context_mode:Some(mode), .. } if *mode == expected)
1316 );
1317 let encoded = serde_json::to_value(&reply).unwrap();
1318 assert_eq!(encoded["context_mode"], name);
1319 let (grant, _) = Grant::from_start("fixture", "session", reply).unwrap();
1320 assert_eq!(grant.context_mode, Some(expected));
1321 }
1322 for bad in [
1323 json!(null),
1324 json!(true),
1325 json!(1),
1326 json!("AUTO"),
1327 json!("inherit"),
1328 json!({}),
1329 json!([]),
1330 ] {
1331 let mut value = start();
1332 value["session_driver"]["context_mode"] = bad;
1333 assert!(parse_reply(&value).is_err());
1334 }
1335 for action in ["next", "stop", "status"] {
1336 let value = json!({"session_driver":{
1337 "action":action, "context_mode":"auto"
1338 }});
1339 assert!(parse_reply(&value).is_err());
1340 }
1341 }
1342
1343 #[test]
1344 #[serial_test::serial(synaps_base_dir)]
1345 fn context_mode_uses_session_command_without_recall_or_global_config_changes() {
1346 let env = crate::test_env::BaseDirGuard::new();
1347 let runtime = Runtime::new_headless();
1348 let (legacy, _) = Grant::from_start(
1349 "fixture",
1350 "session",
1351 parse_reply(&start()).unwrap().unwrap(),
1352 )
1353 .unwrap();
1354 assert!(legacy.apply_context_mode(&runtime).unwrap().is_none());
1355 assert!(!runtime.context_management_enabled());
1356 let mut value = start();
1357 value["session_driver"]["context_mode"] = json!("auto");
1358 let (auto, _) =
1359 Grant::from_start("fixture", "session", parse_reply(&value).unwrap().unwrap()).unwrap();
1360 let notice = auto.apply_context_mode(&runtime).unwrap().unwrap();
1361 assert!(runtime.context_management_enabled());
1362 assert!(notice.contains("Session-only"));
1363 assert!(notice.contains("excludes private reasoning"));
1364 assert!(legacy.apply_context_mode(&runtime).unwrap().is_none());
1365 assert!(
1366 runtime.context_management_enabled(),
1367 "legacy mode cannot undo an explicit choice"
1368 );
1369 value["session_driver"]["context_mode"] = json!("off");
1370 let (off, _) =
1371 Grant::from_start("fixture", "session", parse_reply(&value).unwrap().unwrap()).unwrap();
1372 off.apply_context_mode(&runtime).unwrap();
1373 assert!(!runtime.context_management_enabled());
1374 assert!(!env.path().join("config").exists());
1375 assert!(!env.path().join("context-archives").exists());
1376 }
1377
1378 #[test]
1379 #[serial_test::serial(synaps_base_dir)]
1380 fn context_auto_backend_failure_preserves_previous_mode() {
1381 let _env = crate::test_env::BaseDirGuard::new();
1382 let mut runtime = Runtime::new_headless();
1383 runtime.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 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 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 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 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 let value = start();
1952 let reply = parse_reply(&value).unwrap().unwrap();
1953 assert!(matches!(&reply, Reply::Start { max_cost_usd: None, .. }));
1954
1955 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 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}