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)] // complex algorithm function; both suppressions justified until the function is decomposed in a future refactor
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        // Per-mode runtime enforcement is split across three sites and intentionally NOT done here:
743        //   - `Plan` is enforced by `PlanModeExecutor` (build_filtered_executor, ~line 68).
744        //   - `BypassPermissions` is gated by `apply_def_config_defaults` (~line 104).
745        //   - Tool allow/deny lists are enforced for every mode by `FilteredToolExecutor`
746        //     (build_filtered_executor, ~line 76; filter.rs::is_allowed).
747        // `Default`, `AcceptEdits`, `DontAsk`, and `BypassPermissions` are documented as
748        // functionally equivalent for sub-agents (see PermissionMode doc-comment in
749        // zeph-config/src/subagent.rs). If a future requirement adds a per-mode tool gate
750        // beyond `Plan`, replace this comment with the new wrapper. Architect triage 2026-04-28:
751        // no concrete (mode, tool) counterexample found.
752        tracing::info!(
753            task_id,
754            def_name,
755            permission_mode = ?self.agents[&task_id].def.permissions.permission_mode,
756            "sub-agent spawned"
757        );
758
759        self.cache_and_fire_start_hooks(config, &task_id, def_name);
760
761        Ok(task_id)
762    }
763
764    fn cache_and_fire_start_hooks(
765        &mut self,
766        config: &SubAgentConfig,
767        task_id: &str,
768        def_name: &str,
769    ) {
770        if !config.hooks.stop.is_empty() && self.stop_hooks.is_empty() {
771            self.stop_hooks.clone_from(&config.hooks.stop);
772        }
773        if !config.hooks.start.is_empty() {
774            let start_hooks = config.hooks.start.clone();
775            let start_env = make_hook_env(task_id, def_name, "");
776            tokio::spawn(async move {
777                if let Err(e) = fire_hooks(&start_hooks, &start_env, None).await {
778                    tracing::warn!(error = %e, "SubagentStart hook failed");
779                }
780            });
781        }
782    }
783
784    fn create_transcript_writer(
785        &mut self,
786        config: &SubAgentConfig,
787        task_id: &str,
788        agent_name: &str,
789        resumed_from: Option<&str>,
790    ) -> Option<TranscriptWriter> {
791        if !config.transcript_enabled {
792            return None;
793        }
794        let dir = self.effective_transcript_dir(config);
795        if self.transcript_max_files > 0
796            && let Err(e) = sweep_old_transcripts(&dir, self.transcript_max_files)
797        {
798            tracing::warn!(error = %e, "transcript sweep failed");
799        }
800        let path = dir.join(format!("{task_id}.jsonl"));
801        match TranscriptWriter::new(&path) {
802            Ok(w) => {
803                let meta = TranscriptMeta {
804                    agent_id: task_id.to_owned(),
805                    agent_name: agent_name.to_owned(),
806                    def_name: agent_name.to_owned(),
807                    status: SubAgentState::Submitted,
808                    started_at: crate::transcript::utc_now_pub(),
809                    finished_at: None,
810                    resumed_from: resumed_from.map(str::to_owned),
811                    turns_used: 0,
812                };
813                if let Err(e) = TranscriptWriter::write_meta(&dir, task_id, &meta) {
814                    tracing::warn!(error = %e, "failed to write initial transcript meta");
815                }
816                Some(w)
817            }
818            Err(e) => {
819                tracing::warn!(error = %e, "failed to create transcript writer");
820                None
821            }
822        }
823    }
824
825    /// Cancel all active sub-agents gracefully.
826    ///
827    /// Iterates every agent ID and calls [`cancel`][Self::cancel] on each.
828    /// Unlike [`cancel_all`][Self::cancel_all], this method goes through the normal
829    /// cancel path including hook firing. Prefer this during planned shutdown.
830    pub fn shutdown_all(&mut self) {
831        let ids: Vec<String> = self.agents.keys().cloned().collect();
832        for id in ids {
833            let _ = self.cancel(&id);
834        }
835    }
836
837    /// Cancel a running sub-agent by task ID.
838    ///
839    /// # Errors
840    ///
841    /// Returns [`SubAgentError::NotFound`] if the task ID is unknown.
842    pub fn cancel(&mut self, task_id: &str) -> Result<(), SubAgentError> {
843        let handle = self
844            .agents
845            .get_mut(task_id)
846            .ok_or_else(|| SubAgentError::NotFound(task_id.to_owned()))?;
847        handle.cancel.cancel();
848        handle.state = SubAgentState::Canceled;
849        handle.grants.revoke_all();
850        tracing::info!(task_id, "sub-agent cancelled");
851
852        // Fire SubagentStop lifecycle hooks (fire-and-forget).
853        if !self.stop_hooks.is_empty() {
854            let stop_hooks = self.stop_hooks.clone();
855            let stop_env = make_hook_env(task_id, &handle.def.name, "");
856            tokio::spawn(async move {
857                if let Err(e) = fire_hooks(&stop_hooks, &stop_env, None).await {
858                    tracing::warn!(error = %e, "SubagentStop hook failed");
859                }
860            });
861        }
862
863        Ok(())
864    }
865
866    /// Cancel all active sub-agents immediately, revoking their grants.
867    ///
868    /// Used during main agent shutdown or Ctrl+C handling when `DagScheduler` may not be
869    /// running. For coordinated scheduler-aware cancellation, prefer `DagScheduler::cancel_all`.
870    pub fn cancel_all(&mut self) {
871        for (task_id, handle) in &mut self.agents {
872            if matches!(
873                handle.state,
874                SubAgentState::Working | SubAgentState::Submitted
875            ) {
876                handle.cancel.cancel();
877                handle.state = SubAgentState::Canceled;
878                handle.grants.revoke_all();
879                tracing::info!(task_id, "sub-agent cancelled (cancel_all)");
880            }
881        }
882    }
883
884    /// Approve a secret request for a running sub-agent.
885    ///
886    /// Called after the user approves a vault secret access prompt. The secret
887    /// key must appear in the sub-agent definition's allowed `secrets` list;
888    /// otherwise the request is auto-denied.
889    ///
890    /// # Errors
891    ///
892    /// Returns [`SubAgentError::NotFound`] if the task ID is unknown,
893    /// [`SubAgentError::Invalid`] if the key is not in the definition's allowed list.
894    pub fn approve_secret(
895        &mut self,
896        task_id: &str,
897        secret_key: &str,
898        ttl: std::time::Duration,
899    ) -> Result<(), SubAgentError> {
900        let handle = self
901            .agents
902            .get_mut(task_id)
903            .ok_or_else(|| SubAgentError::NotFound(task_id.to_owned()))?;
904
905        // Sweep stale grants before adding a new one for consistent housekeeping.
906        handle.grants.sweep_expired();
907
908        if !handle
909            .def
910            .permissions
911            .secrets
912            .iter()
913            .any(|k| k == secret_key)
914        {
915            // Do not log the key name at warn level — only log that a request was denied.
916            tracing::warn!(task_id, "secret request denied: key not in allowed list");
917            return Err(SubAgentError::Invalid(format!(
918                "secret is not in the allowed secrets list for '{}'",
919                handle.def.name
920            )));
921        }
922
923        handle.grants.grant_secret(secret_key, ttl);
924        Ok(())
925    }
926
927    /// Deliver a secret value to a waiting sub-agent loop.
928    ///
929    /// Should be called after the user approves the request and the vault value
930    /// has been resolved. Returns an error if no such agent is found.
931    ///
932    /// # Errors
933    ///
934    /// Returns [`SubAgentError::NotFound`] if the task ID is unknown.
935    pub fn deliver_secret(&mut self, task_id: &str, key: String) -> Result<(), SubAgentError> {
936        // Signal approval to the sub-agent loop. The secret value is NOT passed through the
937        // channel to avoid embedding it in LLM message history. The sub-agent accesses it
938        // exclusively via PermissionGrants (granted by approve_secret() before this call).
939        let handle = self
940            .agents
941            .get_mut(task_id)
942            .ok_or_else(|| SubAgentError::NotFound(task_id.to_owned()))?;
943        handle
944            .secret_tx
945            .try_send(Some(key))
946            .map_err(|e| SubAgentError::Channel(e.to_string()))
947    }
948
949    /// Deny a pending secret request — sends `None` to unblock the waiting sub-agent loop.
950    ///
951    /// # Errors
952    ///
953    /// Returns [`SubAgentError::NotFound`] if the task ID is unknown,
954    /// [`SubAgentError::Channel`] if the channel is full or closed.
955    pub fn deny_secret(&mut self, task_id: &str) -> Result<(), SubAgentError> {
956        let handle = self
957            .agents
958            .get_mut(task_id)
959            .ok_or_else(|| SubAgentError::NotFound(task_id.to_owned()))?;
960        handle
961            .secret_tx
962            .try_send(None)
963            .map_err(|e| SubAgentError::Channel(e.to_string()))
964    }
965
966    /// Try to receive a pending secret request from any sub-agent (non-blocking).
967    ///
968    /// Polls each active agent's request channel once. Returns `Some((task_id, request))`
969    /// if any agent has a pending request, or `None` if all channels are empty.
970    /// Call this from the main agent loop to surface approval prompts to the user.
971    pub fn try_recv_secret_request(&mut self) -> Option<(String, SecretRequest)> {
972        for handle in self.agents.values_mut() {
973            if let Ok(req) = handle.pending_secret_rx.try_recv() {
974                return Some((handle.task_id.clone(), req));
975            }
976        }
977        None
978    }
979
980    /// Collect the result from a completed sub-agent, removing it from the active set.
981    ///
982    /// Writes a final `TranscriptMeta` sidecar with the terminal state and turn count.
983    ///
984    /// # Errors
985    ///
986    /// Returns [`SubAgentError::NotFound`] if the task ID is unknown,
987    /// [`SubAgentError::Spawn`] if the task panicked.
988    pub async fn collect(&mut self, task_id: &str) -> Result<String, SubAgentError> {
989        let mut handle = self
990            .agents
991            .remove(task_id)
992            .ok_or_else(|| SubAgentError::NotFound(task_id.to_owned()))?;
993
994        // Fire SubagentStop lifecycle hooks (fire-and-forget) before cleanup.
995        if !self.stop_hooks.is_empty() {
996            let stop_hooks = self.stop_hooks.clone();
997            let stop_env = make_hook_env(task_id, &handle.def.name, "");
998            tokio::spawn(async move {
999                if let Err(e) = fire_hooks(&stop_hooks, &stop_env, None).await {
1000                    tracing::warn!(error = %e, "SubagentStop hook failed");
1001                }
1002            });
1003        }
1004
1005        handle.grants.revoke_all();
1006
1007        let result = if let Some(jh) = handle.join_handle.take() {
1008            jh.await.map_err(|e| SubAgentError::Spawn(e.to_string()))?
1009        } else {
1010            Ok(String::new())
1011        };
1012
1013        // Write terminal meta sidecar if transcripts were enabled at spawn time.
1014        if let Some(ref dir) = handle.transcript_dir.clone() {
1015            let status = handle.status_rx.borrow();
1016            let final_status = if result.is_err() {
1017                SubAgentState::Failed
1018            } else if status.state == SubAgentState::Canceled {
1019                SubAgentState::Canceled
1020            } else {
1021                SubAgentState::Completed
1022            };
1023            let turns_used = status.turns_used;
1024            drop(status);
1025
1026            let meta = TranscriptMeta {
1027                agent_id: task_id.to_owned(),
1028                agent_name: handle.def.name.clone(),
1029                def_name: handle.def.name.clone(),
1030                status: final_status,
1031                started_at: handle.started_at_str.clone(),
1032                finished_at: Some(crate::transcript::utc_now_pub()),
1033                resumed_from: None,
1034                turns_used,
1035            };
1036            if let Err(e) = TranscriptWriter::write_meta(dir, task_id, &meta) {
1037                tracing::warn!(error = %e, task_id, "failed to write final transcript meta");
1038            }
1039        }
1040
1041        result
1042    }
1043
1044    /// Resume a previously completed (or failed/cancelled) sub-agent session.
1045    ///
1046    /// Loads the transcript from the original session into memory and spawns a new
1047    /// agent loop with that history prepended. The new session gets a fresh UUID.
1048    ///
1049    /// Returns `(new_task_id, def_name)` on success so the caller can resolve skills by name.
1050    ///
1051    /// # Errors
1052    ///
1053    /// Returns [`SubAgentError::StillRunning`] if the agent is still active,
1054    /// [`SubAgentError::NotFound`] if no transcript with the given prefix exists,
1055    /// [`SubAgentError::AmbiguousId`] if the prefix matches multiple agents,
1056    /// [`SubAgentError::Transcript`] on I/O or parse failure,
1057    /// [`SubAgentError::ConcurrencyLimit`] if the concurrency limit is exceeded.
1058    #[allow(clippy::too_many_lines, clippy::too_many_arguments)]
1059    pub fn resume(
1060        &mut self,
1061        id_prefix: &str,
1062        task_prompt: &str,
1063        provider: AnyProvider,
1064        tool_executor: Arc<dyn ErasedToolExecutor>,
1065        skills: Option<Vec<String>>,
1066        config: &SubAgentConfig,
1067    ) -> Result<(String, String), SubAgentError> {
1068        let dir = self.effective_transcript_dir(config);
1069        // Resolve full original ID first so the StillRunning check is precise
1070        // (avoids false positives from very short prefixes matching unrelated active agents).
1071        let original_id = TranscriptReader::find_by_prefix(&dir, id_prefix)?;
1072
1073        // Check if the resolved original agent ID is still active in memory.
1074        if self.agents.contains_key(&original_id) {
1075            return Err(SubAgentError::StillRunning(original_id));
1076        }
1077        let meta = TranscriptReader::load_meta(&dir, &original_id)?;
1078
1079        // Only terminal states can be resumed.
1080        match meta.status {
1081            SubAgentState::Completed | SubAgentState::Failed | SubAgentState::Canceled => {}
1082            other => {
1083                return Err(SubAgentError::StillRunning(format!(
1084                    "{original_id} (status: {other:?})"
1085                )));
1086            }
1087        }
1088
1089        let jsonl_path = dir.join(format!("{original_id}.jsonl"));
1090        let initial_messages = TranscriptReader::load(&jsonl_path)?;
1091
1092        // Resolve the definition from the original meta and apply config-level defaults,
1093        // identical to spawn() so that config policy is always enforced.
1094        let mut def = self
1095            .definitions
1096            .iter()
1097            .find(|d| d.name == meta.def_name)
1098            .cloned()
1099            .ok_or_else(|| SubAgentError::NotFound(meta.def_name.clone()))?;
1100
1101        if def.permissions.permission_mode == PermissionMode::Default
1102            && let Some(default_mode) = config.default_permission_mode
1103        {
1104            def.permissions.permission_mode = default_mode;
1105        }
1106
1107        if !config.default_disallowed_tools.is_empty() {
1108            let mut merged = def.disallowed_tools.clone();
1109            for tool in &config.default_disallowed_tools {
1110                if !merged.contains(tool) {
1111                    merged.push(tool.clone());
1112                }
1113            }
1114            def.disallowed_tools = merged;
1115        }
1116
1117        if def.permissions.permission_mode == PermissionMode::BypassPermissions
1118            && !config.allow_bypass_permissions
1119        {
1120            return Err(SubAgentError::Invalid(format!(
1121                "sub-agent '{}' requests bypass_permissions mode but it is not allowed by config",
1122                def.name
1123            )));
1124        }
1125
1126        // Check concurrency limit.
1127        let active = self
1128            .agents
1129            .values()
1130            .filter(|h| matches!(h.state, SubAgentState::Working | SubAgentState::Submitted))
1131            .count();
1132        if active >= self.max_concurrent {
1133            return Err(SubAgentError::ConcurrencyLimit {
1134                active,
1135                max: self.max_concurrent,
1136            });
1137        }
1138
1139        let new_task_id = Uuid::new_v4().to_string();
1140        let cancel = CancellationToken::new();
1141        let started_at = Instant::now();
1142        let initial_status = SubAgentStatus {
1143            state: SubAgentState::Submitted,
1144            last_message: None,
1145            turns_used: 0,
1146            started_at,
1147        };
1148        let (status_tx, status_rx) = watch::channel(initial_status);
1149
1150        let permission_mode = def.permissions.permission_mode;
1151        let background = def.permissions.background;
1152        let max_turns = def.permissions.max_turns;
1153        let system_prompt = def.system_prompt.clone();
1154        let task_prompt_owned = task_prompt.to_owned();
1155        let cancel_clone = cancel.clone();
1156        let agent_hooks = def.hooks.clone();
1157        let agent_name_clone = def.name.clone();
1158
1159        let executor = build_filtered_executor(tool_executor, permission_mode, &def);
1160
1161        let (secret_request_tx, pending_secret_rx) = mpsc::channel::<SecretRequest>(4);
1162        let (secret_tx, secret_rx) = mpsc::channel::<Option<String>>(4);
1163
1164        let transcript_writer =
1165            self.create_transcript_writer(config, &new_task_id, &def.name, Some(&original_id));
1166
1167        let new_task_id_for_loop = new_task_id.clone();
1168        let join_handle: JoinHandle<Result<String, SubAgentError>> =
1169            tokio::spawn(run_agent_loop(AgentLoopArgs {
1170                provider,
1171                executor,
1172                system_prompt,
1173                task_prompt: task_prompt_owned,
1174                skills,
1175                max_turns,
1176                cancel: cancel_clone,
1177                status_tx,
1178                started_at,
1179                secret_request_tx,
1180                secret_rx,
1181                background,
1182                hooks: agent_hooks,
1183                task_id: new_task_id_for_loop,
1184                agent_name: agent_name_clone,
1185                initial_messages,
1186                transcript_writer,
1187                spawn_depth: 0,
1188                mcp_tool_names: Vec::new(),
1189            }));
1190
1191        let resume_handle_transcript_dir = if config.transcript_enabled {
1192            Some(dir.clone())
1193        } else {
1194            None
1195        };
1196
1197        let handle = SubAgentHandle {
1198            id: new_task_id.clone(),
1199            def,
1200            task_id: new_task_id.clone(),
1201            state: SubAgentState::Submitted,
1202            join_handle: Some(join_handle),
1203            cancel,
1204            status_rx,
1205            grants: PermissionGrants::default(),
1206            pending_secret_rx,
1207            secret_tx,
1208            started_at_str: crate::transcript::utc_now_pub(),
1209            transcript_dir: resume_handle_transcript_dir,
1210        };
1211
1212        self.agents.insert(new_task_id.clone(), handle);
1213        tracing::info!(
1214            task_id = %new_task_id,
1215            original_id = %original_id,
1216            "sub-agent resumed"
1217        );
1218
1219        // Cache stop hooks from config if not already cached.
1220        if !config.hooks.stop.is_empty() && self.stop_hooks.is_empty() {
1221            self.stop_hooks.clone_from(&config.hooks.stop);
1222        }
1223
1224        // Fire SubagentStart lifecycle hooks (fire-and-forget).
1225        if !config.hooks.start.is_empty() {
1226            let start_hooks = config.hooks.start.clone();
1227            let def_name = meta.def_name.clone();
1228            let start_env = make_hook_env(&new_task_id, &def_name, "");
1229            tokio::spawn(async move {
1230                if let Err(e) = fire_hooks(&start_hooks, &start_env, None).await {
1231                    tracing::warn!(error = %e, "SubagentStart hook failed");
1232                }
1233            });
1234        }
1235
1236        Ok((new_task_id, meta.def_name))
1237    }
1238
1239    /// Resolve the effective transcript directory from config or default.
1240    fn effective_transcript_dir(&self, config: &SubAgentConfig) -> PathBuf {
1241        if let Some(ref dir) = self.transcript_dir {
1242            dir.clone()
1243        } else if let Some(ref dir) = config.transcript_dir {
1244            dir.clone()
1245        } else {
1246            PathBuf::from(".zeph/subagents")
1247        }
1248    }
1249
1250    /// Look up the definition name for a resumable transcript without spawning.
1251    ///
1252    /// Used by callers that need to resolve skills before calling `resume()`.
1253    ///
1254    /// # Errors
1255    ///
1256    /// Returns the same errors as [`TranscriptReader::find_by_prefix`] and
1257    /// [`TranscriptReader::load_meta`].
1258    pub fn def_name_for_resume(
1259        &self,
1260        id_prefix: &str,
1261        config: &SubAgentConfig,
1262    ) -> Result<String, SubAgentError> {
1263        let dir = self.effective_transcript_dir(config);
1264        let original_id = TranscriptReader::find_by_prefix(&dir, id_prefix)?;
1265        let meta = TranscriptReader::load_meta(&dir, &original_id)?;
1266        Ok(meta.def_name)
1267    }
1268
1269    /// Return a snapshot of all active sub-agent statuses.
1270    #[must_use]
1271    pub fn statuses(&self) -> Vec<(String, SubAgentStatus)> {
1272        self.agents
1273            .values()
1274            .map(|h| {
1275                let mut status = h.status_rx.borrow().clone();
1276                // cancel() updates handle.state synchronously but the background task
1277                // may not have sent the final watch update yet; reflect it here.
1278                if h.state == SubAgentState::Canceled {
1279                    status.state = SubAgentState::Canceled;
1280                }
1281                (h.task_id.clone(), status)
1282            })
1283            .collect()
1284    }
1285
1286    /// Return the definition for a specific agent by `task_id`.
1287    #[must_use]
1288    pub fn agents_def(&self, task_id: &str) -> Option<&SubAgentDef> {
1289        self.agents.get(task_id).map(|h| &h.def)
1290    }
1291
1292    /// Return the transcript directory for a specific agent by `task_id`.
1293    #[must_use]
1294    pub fn agent_transcript_dir(&self, task_id: &str) -> Option<&std::path::Path> {
1295        self.agents
1296            .get(task_id)
1297            .and_then(|h| h.transcript_dir.as_deref())
1298    }
1299
1300    /// Spawn a sub-agent for an orchestrated task.
1301    ///
1302    /// Identical to [`spawn`][Self::spawn] but wraps the `JoinHandle` to send a
1303    /// `TaskEvent` on the provided channel when the agent loop
1304    /// terminates. This allows the `DagScheduler` to receive completion notifications
1305    /// without polling (ADR-027).
1306    ///
1307    /// The `event_tx` channel is best-effort: if the scheduler is dropped before all
1308    /// agents complete, the send will fail silently with a warning log.
1309    ///
1310    /// # Errors
1311    ///
1312    /// Same error conditions as [`spawn`][Self::spawn].
1313    ///
1314    /// # Panics
1315    ///
1316    /// Panics if the internal agent entry is missing after a successful `spawn` call.
1317    /// This is a programming error and should never occur in normal operation.
1318    #[allow(clippy::too_many_arguments)]
1319    // TODO(B3): refactor into a builder or config struct to reduce argument count
1320    // function with many required inputs; a *Params struct would be more verbose without simplifying the call site
1321    /// Spawn a sub-agent and attach a completion callback invoked when the agent terminates.
1322    ///
1323    /// The callback receives the agent handle ID and the agent's result.
1324    /// The caller is responsible for translating this into orchestration events.
1325    ///
1326    /// # Errors
1327    ///
1328    /// Same error conditions as [`spawn`][Self::spawn].
1329    ///
1330    /// # Panics
1331    ///
1332    /// Panics if the internal agent entry is missing after a successful `spawn` call.
1333    /// This is a programming error and should never occur in normal operation.
1334    #[allow(clippy::too_many_arguments)] // function with many required inputs; a *Params struct would be more verbose without simplifying the call site
1335    pub fn spawn_for_task<F>(
1336        &mut self,
1337        def_name: &str,
1338        task_prompt: &str,
1339        provider: AnyProvider,
1340        tool_executor: Arc<dyn ErasedToolExecutor>,
1341        skills: Option<Vec<String>>,
1342        config: &SubAgentConfig,
1343        ctx: SpawnContext,
1344        on_done: F,
1345    ) -> Result<String, SubAgentError>
1346    where
1347        F: FnOnce(String, Result<String, SubAgentError>) + Send + 'static,
1348    {
1349        let handle_id = self.spawn(
1350            def_name,
1351            task_prompt,
1352            provider,
1353            tool_executor,
1354            skills,
1355            config,
1356            ctx,
1357        )?;
1358
1359        let handle = self
1360            .agents
1361            .get_mut(&handle_id)
1362            .expect("just spawned agent must exist");
1363
1364        let original_join = handle
1365            .join_handle
1366            .take()
1367            .expect("just spawned agent must have a join handle");
1368
1369        let handle_id_clone = handle_id.clone();
1370        let wrapped_join: tokio::task::JoinHandle<Result<String, SubAgentError>> =
1371            tokio::spawn(async move {
1372                let result = original_join.await;
1373
1374                let (notify_result, output) = match result {
1375                    Ok(Ok(output)) => (Ok(output.clone()), Ok(output)),
1376                    Ok(Err(e)) => {
1377                        let msg = e.to_string();
1378                        (
1379                            Err(SubAgentError::Spawn(msg.clone())),
1380                            Err(SubAgentError::Spawn(msg)),
1381                        )
1382                    }
1383                    Err(join_err) => {
1384                        let msg = format!("task panicked: {join_err:?}");
1385                        (
1386                            Err(SubAgentError::TaskPanic(msg.clone())),
1387                            Err(SubAgentError::TaskPanic(msg)),
1388                        )
1389                    }
1390                };
1391
1392                on_done(handle_id_clone, notify_result);
1393
1394                output
1395            });
1396
1397        handle.join_handle = Some(wrapped_join);
1398
1399        Ok(handle_id)
1400    }
1401}
1402
1403#[cfg(test)]
1404mod tests {
1405    #![allow(
1406        clippy::await_holding_lock,
1407        clippy::field_reassign_with_default,
1408        clippy::too_many_lines
1409    )]
1410
1411    use std::pin::Pin;
1412
1413    use indoc::indoc;
1414    use zeph_llm::any::AnyProvider;
1415    use zeph_llm::mock::MockProvider;
1416    use zeph_tools::ToolCall;
1417    use zeph_tools::executor::{ErasedToolExecutor, ToolError, ToolOutput};
1418    use zeph_tools::registry::ToolDef;
1419
1420    use serial_test::serial;
1421
1422    use crate::agent_loop::{AgentLoopArgs, make_message, run_agent_loop};
1423    use crate::def::{MemoryScope, ModelSpec};
1424    use zeph_config::SubAgentConfig;
1425    use zeph_llm::provider::ChatResponse;
1426
1427    use super::*;
1428
1429    fn make_manager() -> SubAgentManager {
1430        SubAgentManager::new(4)
1431    }
1432
1433    fn sample_def() -> SubAgentDef {
1434        SubAgentDef::parse("---\nname: bot\ndescription: A bot\n---\n\nDo things.\n").unwrap()
1435    }
1436
1437    fn def_with_secrets() -> SubAgentDef {
1438        SubAgentDef::parse(
1439            "---\nname: bot\ndescription: A bot\npermissions:\n  secrets:\n    - api-key\n---\n\nDo things.\n",
1440        )
1441        .unwrap()
1442    }
1443
1444    struct NoopExecutor;
1445
1446    impl ErasedToolExecutor for NoopExecutor {
1447        fn execute_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 execute_confirmed_erased<'a>(
1459            &'a self,
1460            _response: &'a str,
1461        ) -> Pin<
1462            Box<
1463                dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
1464            >,
1465        > {
1466            Box::pin(std::future::ready(Ok(None)))
1467        }
1468
1469        fn tool_definitions_erased(&self) -> Vec<ToolDef> {
1470            vec![]
1471        }
1472
1473        fn execute_tool_call_erased<'a>(
1474            &'a self,
1475            _call: &'a ToolCall,
1476        ) -> Pin<
1477            Box<
1478                dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
1479            >,
1480        > {
1481            Box::pin(std::future::ready(Ok(None)))
1482        }
1483
1484        fn is_tool_retryable_erased(&self, _tool_id: &str) -> bool {
1485            false
1486        }
1487    }
1488
1489    fn mock_provider(responses: Vec<&str>) -> AnyProvider {
1490        AnyProvider::Mock(MockProvider::with_responses(
1491            responses.into_iter().map(String::from).collect(),
1492        ))
1493    }
1494
1495    fn noop_executor() -> Arc<dyn ErasedToolExecutor> {
1496        Arc::new(NoopExecutor)
1497    }
1498
1499    fn do_spawn(
1500        mgr: &mut SubAgentManager,
1501        name: &str,
1502        prompt: &str,
1503    ) -> Result<String, SubAgentError> {
1504        mgr.spawn(
1505            name,
1506            prompt,
1507            mock_provider(vec!["done"]),
1508            noop_executor(),
1509            None,
1510            &SubAgentConfig::default(),
1511            SpawnContext::default(),
1512        )
1513    }
1514
1515    #[test]
1516    fn load_definitions_populates_vec() {
1517        use std::io::Write as _;
1518        let dir = tempfile::tempdir().unwrap();
1519        let content = "---\nname: helper\ndescription: A helper\n---\n\nHelp.\n";
1520        let mut f = std::fs::File::create(dir.path().join("helper.md")).unwrap();
1521        f.write_all(content.as_bytes()).unwrap();
1522
1523        let mut mgr = make_manager();
1524        mgr.load_definitions(&[dir.path().to_path_buf()]).unwrap();
1525        assert_eq!(mgr.definitions().len(), 1);
1526        assert_eq!(mgr.definitions()[0].name, "helper");
1527    }
1528
1529    #[test]
1530    fn spawn_not_found_error() {
1531        let rt = tokio::runtime::Runtime::new().unwrap();
1532        let _guard = rt.enter();
1533        let mut mgr = make_manager();
1534        let err = do_spawn(&mut mgr, "nonexistent", "prompt").unwrap_err();
1535        assert!(matches!(err, SubAgentError::NotFound(_)));
1536    }
1537
1538    #[test]
1539    fn spawn_and_cancel() {
1540        let rt = tokio::runtime::Runtime::new().unwrap();
1541        let _guard = rt.enter();
1542        let mut mgr = make_manager();
1543        mgr.definitions.push(sample_def());
1544
1545        let task_id = do_spawn(&mut mgr, "bot", "do stuff").unwrap();
1546        assert!(!task_id.is_empty());
1547
1548        mgr.cancel(&task_id).unwrap();
1549        assert_eq!(mgr.agents[&task_id].state, SubAgentState::Canceled);
1550    }
1551
1552    #[test]
1553    fn cancel_unknown_task_id_returns_not_found() {
1554        let mut mgr = make_manager();
1555        let err = mgr.cancel("unknown-id").unwrap_err();
1556        assert!(matches!(err, SubAgentError::NotFound(_)));
1557    }
1558
1559    #[tokio::test]
1560    async fn collect_removes_agent() {
1561        let mut mgr = make_manager();
1562        mgr.definitions.push(sample_def());
1563
1564        let task_id = do_spawn(&mut mgr, "bot", "do stuff").unwrap();
1565        mgr.cancel(&task_id).unwrap();
1566
1567        // Wait briefly for the task to observe cancellation
1568        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1569
1570        let result = mgr.collect(&task_id).await.unwrap();
1571        assert!(!mgr.agents.contains_key(&task_id));
1572        // result may be empty string (cancelled before LLM response) or the mock response
1573        let _ = result;
1574    }
1575
1576    #[tokio::test]
1577    async fn collect_unknown_task_id_returns_not_found() {
1578        let mut mgr = make_manager();
1579        let err = mgr.collect("unknown-id").await.unwrap_err();
1580        assert!(matches!(err, SubAgentError::NotFound(_)));
1581    }
1582
1583    #[test]
1584    fn approve_secret_grants_access() {
1585        let rt = tokio::runtime::Runtime::new().unwrap();
1586        let _guard = rt.enter();
1587        let mut mgr = make_manager();
1588        mgr.definitions.push(def_with_secrets());
1589
1590        let task_id = do_spawn(&mut mgr, "bot", "work").unwrap();
1591        mgr.approve_secret(&task_id, "api-key", std::time::Duration::from_mins(1))
1592            .unwrap();
1593
1594        let handle = mgr.agents.get_mut(&task_id).unwrap();
1595        assert!(
1596            handle
1597                .grants
1598                .is_active(&crate::grants::GrantKind::Secret("api-key".into()))
1599        );
1600    }
1601
1602    #[test]
1603    fn approve_secret_denied_for_unlisted_key() {
1604        let rt = tokio::runtime::Runtime::new().unwrap();
1605        let _guard = rt.enter();
1606        let mut mgr = make_manager();
1607        mgr.definitions.push(sample_def()); // no secrets in allowed list
1608
1609        let task_id = do_spawn(&mut mgr, "bot", "work").unwrap();
1610        let err = mgr
1611            .approve_secret(&task_id, "not-allowed", std::time::Duration::from_mins(1))
1612            .unwrap_err();
1613        assert!(matches!(err, SubAgentError::Invalid(_)));
1614    }
1615
1616    #[test]
1617    fn approve_secret_unknown_task_id_returns_not_found() {
1618        let mut mgr = make_manager();
1619        let err = mgr
1620            .approve_secret("unknown", "key", std::time::Duration::from_mins(1))
1621            .unwrap_err();
1622        assert!(matches!(err, SubAgentError::NotFound(_)));
1623    }
1624
1625    #[test]
1626    fn statuses_returns_active_agents() {
1627        let rt = tokio::runtime::Runtime::new().unwrap();
1628        let _guard = rt.enter();
1629        let mut mgr = make_manager();
1630        mgr.definitions.push(sample_def());
1631
1632        let task_id = do_spawn(&mut mgr, "bot", "work").unwrap();
1633        let statuses = mgr.statuses();
1634        assert_eq!(statuses.len(), 1);
1635        assert_eq!(statuses[0].0, task_id);
1636    }
1637
1638    #[test]
1639    fn concurrency_limit_enforced() {
1640        let rt = tokio::runtime::Runtime::new().unwrap();
1641        let _guard = rt.enter();
1642        let mut mgr = SubAgentManager::new(1);
1643        mgr.definitions.push(sample_def());
1644
1645        let _first = do_spawn(&mut mgr, "bot", "first").unwrap();
1646        let err = do_spawn(&mut mgr, "bot", "second").unwrap_err();
1647        assert!(matches!(err, SubAgentError::ConcurrencyLimit { .. }));
1648    }
1649
1650    // --- #1619 regression tests: reserved_slots ---
1651
1652    #[test]
1653    fn test_reserve_slots_blocks_spawn() {
1654        // max_concurrent=2, reserved=1, active=1 → active+reserved >= max → ConcurrencyLimit.
1655        let rt = tokio::runtime::Runtime::new().unwrap();
1656        let _guard = rt.enter();
1657        let mut mgr = SubAgentManager::new(2);
1658        mgr.definitions.push(sample_def());
1659
1660        // Occupy one slot.
1661        let _first = do_spawn(&mut mgr, "bot", "first").unwrap();
1662        // Reserve the remaining slot.
1663        mgr.reserve_slots(1);
1664        // Now active(1) + reserved(1) >= max_concurrent(2) → should reject.
1665        let err = do_spawn(&mut mgr, "bot", "second").unwrap_err();
1666        assert!(
1667            matches!(err, SubAgentError::ConcurrencyLimit { .. }),
1668            "expected ConcurrencyLimit, got: {err}"
1669        );
1670    }
1671
1672    #[test]
1673    fn test_release_reservation_allows_spawn() {
1674        // After release_reservation(), the reserved slot is freed and spawn succeeds.
1675        let rt = tokio::runtime::Runtime::new().unwrap();
1676        let _guard = rt.enter();
1677        let mut mgr = SubAgentManager::new(2);
1678        mgr.definitions.push(sample_def());
1679
1680        // Reserve one slot (no active agents yet).
1681        mgr.reserve_slots(1);
1682        // active(0) + reserved(1) < max_concurrent(2), so one more spawn is allowed.
1683        let _first = do_spawn(&mut mgr, "bot", "first").unwrap();
1684        // Now active(1) + reserved(1) >= max_concurrent(2) → blocked.
1685        let err = do_spawn(&mut mgr, "bot", "second").unwrap_err();
1686        assert!(matches!(err, SubAgentError::ConcurrencyLimit { .. }));
1687
1688        // Release the reservation — active(1) + reserved(0) < max_concurrent(2).
1689        mgr.release_reservation(1);
1690        let result = do_spawn(&mut mgr, "bot", "third");
1691        assert!(
1692            result.is_ok(),
1693            "spawn must succeed after release_reservation, got: {result:?}"
1694        );
1695    }
1696
1697    #[test]
1698    fn test_reservation_with_zero_active_blocks_spawn() {
1699        // Reserved slots alone (no active agents) should block spawn when reserved >= max.
1700        let rt = tokio::runtime::Runtime::new().unwrap();
1701        let _guard = rt.enter();
1702        let mut mgr = SubAgentManager::new(2);
1703        mgr.definitions.push(sample_def());
1704
1705        // Reserve all slots — no active agents.
1706        mgr.reserve_slots(2);
1707        // active(0) + reserved(2) >= max_concurrent(2) → blocked.
1708        let err = do_spawn(&mut mgr, "bot", "first").unwrap_err();
1709        assert!(
1710            matches!(err, SubAgentError::ConcurrencyLimit { .. }),
1711            "reservation alone must block spawn when reserved >= max_concurrent"
1712        );
1713    }
1714
1715    #[tokio::test]
1716    async fn background_agent_does_not_block_caller() {
1717        let mut mgr = make_manager();
1718        mgr.definitions.push(sample_def());
1719
1720        // Spawn should return immediately without waiting for LLM
1721        let result = tokio::time::timeout(
1722            std::time::Duration::from_millis(100),
1723            std::future::ready(do_spawn(&mut mgr, "bot", "work")),
1724        )
1725        .await;
1726        assert!(result.is_ok(), "spawn() must not block");
1727        assert!(result.unwrap().is_ok());
1728    }
1729
1730    #[tokio::test]
1731    async fn max_turns_terminates_agent_loop() {
1732        let mut mgr = make_manager();
1733        // max_turns = 1, mock returns empty (no tool call), so loop ends after 1 turn
1734        let def = SubAgentDef::parse(indoc! {"
1735            ---
1736            name: limited
1737            description: A bot
1738            permissions:
1739              max_turns: 1
1740            ---
1741
1742            Do one thing.
1743        "})
1744        .unwrap();
1745        mgr.definitions.push(def);
1746
1747        let task_id = mgr
1748            .spawn(
1749                "limited",
1750                "task",
1751                mock_provider(vec!["final answer"]),
1752                noop_executor(),
1753                None,
1754                &SubAgentConfig::default(),
1755                SpawnContext::default(),
1756            )
1757            .unwrap();
1758
1759        // Wait for completion
1760        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1761
1762        let status = mgr.statuses().into_iter().find(|(id, _)| id == &task_id);
1763        // Status should show Completed or still Working but <= 1 turn
1764        if let Some((_, s)) = status {
1765            assert!(s.turns_used <= 1);
1766        }
1767    }
1768
1769    #[tokio::test]
1770    async fn cancellation_token_stops_agent_loop() {
1771        let mut mgr = make_manager();
1772        mgr.definitions.push(sample_def());
1773
1774        let task_id = do_spawn(&mut mgr, "bot", "long task").unwrap();
1775
1776        // Cancel immediately
1777        mgr.cancel(&task_id).unwrap();
1778
1779        // Wait a bit then collect
1780        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1781        let result = mgr.collect(&task_id).await;
1782        // Cancelled task may return empty or partial result — both are acceptable
1783        assert!(result.is_ok() || result.is_err());
1784    }
1785
1786    #[tokio::test]
1787    async fn shutdown_all_cancels_all_active_agents() {
1788        let mut mgr = make_manager();
1789        mgr.definitions.push(sample_def());
1790
1791        do_spawn(&mut mgr, "bot", "task 1").unwrap();
1792        do_spawn(&mut mgr, "bot", "task 2").unwrap();
1793
1794        assert_eq!(mgr.agents.len(), 2);
1795        mgr.shutdown_all();
1796
1797        // All agents should be in Canceled state
1798        for (_, status) in mgr.statuses() {
1799            assert_eq!(status.state, SubAgentState::Canceled);
1800        }
1801    }
1802
1803    #[test]
1804    fn debug_impl_does_not_expose_sensitive_fields() {
1805        let rt = tokio::runtime::Runtime::new().unwrap();
1806        let _guard = rt.enter();
1807        let mut mgr = make_manager();
1808        mgr.definitions.push(def_with_secrets());
1809        let task_id = do_spawn(&mut mgr, "bot", "work").unwrap();
1810        let handle = &mgr.agents[&task_id];
1811        let debug_str = format!("{handle:?}");
1812        // SubAgentHandle Debug must not expose grant contents or secrets
1813        assert!(!debug_str.contains("api-key"));
1814    }
1815
1816    #[tokio::test]
1817    async fn llm_failure_transitions_to_failed_state() {
1818        let rt_handle = tokio::runtime::Handle::current();
1819        let _guard = rt_handle.enter();
1820        let mut mgr = make_manager();
1821        mgr.definitions.push(sample_def());
1822
1823        let failing = AnyProvider::Mock(MockProvider::failing());
1824        let task_id = mgr
1825            .spawn(
1826                "bot",
1827                "do work",
1828                failing,
1829                noop_executor(),
1830                None,
1831                &SubAgentConfig::default(),
1832                SpawnContext::default(),
1833            )
1834            .unwrap();
1835
1836        // Wait for the background task to complete.
1837        tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
1838
1839        let statuses = mgr.statuses();
1840        let status = statuses
1841            .iter()
1842            .find(|(id, _)| id == &task_id)
1843            .map(|(_, s)| s);
1844        // The background loop should have caught the LLM error and reported Failed.
1845        assert!(
1846            status.is_some_and(|s| s.state == SubAgentState::Failed),
1847            "expected Failed, got: {status:?}"
1848        );
1849    }
1850
1851    #[tokio::test]
1852    async fn tool_call_loop_two_turns() {
1853        use std::sync::Mutex;
1854        use zeph_llm::mock::MockProvider;
1855        use zeph_llm::provider::{ChatResponse, ToolUseRequest};
1856        use zeph_tools::ToolCall;
1857
1858        struct ToolOnceExecutor {
1859            calls: Mutex<u32>,
1860        }
1861
1862        impl ErasedToolExecutor for ToolOnceExecutor {
1863            fn execute_erased<'a>(
1864                &'a self,
1865                _response: &'a str,
1866            ) -> Pin<
1867                Box<
1868                    dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>>
1869                        + Send
1870                        + 'a,
1871                >,
1872            > {
1873                Box::pin(std::future::ready(Ok(None)))
1874            }
1875
1876            fn execute_confirmed_erased<'a>(
1877                &'a self,
1878                _response: &'a str,
1879            ) -> Pin<
1880                Box<
1881                    dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>>
1882                        + Send
1883                        + 'a,
1884                >,
1885            > {
1886                Box::pin(std::future::ready(Ok(None)))
1887            }
1888
1889            fn tool_definitions_erased(&self) -> Vec<ToolDef> {
1890                vec![]
1891            }
1892
1893            fn execute_tool_call_erased<'a>(
1894                &'a self,
1895                call: &'a ToolCall,
1896            ) -> Pin<
1897                Box<
1898                    dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>>
1899                        + Send
1900                        + 'a,
1901                >,
1902            > {
1903                let mut n = self.calls.lock().unwrap();
1904                *n += 1;
1905                let result = if *n == 1 {
1906                    Ok(Some(ToolOutput {
1907                        tool_name: call.tool_id.clone(),
1908                        summary: "step 1 done".into(),
1909                        blocks_executed: 1,
1910                        filter_stats: None,
1911                        diff: None,
1912                        streamed: false,
1913                        terminal_id: None,
1914                        locations: None,
1915                        raw_response: None,
1916                        claim_source: None,
1917                    }))
1918                } else {
1919                    Ok(None)
1920                };
1921                Box::pin(std::future::ready(result))
1922            }
1923
1924            fn is_tool_retryable_erased(&self, _tool_id: &str) -> bool {
1925                false
1926            }
1927        }
1928
1929        let rt_handle = tokio::runtime::Handle::current();
1930        let _guard = rt_handle.enter();
1931        let mut mgr = make_manager();
1932        mgr.definitions.push(sample_def());
1933
1934        // First response: ToolUse with a shell call; second: Text with final answer.
1935        let tool_response = ChatResponse::ToolUse {
1936            text: None,
1937            tool_calls: vec![ToolUseRequest {
1938                id: "call-1".into(),
1939                name: "shell".into(),
1940                input: serde_json::json!({"command": "echo hi"}),
1941            }],
1942            thinking_blocks: vec![],
1943        };
1944        let (mock, _counter) = MockProvider::default().with_tool_use(vec![
1945            tool_response,
1946            ChatResponse::Text("final answer".into()),
1947        ]);
1948        let provider = AnyProvider::Mock(mock);
1949        let executor = Arc::new(ToolOnceExecutor {
1950            calls: Mutex::new(0),
1951        });
1952
1953        let task_id = mgr
1954            .spawn(
1955                "bot",
1956                "run two turns",
1957                provider,
1958                executor,
1959                None,
1960                &SubAgentConfig::default(),
1961                SpawnContext::default(),
1962            )
1963            .unwrap();
1964
1965        // Wait for background loop to finish.
1966        tokio::time::sleep(tokio::time::Duration::from_millis(300)).await;
1967
1968        let result = mgr.collect(&task_id).await;
1969        assert!(result.is_ok(), "expected Ok, got: {result:?}");
1970    }
1971
1972    #[tokio::test]
1973    async fn collect_on_running_task_completes_eventually() {
1974        let mut mgr = make_manager();
1975        mgr.definitions.push(sample_def());
1976
1977        // Spawn with a slow response so the task is still running.
1978        let task_id = do_spawn(&mut mgr, "bot", "slow work").unwrap();
1979
1980        // collect() awaits the JoinHandle, so it will finish when the task completes.
1981        let result =
1982            tokio::time::timeout(tokio::time::Duration::from_secs(5), mgr.collect(&task_id)).await;
1983
1984        assert!(result.is_ok(), "collect timed out after 5s");
1985        let inner = result.unwrap();
1986        assert!(inner.is_ok(), "collect returned error: {inner:?}");
1987    }
1988
1989    #[test]
1990    fn concurrency_slot_freed_after_cancel() {
1991        let rt = tokio::runtime::Runtime::new().unwrap();
1992        let _guard = rt.enter();
1993        let mut mgr = SubAgentManager::new(1); // limit to 1
1994        mgr.definitions.push(sample_def());
1995
1996        let id1 = do_spawn(&mut mgr, "bot", "task 1").unwrap();
1997
1998        // Concurrency limit reached — second spawn should fail.
1999        let err = do_spawn(&mut mgr, "bot", "task 2").unwrap_err();
2000        assert!(
2001            matches!(err, SubAgentError::ConcurrencyLimit { .. }),
2002            "expected concurrency limit error, got: {err}"
2003        );
2004
2005        // Cancel the first agent to free the slot.
2006        mgr.cancel(&id1).unwrap();
2007
2008        // Now a new spawn should succeed.
2009        let result = do_spawn(&mut mgr, "bot", "task 3");
2010        assert!(
2011            result.is_ok(),
2012            "expected spawn to succeed after cancel, got: {result:?}"
2013        );
2014    }
2015
2016    #[tokio::test]
2017    async fn skill_bodies_prepended_to_system_prompt() {
2018        // Verify that when skills are passed to spawn(), the agent loop prepends
2019        // them to the system prompt inside a ```skills fence.
2020        use zeph_llm::mock::MockProvider;
2021
2022        let (mock, recorded) = MockProvider::default().with_recording();
2023        let provider = AnyProvider::Mock(mock);
2024
2025        let mut mgr = make_manager();
2026        mgr.definitions.push(sample_def());
2027
2028        let skill_bodies = vec!["# skill-one\nDo something useful.".to_owned()];
2029        let task_id = mgr
2030            .spawn(
2031                "bot",
2032                "task",
2033                provider,
2034                noop_executor(),
2035                Some(skill_bodies),
2036                &SubAgentConfig::default(),
2037                SpawnContext::default(),
2038            )
2039            .unwrap();
2040
2041        // Wait for the loop to call the provider at least once.
2042        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
2043
2044        let calls = recorded.lock().unwrap();
2045        assert!(!calls.is_empty(), "provider should have been called");
2046        // The first message in the first call is the system prompt.
2047        let system_msg = &calls[0][0].content;
2048        assert!(
2049            system_msg.contains("```skills"),
2050            "system prompt must contain ```skills fence, got: {system_msg}"
2051        );
2052        assert!(
2053            system_msg.contains("skill-one"),
2054            "system prompt must contain the skill body, got: {system_msg}"
2055        );
2056        drop(calls);
2057
2058        let _ = mgr.collect(&task_id).await;
2059    }
2060
2061    #[tokio::test]
2062    async fn no_skills_does_not_add_fence_to_system_prompt() {
2063        use zeph_llm::mock::MockProvider;
2064
2065        let (mock, recorded) = MockProvider::default().with_recording();
2066        let provider = AnyProvider::Mock(mock);
2067
2068        let mut mgr = make_manager();
2069        mgr.definitions.push(sample_def());
2070
2071        let task_id = mgr
2072            .spawn(
2073                "bot",
2074                "task",
2075                provider,
2076                noop_executor(),
2077                None,
2078                &SubAgentConfig::default(),
2079                SpawnContext::default(),
2080            )
2081            .unwrap();
2082
2083        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
2084
2085        let calls = recorded.lock().unwrap();
2086        assert!(!calls.is_empty());
2087        let system_msg = &calls[0][0].content;
2088        assert!(
2089            !system_msg.contains("```skills"),
2090            "system prompt must not contain skills fence when no skills passed"
2091        );
2092        drop(calls);
2093
2094        let _ = mgr.collect(&task_id).await;
2095    }
2096
2097    #[tokio::test]
2098    async fn statuses_does_not_include_collected_task() {
2099        let mut mgr = make_manager();
2100        mgr.definitions.push(sample_def());
2101
2102        let task_id = do_spawn(&mut mgr, "bot", "task").unwrap();
2103        assert_eq!(mgr.statuses().len(), 1);
2104
2105        // Wait for task completion then collect.
2106        tokio::time::sleep(tokio::time::Duration::from_millis(300)).await;
2107        let _ = mgr.collect(&task_id).await;
2108
2109        // After collect(), the task should no longer appear in statuses.
2110        assert!(
2111            mgr.statuses().is_empty(),
2112            "expected empty statuses after collect"
2113        );
2114    }
2115
2116    #[tokio::test]
2117    async fn background_agent_auto_denies_secret_request() {
2118        use zeph_llm::mock::MockProvider;
2119
2120        // Background agent that requests a secret — the loop must auto-deny without blocking.
2121        let def = SubAgentDef::parse(indoc! {"
2122            ---
2123            name: bg-bot
2124            description: Background bot
2125            permissions:
2126              background: true
2127              secrets:
2128                - api-key
2129            ---
2130
2131            [REQUEST_SECRET: api-key]
2132        "})
2133        .unwrap();
2134
2135        let (mock, recorded) = MockProvider::default().with_recording();
2136        let provider = AnyProvider::Mock(mock);
2137
2138        let mut mgr = make_manager();
2139        mgr.definitions.push(def);
2140
2141        let task_id = mgr
2142            .spawn(
2143                "bg-bot",
2144                "task",
2145                provider,
2146                noop_executor(),
2147                None,
2148                &SubAgentConfig::default(),
2149                SpawnContext::default(),
2150            )
2151            .unwrap();
2152
2153        // Should complete without blocking — background auto-denies the secret.
2154        let result =
2155            tokio::time::timeout(tokio::time::Duration::from_secs(2), mgr.collect(&task_id)).await;
2156        assert!(
2157            result.is_ok(),
2158            "background agent must not block on secret request"
2159        );
2160        drop(recorded);
2161    }
2162
2163    #[test]
2164    fn spawn_with_plan_mode_definition_succeeds() {
2165        let rt = tokio::runtime::Runtime::new().unwrap();
2166        let _guard = rt.enter();
2167
2168        let def = SubAgentDef::parse(indoc! {"
2169            ---
2170            name: planner
2171            description: A planner bot
2172            permissions:
2173              permission_mode: plan
2174            ---
2175
2176            Plan only.
2177        "})
2178        .unwrap();
2179
2180        let mut mgr = make_manager();
2181        mgr.definitions.push(def);
2182
2183        let task_id = do_spawn(&mut mgr, "planner", "make a plan").unwrap();
2184        assert!(!task_id.is_empty());
2185        mgr.cancel(&task_id).unwrap();
2186    }
2187
2188    #[test]
2189    fn spawn_with_disallowed_tools_definition_succeeds() {
2190        let rt = tokio::runtime::Runtime::new().unwrap();
2191        let _guard = rt.enter();
2192
2193        let def = SubAgentDef::parse(indoc! {"
2194            ---
2195            name: safe-bot
2196            description: Bot with disallowed tools
2197            tools:
2198              allow:
2199                - shell
2200                - web
2201              except:
2202                - shell
2203            ---
2204
2205            Do safe things.
2206        "})
2207        .unwrap();
2208
2209        assert_eq!(def.disallowed_tools, ["shell"]);
2210
2211        let mut mgr = make_manager();
2212        mgr.definitions.push(def);
2213
2214        let task_id = do_spawn(&mut mgr, "safe-bot", "task").unwrap();
2215        assert!(!task_id.is_empty());
2216        mgr.cancel(&task_id).unwrap();
2217    }
2218
2219    // ── #1180: default_permission_mode / default_disallowed_tools applied at spawn ──
2220
2221    #[test]
2222    fn spawn_applies_default_permission_mode_from_config() {
2223        let rt = tokio::runtime::Runtime::new().unwrap();
2224        let _guard = rt.enter();
2225
2226        // Agent has Default permission mode — config sets Plan as default.
2227        let def =
2228            SubAgentDef::parse("---\nname: bot\ndescription: A bot\n---\n\nDo things.\n").unwrap();
2229        assert_eq!(def.permissions.permission_mode, PermissionMode::Default);
2230
2231        let mut mgr = make_manager();
2232        mgr.definitions.push(def);
2233
2234        let cfg = SubAgentConfig {
2235            default_permission_mode: Some(PermissionMode::Plan),
2236            ..SubAgentConfig::default()
2237        };
2238
2239        let task_id = mgr
2240            .spawn(
2241                "bot",
2242                "prompt",
2243                mock_provider(vec!["done"]),
2244                noop_executor(),
2245                None,
2246                &cfg,
2247                SpawnContext::default(),
2248            )
2249            .unwrap();
2250        assert!(!task_id.is_empty());
2251        mgr.cancel(&task_id).unwrap();
2252    }
2253
2254    #[test]
2255    fn spawn_does_not_override_explicit_permission_mode() {
2256        let rt = tokio::runtime::Runtime::new().unwrap();
2257        let _guard = rt.enter();
2258
2259        // Agent explicitly sets DontAsk — config default must not override it.
2260        let def = SubAgentDef::parse(indoc! {"
2261            ---
2262            name: bot
2263            description: A bot
2264            permissions:
2265              permission_mode: dont_ask
2266            ---
2267
2268            Do things.
2269        "})
2270        .unwrap();
2271        assert_eq!(def.permissions.permission_mode, PermissionMode::DontAsk);
2272
2273        let mut mgr = make_manager();
2274        mgr.definitions.push(def);
2275
2276        let cfg = SubAgentConfig {
2277            default_permission_mode: Some(PermissionMode::Plan),
2278            ..SubAgentConfig::default()
2279        };
2280
2281        let task_id = mgr
2282            .spawn(
2283                "bot",
2284                "prompt",
2285                mock_provider(vec!["done"]),
2286                noop_executor(),
2287                None,
2288                &cfg,
2289                SpawnContext::default(),
2290            )
2291            .unwrap();
2292        assert!(!task_id.is_empty());
2293        mgr.cancel(&task_id).unwrap();
2294    }
2295
2296    #[test]
2297    fn spawn_merges_global_disallowed_tools() {
2298        let rt = tokio::runtime::Runtime::new().unwrap();
2299        let _guard = rt.enter();
2300
2301        let def =
2302            SubAgentDef::parse("---\nname: bot\ndescription: A bot\n---\n\nDo things.\n").unwrap();
2303
2304        let mut mgr = make_manager();
2305        mgr.definitions.push(def);
2306
2307        let cfg = SubAgentConfig {
2308            default_disallowed_tools: vec!["dangerous".into()],
2309            ..SubAgentConfig::default()
2310        };
2311
2312        let task_id = mgr
2313            .spawn(
2314                "bot",
2315                "prompt",
2316                mock_provider(vec!["done"]),
2317                noop_executor(),
2318                None,
2319                &cfg,
2320                SpawnContext::default(),
2321            )
2322            .unwrap();
2323        assert!(!task_id.is_empty());
2324        mgr.cancel(&task_id).unwrap();
2325    }
2326
2327    // ── #1182: bypass_permissions blocked without config gate ─────────────
2328
2329    #[test]
2330    fn spawn_bypass_permissions_without_config_gate_is_error() {
2331        let rt = tokio::runtime::Runtime::new().unwrap();
2332        let _guard = rt.enter();
2333
2334        let def = SubAgentDef::parse(indoc! {"
2335            ---
2336            name: bypass-bot
2337            description: A bot with bypass mode
2338            permissions:
2339              permission_mode: bypass_permissions
2340            ---
2341
2342            Unrestricted.
2343        "})
2344        .unwrap();
2345
2346        let mut mgr = make_manager();
2347        mgr.definitions.push(def);
2348
2349        // Default config: allow_bypass_permissions = false
2350        let cfg = SubAgentConfig::default();
2351        let err = mgr
2352            .spawn(
2353                "bypass-bot",
2354                "prompt",
2355                mock_provider(vec!["done"]),
2356                noop_executor(),
2357                None,
2358                &cfg,
2359                SpawnContext::default(),
2360            )
2361            .unwrap_err();
2362        assert!(matches!(err, SubAgentError::Invalid(_)));
2363    }
2364
2365    #[test]
2366    fn spawn_bypass_permissions_with_config_gate_succeeds() {
2367        let rt = tokio::runtime::Runtime::new().unwrap();
2368        let _guard = rt.enter();
2369
2370        let def = SubAgentDef::parse(indoc! {"
2371            ---
2372            name: bypass-bot
2373            description: A bot with bypass mode
2374            permissions:
2375              permission_mode: bypass_permissions
2376            ---
2377
2378            Unrestricted.
2379        "})
2380        .unwrap();
2381
2382        let mut mgr = make_manager();
2383        mgr.definitions.push(def);
2384
2385        let cfg = SubAgentConfig {
2386            allow_bypass_permissions: true,
2387            ..SubAgentConfig::default()
2388        };
2389
2390        let task_id = mgr
2391            .spawn(
2392                "bypass-bot",
2393                "prompt",
2394                mock_provider(vec!["done"]),
2395                noop_executor(),
2396                None,
2397                &cfg,
2398                SpawnContext::default(),
2399            )
2400            .unwrap();
2401        assert!(!task_id.is_empty());
2402        mgr.cancel(&task_id).unwrap();
2403    }
2404
2405    // ── resume() tests ────────────────────────────────────────────────────────
2406
2407    /// Write a minimal completed meta file and empty JSONL so `resume()` has something to load.
2408    fn write_completed_meta(dir: &std::path::Path, agent_id: &str, def_name: &str) {
2409        use crate::transcript::{TranscriptMeta, TranscriptWriter};
2410        let meta = TranscriptMeta {
2411            agent_id: agent_id.to_owned(),
2412            agent_name: def_name.to_owned(),
2413            def_name: def_name.to_owned(),
2414            status: SubAgentState::Completed,
2415            started_at: "2026-01-01T00:00:00Z".to_owned(),
2416            finished_at: Some("2026-01-01T00:01:00Z".to_owned()),
2417            resumed_from: None,
2418            turns_used: 1,
2419        };
2420        TranscriptWriter::write_meta(dir, agent_id, &meta).unwrap();
2421        // Create the empty JSONL so TranscriptReader::load succeeds.
2422        std::fs::write(dir.join(format!("{agent_id}.jsonl")), b"").unwrap();
2423    }
2424
2425    fn make_cfg_with_dir(dir: &std::path::Path) -> SubAgentConfig {
2426        SubAgentConfig {
2427            transcript_dir: Some(dir.to_path_buf()),
2428            ..SubAgentConfig::default()
2429        }
2430    }
2431
2432    #[test]
2433    fn resume_not_found_returns_not_found_error() {
2434        let rt = tokio::runtime::Runtime::new().unwrap();
2435        let _guard = rt.enter();
2436
2437        let tmp = tempfile::tempdir().unwrap();
2438        let mut mgr = make_manager();
2439        mgr.definitions.push(sample_def());
2440        let cfg = make_cfg_with_dir(tmp.path());
2441
2442        let err = mgr
2443            .resume(
2444                "deadbeef",
2445                "continue",
2446                mock_provider(vec!["done"]),
2447                noop_executor(),
2448                None,
2449                &cfg,
2450            )
2451            .unwrap_err();
2452        assert!(matches!(err, SubAgentError::NotFound(_)));
2453    }
2454
2455    #[test]
2456    fn resume_ambiguous_id_returns_ambiguous_error() {
2457        let rt = tokio::runtime::Runtime::new().unwrap();
2458        let _guard = rt.enter();
2459
2460        let tmp = tempfile::tempdir().unwrap();
2461        write_completed_meta(tmp.path(), "aabb0001-0000-0000-0000-000000000000", "bot");
2462        write_completed_meta(tmp.path(), "aabb0002-0000-0000-0000-000000000000", "bot");
2463
2464        let mut mgr = make_manager();
2465        mgr.definitions.push(sample_def());
2466        let cfg = make_cfg_with_dir(tmp.path());
2467
2468        let err = mgr
2469            .resume(
2470                "aabb",
2471                "continue",
2472                mock_provider(vec!["done"]),
2473                noop_executor(),
2474                None,
2475                &cfg,
2476            )
2477            .unwrap_err();
2478        assert!(matches!(err, SubAgentError::AmbiguousId(_, 2)));
2479    }
2480
2481    #[test]
2482    fn resume_still_running_via_active_agents_returns_error() {
2483        let rt = tokio::runtime::Runtime::new().unwrap();
2484        let _guard = rt.enter();
2485
2486        let tmp = tempfile::tempdir().unwrap();
2487        let agent_id = "cafebabe-0000-0000-0000-000000000000";
2488        write_completed_meta(tmp.path(), agent_id, "bot");
2489
2490        let mut mgr = make_manager();
2491        mgr.definitions.push(sample_def());
2492
2493        // Manually insert a fake active handle so resume() thinks it's still running.
2494        let (status_tx, status_rx) = watch::channel(SubAgentStatus {
2495            state: SubAgentState::Working,
2496            last_message: None,
2497            turns_used: 0,
2498            started_at: std::time::Instant::now(),
2499        });
2500        let (_secret_request_tx, pending_secret_rx) = tokio::sync::mpsc::channel(1);
2501        let (secret_tx, _secret_rx) = tokio::sync::mpsc::channel(1);
2502        let cancel = CancellationToken::new();
2503        let fake_def = sample_def();
2504        mgr.agents.insert(
2505            agent_id.to_owned(),
2506            SubAgentHandle {
2507                id: agent_id.to_owned(),
2508                def: fake_def,
2509                task_id: agent_id.to_owned(),
2510                state: SubAgentState::Working,
2511                join_handle: None,
2512                cancel,
2513                status_rx,
2514                grants: PermissionGrants::default(),
2515                pending_secret_rx,
2516                secret_tx,
2517                started_at_str: "2026-01-01T00:00:00Z".to_owned(),
2518                transcript_dir: None,
2519            },
2520        );
2521        drop(status_tx);
2522
2523        let cfg = make_cfg_with_dir(tmp.path());
2524        let err = mgr
2525            .resume(
2526                agent_id,
2527                "continue",
2528                mock_provider(vec!["done"]),
2529                noop_executor(),
2530                None,
2531                &cfg,
2532            )
2533            .unwrap_err();
2534        assert!(matches!(err, SubAgentError::StillRunning(_)));
2535    }
2536
2537    #[test]
2538    fn resume_def_not_found_returns_not_found_error() {
2539        let rt = tokio::runtime::Runtime::new().unwrap();
2540        let _guard = rt.enter();
2541
2542        let tmp = tempfile::tempdir().unwrap();
2543        let agent_id = "feedface-0000-0000-0000-000000000000";
2544        // Meta points to "unknown-agent" which is not in definitions.
2545        write_completed_meta(tmp.path(), agent_id, "unknown-agent");
2546
2547        let mut mgr = make_manager();
2548        // Do NOT push any definition — so def_name "unknown-agent" won't be found.
2549        let cfg = make_cfg_with_dir(tmp.path());
2550
2551        let err = mgr
2552            .resume(
2553                "feedface",
2554                "continue",
2555                mock_provider(vec!["done"]),
2556                noop_executor(),
2557                None,
2558                &cfg,
2559            )
2560            .unwrap_err();
2561        assert!(matches!(err, SubAgentError::NotFound(_)));
2562    }
2563
2564    #[test]
2565    fn resume_concurrency_limit_reached_returns_error() {
2566        let rt = tokio::runtime::Runtime::new().unwrap();
2567        let _guard = rt.enter();
2568
2569        let tmp = tempfile::tempdir().unwrap();
2570        let agent_id = "babe0000-0000-0000-0000-000000000000";
2571        write_completed_meta(tmp.path(), agent_id, "bot");
2572
2573        let mut mgr = SubAgentManager::new(1); // limit of 1
2574        mgr.definitions.push(sample_def());
2575
2576        // Occupy the single slot.
2577        let _running_id = do_spawn(&mut mgr, "bot", "occupying slot").unwrap();
2578
2579        let cfg = make_cfg_with_dir(tmp.path());
2580        let err = mgr
2581            .resume(
2582                "babe0000",
2583                "continue",
2584                mock_provider(vec!["done"]),
2585                noop_executor(),
2586                None,
2587                &cfg,
2588            )
2589            .unwrap_err();
2590        assert!(
2591            matches!(err, SubAgentError::ConcurrencyLimit { .. }),
2592            "expected concurrency limit error, got: {err}"
2593        );
2594    }
2595
2596    #[test]
2597    fn resume_happy_path_returns_new_task_id() {
2598        let rt = tokio::runtime::Runtime::new().unwrap();
2599        let _guard = rt.enter();
2600
2601        let tmp = tempfile::tempdir().unwrap();
2602        let agent_id = "deadcode-0000-0000-0000-000000000000";
2603        write_completed_meta(tmp.path(), agent_id, "bot");
2604
2605        let mut mgr = make_manager();
2606        mgr.definitions.push(sample_def());
2607        let cfg = make_cfg_with_dir(tmp.path());
2608
2609        let (new_id, def_name) = mgr
2610            .resume(
2611                "deadcode",
2612                "continue the work",
2613                mock_provider(vec!["done"]),
2614                noop_executor(),
2615                None,
2616                &cfg,
2617            )
2618            .unwrap();
2619
2620        assert!(!new_id.is_empty(), "new task id must not be empty");
2621        assert_ne!(
2622            new_id, agent_id,
2623            "resumed session must have a fresh task id"
2624        );
2625        assert_eq!(def_name, "bot");
2626        // New agent must be tracked.
2627        assert!(mgr.agents.contains_key(&new_id));
2628
2629        mgr.cancel(&new_id).unwrap();
2630    }
2631
2632    #[test]
2633    fn resume_populates_resumed_from_in_meta() {
2634        let rt = tokio::runtime::Runtime::new().unwrap();
2635        let _guard = rt.enter();
2636
2637        let tmp = tempfile::tempdir().unwrap();
2638        let original_id = "0000abcd-0000-0000-0000-000000000000";
2639        write_completed_meta(tmp.path(), original_id, "bot");
2640
2641        let mut mgr = make_manager();
2642        mgr.definitions.push(sample_def());
2643        let cfg = make_cfg_with_dir(tmp.path());
2644
2645        let (new_id, _) = mgr
2646            .resume(
2647                "0000abcd",
2648                "continue",
2649                mock_provider(vec!["done"]),
2650                noop_executor(),
2651                None,
2652                &cfg,
2653            )
2654            .unwrap();
2655
2656        // The new meta sidecar must have resumed_from = original_id.
2657        let new_meta = crate::transcript::TranscriptReader::load_meta(tmp.path(), &new_id).unwrap();
2658        assert_eq!(
2659            new_meta.resumed_from.as_deref(),
2660            Some(original_id),
2661            "resumed_from must point to original agent id"
2662        );
2663
2664        mgr.cancel(&new_id).unwrap();
2665    }
2666
2667    #[test]
2668    fn def_name_for_resume_returns_def_name() {
2669        let rt = tokio::runtime::Runtime::new().unwrap();
2670        let _guard = rt.enter();
2671
2672        let tmp = tempfile::tempdir().unwrap();
2673        let agent_id = "aaaabbbb-0000-0000-0000-000000000000";
2674        write_completed_meta(tmp.path(), agent_id, "bot");
2675
2676        let mgr = make_manager();
2677        let cfg = make_cfg_with_dir(tmp.path());
2678
2679        let name = mgr.def_name_for_resume("aaaabbbb", &cfg).unwrap();
2680        assert_eq!(name, "bot");
2681    }
2682
2683    #[test]
2684    fn def_name_for_resume_not_found_returns_error() {
2685        let rt = tokio::runtime::Runtime::new().unwrap();
2686        let _guard = rt.enter();
2687
2688        let tmp = tempfile::tempdir().unwrap();
2689        let mgr = make_manager();
2690        let cfg = make_cfg_with_dir(tmp.path());
2691
2692        let err = mgr.def_name_for_resume("notexist", &cfg).unwrap_err();
2693        assert!(matches!(err, SubAgentError::NotFound(_)));
2694    }
2695
2696    // ── Memory scope tests ────────────────────────────────────────────────────
2697
2698    #[tokio::test]
2699    #[serial]
2700    async fn spawn_with_memory_scope_project_creates_directory() {
2701        let tmp = tempfile::tempdir().unwrap();
2702        let orig_dir = std::env::current_dir().unwrap();
2703        std::env::set_current_dir(tmp.path()).unwrap();
2704
2705        let def = SubAgentDef::parse(indoc! {"
2706            ---
2707            name: mem-agent
2708            description: Agent with memory
2709            memory: project
2710            ---
2711
2712            System prompt.
2713        "})
2714        .unwrap();
2715
2716        let mut mgr = make_manager();
2717        mgr.definitions.push(def);
2718
2719        let task_id = mgr
2720            .spawn(
2721                "mem-agent",
2722                "do something",
2723                mock_provider(vec!["done"]),
2724                noop_executor(),
2725                None,
2726                &SubAgentConfig::default(),
2727                SpawnContext::default(),
2728            )
2729            .unwrap();
2730        assert!(!task_id.is_empty());
2731        mgr.cancel(&task_id).unwrap();
2732
2733        // Verify memory directory was created.
2734        let mem_dir = tmp
2735            .path()
2736            .join(".zeph")
2737            .join("agent-memory")
2738            .join("mem-agent");
2739        assert!(
2740            mem_dir.exists(),
2741            "memory directory should be created at spawn"
2742        );
2743
2744        std::env::set_current_dir(orig_dir).unwrap();
2745    }
2746
2747    #[tokio::test]
2748    #[serial]
2749    async fn spawn_with_config_default_memory_scope_applies_when_def_has_none() {
2750        let tmp = tempfile::tempdir().unwrap();
2751        let orig_dir = std::env::current_dir().unwrap();
2752        std::env::set_current_dir(tmp.path()).unwrap();
2753
2754        let def = SubAgentDef::parse(indoc! {"
2755            ---
2756            name: mem-agent2
2757            description: Agent without explicit memory
2758            ---
2759
2760            System prompt.
2761        "})
2762        .unwrap();
2763
2764        let mut mgr = make_manager();
2765        mgr.definitions.push(def);
2766
2767        let cfg = SubAgentConfig {
2768            default_memory_scope: Some(MemoryScope::Project),
2769            ..SubAgentConfig::default()
2770        };
2771
2772        let task_id = mgr
2773            .spawn(
2774                "mem-agent2",
2775                "do something",
2776                mock_provider(vec!["done"]),
2777                noop_executor(),
2778                None,
2779                &cfg,
2780                SpawnContext::default(),
2781            )
2782            .unwrap();
2783        assert!(!task_id.is_empty());
2784        mgr.cancel(&task_id).unwrap();
2785
2786        // Verify memory directory was created via config default.
2787        let mem_dir = tmp
2788            .path()
2789            .join(".zeph")
2790            .join("agent-memory")
2791            .join("mem-agent2");
2792        assert!(
2793            mem_dir.exists(),
2794            "config default memory scope should create directory"
2795        );
2796
2797        std::env::set_current_dir(orig_dir).unwrap();
2798    }
2799
2800    #[tokio::test]
2801    #[serial]
2802    async fn spawn_with_memory_blocked_by_disallowed_tools_skips_memory() {
2803        let tmp = tempfile::tempdir().unwrap();
2804        let orig_dir = std::env::current_dir().unwrap();
2805        std::env::set_current_dir(tmp.path()).unwrap();
2806
2807        let def = SubAgentDef::parse(indoc! {"
2808            ---
2809            name: blocked-mem
2810            description: Agent with memory but blocked tools
2811            memory: project
2812            tools:
2813              except:
2814                - Read
2815                - Write
2816                - Edit
2817            ---
2818
2819            System prompt.
2820        "})
2821        .unwrap();
2822
2823        let mut mgr = make_manager();
2824        mgr.definitions.push(def);
2825
2826        let task_id = mgr
2827            .spawn(
2828                "blocked-mem",
2829                "do something",
2830                mock_provider(vec!["done"]),
2831                noop_executor(),
2832                None,
2833                &SubAgentConfig::default(),
2834                SpawnContext::default(),
2835            )
2836            .unwrap();
2837        assert!(!task_id.is_empty());
2838        mgr.cancel(&task_id).unwrap();
2839
2840        // Memory dir should NOT be created because tools are blocked (HIGH-04).
2841        let mem_dir = tmp
2842            .path()
2843            .join(".zeph")
2844            .join("agent-memory")
2845            .join("blocked-mem");
2846        assert!(
2847            !mem_dir.exists(),
2848            "memory directory should not be created when tools are blocked"
2849        );
2850
2851        std::env::set_current_dir(orig_dir).unwrap();
2852    }
2853
2854    #[tokio::test]
2855    #[serial]
2856    async fn spawn_without_memory_scope_no_directory_created() {
2857        let tmp = tempfile::tempdir().unwrap();
2858        let orig_dir = std::env::current_dir().unwrap();
2859        std::env::set_current_dir(tmp.path()).unwrap();
2860
2861        let def = SubAgentDef::parse(indoc! {"
2862            ---
2863            name: no-mem-agent
2864            description: Agent without memory
2865            ---
2866
2867            System prompt.
2868        "})
2869        .unwrap();
2870
2871        let mut mgr = make_manager();
2872        mgr.definitions.push(def);
2873
2874        let task_id = mgr
2875            .spawn(
2876                "no-mem-agent",
2877                "do something",
2878                mock_provider(vec!["done"]),
2879                noop_executor(),
2880                None,
2881                &SubAgentConfig::default(),
2882                SpawnContext::default(),
2883            )
2884            .unwrap();
2885        assert!(!task_id.is_empty());
2886        mgr.cancel(&task_id).unwrap();
2887
2888        // No agent-memory directory should exist (transcript dirs may be created separately).
2889        let mem_dir = tmp.path().join(".zeph").join("agent-memory");
2890        assert!(
2891            !mem_dir.exists(),
2892            "no agent-memory directory should be created without memory scope"
2893        );
2894
2895        std::env::set_current_dir(orig_dir).unwrap();
2896    }
2897
2898    #[test]
2899    #[serial]
2900    fn build_prompt_injects_memory_block_after_behavioral_prompt() {
2901        let tmp = tempfile::tempdir().unwrap();
2902        let orig_dir = std::env::current_dir().unwrap();
2903        std::env::set_current_dir(tmp.path()).unwrap();
2904
2905        // Create memory directory and MEMORY.md.
2906        let mem_dir = tmp
2907            .path()
2908            .join(".zeph")
2909            .join("agent-memory")
2910            .join("test-agent");
2911        std::fs::create_dir_all(&mem_dir).unwrap();
2912        std::fs::write(mem_dir.join("MEMORY.md"), "# Test Memory\nkey: value\n").unwrap();
2913
2914        let mut def = SubAgentDef::parse(indoc! {"
2915            ---
2916            name: test-agent
2917            description: Test agent
2918            memory: project
2919            ---
2920
2921            Behavioral instructions here.
2922        "})
2923        .unwrap();
2924
2925        let prompt = build_system_prompt_with_memory(&mut def, Some(MemoryScope::Project));
2926
2927        // Memory block must appear AFTER behavioral prompt text.
2928        let behavioral_pos = prompt.find("Behavioral instructions").unwrap();
2929        let memory_pos = prompt.find("<agent-memory>").unwrap();
2930        assert!(
2931            memory_pos > behavioral_pos,
2932            "memory block must appear AFTER behavioral prompt"
2933        );
2934        assert!(
2935            prompt.contains("key: value"),
2936            "MEMORY.md content must be injected"
2937        );
2938
2939        std::env::set_current_dir(orig_dir).unwrap();
2940    }
2941
2942    #[test]
2943    #[serial]
2944    fn build_prompt_auto_enables_read_write_edit_for_allowlist() {
2945        let tmp = tempfile::tempdir().unwrap();
2946        let orig_dir = std::env::current_dir().unwrap();
2947        std::env::set_current_dir(tmp.path()).unwrap();
2948
2949        let mut def = SubAgentDef::parse(indoc! {"
2950            ---
2951            name: allowlist-agent
2952            description: AllowList agent
2953            memory: project
2954            tools:
2955              allow:
2956                - shell
2957            ---
2958
2959            System prompt.
2960        "})
2961        .unwrap();
2962
2963        assert!(
2964            matches!(&def.tools, ToolPolicy::AllowList(list) if list == &["shell"]),
2965            "should start with only shell"
2966        );
2967
2968        build_system_prompt_with_memory(&mut def, Some(MemoryScope::Project));
2969
2970        // Read/Write/Edit must be auto-added to the AllowList.
2971        assert!(
2972            matches!(&def.tools, ToolPolicy::AllowList(list)
2973                if list.contains(&"Read".to_owned())
2974                    && list.contains(&"Write".to_owned())
2975                    && list.contains(&"Edit".to_owned())),
2976            "Read/Write/Edit must be auto-enabled in AllowList when memory is set"
2977        );
2978
2979        std::env::set_current_dir(orig_dir).unwrap();
2980    }
2981
2982    #[tokio::test]
2983    #[serial]
2984    async fn spawn_with_explicit_def_memory_overrides_config_default() {
2985        let tmp = tempfile::tempdir().unwrap();
2986        let orig_dir = std::env::current_dir().unwrap();
2987        std::env::set_current_dir(tmp.path()).unwrap();
2988
2989        // Agent explicitly sets memory: local, config sets default: project.
2990        // The explicit local should win.
2991        let def = SubAgentDef::parse(indoc! {"
2992            ---
2993            name: override-agent
2994            description: Agent with explicit memory
2995            memory: local
2996            ---
2997
2998            System prompt.
2999        "})
3000        .unwrap();
3001        assert_eq!(def.memory, Some(MemoryScope::Local));
3002
3003        let mut mgr = make_manager();
3004        mgr.definitions.push(def);
3005
3006        let cfg = SubAgentConfig {
3007            default_memory_scope: Some(MemoryScope::Project),
3008            ..SubAgentConfig::default()
3009        };
3010
3011        let task_id = mgr
3012            .spawn(
3013                "override-agent",
3014                "do something",
3015                mock_provider(vec!["done"]),
3016                noop_executor(),
3017                None,
3018                &cfg,
3019                SpawnContext::default(),
3020            )
3021            .unwrap();
3022        assert!(!task_id.is_empty());
3023        mgr.cancel(&task_id).unwrap();
3024
3025        // Local scope directory should be created, not project scope.
3026        let local_dir = tmp
3027            .path()
3028            .join(".zeph")
3029            .join("agent-memory-local")
3030            .join("override-agent");
3031        let project_dir = tmp
3032            .path()
3033            .join(".zeph")
3034            .join("agent-memory")
3035            .join("override-agent");
3036        assert!(local_dir.exists(), "local memory dir should be created");
3037        assert!(
3038            !project_dir.exists(),
3039            "project memory dir must NOT be created"
3040        );
3041
3042        std::env::set_current_dir(orig_dir).unwrap();
3043    }
3044
3045    #[tokio::test]
3046    #[serial]
3047    async fn spawn_memory_blocked_by_deny_list_policy() {
3048        let tmp = tempfile::tempdir().unwrap();
3049        let orig_dir = std::env::current_dir().unwrap();
3050        std::env::set_current_dir(tmp.path()).unwrap();
3051
3052        // tools.deny: [Read, Write, Edit] — DenyList policy blocking all file tools.
3053        let def = SubAgentDef::parse(indoc! {"
3054            ---
3055            name: deny-list-mem
3056            description: Agent with deny list
3057            memory: project
3058            tools:
3059              deny:
3060                - Read
3061                - Write
3062                - Edit
3063            ---
3064
3065            System prompt.
3066        "})
3067        .unwrap();
3068
3069        let mut mgr = make_manager();
3070        mgr.definitions.push(def);
3071
3072        let task_id = mgr
3073            .spawn(
3074                "deny-list-mem",
3075                "do something",
3076                mock_provider(vec!["done"]),
3077                noop_executor(),
3078                None,
3079                &SubAgentConfig::default(),
3080                SpawnContext::default(),
3081            )
3082            .unwrap();
3083        assert!(!task_id.is_empty());
3084        mgr.cancel(&task_id).unwrap();
3085
3086        // Memory dir should NOT be created because DenyList blocks file tools (REV-HIGH-02).
3087        let mem_dir = tmp
3088            .path()
3089            .join(".zeph")
3090            .join("agent-memory")
3091            .join("deny-list-mem");
3092        assert!(
3093            !mem_dir.exists(),
3094            "memory dir must not be created when DenyList blocks all file tools"
3095        );
3096
3097        std::env::set_current_dir(orig_dir).unwrap();
3098    }
3099
3100    // ── regression tests for #1467: sub-agent tools passed to LLM ────────────
3101
3102    fn make_agent_loop_args(
3103        provider: AnyProvider,
3104        executor: FilteredToolExecutor,
3105        max_turns: u32,
3106    ) -> AgentLoopArgs {
3107        let (status_tx, _status_rx) = tokio::sync::watch::channel(SubAgentStatus {
3108            state: SubAgentState::Working,
3109            last_message: None,
3110            turns_used: 0,
3111            started_at: std::time::Instant::now(),
3112        });
3113        let (secret_request_tx, _secret_request_rx) = tokio::sync::mpsc::channel(1);
3114        let (_secret_approved_tx, secret_rx) = tokio::sync::mpsc::channel::<Option<String>>(1);
3115        AgentLoopArgs {
3116            provider,
3117            executor,
3118            system_prompt: "You are a bot".into(),
3119            task_prompt: "Do something".into(),
3120            skills: None,
3121            max_turns,
3122            cancel: tokio_util::sync::CancellationToken::new(),
3123            status_tx,
3124            started_at: std::time::Instant::now(),
3125            secret_request_tx,
3126            secret_rx,
3127            background: false,
3128            hooks: super::super::hooks::SubagentHooks::default(),
3129            task_id: "test-task".into(),
3130            agent_name: "test-bot".into(),
3131            initial_messages: vec![],
3132            transcript_writer: None,
3133            spawn_depth: 0,
3134            mcp_tool_names: Vec::new(),
3135        }
3136    }
3137
3138    #[tokio::test]
3139    async fn run_agent_loop_passes_tools_to_provider() {
3140        use std::sync::Arc;
3141        use zeph_llm::provider::ChatResponse;
3142        use zeph_tools::registry::{InvocationHint, ToolDef};
3143
3144        // Executor that exposes one tool definition.
3145        struct SingleToolExecutor;
3146
3147        impl ErasedToolExecutor for SingleToolExecutor {
3148            fn execute_erased<'a>(
3149                &'a self,
3150                _response: &'a str,
3151            ) -> Pin<
3152                Box<
3153                    dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>>
3154                        + Send
3155                        + 'a,
3156                >,
3157            > {
3158                Box::pin(std::future::ready(Ok(None)))
3159            }
3160
3161            fn execute_confirmed_erased<'a>(
3162                &'a self,
3163                _response: &'a str,
3164            ) -> Pin<
3165                Box<
3166                    dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>>
3167                        + Send
3168                        + 'a,
3169                >,
3170            > {
3171                Box::pin(std::future::ready(Ok(None)))
3172            }
3173
3174            fn tool_definitions_erased(&self) -> Vec<ToolDef> {
3175                vec![ToolDef {
3176                    id: std::borrow::Cow::Borrowed("shell"),
3177                    description: std::borrow::Cow::Borrowed("Run a shell command"),
3178                    schema: schemars::Schema::default(),
3179                    invocation: InvocationHint::ToolCall,
3180                    output_schema: None,
3181                }]
3182            }
3183
3184            fn execute_tool_call_erased<'a>(
3185                &'a self,
3186                _call: &'a ToolCall,
3187            ) -> Pin<
3188                Box<
3189                    dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>>
3190                        + Send
3191                        + 'a,
3192                >,
3193            > {
3194                Box::pin(std::future::ready(Ok(None)))
3195            }
3196
3197            fn is_tool_retryable_erased(&self, _tool_id: &str) -> bool {
3198                false
3199            }
3200        }
3201
3202        // MockProvider with tool_use: records call count for chat_with_tools.
3203        let (mock, tool_call_count) =
3204            MockProvider::default().with_tool_use(vec![ChatResponse::Text("done".into())]);
3205        let provider = AnyProvider::Mock(mock);
3206        let executor =
3207            FilteredToolExecutor::new(Arc::new(SingleToolExecutor), ToolPolicy::InheritAll);
3208
3209        let args = make_agent_loop_args(provider, executor, 1);
3210        let result = run_agent_loop(args).await;
3211        assert!(result.is_ok(), "loop failed: {result:?}");
3212        assert_eq!(
3213            *tool_call_count.lock().unwrap(),
3214            1,
3215            "chat_with_tools must have been called exactly once"
3216        );
3217    }
3218
3219    #[tokio::test]
3220    async fn run_agent_loop_executes_native_tool_call() {
3221        use std::sync::{Arc, Mutex};
3222        use zeph_llm::provider::{ChatResponse, ToolUseRequest};
3223        use zeph_tools::registry::ToolDef;
3224
3225        struct TrackingExecutor {
3226            calls: Mutex<Vec<String>>,
3227        }
3228
3229        impl ErasedToolExecutor for TrackingExecutor {
3230            fn execute_erased<'a>(
3231                &'a self,
3232                _response: &'a str,
3233            ) -> Pin<
3234                Box<
3235                    dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>>
3236                        + Send
3237                        + 'a,
3238                >,
3239            > {
3240                Box::pin(std::future::ready(Ok(None)))
3241            }
3242
3243            fn execute_confirmed_erased<'a>(
3244                &'a self,
3245                _response: &'a str,
3246            ) -> Pin<
3247                Box<
3248                    dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>>
3249                        + Send
3250                        + 'a,
3251                >,
3252            > {
3253                Box::pin(std::future::ready(Ok(None)))
3254            }
3255
3256            fn tool_definitions_erased(&self) -> Vec<ToolDef> {
3257                vec![]
3258            }
3259
3260            fn execute_tool_call_erased<'a>(
3261                &'a self,
3262                call: &'a ToolCall,
3263            ) -> Pin<
3264                Box<
3265                    dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>>
3266                        + Send
3267                        + 'a,
3268                >,
3269            > {
3270                self.calls.lock().unwrap().push(call.tool_id.to_string());
3271                let output = ToolOutput {
3272                    tool_name: call.tool_id.clone(),
3273                    summary: "executed".into(),
3274                    blocks_executed: 1,
3275                    filter_stats: None,
3276                    diff: None,
3277                    streamed: false,
3278                    terminal_id: None,
3279                    locations: None,
3280                    raw_response: None,
3281                    claim_source: None,
3282                };
3283                Box::pin(std::future::ready(Ok(Some(output))))
3284            }
3285
3286            fn is_tool_retryable_erased(&self, _tool_id: &str) -> bool {
3287                false
3288            }
3289        }
3290
3291        // Provider: first call returns ToolUse, second returns Text.
3292        let (mock, _counter) = MockProvider::default().with_tool_use(vec![
3293            ChatResponse::ToolUse {
3294                text: None,
3295                tool_calls: vec![ToolUseRequest {
3296                    id: "call-1".into(),
3297                    name: "shell".into(),
3298                    input: serde_json::json!({"command": "echo hi"}),
3299                }],
3300                thinking_blocks: vec![],
3301            },
3302            ChatResponse::Text("all done".into()),
3303        ]);
3304
3305        let tracker = Arc::new(TrackingExecutor {
3306            calls: Mutex::new(vec![]),
3307        });
3308        let tracker_clone = Arc::clone(&tracker);
3309        let executor = FilteredToolExecutor::new(tracker_clone, ToolPolicy::InheritAll);
3310
3311        let args = make_agent_loop_args(AnyProvider::Mock(mock), executor, 5);
3312        let result = run_agent_loop(args).await;
3313        assert!(result.is_ok(), "loop failed: {result:?}");
3314        assert_eq!(result.unwrap(), "all done");
3315
3316        let recorded = tracker.calls.lock().unwrap();
3317        assert_eq!(
3318            recorded.len(),
3319            1,
3320            "execute_tool_call_erased must be called once"
3321        );
3322        assert_eq!(recorded[0], "shell");
3323    }
3324
3325    // --- Fix #2582 tests ---
3326
3327    #[test]
3328    fn build_system_prompt_injects_working_directory() {
3329        use tempfile::TempDir;
3330
3331        let tmp = TempDir::new().unwrap();
3332        let orig = std::env::current_dir().unwrap();
3333        std::env::set_current_dir(tmp.path()).unwrap();
3334
3335        let mut def = SubAgentDef::parse(indoc! {"
3336            ---
3337            name: cwd-agent
3338            description: test
3339            ---
3340            Base prompt.
3341        "})
3342        .unwrap();
3343
3344        let prompt = build_system_prompt_with_memory(&mut def, None);
3345        std::env::set_current_dir(orig).unwrap();
3346
3347        assert!(
3348            prompt.contains("Working directory:"),
3349            "system prompt must contain 'Working directory:', got: {prompt}"
3350        );
3351        assert!(
3352            prompt.contains(tmp.path().to_str().unwrap()),
3353            "system prompt must contain the actual cwd path, got: {prompt}"
3354        );
3355    }
3356
3357    #[tokio::test]
3358    async fn text_only_first_turn_sends_nudge_and_retries() {
3359        use zeph_llm::mock::MockProvider;
3360
3361        // First call returns text-only; second call also text (loop should stop after nudge retry).
3362        let (mock, call_count) = MockProvider::default().with_tool_use(vec![
3363            ChatResponse::Text("I will now do the task...".into()),
3364            ChatResponse::Text("Done.".into()),
3365        ]);
3366
3367        let executor = FilteredToolExecutor::new(noop_executor(), ToolPolicy::InheritAll);
3368        let args = make_agent_loop_args(AnyProvider::Mock(mock), executor, 10);
3369        let result = run_agent_loop(args).await;
3370        assert!(result.is_ok(), "loop should succeed: {result:?}");
3371        assert_eq!(result.unwrap(), "Done.");
3372
3373        // Provider must have been called twice: initial turn + nudge retry.
3374        let count = *call_count.lock().unwrap();
3375        assert_eq!(
3376            count, 2,
3377            "provider must be called exactly twice (initial + nudge retry), got {count}"
3378        );
3379    }
3380
3381    // ── Phase 1: subagent context propagation tests (#2576, #2577, #2578) ────
3382
3383    #[test]
3384    fn model_spec_deserialize_inherit() {
3385        let spec: ModelSpec = serde_json::from_str("\"inherit\"").unwrap();
3386        assert_eq!(spec, ModelSpec::Inherit);
3387    }
3388
3389    #[test]
3390    fn model_spec_deserialize_named() {
3391        let spec: ModelSpec = serde_json::from_str("\"fast\"").unwrap();
3392        assert_eq!(spec, ModelSpec::Named("fast".to_owned()));
3393    }
3394
3395    #[test]
3396    fn model_spec_serialize_roundtrip() {
3397        assert_eq!(
3398            serde_json::to_string(&ModelSpec::Inherit).unwrap(),
3399            "\"inherit\""
3400        );
3401        assert_eq!(
3402            serde_json::to_string(&ModelSpec::Named("my-provider".to_owned())).unwrap(),
3403            "\"my-provider\""
3404        );
3405    }
3406
3407    #[test]
3408    fn spawn_context_default_is_empty() {
3409        let ctx = SpawnContext::default();
3410        assert!(ctx.parent_messages.is_empty());
3411        assert!(ctx.parent_cancel.is_none());
3412        assert!(ctx.parent_provider_name.is_none());
3413        assert_eq!(ctx.spawn_depth, 0);
3414        assert!(ctx.mcp_tool_names.is_empty());
3415    }
3416
3417    #[test]
3418    fn context_injection_none_passes_raw_prompt() {
3419        use zeph_config::ContextInjectionMode;
3420        let result = apply_context_injection("do work", &[], ContextInjectionMode::None);
3421        assert_eq!(result, "do work");
3422    }
3423
3424    #[test]
3425    fn context_injection_last_assistant_prepends_when_present() {
3426        use zeph_config::ContextInjectionMode;
3427        let msgs = vec![
3428            make_message(Role::User, "hello".into()),
3429            make_message(Role::Assistant, "I found X".into()),
3430        ];
3431        let result =
3432            apply_context_injection("do work", &msgs, ContextInjectionMode::LastAssistantTurn);
3433        assert!(
3434            result.contains("I found X"),
3435            "should contain last assistant content"
3436        );
3437        assert!(result.contains("do work"), "should contain original task");
3438    }
3439
3440    #[test]
3441    fn context_injection_last_assistant_fallback_when_no_assistant() {
3442        use zeph_config::ContextInjectionMode;
3443        let msgs = vec![make_message(Role::User, "hello".into())];
3444        let result =
3445            apply_context_injection("do work", &msgs, ContextInjectionMode::LastAssistantTurn);
3446        assert_eq!(result, "do work");
3447    }
3448
3449    #[tokio::test]
3450    async fn spawn_model_inherit_resolves_to_parent_provider() {
3451        let rt = tokio::runtime::Handle::current();
3452        let _guard = rt.enter();
3453        let mut mgr = make_manager();
3454        let mut def = sample_def();
3455        def.model = Some(ModelSpec::Inherit);
3456        mgr.definitions.push(def);
3457
3458        let ctx = SpawnContext {
3459            parent_provider_name: Some("my-parent-provider".to_owned()),
3460            ..SpawnContext::default()
3461        };
3462        // spawn should succeed without error (model resolution doesn't fail on missing provider)
3463        let result = mgr.spawn(
3464            "bot",
3465            "task",
3466            mock_provider(vec!["done"]),
3467            noop_executor(),
3468            None,
3469            &SubAgentConfig::default(),
3470            ctx,
3471        );
3472        assert!(
3473            result.is_ok(),
3474            "spawn with Inherit model should succeed: {result:?}"
3475        );
3476    }
3477
3478    #[tokio::test]
3479    async fn spawn_model_named_uses_value() {
3480        let rt = tokio::runtime::Handle::current();
3481        let _guard = rt.enter();
3482        let mut mgr = make_manager();
3483        let mut def = sample_def();
3484        def.model = Some(ModelSpec::Named("fast".to_owned()));
3485        mgr.definitions.push(def);
3486
3487        let result = mgr.spawn(
3488            "bot",
3489            "task",
3490            mock_provider(vec!["done"]),
3491            noop_executor(),
3492            None,
3493            &SubAgentConfig::default(),
3494            SpawnContext::default(),
3495        );
3496        assert!(result.is_ok());
3497    }
3498
3499    #[test]
3500    fn spawn_exceeds_max_depth_returns_error() {
3501        let rt = tokio::runtime::Runtime::new().unwrap();
3502        let _guard = rt.enter();
3503        let mut mgr = make_manager();
3504        mgr.definitions.push(sample_def());
3505
3506        let cfg = SubAgentConfig {
3507            max_spawn_depth: 2,
3508            ..SubAgentConfig::default()
3509        };
3510        let ctx = SpawnContext {
3511            spawn_depth: 2, // equals max_spawn_depth → should fail
3512            ..SpawnContext::default()
3513        };
3514        let err = mgr
3515            .spawn(
3516                "bot",
3517                "task",
3518                mock_provider(vec!["done"]),
3519                noop_executor(),
3520                None,
3521                &cfg,
3522                ctx,
3523            )
3524            .unwrap_err();
3525        assert!(
3526            matches!(err, SubAgentError::MaxDepthExceeded { depth: 2, max: 2 }),
3527            "expected MaxDepthExceeded, got {err:?}"
3528        );
3529    }
3530
3531    #[test]
3532    fn spawn_at_max_depth_minus_one_succeeds() {
3533        let rt = tokio::runtime::Runtime::new().unwrap();
3534        let _guard = rt.enter();
3535        let mut mgr = make_manager();
3536        mgr.definitions.push(sample_def());
3537
3538        let cfg = SubAgentConfig {
3539            max_spawn_depth: 3,
3540            ..SubAgentConfig::default()
3541        };
3542        let ctx = SpawnContext {
3543            spawn_depth: 2, // one below max → should succeed
3544            ..SpawnContext::default()
3545        };
3546        let result = mgr.spawn(
3547            "bot",
3548            "task",
3549            mock_provider(vec!["done"]),
3550            noop_executor(),
3551            None,
3552            &cfg,
3553            ctx,
3554        );
3555        assert!(
3556            result.is_ok(),
3557            "spawn at depth 2 with max 3 should succeed: {result:?}"
3558        );
3559    }
3560
3561    #[test]
3562    fn spawn_foreground_uses_child_token() {
3563        let rt = tokio::runtime::Runtime::new().unwrap();
3564        let _guard = rt.enter();
3565        let mut mgr = make_manager();
3566        mgr.definitions.push(sample_def());
3567
3568        let parent_cancel = CancellationToken::new();
3569        let ctx = SpawnContext {
3570            parent_cancel: Some(parent_cancel.clone()),
3571            ..SpawnContext::default()
3572        };
3573        // Foreground spawn (background: false by default in sample_def)
3574        let task_id = mgr
3575            .spawn(
3576                "bot",
3577                "task",
3578                mock_provider(vec!["done"]),
3579                noop_executor(),
3580                None,
3581                &SubAgentConfig::default(),
3582                ctx,
3583            )
3584            .unwrap();
3585
3586        // Cancel parent — child should also be cancelled
3587        parent_cancel.cancel();
3588        let handle = mgr.agents.get(&task_id).unwrap();
3589        assert!(
3590            handle.cancel.is_cancelled(),
3591            "child token should be cancelled when parent cancels"
3592        );
3593    }
3594
3595    #[test]
3596    fn parent_history_zero_turns_returns_empty() {
3597        use zeph_config::ContextInjectionMode;
3598        let msgs = vec![make_message(Role::User, "hi".into())];
3599        // apply_context_injection with zero turns — we test by passing empty vec
3600        // The actual extract_parent_messages is in zeph-core; here we test the injection side
3601        let result = apply_context_injection("task", &[], ContextInjectionMode::LastAssistantTurn);
3602        assert_eq!(result, "task", "no history should pass prompt unchanged");
3603        let _ = msgs; // suppress unused
3604    }
3605
3606    // ── Phase 2: MCP tool annotation tests (#2581) ────────────────────────────
3607
3608    #[tokio::test]
3609    async fn mcp_tool_names_appended_to_system_prompt() {
3610        use zeph_llm::mock::MockProvider;
3611
3612        let (mock, _) =
3613            MockProvider::default().with_tool_use(vec![ChatResponse::Text("done".into())]);
3614
3615        let executor = FilteredToolExecutor::new(noop_executor(), ToolPolicy::InheritAll);
3616        let mut args = make_agent_loop_args(AnyProvider::Mock(mock), executor, 5);
3617        args.mcp_tool_names = vec!["search".into(), "write_file".into()];
3618        // The system_prompt is inspected indirectly — if the loop completes the annotation was built.
3619        let result = run_agent_loop(args).await;
3620        assert!(result.is_ok(), "loop should succeed: {result:?}");
3621    }
3622
3623    #[tokio::test]
3624    async fn empty_mcp_tool_names_no_annotation() {
3625        use zeph_llm::mock::MockProvider;
3626
3627        let (mock, _) =
3628            MockProvider::default().with_tool_use(vec![ChatResponse::Text("done".into())]);
3629
3630        let executor = FilteredToolExecutor::new(noop_executor(), ToolPolicy::InheritAll);
3631        let mut args = make_agent_loop_args(AnyProvider::Mock(mock), executor, 5);
3632        args.mcp_tool_names = vec![];
3633        let result = run_agent_loop(args).await;
3634        assert!(
3635            result.is_ok(),
3636            "loop should succeed with no MCP tools: {result:?}"
3637        );
3638    }
3639}