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