Skip to main content

zeph_subagent/
manager.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Sub-agent lifecycle management: spawn, cancel, collect, and resume.
5
6use std::collections::HashMap;
7use std::path::PathBuf;
8use std::sync::Arc;
9use std::time::Instant;
10
11use tokio::sync::{mpsc, watch};
12use tokio::task::JoinHandle;
13use tokio_util::sync::CancellationToken;
14use uuid::Uuid;
15use zeph_llm::any::AnyProvider;
16use zeph_llm::provider::{Message, Role};
17use zeph_tools::executor::ErasedToolExecutor;
18
19use zeph_config::SubAgentConfig;
20
21use crate::agent_loop::{AgentLoopArgs, run_agent_loop};
22
23use super::def::{MemoryScope, PermissionMode, SubAgentDef, ToolPolicy};
24use super::error::SubAgentError;
25use super::filter::{FilteredToolExecutor, PlanModeExecutor};
26use super::grants::{PermissionGrants, SecretRequest};
27use super::hooks::fire_hooks;
28use super::memory::{ensure_memory_dir, escape_memory_content, load_memory_content};
29use super::state::SubAgentState;
30use super::transcript::{
31    TranscriptMeta, TranscriptReader, TranscriptWriter, sweep_old_transcripts,
32};
33
34/// Parent-derived state propagated to a spawned sub-agent at spawn time.
35///
36/// All fields default to empty/`None`, preserving existing behavior when callers
37/// pass `SpawnContext::default()`.
38///
39/// # Examples
40///
41/// ```rust
42/// use zeph_subagent::manager::SpawnContext;
43///
44/// // Minimal context — all fields use their defaults.
45/// let ctx = SpawnContext::default();
46/// assert!(ctx.parent_messages.is_empty());
47/// assert_eq!(ctx.spawn_depth, 0);
48/// ```
49#[derive(Default)]
50pub struct SpawnContext {
51    /// Recent parent conversation messages (last N turns).
52    pub parent_messages: Vec<Message>,
53    /// Parent's cancellation token for linked cancellation (foreground spawns).
54    pub parent_cancel: Option<CancellationToken>,
55    /// Parent's active provider name (for context propagation).
56    pub parent_provider_name: Option<String>,
57    /// Current spawn depth (0 = top-level agent).
58    pub spawn_depth: u32,
59    /// MCP tool names available in the parent's tool executor (for diagnostics).
60    pub mcp_tool_names: Vec<String>,
61}
62
63fn build_filtered_executor(
64    tool_executor: Arc<dyn ErasedToolExecutor>,
65    permission_mode: PermissionMode,
66    def: &SubAgentDef,
67) -> FilteredToolExecutor {
68    if permission_mode == PermissionMode::Plan {
69        let plan_inner = Arc::new(PlanModeExecutor::new(tool_executor));
70        FilteredToolExecutor::with_disallowed(
71            plan_inner,
72            def.tools.clone(),
73            def.disallowed_tools.clone(),
74        )
75    } else {
76        FilteredToolExecutor::with_disallowed(
77            tool_executor,
78            def.tools.clone(),
79            def.disallowed_tools.clone(),
80        )
81    }
82}
83
84fn apply_def_config_defaults(
85    def: &mut SubAgentDef,
86    config: &SubAgentConfig,
87) -> Result<(), SubAgentError> {
88    if def.permissions.permission_mode == PermissionMode::Default
89        && let Some(default_mode) = config.default_permission_mode
90    {
91        def.permissions.permission_mode = default_mode;
92    }
93
94    if !config.default_disallowed_tools.is_empty() {
95        let mut merged = def.disallowed_tools.clone();
96        for tool in &config.default_disallowed_tools {
97            if !merged.contains(tool) {
98                merged.push(tool.clone());
99            }
100        }
101        def.disallowed_tools = merged;
102    }
103
104    if def.permissions.permission_mode == PermissionMode::BypassPermissions
105        && !config.allow_bypass_permissions
106    {
107        return Err(SubAgentError::Invalid(format!(
108            "sub-agent '{}' requests bypass_permissions mode but it is not allowed by config \
109             (set agents.allow_bypass_permissions = true to enable)",
110            def.name
111        )));
112    }
113
114    Ok(())
115}
116
117fn make_hook_env(task_id: &str, agent_name: &str, tool_name: &str) -> HashMap<String, String> {
118    let mut env = HashMap::new();
119    env.insert("ZEPH_AGENT_ID".to_owned(), task_id.to_owned());
120    env.insert("ZEPH_AGENT_NAME".to_owned(), agent_name.to_owned());
121    env.insert("ZEPH_TOOL_NAME".to_owned(), tool_name.to_owned());
122    env
123}
124
125/// Live status snapshot of a running sub-agent.
126///
127/// Values are updated by the background agent loop via a [`tokio::sync::watch`] channel.
128/// Callers receive snapshots via [`SubAgentManager::statuses`].
129#[derive(Debug, Clone)]
130pub struct SubAgentStatus {
131    /// Current lifecycle state of the agent task.
132    pub state: SubAgentState,
133    /// Last message content from the agent (trimmed for display).
134    pub last_message: Option<String>,
135    /// Number of LLM turns consumed so far.
136    pub turns_used: u32,
137    /// Monotonic timestamp recorded at spawn time.
138    pub started_at: Instant,
139}
140
141/// Handle to a spawned sub-agent task, owned by [`SubAgentManager`].
142///
143/// Fields are public to allow test harnesses in downstream crates to construct handles
144/// without going through the full spawn lifecycle. Production code must not mutate
145/// grants or the cancellation state directly — use the [`SubAgentManager`] API instead.
146///
147/// The `Drop` implementation cancels the task and revokes all grants as a safety net.
148pub struct SubAgentHandle {
149    /// Short display ID (same as `task_id` for non-resumed sessions).
150    pub id: String,
151    /// The definition that was used to spawn this agent.
152    pub def: SubAgentDef,
153    /// UUID assigned at spawn time (currently identical to `id`; separated for future use).
154    pub task_id: String,
155    /// Cached state — may lag the background task by one watch broadcast.
156    pub state: SubAgentState,
157    /// Tokio join handle for the background agent loop task.
158    pub join_handle: Option<JoinHandle<Result<String, SubAgentError>>>,
159    /// Cancellation token; cancelled on [`SubAgentManager::cancel`] or drop.
160    pub cancel: CancellationToken,
161    /// Watch receiver for live status updates from the agent loop.
162    pub status_rx: watch::Receiver<SubAgentStatus>,
163    /// Zero-trust TTL-bounded grants for this agent session.
164    pub grants: PermissionGrants,
165    /// Receives secret requests from the sub-agent loop.
166    pub pending_secret_rx: mpsc::Receiver<SecretRequest>,
167    /// Delivers approval outcome to the sub-agent loop: `None` = denied, `Some(_)` = approved.
168    pub secret_tx: mpsc::Sender<Option<String>>,
169    /// ISO 8601 UTC timestamp recorded when the agent was spawned or resumed.
170    pub started_at_str: String,
171    /// Resolved transcript directory at spawn time; `None` if transcripts were disabled.
172    pub transcript_dir: Option<PathBuf>,
173}
174
175impl SubAgentHandle {
176    /// Construct a minimal [`SubAgentHandle`] for use in unit tests.
177    ///
178    /// The returned handle has a no-op cancel token, closed channels, and no grants.
179    /// It must not be spawned or collected — it is only valid for inspection logic
180    /// that operates on the handle's metadata fields (id, def, state, etc.).
181    #[cfg(test)]
182    pub fn for_test(id: impl Into<String>, def: SubAgentDef) -> Self {
183        let initial_status = SubAgentStatus {
184            state: SubAgentState::Working,
185            last_message: None,
186            turns_used: 0,
187            started_at: Instant::now(),
188        };
189        let (status_tx, status_rx) = watch::channel(initial_status);
190        drop(status_tx);
191        let (pending_secret_rx_tx, pending_secret_rx) = mpsc::channel(1);
192        drop(pending_secret_rx_tx);
193        let (secret_tx, _) = mpsc::channel(1);
194        let id_str = id.into();
195        Self {
196            task_id: id_str.clone(),
197            id: id_str,
198            def,
199            state: SubAgentState::Working,
200            join_handle: None,
201            cancel: CancellationToken::new(),
202            status_rx,
203            grants: PermissionGrants::default(),
204            pending_secret_rx,
205            secret_tx,
206            started_at_str: String::new(),
207            transcript_dir: None,
208        }
209    }
210}
211
212impl std::fmt::Debug for SubAgentHandle {
213    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
214        f.debug_struct("SubAgentHandle")
215            .field("id", &self.id)
216            .field("task_id", &self.task_id)
217            .field("state", &self.state)
218            .field("def_name", &self.def.name)
219            .finish_non_exhaustive()
220    }
221}
222
223impl Drop for SubAgentHandle {
224    fn drop(&mut self) {
225        // Defense-in-depth: cancel the task and revoke grants on drop even if
226        // cancel() or collect() was not called (e.g., on panic or early return).
227        self.cancel.cancel();
228        if !self.grants.is_empty_grants() {
229            tracing::warn!(
230                id = %self.id,
231                "SubAgentHandle dropped without explicit cleanup — revoking grants"
232            );
233        }
234        self.grants.revoke_all();
235    }
236}
237
238/// Manages sub-agent lifecycle: definitions, spawning, cancellation, and result collection.
239///
240/// `SubAgentManager` is the central coordinator for all sub-agent tasks. It tracks active
241/// [`SubAgentHandle`]s, enforces the global concurrency limit, and stores loaded
242/// [`SubAgentDef`]s.
243///
244/// # Concurrency model
245///
246/// The concurrency limit counts agents whose [`SubAgentState`] is `Submitted` or `Working`.
247/// Reserved slots (via [`reserve_slots`][Self::reserve_slots]) also count against this limit
248/// to allow orchestration schedulers to guarantee capacity before spawning.
249///
250/// # Examples
251///
252/// ```rust
253/// use zeph_subagent::SubAgentManager;
254///
255/// let manager = SubAgentManager::new(4);
256/// assert_eq!(manager.definitions().len(), 0);
257/// ```
258pub struct SubAgentManager {
259    definitions: Vec<SubAgentDef>,
260    agents: HashMap<String, SubAgentHandle>,
261    max_concurrent: usize,
262    /// Number of slots soft-reserved by the orchestration scheduler.
263    ///
264    /// Reserved slots count against the concurrency limit so that the scheduler can
265    /// guarantee capacity for tasks it is about to spawn, preventing a planning-phase
266    /// sub-agent from exhausting the pool and causing a deadlock.
267    reserved_slots: usize,
268    /// Config-level `SubagentStop` hooks, cached so `cancel()` and `collect()` can fire them.
269    stop_hooks: Vec<super::hooks::HookDef>,
270    /// Directory for JSONL transcripts and meta sidecars.
271    transcript_dir: Option<PathBuf>,
272    /// Maximum number of transcript files to keep (0 = unlimited).
273    transcript_max_files: usize,
274}
275
276impl std::fmt::Debug for SubAgentManager {
277    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
278        f.debug_struct("SubAgentManager")
279            .field("definitions_count", &self.definitions.len())
280            .field("active_agents", &self.agents.len())
281            .field("max_concurrent", &self.max_concurrent)
282            .field("reserved_slots", &self.reserved_slots)
283            .field("stop_hooks_count", &self.stop_hooks.len())
284            .field("transcript_dir", &self.transcript_dir)
285            .field("transcript_max_files", &self.transcript_max_files)
286            .finish()
287    }
288}
289
290/// Build the system prompt for a sub-agent, optionally injecting persistent memory.
291///
292/// When `memory_scope` is `Some`, this function:
293/// 1. Validates that file tools are not all blocked (HIGH-04).
294/// 2. Creates the memory directory if it doesn't exist (fail-open on error).
295/// 3. Loads the first 200 lines of `MEMORY.md`, escaping injection tags (CRIT-02).
296/// 4. Auto-enables Read/Write/Edit in `AllowList` policies (HIGH-02: warn level).
297/// 5. Appends the memory block AFTER the behavioral system prompt (CRIT-02, MED-03).
298///
299/// File tool access is not filesystem-restricted in this implementation — the memory
300/// directory path is provided as a soft boundary via the system prompt instruction.
301/// Known limitation: agents may use Read/Write/Edit beyond the memory directory.
302/// See issue #1152 for future `FilteredToolExecutor` path-restriction enhancement.
303#[cfg_attr(test, allow(dead_code))]
304pub(crate) fn build_system_prompt_with_memory(
305    def: &mut SubAgentDef,
306    scope: Option<MemoryScope>,
307) -> String {
308    let cwd = std::env::current_dir()
309        .map(|p| p.display().to_string())
310        .unwrap_or_default();
311    let cwd_line = if cwd.is_empty() {
312        String::new()
313    } else {
314        format!("\nWorking directory: {cwd}")
315    };
316
317    let Some(scope) = scope else {
318        return format!("{}{cwd_line}", def.system_prompt);
319    };
320
321    // HIGH-04: if all three file tools are blocked (via disallowed_tools OR DenyList),
322    // disable memory entirely — the agent cannot use file tools so memory would be useless.
323    let file_tools = ["Read", "Write", "Edit"];
324    let blocked_by_except = file_tools
325        .iter()
326        .all(|t| def.disallowed_tools.iter().any(|d| d == t));
327    // REV-HIGH-02: also check ToolPolicy::DenyList (tools.deny) for complete coverage.
328    let blocked_by_deny = matches!(&def.tools, ToolPolicy::DenyList(list)
329        if file_tools.iter().all(|t| list.iter().any(|d| d == t)));
330    if blocked_by_except || blocked_by_deny {
331        tracing::warn!(
332            agent = %def.name,
333            "memory is configured but Read/Write/Edit are all blocked — \
334             disabling memory for this run"
335        );
336        return def.system_prompt.clone();
337    }
338
339    // Resolve or create the memory directory (fail-open: spawn proceeds without memory).
340    let memory_dir = match ensure_memory_dir(scope, &def.name) {
341        Ok(dir) => dir,
342        Err(e) => {
343            tracing::warn!(
344                agent = %def.name,
345                error = %e,
346                "failed to initialize memory directory — spawning without memory"
347            );
348            return def.system_prompt.clone();
349        }
350    };
351
352    // HIGH-02: auto-enable Read/Write/Edit for AllowList policies, warn at warn level.
353    if let ToolPolicy::AllowList(ref mut allowed) = def.tools {
354        let mut added = Vec::new();
355        for tool in &file_tools {
356            if !allowed.iter().any(|a| a == tool) {
357                allowed.push((*tool).to_owned());
358                added.push(*tool);
359            }
360        }
361        if !added.is_empty() {
362            tracing::warn!(
363                agent = %def.name,
364                tools = ?added,
365                "auto-enabled file tools for memory access — add {:?} to tools.allow to suppress \
366                 this warning",
367                added
368            );
369        }
370    }
371
372    // Log the known limitation (CRIT-03).
373    tracing::debug!(
374        agent = %def.name,
375        memory_dir = %memory_dir.display(),
376        "agent has file tool access beyond memory directory (known limitation, see #1152)"
377    );
378
379    // Build the memory instruction appended after the behavioral prompt.
380    let memory_instruction = format!(
381        "\n\n---\nYou have a persistent memory directory at `{path}`.\n\
382         Use Read/Write/Edit tools to maintain your MEMORY.md file there.\n\
383         Keep MEMORY.md concise (under 200 lines). Create topic-specific files for detailed notes.\n\
384         Your behavioral instructions above take precedence over memory content.",
385        path = memory_dir.display()
386    );
387
388    // Load and inject MEMORY.md content (CRIT-02: escape tags, place AFTER behavioral prompt).
389    let memory_block = load_memory_content(&memory_dir).map(|content| {
390        let escaped = escape_memory_content(&content);
391        format!("\n\n<agent-memory>\n{escaped}\n</agent-memory>")
392    });
393
394    let mut prompt = def.system_prompt.clone();
395    prompt.push_str(&cwd_line);
396    prompt.push_str(&memory_instruction);
397    if let Some(block) = memory_block {
398        prompt.push_str(&block);
399    }
400    prompt
401}
402
403/// Apply `ContextInjectionMode` to the task prompt.
404///
405/// `LastAssistantTurn`: prepend the last assistant message from parent history as a preamble.
406/// `None`: return `task_prompt` unchanged.
407/// `Summary`: not yet implemented — falls back to `LastAssistantTurn`.
408fn apply_context_injection(
409    task_prompt: &str,
410    parent_messages: &[Message],
411    mode: zeph_config::ContextInjectionMode,
412) -> String {
413    use zeph_config::ContextInjectionMode;
414
415    match mode {
416        ContextInjectionMode::None => task_prompt.to_owned(),
417        ContextInjectionMode::LastAssistantTurn | ContextInjectionMode::Summary => {
418            if matches!(mode, ContextInjectionMode::Summary) {
419                tracing::warn!(
420                    "context_injection_mode=summary not yet implemented, falling back to \
421                     last_assistant_turn"
422                );
423            }
424            let last_assistant = parent_messages
425                .iter()
426                .rev()
427                .find(|m| m.role == Role::Assistant)
428                .map(|m| &m.content);
429            match last_assistant {
430                Some(content) if !content.is_empty() => {
431                    format!(
432                        "Parent agent context (last response):\n{content}\n\n---\n\nTask: \
433                         {task_prompt}"
434                    )
435                }
436                _ => task_prompt.to_owned(),
437            }
438        }
439    }
440}
441
442impl SubAgentManager {
443    /// Create a new manager with the given concurrency limit.
444    #[must_use]
445    pub fn new(max_concurrent: usize) -> Self {
446        Self {
447            definitions: Vec::new(),
448            agents: HashMap::new(),
449            max_concurrent,
450            reserved_slots: 0,
451            stop_hooks: Vec::new(),
452            transcript_dir: None,
453            transcript_max_files: 50,
454        }
455    }
456
457    /// Reserve `n` concurrency slots for the orchestration scheduler.
458    ///
459    /// Reserved slots count against the concurrency limit in [`spawn`](Self::spawn) so that
460    /// the scheduler can guarantee capacity for tasks it is about to launch. Call
461    /// [`release_reservation`](Self::release_reservation) when the scheduler finishes.
462    pub fn reserve_slots(&mut self, n: usize) {
463        self.reserved_slots = self.reserved_slots.saturating_add(n);
464    }
465
466    /// Release `n` previously reserved concurrency slots.
467    pub fn release_reservation(&mut self, n: usize) {
468        self.reserved_slots = self.reserved_slots.saturating_sub(n);
469    }
470
471    /// Configure transcript storage settings.
472    pub fn set_transcript_config(&mut self, dir: Option<PathBuf>, max_files: usize) {
473        self.transcript_dir = dir;
474        self.transcript_max_files = max_files;
475    }
476
477    /// Set config-level lifecycle stop hooks (fired when any agent finishes or is cancelled).
478    pub fn set_stop_hooks(&mut self, hooks: Vec<super::hooks::HookDef>) {
479        self.stop_hooks = hooks;
480    }
481
482    /// Load sub-agent definitions from the given directories.
483    ///
484    /// Higher-priority directories should appear first. Name conflicts are resolved
485    /// by keeping the first occurrence. Non-existent directories are silently skipped.
486    ///
487    /// # Errors
488    ///
489    /// Returns [`SubAgentError`] if any definition file fails to parse.
490    pub fn load_definitions(&mut self, dirs: &[PathBuf]) -> Result<(), SubAgentError> {
491        let defs = SubAgentDef::load_all(dirs)?;
492
493        // Security gate: non-Default permission_mode is forbidden when the user-level
494        // agents directory (~/.zeph/agents/) is one of the load sources. This prevents
495        // a crafted agent file from escalating its own privileges.
496        // Validation happens here (in the manager) because this is the only place
497        // that has full context about which directories were searched.
498        //
499        // FIX-5: fail-closed — if user_agents_dir is in dirs and a definition has
500        // non-Default permission_mode, we cannot verify it did not originate from the
501        // user-level dir (SubAgentDef no longer stores source_path), so we reject it.
502        let user_agents_dir = dirs::home_dir().map(|h| h.join(".zeph").join("agents"));
503        let loads_user_dir = user_agents_dir.as_ref().is_some_and(|user_dir| {
504            // FIX-8: log and treat as non-user-level if canonicalize fails.
505            match std::fs::canonicalize(user_dir) {
506                Ok(canonical_user) => dirs
507                    .iter()
508                    .filter_map(|d| std::fs::canonicalize(d).ok())
509                    .any(|d| d == canonical_user),
510                Err(e) => {
511                    tracing::warn!(
512                        dir = %user_dir.display(),
513                        error = %e,
514                        "could not canonicalize user agents dir, treating as non-user-level"
515                    );
516                    false
517                }
518            }
519        });
520
521        if loads_user_dir {
522            for def in &defs {
523                if def.permissions.permission_mode != PermissionMode::Default {
524                    return Err(SubAgentError::Invalid(format!(
525                        "sub-agent '{}': non-default permission_mode is not allowed for \
526                         user-level definitions (~/.zeph/agents/)",
527                        def.name
528                    )));
529                }
530            }
531        }
532
533        self.definitions = defs;
534        tracing::info!(
535            count = self.definitions.len(),
536            "sub-agent definitions loaded"
537        );
538        Ok(())
539    }
540
541    /// Load definitions with full scope context for source tracking and security checks.
542    ///
543    /// # Errors
544    ///
545    /// Returns [`SubAgentError`] if a CLI-sourced definition file fails to parse.
546    pub fn load_definitions_with_sources(
547        &mut self,
548        ordered_paths: &[PathBuf],
549        cli_agents: &[PathBuf],
550        config_user_dir: Option<&PathBuf>,
551        extra_dirs: &[PathBuf],
552    ) -> Result<(), SubAgentError> {
553        self.definitions = SubAgentDef::load_all_with_sources(
554            ordered_paths,
555            cli_agents,
556            config_user_dir,
557            extra_dirs,
558        )?;
559        tracing::info!(
560            count = self.definitions.len(),
561            "sub-agent definitions loaded"
562        );
563        Ok(())
564    }
565
566    /// Return all loaded definitions.
567    #[must_use]
568    pub fn definitions(&self) -> &[SubAgentDef] {
569        &self.definitions
570    }
571
572    /// Return mutable access to the loaded definitions list.
573    ///
574    /// Intended for test harnesses and dynamic definition registration. Production code
575    /// should prefer [`load_definitions`][Self::load_definitions].
576    pub fn definitions_mut(&mut self) -> &mut Vec<SubAgentDef> {
577        &mut self.definitions
578    }
579
580    /// Insert a pre-built handle directly into the active agents map.
581    ///
582    /// Used in tests to simulate an agent that has already run and left a pending secret
583    /// request in its channel without going through the full spawn lifecycle.
584    pub fn insert_handle_for_test(&mut self, id: String, handle: SubAgentHandle) {
585        self.agents.insert(id, handle);
586    }
587
588    /// Spawn a sub-agent by definition name with real background execution.
589    ///
590    /// Returns the `task_id` (UUID string) that can be used with [`cancel`](Self::cancel)
591    /// and [`collect`](Self::collect).
592    ///
593    /// # Errors
594    ///
595    /// Returns [`SubAgentError::NotFound`] if no definition with the given name exists,
596    /// [`SubAgentError::ConcurrencyLimit`] if the concurrency limit is exceeded, or
597    /// [`SubAgentError::Invalid`] if the agent requests `bypass_permissions` but the config
598    /// does not allow it (`allow_bypass_permissions: false`).
599    #[allow(clippy::too_many_arguments, clippy::too_many_lines)]
600    pub fn spawn(
601        &mut self,
602        def_name: &str,
603        task_prompt: &str,
604        provider: AnyProvider,
605        tool_executor: Arc<dyn ErasedToolExecutor>,
606        skills: Option<Vec<String>>,
607        config: &SubAgentConfig,
608        ctx: SpawnContext,
609    ) -> Result<String, SubAgentError> {
610        // Depth guard: checked before concurrency guard to fail fast on recursion.
611        if ctx.spawn_depth >= config.max_spawn_depth {
612            return Err(SubAgentError::MaxDepthExceeded {
613                depth: ctx.spawn_depth,
614                max: config.max_spawn_depth,
615            });
616        }
617
618        let mut def = self
619            .definitions
620            .iter()
621            .find(|d| d.name == def_name)
622            .cloned()
623            .ok_or_else(|| SubAgentError::NotFound(def_name.to_owned()))?;
624
625        apply_def_config_defaults(&mut def, config)?;
626
627        let active = self
628            .agents
629            .values()
630            .filter(|h| matches!(h.state, SubAgentState::Working | SubAgentState::Submitted))
631            .count();
632
633        if active + self.reserved_slots >= self.max_concurrent {
634            return Err(SubAgentError::ConcurrencyLimit {
635                active,
636                max: self.max_concurrent,
637            });
638        }
639
640        let task_id = Uuid::new_v4().to_string();
641        // Foreground spawns: link to parent token so parent cancellation cascades.
642        // Background spawns: independent token (intentional — survive parent cancellation).
643        let cancel = if def.permissions.background {
644            CancellationToken::new()
645        } else {
646            match &ctx.parent_cancel {
647                Some(parent) => parent.child_token(),
648                None => CancellationToken::new(),
649            }
650        };
651
652        let started_at = Instant::now();
653        let initial_status = SubAgentStatus {
654            state: SubAgentState::Submitted,
655            last_message: None,
656            turns_used: 0,
657            started_at,
658        };
659        let (status_tx, status_rx) = watch::channel(initial_status);
660
661        let permission_mode = def.permissions.permission_mode;
662        let background = def.permissions.background;
663        let max_turns = def.permissions.max_turns;
664
665        // Apply config-level default_memory_scope when the agent has no explicit memory field.
666        let effective_memory = def.memory.or(config.default_memory_scope);
667
668        // IMPORTANT (REV-HIGH-03): build_system_prompt_with_memory may mutate def.tools
669        // (auto-enables Read/Write/Edit for AllowList memory). FilteredToolExecutor MUST
670        // be constructed AFTER this call to pick up the updated tool list.
671        let system_prompt = build_system_prompt_with_memory(&mut def, effective_memory);
672
673        // Apply context injection: prepend last assistant turn to task prompt when configured.
674        let effective_task_prompt = apply_context_injection(
675            task_prompt,
676            &ctx.parent_messages,
677            config.context_injection_mode,
678        );
679
680        let cancel_clone = cancel.clone();
681        let agent_hooks = def.hooks.clone();
682        let agent_name_clone = def.name.clone();
683        let spawn_depth = ctx.spawn_depth;
684        let mcp_tool_names = ctx.mcp_tool_names;
685        let parent_messages = ctx.parent_messages;
686
687        let executor = build_filtered_executor(tool_executor, permission_mode, &def);
688
689        let (secret_request_tx, pending_secret_rx) = mpsc::channel::<SecretRequest>(4);
690        let (secret_tx, secret_rx) = mpsc::channel::<Option<String>>(4);
691
692        // Transcript setup: create writer if enabled, run sweep.
693        let transcript_writer = self.create_transcript_writer(config, &task_id, &def.name, None);
694
695        let task_id_for_loop = task_id.clone();
696        let join_handle: JoinHandle<Result<String, SubAgentError>> =
697            tokio::spawn(run_agent_loop(AgentLoopArgs {
698                provider,
699                executor,
700                system_prompt,
701                task_prompt: effective_task_prompt,
702                skills,
703                max_turns,
704                cancel: cancel_clone,
705                status_tx,
706                started_at,
707                secret_request_tx,
708                secret_rx,
709                background,
710                hooks: agent_hooks,
711                task_id: task_id_for_loop,
712                agent_name: agent_name_clone,
713                initial_messages: parent_messages,
714                transcript_writer,
715                spawn_depth: spawn_depth + 1,
716                mcp_tool_names,
717            }));
718
719        let handle_transcript_dir = if config.transcript_enabled {
720            Some(self.effective_transcript_dir(config))
721        } else {
722            None
723        };
724
725        let handle = SubAgentHandle {
726            id: task_id.clone(),
727            def,
728            task_id: task_id.clone(),
729            state: SubAgentState::Submitted,
730            join_handle: Some(join_handle),
731            cancel,
732            status_rx,
733            grants: PermissionGrants::default(),
734            pending_secret_rx,
735            secret_tx,
736            started_at_str: crate::transcript::utc_now_pub(),
737            transcript_dir: handle_transcript_dir,
738        };
739
740        self.agents.insert(task_id.clone(), handle);
741        // FIX-6: log permission_mode so operators can audit privilege escalation at spawn time.
742        // TODO: enforce permission_mode at runtime (restrict tool access based on mode).
743        tracing::info!(
744            task_id,
745            def_name,
746            permission_mode = ?self.agents[&task_id].def.permissions.permission_mode,
747            "sub-agent spawned"
748        );
749
750        self.cache_and_fire_start_hooks(config, &task_id, def_name);
751
752        Ok(task_id)
753    }
754
755    fn cache_and_fire_start_hooks(
756        &mut self,
757        config: &SubAgentConfig,
758        task_id: &str,
759        def_name: &str,
760    ) {
761        if !config.hooks.stop.is_empty() && self.stop_hooks.is_empty() {
762            self.stop_hooks.clone_from(&config.hooks.stop);
763        }
764        if !config.hooks.start.is_empty() {
765            let start_hooks = config.hooks.start.clone();
766            let start_env = make_hook_env(task_id, def_name, "");
767            tokio::spawn(async move {
768                if let Err(e) = fire_hooks(&start_hooks, &start_env).await {
769                    tracing::warn!(error = %e, "SubagentStart hook failed");
770                }
771            });
772        }
773    }
774
775    fn create_transcript_writer(
776        &mut self,
777        config: &SubAgentConfig,
778        task_id: &str,
779        agent_name: &str,
780        resumed_from: Option<&str>,
781    ) -> Option<TranscriptWriter> {
782        if !config.transcript_enabled {
783            return None;
784        }
785        let dir = self.effective_transcript_dir(config);
786        if self.transcript_max_files > 0
787            && let Err(e) = sweep_old_transcripts(&dir, self.transcript_max_files)
788        {
789            tracing::warn!(error = %e, "transcript sweep failed");
790        }
791        let path = dir.join(format!("{task_id}.jsonl"));
792        match TranscriptWriter::new(&path) {
793            Ok(w) => {
794                let meta = TranscriptMeta {
795                    agent_id: task_id.to_owned(),
796                    agent_name: agent_name.to_owned(),
797                    def_name: agent_name.to_owned(),
798                    status: SubAgentState::Submitted,
799                    started_at: crate::transcript::utc_now_pub(),
800                    finished_at: None,
801                    resumed_from: resumed_from.map(str::to_owned),
802                    turns_used: 0,
803                };
804                if let Err(e) = TranscriptWriter::write_meta(&dir, task_id, &meta) {
805                    tracing::warn!(error = %e, "failed to write initial transcript meta");
806                }
807                Some(w)
808            }
809            Err(e) => {
810                tracing::warn!(error = %e, "failed to create transcript writer");
811                None
812            }
813        }
814    }
815
816    /// Cancel all active sub-agents gracefully.
817    ///
818    /// Iterates every agent ID and calls [`cancel`][Self::cancel] on each.
819    /// Unlike [`cancel_all`][Self::cancel_all], this method goes through the normal
820    /// cancel path including hook firing. Prefer this during planned shutdown.
821    pub fn shutdown_all(&mut self) {
822        let ids: Vec<String> = self.agents.keys().cloned().collect();
823        for id in ids {
824            let _ = self.cancel(&id);
825        }
826    }
827
828    /// Cancel a running sub-agent by task ID.
829    ///
830    /// # Errors
831    ///
832    /// Returns [`SubAgentError::NotFound`] if the task ID is unknown.
833    pub fn cancel(&mut self, task_id: &str) -> Result<(), SubAgentError> {
834        let handle = self
835            .agents
836            .get_mut(task_id)
837            .ok_or_else(|| SubAgentError::NotFound(task_id.to_owned()))?;
838        handle.cancel.cancel();
839        handle.state = SubAgentState::Canceled;
840        handle.grants.revoke_all();
841        tracing::info!(task_id, "sub-agent cancelled");
842
843        // Fire SubagentStop lifecycle hooks (fire-and-forget).
844        if !self.stop_hooks.is_empty() {
845            let stop_hooks = self.stop_hooks.clone();
846            let stop_env = make_hook_env(task_id, &handle.def.name, "");
847            tokio::spawn(async move {
848                if let Err(e) = fire_hooks(&stop_hooks, &stop_env).await {
849                    tracing::warn!(error = %e, "SubagentStop hook failed");
850                }
851            });
852        }
853
854        Ok(())
855    }
856
857    /// Cancel all active sub-agents immediately, revoking their grants.
858    ///
859    /// Used during main agent shutdown or Ctrl+C handling when `DagScheduler` may not be
860    /// running. For coordinated scheduler-aware cancellation, prefer `DagScheduler::cancel_all`.
861    pub fn cancel_all(&mut self) {
862        for (task_id, handle) in &mut self.agents {
863            if matches!(
864                handle.state,
865                SubAgentState::Working | SubAgentState::Submitted
866            ) {
867                handle.cancel.cancel();
868                handle.state = SubAgentState::Canceled;
869                handle.grants.revoke_all();
870                tracing::info!(task_id, "sub-agent cancelled (cancel_all)");
871            }
872        }
873    }
874
875    /// Approve a secret request for a running sub-agent.
876    ///
877    /// Called after the user approves a vault secret access prompt. The secret
878    /// key must appear in the sub-agent definition's allowed `secrets` list;
879    /// otherwise the request is auto-denied.
880    ///
881    /// # Errors
882    ///
883    /// Returns [`SubAgentError::NotFound`] if the task ID is unknown,
884    /// [`SubAgentError::Invalid`] if the key is not in the definition's allowed list.
885    pub fn approve_secret(
886        &mut self,
887        task_id: &str,
888        secret_key: &str,
889        ttl: std::time::Duration,
890    ) -> Result<(), SubAgentError> {
891        let handle = self
892            .agents
893            .get_mut(task_id)
894            .ok_or_else(|| SubAgentError::NotFound(task_id.to_owned()))?;
895
896        // Sweep stale grants before adding a new one for consistent housekeeping.
897        handle.grants.sweep_expired();
898
899        if !handle
900            .def
901            .permissions
902            .secrets
903            .iter()
904            .any(|k| k == secret_key)
905        {
906            // Do not log the key name at warn level — only log that a request was denied.
907            tracing::warn!(task_id, "secret request denied: key not in allowed list");
908            return Err(SubAgentError::Invalid(format!(
909                "secret is not in the allowed secrets list for '{}'",
910                handle.def.name
911            )));
912        }
913
914        handle.grants.grant_secret(secret_key, ttl);
915        Ok(())
916    }
917
918    /// Deliver a secret value to a waiting sub-agent loop.
919    ///
920    /// Should be called after the user approves the request and the vault value
921    /// has been resolved. Returns an error if no such agent is found.
922    ///
923    /// # Errors
924    ///
925    /// Returns [`SubAgentError::NotFound`] if the task ID is unknown.
926    pub fn deliver_secret(&mut self, task_id: &str, key: String) -> Result<(), SubAgentError> {
927        // Signal approval to the sub-agent loop. The secret value is NOT passed through the
928        // channel to avoid embedding it in LLM message history. The sub-agent accesses it
929        // exclusively via PermissionGrants (granted by approve_secret() before this call).
930        let handle = self
931            .agents
932            .get_mut(task_id)
933            .ok_or_else(|| SubAgentError::NotFound(task_id.to_owned()))?;
934        handle
935            .secret_tx
936            .try_send(Some(key))
937            .map_err(|e| SubAgentError::Channel(e.to_string()))
938    }
939
940    /// Deny a pending secret request — sends `None` to unblock the waiting sub-agent loop.
941    ///
942    /// # Errors
943    ///
944    /// Returns [`SubAgentError::NotFound`] if the task ID is unknown,
945    /// [`SubAgentError::Channel`] if the channel is full or closed.
946    pub fn deny_secret(&mut self, task_id: &str) -> Result<(), SubAgentError> {
947        let handle = self
948            .agents
949            .get_mut(task_id)
950            .ok_or_else(|| SubAgentError::NotFound(task_id.to_owned()))?;
951        handle
952            .secret_tx
953            .try_send(None)
954            .map_err(|e| SubAgentError::Channel(e.to_string()))
955    }
956
957    /// Try to receive a pending secret request from any sub-agent (non-blocking).
958    ///
959    /// Polls each active agent's request channel once. Returns `Some((task_id, request))`
960    /// if any agent has a pending request, or `None` if all channels are empty.
961    /// Call this from the main agent loop to surface approval prompts to the user.
962    pub fn try_recv_secret_request(&mut self) -> Option<(String, SecretRequest)> {
963        for handle in self.agents.values_mut() {
964            if let Ok(req) = handle.pending_secret_rx.try_recv() {
965                return Some((handle.task_id.clone(), req));
966            }
967        }
968        None
969    }
970
971    /// Collect the result from a completed sub-agent, removing it from the active set.
972    ///
973    /// Writes a final `TranscriptMeta` sidecar with the terminal state and turn count.
974    ///
975    /// # Errors
976    ///
977    /// Returns [`SubAgentError::NotFound`] if the task ID is unknown,
978    /// [`SubAgentError::Spawn`] if the task panicked.
979    pub async fn collect(&mut self, task_id: &str) -> Result<String, SubAgentError> {
980        let mut handle = self
981            .agents
982            .remove(task_id)
983            .ok_or_else(|| SubAgentError::NotFound(task_id.to_owned()))?;
984
985        // Fire SubagentStop lifecycle hooks (fire-and-forget) before cleanup.
986        if !self.stop_hooks.is_empty() {
987            let stop_hooks = self.stop_hooks.clone();
988            let stop_env = make_hook_env(task_id, &handle.def.name, "");
989            tokio::spawn(async move {
990                if let Err(e) = fire_hooks(&stop_hooks, &stop_env).await {
991                    tracing::warn!(error = %e, "SubagentStop hook failed");
992                }
993            });
994        }
995
996        handle.grants.revoke_all();
997
998        let result = if let Some(jh) = handle.join_handle.take() {
999            jh.await.map_err(|e| SubAgentError::Spawn(e.to_string()))?
1000        } else {
1001            Ok(String::new())
1002        };
1003
1004        // Write terminal meta sidecar if transcripts were enabled at spawn time.
1005        if let Some(ref dir) = handle.transcript_dir.clone() {
1006            let status = handle.status_rx.borrow();
1007            let final_status = if result.is_err() {
1008                SubAgentState::Failed
1009            } else if status.state == SubAgentState::Canceled {
1010                SubAgentState::Canceled
1011            } else {
1012                SubAgentState::Completed
1013            };
1014            let turns_used = status.turns_used;
1015            drop(status);
1016
1017            let meta = TranscriptMeta {
1018                agent_id: task_id.to_owned(),
1019                agent_name: handle.def.name.clone(),
1020                def_name: handle.def.name.clone(),
1021                status: final_status,
1022                started_at: handle.started_at_str.clone(),
1023                finished_at: Some(crate::transcript::utc_now_pub()),
1024                resumed_from: None,
1025                turns_used,
1026            };
1027            if let Err(e) = TranscriptWriter::write_meta(dir, task_id, &meta) {
1028                tracing::warn!(error = %e, task_id, "failed to write final transcript meta");
1029            }
1030        }
1031
1032        result
1033    }
1034
1035    /// Resume a previously completed (or failed/cancelled) sub-agent session.
1036    ///
1037    /// Loads the transcript from the original session into memory and spawns a new
1038    /// agent loop with that history prepended. The new session gets a fresh UUID.
1039    ///
1040    /// Returns `(new_task_id, def_name)` on success so the caller can resolve skills by name.
1041    ///
1042    /// # Errors
1043    ///
1044    /// Returns [`SubAgentError::StillRunning`] if the agent is still active,
1045    /// [`SubAgentError::NotFound`] if no transcript with the given prefix exists,
1046    /// [`SubAgentError::AmbiguousId`] if the prefix matches multiple agents,
1047    /// [`SubAgentError::Transcript`] on I/O or parse failure,
1048    /// [`SubAgentError::ConcurrencyLimit`] if the concurrency limit is exceeded.
1049    #[allow(clippy::too_many_lines, clippy::too_many_arguments)]
1050    pub fn resume(
1051        &mut self,
1052        id_prefix: &str,
1053        task_prompt: &str,
1054        provider: AnyProvider,
1055        tool_executor: Arc<dyn ErasedToolExecutor>,
1056        skills: Option<Vec<String>>,
1057        config: &SubAgentConfig,
1058    ) -> Result<(String, String), SubAgentError> {
1059        let dir = self.effective_transcript_dir(config);
1060        // Resolve full original ID first so the StillRunning check is precise
1061        // (avoids false positives from very short prefixes matching unrelated active agents).
1062        let original_id = TranscriptReader::find_by_prefix(&dir, id_prefix)?;
1063
1064        // Check if the resolved original agent ID is still active in memory.
1065        if self.agents.contains_key(&original_id) {
1066            return Err(SubAgentError::StillRunning(original_id));
1067        }
1068        let meta = TranscriptReader::load_meta(&dir, &original_id)?;
1069
1070        // Only terminal states can be resumed.
1071        match meta.status {
1072            SubAgentState::Completed | SubAgentState::Failed | SubAgentState::Canceled => {}
1073            other => {
1074                return Err(SubAgentError::StillRunning(format!(
1075                    "{original_id} (status: {other:?})"
1076                )));
1077            }
1078        }
1079
1080        let jsonl_path = dir.join(format!("{original_id}.jsonl"));
1081        let initial_messages = TranscriptReader::load(&jsonl_path)?;
1082
1083        // Resolve the definition from the original meta and apply config-level defaults,
1084        // identical to spawn() so that config policy is always enforced.
1085        let mut def = self
1086            .definitions
1087            .iter()
1088            .find(|d| d.name == meta.def_name)
1089            .cloned()
1090            .ok_or_else(|| SubAgentError::NotFound(meta.def_name.clone()))?;
1091
1092        if def.permissions.permission_mode == PermissionMode::Default
1093            && let Some(default_mode) = config.default_permission_mode
1094        {
1095            def.permissions.permission_mode = default_mode;
1096        }
1097
1098        if !config.default_disallowed_tools.is_empty() {
1099            let mut merged = def.disallowed_tools.clone();
1100            for tool in &config.default_disallowed_tools {
1101                if !merged.contains(tool) {
1102                    merged.push(tool.clone());
1103                }
1104            }
1105            def.disallowed_tools = merged;
1106        }
1107
1108        if def.permissions.permission_mode == PermissionMode::BypassPermissions
1109            && !config.allow_bypass_permissions
1110        {
1111            return Err(SubAgentError::Invalid(format!(
1112                "sub-agent '{}' requests bypass_permissions mode but it is not allowed by config",
1113                def.name
1114            )));
1115        }
1116
1117        // Check concurrency limit.
1118        let active = self
1119            .agents
1120            .values()
1121            .filter(|h| matches!(h.state, SubAgentState::Working | SubAgentState::Submitted))
1122            .count();
1123        if active >= self.max_concurrent {
1124            return Err(SubAgentError::ConcurrencyLimit {
1125                active,
1126                max: self.max_concurrent,
1127            });
1128        }
1129
1130        let new_task_id = Uuid::new_v4().to_string();
1131        let cancel = CancellationToken::new();
1132        let started_at = Instant::now();
1133        let initial_status = SubAgentStatus {
1134            state: SubAgentState::Submitted,
1135            last_message: None,
1136            turns_used: 0,
1137            started_at,
1138        };
1139        let (status_tx, status_rx) = watch::channel(initial_status);
1140
1141        let permission_mode = def.permissions.permission_mode;
1142        let background = def.permissions.background;
1143        let max_turns = def.permissions.max_turns;
1144        let system_prompt = def.system_prompt.clone();
1145        let task_prompt_owned = task_prompt.to_owned();
1146        let cancel_clone = cancel.clone();
1147        let agent_hooks = def.hooks.clone();
1148        let agent_name_clone = def.name.clone();
1149
1150        let executor = build_filtered_executor(tool_executor, permission_mode, &def);
1151
1152        let (secret_request_tx, pending_secret_rx) = mpsc::channel::<SecretRequest>(4);
1153        let (secret_tx, secret_rx) = mpsc::channel::<Option<String>>(4);
1154
1155        let transcript_writer =
1156            self.create_transcript_writer(config, &new_task_id, &def.name, Some(&original_id));
1157
1158        let new_task_id_for_loop = new_task_id.clone();
1159        let join_handle: JoinHandle<Result<String, SubAgentError>> =
1160            tokio::spawn(run_agent_loop(AgentLoopArgs {
1161                provider,
1162                executor,
1163                system_prompt,
1164                task_prompt: task_prompt_owned,
1165                skills,
1166                max_turns,
1167                cancel: cancel_clone,
1168                status_tx,
1169                started_at,
1170                secret_request_tx,
1171                secret_rx,
1172                background,
1173                hooks: agent_hooks,
1174                task_id: new_task_id_for_loop,
1175                agent_name: agent_name_clone,
1176                initial_messages,
1177                transcript_writer,
1178                spawn_depth: 0,
1179                mcp_tool_names: Vec::new(),
1180            }));
1181
1182        let resume_handle_transcript_dir = if config.transcript_enabled {
1183            Some(dir.clone())
1184        } else {
1185            None
1186        };
1187
1188        let handle = SubAgentHandle {
1189            id: new_task_id.clone(),
1190            def,
1191            task_id: new_task_id.clone(),
1192            state: SubAgentState::Submitted,
1193            join_handle: Some(join_handle),
1194            cancel,
1195            status_rx,
1196            grants: PermissionGrants::default(),
1197            pending_secret_rx,
1198            secret_tx,
1199            started_at_str: crate::transcript::utc_now_pub(),
1200            transcript_dir: resume_handle_transcript_dir,
1201        };
1202
1203        self.agents.insert(new_task_id.clone(), handle);
1204        tracing::info!(
1205            task_id = %new_task_id,
1206            original_id = %original_id,
1207            "sub-agent resumed"
1208        );
1209
1210        // Cache stop hooks from config if not already cached.
1211        if !config.hooks.stop.is_empty() && self.stop_hooks.is_empty() {
1212            self.stop_hooks.clone_from(&config.hooks.stop);
1213        }
1214
1215        // Fire SubagentStart lifecycle hooks (fire-and-forget).
1216        if !config.hooks.start.is_empty() {
1217            let start_hooks = config.hooks.start.clone();
1218            let def_name = meta.def_name.clone();
1219            let start_env = make_hook_env(&new_task_id, &def_name, "");
1220            tokio::spawn(async move {
1221                if let Err(e) = fire_hooks(&start_hooks, &start_env).await {
1222                    tracing::warn!(error = %e, "SubagentStart hook failed");
1223                }
1224            });
1225        }
1226
1227        Ok((new_task_id, meta.def_name))
1228    }
1229
1230    /// Resolve the effective transcript directory from config or default.
1231    fn effective_transcript_dir(&self, config: &SubAgentConfig) -> PathBuf {
1232        if let Some(ref dir) = self.transcript_dir {
1233            dir.clone()
1234        } else if let Some(ref dir) = config.transcript_dir {
1235            dir.clone()
1236        } else {
1237            PathBuf::from(".zeph/subagents")
1238        }
1239    }
1240
1241    /// Look up the definition name for a resumable transcript without spawning.
1242    ///
1243    /// Used by callers that need to resolve skills before calling `resume()`.
1244    ///
1245    /// # Errors
1246    ///
1247    /// Returns the same errors as [`TranscriptReader::find_by_prefix`] and
1248    /// [`TranscriptReader::load_meta`].
1249    pub fn def_name_for_resume(
1250        &self,
1251        id_prefix: &str,
1252        config: &SubAgentConfig,
1253    ) -> Result<String, SubAgentError> {
1254        let dir = self.effective_transcript_dir(config);
1255        let original_id = TranscriptReader::find_by_prefix(&dir, id_prefix)?;
1256        let meta = TranscriptReader::load_meta(&dir, &original_id)?;
1257        Ok(meta.def_name)
1258    }
1259
1260    /// Return a snapshot of all active sub-agent statuses.
1261    #[must_use]
1262    pub fn statuses(&self) -> Vec<(String, SubAgentStatus)> {
1263        self.agents
1264            .values()
1265            .map(|h| {
1266                let mut status = h.status_rx.borrow().clone();
1267                // cancel() updates handle.state synchronously but the background task
1268                // may not have sent the final watch update yet; reflect it here.
1269                if h.state == SubAgentState::Canceled {
1270                    status.state = SubAgentState::Canceled;
1271                }
1272                (h.task_id.clone(), status)
1273            })
1274            .collect()
1275    }
1276
1277    /// Return the definition for a specific agent by `task_id`.
1278    #[must_use]
1279    pub fn agents_def(&self, task_id: &str) -> Option<&SubAgentDef> {
1280        self.agents.get(task_id).map(|h| &h.def)
1281    }
1282
1283    /// Return the transcript directory for a specific agent by `task_id`.
1284    #[must_use]
1285    pub fn agent_transcript_dir(&self, task_id: &str) -> Option<&std::path::Path> {
1286        self.agents
1287            .get(task_id)
1288            .and_then(|h| h.transcript_dir.as_deref())
1289    }
1290
1291    /// Spawn a sub-agent for an orchestrated task.
1292    ///
1293    /// Identical to [`spawn`][Self::spawn] but wraps the `JoinHandle` to send a
1294    /// `TaskEvent` on the provided channel when the agent loop
1295    /// terminates. This allows the `DagScheduler` to receive completion notifications
1296    /// without polling (ADR-027).
1297    ///
1298    /// The `event_tx` channel is best-effort: if the scheduler is dropped before all
1299    /// agents complete, the send will fail silently with a warning log.
1300    ///
1301    /// # Errors
1302    ///
1303    /// Same error conditions as [`spawn`][Self::spawn].
1304    ///
1305    /// # Panics
1306    ///
1307    /// Panics if the internal agent entry is missing after a successful `spawn` call.
1308    /// This is a programming error and should never occur in normal operation.
1309    #[allow(clippy::too_many_arguments)]
1310    /// Spawn a sub-agent and attach a completion callback invoked when the agent terminates.
1311    ///
1312    /// The callback receives the agent handle ID and the agent's result.
1313    /// The caller is responsible for translating this into orchestration events.
1314    ///
1315    /// # Errors
1316    ///
1317    /// Same error conditions as [`spawn`][Self::spawn].
1318    ///
1319    /// # Panics
1320    ///
1321    /// Panics if the internal agent entry is missing after a successful `spawn` call.
1322    /// This is a programming error and should never occur in normal operation.
1323    #[allow(clippy::too_many_arguments)]
1324    pub fn spawn_for_task<F>(
1325        &mut self,
1326        def_name: &str,
1327        task_prompt: &str,
1328        provider: AnyProvider,
1329        tool_executor: Arc<dyn ErasedToolExecutor>,
1330        skills: Option<Vec<String>>,
1331        config: &SubAgentConfig,
1332        ctx: SpawnContext,
1333        on_done: F,
1334    ) -> Result<String, SubAgentError>
1335    where
1336        F: FnOnce(String, Result<String, SubAgentError>) + Send + 'static,
1337    {
1338        let handle_id = self.spawn(
1339            def_name,
1340            task_prompt,
1341            provider,
1342            tool_executor,
1343            skills,
1344            config,
1345            ctx,
1346        )?;
1347
1348        let handle = self
1349            .agents
1350            .get_mut(&handle_id)
1351            .expect("just spawned agent must exist");
1352
1353        let original_join = handle
1354            .join_handle
1355            .take()
1356            .expect("just spawned agent must have a join handle");
1357
1358        let handle_id_clone = handle_id.clone();
1359        let wrapped_join: tokio::task::JoinHandle<Result<String, SubAgentError>> =
1360            tokio::spawn(async move {
1361                let result = original_join.await;
1362
1363                let (notify_result, output) = match result {
1364                    Ok(Ok(output)) => (Ok(output.clone()), Ok(output)),
1365                    Ok(Err(e)) => {
1366                        let msg = e.to_string();
1367                        (
1368                            Err(SubAgentError::Spawn(msg.clone())),
1369                            Err(SubAgentError::Spawn(msg)),
1370                        )
1371                    }
1372                    Err(join_err) => {
1373                        let msg = format!("task panicked: {join_err:?}");
1374                        (
1375                            Err(SubAgentError::TaskPanic(msg.clone())),
1376                            Err(SubAgentError::TaskPanic(msg)),
1377                        )
1378                    }
1379                };
1380
1381                on_done(handle_id_clone, notify_result);
1382
1383                output
1384            });
1385
1386        handle.join_handle = Some(wrapped_join);
1387
1388        Ok(handle_id)
1389    }
1390}
1391
1392#[cfg(test)]
1393mod tests {
1394    #![allow(
1395        clippy::await_holding_lock,
1396        clippy::field_reassign_with_default,
1397        clippy::too_many_lines
1398    )]
1399
1400    use std::pin::Pin;
1401
1402    use indoc::indoc;
1403    use zeph_llm::any::AnyProvider;
1404    use zeph_llm::mock::MockProvider;
1405    use zeph_tools::ToolCall;
1406    use zeph_tools::executor::{ErasedToolExecutor, ToolError, ToolOutput};
1407    use zeph_tools::registry::ToolDef;
1408
1409    use serial_test::serial;
1410
1411    use crate::agent_loop::{AgentLoopArgs, make_message, run_agent_loop};
1412    use crate::def::{MemoryScope, ModelSpec};
1413    use zeph_config::SubAgentConfig;
1414    use zeph_llm::provider::ChatResponse;
1415
1416    use super::*;
1417
1418    fn make_manager() -> SubAgentManager {
1419        SubAgentManager::new(4)
1420    }
1421
1422    fn sample_def() -> SubAgentDef {
1423        SubAgentDef::parse("---\nname: bot\ndescription: A bot\n---\n\nDo things.\n").unwrap()
1424    }
1425
1426    fn def_with_secrets() -> SubAgentDef {
1427        SubAgentDef::parse(
1428            "---\nname: bot\ndescription: A bot\npermissions:\n  secrets:\n    - api-key\n---\n\nDo things.\n",
1429        )
1430        .unwrap()
1431    }
1432
1433    struct NoopExecutor;
1434
1435    impl ErasedToolExecutor for NoopExecutor {
1436        fn execute_erased<'a>(
1437            &'a self,
1438            _response: &'a str,
1439        ) -> Pin<
1440            Box<
1441                dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
1442            >,
1443        > {
1444            Box::pin(std::future::ready(Ok(None)))
1445        }
1446
1447        fn execute_confirmed_erased<'a>(
1448            &'a self,
1449            _response: &'a str,
1450        ) -> Pin<
1451            Box<
1452                dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
1453            >,
1454        > {
1455            Box::pin(std::future::ready(Ok(None)))
1456        }
1457
1458        fn tool_definitions_erased(&self) -> Vec<ToolDef> {
1459            vec![]
1460        }
1461
1462        fn execute_tool_call_erased<'a>(
1463            &'a self,
1464            _call: &'a ToolCall,
1465        ) -> Pin<
1466            Box<
1467                dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
1468            >,
1469        > {
1470            Box::pin(std::future::ready(Ok(None)))
1471        }
1472
1473        fn is_tool_retryable_erased(&self, _tool_id: &str) -> bool {
1474            false
1475        }
1476    }
1477
1478    fn mock_provider(responses: Vec<&str>) -> AnyProvider {
1479        AnyProvider::Mock(MockProvider::with_responses(
1480            responses.into_iter().map(String::from).collect(),
1481        ))
1482    }
1483
1484    fn noop_executor() -> Arc<dyn ErasedToolExecutor> {
1485        Arc::new(NoopExecutor)
1486    }
1487
1488    fn do_spawn(
1489        mgr: &mut SubAgentManager,
1490        name: &str,
1491        prompt: &str,
1492    ) -> Result<String, SubAgentError> {
1493        mgr.spawn(
1494            name,
1495            prompt,
1496            mock_provider(vec!["done"]),
1497            noop_executor(),
1498            None,
1499            &SubAgentConfig::default(),
1500            SpawnContext::default(),
1501        )
1502    }
1503
1504    #[test]
1505    fn load_definitions_populates_vec() {
1506        use std::io::Write as _;
1507        let dir = tempfile::tempdir().unwrap();
1508        let content = "---\nname: helper\ndescription: A helper\n---\n\nHelp.\n";
1509        let mut f = std::fs::File::create(dir.path().join("helper.md")).unwrap();
1510        f.write_all(content.as_bytes()).unwrap();
1511
1512        let mut mgr = make_manager();
1513        mgr.load_definitions(&[dir.path().to_path_buf()]).unwrap();
1514        assert_eq!(mgr.definitions().len(), 1);
1515        assert_eq!(mgr.definitions()[0].name, "helper");
1516    }
1517
1518    #[test]
1519    fn spawn_not_found_error() {
1520        let rt = tokio::runtime::Runtime::new().unwrap();
1521        let _guard = rt.enter();
1522        let mut mgr = make_manager();
1523        let err = do_spawn(&mut mgr, "nonexistent", "prompt").unwrap_err();
1524        assert!(matches!(err, SubAgentError::NotFound(_)));
1525    }
1526
1527    #[test]
1528    fn spawn_and_cancel() {
1529        let rt = tokio::runtime::Runtime::new().unwrap();
1530        let _guard = rt.enter();
1531        let mut mgr = make_manager();
1532        mgr.definitions.push(sample_def());
1533
1534        let task_id = do_spawn(&mut mgr, "bot", "do stuff").unwrap();
1535        assert!(!task_id.is_empty());
1536
1537        mgr.cancel(&task_id).unwrap();
1538        assert_eq!(mgr.agents[&task_id].state, SubAgentState::Canceled);
1539    }
1540
1541    #[test]
1542    fn cancel_unknown_task_id_returns_not_found() {
1543        let mut mgr = make_manager();
1544        let err = mgr.cancel("unknown-id").unwrap_err();
1545        assert!(matches!(err, SubAgentError::NotFound(_)));
1546    }
1547
1548    #[tokio::test]
1549    async fn collect_removes_agent() {
1550        let mut mgr = make_manager();
1551        mgr.definitions.push(sample_def());
1552
1553        let task_id = do_spawn(&mut mgr, "bot", "do stuff").unwrap();
1554        mgr.cancel(&task_id).unwrap();
1555
1556        // Wait briefly for the task to observe cancellation
1557        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1558
1559        let result = mgr.collect(&task_id).await.unwrap();
1560        assert!(!mgr.agents.contains_key(&task_id));
1561        // result may be empty string (cancelled before LLM response) or the mock response
1562        let _ = result;
1563    }
1564
1565    #[tokio::test]
1566    async fn collect_unknown_task_id_returns_not_found() {
1567        let mut mgr = make_manager();
1568        let err = mgr.collect("unknown-id").await.unwrap_err();
1569        assert!(matches!(err, SubAgentError::NotFound(_)));
1570    }
1571
1572    #[test]
1573    fn approve_secret_grants_access() {
1574        let rt = tokio::runtime::Runtime::new().unwrap();
1575        let _guard = rt.enter();
1576        let mut mgr = make_manager();
1577        mgr.definitions.push(def_with_secrets());
1578
1579        let task_id = do_spawn(&mut mgr, "bot", "work").unwrap();
1580        mgr.approve_secret(&task_id, "api-key", std::time::Duration::from_secs(60))
1581            .unwrap();
1582
1583        let handle = mgr.agents.get_mut(&task_id).unwrap();
1584        assert!(
1585            handle
1586                .grants
1587                .is_active(&crate::grants::GrantKind::Secret("api-key".into()))
1588        );
1589    }
1590
1591    #[test]
1592    fn approve_secret_denied_for_unlisted_key() {
1593        let rt = tokio::runtime::Runtime::new().unwrap();
1594        let _guard = rt.enter();
1595        let mut mgr = make_manager();
1596        mgr.definitions.push(sample_def()); // no secrets in allowed list
1597
1598        let task_id = do_spawn(&mut mgr, "bot", "work").unwrap();
1599        let err = mgr
1600            .approve_secret(&task_id, "not-allowed", std::time::Duration::from_secs(60))
1601            .unwrap_err();
1602        assert!(matches!(err, SubAgentError::Invalid(_)));
1603    }
1604
1605    #[test]
1606    fn approve_secret_unknown_task_id_returns_not_found() {
1607        let mut mgr = make_manager();
1608        let err = mgr
1609            .approve_secret("unknown", "key", std::time::Duration::from_secs(60))
1610            .unwrap_err();
1611        assert!(matches!(err, SubAgentError::NotFound(_)));
1612    }
1613
1614    #[test]
1615    fn statuses_returns_active_agents() {
1616        let rt = tokio::runtime::Runtime::new().unwrap();
1617        let _guard = rt.enter();
1618        let mut mgr = make_manager();
1619        mgr.definitions.push(sample_def());
1620
1621        let task_id = do_spawn(&mut mgr, "bot", "work").unwrap();
1622        let statuses = mgr.statuses();
1623        assert_eq!(statuses.len(), 1);
1624        assert_eq!(statuses[0].0, task_id);
1625    }
1626
1627    #[test]
1628    fn concurrency_limit_enforced() {
1629        let rt = tokio::runtime::Runtime::new().unwrap();
1630        let _guard = rt.enter();
1631        let mut mgr = SubAgentManager::new(1);
1632        mgr.definitions.push(sample_def());
1633
1634        let _first = do_spawn(&mut mgr, "bot", "first").unwrap();
1635        let err = do_spawn(&mut mgr, "bot", "second").unwrap_err();
1636        assert!(matches!(err, SubAgentError::ConcurrencyLimit { .. }));
1637    }
1638
1639    // --- #1619 regression tests: reserved_slots ---
1640
1641    #[test]
1642    fn test_reserve_slots_blocks_spawn() {
1643        // max_concurrent=2, reserved=1, active=1 → active+reserved >= max → ConcurrencyLimit.
1644        let rt = tokio::runtime::Runtime::new().unwrap();
1645        let _guard = rt.enter();
1646        let mut mgr = SubAgentManager::new(2);
1647        mgr.definitions.push(sample_def());
1648
1649        // Occupy one slot.
1650        let _first = do_spawn(&mut mgr, "bot", "first").unwrap();
1651        // Reserve the remaining slot.
1652        mgr.reserve_slots(1);
1653        // Now active(1) + reserved(1) >= max_concurrent(2) → should reject.
1654        let err = do_spawn(&mut mgr, "bot", "second").unwrap_err();
1655        assert!(
1656            matches!(err, SubAgentError::ConcurrencyLimit { .. }),
1657            "expected ConcurrencyLimit, got: {err}"
1658        );
1659    }
1660
1661    #[test]
1662    fn test_release_reservation_allows_spawn() {
1663        // After release_reservation(), the reserved slot is freed and spawn succeeds.
1664        let rt = tokio::runtime::Runtime::new().unwrap();
1665        let _guard = rt.enter();
1666        let mut mgr = SubAgentManager::new(2);
1667        mgr.definitions.push(sample_def());
1668
1669        // Reserve one slot (no active agents yet).
1670        mgr.reserve_slots(1);
1671        // active(0) + reserved(1) < max_concurrent(2), so one more spawn is allowed.
1672        let _first = do_spawn(&mut mgr, "bot", "first").unwrap();
1673        // Now active(1) + reserved(1) >= max_concurrent(2) → blocked.
1674        let err = do_spawn(&mut mgr, "bot", "second").unwrap_err();
1675        assert!(matches!(err, SubAgentError::ConcurrencyLimit { .. }));
1676
1677        // Release the reservation — active(1) + reserved(0) < max_concurrent(2).
1678        mgr.release_reservation(1);
1679        let result = do_spawn(&mut mgr, "bot", "third");
1680        assert!(
1681            result.is_ok(),
1682            "spawn must succeed after release_reservation, got: {result:?}"
1683        );
1684    }
1685
1686    #[test]
1687    fn test_reservation_with_zero_active_blocks_spawn() {
1688        // Reserved slots alone (no active agents) should block spawn when reserved >= max.
1689        let rt = tokio::runtime::Runtime::new().unwrap();
1690        let _guard = rt.enter();
1691        let mut mgr = SubAgentManager::new(2);
1692        mgr.definitions.push(sample_def());
1693
1694        // Reserve all slots — no active agents.
1695        mgr.reserve_slots(2);
1696        // active(0) + reserved(2) >= max_concurrent(2) → blocked.
1697        let err = do_spawn(&mut mgr, "bot", "first").unwrap_err();
1698        assert!(
1699            matches!(err, SubAgentError::ConcurrencyLimit { .. }),
1700            "reservation alone must block spawn when reserved >= max_concurrent"
1701        );
1702    }
1703
1704    #[tokio::test]
1705    async fn background_agent_does_not_block_caller() {
1706        let mut mgr = make_manager();
1707        mgr.definitions.push(sample_def());
1708
1709        // Spawn should return immediately without waiting for LLM
1710        let result = tokio::time::timeout(
1711            std::time::Duration::from_millis(100),
1712            std::future::ready(do_spawn(&mut mgr, "bot", "work")),
1713        )
1714        .await;
1715        assert!(result.is_ok(), "spawn() must not block");
1716        assert!(result.unwrap().is_ok());
1717    }
1718
1719    #[tokio::test]
1720    async fn max_turns_terminates_agent_loop() {
1721        let mut mgr = make_manager();
1722        // max_turns = 1, mock returns empty (no tool call), so loop ends after 1 turn
1723        let def = SubAgentDef::parse(indoc! {"
1724            ---
1725            name: limited
1726            description: A bot
1727            permissions:
1728              max_turns: 1
1729            ---
1730
1731            Do one thing.
1732        "})
1733        .unwrap();
1734        mgr.definitions.push(def);
1735
1736        let task_id = mgr
1737            .spawn(
1738                "limited",
1739                "task",
1740                mock_provider(vec!["final answer"]),
1741                noop_executor(),
1742                None,
1743                &SubAgentConfig::default(),
1744                SpawnContext::default(),
1745            )
1746            .unwrap();
1747
1748        // Wait for completion
1749        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1750
1751        let status = mgr.statuses().into_iter().find(|(id, _)| id == &task_id);
1752        // Status should show Completed or still Working but <= 1 turn
1753        if let Some((_, s)) = status {
1754            assert!(s.turns_used <= 1);
1755        }
1756    }
1757
1758    #[tokio::test]
1759    async fn cancellation_token_stops_agent_loop() {
1760        let mut mgr = make_manager();
1761        mgr.definitions.push(sample_def());
1762
1763        let task_id = do_spawn(&mut mgr, "bot", "long task").unwrap();
1764
1765        // Cancel immediately
1766        mgr.cancel(&task_id).unwrap();
1767
1768        // Wait a bit then collect
1769        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1770        let result = mgr.collect(&task_id).await;
1771        // Cancelled task may return empty or partial result — both are acceptable
1772        assert!(result.is_ok() || result.is_err());
1773    }
1774
1775    #[tokio::test]
1776    async fn shutdown_all_cancels_all_active_agents() {
1777        let mut mgr = make_manager();
1778        mgr.definitions.push(sample_def());
1779
1780        do_spawn(&mut mgr, "bot", "task 1").unwrap();
1781        do_spawn(&mut mgr, "bot", "task 2").unwrap();
1782
1783        assert_eq!(mgr.agents.len(), 2);
1784        mgr.shutdown_all();
1785
1786        // All agents should be in Canceled state
1787        for (_, status) in mgr.statuses() {
1788            assert_eq!(status.state, SubAgentState::Canceled);
1789        }
1790    }
1791
1792    #[test]
1793    fn debug_impl_does_not_expose_sensitive_fields() {
1794        let rt = tokio::runtime::Runtime::new().unwrap();
1795        let _guard = rt.enter();
1796        let mut mgr = make_manager();
1797        mgr.definitions.push(def_with_secrets());
1798        let task_id = do_spawn(&mut mgr, "bot", "work").unwrap();
1799        let handle = &mgr.agents[&task_id];
1800        let debug_str = format!("{handle:?}");
1801        // SubAgentHandle Debug must not expose grant contents or secrets
1802        assert!(!debug_str.contains("api-key"));
1803    }
1804
1805    #[tokio::test]
1806    async fn llm_failure_transitions_to_failed_state() {
1807        let rt_handle = tokio::runtime::Handle::current();
1808        let _guard = rt_handle.enter();
1809        let mut mgr = make_manager();
1810        mgr.definitions.push(sample_def());
1811
1812        let failing = AnyProvider::Mock(MockProvider::failing());
1813        let task_id = mgr
1814            .spawn(
1815                "bot",
1816                "do work",
1817                failing,
1818                noop_executor(),
1819                None,
1820                &SubAgentConfig::default(),
1821                SpawnContext::default(),
1822            )
1823            .unwrap();
1824
1825        // Wait for the background task to complete.
1826        tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
1827
1828        let statuses = mgr.statuses();
1829        let status = statuses
1830            .iter()
1831            .find(|(id, _)| id == &task_id)
1832            .map(|(_, s)| s);
1833        // The background loop should have caught the LLM error and reported Failed.
1834        assert!(
1835            status.is_some_and(|s| s.state == SubAgentState::Failed),
1836            "expected Failed, got: {status:?}"
1837        );
1838    }
1839
1840    #[tokio::test]
1841    async fn tool_call_loop_two_turns() {
1842        use std::sync::Mutex;
1843        use zeph_llm::mock::MockProvider;
1844        use zeph_llm::provider::{ChatResponse, ToolUseRequest};
1845        use zeph_tools::ToolCall;
1846
1847        struct ToolOnceExecutor {
1848            calls: Mutex<u32>,
1849        }
1850
1851        impl ErasedToolExecutor for ToolOnceExecutor {
1852            fn execute_erased<'a>(
1853                &'a self,
1854                _response: &'a str,
1855            ) -> Pin<
1856                Box<
1857                    dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>>
1858                        + Send
1859                        + 'a,
1860                >,
1861            > {
1862                Box::pin(std::future::ready(Ok(None)))
1863            }
1864
1865            fn execute_confirmed_erased<'a>(
1866                &'a self,
1867                _response: &'a str,
1868            ) -> Pin<
1869                Box<
1870                    dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>>
1871                        + Send
1872                        + 'a,
1873                >,
1874            > {
1875                Box::pin(std::future::ready(Ok(None)))
1876            }
1877
1878            fn tool_definitions_erased(&self) -> Vec<ToolDef> {
1879                vec![]
1880            }
1881
1882            fn execute_tool_call_erased<'a>(
1883                &'a self,
1884                call: &'a ToolCall,
1885            ) -> Pin<
1886                Box<
1887                    dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>>
1888                        + Send
1889                        + 'a,
1890                >,
1891            > {
1892                let mut n = self.calls.lock().unwrap();
1893                *n += 1;
1894                let result = if *n == 1 {
1895                    Ok(Some(ToolOutput {
1896                        tool_name: call.tool_id.clone(),
1897                        summary: "step 1 done".into(),
1898                        blocks_executed: 1,
1899                        filter_stats: None,
1900                        diff: None,
1901                        streamed: false,
1902                        terminal_id: None,
1903                        locations: None,
1904                        raw_response: None,
1905                        claim_source: None,
1906                    }))
1907                } else {
1908                    Ok(None)
1909                };
1910                Box::pin(std::future::ready(result))
1911            }
1912
1913            fn is_tool_retryable_erased(&self, _tool_id: &str) -> bool {
1914                false
1915            }
1916        }
1917
1918        let rt_handle = tokio::runtime::Handle::current();
1919        let _guard = rt_handle.enter();
1920        let mut mgr = make_manager();
1921        mgr.definitions.push(sample_def());
1922
1923        // First response: ToolUse with a shell call; second: Text with final answer.
1924        let tool_response = ChatResponse::ToolUse {
1925            text: None,
1926            tool_calls: vec![ToolUseRequest {
1927                id: "call-1".into(),
1928                name: "shell".into(),
1929                input: serde_json::json!({"command": "echo hi"}),
1930            }],
1931            thinking_blocks: vec![],
1932        };
1933        let (mock, _counter) = MockProvider::default().with_tool_use(vec![
1934            tool_response,
1935            ChatResponse::Text("final answer".into()),
1936        ]);
1937        let provider = AnyProvider::Mock(mock);
1938        let executor = Arc::new(ToolOnceExecutor {
1939            calls: Mutex::new(0),
1940        });
1941
1942        let task_id = mgr
1943            .spawn(
1944                "bot",
1945                "run two turns",
1946                provider,
1947                executor,
1948                None,
1949                &SubAgentConfig::default(),
1950                SpawnContext::default(),
1951            )
1952            .unwrap();
1953
1954        // Wait for background loop to finish.
1955        tokio::time::sleep(tokio::time::Duration::from_millis(300)).await;
1956
1957        let result = mgr.collect(&task_id).await;
1958        assert!(result.is_ok(), "expected Ok, got: {result:?}");
1959    }
1960
1961    #[tokio::test]
1962    async fn collect_on_running_task_completes_eventually() {
1963        let mut mgr = make_manager();
1964        mgr.definitions.push(sample_def());
1965
1966        // Spawn with a slow response so the task is still running.
1967        let task_id = do_spawn(&mut mgr, "bot", "slow work").unwrap();
1968
1969        // collect() awaits the JoinHandle, so it will finish when the task completes.
1970        let result =
1971            tokio::time::timeout(tokio::time::Duration::from_secs(5), mgr.collect(&task_id)).await;
1972
1973        assert!(result.is_ok(), "collect timed out after 5s");
1974        let inner = result.unwrap();
1975        assert!(inner.is_ok(), "collect returned error: {inner:?}");
1976    }
1977
1978    #[test]
1979    fn concurrency_slot_freed_after_cancel() {
1980        let rt = tokio::runtime::Runtime::new().unwrap();
1981        let _guard = rt.enter();
1982        let mut mgr = SubAgentManager::new(1); // limit to 1
1983        mgr.definitions.push(sample_def());
1984
1985        let id1 = do_spawn(&mut mgr, "bot", "task 1").unwrap();
1986
1987        // Concurrency limit reached — second spawn should fail.
1988        let err = do_spawn(&mut mgr, "bot", "task 2").unwrap_err();
1989        assert!(
1990            matches!(err, SubAgentError::ConcurrencyLimit { .. }),
1991            "expected concurrency limit error, got: {err}"
1992        );
1993
1994        // Cancel the first agent to free the slot.
1995        mgr.cancel(&id1).unwrap();
1996
1997        // Now a new spawn should succeed.
1998        let result = do_spawn(&mut mgr, "bot", "task 3");
1999        assert!(
2000            result.is_ok(),
2001            "expected spawn to succeed after cancel, got: {result:?}"
2002        );
2003    }
2004
2005    #[tokio::test]
2006    async fn skill_bodies_prepended_to_system_prompt() {
2007        // Verify that when skills are passed to spawn(), the agent loop prepends
2008        // them to the system prompt inside a ```skills fence.
2009        use zeph_llm::mock::MockProvider;
2010
2011        let (mock, recorded) = MockProvider::default().with_recording();
2012        let provider = AnyProvider::Mock(mock);
2013
2014        let mut mgr = make_manager();
2015        mgr.definitions.push(sample_def());
2016
2017        let skill_bodies = vec!["# skill-one\nDo something useful.".to_owned()];
2018        let task_id = mgr
2019            .spawn(
2020                "bot",
2021                "task",
2022                provider,
2023                noop_executor(),
2024                Some(skill_bodies),
2025                &SubAgentConfig::default(),
2026                SpawnContext::default(),
2027            )
2028            .unwrap();
2029
2030        // Wait for the loop to call the provider at least once.
2031        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
2032
2033        let calls = recorded.lock().unwrap();
2034        assert!(!calls.is_empty(), "provider should have been called");
2035        // The first message in the first call is the system prompt.
2036        let system_msg = &calls[0][0].content;
2037        assert!(
2038            system_msg.contains("```skills"),
2039            "system prompt must contain ```skills fence, got: {system_msg}"
2040        );
2041        assert!(
2042            system_msg.contains("skill-one"),
2043            "system prompt must contain the skill body, got: {system_msg}"
2044        );
2045        drop(calls);
2046
2047        let _ = mgr.collect(&task_id).await;
2048    }
2049
2050    #[tokio::test]
2051    async fn no_skills_does_not_add_fence_to_system_prompt() {
2052        use zeph_llm::mock::MockProvider;
2053
2054        let (mock, recorded) = MockProvider::default().with_recording();
2055        let provider = AnyProvider::Mock(mock);
2056
2057        let mut mgr = make_manager();
2058        mgr.definitions.push(sample_def());
2059
2060        let task_id = mgr
2061            .spawn(
2062                "bot",
2063                "task",
2064                provider,
2065                noop_executor(),
2066                None,
2067                &SubAgentConfig::default(),
2068                SpawnContext::default(),
2069            )
2070            .unwrap();
2071
2072        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
2073
2074        let calls = recorded.lock().unwrap();
2075        assert!(!calls.is_empty());
2076        let system_msg = &calls[0][0].content;
2077        assert!(
2078            !system_msg.contains("```skills"),
2079            "system prompt must not contain skills fence when no skills passed"
2080        );
2081        drop(calls);
2082
2083        let _ = mgr.collect(&task_id).await;
2084    }
2085
2086    #[tokio::test]
2087    async fn statuses_does_not_include_collected_task() {
2088        let mut mgr = make_manager();
2089        mgr.definitions.push(sample_def());
2090
2091        let task_id = do_spawn(&mut mgr, "bot", "task").unwrap();
2092        assert_eq!(mgr.statuses().len(), 1);
2093
2094        // Wait for task completion then collect.
2095        tokio::time::sleep(tokio::time::Duration::from_millis(300)).await;
2096        let _ = mgr.collect(&task_id).await;
2097
2098        // After collect(), the task should no longer appear in statuses.
2099        assert!(
2100            mgr.statuses().is_empty(),
2101            "expected empty statuses after collect"
2102        );
2103    }
2104
2105    #[tokio::test]
2106    async fn background_agent_auto_denies_secret_request() {
2107        use zeph_llm::mock::MockProvider;
2108
2109        // Background agent that requests a secret — the loop must auto-deny without blocking.
2110        let def = SubAgentDef::parse(indoc! {"
2111            ---
2112            name: bg-bot
2113            description: Background bot
2114            permissions:
2115              background: true
2116              secrets:
2117                - api-key
2118            ---
2119
2120            [REQUEST_SECRET: api-key]
2121        "})
2122        .unwrap();
2123
2124        let (mock, recorded) = MockProvider::default().with_recording();
2125        let provider = AnyProvider::Mock(mock);
2126
2127        let mut mgr = make_manager();
2128        mgr.definitions.push(def);
2129
2130        let task_id = mgr
2131            .spawn(
2132                "bg-bot",
2133                "task",
2134                provider,
2135                noop_executor(),
2136                None,
2137                &SubAgentConfig::default(),
2138                SpawnContext::default(),
2139            )
2140            .unwrap();
2141
2142        // Should complete without blocking — background auto-denies the secret.
2143        let result =
2144            tokio::time::timeout(tokio::time::Duration::from_secs(2), mgr.collect(&task_id)).await;
2145        assert!(
2146            result.is_ok(),
2147            "background agent must not block on secret request"
2148        );
2149        drop(recorded);
2150    }
2151
2152    #[test]
2153    fn spawn_with_plan_mode_definition_succeeds() {
2154        let rt = tokio::runtime::Runtime::new().unwrap();
2155        let _guard = rt.enter();
2156
2157        let def = SubAgentDef::parse(indoc! {"
2158            ---
2159            name: planner
2160            description: A planner bot
2161            permissions:
2162              permission_mode: plan
2163            ---
2164
2165            Plan only.
2166        "})
2167        .unwrap();
2168
2169        let mut mgr = make_manager();
2170        mgr.definitions.push(def);
2171
2172        let task_id = do_spawn(&mut mgr, "planner", "make a plan").unwrap();
2173        assert!(!task_id.is_empty());
2174        mgr.cancel(&task_id).unwrap();
2175    }
2176
2177    #[test]
2178    fn spawn_with_disallowed_tools_definition_succeeds() {
2179        let rt = tokio::runtime::Runtime::new().unwrap();
2180        let _guard = rt.enter();
2181
2182        let def = SubAgentDef::parse(indoc! {"
2183            ---
2184            name: safe-bot
2185            description: Bot with disallowed tools
2186            tools:
2187              allow:
2188                - shell
2189                - web
2190              except:
2191                - shell
2192            ---
2193
2194            Do safe things.
2195        "})
2196        .unwrap();
2197
2198        assert_eq!(def.disallowed_tools, ["shell"]);
2199
2200        let mut mgr = make_manager();
2201        mgr.definitions.push(def);
2202
2203        let task_id = do_spawn(&mut mgr, "safe-bot", "task").unwrap();
2204        assert!(!task_id.is_empty());
2205        mgr.cancel(&task_id).unwrap();
2206    }
2207
2208    // ── #1180: default_permission_mode / default_disallowed_tools applied at spawn ──
2209
2210    #[test]
2211    fn spawn_applies_default_permission_mode_from_config() {
2212        let rt = tokio::runtime::Runtime::new().unwrap();
2213        let _guard = rt.enter();
2214
2215        // Agent has Default permission mode — config sets Plan as default.
2216        let def =
2217            SubAgentDef::parse("---\nname: bot\ndescription: A bot\n---\n\nDo things.\n").unwrap();
2218        assert_eq!(def.permissions.permission_mode, PermissionMode::Default);
2219
2220        let mut mgr = make_manager();
2221        mgr.definitions.push(def);
2222
2223        let cfg = SubAgentConfig {
2224            default_permission_mode: Some(PermissionMode::Plan),
2225            ..SubAgentConfig::default()
2226        };
2227
2228        let task_id = mgr
2229            .spawn(
2230                "bot",
2231                "prompt",
2232                mock_provider(vec!["done"]),
2233                noop_executor(),
2234                None,
2235                &cfg,
2236                SpawnContext::default(),
2237            )
2238            .unwrap();
2239        assert!(!task_id.is_empty());
2240        mgr.cancel(&task_id).unwrap();
2241    }
2242
2243    #[test]
2244    fn spawn_does_not_override_explicit_permission_mode() {
2245        let rt = tokio::runtime::Runtime::new().unwrap();
2246        let _guard = rt.enter();
2247
2248        // Agent explicitly sets DontAsk — config default must not override it.
2249        let def = SubAgentDef::parse(indoc! {"
2250            ---
2251            name: bot
2252            description: A bot
2253            permissions:
2254              permission_mode: dont_ask
2255            ---
2256
2257            Do things.
2258        "})
2259        .unwrap();
2260        assert_eq!(def.permissions.permission_mode, PermissionMode::DontAsk);
2261
2262        let mut mgr = make_manager();
2263        mgr.definitions.push(def);
2264
2265        let cfg = SubAgentConfig {
2266            default_permission_mode: Some(PermissionMode::Plan),
2267            ..SubAgentConfig::default()
2268        };
2269
2270        let task_id = mgr
2271            .spawn(
2272                "bot",
2273                "prompt",
2274                mock_provider(vec!["done"]),
2275                noop_executor(),
2276                None,
2277                &cfg,
2278                SpawnContext::default(),
2279            )
2280            .unwrap();
2281        assert!(!task_id.is_empty());
2282        mgr.cancel(&task_id).unwrap();
2283    }
2284
2285    #[test]
2286    fn spawn_merges_global_disallowed_tools() {
2287        let rt = tokio::runtime::Runtime::new().unwrap();
2288        let _guard = rt.enter();
2289
2290        let def =
2291            SubAgentDef::parse("---\nname: bot\ndescription: A bot\n---\n\nDo things.\n").unwrap();
2292
2293        let mut mgr = make_manager();
2294        mgr.definitions.push(def);
2295
2296        let cfg = SubAgentConfig {
2297            default_disallowed_tools: vec!["dangerous".into()],
2298            ..SubAgentConfig::default()
2299        };
2300
2301        let task_id = mgr
2302            .spawn(
2303                "bot",
2304                "prompt",
2305                mock_provider(vec!["done"]),
2306                noop_executor(),
2307                None,
2308                &cfg,
2309                SpawnContext::default(),
2310            )
2311            .unwrap();
2312        assert!(!task_id.is_empty());
2313        mgr.cancel(&task_id).unwrap();
2314    }
2315
2316    // ── #1182: bypass_permissions blocked without config gate ─────────────
2317
2318    #[test]
2319    fn spawn_bypass_permissions_without_config_gate_is_error() {
2320        let rt = tokio::runtime::Runtime::new().unwrap();
2321        let _guard = rt.enter();
2322
2323        let def = SubAgentDef::parse(indoc! {"
2324            ---
2325            name: bypass-bot
2326            description: A bot with bypass mode
2327            permissions:
2328              permission_mode: bypass_permissions
2329            ---
2330
2331            Unrestricted.
2332        "})
2333        .unwrap();
2334
2335        let mut mgr = make_manager();
2336        mgr.definitions.push(def);
2337
2338        // Default config: allow_bypass_permissions = false
2339        let cfg = SubAgentConfig::default();
2340        let err = mgr
2341            .spawn(
2342                "bypass-bot",
2343                "prompt",
2344                mock_provider(vec!["done"]),
2345                noop_executor(),
2346                None,
2347                &cfg,
2348                SpawnContext::default(),
2349            )
2350            .unwrap_err();
2351        assert!(matches!(err, SubAgentError::Invalid(_)));
2352    }
2353
2354    #[test]
2355    fn spawn_bypass_permissions_with_config_gate_succeeds() {
2356        let rt = tokio::runtime::Runtime::new().unwrap();
2357        let _guard = rt.enter();
2358
2359        let def = SubAgentDef::parse(indoc! {"
2360            ---
2361            name: bypass-bot
2362            description: A bot with bypass mode
2363            permissions:
2364              permission_mode: bypass_permissions
2365            ---
2366
2367            Unrestricted.
2368        "})
2369        .unwrap();
2370
2371        let mut mgr = make_manager();
2372        mgr.definitions.push(def);
2373
2374        let cfg = SubAgentConfig {
2375            allow_bypass_permissions: true,
2376            ..SubAgentConfig::default()
2377        };
2378
2379        let task_id = mgr
2380            .spawn(
2381                "bypass-bot",
2382                "prompt",
2383                mock_provider(vec!["done"]),
2384                noop_executor(),
2385                None,
2386                &cfg,
2387                SpawnContext::default(),
2388            )
2389            .unwrap();
2390        assert!(!task_id.is_empty());
2391        mgr.cancel(&task_id).unwrap();
2392    }
2393
2394    // ── resume() tests ────────────────────────────────────────────────────────
2395
2396    /// Write a minimal completed meta file and empty JSONL so `resume()` has something to load.
2397    fn write_completed_meta(dir: &std::path::Path, agent_id: &str, def_name: &str) {
2398        use crate::transcript::{TranscriptMeta, TranscriptWriter};
2399        let meta = TranscriptMeta {
2400            agent_id: agent_id.to_owned(),
2401            agent_name: def_name.to_owned(),
2402            def_name: def_name.to_owned(),
2403            status: SubAgentState::Completed,
2404            started_at: "2026-01-01T00:00:00Z".to_owned(),
2405            finished_at: Some("2026-01-01T00:01:00Z".to_owned()),
2406            resumed_from: None,
2407            turns_used: 1,
2408        };
2409        TranscriptWriter::write_meta(dir, agent_id, &meta).unwrap();
2410        // Create the empty JSONL so TranscriptReader::load succeeds.
2411        std::fs::write(dir.join(format!("{agent_id}.jsonl")), b"").unwrap();
2412    }
2413
2414    fn make_cfg_with_dir(dir: &std::path::Path) -> SubAgentConfig {
2415        SubAgentConfig {
2416            transcript_dir: Some(dir.to_path_buf()),
2417            ..SubAgentConfig::default()
2418        }
2419    }
2420
2421    #[test]
2422    fn resume_not_found_returns_not_found_error() {
2423        let rt = tokio::runtime::Runtime::new().unwrap();
2424        let _guard = rt.enter();
2425
2426        let tmp = tempfile::tempdir().unwrap();
2427        let mut mgr = make_manager();
2428        mgr.definitions.push(sample_def());
2429        let cfg = make_cfg_with_dir(tmp.path());
2430
2431        let err = mgr
2432            .resume(
2433                "deadbeef",
2434                "continue",
2435                mock_provider(vec!["done"]),
2436                noop_executor(),
2437                None,
2438                &cfg,
2439            )
2440            .unwrap_err();
2441        assert!(matches!(err, SubAgentError::NotFound(_)));
2442    }
2443
2444    #[test]
2445    fn resume_ambiguous_id_returns_ambiguous_error() {
2446        let rt = tokio::runtime::Runtime::new().unwrap();
2447        let _guard = rt.enter();
2448
2449        let tmp = tempfile::tempdir().unwrap();
2450        write_completed_meta(tmp.path(), "aabb0001-0000-0000-0000-000000000000", "bot");
2451        write_completed_meta(tmp.path(), "aabb0002-0000-0000-0000-000000000000", "bot");
2452
2453        let mut mgr = make_manager();
2454        mgr.definitions.push(sample_def());
2455        let cfg = make_cfg_with_dir(tmp.path());
2456
2457        let err = mgr
2458            .resume(
2459                "aabb",
2460                "continue",
2461                mock_provider(vec!["done"]),
2462                noop_executor(),
2463                None,
2464                &cfg,
2465            )
2466            .unwrap_err();
2467        assert!(matches!(err, SubAgentError::AmbiguousId(_, 2)));
2468    }
2469
2470    #[test]
2471    fn resume_still_running_via_active_agents_returns_error() {
2472        let rt = tokio::runtime::Runtime::new().unwrap();
2473        let _guard = rt.enter();
2474
2475        let tmp = tempfile::tempdir().unwrap();
2476        let agent_id = "cafebabe-0000-0000-0000-000000000000";
2477        write_completed_meta(tmp.path(), agent_id, "bot");
2478
2479        let mut mgr = make_manager();
2480        mgr.definitions.push(sample_def());
2481
2482        // Manually insert a fake active handle so resume() thinks it's still running.
2483        let (status_tx, status_rx) = watch::channel(SubAgentStatus {
2484            state: SubAgentState::Working,
2485            last_message: None,
2486            turns_used: 0,
2487            started_at: std::time::Instant::now(),
2488        });
2489        let (_secret_request_tx, pending_secret_rx) = tokio::sync::mpsc::channel(1);
2490        let (secret_tx, _secret_rx) = tokio::sync::mpsc::channel(1);
2491        let cancel = CancellationToken::new();
2492        let fake_def = sample_def();
2493        mgr.agents.insert(
2494            agent_id.to_owned(),
2495            SubAgentHandle {
2496                id: agent_id.to_owned(),
2497                def: fake_def,
2498                task_id: agent_id.to_owned(),
2499                state: SubAgentState::Working,
2500                join_handle: None,
2501                cancel,
2502                status_rx,
2503                grants: PermissionGrants::default(),
2504                pending_secret_rx,
2505                secret_tx,
2506                started_at_str: "2026-01-01T00:00:00Z".to_owned(),
2507                transcript_dir: None,
2508            },
2509        );
2510        drop(status_tx);
2511
2512        let cfg = make_cfg_with_dir(tmp.path());
2513        let err = mgr
2514            .resume(
2515                agent_id,
2516                "continue",
2517                mock_provider(vec!["done"]),
2518                noop_executor(),
2519                None,
2520                &cfg,
2521            )
2522            .unwrap_err();
2523        assert!(matches!(err, SubAgentError::StillRunning(_)));
2524    }
2525
2526    #[test]
2527    fn resume_def_not_found_returns_not_found_error() {
2528        let rt = tokio::runtime::Runtime::new().unwrap();
2529        let _guard = rt.enter();
2530
2531        let tmp = tempfile::tempdir().unwrap();
2532        let agent_id = "feedface-0000-0000-0000-000000000000";
2533        // Meta points to "unknown-agent" which is not in definitions.
2534        write_completed_meta(tmp.path(), agent_id, "unknown-agent");
2535
2536        let mut mgr = make_manager();
2537        // Do NOT push any definition — so def_name "unknown-agent" won't be found.
2538        let cfg = make_cfg_with_dir(tmp.path());
2539
2540        let err = mgr
2541            .resume(
2542                "feedface",
2543                "continue",
2544                mock_provider(vec!["done"]),
2545                noop_executor(),
2546                None,
2547                &cfg,
2548            )
2549            .unwrap_err();
2550        assert!(matches!(err, SubAgentError::NotFound(_)));
2551    }
2552
2553    #[test]
2554    fn resume_concurrency_limit_reached_returns_error() {
2555        let rt = tokio::runtime::Runtime::new().unwrap();
2556        let _guard = rt.enter();
2557
2558        let tmp = tempfile::tempdir().unwrap();
2559        let agent_id = "babe0000-0000-0000-0000-000000000000";
2560        write_completed_meta(tmp.path(), agent_id, "bot");
2561
2562        let mut mgr = SubAgentManager::new(1); // limit of 1
2563        mgr.definitions.push(sample_def());
2564
2565        // Occupy the single slot.
2566        let _running_id = do_spawn(&mut mgr, "bot", "occupying slot").unwrap();
2567
2568        let cfg = make_cfg_with_dir(tmp.path());
2569        let err = mgr
2570            .resume(
2571                "babe0000",
2572                "continue",
2573                mock_provider(vec!["done"]),
2574                noop_executor(),
2575                None,
2576                &cfg,
2577            )
2578            .unwrap_err();
2579        assert!(
2580            matches!(err, SubAgentError::ConcurrencyLimit { .. }),
2581            "expected concurrency limit error, got: {err}"
2582        );
2583    }
2584
2585    #[test]
2586    fn resume_happy_path_returns_new_task_id() {
2587        let rt = tokio::runtime::Runtime::new().unwrap();
2588        let _guard = rt.enter();
2589
2590        let tmp = tempfile::tempdir().unwrap();
2591        let agent_id = "deadcode-0000-0000-0000-000000000000";
2592        write_completed_meta(tmp.path(), agent_id, "bot");
2593
2594        let mut mgr = make_manager();
2595        mgr.definitions.push(sample_def());
2596        let cfg = make_cfg_with_dir(tmp.path());
2597
2598        let (new_id, def_name) = mgr
2599            .resume(
2600                "deadcode",
2601                "continue the work",
2602                mock_provider(vec!["done"]),
2603                noop_executor(),
2604                None,
2605                &cfg,
2606            )
2607            .unwrap();
2608
2609        assert!(!new_id.is_empty(), "new task id must not be empty");
2610        assert_ne!(
2611            new_id, agent_id,
2612            "resumed session must have a fresh task id"
2613        );
2614        assert_eq!(def_name, "bot");
2615        // New agent must be tracked.
2616        assert!(mgr.agents.contains_key(&new_id));
2617
2618        mgr.cancel(&new_id).unwrap();
2619    }
2620
2621    #[test]
2622    fn resume_populates_resumed_from_in_meta() {
2623        let rt = tokio::runtime::Runtime::new().unwrap();
2624        let _guard = rt.enter();
2625
2626        let tmp = tempfile::tempdir().unwrap();
2627        let original_id = "0000abcd-0000-0000-0000-000000000000";
2628        write_completed_meta(tmp.path(), original_id, "bot");
2629
2630        let mut mgr = make_manager();
2631        mgr.definitions.push(sample_def());
2632        let cfg = make_cfg_with_dir(tmp.path());
2633
2634        let (new_id, _) = mgr
2635            .resume(
2636                "0000abcd",
2637                "continue",
2638                mock_provider(vec!["done"]),
2639                noop_executor(),
2640                None,
2641                &cfg,
2642            )
2643            .unwrap();
2644
2645        // The new meta sidecar must have resumed_from = original_id.
2646        let new_meta = crate::transcript::TranscriptReader::load_meta(tmp.path(), &new_id).unwrap();
2647        assert_eq!(
2648            new_meta.resumed_from.as_deref(),
2649            Some(original_id),
2650            "resumed_from must point to original agent id"
2651        );
2652
2653        mgr.cancel(&new_id).unwrap();
2654    }
2655
2656    #[test]
2657    fn def_name_for_resume_returns_def_name() {
2658        let rt = tokio::runtime::Runtime::new().unwrap();
2659        let _guard = rt.enter();
2660
2661        let tmp = tempfile::tempdir().unwrap();
2662        let agent_id = "aaaabbbb-0000-0000-0000-000000000000";
2663        write_completed_meta(tmp.path(), agent_id, "bot");
2664
2665        let mgr = make_manager();
2666        let cfg = make_cfg_with_dir(tmp.path());
2667
2668        let name = mgr.def_name_for_resume("aaaabbbb", &cfg).unwrap();
2669        assert_eq!(name, "bot");
2670    }
2671
2672    #[test]
2673    fn def_name_for_resume_not_found_returns_error() {
2674        let rt = tokio::runtime::Runtime::new().unwrap();
2675        let _guard = rt.enter();
2676
2677        let tmp = tempfile::tempdir().unwrap();
2678        let mgr = make_manager();
2679        let cfg = make_cfg_with_dir(tmp.path());
2680
2681        let err = mgr.def_name_for_resume("notexist", &cfg).unwrap_err();
2682        assert!(matches!(err, SubAgentError::NotFound(_)));
2683    }
2684
2685    // ── Memory scope tests ────────────────────────────────────────────────────
2686
2687    #[tokio::test]
2688    #[serial]
2689    async fn spawn_with_memory_scope_project_creates_directory() {
2690        let tmp = tempfile::tempdir().unwrap();
2691        let orig_dir = std::env::current_dir().unwrap();
2692        std::env::set_current_dir(tmp.path()).unwrap();
2693
2694        let def = SubAgentDef::parse(indoc! {"
2695            ---
2696            name: mem-agent
2697            description: Agent with memory
2698            memory: project
2699            ---
2700
2701            System prompt.
2702        "})
2703        .unwrap();
2704
2705        let mut mgr = make_manager();
2706        mgr.definitions.push(def);
2707
2708        let task_id = mgr
2709            .spawn(
2710                "mem-agent",
2711                "do something",
2712                mock_provider(vec!["done"]),
2713                noop_executor(),
2714                None,
2715                &SubAgentConfig::default(),
2716                SpawnContext::default(),
2717            )
2718            .unwrap();
2719        assert!(!task_id.is_empty());
2720        mgr.cancel(&task_id).unwrap();
2721
2722        // Verify memory directory was created.
2723        let mem_dir = tmp
2724            .path()
2725            .join(".zeph")
2726            .join("agent-memory")
2727            .join("mem-agent");
2728        assert!(
2729            mem_dir.exists(),
2730            "memory directory should be created at spawn"
2731        );
2732
2733        std::env::set_current_dir(orig_dir).unwrap();
2734    }
2735
2736    #[tokio::test]
2737    #[serial]
2738    async fn spawn_with_config_default_memory_scope_applies_when_def_has_none() {
2739        let tmp = tempfile::tempdir().unwrap();
2740        let orig_dir = std::env::current_dir().unwrap();
2741        std::env::set_current_dir(tmp.path()).unwrap();
2742
2743        let def = SubAgentDef::parse(indoc! {"
2744            ---
2745            name: mem-agent2
2746            description: Agent without explicit memory
2747            ---
2748
2749            System prompt.
2750        "})
2751        .unwrap();
2752
2753        let mut mgr = make_manager();
2754        mgr.definitions.push(def);
2755
2756        let cfg = SubAgentConfig {
2757            default_memory_scope: Some(MemoryScope::Project),
2758            ..SubAgentConfig::default()
2759        };
2760
2761        let task_id = mgr
2762            .spawn(
2763                "mem-agent2",
2764                "do something",
2765                mock_provider(vec!["done"]),
2766                noop_executor(),
2767                None,
2768                &cfg,
2769                SpawnContext::default(),
2770            )
2771            .unwrap();
2772        assert!(!task_id.is_empty());
2773        mgr.cancel(&task_id).unwrap();
2774
2775        // Verify memory directory was created via config default.
2776        let mem_dir = tmp
2777            .path()
2778            .join(".zeph")
2779            .join("agent-memory")
2780            .join("mem-agent2");
2781        assert!(
2782            mem_dir.exists(),
2783            "config default memory scope should create directory"
2784        );
2785
2786        std::env::set_current_dir(orig_dir).unwrap();
2787    }
2788
2789    #[tokio::test]
2790    #[serial]
2791    async fn spawn_with_memory_blocked_by_disallowed_tools_skips_memory() {
2792        let tmp = tempfile::tempdir().unwrap();
2793        let orig_dir = std::env::current_dir().unwrap();
2794        std::env::set_current_dir(tmp.path()).unwrap();
2795
2796        let def = SubAgentDef::parse(indoc! {"
2797            ---
2798            name: blocked-mem
2799            description: Agent with memory but blocked tools
2800            memory: project
2801            tools:
2802              except:
2803                - Read
2804                - Write
2805                - Edit
2806            ---
2807
2808            System prompt.
2809        "})
2810        .unwrap();
2811
2812        let mut mgr = make_manager();
2813        mgr.definitions.push(def);
2814
2815        let task_id = mgr
2816            .spawn(
2817                "blocked-mem",
2818                "do something",
2819                mock_provider(vec!["done"]),
2820                noop_executor(),
2821                None,
2822                &SubAgentConfig::default(),
2823                SpawnContext::default(),
2824            )
2825            .unwrap();
2826        assert!(!task_id.is_empty());
2827        mgr.cancel(&task_id).unwrap();
2828
2829        // Memory dir should NOT be created because tools are blocked (HIGH-04).
2830        let mem_dir = tmp
2831            .path()
2832            .join(".zeph")
2833            .join("agent-memory")
2834            .join("blocked-mem");
2835        assert!(
2836            !mem_dir.exists(),
2837            "memory directory should not be created when tools are blocked"
2838        );
2839
2840        std::env::set_current_dir(orig_dir).unwrap();
2841    }
2842
2843    #[tokio::test]
2844    #[serial]
2845    async fn spawn_without_memory_scope_no_directory_created() {
2846        let tmp = tempfile::tempdir().unwrap();
2847        let orig_dir = std::env::current_dir().unwrap();
2848        std::env::set_current_dir(tmp.path()).unwrap();
2849
2850        let def = SubAgentDef::parse(indoc! {"
2851            ---
2852            name: no-mem-agent
2853            description: Agent without memory
2854            ---
2855
2856            System prompt.
2857        "})
2858        .unwrap();
2859
2860        let mut mgr = make_manager();
2861        mgr.definitions.push(def);
2862
2863        let task_id = mgr
2864            .spawn(
2865                "no-mem-agent",
2866                "do something",
2867                mock_provider(vec!["done"]),
2868                noop_executor(),
2869                None,
2870                &SubAgentConfig::default(),
2871                SpawnContext::default(),
2872            )
2873            .unwrap();
2874        assert!(!task_id.is_empty());
2875        mgr.cancel(&task_id).unwrap();
2876
2877        // No agent-memory directory should exist (transcript dirs may be created separately).
2878        let mem_dir = tmp.path().join(".zeph").join("agent-memory");
2879        assert!(
2880            !mem_dir.exists(),
2881            "no agent-memory directory should be created without memory scope"
2882        );
2883
2884        std::env::set_current_dir(orig_dir).unwrap();
2885    }
2886
2887    #[test]
2888    #[serial]
2889    fn build_prompt_injects_memory_block_after_behavioral_prompt() {
2890        let tmp = tempfile::tempdir().unwrap();
2891        let orig_dir = std::env::current_dir().unwrap();
2892        std::env::set_current_dir(tmp.path()).unwrap();
2893
2894        // Create memory directory and MEMORY.md.
2895        let mem_dir = tmp
2896            .path()
2897            .join(".zeph")
2898            .join("agent-memory")
2899            .join("test-agent");
2900        std::fs::create_dir_all(&mem_dir).unwrap();
2901        std::fs::write(mem_dir.join("MEMORY.md"), "# Test Memory\nkey: value\n").unwrap();
2902
2903        let mut def = SubAgentDef::parse(indoc! {"
2904            ---
2905            name: test-agent
2906            description: Test agent
2907            memory: project
2908            ---
2909
2910            Behavioral instructions here.
2911        "})
2912        .unwrap();
2913
2914        let prompt = build_system_prompt_with_memory(&mut def, Some(MemoryScope::Project));
2915
2916        // Memory block must appear AFTER behavioral prompt text.
2917        let behavioral_pos = prompt.find("Behavioral instructions").unwrap();
2918        let memory_pos = prompt.find("<agent-memory>").unwrap();
2919        assert!(
2920            memory_pos > behavioral_pos,
2921            "memory block must appear AFTER behavioral prompt"
2922        );
2923        assert!(
2924            prompt.contains("key: value"),
2925            "MEMORY.md content must be injected"
2926        );
2927
2928        std::env::set_current_dir(orig_dir).unwrap();
2929    }
2930
2931    #[test]
2932    #[serial]
2933    fn build_prompt_auto_enables_read_write_edit_for_allowlist() {
2934        let tmp = tempfile::tempdir().unwrap();
2935        let orig_dir = std::env::current_dir().unwrap();
2936        std::env::set_current_dir(tmp.path()).unwrap();
2937
2938        let mut def = SubAgentDef::parse(indoc! {"
2939            ---
2940            name: allowlist-agent
2941            description: AllowList agent
2942            memory: project
2943            tools:
2944              allow:
2945                - shell
2946            ---
2947
2948            System prompt.
2949        "})
2950        .unwrap();
2951
2952        assert!(
2953            matches!(&def.tools, ToolPolicy::AllowList(list) if list == &["shell"]),
2954            "should start with only shell"
2955        );
2956
2957        build_system_prompt_with_memory(&mut def, Some(MemoryScope::Project));
2958
2959        // Read/Write/Edit must be auto-added to the AllowList.
2960        assert!(
2961            matches!(&def.tools, ToolPolicy::AllowList(list)
2962                if list.contains(&"Read".to_owned())
2963                    && list.contains(&"Write".to_owned())
2964                    && list.contains(&"Edit".to_owned())),
2965            "Read/Write/Edit must be auto-enabled in AllowList when memory is set"
2966        );
2967
2968        std::env::set_current_dir(orig_dir).unwrap();
2969    }
2970
2971    #[tokio::test]
2972    #[serial]
2973    async fn spawn_with_explicit_def_memory_overrides_config_default() {
2974        let tmp = tempfile::tempdir().unwrap();
2975        let orig_dir = std::env::current_dir().unwrap();
2976        std::env::set_current_dir(tmp.path()).unwrap();
2977
2978        // Agent explicitly sets memory: local, config sets default: project.
2979        // The explicit local should win.
2980        let def = SubAgentDef::parse(indoc! {"
2981            ---
2982            name: override-agent
2983            description: Agent with explicit memory
2984            memory: local
2985            ---
2986
2987            System prompt.
2988        "})
2989        .unwrap();
2990        assert_eq!(def.memory, Some(MemoryScope::Local));
2991
2992        let mut mgr = make_manager();
2993        mgr.definitions.push(def);
2994
2995        let cfg = SubAgentConfig {
2996            default_memory_scope: Some(MemoryScope::Project),
2997            ..SubAgentConfig::default()
2998        };
2999
3000        let task_id = mgr
3001            .spawn(
3002                "override-agent",
3003                "do something",
3004                mock_provider(vec!["done"]),
3005                noop_executor(),
3006                None,
3007                &cfg,
3008                SpawnContext::default(),
3009            )
3010            .unwrap();
3011        assert!(!task_id.is_empty());
3012        mgr.cancel(&task_id).unwrap();
3013
3014        // Local scope directory should be created, not project scope.
3015        let local_dir = tmp
3016            .path()
3017            .join(".zeph")
3018            .join("agent-memory-local")
3019            .join("override-agent");
3020        let project_dir = tmp
3021            .path()
3022            .join(".zeph")
3023            .join("agent-memory")
3024            .join("override-agent");
3025        assert!(local_dir.exists(), "local memory dir should be created");
3026        assert!(
3027            !project_dir.exists(),
3028            "project memory dir must NOT be created"
3029        );
3030
3031        std::env::set_current_dir(orig_dir).unwrap();
3032    }
3033
3034    #[tokio::test]
3035    #[serial]
3036    async fn spawn_memory_blocked_by_deny_list_policy() {
3037        let tmp = tempfile::tempdir().unwrap();
3038        let orig_dir = std::env::current_dir().unwrap();
3039        std::env::set_current_dir(tmp.path()).unwrap();
3040
3041        // tools.deny: [Read, Write, Edit] — DenyList policy blocking all file tools.
3042        let def = SubAgentDef::parse(indoc! {"
3043            ---
3044            name: deny-list-mem
3045            description: Agent with deny list
3046            memory: project
3047            tools:
3048              deny:
3049                - Read
3050                - Write
3051                - Edit
3052            ---
3053
3054            System prompt.
3055        "})
3056        .unwrap();
3057
3058        let mut mgr = make_manager();
3059        mgr.definitions.push(def);
3060
3061        let task_id = mgr
3062            .spawn(
3063                "deny-list-mem",
3064                "do something",
3065                mock_provider(vec!["done"]),
3066                noop_executor(),
3067                None,
3068                &SubAgentConfig::default(),
3069                SpawnContext::default(),
3070            )
3071            .unwrap();
3072        assert!(!task_id.is_empty());
3073        mgr.cancel(&task_id).unwrap();
3074
3075        // Memory dir should NOT be created because DenyList blocks file tools (REV-HIGH-02).
3076        let mem_dir = tmp
3077            .path()
3078            .join(".zeph")
3079            .join("agent-memory")
3080            .join("deny-list-mem");
3081        assert!(
3082            !mem_dir.exists(),
3083            "memory dir must not be created when DenyList blocks all file tools"
3084        );
3085
3086        std::env::set_current_dir(orig_dir).unwrap();
3087    }
3088
3089    // ── regression tests for #1467: sub-agent tools passed to LLM ────────────
3090
3091    fn make_agent_loop_args(
3092        provider: AnyProvider,
3093        executor: FilteredToolExecutor,
3094        max_turns: u32,
3095    ) -> AgentLoopArgs {
3096        let (status_tx, _status_rx) = tokio::sync::watch::channel(SubAgentStatus {
3097            state: SubAgentState::Working,
3098            last_message: None,
3099            turns_used: 0,
3100            started_at: std::time::Instant::now(),
3101        });
3102        let (secret_request_tx, _secret_request_rx) = tokio::sync::mpsc::channel(1);
3103        let (_secret_approved_tx, secret_rx) = tokio::sync::mpsc::channel::<Option<String>>(1);
3104        AgentLoopArgs {
3105            provider,
3106            executor,
3107            system_prompt: "You are a bot".into(),
3108            task_prompt: "Do something".into(),
3109            skills: None,
3110            max_turns,
3111            cancel: tokio_util::sync::CancellationToken::new(),
3112            status_tx,
3113            started_at: std::time::Instant::now(),
3114            secret_request_tx,
3115            secret_rx,
3116            background: false,
3117            hooks: super::super::hooks::SubagentHooks::default(),
3118            task_id: "test-task".into(),
3119            agent_name: "test-bot".into(),
3120            initial_messages: vec![],
3121            transcript_writer: None,
3122            spawn_depth: 0,
3123            mcp_tool_names: Vec::new(),
3124        }
3125    }
3126
3127    #[tokio::test]
3128    async fn run_agent_loop_passes_tools_to_provider() {
3129        use std::sync::Arc;
3130        use zeph_llm::provider::ChatResponse;
3131        use zeph_tools::registry::{InvocationHint, ToolDef};
3132
3133        // Executor that exposes one tool definition.
3134        struct SingleToolExecutor;
3135
3136        impl ErasedToolExecutor for SingleToolExecutor {
3137            fn execute_erased<'a>(
3138                &'a self,
3139                _response: &'a str,
3140            ) -> Pin<
3141                Box<
3142                    dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>>
3143                        + Send
3144                        + 'a,
3145                >,
3146            > {
3147                Box::pin(std::future::ready(Ok(None)))
3148            }
3149
3150            fn execute_confirmed_erased<'a>(
3151                &'a self,
3152                _response: &'a str,
3153            ) -> Pin<
3154                Box<
3155                    dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>>
3156                        + Send
3157                        + 'a,
3158                >,
3159            > {
3160                Box::pin(std::future::ready(Ok(None)))
3161            }
3162
3163            fn tool_definitions_erased(&self) -> Vec<ToolDef> {
3164                vec![ToolDef {
3165                    id: std::borrow::Cow::Borrowed("shell"),
3166                    description: std::borrow::Cow::Borrowed("Run a shell command"),
3167                    schema: schemars::Schema::default(),
3168                    invocation: InvocationHint::ToolCall,
3169                }]
3170            }
3171
3172            fn execute_tool_call_erased<'a>(
3173                &'a self,
3174                _call: &'a ToolCall,
3175            ) -> Pin<
3176                Box<
3177                    dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>>
3178                        + Send
3179                        + 'a,
3180                >,
3181            > {
3182                Box::pin(std::future::ready(Ok(None)))
3183            }
3184
3185            fn is_tool_retryable_erased(&self, _tool_id: &str) -> bool {
3186                false
3187            }
3188        }
3189
3190        // MockProvider with tool_use: records call count for chat_with_tools.
3191        let (mock, tool_call_count) =
3192            MockProvider::default().with_tool_use(vec![ChatResponse::Text("done".into())]);
3193        let provider = AnyProvider::Mock(mock);
3194        let executor =
3195            FilteredToolExecutor::new(Arc::new(SingleToolExecutor), ToolPolicy::InheritAll);
3196
3197        let args = make_agent_loop_args(provider, executor, 1);
3198        let result = run_agent_loop(args).await;
3199        assert!(result.is_ok(), "loop failed: {result:?}");
3200        assert_eq!(
3201            *tool_call_count.lock().unwrap(),
3202            1,
3203            "chat_with_tools must have been called exactly once"
3204        );
3205    }
3206
3207    #[tokio::test]
3208    async fn run_agent_loop_executes_native_tool_call() {
3209        use std::sync::{Arc, Mutex};
3210        use zeph_llm::provider::{ChatResponse, ToolUseRequest};
3211        use zeph_tools::registry::ToolDef;
3212
3213        struct TrackingExecutor {
3214            calls: Mutex<Vec<String>>,
3215        }
3216
3217        impl ErasedToolExecutor for TrackingExecutor {
3218            fn execute_erased<'a>(
3219                &'a self,
3220                _response: &'a str,
3221            ) -> Pin<
3222                Box<
3223                    dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>>
3224                        + Send
3225                        + 'a,
3226                >,
3227            > {
3228                Box::pin(std::future::ready(Ok(None)))
3229            }
3230
3231            fn execute_confirmed_erased<'a>(
3232                &'a self,
3233                _response: &'a str,
3234            ) -> Pin<
3235                Box<
3236                    dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>>
3237                        + Send
3238                        + 'a,
3239                >,
3240            > {
3241                Box::pin(std::future::ready(Ok(None)))
3242            }
3243
3244            fn tool_definitions_erased(&self) -> Vec<ToolDef> {
3245                vec![]
3246            }
3247
3248            fn execute_tool_call_erased<'a>(
3249                &'a self,
3250                call: &'a ToolCall,
3251            ) -> Pin<
3252                Box<
3253                    dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>>
3254                        + Send
3255                        + 'a,
3256                >,
3257            > {
3258                self.calls.lock().unwrap().push(call.tool_id.to_string());
3259                let output = ToolOutput {
3260                    tool_name: call.tool_id.clone(),
3261                    summary: "executed".into(),
3262                    blocks_executed: 1,
3263                    filter_stats: None,
3264                    diff: None,
3265                    streamed: false,
3266                    terminal_id: None,
3267                    locations: None,
3268                    raw_response: None,
3269                    claim_source: None,
3270                };
3271                Box::pin(std::future::ready(Ok(Some(output))))
3272            }
3273
3274            fn is_tool_retryable_erased(&self, _tool_id: &str) -> bool {
3275                false
3276            }
3277        }
3278
3279        // Provider: first call returns ToolUse, second returns Text.
3280        let (mock, _counter) = MockProvider::default().with_tool_use(vec![
3281            ChatResponse::ToolUse {
3282                text: None,
3283                tool_calls: vec![ToolUseRequest {
3284                    id: "call-1".into(),
3285                    name: "shell".into(),
3286                    input: serde_json::json!({"command": "echo hi"}),
3287                }],
3288                thinking_blocks: vec![],
3289            },
3290            ChatResponse::Text("all done".into()),
3291        ]);
3292
3293        let tracker = Arc::new(TrackingExecutor {
3294            calls: Mutex::new(vec![]),
3295        });
3296        let tracker_clone = Arc::clone(&tracker);
3297        let executor = FilteredToolExecutor::new(tracker_clone, ToolPolicy::InheritAll);
3298
3299        let args = make_agent_loop_args(AnyProvider::Mock(mock), executor, 5);
3300        let result = run_agent_loop(args).await;
3301        assert!(result.is_ok(), "loop failed: {result:?}");
3302        assert_eq!(result.unwrap(), "all done");
3303
3304        let recorded = tracker.calls.lock().unwrap();
3305        assert_eq!(
3306            recorded.len(),
3307            1,
3308            "execute_tool_call_erased must be called once"
3309        );
3310        assert_eq!(recorded[0], "shell");
3311    }
3312
3313    // --- Fix #2582 tests ---
3314
3315    #[test]
3316    fn build_system_prompt_injects_working_directory() {
3317        use tempfile::TempDir;
3318
3319        let tmp = TempDir::new().unwrap();
3320        let orig = std::env::current_dir().unwrap();
3321        std::env::set_current_dir(tmp.path()).unwrap();
3322
3323        let mut def = SubAgentDef::parse(indoc! {"
3324            ---
3325            name: cwd-agent
3326            description: test
3327            ---
3328            Base prompt.
3329        "})
3330        .unwrap();
3331
3332        let prompt = build_system_prompt_with_memory(&mut def, None);
3333        std::env::set_current_dir(orig).unwrap();
3334
3335        assert!(
3336            prompt.contains("Working directory:"),
3337            "system prompt must contain 'Working directory:', got: {prompt}"
3338        );
3339        assert!(
3340            prompt.contains(tmp.path().to_str().unwrap()),
3341            "system prompt must contain the actual cwd path, got: {prompt}"
3342        );
3343    }
3344
3345    #[tokio::test]
3346    async fn text_only_first_turn_sends_nudge_and_retries() {
3347        use zeph_llm::mock::MockProvider;
3348
3349        // First call returns text-only; second call also text (loop should stop after nudge retry).
3350        let (mock, call_count) = MockProvider::default().with_tool_use(vec![
3351            ChatResponse::Text("I will now do the task...".into()),
3352            ChatResponse::Text("Done.".into()),
3353        ]);
3354
3355        let executor = FilteredToolExecutor::new(noop_executor(), ToolPolicy::InheritAll);
3356        let args = make_agent_loop_args(AnyProvider::Mock(mock), executor, 10);
3357        let result = run_agent_loop(args).await;
3358        assert!(result.is_ok(), "loop should succeed: {result:?}");
3359        assert_eq!(result.unwrap(), "Done.");
3360
3361        // Provider must have been called twice: initial turn + nudge retry.
3362        let count = *call_count.lock().unwrap();
3363        assert_eq!(
3364            count, 2,
3365            "provider must be called exactly twice (initial + nudge retry), got {count}"
3366        );
3367    }
3368
3369    // ── Phase 1: subagent context propagation tests (#2576, #2577, #2578) ────
3370
3371    #[test]
3372    fn model_spec_deserialize_inherit() {
3373        let spec: ModelSpec = serde_json::from_str("\"inherit\"").unwrap();
3374        assert_eq!(spec, ModelSpec::Inherit);
3375    }
3376
3377    #[test]
3378    fn model_spec_deserialize_named() {
3379        let spec: ModelSpec = serde_json::from_str("\"fast\"").unwrap();
3380        assert_eq!(spec, ModelSpec::Named("fast".to_owned()));
3381    }
3382
3383    #[test]
3384    fn model_spec_serialize_roundtrip() {
3385        assert_eq!(
3386            serde_json::to_string(&ModelSpec::Inherit).unwrap(),
3387            "\"inherit\""
3388        );
3389        assert_eq!(
3390            serde_json::to_string(&ModelSpec::Named("my-provider".to_owned())).unwrap(),
3391            "\"my-provider\""
3392        );
3393    }
3394
3395    #[test]
3396    fn spawn_context_default_is_empty() {
3397        let ctx = SpawnContext::default();
3398        assert!(ctx.parent_messages.is_empty());
3399        assert!(ctx.parent_cancel.is_none());
3400        assert!(ctx.parent_provider_name.is_none());
3401        assert_eq!(ctx.spawn_depth, 0);
3402        assert!(ctx.mcp_tool_names.is_empty());
3403    }
3404
3405    #[test]
3406    fn context_injection_none_passes_raw_prompt() {
3407        use zeph_config::ContextInjectionMode;
3408        let result = apply_context_injection("do work", &[], ContextInjectionMode::None);
3409        assert_eq!(result, "do work");
3410    }
3411
3412    #[test]
3413    fn context_injection_last_assistant_prepends_when_present() {
3414        use zeph_config::ContextInjectionMode;
3415        let msgs = vec![
3416            make_message(Role::User, "hello".into()),
3417            make_message(Role::Assistant, "I found X".into()),
3418        ];
3419        let result =
3420            apply_context_injection("do work", &msgs, ContextInjectionMode::LastAssistantTurn);
3421        assert!(
3422            result.contains("I found X"),
3423            "should contain last assistant content"
3424        );
3425        assert!(result.contains("do work"), "should contain original task");
3426    }
3427
3428    #[test]
3429    fn context_injection_last_assistant_fallback_when_no_assistant() {
3430        use zeph_config::ContextInjectionMode;
3431        let msgs = vec![make_message(Role::User, "hello".into())];
3432        let result =
3433            apply_context_injection("do work", &msgs, ContextInjectionMode::LastAssistantTurn);
3434        assert_eq!(result, "do work");
3435    }
3436
3437    #[tokio::test]
3438    async fn spawn_model_inherit_resolves_to_parent_provider() {
3439        let rt = tokio::runtime::Handle::current();
3440        let _guard = rt.enter();
3441        let mut mgr = make_manager();
3442        let mut def = sample_def();
3443        def.model = Some(ModelSpec::Inherit);
3444        mgr.definitions.push(def);
3445
3446        let ctx = SpawnContext {
3447            parent_provider_name: Some("my-parent-provider".to_owned()),
3448            ..SpawnContext::default()
3449        };
3450        // spawn should succeed without error (model resolution doesn't fail on missing provider)
3451        let result = mgr.spawn(
3452            "bot",
3453            "task",
3454            mock_provider(vec!["done"]),
3455            noop_executor(),
3456            None,
3457            &SubAgentConfig::default(),
3458            ctx,
3459        );
3460        assert!(
3461            result.is_ok(),
3462            "spawn with Inherit model should succeed: {result:?}"
3463        );
3464    }
3465
3466    #[tokio::test]
3467    async fn spawn_model_named_uses_value() {
3468        let rt = tokio::runtime::Handle::current();
3469        let _guard = rt.enter();
3470        let mut mgr = make_manager();
3471        let mut def = sample_def();
3472        def.model = Some(ModelSpec::Named("fast".to_owned()));
3473        mgr.definitions.push(def);
3474
3475        let result = mgr.spawn(
3476            "bot",
3477            "task",
3478            mock_provider(vec!["done"]),
3479            noop_executor(),
3480            None,
3481            &SubAgentConfig::default(),
3482            SpawnContext::default(),
3483        );
3484        assert!(result.is_ok());
3485    }
3486
3487    #[test]
3488    fn spawn_exceeds_max_depth_returns_error() {
3489        let rt = tokio::runtime::Runtime::new().unwrap();
3490        let _guard = rt.enter();
3491        let mut mgr = make_manager();
3492        mgr.definitions.push(sample_def());
3493
3494        let cfg = SubAgentConfig {
3495            max_spawn_depth: 2,
3496            ..SubAgentConfig::default()
3497        };
3498        let ctx = SpawnContext {
3499            spawn_depth: 2, // equals max_spawn_depth → should fail
3500            ..SpawnContext::default()
3501        };
3502        let err = mgr
3503            .spawn(
3504                "bot",
3505                "task",
3506                mock_provider(vec!["done"]),
3507                noop_executor(),
3508                None,
3509                &cfg,
3510                ctx,
3511            )
3512            .unwrap_err();
3513        assert!(
3514            matches!(err, SubAgentError::MaxDepthExceeded { depth: 2, max: 2 }),
3515            "expected MaxDepthExceeded, got {err:?}"
3516        );
3517    }
3518
3519    #[test]
3520    fn spawn_at_max_depth_minus_one_succeeds() {
3521        let rt = tokio::runtime::Runtime::new().unwrap();
3522        let _guard = rt.enter();
3523        let mut mgr = make_manager();
3524        mgr.definitions.push(sample_def());
3525
3526        let cfg = SubAgentConfig {
3527            max_spawn_depth: 3,
3528            ..SubAgentConfig::default()
3529        };
3530        let ctx = SpawnContext {
3531            spawn_depth: 2, // one below max → should succeed
3532            ..SpawnContext::default()
3533        };
3534        let result = mgr.spawn(
3535            "bot",
3536            "task",
3537            mock_provider(vec!["done"]),
3538            noop_executor(),
3539            None,
3540            &cfg,
3541            ctx,
3542        );
3543        assert!(
3544            result.is_ok(),
3545            "spawn at depth 2 with max 3 should succeed: {result:?}"
3546        );
3547    }
3548
3549    #[test]
3550    fn spawn_foreground_uses_child_token() {
3551        let rt = tokio::runtime::Runtime::new().unwrap();
3552        let _guard = rt.enter();
3553        let mut mgr = make_manager();
3554        mgr.definitions.push(sample_def());
3555
3556        let parent_cancel = CancellationToken::new();
3557        let ctx = SpawnContext {
3558            parent_cancel: Some(parent_cancel.clone()),
3559            ..SpawnContext::default()
3560        };
3561        // Foreground spawn (background: false by default in sample_def)
3562        let task_id = mgr
3563            .spawn(
3564                "bot",
3565                "task",
3566                mock_provider(vec!["done"]),
3567                noop_executor(),
3568                None,
3569                &SubAgentConfig::default(),
3570                ctx,
3571            )
3572            .unwrap();
3573
3574        // Cancel parent — child should also be cancelled
3575        parent_cancel.cancel();
3576        let handle = mgr.agents.get(&task_id).unwrap();
3577        assert!(
3578            handle.cancel.is_cancelled(),
3579            "child token should be cancelled when parent cancels"
3580        );
3581    }
3582
3583    #[test]
3584    fn parent_history_zero_turns_returns_empty() {
3585        use zeph_config::ContextInjectionMode;
3586        let msgs = vec![make_message(Role::User, "hi".into())];
3587        // apply_context_injection with zero turns — we test by passing empty vec
3588        // The actual extract_parent_messages is in zeph-core; here we test the injection side
3589        let result = apply_context_injection("task", &[], ContextInjectionMode::LastAssistantTurn);
3590        assert_eq!(result, "task", "no history should pass prompt unchanged");
3591        let _ = msgs; // suppress unused
3592    }
3593
3594    // ── Phase 2: MCP tool annotation tests (#2581) ────────────────────────────
3595
3596    #[tokio::test]
3597    async fn mcp_tool_names_appended_to_system_prompt() {
3598        use zeph_llm::mock::MockProvider;
3599
3600        let (mock, _) =
3601            MockProvider::default().with_tool_use(vec![ChatResponse::Text("done".into())]);
3602
3603        let executor = FilteredToolExecutor::new(noop_executor(), ToolPolicy::InheritAll);
3604        let mut args = make_agent_loop_args(AnyProvider::Mock(mock), executor, 5);
3605        args.mcp_tool_names = vec!["search".into(), "write_file".into()];
3606        // The system_prompt is inspected indirectly — if the loop completes the annotation was built.
3607        let result = run_agent_loop(args).await;
3608        assert!(result.is_ok(), "loop should succeed: {result:?}");
3609    }
3610
3611    #[tokio::test]
3612    async fn empty_mcp_tool_names_no_annotation() {
3613        use zeph_llm::mock::MockProvider;
3614
3615        let (mock, _) =
3616            MockProvider::default().with_tool_use(vec![ChatResponse::Text("done".into())]);
3617
3618        let executor = FilteredToolExecutor::new(noop_executor(), ToolPolicy::InheritAll);
3619        let mut args = make_agent_loop_args(AnyProvider::Mock(mock), executor, 5);
3620        args.mcp_tool_names = vec![];
3621        let result = run_agent_loop(args).await;
3622        assert!(
3623            result.is_ok(),
3624            "loop should succeed with no MCP tools: {result:?}"
3625        );
3626    }
3627}