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