Skip to main content

agent_engine/tools/subagent/
mod.rs

1//! Subagent tools — oneshot and reactive (start/status/steer/collect/resume).
2
3pub(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
24/// Longest display name kept for an inline subagent.
25const MAX_LABEL_CHARS: usize = 32;
26
27/// Label for a subagent in the panel, status/collect output, completion
28/// events and the oneshot log file name. A named agent is labelled by its
29/// agent name; an inline (`system_prompt`) agent by its optional `name`,
30/// falling back to "inline".
31pub(crate) fn subagent_label(agent: Option<&str>, name: Option<&str>) -> String {
32    if let Some(agent) = agent {
33        // `resolve_agent_prompt` reads any agent containing '/' as a file
34        // path; label those by the file stem, not the whole path.
35        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
48/// Log file for a finished oneshot subagent:
49/// `<log_dir>/<timestamp>-<label>[-error].md`. Any label character outside
50/// letters, digits, `-` and `_` becomes `-`, so the file always lands directly
51/// in `log_dir` (a `plugin:agent` label or a stray separator can't redirect it).
52pub(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
72/// Reduce a caller-chosen name to `[A-Za-z0-9_-]` (whitespace becomes `-`),
73/// capped at [`MAX_LABEL_CHARS`]. The label ends up in a log file name and in
74/// event text, so nothing else gets through. `None` when nothing is left.
75fn 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/// The parent session's working directory and environment, handed to every
95/// worker it spawns. `Default` (all empty) = process cwd/env, the in-process
96/// behaviour.
97#[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
114/// Apply the subagent-spawn credential policy to a freshly-created `Runtime`
115/// (which has already had `Runtime::new()` called), then **unconditionally
116/// force** the cache TTL to `FiveMinutes`.
117///
118/// Subagents are short-lived one-shots. Paying the 1h-cache write premium
119/// (~2× input price) on them is unrecoverable waste — a 10-spawn fan-out
120/// costs ~$0.23 extra per session when the parent config opts into `1h` or
121/// `hybrid`. This function is the single enforcement point; all three spawn
122/// paths (`oneshot`, `start`, `resume`) call it so the policy cannot regress.
123///
124/// Called immediately after `Runtime::new()` in each spawn path, before any
125/// streaming starts.
126pub(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    // Workers run where their parent session runs. Under the daemon the
135    // process cwd/env belong to the daemon, not the session; without this
136    // every worker tool resolved paths and spawned commands in the daemon's
137    // directory. Set before the memory fallback below so it scopes to the
138    // session's project too.
139    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    // Record at the parent's telemetry level, through the parent's session
146    // writer. Workers start `Off` (`Runtime::from_parts`); without this no
147    // subagent request ever reached `api-log.jsonl`. `None` stays `Off`.
148    if let Some(parent) = telemetry {
149        runtime.inherit_telemetry(parent);
150    }
151    runtime.apply_retry_config(config);
152    // Tool output virtualisation: a worker spills big results into its own
153    // dir (keyed by its runtime session) and can `read`/`grep` them back.
154    runtime.set_tool_spill_policy(crate::runtime::tool_spill::ToolSpillPolicy::from_config(
155        config,
156    ));
157    runtime.set_read_dedupe(config.read_dedupe);
158    // Keep the 5m cache warm while a tool round runs (`cache_keepwarm_tools`):
159    // a worker's builds, test sweeps and poll loops routinely outlast the
160    // TTL pinned below, and their 300s default `bash_max_timeout` equals it.
161    runtime.set_tool_keepwarm(config.cache_keepwarm_tools);
162    // Tool limits, as the foreground gets them through `apply_config`.
163    // Without them a worker ran every command under the 30s/300s defaults and
164    // truncated tool output at 30000 whatever the operator configured.
165    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    // Cache diagnostics: the worker's own chain (a `from_parts` runtime has a
169    // fresh `previous_message_id` and rejection latch), API-key auth only for
170    // the beta header, and the same fail-soft retry as the foreground.
171    runtime.set_cache_diagnostics(config.cache_diagnostics);
172    // gzip request bodies, sharing the process-wide refusal latch.
173    runtime.set_request_compression(config.request_compression, config.request_compression_level);
174    // Output transforms: the spawn path gave the worker the host's
175    // after_tool_call transform view (`EngineHost::worker_hook_bus`); the
176    // operator can turn it off. Detaching only ever narrows: no spawn path
177    // hands a worker the full host bus.
178    if !config.subagent_transform_hooks {
179        runtime.set_hook_bus(std::sync::Arc::new(crate::extensions::hooks::HookBus::new()));
180    }
181
182    // Credential source / token cache: host-built workers already share the
183    // process-wide broker (`spawn_runtime`); only the legacy fresh-runtime
184    // path re-applies auth config there. (#158 A3 → engine-host B2)
185
186    // The parent's binding, else one for the worker's folder: a worker acts
187    // on its parent's project, wherever its own folder is. The common
188    // inherit path forks execution authorship, not authority: it never
189    // expands the worker tool registry.
190    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    // Policy: subagent spawns are always 5m cache TTL regardless of what the
205    // parent session configured. Load-bearing: `Runtime::new()` defaults to
206    // `cache_ttl = auto`, which resolves to 1h on OAuth credentials; this
207    // explicit call pins workers to a fixed 5m (#110) so neither that
208    // default nor a future `apply_config` addition can silently change it.
209    runtime.set_cache_ttl(crate::core::config::CacheTtl::FiveMinutes);
210
211    // Task 23: workers run under the WORKER turn budget (typed config
212    // overrides applied); the single policy point keeps all three spawn
213    // paths identical.
214    runtime.set_turn_budget(crate::runtime::budget::TurnBudget::from_config(
215        crate::runtime::budget::TurnRole::Worker,
216        &config.turn_budgets,
217    ));
218
219    // Automatic context rollover (`subagent_context_management`,
220    // `subagent_rollover_tokens`). Inert until the spawn path attaches the
221    // worker's transcript sink (`attach_worker_transcripts`).
222    runtime.set_worker_context_settings(Some(
223        crate::runtime::worker_context::WorkerContextSettings::from_config(config),
224    ));
225
226    // The configured `context_window`, as the foreground gets it through
227    // `apply_config`. Without it every worker budgeted against the 200k
228    // model default and never sent the 1M opt-in, so on Opus the output
229    // reserve alone put it at hard capacity near 32k (#332 rollover thrash).
230    // The model is not chosen yet: `fit_subagent_context_window` drops a
231    // window the worker's model cannot open once it is.
232    runtime.set_context_window(config.context_window);
233}
234
235/// The window a worker on `model` runs with, given the configured
236/// `context_window`. The setting is one per user, not per model, so a worker
237/// on any model gets it, as the foreground keeps it across `/model`, except a
238/// window above the model default on a model without 1M support: the request
239/// builder never sends that model the 1M opt-in, so the provider window is
240/// the default one, and the worker must budget against that.
241pub(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
246/// Called after model selection by start, oneshot AND resume: fit the window
247/// `apply_subagent_runtime_policy` inherited to the model the worker runs on
248/// (an agent file or the call can pin a model other than the parent's).
249pub(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/// Give a worker somewhere to keep the context it rolls over: its archive
255/// directory (`<handle>.window-<N>.md` beside the resume archive), and wire
256/// each rollover into its status (`state`) and subagent panel line. Called
257/// by every spawn path after `apply_subagent_runtime_policy`, before the run
258/// starts. A resumed worker starts at its archived window.
259#[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
302/// Called after model selection by start, oneshot AND resume. Only inherit
303/// Ultra for the exact authorized foreground identity: explicitly selected
304/// different models/providers retain their own defaults. The worker planner
305/// revalidates current capability data and lowers Ultra to its wire effort;
306/// Worker role keeps proactive delegation disabled regardless of selection.
307pub(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
322/// Resolve once at admission, before allocating a handle. Resume preserves the
323/// recorded level; old archives without it use today's independent worker default.
324pub(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
344/// Last reasoning mutation before dispatch: neither model defaults nor a
345/// foreground Ultra inheritance may override the worker's selected effort.
346pub(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
353/// Kill-switch: `SYNAPS_SUBAGENT_FRESH_RUNTIME=1` restores the pre-engine-host
354/// spawn path (fresh `Runtime::new()`, fresh HTTP client, registry rebuilt,
355/// global broker re-installed with a fresh token cache on every spawn).
356pub fn legacy_fresh_runtime() -> bool {
357    std::env::var("SYNAPS_SUBAGENT_FRESH_RUNTIME").is_ok_and(|v| v == "1")
358}
359
360/// Build the runtime a subagent runs on. Preferred: `EngineHost::worker_runtime()`
361/// — shares the host credential source and token cache (so NO
362/// `set_global_broker` re-install and NO token-cache eviction per spawn) and
363/// takes a clone of the cached worker registry template. Legacy path (no host
364/// installed, or kill-switch set): today's `Runtime::new()` + rebuilt tools +
365/// `apply_auth_config`, verbatim.
366///
367/// Both paths run with the host's worker hook view
368/// (`EngineHost::worker_hook_bus`: its `after_tool_call` output transforms,
369/// nothing else). Without an installed host there are no host subscriptions
370/// to see, and the legacy runtime keeps its own empty bus.
371///
372/// Called from the subagent's own OS thread / current-thread tokio runtime:
373/// `EngineHost::current()` is a `OnceLock` read and `worker_runtime()` only
374/// touches `tokio::sync` primitives, so this is runtime-agnostic.
375pub 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    // The host's config when there is one (the source of its foreground
383    // lockdown and of `worker_runtime`), else the disk.
384    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
397/// Build the subagent tool registry: extension tools if the routing manager
398/// has a shared registry, otherwise the bare without_subagent set, minus
399/// every built-in in `disabled` (`disabled_tools`; the legacy twin of
400/// `EngineHost::worker_registry`, see `ToolRegistry::worker_set`).
401///
402/// Single source of truth for all three spawn paths (oneshot, start, resume).
403/// Divergence is structurally impossible when all three call this function.
404pub(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
417/// The config one spawn reads, once, at admission: the worker's thinking
418/// level, `apply_subagent_runtime_policy` and (resume) the cold-resume
419/// compact size all come from this one snapshot, never from a second read
420/// partway through the spawn.
421///
422/// Read from disk, not `EngineHost::config()`: nothing refreshes the host's
423/// snapshot after boot in production (`reload_config` has no caller), and
424/// every `/settings` row that says "applies to workers started afterwards"
425/// only writes the file. The registry's `disabled_tools` stays on the host
426/// snapshot, the boot-time lockdown the foreground runs under.
427pub(crate) fn spawn_config() -> std::sync::Arc<crate::config::SynapsConfig> {
428    std::sync::Arc::new(crate::config::load_config())
429}
430
431/// Compose the final system prompt for every subagent spawn, including resume.
432/// A non-empty `<base>/subagent-preamble.md` is prepended when readable (the
433/// way to tell every worker something, e.g. an extension's forum); a missing,
434/// unreadable or empty preamble leaves the agent prompt as it is. Any IO
435/// error is ignored. Never panics.
436pub(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(&params, 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    /// `role` and `write_policy` were advertised but never read: dispatch
520    /// always registered an implementer and nothing enforced a write policy.
521    #[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    /// Verify that `apply_subagent_runtime_policy` forces `FiveMinutes` even
556    /// when the parent session has `cache_ttl = OneHour` in its config.
557    ///
558    /// This uses a real async `Runtime` (spawned on a single-threaded Tokio
559    /// runtime) to confirm the full derivation path: `Runtime::new()` →
560    /// `apply_config(1h parent)` → `apply_subagent_runtime_policy` →
561    /// TTL must be `FiveMinutes`.
562    ///
563    /// The OLD code path (`apply_auth_config` only, no forced TTL) would
564    /// leave the TTL at `OneHour` when `apply_config` is also called — this
565    /// test is the regression guard for that scenario.
566    #[tokio::test]
567    async fn subagent_policy_forces_five_minutes_even_when_parent_is_one_hour() {
568        // Build a config representing a parent session with 1h cache TTL.
569        let parent_config = crate::config::SynapsConfig {
570            cache_ttl: CacheTtl::OneHour.into(),
571            ..Default::default()
572        };
573
574        // Create a fresh runtime (as each subagent spawn does).
575        let mut runtime = crate::Runtime::new()
576            .await
577            .expect("Runtime::new() must succeed in test environment");
578
579        // Simulate a scenario where apply_config was called with a 1h parent
580        // config (realistic if a future refactor wires apply_config instead of
581        // apply_auth_config). This is the "before" state the policy must override.
582        runtime.set_cache_ttl(CacheTtl::OneHour);
583
584        // Confirm the runtime IS at 1h before we apply the policy.
585        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 the subagent runtime policy — this is what the spawn paths call.
592        apply_subagent_runtime_policy(&mut runtime, &parent_config, None, None, None, &Default::default());
593
594        // Post-condition: TTL must be FiveMinutes regardless of parent config.
595        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        // Simulate the parent having configured Hybrid on this runtime.
615        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    /// `cache_ttl = auto` (the default) resolves to 1h on OAuth credentials.
654    /// Workers keep #110's forced 5m regardless: the policy replaces the
655    /// inherited `auto` with a fixed 5m, so even an OAuth-bound worker sends
656    /// 5m markers.
657    #[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    /// Workers virtualise big tool results like the foreground, into a dir
701    /// of their own (keyed by the worker's runtime trace session, since a
702    /// worker has no conversation id), under the bound `<base>/spill`.
703    #[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        // `0` keeps a worker on the legacy path.
718        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    /// Workers dedupe re-reads like the foreground, from the same key.
724    #[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    /// Workers keep their cache warm through long tool rounds, from the
737    /// foreground's key (default on).
738    #[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    /// G3/G4/G5/G6: a worker runs under the configured bash timeouts, tool
751    /// output cap, cache diagnostics and gzip policy, not the `Runtime`
752    /// defaults (30s / 300s / 30000 / off / auto level 1).
753    #[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        // The worker's diagnostics chain is its own, starting empty.
779        let chain = runtime.cache_diagnosis().expect("on");
780        assert_eq!(chain.previous_message_id(), None);
781        // Back to the defaults when the config says so (no stickiness).
782        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    /// `subagent_transform_hooks`: on (the default) keeps the host's
797    /// transform view the spawn path installed; off detaches it, so the
798    /// worker's tool results reach history untransformed.
799    #[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    /// The production IO seam, against a base directory of the test's own:
820    /// the prompt is the agent's text, then the preamble file and the
821    /// agent's text, and core adds nothing of its own about a forum (the
822    /// guidance it used to append is gone; a preamble may name any tool).
823    #[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    /// Worker prompts are the agent's own text: the stock default prompt
862    /// (and its steering) never leaks into them.
863    #[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        // Consent arriving AFTER worker creation must reach its headless hooks
1003        // — but only for the extension the user allowed.
1004        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        // A worker spawned after consent inherits the same live set too.
1045        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    /// The stream loop's serial and parallel lane each making a call's
1060    /// context from the turn's one tool context.
1061    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        // Both serial and parallel streaming dispatch must supply the parent
1069        // capability; worker-policy tests alone would miss a disconnected wire.
1070        // The turn's one tool context carries it and both lanes make their
1071        // calls' contexts from that.
1072        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            // Formatting-agnostic: the call may be wrapped across lines.
1086            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    /// A worker records at its parent's level through the parent's session
1099    /// writer (one writer per session, never one per worker) under its own
1100    /// trace session. Before this, workers stayed `Off` and every subagent
1101    /// request was missing from `api-log.jsonl`.
1102    #[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        // One writer, not two: what the worker enqueues, the parent's handle
1142        // flushes.
1143        worker
1144            .request_telemetry()
1145            .expect("worker records")
1146            .enqueue_telemetry(TelemetryRecord {
1147                ts: 1,
1148                worker: true,
1149                ..Default::default()
1150            });
1151        // The parent's own requests carry its persisted conversation id.
1152        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        // The worker-tagged handle stamps who made the request and the
1171        // parent's ids; the parent's own `session_id` is NOT the worker's.
1172        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        // Manual contexts pass no telemetry: still Off.
1205        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    /// Output transforms reach every worker: oneshot, start and resume
1212    /// each build their runtime ONLY through `spawn_runtime` (whose host
1213    /// and legacy fresh-runtime branches both install the host's transform
1214    /// view) and apply the policy (the opt-out) before streaming. Nothing
1215    /// else in a spawn path constructs a runtime or swaps its hook bus.
1216    #[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    /// G2-lite + G1, by source: every spawn path reads its config ONCE,
1250    /// through `spawn_config()`, and hands that one snapshot to both the
1251    /// effort resolution and the runtime policy. Both `spawn_runtime`
1252    /// branches build the worker registry minus the whole `disabled_tools`
1253    /// list of the host's config (the disk only with no host).
1254    #[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 = &registry[..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        // Both streaming dispatch sites must hand the parent's telemetry to
1298        // tools (through the turn's one tool context), and every spawn path
1299        // must pass it into the worker policy.
1300        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            // Labelled with the worker's agent + handle before the spawn.
1312            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    // FINDING: all_launch_paths_use_common_author_registry_and_prompt_wiring
1392    // Dev's oneshot/start/resume don't call super::subagent_tools() — the
1393    // tool registry is set by Runtime::new() or apply_subagent_runtime_policy.
1394    // The upstream source-scanning assertion is not valid on dev.
1395
1396    #[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    /// G1, legacy path: the whole `disabled_tools` list never reaches a
1415    /// worker's registry or schema.
1416    #[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        // Not vacuous: without the list a worker has them.
1422        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    /// A worker acts on its parent's project, wherever its own folder is,
1437    /// and a later `apply_config` on it does not rebind it to that folder.
1438    /// With no parent binding it is bound to its own folder's project.
1439    #[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        // T-B9: the worker's `memory_store` writes under its parent's key,
1474        // before and after `apply_config` runs on the worker.
1475        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        // A parent with no project: the worker has none either, and none
1501        // is found for it from its own folder.
1502        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        // Plain and namespaced names are not paths and stay as they are.
1529        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        // A cut that lands on a separator leaves no trailing dash.
1587        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/// Workers run in the PARENT SESSION's directory and environment, not the
1617/// process's. Under the daemon the process cwd/env are the daemon's; these
1618/// tests make the session cwd a temp dir distinct from the test process cwd
1619/// and check what a worker's `bash` actually sees, through a loopback
1620/// provider that asks each worker to run one command.
1621#[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    /// Git Bash (MSYS) `pwd -P` prints an MSYS path (`/c/Users/x`, or
1637    /// `/tmp/x` under its temp mount); `pwd -W` prints the native one.
1638    #[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    /// Every worker turn that does not end in a tool result gets one `bash`
1653    /// call; a tool result gets "done". Returns the base URL and every bash
1654    /// result the workers sent back, in order.
1655    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    /// A parent context whose session runs in `dir`, with a session env
1732    /// snapshot that carries `PROBE` and not the process-only var.
1733    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        // The recorded tool_result content is JSON: a quoted, escaped string.
1751        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(), &registry)).await;
1807        finish(&registry, &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(), &registry),
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    /// A resumed worker continues in the directory it was started in (its
1832    /// history refers to paths there), even when resumed from elsewhere.
1833    #[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(), &registry)).await;
1841        finish(&registry, &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(), &registry),
1847            )
1848            .await
1849            .expect("subagent_resume");
1850        let body: Value = serde_json::from_str(&out).unwrap();
1851        finish(&registry, 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    /// The single policy point turns worker rollover on from config (default
1870    /// auto, 200k) without disturbing the forced 5m TTL (#110); off stays off.
1871    #[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    /// Workers get the configured `context_window` (the foreground gets it
1898    /// from `apply_config`); without it they all budgeted against the 200k
1899    /// default, where an Opus worker's reserves leave ~32k. A model
1900    /// that cannot open the window (no 1M support: the request builder never
1901    /// sends it the opt-in) keeps its default instead.
1902    #[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        // An agent file can pin a model the parent's window does not fit.
1919        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        // `auto` (unset) stays the model default; a smaller window always fits.
1924        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    /// Every spawn path fits the inherited window after choosing the model
1932    /// and before the run, exactly once (the policy runs before the model is
1933    /// known).
1934    #[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    /// Every spawn path gives its worker a transcript sink, after the policy
1951    /// and before the run, exactly once.
1952    #[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    /// A resumed rolled worker reports its archived window from the start;
1978    /// each rollover updates its state, panel line and status summary.
1979    #[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    /// Status and collect of a reaped rolled worker read the summary from its
2036    /// archive; a never-rolled archive has none (outputs unchanged).
2037    #[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/// The live bug end to end: with `context_window = 1m` configured, a worker
2049/// from every spawn path (oneshot, start, resume) sends the 1M opt-in, as the
2050/// foreground does. Before the fix none did, through a loopback provider that
2051/// records each request's `anthropic-beta` header.
2052#[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    /// The harness's foreground model (its catalog admits no other); 1M-capable.
2062    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(&registry, &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(&registry, &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/// The loopback provider and wire assertions the host-path twin
2158/// (`tests/worker_config_inherit.rs`) shares.
2159#[cfg(test)]
2160#[path = "../../../tests/support/worker_wire.rs"]
2161mod worker_wire;
2162
2163/// Tool-round keep-warm (`cache_keepwarm_tools`) reaches the worker of every
2164/// spawn path with no `EngineHost`: a `bash` call that outlives the cache
2165/// TTL is kept warm and the request after it reads the prefix; with the key
2166/// off the same worker rewrites it.
2167#[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(&registry)).await.expect("subagent");
2212        assert!(out.contains("done"), "{out}");
2213        cache_sim::take(sim)
2214    }
2215
2216    /// The requests of the oneshot, start and resume workers, in that order.
2217    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(&registry)).await.expect("subagent_start");
2221        let handle = serde_json::from_str::<Value>(&out).unwrap()["handle_id"].as_str().unwrap().to_owned();
2222        finish(&registry, &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(&registry))
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(&registry, &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        // Stopped with the rounds: nothing once the workers are done.
2260        tokio::time::sleep(cache_sim::WORKER_INTERVAL * 3).await;
2261        assert!(cache_sim::take(&sim).is_empty(), "a ping after the workers");
2262
2263        // Control: the key off, the same worker rewrites its prefix.
2264        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/// What the worker of every spawn path puts on the wire under a locked-down,
2271/// diagnostics-on, gzip-on config, with no `EngineHost` (the `load_config()`
2272/// fallback; the host paths are `tests/worker_config_inherit.rs`).
2273#[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        // Over `MIN_BYTES`, so gzip applies whatever the tool set.
2285        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(&registry)).await.expect("subagent");
2322        assert!(out.contains("done"), "{out}");
2323        take(seen)
2324    }
2325
2326    /// The requests of the oneshot, start and resume workers, in that order.
2327    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(&registry)).await.expect("subagent_start");
2331        let handle = serde_json::from_str::<Value>(&out).unwrap()["handle_id"].as_str().unwrap().to_owned();
2332        finish(&registry, &handle).await;
2333        let start = take(seen);
2334        let out = super::SubagentResumeTool
2335            .execute(json!({"handle_id": handle, "instructions": "again", "timeout": 60}), ctx(&registry))
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(&registry, &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        // G5: the cache prefix is unchanged. With diagnostics off the same
2365        // worker sends the same bytes the cache keys on: only the
2366        // top-level object differs.
2367        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        // G5: the existing refusal fallback covers a worker too.
2380        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        // Control: the same worker, `bash` not listed, does run the call,
2387        // so "the marker never appears" above is a real refusal.
2388        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}