1pub(crate) mod archive;
4pub mod authorize_model;
5pub mod collect;
6mod drive;
7pub(crate) mod finalize;
8pub mod models;
9mod oneshot;
10pub mod resume;
11pub mod start;
12pub mod status;
13pub mod steer;
14
15pub use authorize_model::SubagentModelAuthorizeTool;
16pub use collect::SubagentCollectTool;
17pub use models::SubagentModelsTool;
18pub use oneshot::SubagentTool;
19pub use resume::SubagentResumeTool;
20pub use start::SubagentStartTool;
21pub use status::SubagentStatusTool;
22pub use steer::SubagentSteerTool;
23
24const MAX_LABEL_CHARS: usize = 32;
26
27pub(crate) fn subagent_label(agent: Option<&str>, name: Option<&str>) -> String {
32 if let Some(agent) = agent {
33 if agent.contains('/') {
36 return std::path::Path::new(agent)
37 .file_stem()
38 .and_then(|stem| stem.to_str())
39 .and_then(sanitize_label)
40 .unwrap_or_else(|| "agent".to_string());
41 }
42 return agent.to_string();
43 }
44 name.and_then(sanitize_label)
45 .unwrap_or_else(|| "inline".to_string())
46}
47
48pub(crate) fn subagent_log_path(
53 log_dir: &std::path::Path,
54 timestamp: &str,
55 label: &str,
56 error: bool,
57) -> std::path::PathBuf {
58 let slug: String = label
59 .chars()
60 .map(|c| {
61 if c.is_alphanumeric() || c == '-' || c == '_' {
62 c
63 } else {
64 '-'
65 }
66 })
67 .collect();
68 let suffix = if error { "-error" } else { "" };
69 log_dir.join(format!("{timestamp}-{slug}{suffix}.md"))
70}
71
72fn sanitize_label(raw: &str) -> Option<String> {
76 let mut out = String::new();
77 for c in raw.trim().chars() {
78 let c = if c.is_whitespace() { '-' } else { c };
79 if !(c.is_ascii_alphanumeric() || c == '-' || c == '_') {
80 continue;
81 }
82 if c == '-' && (out.is_empty() || out.ends_with('-')) {
83 continue;
84 }
85 out.push(c);
86 if out.len() == MAX_LABEL_CHARS {
87 break;
88 }
89 }
90 let out = out.trim_end_matches('-');
91 (!out.is_empty()).then(|| out.to_string())
92}
93
94#[derive(Debug, Clone, Default)]
98pub(crate) struct WorkerWorkspace {
99 pub cwd: Option<std::path::PathBuf>,
100 pub env: Option<crate::session::types::ToolEnv>,
101 pub env_stripped: Vec<String>,
102}
103
104impl WorkerWorkspace {
105 pub(crate) fn from_capabilities(caps: &crate::tools::ToolCapabilities) -> Self {
106 Self {
107 cwd: caps.cwd.clone(),
108 env: caps.env.clone(),
109 env_stripped: caps.env_stripped.clone(),
110 }
111 }
112}
113
114pub(crate) fn apply_subagent_runtime_policy(
127 runtime: &mut crate::Runtime,
128 config: &crate::config::SynapsConfig,
129 memory_backend: Option<&crate::memory_backend::MemoryBinding>,
130 session_allow_all: Option<&std::sync::Arc<crate::tools::SessionConfirmLatch>>,
131 telemetry: Option<&crate::runtime::telemetry::WorkerTelemetry>,
132 workspace: &WorkerWorkspace,
133) {
134 runtime.set_cwd(workspace.cwd.clone());
140 runtime.set_tool_env(workspace.env.clone());
141 runtime.set_env_stripped(workspace.env_stripped.clone());
142
143 runtime.inherit_session_allow_all(session_allow_all);
144
145 if let Some(parent) = telemetry {
149 runtime.inherit_telemetry(parent);
150 }
151 runtime.apply_retry_config(config);
152 runtime.set_tool_spill_policy(crate::runtime::tool_spill::ToolSpillPolicy::from_config(
155 config,
156 ));
157 runtime.set_read_dedupe(config.read_dedupe);
158 runtime.set_tool_keepwarm(config.cache_keepwarm_tools);
162 runtime.set_bash_timeout(config.bash_timeout);
166 runtime.set_bash_max_timeout(config.bash_max_timeout);
167 runtime.set_max_tool_output(config.max_tool_output);
168 runtime.set_cache_diagnostics(config.cache_diagnostics);
172 runtime.set_request_compression(config.request_compression, config.request_compression_level);
174 if !config.subagent_transform_hooks {
179 runtime.set_hook_bus(std::sync::Arc::new(crate::extensions::hooks::HookBus::new()));
180 }
181
182 let fallback;
191 let binding = match memory_backend {
192 Some(binding) => binding,
193 None => {
194 fallback = crate::memory_backend::MemoryBinding::for_cwd(
195 workspace.cwd.clone().or_else(|| std::env::current_dir().ok()),
196 );
197 &fallback
198 }
199 };
200 runtime.inherit_memory_binding(binding);
201
202 runtime.set_codex_request_role(crate::runtime::openai::catalog::CodexRequestRole::Worker);
203
204 runtime.set_cache_ttl(crate::core::config::CacheTtl::FiveMinutes);
210
211 runtime.set_turn_budget(crate::runtime::budget::TurnBudget::from_config(
215 crate::runtime::budget::TurnRole::Worker,
216 &config.turn_budgets,
217 ));
218
219 runtime.set_worker_context_settings(Some(
223 crate::runtime::worker_context::WorkerContextSettings::from_config(config),
224 ));
225
226 runtime.set_context_window(config.context_window);
233}
234
235pub(crate) fn subagent_context_window(configured: Option<u64>, model: &str) -> Option<u64> {
242 use crate::core::models::{context_window_for_model, model_supports_1m};
243 configured.filter(|&window| window <= context_window_for_model(model) || model_supports_1m(model))
244}
245
246pub(crate) fn fit_subagent_context_window(runtime: &mut crate::Runtime) {
250 let fitted = subagent_context_window(runtime.context_window_override(), runtime.model());
251 runtime.set_context_window(fitted);
252}
253
254#[allow(clippy::too_many_arguments)]
260pub(crate) fn attach_worker_transcripts(
261 runtime: &mut crate::Runtime,
262 dir: std::path::PathBuf,
263 handle_id: &str,
264 state: Option<std::sync::Arc<std::sync::RwLock<crate::runtime::subagent::SubagentState>>>,
265 initial_messages: &[crate::SharedMessage],
266 tx_events: Option<tokio::sync::mpsc::UnboundedSender<crate::StreamEvent>>,
267 subagent_id: u64,
268 label: &str,
269) {
270 use crate::runtime::worker_context::{window_state, WorkerRolloverReport, WorkerTranscriptSink};
271 if let Some(state) = &state {
272 let (window, transcripts) = window_state(initial_messages);
273 state
274 .write()
275 .unwrap_or_else(|p| p.into_inner())
276 .note_context(window, transcripts.into_iter().map(|t| t.path.into()).collect());
277 }
278 let label = label.to_owned();
279 let observer = move |report: &WorkerRolloverReport| {
280 if let Some(state) = &state {
281 let mut s = state.write().unwrap_or_else(|p| p.into_inner());
282 s.note_context(report.window, report.transcripts.clone());
283 s.context_rollovers = s.context_rollovers.saturating_add(1);
284 let tools = s.tools;
285 s.note_progress(&report.step(), tools);
286 }
287 if let Some(tx) = &tx_events {
288 let _ = tx.send(crate::StreamEvent::Agent(crate::AgentEvent::SubagentUpdate {
289 subagent_id,
290 agent_name: label.clone(),
291 status: report.step(),
292 }));
293 }
294 };
295 runtime.attach_worker_transcripts(WorkerTranscriptSink {
296 dir,
297 stem: handle_id.to_owned(),
298 observer: Some(std::sync::Arc::new(observer)),
299 });
300}
301
302pub(crate) fn apply_codex_worker_reasoning(
308 runtime: &mut crate::Runtime,
309 parent: Option<&crate::runtime::openai::catalog::CodexExecutionPlan>,
310) {
311 use crate::runtime::openai::catalog::CodexRequestRole;
312 let Some(parent) = parent else { return };
313 if runtime.codex_request_role() == CodexRequestRole::Worker
314 && parent.automatic_delegation()
315 && parent.request_role == CodexRequestRole::Foreground
316 && runtime.model() == parent.qualified_model
317 {
318 runtime.set_reasoning_level(agent_core::reasoning::ReasoningLevel::Ultra);
319 }
320}
321
322pub(crate) fn resolve_subagent_thinking(
325 params: &serde_json::Value,
326 prior: Option<&str>,
327 config: &crate::config::SynapsConfig,
328) -> crate::Result<agent_core::reasoning::ReasoningLevel> {
329 let value = match params.get("thinking") {
330 None | Some(serde_json::Value::Null) => prior,
331 Some(serde_json::Value::String(value)) => Some(value.as_str()),
332 Some(_) => return Err(crate::RuntimeError::Tool(
333 "thinking must be low|medium|high|xhigh|max".into(),
334 )),
335 };
336 match value {
337 None => Ok(config.subagent_thinking),
338 Some(value) => agent_core::reasoning::parse_subagent_thinking(value).ok_or_else(|| {
339 crate::RuntimeError::Tool("thinking must be low|medium|high|xhigh|max".into())
340 }),
341 }
342}
343
344pub(crate) fn apply_subagent_thinking(
347 runtime: &mut crate::Runtime,
348 thinking: agent_core::reasoning::ReasoningLevel,
349) -> Result<(), String> {
350 runtime.set_reasoning_level_checked(thinking)
351}
352
353pub fn legacy_fresh_runtime() -> bool {
357 std::env::var("SYNAPS_SUBAGENT_FRESH_RUNTIME").is_ok_and(|v| v == "1")
358}
359
360pub async fn spawn_runtime() -> crate::Result<crate::Runtime> {
376 let host = crate::EngineHost::current();
377 if !legacy_fresh_runtime() {
378 if let Some(host) = &host {
379 return host.worker_runtime().await;
380 }
381 }
382 let config = match &host {
385 Some(host) => std::sync::Arc::clone(&*host.config()),
386 None => std::sync::Arc::new(crate::config::load_config()),
387 };
388 let mut rt = crate::Runtime::new().await?;
389 rt.set_tools(subagent_tools(&config.disabled_tools).await);
390 rt.apply_auth_config(&config);
391 if let Some(host) = &host {
392 rt.set_hook_bus(host.worker_hook_bus());
393 }
394 Ok(rt)
395}
396
397pub(crate) async fn subagent_tools(disabled: &[String]) -> crate::ToolRegistry {
405 if let Some(ext_mgr) = crate::runtime::openai::extension_manager_for_routing() {
406 let mgr = ext_mgr.read().await;
407 if let Some(shared) = mgr.tools_shared() {
408 let extension_tools = shared.read().await;
409 return crate::ToolRegistry::worker_set(&extension_tools, disabled);
410 }
411 }
412 let mut tools = crate::ToolRegistry::without_subagent();
413 tools.disable(disabled);
414 tools
415}
416
417pub(crate) fn spawn_config() -> std::sync::Arc<crate::config::SynapsConfig> {
428 std::sync::Arc::new(crate::config::load_config())
429}
430
431pub(crate) fn compose_system_prompt(agent_prompt: String) -> String {
437 let preamble_path = crate::config::base_dir().join("subagent-preamble.md");
438 let preamble = std::fs::read_to_string(&preamble_path).ok();
439 compose_system_prompt_with_preamble(agent_prompt, preamble.as_deref())
440}
441
442fn compose_system_prompt_with_preamble(agent_prompt: String, preamble: Option<&str>) -> String {
443 match preamble.map(str::trim).filter(|text| !text.is_empty()) {
444 Some(preamble) => format!("{preamble}\n\n{agent_prompt}"),
445 None => agent_prompt,
446 }
447}
448
449#[cfg(test)]
450mod subagent_thinking_tests {
451 use super::*;
452 use crate::tools::Tool;
453 use agent_core::reasoning::ReasoningLevel;
454 use serde_json::json;
455
456 #[test]
457 fn subagent_thinking_default_override_and_resume_precedence() {
458 let cfg = crate::config::load_config_from_str("thinking = low\nsubagent_thinking = high\n");
459 assert_eq!(resolve_subagent_thinking(&json!({}), None, &cfg).unwrap(), ReasoningLevel::High);
460 assert_eq!(resolve_subagent_thinking(&json!({}), Some("low"), &cfg).unwrap(), ReasoningLevel::Low);
461 assert_eq!(resolve_subagent_thinking(&json!({"thinking":"max"}), Some("low"), &cfg).unwrap(), ReasoningLevel::Max);
462 assert_eq!(resolve_subagent_thinking(&json!({"thinking":null}), Some("medium"), &cfg).unwrap(), ReasoningLevel::Medium);
463 for value in [json!(false), json!(1), json!(""), json!("typo"), json!("ultra")] {
464 assert!(resolve_subagent_thinking(&json!({"thinking":value}), None, &cfg).is_err());
465 }
466 }
467
468 #[test]
469 fn subagent_thinking_actual_runtime_and_astra_wire() {
470 use crate::runtime::openai::catalog::{plan_codex_execution, CodexRequestRole};
471 for value in ["low", "medium", "high", "xhigh", "max"] {
472 let cfg = crate::config::load_config_from_str("thinking = low\n");
473 let params = if value == "xhigh" { json!({}) } else { json!({"thinking":value}) };
474 let level = resolve_subagent_thinking(¶ms, None, &cfg).unwrap();
475 let mut runtime = crate::Runtime::new_headless();
476 apply_subagent_runtime_policy(&mut runtime, &cfg, None, None, None, &Default::default());
477 runtime.set_model("openai-codex/gpt-6-astra".into());
478 let parent = crate::Runtime::codex_delegation_plan(runtime.model(), ReasoningLevel::Ultra, CodexRequestRole::Foreground).unwrap();
479 apply_codex_worker_reasoning(&mut runtime, Some(&parent));
480 apply_subagent_thinking(&mut runtime, level).unwrap();
481 assert_eq!(runtime.reasoning_level().as_str(), value);
482 let plan = plan_codex_execution(runtime.model(), runtime.reasoning_level(), runtime.codex_request_role(), None).unwrap();
483 let body = crate::runtime::openai::stream::build_codex_body(
484 "gpt-6-astra", &plan, json!([]), "test".into(), vec![], None, None, "test-cache",
485 );
486 assert_eq!(body["reasoning"]["effort"], value);
487 assert!(!plan.automatic_delegation());
488 }
489 }
490
491 #[test]
492 fn subagent_thinking_schema_and_dispatch_order_all_three_tools() {
493 for tool in [&SubagentTool as &dyn Tool, &SubagentStartTool, &SubagentResumeTool] {
494 assert_eq!(tool.parameters()["properties"]["thinking"]["enum"], json!(["low","medium","high","xhigh","max"]));
495 }
496 for source in [include_str!("oneshot.rs"), include_str!("start.rs"), include_str!("resume.rs")] {
497 let model = source.find("runtime.set_model(").unwrap();
498 let parent = source.find("super::apply_codex_worker_reasoning(").unwrap();
499 let apply = source.find("super::apply_subagent_thinking(").unwrap();
500 let send = source.find("runtime.run_stream").unwrap();
501 assert!(model < parent && parent < apply && apply < send);
502 }
503 }
504
505 #[tokio::test]
506 async fn subagent_thinking_bad_override_refused_before_launch() {
507 for tool in [&SubagentTool as &dyn Tool, &SubagentStartTool] {
508 let error = tool.execute(json!({"task":"test", "system_prompt":"test", "thinking":42}), crate::tools::test_helpers::create_tool_context()).await.unwrap_err();
509 assert!(error.to_string().contains("thinking must be"));
510 }
511 }
512}
513
514#[cfg(test)]
515mod retired_param_tests {
516 use super::*;
517 use crate::tools::Tool;
518
519 #[test]
522 fn no_subagent_tool_advertises_role_or_write_policy() {
523 let tools: [&dyn Tool; 8] = [
524 &SubagentTool,
525 &SubagentStartTool,
526 &SubagentResumeTool,
527 &SubagentStatusTool,
528 &SubagentSteerTool,
529 &SubagentCollectTool,
530 &SubagentModelsTool,
531 &SubagentModelAuthorizeTool,
532 ];
533 for tool in tools {
534 let schema = tool.parameters();
535 let properties = schema["properties"]
536 .as_object()
537 .unwrap_or_else(|| panic!("{} has no properties", tool.name()));
538 for retired in ["role", "write_policy"] {
539 assert!(
540 !properties.contains_key(retired),
541 "{} advertises `{retired}`",
542 tool.name()
543 );
544 }
545 assert!(!schema.to_string().contains("write_policy"), "{}", tool.name());
546 }
547 }
548}
549
550#[cfg(test)]
551mod cache_ttl_policy_tests {
552 use super::apply_subagent_runtime_policy;
553 use crate::core::config::CacheTtl;
554
555 #[tokio::test]
567 async fn subagent_policy_forces_five_minutes_even_when_parent_is_one_hour() {
568 let parent_config = crate::config::SynapsConfig {
570 cache_ttl: CacheTtl::OneHour.into(),
571 ..Default::default()
572 };
573
574 let mut runtime = crate::Runtime::new()
576 .await
577 .expect("Runtime::new() must succeed in test environment");
578
579 runtime.set_cache_ttl(CacheTtl::OneHour);
583
584 assert_eq!(
586 runtime.cache_ttl(),
587 CacheTtl::OneHour.into(),
588 "pre-condition: runtime must be at OneHour before applying subagent policy"
589 );
590
591 apply_subagent_runtime_policy(&mut runtime, &parent_config, None, None, None, &Default::default());
593
594 assert_eq!(
596 runtime.cache_ttl(),
597 CacheTtl::FiveMinutes.into(),
598 "subagent spawn must always use 5m TTL, even when parent config is 1h \
599 (paying 1h write premium on short-lived one-shots is unrecoverable waste)"
600 );
601 }
602
603 #[tokio::test]
604 async fn subagent_policy_forces_five_minutes_even_when_parent_is_hybrid() {
605 let parent_config = crate::config::SynapsConfig {
606 cache_ttl: CacheTtl::Hybrid.into(),
607 ..Default::default()
608 };
609
610 let mut runtime = crate::Runtime::new()
611 .await
612 .expect("Runtime::new() must succeed in test environment");
613
614 runtime.set_cache_ttl(CacheTtl::Hybrid);
616
617 assert_eq!(
618 runtime.cache_ttl(),
619 CacheTtl::Hybrid.into(),
620 "pre-condition: must be Hybrid"
621 );
622
623 apply_subagent_runtime_policy(&mut runtime, &parent_config, None, None, None, &Default::default());
624
625 assert_eq!(
626 runtime.cache_ttl(),
627 CacheTtl::FiveMinutes.into(),
628 "subagent spawn must always use 5m TTL, even when parent config is hybrid"
629 );
630 }
631
632 #[tokio::test]
633 async fn subagent_policy_is_idempotent_when_parent_already_five_minutes() {
634 let parent_config = crate::config::SynapsConfig {
635 cache_ttl: CacheTtl::FiveMinutes.into(),
636 ..Default::default()
637 };
638
639 let mut runtime = crate::Runtime::new()
640 .await
641 .expect("Runtime::new() must succeed in test environment");
642 runtime.set_cache_ttl(CacheTtl::FiveMinutes);
643
644 apply_subagent_runtime_policy(&mut runtime, &parent_config, None, None, None, &Default::default());
645
646 assert_eq!(
647 runtime.cache_ttl(),
648 CacheTtl::FiveMinutes.into(),
649 "subagent spawn must stay 5m when parent is already 5m (idempotent)"
650 );
651 }
652
653 #[tokio::test]
658 async fn subagent_policy_pins_five_minutes_over_the_auto_default_on_oauth() {
659 let parent_config = crate::config::SynapsConfig::default();
660 assert_eq!(parent_config.cache_ttl, crate::config::CacheTtlSetting::Auto);
661
662 let mut runtime = crate::Runtime::new_headless();
663 runtime.set_auth_kind_for_test("oauth").await;
664 assert_eq!(
665 runtime.cache_ttl(),
666 crate::config::CacheTtlSetting::Auto,
667 "pre-condition: Runtime defaults to cache_ttl = auto"
668 );
669 assert_eq!(
670 runtime.resolved_cache_ttl().await,
671 CacheTtl::OneHour,
672 "pre-condition: auto resolves to 1h on OAuth"
673 );
674
675 apply_subagent_runtime_policy(&mut runtime, &parent_config, None, None, None, &Default::default());
676
677 assert_eq!(runtime.cache_ttl(), CacheTtl::FiveMinutes.into());
678 assert_eq!(
679 runtime.resolved_cache_ttl().await,
680 CacheTtl::FiveMinutes,
681 "an OAuth worker must still send 5m markers (#110)"
682 );
683 }
684
685 #[tokio::test]
686 async fn subagent_policy_marks_runtime_as_non_recursive_worker() {
687 let config = crate::config::SynapsConfig::default();
688 let mut runtime = crate::Runtime::new()
689 .await
690 .expect("Runtime::new() must succeed in test environment");
691
692 apply_subagent_runtime_policy(&mut runtime, &config, None, None, None, &Default::default());
693
694 assert_eq!(
695 runtime.codex_request_role(),
696 crate::runtime::openai::catalog::CodexRequestRole::Worker
697 );
698 }
699
700 #[tokio::test]
704 #[serial_test::serial(synaps_base_dir)]
705 async fn subagent_policy_turns_on_tool_output_virtualisation_from_config() {
706 let base = crate::test_env::BaseDirGuard::new();
707 let mut runtime = crate::Runtime::new_headless();
708 assert!(!runtime.tool_spill_policy().is_enabled(), "off until configured");
709 let config = agent_core::config::load_config_from_str("tool_output_spill_bytes = 20000\n");
710 apply_subagent_runtime_policy(&mut runtime, &config, None, None, None, &Default::default());
711 assert_eq!(runtime.tool_spill_policy().threshold_bytes, 20_000);
712 let spill = runtime.tool_spill();
713 let dir = spill.dir().expect("a worker gets its own spill dir");
714 assert_eq!(dir.parent(), Some(base.path().join("spill").as_path()));
715 let key = dir.file_name().unwrap().to_str().unwrap();
716 assert!(key.starts_with("session-"), "keyed by the runtime trace session: {key}");
717 let off = agent_core::config::load_config_from_str("tool_output_spill_bytes = 0\n");
719 apply_subagent_runtime_policy(&mut runtime, &off, None, None, None, &Default::default());
720 assert!(!runtime.tool_spill().is_enabled());
721 }
722
723 #[test]
725 fn subagent_policy_takes_read_dedupe_from_config() {
726 let mut runtime = crate::Runtime::new_headless();
727 assert!(!runtime.read_dedupe(), "off until configured");
728 let on = agent_core::config::load_config_from_str("");
729 apply_subagent_runtime_policy(&mut runtime, &on, None, None, None, &Default::default());
730 assert!(runtime.read_dedupe());
731 let off = agent_core::config::load_config_from_str("read_dedupe = off\n");
732 apply_subagent_runtime_policy(&mut runtime, &off, None, None, None, &Default::default());
733 assert!(!runtime.read_dedupe());
734 }
735
736 #[test]
739 fn subagent_policy_takes_tool_keepwarm_from_config() {
740 let mut runtime = crate::Runtime::new_headless();
741 assert!(!runtime.tool_keepwarm(), "off until configured");
742 let on = agent_core::config::load_config_from_str("");
743 apply_subagent_runtime_policy(&mut runtime, &on, None, None, None, &Default::default());
744 assert!(runtime.tool_keepwarm());
745 let off = agent_core::config::load_config_from_str("cache_keepwarm_tools = off\n");
746 apply_subagent_runtime_policy(&mut runtime, &off, None, None, None, &Default::default());
747 assert!(!runtime.tool_keepwarm());
748 }
749
750 #[test]
754 fn subagent_policy_takes_tool_limits_diagnostics_and_compression_from_config() {
755 use agent_core::config::RequestCompressionMode;
756 let mut runtime = crate::Runtime::new_headless();
757 let defaults = (
758 runtime.bash_timeout(),
759 runtime.bash_max_timeout(),
760 runtime.max_tool_output(),
761 runtime.cache_diagnostics(),
762 runtime.request_compression_for_test(),
763 );
764 assert_eq!(defaults, (30, 300, 30000, false, (Some(RequestCompressionMode::Auto), 1)));
765 let config = agent_core::config::load_config_from_str(
766 "bash_timeout = 90\nbash_max_timeout = 1200\nmax_tool_output = 120000\n\
767 cache_diagnostics = true\nrequest_compression = off\nrequest_compression_level = 7\n",
768 );
769 apply_subagent_runtime_policy(&mut runtime, &config, None, None, None, &Default::default());
770 assert_eq!(runtime.bash_timeout(), 90);
771 assert_eq!(runtime.bash_max_timeout(), 1200);
772 assert_eq!(runtime.max_tool_output(), 120000);
773 assert!(runtime.cache_diagnostics());
774 assert_eq!(
775 runtime.request_compression_for_test(),
776 (Some(RequestCompressionMode::Off), 7)
777 );
778 let chain = runtime.cache_diagnosis().expect("on");
780 assert_eq!(chain.previous_message_id(), None);
781 let plain = agent_core::config::load_config_from_str("");
783 apply_subagent_runtime_policy(&mut runtime, &plain, None, None, None, &Default::default());
784 assert_eq!(
785 (
786 runtime.bash_timeout(),
787 runtime.bash_max_timeout(),
788 runtime.max_tool_output(),
789 runtime.cache_diagnostics(),
790 runtime.request_compression_for_test(),
791 ),
792 defaults
793 );
794 }
795
796 #[test]
800 fn subagent_policy_keeps_or_detaches_the_transform_view() {
801 let host = std::sync::Arc::new(crate::extensions::hooks::HookBus::new());
802 let view = crate::extensions::hooks::HookBus::worker_transforms(&host);
803 let mut runtime = crate::Runtime::new_headless();
804 runtime.set_hook_bus(view.clone());
805 let on = agent_core::config::load_config_from_str("");
806 apply_subagent_runtime_policy(&mut runtime, &on, None, None, None, &Default::default());
807 assert!(std::sync::Arc::ptr_eq(runtime.hook_bus(), &view), "default on: kept");
808 let off = agent_core::config::load_config_from_str("subagent_transform_hooks = off\n");
809 apply_subagent_runtime_policy(&mut runtime, &off, None, None, None, &Default::default());
810 assert!(!runtime.hook_bus().is_worker_view(), "off: detached");
811 assert!(!std::sync::Arc::ptr_eq(runtime.hook_bus(), &host), "never the full host bus");
812 }
813}
814
815#[cfg(test)]
816mod preamble_tests {
817 use super::{compose_system_prompt, compose_system_prompt_with_preamble};
818
819 #[test]
824 #[serial_test::serial(synaps_base_dir)]
825 fn prompt_keeps_the_agent_text_and_adds_no_forum_guidance() {
826 const REMOVED: [&str; 3] = [
827 "Project forum (when enabled)",
828 "using forum_post/forum_read",
829 "The foreman remains responsible",
830 ];
831 let base = crate::test_env::BaseDirGuard::new();
832 let result = compose_system_prompt("hello world".to_string());
833 assert_eq!(result, "hello world");
834
835 let preamble = "Share findings with axel_forum_post.";
836 std::fs::write(base.path().join("subagent-preamble.md"), preamble).unwrap();
837 let result = compose_system_prompt("hello world".to_string());
838 assert_eq!(result, format!("{preamble}\n\nhello world"));
839 for removed in REMOVED {
840 assert!(!result.contains(removed), "{removed}");
841 }
842 }
843
844 #[test]
845 fn missing_empty_and_whitespace_preambles_leave_the_prompt_alone() {
846 for preamble in [None, Some(""), Some(" \n\t ")] {
847 let result = compose_system_prompt_with_preamble("task".into(), preamble);
848 assert_eq!(result, "task");
849 }
850 }
851
852 #[test]
853 fn preamble_is_prepended_once() {
854 let result = compose_system_prompt_with_preamble(
855 "You are spike.".into(),
856 Some(" \n## Shared context\nUse Sonnet for reads.\n "),
857 );
858 assert_eq!(result, "## Shared context\nUse Sonnet for reads.\n\nYou are spike.");
859 }
860
861 #[test]
864 fn worker_prompt_never_includes_stock_default() {
865 let result = compose_system_prompt_with_preamble("You are spike.".into(), None);
866 assert_eq!(result, "You are spike.");
867 assert!(!result.contains(crate::config::DEFAULT_PROMPT));
868 assert!(!result.contains("targeted edits"));
869 }
870}
871
872#[cfg(test)]
873mod codex_ultra_worker_tests {
874 use super::*;
875 use crate::runtime::openai::catalog::{
876 plan_codex_execution, CodexRequestRole, CodexWireEffort,
877 };
878 use agent_core::reasoning::ReasoningLevel;
879
880 fn parent(
881 model: &str,
882 level: ReasoningLevel,
883 ) -> Option<crate::runtime::openai::catalog::CodexExecutionPlan> {
884 crate::Runtime::codex_delegation_plan(model, level, CodexRequestRole::Foreground)
885 }
886
887 #[test]
888 fn astra_ultra_worker_inherits_ultra_but_sends_xhigh_without_recursion() {
889 let parent = parent("openai-codex/gpt-6-astra", ReasoningLevel::Ultra).unwrap();
890 assert_eq!(parent.wire_effort, Some(CodexWireEffort::XHigh));
891 let mut runtime = crate::Runtime::new_headless();
892 apply_subagent_runtime_policy(&mut runtime, &Default::default(), None, None, None, &Default::default());
893 runtime.set_model(parent.qualified_model.clone());
894 assert_eq!(runtime.reasoning_level(), ReasoningLevel::Medium);
895 apply_codex_worker_reasoning(&mut runtime, Some(&parent));
896 assert_eq!(runtime.reasoning_level(), ReasoningLevel::Ultra);
897 let plan = plan_codex_execution(
898 runtime.model(),
899 runtime.reasoning_level(),
900 runtime.codex_request_role(),
901 None,
902 )
903 .unwrap();
904 assert_eq!(plan.wire_effort, Some(CodexWireEffort::XHigh));
905 assert!(!plan.automatic_delegation());
906 assert!(crate::Runtime::codex_delegation_plan(
907 runtime.model(),
908 runtime.reasoning_level(),
909 runtime.codex_request_role()
910 )
911 .is_none());
912 }
913
914 #[test]
915 fn astra_ultra_never_overrides_another_model_or_provider() {
916 let parent = parent("openai-codex/gpt-6-astra", ReasoningLevel::Ultra).unwrap();
917 for model in [
918 "openai-codex/gpt-5.6-sol",
919 "anthropic/claude-sonnet-4-6",
920 "openrouter/openai/gpt-6-astra",
921 ] {
922 let mut runtime = crate::Runtime::new_headless();
923 apply_subagent_runtime_policy(&mut runtime, &Default::default(), None, None, None, &Default::default());
924 runtime.set_model(model.into());
925 let default = runtime.reasoning_level();
926 apply_codex_worker_reasoning(&mut runtime, Some(&parent));
927 assert_eq!(runtime.reasoning_level(), default, "{model}");
928 }
929 for role in [CodexRequestRole::Foreground, CodexRequestRole::Internal] {
930 let mut runtime = crate::Runtime::new_headless();
931 runtime.set_codex_request_role(role);
932 runtime.set_model(parent.qualified_model.clone());
933 apply_codex_worker_reasoning(&mut runtime, Some(&parent));
934 assert_eq!(runtime.reasoning_level(), ReasoningLevel::Medium);
935 }
936 }
937
938 #[test]
939 fn non_ultra_parent_has_no_worker_override_and_worker_role_cannot_forward_it() {
940 for level in [
941 ReasoningLevel::Off,
942 ReasoningLevel::Adaptive,
943 ReasoningLevel::Low,
944 ReasoningLevel::Medium,
945 ReasoningLevel::High,
946 ReasoningLevel::XHigh,
947 ReasoningLevel::Max,
948 ] {
949 assert!(
950 parent("openai-codex/gpt-6-astra", level).is_none(),
951 "{level}"
952 );
953 }
954 assert!(parent("openrouter/openai/gpt-6-astra", ReasoningLevel::Ultra).is_none());
955 for role in [CodexRequestRole::Worker, CodexRequestRole::Internal] {
956 assert!(crate::Runtime::codex_delegation_plan(
957 "openai-codex/gpt-6-astra",
958 ReasoningLevel::Ultra,
959 role
960 )
961 .is_none());
962 }
963 let mut runtime = crate::Runtime::new_headless();
964 apply_subagent_runtime_policy(&mut runtime, &Default::default(), None, None, None, &Default::default());
965 runtime.set_model("openai-codex/gpt-6-astra".into());
966 apply_codex_worker_reasoning(&mut runtime, None);
967 assert_eq!(runtime.reasoning_level(), ReasoningLevel::Medium);
968 }
969
970 #[tokio::test]
971 async fn session_allow_all_worker_policy_shares_live_parent_latch() {
972 use crate::extensions::hooks::events::HookResult;
973 use std::sync::Arc;
974
975 let jev = vec![("jev".to_string(), 0u64)];
976 let confirm_from = |source: &str| HookResult::Confirm {
977 message: "worker action".into(),
978 sources: vec![source.to_string()],
979 tool: Some("bash".into()),
980 generations: vec![],
981 modified_input: None,
982 modified_by: vec![],
983 };
984 let parent = crate::Runtime::new_headless();
985 let mut independent = crate::Runtime::new_headless();
986 apply_subagent_runtime_policy(&mut independent, &Default::default(), None, None, None, &Default::default());
987 let mut worker = crate::Runtime::new_headless();
988 apply_subagent_runtime_policy(
989 &mut worker,
990 &Default::default(),
991 None,
992 Some(parent.session_allow_all()),
993 None,
994 &Default::default(),
995 );
996 assert!(Arc::ptr_eq(
997 parent.session_allow_all(),
998 worker.session_allow_all()
999 ));
1000 assert!(worker.session_allow_all().is_empty());
1001
1002 parent.session_allow_all().allow(&jev);
1005 assert!(independent.session_allow_all().is_empty());
1006 let approved = crate::runtime::resolve_before_tool_call_decision(
1007 serde_json::json!({}),
1008 confirm_from("jev"),
1009 None,
1010 false,
1011 Some(worker.session_allow_all()),
1012 )
1013 .await;
1014 assert!(matches!(
1015 approved,
1016 crate::runtime::BeforeToolCallDecision::Continue { .. }
1017 ));
1018 let other = crate::runtime::resolve_before_tool_call_decision(
1019 serde_json::json!({}),
1020 confirm_from("other"),
1021 None,
1022 false,
1023 Some(worker.session_allow_all()),
1024 )
1025 .await;
1026 assert!(
1027 matches!(other, crate::runtime::BeforeToolCallDecision::Block { .. }),
1028 "a headless worker still fails closed for an extension the user did not allow"
1029 );
1030 let blocked = crate::runtime::resolve_before_tool_call_decision(
1031 serde_json::json!({}),
1032 HookResult::Block {
1033 reason: "policy veto".into(),
1034 },
1035 None,
1036 false,
1037 Some(worker.session_allow_all()),
1038 )
1039 .await;
1040 assert!(
1041 matches!(blocked, crate::runtime::BeforeToolCallDecision::Block { reason } if reason == "policy veto")
1042 );
1043
1044 let mut later = crate::Runtime::new_headless();
1046 apply_subagent_runtime_policy(
1047 &mut later,
1048 &Default::default(),
1049 None,
1050 Some(parent.session_allow_all()),
1051 None,
1052 &Default::default(),
1053 );
1054 assert!(later.session_allow_all().allows_all(&jev));
1055 worker.session_allow_all().clear();
1056 assert!(later.session_allow_all().is_empty());
1057 }
1058
1059 const BOTH_LANES: [&str; 2] = [
1062 "tool.execute_rich(input, dispatch_tools.for_call(",
1063 "t.execute_rich(input, dispatch_tools_inner.for_call(",
1064 ];
1065
1066 #[test]
1067 fn session_allow_all_all_real_worker_paths_pass_parent_before_streaming() {
1068 let dispatch = include_str!("../../runtime/stream.rs");
1073 assert!(dispatch.contains("session_allow_all: Some(session_allow_all.clone())"));
1074 assert!(dispatch.contains(BOTH_LANES[0]) && dispatch.contains(BOTH_LANES[1]));
1075 for (name, source) in [
1076 ("oneshot", include_str!("oneshot.rs")),
1077 ("start", include_str!("start.rs")),
1078 ("resume", include_str!("resume.rs")),
1079 ] {
1080 let capture = source
1081 .find("let session_allow_all = ctx.capabilities.session_allow_all.clone();")
1082 .unwrap();
1083 let spawn = source.find("super::spawn_runtime().await").unwrap();
1084 let memory = source.find("memory_backend.as_ref()").unwrap();
1085 let inherit = memory
1087 + source[memory..]
1088 .find("session_allow_all.as_ref()")
1089 .unwrap();
1090 let stream = source.find("runtime.run_stream").unwrap();
1091 assert!(
1092 capture < spawn && spawn < inherit && inherit < stream,
1093 "{name}"
1094 );
1095 }
1096 }
1097
1098 #[test]
1103 fn worker_policy_inherits_parent_telemetry_through_the_shared_writer() {
1104 use crate::runtime::telemetry::{
1105 TelemetryLevel, TelemetryRecord, TelemetryWriter, WriterOptions,
1106 };
1107 let tmp = tempfile::tempdir().unwrap();
1108 let log = tmp.path().join("synaps/api-log.jsonl");
1109 let writer = TelemetryWriter::new(WriterOptions {
1110 telemetry_path: Some(log.clone()),
1111 trace_path: Some(tmp.path().join("synaps/request-trace.jsonl")),
1112 ..Default::default()
1113 });
1114 let mut parent = crate::Runtime::new_headless();
1115 parent.install_observability_for_tests(writer);
1116 parent.set_telemetry_level(TelemetryLevel::Full);
1117 parent.set_session_id(Some("20261028-045932-eb8e".into()));
1118
1119 let mut worker = crate::Runtime::new_headless();
1120 assert_eq!(
1121 worker.telemetry_level(),
1122 TelemetryLevel::Off,
1123 "workers start Off"
1124 );
1125 apply_subagent_runtime_policy(
1126 &mut worker,
1127 &Default::default(),
1128 None,
1129 None,
1130 Some(&parent.worker_telemetry().for_worker("spike", "sa_7")),
1131 &Default::default(),
1132 );
1133 assert_eq!(worker.telemetry_level(), TelemetryLevel::Full);
1134 assert!(worker.trace_context().enabled());
1135 assert_ne!(
1136 worker.trace_context().session_id(),
1137 parent.trace_context().session_id(),
1138 "a worker is its own trace session, never a break in the parent's"
1139 );
1140
1141 worker
1144 .request_telemetry()
1145 .expect("worker records")
1146 .enqueue_telemetry(TelemetryRecord {
1147 ts: 1,
1148 worker: true,
1149 ..Default::default()
1150 });
1151 parent
1153 .request_telemetry()
1154 .unwrap()
1155 .enqueue_telemetry(TelemetryRecord {
1156 ts: 2,
1157 ..Default::default()
1158 });
1159 let out = parent
1160 .telemetry_writer()
1161 .unwrap()
1162 .shutdown(std::time::Duration::from_secs(10));
1163 assert!(out.is_flushed());
1164 assert_eq!(out.stats().written, 2);
1165 let text = std::fs::read_to_string(&log).unwrap();
1166 let lines: Vec<&str> = text.lines().collect();
1167 assert_eq!(lines.len(), 2);
1168 let line = lines[0];
1169 assert!(line.contains("\"worker\":true"), "{line}");
1170 let record: serde_json::Value = serde_json::from_str(line).unwrap();
1173 assert_eq!(record["worker_agent"], "spike", "{line}");
1174 assert_eq!(record["worker_handle"], "sa_7", "{line}");
1175 assert_eq!(record["parent_session_id"], "20261028-045932-eb8e", "{line}");
1176 assert_eq!(
1177 record["parent_runtime_session_id"],
1178 parent.trace_context().session_id().as_str(),
1179 "{line}"
1180 );
1181 assert!(record.get("session_id").is_none(), "{line}");
1182 let parent_record: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
1183 assert_eq!(parent_record["session_id"], "20261028-045932-eb8e");
1184 assert!(parent_record.get("worker_agent").is_none());
1185 }
1186
1187 #[test]
1188 fn worker_policy_keeps_telemetry_off_without_a_recording_parent() {
1189 use crate::runtime::telemetry::TelemetryLevel;
1190 let parent = crate::Runtime::new_headless();
1191 let mut worker = crate::Runtime::new_headless();
1192 apply_subagent_runtime_policy(
1193 &mut worker,
1194 &Default::default(),
1195 None,
1196 None,
1197 Some(&parent.worker_telemetry()),
1198 &Default::default(),
1199 );
1200 assert_eq!(worker.telemetry_level(), TelemetryLevel::Off);
1201 assert!(worker.telemetry_writer().is_none());
1202 assert!(!worker.trace_context().enabled());
1203
1204 let mut manual = crate::Runtime::new_headless();
1206 apply_subagent_runtime_policy(&mut manual, &Default::default(), None, None, None, &Default::default());
1207 assert_eq!(manual.telemetry_level(), TelemetryLevel::Off);
1208 assert!(manual.telemetry_writer().is_none());
1209 }
1210
1211 #[test]
1217 fn every_spawn_path_streams_with_the_hosts_transform_view() {
1218 let spawn = include_str!("mod.rs");
1219 let spawn = &spawn[spawn.find("pub async fn spawn_runtime()").unwrap()..];
1220 let spawn = &spawn[..spawn.find("\n}\n").unwrap()];
1221 let host_branch = spawn.find("return host.worker_runtime().await;").unwrap();
1222 let legacy = spawn.find("crate::Runtime::new().await?").unwrap();
1223 let legacy_view = spawn.find("rt.set_hook_bus(host.worker_hook_bus());").unwrap();
1224 let legacy_ok = spawn.find("Ok(rt)").unwrap();
1225 assert!(host_branch < legacy && legacy < legacy_view && legacy_view < legacy_ok);
1226 let host = include_str!("../../host.rs");
1227 let worker_runtime = &host[host.find("pub async fn worker_runtime(").unwrap()..];
1228 let worker_runtime = &worker_runtime[..worker_runtime.find("\n }\n").unwrap()];
1229 assert!(worker_runtime.contains("host.hook_bus = self.worker_hook_bus();"));
1230 assert!(host.contains("HookBus::worker_transforms(&self.parts.hook_bus)"));
1231
1232 for (name, source) in [
1233 ("oneshot", include_str!("oneshot.rs")),
1234 ("start", include_str!("start.rs")),
1235 ("resume", include_str!("resume.rs")),
1236 ] {
1237 let production = source.split("#[cfg(test)]\nmod tests").next().unwrap();
1238 assert_eq!(production.matches("super::spawn_runtime().await").count(), 1, "{name}");
1239 for forbidden in ["Runtime::new(", "worker_runtime(", "set_hook_bus(", "HookBus::new("] {
1240 assert!(!production.contains(forbidden), "{name}: {forbidden}");
1241 }
1242 let spawned = production.find("super::spawn_runtime().await").unwrap();
1243 let policy = production.find("super::apply_subagent_runtime_policy(").unwrap();
1244 let stream = production.find("runtime.run_stream").unwrap();
1245 assert!(spawned < policy && policy < stream, "{name}");
1246 }
1247 }
1248
1249 #[test]
1255 fn every_spawn_path_reads_one_config_and_drops_disabled_tools() {
1256 for (name, source) in [
1257 ("oneshot", include_str!("oneshot.rs")),
1258 ("start", include_str!("start.rs")),
1259 ("resume", include_str!("resume.rs")),
1260 ] {
1261 let production = source.split("#[cfg(test)]\nmod tests").next().unwrap();
1262 assert!(!production.contains("load_config("), "{name}: a disk read");
1263 assert_eq!(production.matches("super::spawn_config()").count(), 1, "{name}");
1264 let read = production.find("let config = super::spawn_config();").unwrap();
1265 let thinking = production.find("resolve_subagent_thinking(").unwrap();
1266 let policy = production.find("super::apply_subagent_runtime_policy(").unwrap();
1267 assert!(read < thinking && thinking < policy, "{name}");
1268 let passed = &production[policy..policy + 200];
1269 assert!(passed.contains("&config,"), "{name}: the policy gets the spawn's config");
1270 }
1271
1272 let module = include_str!("mod.rs");
1273 let read = &module[module.find("pub(crate) fn spawn_config()").unwrap()..];
1274 let read = &read[..read.find("\n}\n").unwrap()];
1275 assert_eq!(read.matches("load_config()").count(), 1, "one read");
1276
1277 let spawn = &module[module.find("pub async fn spawn_runtime()").unwrap()..];
1278 let spawn = &spawn[..spawn.find("\n}\n").unwrap()];
1279 let host_branch = spawn.find("return host.worker_runtime().await;").unwrap();
1280 let legacy_config = spawn.find("Some(host) => std::sync::Arc::clone(&*host.config()),").unwrap();
1281 assert!(host_branch < legacy_config);
1282 assert!(spawn.contains("None => std::sync::Arc::new(crate::config::load_config()),"));
1283 let legacy_tools = spawn
1284 .find("rt.set_tools(subagent_tools(&config.disabled_tools).await);")
1285 .unwrap();
1286 let legacy_auth = spawn.find("rt.apply_auth_config(&config);").unwrap();
1287 assert!(legacy_config < legacy_tools && legacy_config < legacy_auth);
1288 let host_rs = include_str!("../../host.rs");
1289 let registry = &host_rs[host_rs.find("pub async fn worker_registry(").unwrap()..];
1290 let registry = ®istry[..registry.find("\n }\n").unwrap()];
1291 assert!(registry.contains("disabled_tools: self.config().disabled_tools.clone(),"));
1292 assert!(registry.contains("ToolRegistry::worker_set(&shared, &key.disabled_tools)"));
1293 }
1294
1295 #[test]
1296 fn every_worker_path_passes_parent_telemetry_before_streaming() {
1297 let dispatch = include_str!("../../runtime/stream.rs");
1301 assert!(dispatch.contains("telemetry: Some(worker_telemetry),"));
1302 assert!(dispatch.contains(BOTH_LANES[0]) && dispatch.contains(BOTH_LANES[1]));
1303 for (name, source) in [
1304 ("oneshot", include_str!("oneshot.rs")),
1305 ("start", include_str!("start.rs")),
1306 ("resume", include_str!("resume.rs")),
1307 ] {
1308 let capture = source
1309 .find("let telemetry = ctx.capabilities.telemetry.clone();")
1310 .unwrap();
1311 let label = source
1313 .find("let telemetry = telemetry.map(|t| t.for_worker(")
1314 .unwrap();
1315 let spawn = source.find("super::spawn_runtime().await").unwrap();
1316 let policy = source
1317 .find("super::apply_subagent_runtime_policy(")
1318 .unwrap();
1319 let pass = policy + source[policy..].find("telemetry.as_ref()").unwrap();
1320 let stream = source.find("runtime.run_stream").unwrap();
1321 assert!(
1322 capture < label && label < spawn && spawn < pass && pass < stream,
1323 "{name}"
1324 );
1325 }
1326 }
1327
1328 #[test]
1329 fn all_spawn_paths_apply_codex_worker_reasoning_after_model_selection() {
1330 for (name, source) in [
1331 ("start", include_str!("start.rs")),
1332 ("oneshot", include_str!("oneshot.rs")),
1333 ("resume", include_str!("resume.rs")),
1334 ] {
1335 let policy = source
1336 .find("super::apply_subagent_runtime_policy(")
1337 .unwrap();
1338 let model = source.find("runtime.set_model(").unwrap();
1339 let reasoning = source.find("super::apply_codex_worker_reasoning(").unwrap();
1340 let stream = source.find("runtime.run_stream").unwrap();
1341 assert!(
1342 policy < model && model < reasoning && reasoning < stream,
1343 "{name}"
1344 );
1345 assert_eq!(
1346 source
1347 .matches("super::apply_codex_worker_reasoning(")
1348 .count(),
1349 1,
1350 "{name}"
1351 );
1352 }
1353 }
1354}
1355
1356#[cfg(test)]
1357mod worker_author_tests {
1358 use super::{apply_subagent_runtime_policy, subagent_tools};
1359 #[test]
1360 fn common_worker_policy_forks_author_without_mutating_parent() {
1361 let parent = crate::Runtime::new_headless();
1362 let binding = parent.memory_backend_for_test();
1363 let author = binding.author().clone();
1364 let mut actors = std::collections::HashSet::new();
1365 for _ in 0..3 {
1366 let mut worker = crate::Runtime::new_headless();
1367 apply_subagent_runtime_policy(
1368 &mut worker,
1369 &Default::default(),
1370 Some(&binding),
1371 None,
1372 None,
1373 &Default::default(),
1374 );
1375 let inherited = worker.memory_backend_for_test();
1376 assert_eq!(inherited.author().group, author.group);
1377 assert_eq!(
1378 inherited.author().parent.as_deref(),
1379 Some(author.actor.as_str())
1380 );
1381 assert_ne!(inherited.author().actor, author.actor);
1382 assert!(actors.insert(inherited.author().actor.clone()));
1383 assert_eq!(
1384 worker.clone().memory_backend_for_test().author(),
1385 inherited.author()
1386 );
1387 }
1388 assert_eq!(parent.memory_backend_for_test().author(), &author);
1389 }
1390
1391 #[tokio::test]
1397 async fn subagent_registry_excludes_delegation_and_search_tools() {
1398 let registry = subagent_tools(&[]).await;
1399 for name in [
1400 "subagent",
1401 "subagent_start",
1402 "subagent_resume",
1403 "subagent_model_authorize",
1404 "subagent_models",
1405 "search_tools",
1406 "activate_tools",
1407 ] {
1408 assert!(registry.get(name).is_none(), "must not grant {name}");
1409 }
1410 assert!(registry.get("write").is_some());
1411 assert!(registry.get("edit").is_some());
1412 }
1413
1414 #[tokio::test]
1417 async fn legacy_subagent_tools_drop_every_disabled_tool() {
1418 let disabled: Vec<String> = ["bash", "shell_start", "ls", "no_such_tool"]
1419 .map(String::from)
1420 .to_vec();
1421 let all = subagent_tools(&[]).await;
1423 assert!(all.get("ls").is_some());
1424 assert_eq!(all.get("bash").is_some(), cfg!(feature = "command-exec"));
1425 let registry = subagent_tools(&disabled).await;
1426 let schema = registry.tools_schema();
1427 let names: Vec<&str> = schema.iter().filter_map(|t| t["name"].as_str()).collect();
1428 for name in &disabled {
1429 assert!(registry.get(name).is_none(), "{name} must not be granted");
1430 assert!(!names.contains(&name.as_str()), "{name} must not be advertised: {names:?}");
1431 }
1432 assert!(registry.get("read").is_some(), "the rest stays");
1433 assert!(names.contains(&"read"));
1434 }
1435
1436 #[tokio::test]
1440 #[serial_test::serial(synaps_base_dir)]
1441 async fn a_worker_inherits_its_parents_project_not_its_own_folders() {
1442 use crate::memory_backend::MemoryBinding;
1443 use crate::tools::Tool;
1444 let base = crate::test_env::BaseDirGuard::new();
1445 let _root = crate::test_env::EnvVarGuard::remove("SYNAPS_PROJECT_ROOT");
1446 let project = || {
1447 let dir = tempfile::tempdir().unwrap();
1448 std::fs::write(dir.path().join(".synaps-project"), "").unwrap();
1449 dir
1450 };
1451 let (parents, workers) = (project(), project());
1452 let parent_binding = MemoryBinding::for_cwd(Some(parents.path().to_path_buf()));
1453 let key = |binding: &MemoryBinding| binding.scope().unwrap().key().to_owned();
1454 let workspace = super::WorkerWorkspace {
1455 cwd: Some(workers.path().to_path_buf()),
1456 ..Default::default()
1457 };
1458 let own = key(&MemoryBinding::for_cwd(workspace.cwd.clone()));
1459 assert_ne!(key(&parent_binding), own);
1460
1461 let mut worker = crate::Runtime::new_headless();
1462 apply_subagent_runtime_policy(
1463 &mut worker,
1464 &Default::default(),
1465 Some(&parent_binding),
1466 None,
1467 None,
1468 &workspace,
1469 );
1470 let inherited = worker.memory_backend_for_test();
1471 assert_eq!(key(&inherited), key(&parent_binding));
1472 assert_eq!(inherited.base(), parent_binding.base());
1473 let notes_of = |key: &str| {
1476 let file = agent_core::memory::store::project_notes_file(base.path(), key);
1477 std::fs::read_to_string(file).unwrap_or_default().lines().count()
1478 };
1479 let store = |binding: MemoryBinding| async move {
1480 crate::tools::memory::MemoryStoreTool
1481 .execute(
1482 serde_json::json!({"content": "a worker's note"}),
1483 crate::tools::test_helpers::context_with_memory(binding),
1484 )
1485 .await
1486 .unwrap()
1487 };
1488 store(worker.memory_backend_for_test()).await;
1489 assert_eq!((notes_of(&key(&parent_binding)), notes_of(&own)), (1, 0));
1490 worker.apply_config(&Default::default());
1491 assert_eq!(key(&worker.memory_backend_for_test()), key(&parent_binding));
1492 assert_eq!(worker.memory_backend_for_test().author(), inherited.author());
1493 store(worker.memory_backend_for_test()).await;
1494 assert_eq!((notes_of(&key(&parent_binding)), notes_of(&own)), (2, 0));
1495
1496 let mut worker_none = crate::Runtime::new_headless();
1497 apply_subagent_runtime_policy(&mut worker_none, &Default::default(), None, None, None, &workspace);
1498 assert_eq!(key(&worker_none.memory_backend_for_test()), own);
1499
1500 let nowhere = MemoryBinding::for_cwd(None);
1503 let mut orphan = crate::Runtime::new_headless();
1504 apply_subagent_runtime_policy(&mut orphan, &Default::default(), Some(&nowhere), None, None, &workspace);
1505 let refused = orphan.memory_backend_for_test().scope().unwrap_err().to_string();
1506 assert_eq!(refused, nowhere.scope().unwrap_err().to_string());
1507 assert_eq!(orphan.memory_backend_for_test().author().group, nowhere.author().group);
1508 }
1509}
1510
1511#[cfg(test)]
1512mod label_tests {
1513 use super::{subagent_label, subagent_log_path};
1514 use std::path::Path;
1515
1516 #[test]
1517 fn path_agent_is_labelled_by_its_file_stem() {
1518 assert_eq!(subagent_label(Some("~/agents/foo.md"), None), "foo");
1519 assert_eq!(
1520 subagent_label(Some("/home/x/.synaps-cli/agents/my-reviewer.md"), Some("n")),
1521 "my-reviewer"
1522 );
1523 assert_eq!(
1524 subagent_label(Some("./agents/review er.md"), None),
1525 "review-er"
1526 );
1527 assert_eq!(subagent_label(Some("~/agents/💀.md"), None), "agent");
1528 assert_eq!(
1530 subagent_label(Some("dev-tools:sage"), None),
1531 "dev-tools:sage"
1532 );
1533 }
1534
1535 #[test]
1536 fn log_path_always_lands_in_log_dir() {
1537 let dir = Path::new("/tmp/synaps-logs/subagents");
1538 for label in [
1539 "inline",
1540 "dev-tools:sage",
1541 "~/agents/foo.md",
1542 "../../etc/x",
1543 "a\\b",
1544 ] {
1545 let path = subagent_log_path(dir, "20260929-224500", label, false);
1546 assert_eq!(path.parent(), Some(dir), "{label:?} -> {path:?}");
1547 }
1548 assert_eq!(
1549 subagent_log_path(dir, "20260929-224500", "dev-tools:sage", true),
1550 dir.join("20260929-224500-dev-tools-sage-error.md")
1551 );
1552 assert_eq!(
1553 subagent_log_path(dir, "20260929-224500", "spike", false),
1554 dir.join("20260929-224500-spike.md")
1555 );
1556 }
1557
1558 #[test]
1559 fn named_agent_keeps_its_name_and_ignores_name() {
1560 assert_eq!(subagent_label(Some("spike"), Some("gif-recorder")), "spike");
1561 assert_eq!(subagent_label(Some("spike"), None), "spike");
1562 }
1563
1564 #[test]
1565 fn inline_agent_uses_name_or_falls_back() {
1566 assert_eq!(subagent_label(None, Some("gif-recorder")), "gif-recorder");
1567 assert_eq!(subagent_label(None, None), "inline");
1568 for blank in ["", " ", "\t\n", "\u{0}", "///", "..", "💀"] {
1569 assert_eq!(subagent_label(None, Some(blank)), "inline", "{blank:?}");
1570 }
1571 }
1572
1573 #[test]
1574 fn name_is_reduced_to_a_safe_label() {
1575 assert_eq!(
1576 subagent_label(None, Some(" Rust reviewer ")),
1577 "Rust-reviewer"
1578 );
1579 assert_eq!(subagent_label(None, Some("a - b")), "a-b");
1580 assert_eq!(subagent_label(None, Some("../../etc/passwd")), "etcpasswd");
1581 assert_eq!(subagent_label(None, Some("it's <b>\"x\"</b>")), "its-bxb");
1582 assert_eq!(subagent_label(None, Some("-lead-")), "lead");
1583 assert_eq!(subagent_label(None, Some("café_1")), "caf_1");
1584 let long = subagent_label(None, Some(&"x".repeat(100)));
1585 assert_eq!(long.len(), 32);
1586 let cut = subagent_label(None, Some(&format!("{} tail", "y".repeat(31))));
1588 assert_eq!(cut, "y".repeat(31));
1589 }
1590}
1591
1592#[cfg(test)]
1593mod retry_inheritance_tests {
1594 #[test]
1595 fn worker_policy_inherits_retry_counts_without_foreground_policy() {
1596 for (api, refusal) in [(8, 5), (0, 0), (u32::MAX, 7)] {
1597 let mut worker = crate::Runtime::new_headless();
1598 let config = crate::SynapsConfig {
1599 api_retries: api,
1600 refusal_retries: refusal,
1601 cache_ttl: crate::config::CacheTtl::OneHour.into(),
1602 ..Default::default()
1603 };
1604 super::apply_subagent_runtime_policy(&mut worker, &config, None, None, None, &Default::default());
1605 assert_eq!(worker.api_retries(), api);
1606 assert_eq!(worker.refusal_retries(), refusal);
1607 assert_eq!(worker.cache_ttl(), crate::config::CacheTtl::FiveMinutes.into());
1608 assert_eq!(
1609 worker.codex_request_role(),
1610 crate::runtime::openai::catalog::CodexRequestRole::Worker
1611 );
1612 }
1613 }
1614}
1615
1616#[cfg(all(test, feature = "command-exec"))]
1622mod worker_workspace_tests {
1623 use crate::runtime::subagent::{SubagentRegistry, SubagentStatus};
1624 use crate::tools::test_helpers::create_tool_context;
1625 use crate::tools::Tool;
1626 use axum::response::IntoResponse;
1627 use serde_json::{json, Value};
1628 use std::path::Path;
1629 use std::sync::atomic::{AtomicUsize, Ordering};
1630 use std::sync::{Arc, Mutex};
1631
1632 const PROBE: &str = "SYNAPS_WORKER_WS_PROBE";
1633 const DAEMON_ONLY: &str = "SYNAPS_WORKER_WS_DAEMON_ONLY";
1634 #[cfg(not(windows))]
1635 const COMMAND: &str = "printf 'PWD=%s\\nPROBE=%s\\nDAEMON=%s\\n' \"$(pwd -P)\" \"${SYNAPS_WORKER_WS_PROBE:-unset}\" \"${SYNAPS_WORKER_WS_DAEMON_ONLY:-unset}\"";
1636 #[cfg(windows)]
1639 const COMMAND: &str = "printf 'PWD=%s\\nPROBE=%s\\nDAEMON=%s\\n' \"$(pwd -W)\" \"${SYNAPS_WORKER_WS_PROBE:-unset}\" \"${SYNAPS_WORKER_WS_DAEMON_ONLY:-unset}\"";
1640
1641 fn sse(blocks: &str, stop: &str) -> String {
1642 let start = json!({"type":"message_start","message":{"id":"msg_ws","type":"message","role":"assistant","content":[],"model":"claude-sonnet-4-6","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"output_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}});
1643 let delta = json!({"type":"message_delta","delta":{"stop_reason":stop,"stop_sequence":null},"usage":{"input_tokens":10,"output_tokens":5,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}});
1644 format!("data: {start}\n\n{blocks}data: {delta}\n\ndata: {{\"type\":\"message_stop\"}}\n\n")
1645 }
1646
1647 fn block(start: Value, delta: Value) -> String {
1648 let stop = json!({"type":"content_block_stop","index":0});
1649 format!("data: {start}\n\ndata: {delta}\n\ndata: {stop}\n\n")
1650 }
1651
1652 async fn spawn_provider() -> (String, Arc<Mutex<Vec<String>>>) {
1656 let results: Arc<Mutex<Vec<String>>> = Arc::default();
1657 let calls = Arc::new(AtomicUsize::new(0));
1658 let seen = results.clone();
1659 let app = axum::Router::new().route(
1660 "/v1/messages",
1661 axum::routing::post(move |body: String| {
1662 let seen = seen.clone();
1663 let calls = calls.clone();
1664 async move {
1665 let body: Value = serde_json::from_str(&body).unwrap_or_default();
1666 let result = body["messages"]
1667 .as_array()
1668 .and_then(|m| m.last())
1669 .and_then(|m| m["content"].as_array())
1670 .and_then(|c| c.iter().find(|b| b["type"] == "tool_result"))
1671 .map(|b| b["content"].to_string());
1672 let frames = match result {
1673 Some(result) => {
1674 seen.lock().unwrap().push(result);
1675 block(
1676 json!({"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}),
1677 json!({"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"done"}}),
1678 ) + ""
1679 }
1680 None => {
1681 let id = format!("toolu_ws{}", calls.fetch_add(1, Ordering::SeqCst));
1682 let input = json!({"command": COMMAND}).to_string();
1683 return (
1684 [("content-type", "text/event-stream")],
1685 sse(
1686 &block(
1687 json!({"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":id,"name":"bash","input":{}}}),
1688 json!({"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":input}}),
1689 ),
1690 "tool_use",
1691 ),
1692 )
1693 .into_response();
1694 }
1695 };
1696 ([("content-type", "text/event-stream")], sse(&frames, "end_turn")).into_response()
1697 }
1698 }),
1699 );
1700 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1701 let addr = listener.local_addr().unwrap();
1702 tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
1703 (format!("http://{addr}"), results)
1704 }
1705
1706 struct Env {
1707 _base: crate::test_env::BaseDirGuard,
1708 _guards: Vec<crate::test_env::EnvVarGuard>,
1709 results: Arc<Mutex<Vec<String>>>,
1710 }
1711
1712 async fn env() -> Env {
1713 let base = crate::test_env::BaseDirGuard::new();
1714 std::fs::write(
1715 base.path().join("auth.json"),
1716 r#"{"anthropic": {"type": "oauth", "refresh": "synthetic", "access": "synthetic-access", "expires": 9999999999999}}"#,
1717 )
1718 .unwrap();
1719 let (url, results) = spawn_provider().await;
1720 let guards = vec![
1721 crate::test_env::EnvVarGuard::remove("SYNAPS_AUTH_ENDPOINT"),
1722 crate::test_env::EnvVarGuard::remove("SYNAPS_MACHINE_TOKEN"),
1723 crate::test_env::EnvVarGuard::remove("SYNAPS_SUBAGENT_FRESH_RUNTIME"),
1724 crate::test_env::EnvVarGuard::remove("ANTHROPIC_API_KEY"),
1725 crate::test_env::EnvVarGuard::set("SYNAPS_ANTHROPIC_BASE_URL", &url),
1726 crate::test_env::EnvVarGuard::set(DAEMON_ONLY, "leaked"),
1727 ];
1728 Env { _base: base, _guards: guards, results }
1729 }
1730
1731 fn parent_ctx(dir: &Path, registry: &Arc<Mutex<SubagentRegistry>>) -> crate::tools::ToolContext {
1734 let mut ctx = create_tool_context();
1735 ctx.capabilities.subagent_registry = Some(registry.clone());
1736 ctx.capabilities.cwd = Some(dir.to_path_buf());
1737 ctx.capabilities.env = Some(
1738 vec![
1739 ("PATH".into(), std::env::var("PATH").unwrap_or_default()),
1740 ("HOME".into(), std::env::var("HOME").unwrap_or_default()),
1741 (PROBE.into(), "from-session".into()),
1742 ]
1743 .into(),
1744 );
1745 ctx
1746 }
1747
1748 fn assert_ran_in(result: &str, dir: &Path) {
1749 let want = dir.canonicalize().unwrap();
1750 let text = serde_json::from_str::<String>(result).unwrap_or_else(|_| result.to_string());
1752 let pwd = text
1753 .lines()
1754 .find_map(|line| line.strip_prefix("PWD="))
1755 .unwrap_or_else(|| panic!("no PWD line in the worker output: {result}"));
1756 let got = Path::new(pwd)
1757 .canonicalize()
1758 .unwrap_or_else(|e| panic!("worker PWD {pwd:?} does not resolve ({e}): {result}"));
1759 assert_eq!(got, want, "worker must run in the session cwd, got: {result}");
1760 assert!(result.contains("PROBE=from-session"), "session env: {result}");
1761 assert!(result.contains("DAEMON=unset"), "process env must not leak: {result}");
1762 }
1763
1764 async fn finish(registry: &Arc<Mutex<SubagentRegistry>>, handle_id: &str) {
1765 for _ in 0..600 {
1766 let done = registry
1767 .lock()
1768 .unwrap()
1769 .get(handle_id)
1770 .is_none_or(|h| h.status() != SubagentStatus::Running);
1771 if done {
1772 break;
1773 }
1774 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1775 }
1776 let thread = registry
1777 .lock()
1778 .unwrap()
1779 .get_mut(handle_id)
1780 .and_then(|h| h.thread_handle.take());
1781 if let Some(thread) = thread {
1782 tokio::task::spawn_blocking(move || thread.join()).await.unwrap().unwrap();
1783 }
1784 let status = registry.lock().unwrap().get(handle_id).map(|h| h.status());
1785 assert_eq!(status, Some(SubagentStatus::Completed), "{handle_id}");
1786 }
1787
1788 async fn start(ctx: crate::tools::ToolContext) -> String {
1789 let out = super::SubagentStartTool
1790 .execute(
1791 json!({"system_prompt": "test worker", "task": "run it", "model": "anthropic/claude-sonnet-4-6", "timeout": 60}),
1792 ctx,
1793 )
1794 .await
1795 .expect("subagent_start");
1796 let body: Value = serde_json::from_str(&out).unwrap();
1797 body["handle_id"].as_str().unwrap().to_string()
1798 }
1799
1800 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1801#[serial_test::serial(synaps_base_dir)]
1802 async fn started_worker_runs_in_the_parent_session_cwd_and_env() {
1803 let env = env().await;
1804 let dir = tempfile::tempdir().unwrap();
1805 let registry = Arc::new(Mutex::new(SubagentRegistry::new()));
1806 let handle = start(parent_ctx(dir.path(), ®istry)).await;
1807 finish(®istry, &handle).await;
1808 let results = env.results.lock().unwrap().clone();
1809 assert_eq!(results.len(), 1, "{results:?}");
1810 assert_ran_in(&results[0], dir.path());
1811 }
1812
1813 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1814#[serial_test::serial(synaps_base_dir)]
1815 async fn oneshot_worker_runs_in_the_parent_session_cwd_and_env() {
1816 let env = env().await;
1817 let dir = tempfile::tempdir().unwrap();
1818 let registry = Arc::new(Mutex::new(SubagentRegistry::new()));
1819 super::SubagentTool
1820 .execute(
1821 json!({"system_prompt": "test worker", "task": "run it", "model": "anthropic/claude-sonnet-4-6", "timeout": 60}),
1822 parent_ctx(dir.path(), ®istry),
1823 )
1824 .await
1825 .expect("subagent");
1826 let results = env.results.lock().unwrap().clone();
1827 assert_eq!(results.len(), 1, "{results:?}");
1828 assert_ran_in(&results[0], dir.path());
1829 }
1830
1831 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1834#[serial_test::serial(synaps_base_dir)]
1835 async fn resumed_worker_keeps_the_directory_it_started_in() {
1836 let env = env().await;
1837 let first = tempfile::tempdir().unwrap();
1838 let elsewhere = tempfile::tempdir().unwrap();
1839 let registry = Arc::new(Mutex::new(SubagentRegistry::new()));
1840 let handle = start(parent_ctx(first.path(), ®istry)).await;
1841 finish(®istry, &handle).await;
1842
1843 let out = super::SubagentResumeTool
1844 .execute(
1845 json!({"handle_id": handle, "instructions": "run it again", "timeout": 60}),
1846 parent_ctx(elsewhere.path(), ®istry),
1847 )
1848 .await
1849 .expect("subagent_resume");
1850 let body: Value = serde_json::from_str(&out).unwrap();
1851 finish(®istry, body["handle_id"].as_str().unwrap()).await;
1852
1853 let results = env.results.lock().unwrap().clone();
1854 assert_eq!(results.len(), 2, "{results:?}");
1855 assert_ran_in(&results[0], first.path());
1856 assert_ran_in(&results[1], first.path());
1857 }
1858}
1859
1860#[cfg(test)]
1861mod worker_rollover_tests {
1862 use super::{apply_subagent_runtime_policy, attach_worker_transcripts};
1863 use crate::core::config::{CacheTtl, ContextManagementMode};
1864 use crate::runtime::subagent::SubagentState;
1865 use crate::runtime::worker_context::{WorkerContextSettings, WorkerRolloverReport};
1866 use serde_json::json;
1867 use std::sync::{Arc, RwLock};
1868
1869 #[test]
1872 fn spawn_policy_applies_the_rollover_settings_and_keeps_the_5m_ttl() {
1873 let mut runtime = crate::Runtime::new_headless();
1874 apply_subagent_runtime_policy(&mut runtime, &Default::default(), None, None, None, &Default::default());
1875 assert_eq!(
1876 runtime.worker_context().settings,
1877 Some(WorkerContextSettings { enabled: true, rollover_tokens: 200_000 })
1878 );
1879 assert!(runtime.worker_context().sink.is_none(), "inert until a spawn attaches a sink");
1880 assert_eq!(runtime.cache_ttl(), CacheTtl::FiveMinutes.into());
1881 assert!(!runtime.context_management_enabled(), "never the foreground's rollover");
1882
1883 let off = crate::config::SynapsConfig {
1884 subagent_context_management: ContextManagementMode::Off,
1885 subagent_rollover_tokens: 150_000,
1886 cache_ttl: CacheTtl::OneHour.into(),
1887 ..Default::default()
1888 };
1889 let mut runtime = crate::Runtime::new_headless();
1890 apply_subagent_runtime_policy(&mut runtime, &off, None, None, None, &Default::default());
1891 let settings = runtime.worker_context().settings.unwrap();
1892 assert!(!settings.enabled);
1893 assert!(settings.policy(1_000_000).is_none());
1894 assert_eq!(runtime.cache_ttl(), CacheTtl::FiveMinutes.into());
1895 }
1896
1897 #[test]
1903 fn workers_inherit_the_configured_window_fitted_to_their_model() {
1904 use super::{fit_subagent_context_window, subagent_context_window};
1905 let one_m = crate::config::load_config_from_str("context_window = 1m\n");
1906 assert_eq!(one_m.context_window, Some(1_000_000));
1907 let worker = |config: &crate::config::SynapsConfig, model: &str| {
1908 let mut runtime = crate::Runtime::new_headless();
1909 apply_subagent_runtime_policy(&mut runtime, config, None, None, None, &Default::default());
1910 runtime.set_model(model.into());
1911 fit_subagent_context_window(&mut runtime);
1912 runtime
1913 };
1914 for model in ["anthropic/claude-opus-5-5", "claude-sonnet-4-6", "claude-opus-4-6"] {
1915 let w = worker(&one_m, model);
1916 assert_eq!((w.context_window_override(), w.context_window()), (Some(1_000_000), 1_000_000), "{model}");
1917 }
1918 for model in ["claude-haiku-4-5-20251001", "claude-opus-4-5", "openai/gpt-5.5"] {
1920 let w = worker(&one_m, model);
1921 assert_eq!((w.context_window_override(), w.context_window()), (None, 200_000), "{model}");
1922 }
1923 assert_eq!(worker(&Default::default(), "anthropic/claude-opus-5-5").context_window_override(), None);
1925 let small = crate::config::load_config_from_str("context_window = 150000\n");
1926 assert_eq!(worker(&small, "claude-haiku-4-5-20251001").context_window(), 150_000);
1927 assert_eq!(subagent_context_window(Some(200_000), "claude-haiku-4-5"), Some(200_000));
1928 assert_eq!(subagent_context_window(Some(200_001), "claude-haiku-4-5"), None);
1929 }
1930
1931 #[test]
1935 fn every_spawn_path_fits_the_context_window_after_model_selection() {
1936 for (name, source) in [
1937 ("oneshot", include_str!("oneshot.rs")),
1938 ("start", include_str!("start.rs")),
1939 ("resume", include_str!("resume.rs")),
1940 ] {
1941 let policy = source.find("super::apply_subagent_runtime_policy(").unwrap();
1942 let model = source.find("runtime.set_model(").unwrap();
1943 let fit = source.find("super::fit_subagent_context_window(&mut runtime)").unwrap();
1944 let stream = source.find("runtime.run_stream").unwrap();
1945 assert!(policy < model && model < fit && fit < stream, "{name}");
1946 assert_eq!(source.matches("super::fit_subagent_context_window(").count(), 1, "{name}");
1947 }
1948 }
1949
1950 #[test]
1953 fn every_spawn_path_attaches_worker_transcripts_before_streaming() {
1954 for (name, source) in [
1955 ("oneshot", include_str!("oneshot.rs")),
1956 ("start", include_str!("start.rs")),
1957 ("resume", include_str!("resume.rs")),
1958 ] {
1959 let policy = source.find("super::apply_subagent_runtime_policy(").unwrap();
1960 let attach = source.find("super::attach_worker_transcripts(").unwrap();
1961 let stream = source.find("runtime.run_stream").unwrap();
1962 assert!(policy < attach && attach < stream, "{name}");
1963 assert_eq!(source.matches("super::attach_worker_transcripts(").count(), 1, "{name}");
1964 }
1965 }
1966
1967 fn rolled_history() -> Vec<crate::SharedMessage> {
1968 vec![
1969 Arc::new(json!({"role":"user","content":"brief"})),
1970 Arc::new(json!({"role":"user","content":[{"type":"text","text":"note"}],
1971 "_synaps_worker_context": {"schema":"synaps-worker-context/1","window":3,
1972 "transcripts":[{"window":1,"path":"/a/sa_1.window-1.md","messages":4},
1973 {"window":2,"path":"/a/sa_1.window-2.md","messages":6}]}})),
1974 ]
1975 }
1976
1977 #[test]
1980 fn rollovers_reach_the_worker_state_and_its_panel_line() {
1981 let state = Arc::new(RwLock::new(SubagentState::new()));
1982 assert!(state.read().unwrap().context_summary().is_none(), "absent before any rollover");
1983 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1984 let mut runtime = crate::Runtime::new_headless();
1985 attach_worker_transcripts(
1986 &mut runtime,
1987 "/a".into(),
1988 "sa_9",
1989 Some(Arc::clone(&state)),
1990 &rolled_history(),
1991 Some(tx),
1992 9,
1993 "spike",
1994 );
1995 {
1996 let s = state.read().unwrap();
1997 assert_eq!(s.context_window, 3);
1998 assert_eq!(s.context_transcripts.len(), 2);
1999 let summary = s.context_summary().unwrap();
2000 assert_eq!(summary["window"], 3);
2001 assert_eq!(summary["rollovers"], 0);
2002 }
2003 let sink = runtime.worker_context().sink.clone().unwrap();
2004 assert_eq!(sink.stem, "sa_9");
2005 assert_eq!(sink.dir, std::path::PathBuf::from("/a"));
2006 let report = WorkerRolloverReport {
2007 window: 4,
2008 before_tokens: 201_000,
2009 after_tokens: 99_000,
2010 evicted_messages: 40,
2011 transcript: "/a/sa_9.window-3.md".into(),
2012 transcripts: vec![
2013 "/a/sa_1.window-1.md".into(),
2014 "/a/sa_1.window-2.md".into(),
2015 "/a/sa_9.window-3.md".into(),
2016 ],
2017 };
2018 (sink.observer.unwrap())(&report);
2019 let s = state.read().unwrap();
2020 assert_eq!((s.context_window, s.context_rollovers), (4, 1));
2021 assert_eq!(s.step, "↻ context window 4: ~201k → ~99k tokens");
2022 let summary = s.context_summary().unwrap();
2023 assert_eq!(summary["transcripts"].as_array().unwrap().len(), 3);
2024 assert_eq!(summary["rollovers"], 1);
2025 match rx.try_recv().unwrap() {
2026 crate::StreamEvent::Agent(crate::AgentEvent::SubagentUpdate { subagent_id, agent_name, status }) => {
2027 assert_eq!((subagent_id, agent_name.as_str()), (9, "spike"));
2028 assert!(status.starts_with("↻ context window 4"));
2029 }
2030 other => panic!("unexpected {other:?}"),
2031 }
2032 assert!(report.notice().contains("/a/sa_9.window-3.md"));
2033 }
2034
2035 #[test]
2038 fn archived_summary_comes_from_the_history_metadata() {
2039 let history: Vec<serde_json::Value> = rolled_history().iter().map(|m| (**m).clone()).collect();
2040 let summary = crate::runtime::subagent::archived_context_summary(&history).unwrap();
2041 assert_eq!(summary["window"], 3);
2042 assert_eq!(summary["transcripts"][1], "/a/sa_1.window-2.md");
2043 assert!(summary.get("rollovers").is_none(), "unknown for an archived run");
2044 assert!(crate::runtime::subagent::archived_context_summary(&history[..1]).is_none());
2045 }
2046}
2047
2048#[cfg(test)]
2053mod worker_context_window_wire_tests {
2054 use crate::runtime::subagent::{SubagentRegistry, SubagentStatus};
2055 use crate::tools::test_helpers::create_tool_context;
2056 use crate::tools::Tool;
2057 use axum::response::IntoResponse;
2058 use serde_json::{json, Value};
2059 use std::sync::{Arc, Mutex};
2060
2061 const MODEL: &str = "anthropic/claude-sonnet-4-6";
2063 const DONE: &str = concat!(
2064 r#"data: {"type":"message_start","message":{"id":"msg_cw","type":"message","role":"assistant","content":[],"model":"claude-opus-5-5","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"output_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}"#, "\n\n",
2065 r#"data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}"#, "\n\n",
2066 r#"data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"done"}}"#, "\n\n",
2067 r#"data: {"type":"content_block_stop","index":0}"#, "\n\n",
2068 r#"data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":10,"output_tokens":5,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}"#, "\n\n",
2069 r#"data: {"type":"message_stop"}"#, "\n\n",
2070 );
2071
2072 async fn spawn_provider() -> (String, Arc<Mutex<Vec<String>>>) {
2073 let betas: Arc<Mutex<Vec<String>>> = Arc::default();
2074 let seen = betas.clone();
2075 let app = axum::Router::new().route(
2076 "/v1/messages",
2077 axum::routing::post(move |headers: axum::http::HeaderMap| {
2078 let seen = seen.clone();
2079 async move {
2080 let beta = headers.get("anthropic-beta").and_then(|v| v.to_str().ok());
2081 seen.lock().unwrap().push(beta.unwrap_or_default().to_string());
2082 ([("content-type", "text/event-stream")], DONE).into_response()
2083 }
2084 }),
2085 );
2086 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2087 let addr = listener.local_addr().unwrap();
2088 tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
2089 (format!("http://{addr}"), betas)
2090 }
2091
2092 async fn finish(registry: &Arc<Mutex<SubagentRegistry>>, handle_id: &str) {
2093 for _ in 0..600 {
2094 let done = registry
2095 .lock()
2096 .unwrap()
2097 .get(handle_id)
2098 .is_none_or(|h| h.status() != SubagentStatus::Running);
2099 if done {
2100 break;
2101 }
2102 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2103 }
2104 let thread = registry.lock().unwrap().get_mut(handle_id).and_then(|h| h.thread_handle.take());
2105 if let Some(thread) = thread {
2106 tokio::task::spawn_blocking(move || thread.join()).await.unwrap().unwrap();
2107 }
2108 let status = registry.lock().unwrap().get(handle_id).map(|h| h.status());
2109 assert_eq!(status, Some(SubagentStatus::Completed), "{handle_id}");
2110 }
2111
2112 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2113 #[serial_test::serial(synaps_base_dir)]
2114 async fn every_spawn_path_sends_the_configured_1m_window() {
2115 let base = crate::test_env::BaseDirGuard::new();
2116 std::fs::write(
2117 base.path().join("auth.json"),
2118 r#"{"anthropic": {"type": "oauth", "refresh": "synthetic", "access": "synthetic-access", "expires": 9999999999999}}"#,
2119 )
2120 .unwrap();
2121 std::fs::write(base.path().join("config"), "context_window = 1m\n").unwrap();
2122 let (url, betas) = spawn_provider().await;
2123 let _guards = [
2124 crate::test_env::EnvVarGuard::remove("SYNAPS_AUTH_ENDPOINT"),
2125 crate::test_env::EnvVarGuard::remove("SYNAPS_MACHINE_TOKEN"),
2126 crate::test_env::EnvVarGuard::remove("SYNAPS_SUBAGENT_FRESH_RUNTIME"),
2127 crate::test_env::EnvVarGuard::remove("ANTHROPIC_API_KEY"),
2128 crate::test_env::EnvVarGuard::set("SYNAPS_ANTHROPIC_BASE_URL", &url),
2129 ];
2130 let registry = Arc::new(Mutex::new(SubagentRegistry::new()));
2131 let ctx = || {
2132 let mut ctx = create_tool_context();
2133 ctx.capabilities.subagent_registry = Some(registry.clone());
2134 ctx
2135 };
2136 let task = json!({"system_prompt": "test worker", "task": "run it", "model": MODEL, "timeout": 60});
2137
2138 super::SubagentTool.execute(task.clone(), ctx()).await.expect("subagent");
2139 let out = super::SubagentStartTool.execute(task, ctx()).await.expect("subagent_start");
2140 let handle = serde_json::from_str::<Value>(&out).unwrap()["handle_id"].as_str().unwrap().to_owned();
2141 finish(®istry, &handle).await;
2142 let out = super::SubagentResumeTool
2143 .execute(json!({"handle_id": handle, "instructions": "again", "timeout": 60}), ctx())
2144 .await
2145 .expect("subagent_resume");
2146 let resumed = serde_json::from_str::<Value>(&out).unwrap()["handle_id"].as_str().unwrap().to_owned();
2147 finish(®istry, &resumed).await;
2148
2149 let betas = betas.lock().unwrap().clone();
2150 assert_eq!(betas.len(), 3, "oneshot, start, resume: {betas:?}");
2151 for (path, beta) in ["oneshot", "start", "resume"].iter().zip(&betas) {
2152 assert!(beta.contains("context-1m-2025-08-07"), "{path}: {beta:?}");
2153 }
2154 }
2155}
2156
2157#[cfg(test)]
2160#[path = "../../../tests/support/worker_wire.rs"]
2161mod worker_wire;
2162
2163#[cfg(all(test, unix, feature = "command-exec"))]
2168mod worker_tool_keepwarm_tests {
2169 use crate::runtime::cache_sim::{self, Req, Sim};
2170 use crate::runtime::subagent::{SubagentRegistry, SubagentStatus};
2171 use crate::runtime::tool_keepwarm::testing::IntervalOverride;
2172 use crate::test_env::{BaseDirGuard, EnvVarGuard};
2173 use crate::tools::test_helpers::create_tool_context;
2174 use crate::tools::Tool;
2175 use serde_json::{json, Value};
2176 use std::sync::{Arc, Mutex};
2177
2178 fn task() -> Value {
2179 let prompt = format!("test worker\n{}", "padding ".repeat(3000));
2180 json!({"system_prompt": prompt, "task": "run it", "model": "anthropic/claude-sonnet-4-6", "timeout": 60})
2181 }
2182
2183 fn ctx(registry: &Arc<Mutex<SubagentRegistry>>) -> crate::tools::ToolContext {
2184 let mut ctx = create_tool_context();
2185 ctx.capabilities.subagent_registry = Some(registry.clone());
2186 ctx
2187 }
2188
2189 async fn finish(registry: &Arc<Mutex<SubagentRegistry>>, handle_id: &str) {
2190 for _ in 0..600 {
2191 let done = registry
2192 .lock()
2193 .unwrap()
2194 .get(handle_id)
2195 .is_none_or(|h| h.status() != SubagentStatus::Running);
2196 if done {
2197 break;
2198 }
2199 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2200 }
2201 let thread = registry.lock().unwrap().get_mut(handle_id).and_then(|h| h.thread_handle.take());
2202 if let Some(thread) = thread {
2203 tokio::task::spawn_blocking(move || thread.join()).await.unwrap().unwrap();
2204 }
2205 let status = registry.lock().unwrap().get(handle_id).map(|h| h.status());
2206 assert_eq!(status, Some(SubagentStatus::Completed), "{handle_id}");
2207 }
2208
2209 async fn oneshot(sim: &Arc<Mutex<Sim>>) -> Vec<Req> {
2210 let registry = Arc::new(Mutex::new(SubagentRegistry::new()));
2211 let out = super::SubagentTool.execute(task(), ctx(®istry)).await.expect("subagent");
2212 assert!(out.contains("done"), "{out}");
2213 cache_sim::take(sim)
2214 }
2215
2216 async fn run_three_paths(sim: &Arc<Mutex<Sim>>) -> [Vec<Req>; 3] {
2218 let registry = Arc::new(Mutex::new(SubagentRegistry::new()));
2219 let oneshot = oneshot(sim).await;
2220 let out = super::SubagentStartTool.execute(task(), ctx(®istry)).await.expect("subagent_start");
2221 let handle = serde_json::from_str::<Value>(&out).unwrap()["handle_id"].as_str().unwrap().to_owned();
2222 finish(®istry, &handle).await;
2223 let start = cache_sim::take(sim);
2224 let out = super::SubagentResumeTool
2225 .execute(json!({"handle_id": handle, "instructions": "again", "timeout": 60}), ctx(®istry))
2226 .await
2227 .expect("subagent_resume");
2228 let resumed = serde_json::from_str::<Value>(&out).unwrap()["handle_id"].as_str().unwrap().to_owned();
2229 finish(®istry, &resumed).await;
2230 [oneshot, start, cache_sim::take(sim)]
2231 }
2232
2233 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2234 #[serial_test::serial(synaps_base_dir)]
2235 async fn every_spawn_path_keeps_a_long_tool_round_warm_without_a_host() {
2236 assert!(crate::EngineHost::current().is_none(), "the no-host fallback");
2237 let base = BaseDirGuard::new();
2238 std::fs::write(base.path().join("auth.json"), super::worker_wire::OAUTH_AUTH_JSON).unwrap();
2239 std::fs::write(base.path().join("config"), "").unwrap();
2240 let (url, sim) = cache_sim::spawn(
2241 cache_sim::Script::kept_warm(2),
2242 "bash",
2243 json!({"command": cache_sim::WORKER_COMMAND}),
2244 )
2245 .await;
2246 let _env = [
2247 EnvVarGuard::remove("SYNAPS_AUTH_ENDPOINT"),
2248 EnvVarGuard::remove("SYNAPS_MACHINE_TOKEN"),
2249 EnvVarGuard::remove("SYNAPS_SUBAGENT_FRESH_RUNTIME"),
2250 EnvVarGuard::remove("ANTHROPIC_API_KEY"),
2251 EnvVarGuard::set("SYNAPS_ANTHROPIC_BASE_URL", &url),
2252 ];
2253 let _interval = IntervalOverride::set(cache_sim::WORKER_INTERVAL);
2254
2255 let runs = run_three_paths(&sim).await;
2256 for (path, requests) in ["oneshot", "start", "resume"].iter().zip(&runs) {
2257 cache_sim::assert_worker_kept_warm(&format!("no-host {path}"), requests);
2258 }
2259 tokio::time::sleep(cache_sim::WORKER_INTERVAL * 3).await;
2261 assert!(cache_sim::take(&sim).is_empty(), "a ping after the workers");
2262
2263 std::fs::write(base.path().join("config"), "cache_keepwarm_tools = off\n").unwrap();
2265 sim.lock().unwrap().script = cache_sim::Script::outlives_ttl();
2266 cache_sim::assert_worker_rewrites("no-host off", &oneshot(&sim).await);
2267 }
2268}
2269
2270#[cfg(test)]
2274mod worker_inherit_wire_tests {
2275 use super::worker_wire::{self, Seen};
2276 use crate::runtime::subagent::{SubagentRegistry, SubagentStatus};
2277 use crate::test_env::{BaseDirGuard, EnvVarGuard};
2278 use crate::tools::test_helpers::create_tool_context;
2279 use crate::tools::Tool;
2280 use serde_json::{json, Value};
2281 use std::sync::{Arc, Mutex};
2282
2283 fn task() -> Value {
2284 let prompt = format!("test worker\n{}", "padding ".repeat(3000));
2286 json!({"system_prompt": prompt, "task": "run it", "model": "anthropic/claude-sonnet-4-6", "timeout": 60})
2287 }
2288
2289 fn ctx(registry: &Arc<Mutex<SubagentRegistry>>) -> crate::tools::ToolContext {
2290 let mut ctx = create_tool_context();
2291 ctx.capabilities.subagent_registry = Some(registry.clone());
2292 ctx
2293 }
2294
2295 fn take(seen: &Arc<Mutex<Vec<Seen>>>) -> Vec<Seen> {
2296 std::mem::take(&mut *seen.lock().unwrap())
2297 }
2298
2299 async fn finish(registry: &Arc<Mutex<SubagentRegistry>>, handle_id: &str) {
2300 for _ in 0..600 {
2301 let done = registry
2302 .lock()
2303 .unwrap()
2304 .get(handle_id)
2305 .is_none_or(|h| h.status() != SubagentStatus::Running);
2306 if done {
2307 break;
2308 }
2309 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2310 }
2311 let thread = registry.lock().unwrap().get_mut(handle_id).and_then(|h| h.thread_handle.take());
2312 if let Some(thread) = thread {
2313 tokio::task::spawn_blocking(move || thread.join()).await.unwrap().unwrap();
2314 }
2315 let status = registry.lock().unwrap().get(handle_id).map(|h| h.status());
2316 assert_eq!(status, Some(SubagentStatus::Completed), "{handle_id}");
2317 }
2318
2319 async fn oneshot(seen: &Arc<Mutex<Vec<Seen>>>) -> Vec<Seen> {
2320 let registry = Arc::new(Mutex::new(SubagentRegistry::new()));
2321 let out = super::SubagentTool.execute(task(), ctx(®istry)).await.expect("subagent");
2322 assert!(out.contains("done"), "{out}");
2323 take(seen)
2324 }
2325
2326 async fn run_three_paths(seen: &Arc<Mutex<Vec<Seen>>>) -> [Vec<Seen>; 3] {
2328 let registry = Arc::new(Mutex::new(SubagentRegistry::new()));
2329 let oneshot = oneshot(seen).await;
2330 let out = super::SubagentStartTool.execute(task(), ctx(®istry)).await.expect("subagent_start");
2331 let handle = serde_json::from_str::<Value>(&out).unwrap()["handle_id"].as_str().unwrap().to_owned();
2332 finish(®istry, &handle).await;
2333 let start = take(seen);
2334 let out = super::SubagentResumeTool
2335 .execute(json!({"handle_id": handle, "instructions": "again", "timeout": 60}), ctx(®istry))
2336 .await
2337 .expect("subagent_resume");
2338 let resumed = serde_json::from_str::<Value>(&out).unwrap()["handle_id"].as_str().unwrap().to_owned();
2339 finish(®istry, &resumed).await;
2340 [oneshot, start, take(seen)]
2341 }
2342
2343 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2344 #[serial_test::serial(synaps_base_dir)]
2345 async fn every_spawn_path_inherits_disabled_tools_diagnostics_and_gzip_without_a_host() {
2346 assert!(crate::EngineHost::current().is_none(), "the no-host fallback");
2347 let base = BaseDirGuard::new();
2348 std::fs::write(base.path().join("auth.json"), worker_wire::OAUTH_AUTH_JSON).unwrap();
2349 std::fs::write(base.path().join("config"), worker_wire::LOCKED_CONFIG).unwrap();
2350 let marker = base.path().join("bash-ran");
2351 let (url, seen) = worker_wire::spawn_provider(&marker, false).await;
2352 let _env = [
2353 EnvVarGuard::remove("SYNAPS_AUTH_ENDPOINT"),
2354 EnvVarGuard::remove("SYNAPS_MACHINE_TOKEN"),
2355 EnvVarGuard::remove("SYNAPS_SUBAGENT_FRESH_RUNTIME"),
2356 EnvVarGuard::remove("SYNAPS_GZIP_REQUEST_BODIES"),
2357 EnvVarGuard::remove("ANTHROPIC_API_KEY"),
2358 EnvVarGuard::set("SYNAPS_ANTHROPIC_BASE_URL", &url),
2359 ];
2360
2361 let runs = run_three_paths(&seen).await;
2362 worker_wire::assert_worker_requests("no-host", &runs, &marker);
2363
2364 std::fs::write(
2368 base.path().join("config"),
2369 worker_wire::LOCKED_CONFIG.replace("cache_diagnostics = true", "cache_diagnostics = false"),
2370 )
2371 .unwrap();
2372 let plain = oneshot(&seen).await;
2373 assert_eq!(plain.len(), 2);
2374 for (on, off) in runs[0].iter().zip(&plain) {
2375 assert!(off.previous_message_id().is_none(), "off: no object");
2376 assert_eq!(on.without_diagnostics(), off.body, "prefix moved");
2377 }
2378
2379 std::fs::write(base.path().join("config"), worker_wire::LOCKED_CONFIG).unwrap();
2381 let (url, seen) = worker_wire::spawn_provider(&marker, true).await;
2382 let _url = EnvVarGuard::set("SYNAPS_ANTHROPIC_BASE_URL", &url);
2383 worker_wire::assert_diagnostics_refusal_falls_back(&oneshot(&seen).await);
2384 assert!(!marker.exists());
2385
2386 if cfg!(all(unix, feature = "command-exec")) {
2389 std::fs::write(base.path().join("config"), "disabled_tools = ls, grep\n").unwrap();
2390 let (url, seen) = worker_wire::spawn_provider(&marker, false).await;
2391 let _url = EnvVarGuard::set("SYNAPS_ANTHROPIC_BASE_URL", &url);
2392 let requests = oneshot(&seen).await;
2393 assert!(requests[0].tool_names().iter().any(|n| n == "bash"));
2394 assert!(!requests[1].body["messages"].to_string().contains("Unknown tool"));
2395 assert!(marker.exists(), "an allowed bash runs");
2396 }
2397 }
2398}