Skip to main content

zeph_core/agent/
subagent_commands.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Sub-agent command handlers and spawn-context assembly.
5//!
6//! Extracted from `agent/mod.rs` (#4923). Handles `/agent` command dispatch (list,
7//! status, approve/deny, spawn, cancel, resume), background polling of running
8//! sub-agents, and construction of the bounded parent-message context handed to a
9//! freshly spawned sub-agent.
10
11use std::sync::Arc;
12
13use zeph_sanitizer::secret_shape::scrub_secret_shapes;
14use zeph_tools::registry::ToolDef;
15
16use super::{Agent, error};
17use crate::channel::Channel;
18
19/// Number of trailing forwarded-transcript lines surfaced per subagent in
20/// [`crate::metrics::SubAgentMetrics::live_transcript`] (issue #6359, FR-005).
21const LIVE_TRANSCRIPT_TAIL_LINES: usize = 20;
22
23impl<C: Channel> Agent<C> {
24    /// Resolve a sub-agent's requested vault-secret key against the custom secrets already
25    /// resolved from the vault at startup (`ZEPH_SECRET_<NAME>` keys — the same pre-resolved
26    /// map used for skill `requires_secrets` injection, see `tool_execution::inject_active_skill_env`).
27    ///
28    /// Matching is case-insensitive with `-` normalized to `_`, mirroring the vault-key
29    /// naming convention (`ZEPH_SECRET_MY-KEY` and `ZEPH_SECRET_MY_KEY` both resolve to
30    /// `my_key`). Returns `None` when `key` was never resolved from the vault at startup.
31    pub(crate) fn resolve_subagent_secret(&self, key: &str) -> Option<crate::vault::Secret> {
32        let normalized = key.to_lowercase().replace('-', "_");
33        self.services
34            .skill
35            .available_custom_secrets
36            .get(&normalized)
37            .map(|s| crate::vault::Secret::new(s.expose().to_owned()))
38    }
39
40    /// Poll all active sub-agents for completed/failed/canceled results.
41    ///
42    /// Non-blocking: returns immediately with a list of `(task_id, name, result, success)`
43    /// tuples for agents that have finished. Each completed agent is removed from the
44    /// manager. `name` and `success` are captured here (before `collect()` removes the
45    /// manager entry) so callers can notify view layers (e.g. the TUI transcript pane) about
46    /// the terminal state without needing to re-resolve the agent definition afterwards
47    /// (#6570).
48    #[tracing::instrument(name = "core.agent.poll_subagents", skip_all, level = "debug")]
49    pub async fn poll_subagents(&mut self) -> Vec<(String, String, String, bool)> {
50        let Some(mgr) = &mut self.services.orchestration.subagent_manager else {
51            return vec![];
52        };
53
54        let finished: Vec<(String, bool)> =
55            mgr.statuses()
56                .into_iter()
57                .filter_map(|(id, status)| match status.state {
58                    zeph_subagent::SubAgentState::Completed => Some((id, true)),
59                    zeph_subagent::SubAgentState::Failed
60                    | zeph_subagent::SubAgentState::Canceled => Some((id, false)),
61                    _ => None,
62                })
63                .collect();
64
65        let mut results = vec![];
66        for (task_id, success) in finished {
67            let name = mgr.agents_def(&task_id).map_or_else(
68                || task_id[..8.min(task_id.len())].to_owned(),
69                |d| d.name.clone(),
70            );
71            match mgr.collect(&task_id).await {
72                Ok(result) => results.push((task_id, name, result, success)),
73                Err(e) => {
74                    tracing::warn!(task_id, error = %e, "failed to collect sub-agent result");
75                }
76            }
77        }
78        results
79    }
80    /// Run the chat loop, receiving messages via the channel until EOF or shutdown.
81    ///
82    /// # Errors
83    ///
84    /// Returns an error if channel I/O or LLM communication fails.
85    /// Refresh sub-agent metrics snapshot for the TUI metrics panel.
86    pub(super) fn refresh_subagent_metrics(&mut self) {
87        let Some(ref mgr) = self.services.orchestration.subagent_manager else {
88            return;
89        };
90        let sub_agent_metrics: Vec<crate::metrics::SubAgentMetrics> = mgr
91            .statuses()
92            .into_iter()
93            .map(|(id, s)| {
94                let def = mgr.agents_def(&id);
95                crate::metrics::SubAgentMetrics {
96                    name: def.map_or_else(|| id[..8.min(id.len())].to_owned(), |d| d.name.clone()),
97                    id: id.clone(),
98                    state: format!("{:?}", s.state).to_lowercase(),
99                    turns_used: s.turns_used,
100                    max_turns: def.map_or(20, |d| d.permissions.max_turns),
101                    background: def.is_some_and(|d| d.permissions.background),
102                    elapsed_secs: s.started_at.elapsed().as_secs(),
103                    permission_mode: def.map_or_else(String::new, |d| {
104                        use zeph_subagent::def::PermissionMode;
105                        match d.permissions.permission_mode {
106                            PermissionMode::AcceptEdits => "accept_edits".into(),
107                            PermissionMode::DontAsk => "dont_ask".into(),
108                            PermissionMode::BypassPermissions => "bypass_permissions".into(),
109                            PermissionMode::Plan => "plan".into(),
110                            _ => String::new(),
111                        }
112                    }),
113                    transcript_dir: mgr
114                        .agent_transcript_dir(&id)
115                        .map(|p| p.to_string_lossy().into_owned()),
116                    live_transcript: mgr.forwarded_tail(&id, LIVE_TRANSCRIPT_TAIL_LINES),
117                }
118            })
119            .collect();
120        self.update_metrics(|m| m.sub_agents = sub_agent_metrics);
121    }
122    /// Non-blocking poll: notify the user when background sub-agents complete.
123    pub(super) async fn notify_completed_subagents(&mut self) -> Result<(), error::AgentError> {
124        let completed = self.poll_subagents().await;
125        for (task_id, name, result, success) in completed {
126            // #6571: `result` is the sub-agent's raw final text — nothing upstream (the agent
127            // loop's own return value, `SubAgentManager::collect`) sanitizes it before it
128            // reaches this operator-visible completion notice, so a generic secret-shaped
129            // string the sub-agent fabricates or echoes must be scrubbed here, the same as the
130            // live-forward path (`zeph-subagent::forward::sanitize_text`).
131            let result = scrub_secret_shapes(&result).into_owned();
132            let notice = if result.is_empty() {
133                format!("[sub-agent {id}] completed (no output)", id = &task_id[..8])
134            } else {
135                format!("[sub-agent {id}] completed:\n{result}", id = &task_id[..8])
136            };
137            if let Err(e) = self.channel.send(&notice).await {
138                tracing::warn!(error = %e, "failed to send sub-agent completion notice");
139            }
140            // Notify view layers (e.g. the TUI transcript pane) so a manually-opened
141            // background subagent view reaches a terminal state instead of stalling
142            // indefinitely once the manager entry backing it disappears (#6570).
143            if let Err(e) = self
144                .channel
145                .notify_background_subagent_completed(&task_id, &name, success)
146                .await
147            {
148                tracing::warn!(error = %e, "failed to notify background sub-agent completion");
149            }
150        }
151        Ok(())
152    }
153    /// Poll a sub-agent until it reaches a terminal state, bridging secret requests to the
154    /// channel. Returns a human-readable status string and success flag suitable for
155    /// sending to the user and emitting lifecycle events.
156    async fn poll_subagent_until_done(
157        &mut self,
158        task_id: &str,
159        label: &str,
160    ) -> Option<(String, bool)> {
161        use zeph_subagent::SubAgentState;
162        let result = loop {
163            tokio::time::sleep(std::time::Duration::from_millis(500)).await;
164
165            // Bridge secret requests from sub-agent to channel.confirm().
166            // Fetch the pending request first, then release the borrow before
167            // calling channel.confirm() (which requires &mut self).
168            #[allow(clippy::redundant_closure_for_method_calls)]
169            let pending = self
170                .services
171                .orchestration
172                .subagent_manager
173                .as_mut()
174                .and_then(|m| m.try_recv_secret_request());
175            if let Some((req_task_id, req)) = pending {
176                // req.secret_key is pre-validated to [a-zA-Z0-9_-] in manager.rs
177                // (SEC-P1-02), so it is safe to embed in the prompt string.
178                let confirm_prompt = format!(
179                    "Sub-agent requests secret '{}'. Allow?",
180                    crate::text::truncate_to_chars(&req.secret_key, 100)
181                );
182                let approved = self.channel.confirm(&confirm_prompt).await.unwrap_or(false);
183                if approved {
184                    let ttl = std::time::Duration::from_mins(5);
185                    let key = req.secret_key.clone();
186                    let resolved = self.resolve_subagent_secret(&key);
187                    if let Some(mgr) = self.services.orchestration.subagent_manager.as_mut() {
188                        if let Some(secret) = resolved {
189                            if mgr.approve_secret(&req_task_id, &key, ttl).is_ok()
190                                && let Err(e) = mgr.deliver_secret(&req_task_id, &key, secret)
191                            {
192                                tracing::warn!(error = %e, "sub-agent secret delivery failed");
193                                let _ = mgr.deny_secret(&req_task_id);
194                            }
195                        } else {
196                            tracing::warn!(
197                                "sub-agent requested secret not resolvable from vault; denying"
198                            );
199                            let _ = mgr.deny_secret(&req_task_id);
200                        }
201                    }
202                } else if let Some(mgr) = self.services.orchestration.subagent_manager.as_mut() {
203                    let _ = mgr.deny_secret(&req_task_id);
204                }
205            }
206
207            let mgr = self.services.orchestration.subagent_manager.as_ref()?;
208            let statuses = mgr.statuses();
209            let Some((_, status)) = statuses.iter().find(|(id, _)| id == task_id) else {
210                break (format!("{label} completed (no status available)."), true);
211            };
212            match status.state {
213                SubAgentState::Completed => {
214                    let msg = status.last_message.clone().unwrap_or_else(|| "done".into());
215                    break (format!("{label} completed: {msg}"), true);
216                }
217                SubAgentState::Failed => {
218                    let msg = status
219                        .last_message
220                        .clone()
221                        .unwrap_or_else(|| "unknown error".into());
222                    break (format!("{label} failed: {msg}"), false);
223                }
224                SubAgentState::Canceled => {
225                    break (format!("{label} was cancelled."), false);
226                }
227                _ => {
228                    self.channel
229                        .send_status_best_effort(&format!(
230                            "{label}: turn {}/{}",
231                            status.turns_used,
232                            self.services
233                                .orchestration
234                                .subagent_manager
235                                .as_ref()
236                                .and_then(|m| m.agents_def(task_id))
237                                .map_or(20, |d| d.permissions.max_turns)
238                        ))
239                        .await;
240                }
241            }
242        };
243        Some(result)
244    }
245    /// Resolve a unique full `task_id` from a prefix. Returns `None` if the manager is absent,
246    /// `Some(Err(msg))` on ambiguity/not-found, `Some(Ok(full_id))` on success.
247    fn resolve_agent_id_prefix(&mut self, prefix: &str) -> Option<Result<String, String>> {
248        let mgr = self.services.orchestration.subagent_manager.as_mut()?;
249        let full_ids: Vec<String> = mgr
250            .statuses()
251            .into_iter()
252            .map(|(tid, _)| tid)
253            .filter(|tid| tid.starts_with(prefix))
254            .collect();
255        Some(match full_ids.as_slice() {
256            [] => Err(format!("No sub-agent with id prefix '{prefix}'")),
257            [fid] => Ok(fid.clone()),
258            _ => Err(format!(
259                "Ambiguous id prefix '{prefix}': matches {} agents",
260                full_ids.len()
261            )),
262        })
263    }
264    fn handle_agent_list(&self) -> Option<String> {
265        use std::fmt::Write as _;
266        let mgr = self.services.orchestration.subagent_manager.as_ref()?;
267        let spawns_line = self.format_session_spawns_line();
268        let mode_label = match mgr.delegation_mode() {
269            zeph_config::DelegationMode::Disabled => "disabled",
270            zeph_config::DelegationMode::ExplicitRequestOnly => "explicit_request_only",
271            zeph_config::DelegationMode::Proactive => "proactive",
272            _ => "unknown",
273        };
274        let defs = mgr.definitions();
275        if defs.is_empty() {
276            return Some(format!(
277                "{spawns_line}\nDelegation mode: {mode_label}\nNo sub-agent definitions found."
278            ));
279        }
280        let mut out =
281            format!("{spawns_line}\nDelegation mode: {mode_label}\nAvailable sub-agents:\n");
282        for d in defs {
283            let memory_label = match d.memory {
284                Some(zeph_subagent::MemoryScope::User) => " [memory:user]",
285                Some(zeph_subagent::MemoryScope::Project) => " [memory:project]",
286                Some(zeph_subagent::MemoryScope::Local) => " [memory:local]",
287                Some(_) => " [memory:unknown]",
288                None => "",
289            };
290            if let Some(ref src) = d.source {
291                let _ = writeln!(
292                    out,
293                    "  {}{} — {} ({})",
294                    d.name, memory_label, d.description, src
295                );
296            } else {
297                let _ = writeln!(out, "  {}{} — {}", d.name, memory_label, d.description);
298            }
299        }
300        Some(out)
301    }
302    fn handle_agent_status(&self) -> Option<String> {
303        use std::fmt::Write as _;
304        let mgr = self.services.orchestration.subagent_manager.as_ref()?;
305        let spawns_line = self.format_session_spawns_line();
306        let statuses = mgr.statuses();
307        if statuses.is_empty() {
308            return Some(format!("{spawns_line}\nNo active sub-agents."));
309        }
310        let mut out = format!("{spawns_line}\nActive sub-agents:\n");
311        for (id, s) in &statuses {
312            let state = format!("{:?}", s.state).to_lowercase();
313            let elapsed = s.started_at.elapsed().as_secs();
314            let _ = writeln!(
315                out,
316                "  [{short}] {state}  turns={t}  elapsed={elapsed}s  {msg}",
317                short = &id[..8.min(id.len())],
318                t = s.turns_used,
319                msg = s.last_message.as_deref().unwrap_or(""),
320            );
321            // Show memory directory path for agents with memory enabled.
322            if let Some(def) = mgr.agents_def(id)
323                && let Some(scope) = def.memory
324                && let Ok(dir) = zeph_subagent::memory::resolve_memory_dir(scope, &def.name)
325            {
326                let _ = writeln!(out, "       memory: {}", dir.display());
327            }
328        }
329        Some(out)
330    }
331    fn handle_agent_approve(&mut self, id: &str) -> Option<String> {
332        let full_id = match self.resolve_agent_id_prefix(id)? {
333            Ok(fid) => fid,
334            Err(msg) => return Some(msg),
335        };
336        let req = {
337            let mgr = self.services.orchestration.subagent_manager.as_mut()?;
338            mgr.try_recv_secret_request_for(&full_id)
339        };
340        let Some(req) = req else {
341            return Some(format!(
342                "No pending secret request for sub-agent '{full_id}'."
343            ));
344        };
345        let key = req.secret_key.clone();
346        let ttl = std::time::Duration::from_mins(5);
347        let Some(secret) = self.resolve_subagent_secret(&key) else {
348            let mgr = self.services.orchestration.subagent_manager.as_mut()?;
349            let _ = mgr.deny_secret(&full_id);
350            return Some(format!(
351                "Secret '{key}' could not be resolved from the vault; request denied."
352            ));
353        };
354        let mgr = self.services.orchestration.subagent_manager.as_mut()?;
355        if let Err(e) = mgr.approve_secret(&full_id, &key, ttl) {
356            return Some(format!("Approve failed: {e}"));
357        }
358        if let Err(e) = mgr.deliver_secret(&full_id, &key, secret) {
359            let _ = mgr.deny_secret(&full_id);
360            return Some(format!("Secret delivery failed: {e}"));
361        }
362        Some(format!("Secret '{key}' approved for sub-agent {full_id}."))
363    }
364    fn handle_agent_deny(&mut self, id: &str) -> Option<String> {
365        let full_id = match self.resolve_agent_id_prefix(id)? {
366            Ok(fid) => fid,
367            Err(msg) => return Some(msg),
368        };
369        let mgr = self.services.orchestration.subagent_manager.as_mut()?;
370        match mgr.deny_secret(&full_id) {
371            Ok(()) => Some(format!("Secret request denied for sub-agent '{full_id}'.")),
372            Err(e) => Some(format!("Deny failed: {e}")),
373        }
374    }
375    pub(super) async fn handle_agent_command(
376        &mut self,
377        cmd: zeph_subagent::AgentCommand,
378    ) -> Option<String> {
379        use zeph_subagent::AgentCommand;
380
381        match cmd {
382            AgentCommand::List => self.handle_agent_list(),
383            AgentCommand::Background { name, prompt } => {
384                self.handle_agent_background(&name, &prompt).await
385            }
386            AgentCommand::Spawn { name, prompt }
387            | AgentCommand::Mention {
388                agent: name,
389                prompt,
390            } => self.handle_agent_spawn_foreground(&name, &prompt).await,
391            AgentCommand::Status => self.handle_agent_status(),
392            AgentCommand::Cancel { id } => self.handle_agent_cancel(&id),
393            AgentCommand::Approve { id } => self.handle_agent_approve(&id),
394            AgentCommand::Deny { id } => self.handle_agent_deny(&id),
395            AgentCommand::Resume { id, prompt } => self.handle_agent_resume(&id, &prompt).await,
396            _ => None,
397        }
398    }
399    /// Return the sub-agent definitions section formatted for the `/agents` fleet view.
400    ///
401    /// Produces a "Sub-agents:" header followed by one line per definition.
402    /// Returns an empty string when no sub-agent manager is configured.
403    pub(crate) fn handle_agents_definitions_list(&self) -> String {
404        use std::fmt::Write as _;
405
406        let Some(mgr) = self.services.orchestration.subagent_manager.as_ref() else {
407            return String::new();
408        };
409        let defs = mgr.definitions();
410        if defs.is_empty() {
411            return String::new();
412        }
413        let mut out = String::from("Sub-agents:\n");
414        for d in defs {
415            let memory_label = match d.memory {
416                Some(zeph_subagent::MemoryScope::User) => " [memory:user]",
417                Some(zeph_subagent::MemoryScope::Project) => " [memory:project]",
418                Some(zeph_subagent::MemoryScope::Local) => " [memory:local]",
419                Some(_) => " [memory:unknown]",
420                None => "",
421            };
422            if let Some(ref src) = d.source {
423                let _ = writeln!(
424                    out,
425                    "  {}{} — {} ({})",
426                    d.name, memory_label, d.description, src
427                );
428            } else {
429                let _ = writeln!(out, "  {}{} — {}", d.name, memory_label, d.description);
430            }
431        }
432        out
433    }
434    /// Execute an `/agents` CRUD subcommand and return a formatted string.
435    ///
436    /// Handles `show`, `create`, `edit`, `delete` (the `list` case is handled by
437    /// [`handle_agents_definitions_list`] and never reaches this method).
438    pub(crate) fn handle_agents_crud(&mut self, cmd: zeph_subagent::AgentsCommand) -> String {
439        use zeph_subagent::AgentsCommand;
440
441        let Some(mgr) = self.services.orchestration.subagent_manager.as_ref() else {
442            return "Sub-agent manager is not available.".to_owned();
443        };
444
445        match cmd {
446            AgentsCommand::List => self.handle_agents_definitions_list(),
447            AgentsCommand::Show { name } => {
448                match mgr.definitions().iter().find(|d| d.name == name) {
449                    Some(d) => format!(
450                        "Agent: {}\nDescription: {}\nSource: {}\n",
451                        d.name,
452                        d.description,
453                        d.source.as_deref().unwrap_or("unknown"),
454                    ),
455                    None => format!("No sub-agent definition named '{name}'."),
456                }
457            }
458            AgentsCommand::Create { name } => {
459                format!(
460                    "To create a sub-agent definition, create a file at `.zeph/agents/{name}.md`.\n\
461                     See the sub-agent documentation for the required frontmatter."
462                )
463            }
464            AgentsCommand::Edit { name } => {
465                format!("To edit '{name}', open its definition file in `.zeph/agents/{name}.md`.")
466            }
467            AgentsCommand::Delete { name } => {
468                format!("To delete '{name}', remove the file `.zeph/agents/{name}.md`.")
469            }
470            _ => "Unknown agents command.".to_owned(),
471        }
472    }
473    async fn handle_agent_background(&mut self, name: &str, prompt: &str) -> Option<String> {
474        let provider = self.provider.clone();
475        let tool_executor = Arc::clone(&self.tool_executor);
476        let skills = self.filtered_skills_for(name).await;
477        let cfg = self.services.orchestration.subagent_config.clone();
478        let mut spawn_ctx = self.build_spawn_context(&cfg);
479        // Background durable: seat wired so child can resolve; on a fresh run the promise
480        // (await side) is dropped — background results are collected via poll_subagents. On a
481        // resumed run whose child already finished, replay short-circuits below instead.
482        self.ensure_session_durable_ctx().await;
483        match resolve_durable_spawn_gate(
484            self.services.session.durable_subagent,
485            self.services.session.durable_ctx.as_deref(),
486        )
487        .await
488        {
489            DurableSpawnGate::Fresh(seat) => spawn_ctx.durable_resolver = Some(seat),
490            DurableSpawnGate::Replayed { result, .. } => {
491                let short = &result.task_id[..8.min(result.task_id.len())];
492                return Some(if result.output.is_empty() {
493                    format!(
494                        "[sub-agent {short}] completed (no output, replayed from durable journal)"
495                    )
496                } else {
497                    format!(
498                        "[sub-agent {short}] completed (replayed from durable journal):\n{}",
499                        result.output
500                    )
501                });
502            }
503            DurableSpawnGate::None => {}
504        }
505        let mgr = self.services.orchestration.subagent_manager.as_mut()?;
506        match mgr
507            .spawn(
508                name,
509                prompt,
510                provider,
511                tool_executor,
512                skills,
513                &cfg,
514                spawn_ctx,
515            )
516            .await
517        {
518            Ok(id) => Some(format!(
519                "Sub-agent '{name}' started in background (id: {short})",
520                short = &id[..8.min(id.len())]
521            )),
522            Err(e) => Some(format!("Failed to spawn sub-agent: {e}")),
523        }
524    }
525    /// Handle a [`DurableSpawnGate::Replayed`] result for a foreground spawn.
526    ///
527    /// Gates the channel side effects (user notice + TUI completion event) behind an
528    /// out-of-band `notified_at` claim on the sub-agent's durable promise, so a parent that
529    /// restarts *again* after already taking the replay branch once does not re-fire them
530    /// (#6027). The claim consumes no durable step id, so unlike a `ctx.step()`-based guard it
531    /// cannot perturb INV-2 step-id determinism or cause `ReplayDivergence`. Returns the
532    /// journaled output/error text either way.
533    async fn notify_replayed_foreground_subagent(
534        &mut self,
535        name: &str,
536        result: zeph_subagent::SubagentResult,
537        promise_id: zeph_durable::PromiseId,
538    ) -> String {
539        let success = result.state == zeph_subagent::SubAgentState::Completed;
540        let task_id = result.task_id.clone();
541
542        // Out-of-band, step-counter-independent claim: the FIRST caller to set `notified_at` fires
543        // the channel side effects; every later replay is suppressed. Unlike a ctx.step this consumes
544        // no StepId, so it cannot cause ReplayDivergence under any restart count (#6027). Degrade to
545        // firing directly when durable is off (no replay can happen) or the claim errors.
546        let should_notify = if let Some(ctx) = self.services.session.durable_ctx.clone() {
547            match ctx.claim_promise_notification(promise_id).await {
548                Ok(claimed) => claimed,
549                Err(e) => {
550                    tracing::warn!(
551                        error = %e,
552                        "durable: promise-notification claim failed; \
553                         firing the replayed sub-agent notice directly"
554                    );
555                    true
556                }
557            }
558        } else {
559            true
560        };
561
562        let text = if success {
563            result.output
564        } else {
565            result.error.unwrap_or_else(|| "unknown error".to_owned())
566        };
567
568        if should_notify {
569            let _ = self
570                .channel
571                .send(&format!(
572                    "Sub-agent '{name}' replayed from durable journal (already finished \
573                     before the parent restarted)."
574                ))
575                .await;
576            let _ = self
577                .channel
578                .notify_foreground_subagent_completed(&task_id, name, success)
579                .await;
580        }
581        text
582    }
583
584    async fn handle_agent_spawn_foreground(&mut self, name: &str, prompt: &str) -> Option<String> {
585        let provider = self.provider.clone();
586        let tool_executor = Arc::clone(&self.tool_executor);
587        let skills = self.filtered_skills_for(name).await;
588        let cfg = self.services.orchestration.subagent_config.clone();
589        let mut spawn_ctx = self.build_spawn_context(&cfg);
590        // Wire the durable resolver seat so the child can resolve its promise on exit. On a
591        // fresh run the promise (await side) is dropped here; foreground result is collected
592        // via poll_subagent_until_done which reads the join-handle output directly. On a
593        // resumed run whose child already finished, replay short-circuits below instead.
594        self.ensure_session_durable_ctx().await;
595        match resolve_durable_spawn_gate(
596            self.services.session.durable_subagent,
597            self.services.session.durable_ctx.as_deref(),
598        )
599        .await
600        {
601            DurableSpawnGate::Fresh(seat) => spawn_ctx.durable_resolver = Some(seat),
602            DurableSpawnGate::Replayed { result, promise_id } => {
603                return Some(
604                    self.notify_replayed_foreground_subagent(name, result, promise_id)
605                        .await,
606                );
607            }
608            DurableSpawnGate::None => {}
609        }
610        let mgr = self.services.orchestration.subagent_manager.as_mut()?;
611        let task_id = match mgr
612            .spawn(
613                name,
614                prompt,
615                provider,
616                tool_executor,
617                skills,
618                &cfg,
619                spawn_ctx,
620            )
621            .await
622        {
623            Ok(id) => id,
624            Err(e) => return Some(format!("Failed to spawn sub-agent: {e}")),
625        };
626        let short = task_id[..8.min(task_id.len())].to_owned();
627        let _ = self
628            .channel
629            .send(&format!("Sub-agent '{name}' running... (id: {short})"))
630            .await;
631        let _ = self
632            .channel
633            .notify_foreground_subagent_started(&task_id, name)
634            .await;
635        let label = format!("Sub-agent '{name}'");
636        let Some((result, success)) = self.poll_subagent_until_done(&task_id, &label).await else {
637            // Manager was dropped mid-poll; emit completed(false) so TUI does not stay stuck.
638            let _ = self
639                .channel
640                .notify_foreground_subagent_completed(&task_id, name, false)
641                .await;
642            return None;
643        };
644        let _ = self
645            .channel
646            .notify_foreground_subagent_completed(&task_id, name, success)
647            .await;
648        Some(result)
649    }
650    fn handle_agent_cancel(&mut self, id: &str) -> Option<String> {
651        let mgr = self.services.orchestration.subagent_manager.as_mut()?;
652        // Accept prefix match on task_id.
653        let ids: Vec<String> = mgr
654            .statuses()
655            .into_iter()
656            .map(|(task_id, _)| task_id)
657            .filter(|task_id| task_id.starts_with(id))
658            .collect();
659        match ids.as_slice() {
660            [] => Some(format!("No sub-agent with id prefix '{id}'")),
661            [full_id] => {
662                let full_id = full_id.clone();
663                match mgr.cancel(&full_id) {
664                    Ok(()) => Some(format!("Cancelled sub-agent {full_id}.")),
665                    Err(e) => Some(format!("Cancel failed: {e}")),
666                }
667            }
668            _ => Some(format!(
669                "Ambiguous id prefix '{id}': matches {} agents",
670                ids.len()
671            )),
672        }
673    }
674    async fn handle_agent_resume(&mut self, id: &str, prompt: &str) -> Option<String> {
675        let cfg = self.services.orchestration.subagent_config.clone();
676        // Resolve definition name from transcript meta before spawning so we can
677        // look up skills by definition name rather than the UUID prefix (S1 fix).
678        let def_name = {
679            let mgr = self.services.orchestration.subagent_manager.as_ref()?;
680            match mgr.def_name_for_resume(id, &cfg).await {
681                Ok(name) => name,
682                Err(e) => return Some(format!("Failed to resume sub-agent: {e}")),
683            }
684        };
685        let skills = self.filtered_skills_for(&def_name).await;
686        let provider = self.provider.clone();
687        let tool_executor = Arc::clone(&self.tool_executor);
688        // Built before borrowing `subagent_manager` mutably below (build_spawn_context takes
689        // `&self`). Previously this call site passed `None`, which meant resumed sub-agents
690        // never got a `debug_dump_sink` — their LLM calls went uncaptured by `--debug-dump`
691        // even though fresh spawns correctly wired it (#6391). `resume()` only reads
692        // `max_trust_level`/`inherited_tool_allowlist`/`network_denied`/`debug_dump_sink` off
693        // `spawn_context` (see `manager/spawn.rs::resume`) — the first three are already at
694        // `build_spawn_context`'s top-level defaults (`None`/`None`/`false`, identical to what
695        // `None` produced here), so this only changes `debug_dump_sink` for this call site.
696        let spawn_ctx = self.build_spawn_context(&cfg);
697        let mgr = self.services.orchestration.subagent_manager.as_mut()?;
698        let (task_id, _) = match mgr
699            .resume(
700                id,
701                prompt,
702                provider,
703                tool_executor,
704                skills,
705                &cfg,
706                Some(&spawn_ctx),
707            )
708            .await
709        {
710            Ok(pair) => pair,
711            Err(e) => return Some(format!("Failed to resume sub-agent: {e}")),
712        };
713        let short = task_id[..8.min(task_id.len())].to_owned();
714        let _ = self
715            .channel
716            .send(&format!("Resuming sub-agent '{id}'... (new id: {short})"))
717            .await;
718        let _ = self
719            .channel
720            .notify_foreground_subagent_started(&task_id, &def_name)
721            .await;
722        let Some((result, success)) = self
723            .poll_subagent_until_done(&task_id, "Resumed sub-agent")
724            .await
725        else {
726            // Manager was dropped mid-poll; emit completed(false) so TUI does not stay stuck.
727            let _ = self
728                .channel
729                .notify_foreground_subagent_completed(&task_id, &def_name, false)
730                .await;
731            return None;
732        };
733        let _ = self
734            .channel
735            .notify_foreground_subagent_completed(&task_id, &def_name, success)
736            .await;
737        Some(result)
738    }
739    /// Resolve the skill bodies to inject into a freshly spawned (or resumed) sub-agent's
740    /// one-shot system prompt.
741    ///
742    /// A sub-agent definition with an empty `skills.include` filter inherits every skill in
743    /// the registry (documented, intentional — see [`zeph_config::SkillFilter`]). Unlike the
744    /// main agent's per-turn skill matcher, these bodies are injected once, at spawn time, with
745    /// no relevance ranking and no later opportunity to trim — an unbounded include set can
746    /// silently blow the turn-1 context budget (#6421).
747    ///
748    /// The `subagent_skill_token_budget` cap applies **only** to that empty-include case. A
749    /// definition with an explicit, hand-curated `skills.include` list is never capped here —
750    /// the operator opted into that specific set on purpose, and applying the same budget would
751    /// silently regress configs that were never broken; #6421 is about the *default* (empty)
752    /// case only.
753    ///
754    /// When capped, bodies are accumulated in the order [`zeph_subagent::filter_skills`] returns
755    /// them — the registry's directory-walk order, i.e. alphabetical by skill directory name,
756    /// **not** relevance-ranked. A task-critical skill whose directory happens to sort late is
757    /// systematically the first cut on every default-include spawn; operators who hit this can
758    /// curate `include` explicitly or raise the budget. Accumulation is a greedy best-fit, not a
759    /// hard prefix cut: an over-budget skill is skipped (not a stopping point), so a smaller
760    /// skill later in the order can still be packed in afterward. The first skill is always
761    /// included even if it alone exceeds the budget, so a single oversized skill never starves
762    /// the whole set. Any skills left out are surfaced via a synthetic marker entry rather than
763    /// silently dropped.
764    pub(super) async fn filtered_skills_for(&mut self, agent_name: &str) -> Option<Vec<String>> {
765        let def_skills = {
766            let mgr = self.services.orchestration.subagent_manager.as_ref()?;
767            mgr.definitions()
768                .iter()
769                .find(|d| d.name == agent_name)?
770                .skills
771                .clone()
772        };
773
774        // #6713 (S1): this must resolve the trust map fresh rather than reading the cached
775        // `trust_snapshot` directly — on the slash-command/@mention spawn path, nothing has
776        // run `resolve_trust_map` (or any per-turn trust load) before a sub-agent is spawned,
777        // so the cached snapshot can be stale or (on a fresh session) still empty. Same
778        // Fresh/LoadFailed fallback policy as `reload_skills` (skill_reload.rs): a load
779        // failure reuses the last-known snapshot instead of failing open to Trusted.
780        let trust_map = match self.build_skill_trust_map().await {
781            crate::agent::trust_commands::SkillTrustMapLoad::Fresh(map) => {
782                self.services.skill.trust_snapshot.write().clone_from(&map);
783                map
784            }
785            crate::agent::trust_commands::SkillTrustMapLoad::LoadFailed => {
786                tracing::warn!(
787                    "filtered_skills_for: trust snapshot load failed, reusing previous \
788                     snapshot for sub-agent skill filtering"
789                );
790                self.services.skill.trust_snapshot.read().clone()
791            }
792        };
793        let trust_levels = crate::skill_invoker::snapshot_map_to_trust_levels(&trust_map);
794
795        let reg = self.services.skill.registry.read();
796        let skills = match zeph_subagent::filter_skills(&reg, &def_skills, &trust_levels) {
797            Ok(skills) => skills,
798            Err(e) => {
799                tracing::warn!(error = %e, "skill filtering failed for sub-agent");
800                return None;
801            }
802        };
803        if skills.is_empty() {
804            return None;
805        }
806
807        // #6421 scope: only the empty-include "inherit everything" case is capped. An explicit,
808        // hand-curated include list is trusted as-is (see doc comment above).
809        if !def_skills.include.is_empty() {
810            return Some(skills.into_iter().map(|s| s.body).collect());
811        }
812
813        let total = skills.len();
814        let budget = self.services.skill.subagent_skill_token_budget;
815        let counter = &self.runtime.metrics.token_counter;
816
817        let mut bodies: Vec<String> = Vec::with_capacity(total);
818        let mut running_tokens = 0usize;
819        let mut omitted_names: Vec<&str> = Vec::new();
820
821        for skill in &skills {
822            let skill_tokens = counter.count_tokens(&skill.body);
823            if !bodies.is_empty() && running_tokens + skill_tokens > budget {
824                omitted_names.push(skill.meta.name.as_str());
825                continue;
826            }
827            running_tokens += skill_tokens;
828            bodies.push(skill.body.clone());
829        }
830
831        if !omitted_names.is_empty() {
832            let included = bodies.len();
833            tracing::warn!(
834                agent_name,
835                included,
836                total,
837                budget_tokens = budget,
838                "sub-agent skill body budget exceeded; truncated skill set"
839            );
840            bodies.push(format!(
841                "[skill budget: {included}/{total} skills included, budget={budget} tokens — omitted: {}]",
842                omitted_names.join(", ")
843            ));
844        }
845
846        Some(bodies)
847    }
848    /// The effective delegation mode currently in force (spec 042, issue #5857).
849    ///
850    /// Reads directly from `subagent_config` (always present, independent of whether a
851    /// `SubAgentManager` happens to be constructed) via
852    /// [`zeph_config::SubAgentConfig::effective_delegation_mode`], which folds in the
853    /// `enabled` outer kill switch. This is the same fold `src/runner.rs` bootstrap applies
854    /// before calling `SubAgentManager::set_delegation_mode` — reading it here independently
855    /// keeps this choke point correct even where no manager is wired up (e.g. a test harness).
856    pub(super) fn effective_delegation_mode(&self) -> zeph_config::DelegationMode {
857        self.services
858            .orchestration
859            .subagent_config
860            .effective_delegation_mode()
861    }
862
863    /// The session-wide cumulative subagent-spawn budget in force for this session (issue
864    /// #6545).
865    ///
866    /// Returns the `SubAgentManager`'s own budget when a manager is wired (the common case:
867    /// CLI/TUI runner), so a manager-side spawn and the ACP `/subagent spawn` chokepoint in
868    /// `slash_commands.rs` observe and contribute to the exact same cumulative count. Falls
869    /// back to `OrchestrationState::session_spawn_budget` when no manager is wired
870    /// (serve/daemon/acp bootstrap paths, or a bare test harness) — fail-closed rather than
871    /// unenforced, mirroring [`effective_delegation_mode`][Self::effective_delegation_mode]'s
872    /// fallback-to-config precedent above. An accessor rather than a copied handle, so a
873    /// future direct `subagent_manager = Some(...)` assignment elsewhere can never
874    /// desynchronize two independent budgets.
875    pub(super) fn session_budget(&self) -> &zeph_subagent::SessionSpawnBudget {
876        self.services
877            .orchestration
878            .subagent_manager
879            .as_ref()
880            .map_or(
881                &self.services.orchestration.session_spawn_budget,
882                zeph_subagent::SubAgentManager::session_budget,
883            )
884    }
885
886    /// Format the `Session spawns: N/max` (or `N/unlimited`) line shared by
887    /// `handle_agent_status` and `handle_agent_list`.
888    ///
889    /// Must be called before either function's early "no active agents"/"no definitions"
890    /// return, not just the non-empty branch — that early return is precisely the state right
891    /// after the cap fires under the shipped `max_concurrent = 1` default, which is exactly
892    /// when an operator needs to see the count (issue #6545). Reads through
893    /// [`session_budget`][Self::session_budget] rather than a manager parameter's own
894    /// `session_budget()`, so this stays the only path that resolves which budget instance
895    /// applies — both callers happen to have a manager in hand already, but routing through
896    /// the accessor avoids a second, parallel resolution path to the same value.
897    fn format_session_spawns_line(&self) -> String {
898        let max = self
899            .services
900            .orchestration
901            .subagent_config
902            .max_spawns_per_session;
903        let spawned = self.session_budget().spawned();
904        if max == 0 {
905            format!("Session spawns: {spawned}/unlimited")
906        } else {
907            format!("Session spawns: {spawned}/{max}")
908        }
909    }
910
911    /// Build a `SpawnContext` from current agent state for sub-agent spawning.
912    pub(super) fn build_spawn_context(
913        &self,
914        cfg: &zeph_config::SubAgentConfig,
915    ) -> zeph_subagent::SpawnContext {
916        zeph_subagent::SpawnContext {
917            parent_messages: self.extract_parent_messages(cfg),
918            parent_cancel: Some(self.runtime.lifecycle.cancel_token.clone()),
919            parent_provider_name: {
920                let name = &self.runtime.config.active_provider_name;
921                if name.is_empty() {
922                    None
923                } else {
924                    Some(name.clone())
925                }
926            },
927            spawn_depth: self.runtime.config.spawn_depth,
928            mcp_tool_names: self.extract_mcp_tool_names(),
929            // F3 spec 050 §4: propagate seeded score when parent is >= Elevated.
930            seed_trajectory_score: {
931                let child = self.services.security.trajectory.spawn_child();
932                let score = child.score_now();
933                if score > 0.0 { Some(score) } else { None }
934            },
935            content_isolation: self.runtime.config.security.content_isolation.clone(),
936            orchestrator_name: Some("zeph".to_owned()),
937            orchestrator_role: Some("orchestrator".to_owned()),
938            session_mcp_servers: Vec::new(),
939            // Threaded down so sub-agent LLM calls are captured through the same
940            // `--debug-dump` pipeline as the top-level agent loop (#6391). `None` when
941            // debug dumps are disabled, mirroring the top-level `debug_dumper: None` case.
942            // Wrapped in `PiiScrubbingDumpSink` so sub-agent dumps get the same optional
943            // `PiiFilter` layer top-level dumps get via `write_chat_debug_dump` — the plain
944            // `DebugDumpSink` impl on `DebugDumper` only applies the baseline
945            // `scrub_content`/`redact_binary_blobs` pass (#6407).
946            debug_dump_sink: self.runtime.debug.debug_dumper.clone().map(|d| {
947                Arc::new(crate::debug_dump::PiiScrubbingDumpSink::new(
948                    d,
949                    self.services.security.pii_filter.clone(),
950                )) as Arc<dyn zeph_llm::debug_dump::DebugDumpSink>
951            }),
952            // Constraint propagation (#3993/#6493): cap the spawned sub-agent's trust to the
953            // parent session's own current effective trust level, so a sub-agent can never
954            // receive higher privileges than the parent itself currently holds — this is the
955            // only production call site that constructs a `SpawnContext`, so every spawn path
956            // (foreground, background, and orchestration-driven via
957            // `handle_scheduler_spawn_action`) is covered.
958            max_trust_level: Some(self.parent_effective_trust_level()),
959            // #6701 (RC-5): shared handle to the same `TurnTrustFloor` cell this agent's own
960            // `TrustGateExecutor` reads, so the cap above is applied via `fold` (monotonic
961            // downgrade) rather than `set_effective_trust` (full overwrite) at spawn/resume —
962            // see `SpawnContext::turn_trust_floor`'s doc comment.
963            turn_trust_floor: self.services.skill.turn_trust_floor.clone(),
964            // This helper's own three callers (`handle_agent_background`,
965            // `handle_agent_spawn_foreground`, `handle_agent_resume`) are all dispatched from
966            // the explicit `/agent spawn`/`/agent resume` slash command, so `Explicit` is the
967            // correct base value here (spec 042, issue #5857). `handle_scheduler_spawn_action`
968            // is the sole caller that needs `Autonomous` — it overrides `spawn_ctx.origin`
969            // immediately after calling this helper, mirroring how it already overrides
970            // `network_denied`/`progress_at` post-construction.
971            origin: zeph_subagent::SpawnOrigin::Explicit,
972            // #6527: derive a defense-in-depth / tool-visibility narrowing signal from the
973            // parent session's own `[tool.permissions]` deny rules. This is NOT the runtime
974            // security boundary — every spawned sub-agent's tool executor is a
975            // `FilteredToolExecutor` wrapping `Arc::clone(&self.tool_executor)`, which is
976            // itself the parent's `TrustGateExecutor`-gated tree (see `agent_setup.rs`
977            // `TrustGateExecutor::new(inner, permission_policy.clone())` and `runner.rs`'s
978            // `self.tool_executor` wiring). So every child tool call is already re-checked
979            // against this same `permission_policy` at call time, regardless of what this
980            // field narrows. This field only controls what the child's LLM *sees* in its
981            // tool catalog, saving wasted turns on tools that would be denied anyway.
982            //
983            // INVARIANT (do not break silently): the `None` returns inside
984            // `effective_tool_allowlist` (for `ReadOnly` autonomy and for "nothing is
985            // wholesale-denied") are safe ONLY because the child inherits the parent's
986            // gated executor as described above. If a future refactor gives subagents a
987            // fresh/ungated executor (e.g. remote/sandboxed subagents), these `None` returns
988            // become real escalation holes — a `ReadOnly` parent would spawn a write-capable
989            // child, and an unrestricted-by-rules parent would give the child no runtime
990            // gating at all. See `effective_tool_allowlist`'s own doc comment for the full
991            // narrowing algorithm and its edge cases.
992            inherited_tool_allowlist: self
993                .runtime
994                .config
995                .permission_policy
996                .effective_tool_allowlist(
997                    self.tool_executor
998                        .tool_definitions_erased()
999                        .into_iter()
1000                        .map(|d| zeph_subagent::normalize_tool_id(d.id.as_ref())),
1001                ),
1002            ..Default::default()
1003        }
1004    }
1005    /// Compute the parent session's own current effective trust level (issue #6493).
1006    ///
1007    /// When a `turn_trust_floor` is wired (#6701), reads it directly — it is the exact same
1008    /// cell the parent's own `TrustGateExecutor` enforces against, so this is correct by
1009    /// construction and also observes any mid-turn fold (e.g. an `invoke_skill` of a
1010    /// Quarantined skill via `SkillTrustGate::resolve_body`, which `active_skill_names` alone
1011    /// would miss — S3). Falls back to [`crate::agent::context::compute_effective_trust`] (D2,
1012    /// with the D4 `skill_fallback_mode` guard — S1) only when no floor was wired, e.g. some
1013    /// test fixtures that construct an `Agent` without `with_turn_trust_floor`.
1014    fn parent_effective_trust_level(&self) -> zeph_common::SkillTrustLevel {
1015        if let Some(floor) = &self.services.skill.turn_trust_floor {
1016            return floor.get();
1017        }
1018        let snapshot = self.services.skill.trust_snapshot.read();
1019        crate::agent::context::compute_effective_trust(
1020            self.services.skill.skill_fallback_mode,
1021            &self.services.skill.active_skill_names,
1022            &snapshot,
1023        )
1024    }
1025    /// Extract recent parent messages for history propagation (Section 5.7 in spec).
1026    ///
1027    /// Filters system messages, applies `context_window_turns` and `max_parent_messages` caps,
1028    /// applies a 25% context window cap using a 4-chars-per-token heuristic, prunes orphaned
1029    /// `ToolUse`/`ToolResult` pairs at the slice boundary, and optionally sanitizes text parts
1030    /// through the IPI pipeline according to `parent_context_policy`.
1031    fn extract_parent_messages(
1032        &self,
1033        config: &zeph_config::SubAgentConfig,
1034    ) -> Vec<zeph_llm::provider::Message> {
1035        use zeph_config::ParentContextPolicy;
1036        use zeph_llm::provider::Role;
1037
1038        if config.parent_context_policy == ParentContextPolicy::None
1039            || config.context_window_turns == 0
1040        {
1041            return Vec::new();
1042        }
1043
1044        let non_system: Vec<_> = self
1045            .msg
1046            .messages
1047            .iter()
1048            .filter(|m| m.role != Role::System)
1049            .cloned()
1050            .collect();
1051
1052        let take_count = config
1053            .context_window_turns
1054            .saturating_mul(2)
1055            .min(config.max_parent_messages);
1056        let start = non_system.len().saturating_sub(take_count);
1057        let mut msgs = non_system[start..].to_vec();
1058
1059        // Cap at 25% of model context window and prune orphaned tool pairs.
1060        let max_chars = 128_000usize / 4;
1061        let requested = msgs.len();
1062        trim_parent_messages(&mut msgs, max_chars);
1063        if msgs.len() < requested {
1064            tracing::info!(
1065                kept = msgs.len(),
1066                requested,
1067                "[subagent] truncated parent history due to token budget or orphan pruning"
1068            );
1069        }
1070
1071        if config.parent_context_policy == ParentContextPolicy::InheritSanitized {
1072            use zeph_sanitizer::{ContentSource, ContentSourceKind};
1073            let source =
1074                ContentSource::new(ContentSourceKind::A2aMessage).with_identifier("parent_history");
1075            msgs = sanitize_parent_messages(msgs, &self.services.security.sanitizer, &source);
1076        }
1077
1078        msgs
1079    }
1080    /// Extract MCP tool names from the tool executor for diagnostic annotation.
1081    fn extract_mcp_tool_names(&self) -> Vec<String> {
1082        self.tool_executor
1083            .tool_definitions_erased()
1084            .into_iter()
1085            .filter(ToolDef::is_mcp_tool)
1086            .map(|t| t.id.to_string())
1087            .collect()
1088    }
1089    /// Classify a skill directory's source kind using on-disk markers and the bundled allowlist.
1090    ///
1091    /// Must be called from a blocking context (uses synchronous FS I/O).
1092    pub(super) fn classify_source_kind(
1093        skill_dir: &std::path::Path,
1094        managed_dir: Option<&std::path::PathBuf>,
1095        bundled_names: &std::collections::HashSet<String>,
1096    ) -> zeph_memory::store::SourceKind {
1097        if managed_dir.is_some_and(|d| skill_dir.starts_with(d)) {
1098            let skill_name = skill_dir.file_name().and_then(|n| n.to_str()).unwrap_or("");
1099            let has_marker = skill_dir.join(".bundled").exists();
1100            if has_marker && bundled_names.contains(skill_name) {
1101                zeph_memory::store::SourceKind::Bundled
1102            } else {
1103                if has_marker {
1104                    tracing::warn!(
1105                        skill = %skill_name,
1106                        "skill has .bundled marker but is not in the bundled skill \
1107                         allowlist — classifying as Hub"
1108                    );
1109                }
1110                zeph_memory::store::SourceKind::Hub
1111            }
1112        } else {
1113            zeph_memory::store::SourceKind::Local
1114        }
1115    }
1116}
1117
1118/// Outcome of checking the durable-execution gate before a sub-agent spawn (spec-064 §P4).
1119enum DurableSpawnGate {
1120    /// Fresh run: wire this seat into `SpawnContext::durable_resolver` so the child resolves
1121    /// the promise on exit (INV-9 channel rule).
1122    Fresh(zeph_subagent::DurableResolverSeat),
1123    /// Resumed run whose child already resolved its promise before the parent crashed. The
1124    /// caller must skip `spawn` entirely and replay this result instead — spawning here would
1125    /// duplicate the LLM calls and any side-effecting tool calls the finished child already
1126    /// performed (#5944). `promise_id` lets the foreground caller claim a one-time replay
1127    /// notification (#6027) via [`zeph_durable::DurableContext::claim_promise_notification`].
1128    Replayed {
1129        result: zeph_subagent::SubagentResult,
1130        promise_id: zeph_durable::PromiseId,
1131    },
1132    /// Gate closed: durable subagent support disabled, a resumed run whose child promise is
1133    /// still pending (out of v1 scope — see `durable.rs` module docs "Scope boundary"), or an
1134    /// error (logged at `warn`). The caller degrades to a plain spawn with no durable wiring.
1135    ///
1136    /// The still-pending case is safe only because the current architecture is
1137    /// LocalBackend-only, in-process tokio tasks (spec-064 INV-9): a parent-process crash
1138    /// necessarily kills its in-process children too, so a still-pending promise on resume
1139    /// means the original child is genuinely gone, and re-spawning cannot duplicate a live
1140    /// child. See `durable.rs` "Scope boundary".
1141    None,
1142}
1143
1144/// Check the durable-execution gate for the next sub-agent spawn.
1145///
1146/// See [`DurableSpawnGate`] for the three possible outcomes.
1147async fn resolve_durable_spawn_gate(
1148    enabled: bool,
1149    ctx: Option<&zeph_durable::DurableContext>,
1150) -> DurableSpawnGate {
1151    let Some(ctx) = ctx.filter(|_| enabled) else {
1152        return DurableSpawnGate::None;
1153    };
1154    let (promise, seat) = match zeph_subagent::make_durable_promise(ctx).await {
1155        Ok(pair) => pair,
1156        Err(e) => {
1157            tracing::warn!(error = %e, "durable: make_durable_promise failed — degrading to non-durable spawn");
1158            return DurableSpawnGate::None;
1159        }
1160    };
1161    if let Some(seat) = seat {
1162        return DurableSpawnGate::Fresh(seat);
1163    }
1164    // Resumed: token unrecoverable (INV-9). Check without blocking whether the child already
1165    // resolved the promise before the crash — replay it instead of re-spawning a duplicate.
1166    match zeph_subagent::try_replay_durable_subagent(ctx, &promise).await {
1167        Ok(Some(result)) => DurableSpawnGate::Replayed {
1168            result,
1169            promise_id: promise.id(),
1170        },
1171        Ok(None) => {
1172            // Safe to fall back to a plain spawn here only because the current architecture
1173            // is LocalBackend-only, in-process tokio tasks: the parent process crashing kills
1174            // its in-process children too, so a still-pending promise on resume means the
1175            // original child is genuinely gone, not merely unreachable. Re-attaching to a
1176            // live child would require cross-process liveness detection, which is out of v1
1177            // scope — see `durable.rs` module docs "Scope boundary" and spec-064 INV-9 (the
1178            // resolver token is unrecoverable by design, so it cannot be re-minted to attempt
1179            // reattachment).
1180            tracing::warn!(
1181                "durable: resumed sub-agent promise still pending after restart — original \
1182                 child did not resolve before the crash; re-spawning may duplicate side effects \
1183                 (#5944 residual v1 gap)"
1184            );
1185            DurableSpawnGate::None
1186        }
1187        Err(e) => {
1188            tracing::warn!(error = %e, "durable: replay check failed on resumed sub-agent promise — degrading to non-durable spawn");
1189            DurableSpawnGate::None
1190        }
1191    }
1192}
1193
1194/// Estimates the JSON payload size of a single [`zeph_llm::provider::Message`] for token-budget
1195/// accounting.
1196///
1197/// When `parts` is empty the message is a legacy text-only message and `content.len()` is used
1198/// directly. Otherwise each part is measured individually so that structured variants (images,
1199/// tool invocations, thinking blocks) are accounted for rather than relying on the already-flat
1200/// `content` string, which may not reflect the actual API payload size.
1201pub(crate) fn estimate_parts_size(m: &zeph_llm::provider::Message) -> usize {
1202    use zeph_llm::provider::MessagePart;
1203    if m.parts.is_empty() {
1204        return m.content.len();
1205    }
1206    m.parts
1207        .iter()
1208        .map(|p| match p {
1209            MessagePart::Text { text }
1210            | MessagePart::Recall { text }
1211            | MessagePart::CodeContext { text }
1212            | MessagePart::Summary { text }
1213            | MessagePart::CrossSession { text } => text.len(),
1214            MessagePart::ToolOutput { body, .. } => body.len(),
1215            MessagePart::ToolUse { id, name, input } => {
1216                50 + id.len() + name.len() + input.to_string().len()
1217            }
1218            MessagePart::ToolResult {
1219                tool_use_id,
1220                content,
1221                ..
1222            } => 50 + tool_use_id.len() + content.len(),
1223            MessagePart::Image(img) => img.data.len() * 4 / 3,
1224            MessagePart::ThinkingBlock {
1225                thinking,
1226                signature,
1227            } => 50 + thinking.len() + signature.len(),
1228            MessagePart::RedactedThinkingBlock { data } => data.len(),
1229            MessagePart::Compaction { summary } => summary.len(),
1230            _ => 0,
1231        })
1232        .sum()
1233}
1234
1235/// Applies token-budget truncation and orphaned-tool-pair pruning to a parent message slice.
1236///
1237/// Budget truncation keeps the **most recent** messages that fit within `max_chars`
1238/// (a suffix), so the subagent always receives the freshest context.
1239///
1240/// Two passes are performed after budget truncation:
1241///
1242/// 1. Remove `ToolResult` parts from user messages whose matching `ToolUse` is no longer in the
1243///    slice (truncated away).
1244/// 2. Remove `ToolUse` parts from **interior** assistant messages whose matching `ToolResult`
1245///    was removed in pass 1 or was already absent. The trailing assistant message is exempt —
1246///    its unanswered `ToolUse` calls are not orphaned; the slice just ends before the result.
1247///
1248/// Messages that become fully empty after pruning are removed from `msgs`.
1249///
1250/// `rebuild_content` is called **only** when `retain` actually removed parts — preserving the
1251/// existing `content` field (and any `ThinkingBlock` text embedded there) for unmodified
1252/// messages.
1253pub(crate) fn trim_parent_messages(msgs: &mut Vec<zeph_llm::provider::Message>, max_chars: usize) {
1254    use zeph_llm::provider::{MessagePart, Role};
1255
1256    // Token-budget cap: keep the most recent messages that fit within max_chars.
1257    // We iterate from the end (newest) and drain from the front once the budget is exceeded,
1258    // so the subagent always receives the most recent context rather than stale early messages.
1259    let mut total_chars = 0usize;
1260    let mut drop_before = 0usize; // index of the first message to keep
1261    for (i, m) in msgs.iter().enumerate().rev() {
1262        total_chars += estimate_parts_size(m);
1263        if total_chars > max_chars {
1264            drop_before = i + 1;
1265            break;
1266        }
1267    }
1268    if drop_before > 0 {
1269        msgs.drain(..drop_before);
1270    }
1271
1272    // Pass 1: collect ToolUse IDs emitted by assistant messages; prune orphaned ToolResult
1273    // parts from user messages that reference a ToolUse no longer present in the slice.
1274    // Use owned Strings to avoid holding immutable borrows across the subsequent mutable loop.
1275    let emitted_tool_ids: std::collections::HashSet<String> = msgs
1276        .iter()
1277        .filter(|m| m.role == Role::Assistant)
1278        .flat_map(|m| m.parts.iter())
1279        .filter_map(|p| {
1280            if let MessagePart::ToolUse { id, .. } = p {
1281                Some(id.clone())
1282            } else {
1283                None
1284            }
1285        })
1286        .collect();
1287
1288    let mut orphans_removed = 0usize;
1289    for m in msgs.iter_mut() {
1290        if m.role != Role::User || m.parts.is_empty() {
1291            continue;
1292        }
1293        let before = m.parts.len();
1294        m.parts.retain(|p| match p {
1295            MessagePart::ToolResult { tool_use_id, .. } => {
1296                emitted_tool_ids.contains(tool_use_id.as_str())
1297            }
1298            _ => true,
1299        });
1300        let dropped = before - m.parts.len();
1301        if dropped > 0 {
1302            orphans_removed += dropped;
1303            if m.parts.is_empty() {
1304                m.content.clear();
1305            } else {
1306                m.rebuild_content();
1307            }
1308        }
1309    }
1310
1311    // Pass 2: collect ToolResult IDs present in user messages after pass 1; prune ToolUse
1312    // parts from assistant messages whose result is confirmed absent.
1313    //
1314    // The trailing assistant message is exempt: it may legitimately contain unanswered
1315    // ToolUse calls (the slice ends before the result arrives). Only interior assistant
1316    // messages — those followed by at least one user message — can have provably orphaned
1317    // ToolUse parts (the conversation moved on without answering them).
1318    let consumed_tool_ids: std::collections::HashSet<String> = msgs
1319        .iter()
1320        .filter(|m| m.role == Role::User)
1321        .flat_map(|m| m.parts.iter())
1322        .filter_map(|p| {
1323            if let MessagePart::ToolResult { tool_use_id, .. } = p {
1324                Some(tool_use_id.clone())
1325            } else {
1326                None
1327            }
1328        })
1329        .collect();
1330
1331    // Index of the last assistant message — exempt from pass 2.
1332    let last_assistant_idx = msgs
1333        .iter()
1334        .enumerate()
1335        .rev()
1336        .find(|(_, m)| m.role == Role::Assistant)
1337        .map(|(i, _)| i);
1338
1339    for (idx, m) in msgs.iter_mut().enumerate() {
1340        if m.role != Role::Assistant || m.parts.is_empty() {
1341            continue;
1342        }
1343        // Skip the trailing assistant message — its unanswered ToolUse calls are not orphaned.
1344        if Some(idx) == last_assistant_idx {
1345            continue;
1346        }
1347        let before = m.parts.len();
1348        m.parts.retain(|p| match p {
1349            MessagePart::ToolUse { id, .. } => consumed_tool_ids.contains(id.as_str()),
1350            _ => true,
1351        });
1352        let dropped = before - m.parts.len();
1353        if dropped > 0 {
1354            orphans_removed += dropped;
1355            if m.parts.is_empty() {
1356                m.content.clear();
1357            } else {
1358                m.rebuild_content();
1359            }
1360        }
1361    }
1362
1363    // Remove messages that were emptied by orphan pruning.
1364    msgs.retain(|m| !m.content.is_empty() || !m.parts.is_empty());
1365
1366    if orphans_removed > 0 {
1367        tracing::debug!(
1368            orphans = orphans_removed,
1369            "[subagent] pruned orphaned ToolUse/ToolResult parts from parent context boundary"
1370        );
1371    }
1372}
1373
1374/// Sanitize text parts of `msgs` through the IPI pipeline.
1375///
1376/// Only [`MessagePart::Text`] parts are passed through the sanitizer; structured parts
1377/// (`ToolUse`, `ToolResult`, `Recall`, `CodeContext`) are left untouched.  After sanitization
1378/// the message `content` field is rebuilt to stay consistent with the updated parts.
1379fn sanitize_parent_messages(
1380    mut msgs: Vec<zeph_llm::provider::Message>,
1381    sanitizer: &zeph_sanitizer::ContentSanitizer,
1382    source: &zeph_sanitizer::ContentSource,
1383) -> Vec<zeph_llm::provider::Message> {
1384    use zeph_llm::provider::MessagePart;
1385    for msg in &mut msgs {
1386        let mut changed = false;
1387        for part in &mut msg.parts {
1388            if let MessagePart::Text { text } = part {
1389                let clean = sanitizer.sanitize(text, source.clone());
1390                if clean.body != *text {
1391                    *text = clean.body;
1392                    changed = true;
1393                }
1394            }
1395        }
1396        if changed {
1397            msg.rebuild_content();
1398        }
1399    }
1400    msgs
1401}
1402
1403impl<C: Channel + Send + 'static> zeph_commands::SubagentAccess for Agent<C> {
1404    // ----- /agent, @mention -----
1405
1406    fn handle_agent_dispatch<'a>(
1407        &'a mut self,
1408        input: &'a str,
1409    ) -> std::pin::Pin<
1410        Box<
1411            dyn std::future::Future<Output = Result<Option<String>, zeph_commands::CommandError>>
1412                + Send
1413                + 'a,
1414        >,
1415    > {
1416        Box::pin(async move {
1417            match self.dispatch_agent_command(input).await {
1418                Some(Err(e)) => Err(zeph_commands::CommandError::new(e.to_string())),
1419                Some(Ok(())) | None => Ok(None),
1420            }
1421        })
1422    }
1423
1424    // ----- /agents -----
1425
1426    fn handle_agents<'a>(
1427        &'a mut self,
1428        args: &'a str,
1429    ) -> std::pin::Pin<
1430        Box<
1431            dyn std::future::Future<Output = Result<String, zeph_commands::CommandError>>
1432                + Send
1433                + 'a,
1434        >,
1435    > {
1436        use zeph_commands::handlers::agents_fleet::{FleetEntry, format_fleet_section};
1437        use zeph_subagent::AgentsCommand;
1438
1439        let args_owned = args.trim().to_owned();
1440        Box::pin(async move {
1441            // Fleet view: bare `/agents` or `/agents fleet` shows autonomous sessions + definitions.
1442            let show_fleet = args_owned.is_empty() || args_owned == "fleet";
1443
1444            let fleet_section = if show_fleet {
1445                let snapshots = self.services.autonomous_registry.list();
1446                let entries: Vec<FleetEntry> = snapshots
1447                    .into_iter()
1448                    .map(|s| FleetEntry {
1449                        goal_id: s.goal_id,
1450                        goal_text_short: s.goal_text_short,
1451                        state: s.state,
1452                        turns_executed: s.turns_executed,
1453                        max_turns: s.max_turns,
1454                        elapsed: s.elapsed,
1455                    })
1456                    .collect();
1457                format_fleet_section(&entries)
1458            } else {
1459                String::new()
1460            };
1461
1462            // Sub-agent definitions section.
1463            let definitions_section = if show_fleet || args_owned == "list" {
1464                self.handle_agents_definitions_list()
1465            } else {
1466                // CRUD subcommands: show, create, edit, delete.
1467                match AgentsCommand::parse(&format!("/agents {args_owned}")) {
1468                    Ok(cmd) => self.handle_agents_crud(cmd),
1469                    Err(e) => e.to_string(),
1470                }
1471            };
1472
1473            let mut out = fleet_section;
1474            if !definitions_section.is_empty() {
1475                if !out.is_empty() {
1476                    out.push('\n');
1477                }
1478                out.push_str(&definitions_section);
1479            }
1480
1481            if out.is_empty() {
1482                "No active autonomous sessions or sub-agent definitions found."
1483                    .clone_into(&mut out);
1484            }
1485
1486            Ok(out)
1487        })
1488    }
1489}
1490
1491#[cfg(test)]
1492mod tests {
1493    use zeph_tools::{ErasedToolExecutor, ToolCall};
1494
1495    use super::*;
1496    use crate::agent::agent_tests::*;
1497
1498    // ── resolve_subagent_secret tests (#5941/#5942) ─────────────────────────
1499
1500    fn agent_with_custom_secret(stored_key: &str, value: &str) -> Agent<MockChannel> {
1501        let provider = mock_provider(vec![]);
1502        let channel = MockChannel::new(vec![]);
1503        let registry = create_test_registry();
1504        let executor = MockToolExecutor::no_tools();
1505        let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
1506        agent.services.skill.available_custom_secrets.insert(
1507            stored_key.to_owned(),
1508            crate::vault::Secret::new(value.to_owned()),
1509        );
1510        agent
1511    }
1512
1513    #[test]
1514    fn resolve_subagent_secret_exact_match() {
1515        let agent = agent_with_custom_secret("my_key", "the-value");
1516        let resolved = agent.resolve_subagent_secret("my_key");
1517        assert_eq!(
1518            resolved.map(|s| s.expose().to_owned()),
1519            Some("the-value".to_owned())
1520        );
1521    }
1522
1523    #[test]
1524    fn resolve_subagent_secret_normalizes_dash_to_underscore() {
1525        // Stored key is underscored (as produced by ZEPH_SECRET_<NAME> normalization);
1526        // the sub-agent may request it with dashes instead.
1527        let agent = agent_with_custom_secret("my_api_key", "dash-value");
1528        let resolved = agent.resolve_subagent_secret("my-api-key");
1529        assert_eq!(
1530            resolved.map(|s| s.expose().to_owned()),
1531            Some("dash-value".to_owned())
1532        );
1533    }
1534
1535    #[test]
1536    fn resolve_subagent_secret_normalizes_case() {
1537        let agent = agent_with_custom_secret("upper_key", "case-value");
1538        let resolved = agent.resolve_subagent_secret("UPPER_KEY");
1539        assert_eq!(
1540            resolved.map(|s| s.expose().to_owned()),
1541            Some("case-value".to_owned())
1542        );
1543    }
1544
1545    #[test]
1546    fn resolve_subagent_secret_missing_key_returns_none() {
1547        let agent = agent_with_custom_secret("known_key", "value");
1548        assert!(agent.resolve_subagent_secret("unknown_key").is_none());
1549    }
1550
1551    #[test]
1552    fn resolve_subagent_secret_empty_map_returns_none() {
1553        let provider = mock_provider(vec![]);
1554        let channel = MockChannel::new(vec![]);
1555        let registry = create_test_registry();
1556        let executor = MockToolExecutor::no_tools();
1557        let agent = Agent::new(provider, channel, registry, None, 5, executor);
1558        assert!(agent.resolve_subagent_secret("anything").is_none());
1559    }
1560
1561    /// #5712 regression: MCP tool identification must key off `ToolDef::server_id`, not a
1562    /// `"mcp_"` name prefix that real `McpTool::sanitized_id()` output never produces.
1563    #[tokio::test]
1564    async fn extract_mcp_tool_names_uses_server_id_not_name_prefix() {
1565        use zeph_tools::registry::InvocationHint;
1566
1567        let provider = mock_provider(vec![]);
1568        let channel = MockChannel::new(vec![]);
1569        let registry = create_test_registry();
1570        let executor = MockToolExecutor::no_tools().with_definitions(vec![
1571            ToolDef {
1572                id: "read".into(),
1573                description: "built-in tool".into(),
1574                schema: schemars::Schema::default(),
1575                invocation: InvocationHint::ToolCall,
1576                output_schema: None,
1577                server_id: None,
1578            },
1579            ToolDef {
1580                id: "github_create_issue".into(),
1581                description: "MCP tool".into(),
1582                schema: schemars::Schema::default(),
1583                invocation: InvocationHint::ToolCall,
1584                output_schema: None,
1585                server_id: Some("github".into()),
1586            },
1587        ]);
1588        let agent = Agent::new(provider, channel, registry, None, 5, executor);
1589
1590        assert_eq!(agent.extract_mcp_tool_names(), vec!["github_create_issue"]);
1591    }
1592
1593    /// Agent with `durable_ctx` populated via the real `ensure_session_durable_ctx` bootstrap
1594    /// path (mirrors `durable_bootstrap::tests::agent_with_conversation`), with
1595    /// `durable_subagent` set per `subagent_enabled` — used to test the FR-003/US-002 seat
1596    /// wiring gate at `resolve_durable_spawn_gate`, not just the config-to-builder plumbing.
1597    async fn agent_with_durable_ctx_ready(subagent_enabled: bool) -> Agent<MockChannel> {
1598        let provider = mock_provider(vec!["ok".into()]);
1599        let channel = MockChannel::new(vec![]);
1600        let registry = create_test_registry();
1601        let executor = MockToolExecutor::no_tools();
1602        let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
1603        agent.services.memory.persistence.conversation_id = Some(zeph_memory::ConversationId(42));
1604        agent.services.session.durable_agent_turns_config = Some(zeph_config::DurableConfig {
1605            enabled: true,
1606            agent_turns: true,
1607            ..zeph_config::DurableConfig::default()
1608        });
1609        agent.services.session.durable_agent_turns_db_url = Some(":memory:".to_owned());
1610        agent.services.session.durable_subagent = subagent_enabled;
1611
1612        agent.ensure_session_durable_ctx().await;
1613        assert!(
1614            agent.services.session.durable_ctx.is_some(),
1615            "test setup: durable_ctx must be populated before exercising the seat gate"
1616        );
1617        agent
1618    }
1619
1620    #[tokio::test]
1621    async fn seat_wired_when_subagent_enabled_and_durable_ctx_populated() {
1622        let agent = Box::pin(agent_with_durable_ctx_ready(true)).await;
1623
1624        let gate = resolve_durable_spawn_gate(
1625            agent.services.session.durable_subagent,
1626            agent.services.session.durable_ctx.as_deref(),
1627        )
1628        .await;
1629
1630        assert!(
1631            matches!(gate, DurableSpawnGate::Fresh(_)),
1632            "US-002: [durable] subagent=true with a populated durable_ctx must yield a seat, \
1633             not just wire the config-to-builder plumbing"
1634        );
1635    }
1636
1637    #[tokio::test]
1638    async fn seat_absent_when_subagent_disabled() {
1639        let agent = Box::pin(agent_with_durable_ctx_ready(false)).await;
1640
1641        let gate = resolve_durable_spawn_gate(
1642            agent.services.session.durable_subagent,
1643            agent.services.session.durable_ctx.as_deref(),
1644        )
1645        .await;
1646
1647        assert!(
1648            matches!(gate, DurableSpawnGate::None),
1649            "FR-008: durable_subagent=false must keep the seat gate closed even when \
1650             durable_ctx is populated"
1651        );
1652    }
1653
1654    // ── #5944 end-to-end replay regression tests ────────────────────────────
1655    //
1656    // These simulate a real parent-process restart: two *separate* `Agent` instances
1657    // pointed at the same on-disk sqlite durable journal and the same `conversation_id`,
1658    // so the second instance's `DurableContext` genuinely re-derives the first's
1659    // `ExecutionId`/`PromiseId` (mirrors `try_replay_durable_subagent_sees_already_resolved_promise_on_resume`
1660    // in `zeph-subagent/src/durable.rs`, but at the `handle_agent_background`/
1661    // `handle_agent_spawn_foreground` call-site level rather than the adapter level).
1662
1663    fn subagent_def(name: &str) -> zeph_subagent::SubAgentDef {
1664        use zeph_subagent::def::{SkillFilter, SubAgentPermissions, ToolPolicy};
1665        use zeph_subagent::hooks::SubagentHooks;
1666
1667        zeph_subagent::SubAgentDef {
1668            name: name.to_owned(),
1669            description: "A helper bot".into(),
1670            model: None,
1671            tools: ToolPolicy::InheritAll,
1672            disallowed_tools: vec![],
1673            permissions: SubAgentPermissions::default(),
1674            skills: SkillFilter::default(),
1675            system_prompt: "You are helpful.".into(),
1676            hooks: SubagentHooks::default(),
1677            memory: None,
1678            source: None,
1679            file_path: None,
1680        }
1681    }
1682
1683    /// Builds an `Agent` wired for durable sub-agent spawns against a real sqlite file at
1684    /// `db_url`, with a `SubAgentManager` carrying a single "helper" definition so
1685    /// `handle_agent_background`/`handle_agent_spawn_foreground` can run past the gate check.
1686    async fn agent_with_durable_and_manager(
1687        db_url: &str,
1688        conversation_id: i64,
1689    ) -> Agent<MockChannel> {
1690        let provider = mock_provider(vec!["ok".into()]);
1691        let channel = MockChannel::new(vec![]);
1692        let registry = create_test_registry();
1693        let executor = MockToolExecutor::no_tools();
1694        let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
1695        agent.services.memory.persistence.conversation_id =
1696            Some(zeph_memory::ConversationId(conversation_id));
1697        agent.services.session.durable_agent_turns_config = Some(zeph_config::DurableConfig {
1698            enabled: true,
1699            agent_turns: true,
1700            ..zeph_config::DurableConfig::default()
1701        });
1702        agent.services.session.durable_agent_turns_db_url = Some(db_url.to_owned());
1703        agent.services.session.durable_subagent = true;
1704
1705        let mut mgr = zeph_subagent::SubAgentManager::new(4);
1706        mgr.definitions_mut().push(subagent_def("helper"));
1707        agent.services.orchestration.subagent_manager = Some(mgr);
1708
1709        agent.ensure_session_durable_ctx().await;
1710        assert!(
1711            agent.services.session.durable_ctx.is_some(),
1712            "test setup: durable_ctx must be populated before exercising the handler"
1713        );
1714        agent
1715    }
1716
1717    #[tokio::test]
1718    async fn handle_agent_background_replays_finished_child_without_respawning() {
1719        let dir = tempfile::tempdir().unwrap();
1720        let db_url = dir.path().join("durable.db").to_string_lossy().into_owned();
1721
1722        // "Run 1": the child finishes and resolves its promise before the parent crashes.
1723        let agent1 = Box::pin(agent_with_durable_and_manager(&db_url, 100)).await;
1724        let ctx1 = agent1.services.session.durable_ctx.clone().unwrap();
1725        let (_promise1, seat) = zeph_subagent::make_durable_promise(&ctx1).await.unwrap();
1726        let seat = seat.expect("test setup: run 1 must be fresh and yield a resolver seat");
1727        let loop_result: Result<String, zeph_subagent::SubAgentError> =
1728            Ok("child finished before crash".to_owned());
1729        zeph_subagent::resolve_durable_promise(seat, "task-e2e-01", &loop_result).await;
1730        agent1
1731            .services
1732            .session
1733            .durable_writer
1734            .as_ref()
1735            .unwrap()
1736            .flush()
1737            .await
1738            .unwrap();
1739        // Drop run 1 to release its process-exclusivity lock on the execution (INV-15, #6122) —
1740        // a real crash closes the process's file descriptors (and thus the flock) before the
1741        // restarted parent below re-opens the same execution; without this, run 2's
1742        // `open_execution_exclusive` would see run 1 as still live and correctly refuse to open.
1743        drop(agent1);
1744
1745        // "Run 2": a brand-new `Agent` (simulating the restarted parent) with the same
1746        // conversation_id and db file re-derives the same promise and must see it resolved.
1747        let mut agent2 = Box::pin(agent_with_durable_and_manager(&db_url, 100)).await;
1748
1749        let resp = agent2
1750            .handle_agent_background("helper", "do work")
1751            .await
1752            .unwrap();
1753        assert!(
1754            resp.contains("replayed from durable journal"),
1755            "expected a replay notice, got: {resp}"
1756        );
1757        assert!(
1758            resp.contains("child finished before crash"),
1759            "expected the journaled output to be surfaced, got: {resp}"
1760        );
1761        assert!(
1762            agent2
1763                .services
1764                .orchestration
1765                .subagent_manager
1766                .as_ref()
1767                .unwrap()
1768                .statuses()
1769                .is_empty(),
1770            "mgr.spawn must not be called when the child result is replayed"
1771        );
1772    }
1773
1774    #[tokio::test]
1775    async fn handle_agent_spawn_foreground_replays_finished_child_without_respawning() {
1776        let dir = tempfile::tempdir().unwrap();
1777        let db_url = dir.path().join("durable.db").to_string_lossy().into_owned();
1778
1779        // "Run 1": the child finishes and resolves its promise before the parent crashes.
1780        let agent1 = Box::pin(agent_with_durable_and_manager(&db_url, 200)).await;
1781        let ctx1 = agent1.services.session.durable_ctx.clone().unwrap();
1782        let (_promise1, seat) = zeph_subagent::make_durable_promise(&ctx1).await.unwrap();
1783        let seat = seat.expect("test setup: run 1 must be fresh and yield a resolver seat");
1784        let loop_result: Result<String, zeph_subagent::SubAgentError> =
1785            Ok("foreground child output".to_owned());
1786        zeph_subagent::resolve_durable_promise(seat, "task-e2e-02", &loop_result).await;
1787        // C1 regression guard (#6027): journal a durable step AFTER the promise, exactly the
1788        // foreground-spawn-followed-by-another-turn topology that triggered the original
1789        // ReplayDivergence bug (a replay-only `ctx.step()` used to land at this same ordinal
1790        // position and collide with whatever the fresh run had already recorded there). The
1791        // `notified_at` claim consumes no step id, so it can never collide with this marker —
1792        // if it regressed to a step-based mechanism, the assertions below would fail with a
1793        // `ReplayDivergence` error instead of the expected replayed output.
1794        ctx1.step(
1795            zeph_durable::StepDescriptor::idempotent(
1796                "post_spawn_marker",
1797                b"post_spawn_marker".to_vec(),
1798            ),
1799            |_handle| async move { Ok::<i64, zeph_durable::StepError>(42) },
1800        )
1801        .await
1802        .unwrap();
1803        agent1
1804            .services
1805            .session
1806            .durable_writer
1807            .as_ref()
1808            .unwrap()
1809            .flush()
1810            .await
1811            .unwrap();
1812        // Drop run 1 to release its process-exclusivity lock on the execution (INV-15, #6122) —
1813        // see the comment in `handle_agent_background_replays_finished_child_without_respawning`.
1814        drop(agent1);
1815
1816        // "Run 2": a brand-new `Agent` re-derives the same promise and must see it resolved,
1817        // returning the journaled output directly instead of spawning and polling a new child.
1818        let mut agent2 = Box::pin(agent_with_durable_and_manager(&db_url, 200)).await;
1819
1820        let resp = agent2
1821            .handle_agent_spawn_foreground("helper", "do work")
1822            .await
1823            .unwrap();
1824        assert_eq!(resp, "foreground child output");
1825        assert!(
1826            agent2
1827                .channel
1828                .sent_messages()
1829                .iter()
1830                .any(|m| m.contains("replayed from durable journal")),
1831            "expected the replay notice to be sent to the channel"
1832        );
1833        assert_eq!(
1834            agent2.channel.notify_completed_calls().len(),
1835            1,
1836            "expected exactly one TUI completion notification on the first replay"
1837        );
1838        assert!(
1839            agent2
1840                .services
1841                .orchestration
1842                .subagent_manager
1843                .as_ref()
1844                .unwrap()
1845                .statuses()
1846                .is_empty(),
1847            "mgr.spawn must not be called when the child result is replayed"
1848        );
1849        drop(agent2);
1850
1851        // "Run 3": the parent restarts *again* after already taking the replay branch once.
1852        // Per #6027, the channel side effects (notice + completion event) must not re-fire on
1853        // this second replay — only the first winner of the out-of-band `notified_at` claim
1854        // fires them; the journaled output is still returned.
1855        let mut agent3 = Box::pin(agent_with_durable_and_manager(&db_url, 200)).await;
1856
1857        let resp = agent3
1858            .handle_agent_spawn_foreground("helper", "do work")
1859            .await
1860            .unwrap();
1861        assert_eq!(resp, "foreground child output");
1862        assert!(
1863            !agent3
1864                .channel
1865                .sent_messages()
1866                .iter()
1867                .any(|m| m.contains("replayed from durable journal")),
1868            "replay notice must not re-fire on a second replay after a parent restart"
1869        );
1870        assert!(
1871            agent3.channel.notify_completed_calls().is_empty(),
1872            "TUI completion event must not re-fire on a second replay after a parent restart"
1873        );
1874    }
1875
1876    #[tokio::test]
1877    async fn handle_agent_background_resumed_still_pending_falls_back_to_spawn() {
1878        let dir = tempfile::tempdir().unwrap();
1879        let db_url = dir.path().join("durable.db").to_string_lossy().into_owned();
1880
1881        // "Run 1": the promise is created (child spawned) but never resolved — simulates a
1882        // child that was still genuinely running (or lost) when the parent crashed.
1883        let agent1 = Box::pin(agent_with_durable_and_manager(&db_url, 300)).await;
1884        let ctx1 = agent1.services.session.durable_ctx.clone().unwrap();
1885        let (_promise1, seat) = zeph_subagent::make_durable_promise(&ctx1).await.unwrap();
1886        assert!(
1887            seat.is_some(),
1888            "test setup: run 1 must be fresh and yield a resolver seat"
1889        );
1890        agent1
1891            .services
1892            .session
1893            .durable_writer
1894            .as_ref()
1895            .unwrap()
1896            .flush()
1897            .await
1898            .unwrap();
1899        // Drop run 1 to release its process-exclusivity lock on the execution (INV-15, #6122) —
1900        // see the comment in `handle_agent_background_replays_finished_child_without_respawning`.
1901        drop(agent1);
1902
1903        // "Run 2": resumed execution observes the same promise still pending — per the
1904        // documented v1 scope boundary (INV-9: no way to recover an orphaned resolver token)
1905        // the gate must degrade to a plain spawn rather than replay or block indefinitely.
1906        let mut agent2 = Box::pin(agent_with_durable_and_manager(&db_url, 300)).await;
1907
1908        let resp = agent2
1909            .handle_agent_background("helper", "do work")
1910            .await
1911            .unwrap();
1912        assert!(
1913            resp.contains("started in background"),
1914            "still-pending resumed promise must fall back to a normal spawn, got: {resp}"
1915        );
1916        assert_eq!(
1917            agent2
1918                .services
1919                .orchestration
1920                .subagent_manager
1921                .as_ref()
1922                .unwrap()
1923                .statuses()
1924                .len(),
1925            1,
1926            "exactly one real spawn must occur on the still-pending fallback path"
1927        );
1928    }
1929
1930    // ── build_spawn_context: debug_dump_sink wiring (#6391) ─────────────────
1931
1932    #[test]
1933    fn build_spawn_context_leaves_debug_dump_sink_none_without_dumper() {
1934        let provider = mock_provider(vec![]);
1935        let channel = MockChannel::new(vec![]);
1936        let registry = create_test_registry();
1937        let agent = Agent::new(
1938            provider,
1939            channel,
1940            registry,
1941            None,
1942            5,
1943            MockToolExecutor::no_tools(),
1944        );
1945
1946        let ctx = agent.build_spawn_context(&zeph_config::SubAgentConfig::default());
1947        assert!(
1948            ctx.debug_dump_sink.is_none(),
1949            "no DebugDumper configured, so SpawnContext must carry no sink"
1950        );
1951    }
1952
1953    #[tokio::test]
1954    async fn build_spawn_context_wires_debug_dump_sink_when_dumper_present() {
1955        let dir = tempfile::tempdir().unwrap();
1956        let dumper =
1957            crate::debug_dump::DebugDumper::new(dir.path(), crate::debug_dump::DumpFormat::Raw)
1958                .unwrap();
1959
1960        let provider = mock_provider(vec![]);
1961        let channel = MockChannel::new(vec![]);
1962        let registry = create_test_registry();
1963        let mut agent = Agent::new(
1964            provider,
1965            channel,
1966            registry,
1967            None,
1968            5,
1969            MockToolExecutor::no_tools(),
1970        );
1971        agent.runtime.debug.debug_dumper = Some(dumper);
1972
1973        let ctx = agent.build_spawn_context(&zeph_config::SubAgentConfig::default());
1974        let sink = ctx
1975            .debug_dump_sink
1976            .expect("a configured DebugDumper must be threaded into SpawnContext");
1977
1978        // Exercise the sink through the trait, same as `zeph-subagent`'s agent loop would —
1979        // proves the wiring produces a working `Arc<dyn DebugDumpSink>`, not just `Some(_)`.
1980        let id = sink.dump_request("mock", &[], &[], serde_json::Value::Null);
1981        sink.dump_response(id, &zeph_llm::provider::ChatResponse::Text("ok".into()));
1982    }
1983
1984    // ── build_spawn_context: inherited_tool_allowlist wiring (#6527) ────────
1985
1986    #[test]
1987    fn build_spawn_context_leaves_inherited_tool_allowlist_none_by_default() {
1988        // Default PermissionPolicy has no rules, so no tool is wholesale-denied — must
1989        // stay None, not Some(full universe) (§2a: would freeze InheritAll children).
1990        let provider = mock_provider(vec![]);
1991        let channel = MockChannel::new(vec![]);
1992        let registry = create_test_registry();
1993        let executor = MockToolExecutor::no_tools().with_definitions(vec![ToolDef {
1994            id: "bash".into(),
1995            description: "shell".into(),
1996            schema: schemars::Schema::default(),
1997            invocation: zeph_tools::registry::InvocationHint::ToolCall,
1998            output_schema: None,
1999            server_id: None,
2000        }]);
2001        let agent = Agent::new(provider, channel, registry, None, 5, executor);
2002
2003        let ctx = agent.build_spawn_context(&zeph_config::SubAgentConfig::default());
2004        assert!(ctx.inherited_tool_allowlist.is_none());
2005    }
2006
2007    #[test]
2008    fn build_spawn_context_populates_inherited_tool_allowlist_from_parent_policy() {
2009        // A wholesale-Deny rule on the parent's own PermissionPolicy must narrow
2010        // SpawnContext::inherited_tool_allowlist, dropping the denied tool but keeping
2011        // everything else in the parent's tool universe.
2012        let provider = mock_provider(vec![]);
2013        let channel = MockChannel::new(vec![]);
2014        let registry = create_test_registry();
2015        let executor = MockToolExecutor::no_tools().with_definitions(vec![
2016            ToolDef {
2017                id: "bash".into(),
2018                description: "shell".into(),
2019                schema: schemars::Schema::default(),
2020                invocation: zeph_tools::registry::InvocationHint::ToolCall,
2021                output_schema: None,
2022                server_id: None,
2023            },
2024            ToolDef {
2025                id: "read".into(),
2026                description: "read a file".into(),
2027                schema: schemars::Schema::default(),
2028                invocation: zeph_tools::registry::InvocationHint::ToolCall,
2029                output_schema: None,
2030                server_id: None,
2031            },
2032        ]);
2033        let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
2034
2035        let mut rules = std::collections::HashMap::new();
2036        rules.insert(
2037            "bash".to_owned(),
2038            vec![zeph_config::tools::PermissionRule {
2039                pattern: "*".to_owned(),
2040                action: zeph_config::tools::PermissionAction::Deny,
2041            }],
2042        );
2043        agent.runtime.config.permission_policy = zeph_tools::PermissionPolicy::new(rules)
2044            .with_autonomy(zeph_config::tools::AutonomyLevel::Supervised);
2045
2046        let ctx = agent.build_spawn_context(&zeph_config::SubAgentConfig::default());
2047        let allowlist = ctx
2048            .inherited_tool_allowlist
2049            .expect("a wholesale-denied bash tool must produce a narrowed Some(set)");
2050        assert!(!allowlist.contains("bash"));
2051        assert!(allowlist.contains("read"));
2052    }
2053
2054    // ── build_spawn_context: trust-level constraint propagation (#6493) ─────
2055
2056    #[test]
2057    fn build_spawn_context_leaves_max_trust_level_trusted_when_no_active_skills() {
2058        let provider = mock_provider(vec![]);
2059        let channel = MockChannel::new(vec![]);
2060        let registry = create_test_registry();
2061        let agent = Agent::new(
2062            provider,
2063            channel,
2064            registry,
2065            None,
2066            5,
2067            MockToolExecutor::no_tools(),
2068        );
2069
2070        let ctx = agent.build_spawn_context(&zeph_config::SubAgentConfig::default());
2071        assert_eq!(
2072            ctx.max_trust_level,
2073            Some(zeph_common::SkillTrustLevel::Trusted),
2074            "with no active skills this turn, the parent's own effective trust is Trusted, \
2075             so the cap must impose no additional restriction"
2076        );
2077    }
2078
2079    #[test]
2080    fn build_spawn_context_caps_trust_to_least_trusted_active_skill() {
2081        let provider = mock_provider(vec![]);
2082        let channel = MockChannel::new(vec![]);
2083        let registry = create_test_registry();
2084        let mut agent = Agent::new(
2085            provider,
2086            channel,
2087            registry,
2088            None,
2089            5,
2090            MockToolExecutor::no_tools(),
2091        );
2092        agent.services.skill.active_skill_names = vec!["trusted-skill".into(), "evil-skill".into()];
2093        agent.services.skill.trust_snapshot.write().insert(
2094            "trusted-skill".into(),
2095            crate::skill_invoker::SkillTrustSnapshot {
2096                trust_level: zeph_common::SkillTrustLevel::Trusted,
2097                requires_trust_check: false,
2098                blake3_hash: String::new(),
2099            },
2100        );
2101        agent.services.skill.trust_snapshot.write().insert(
2102            "evil-skill".into(),
2103            crate::skill_invoker::SkillTrustSnapshot {
2104                trust_level: zeph_common::SkillTrustLevel::Quarantined,
2105                requires_trust_check: false,
2106                blake3_hash: String::new(),
2107            },
2108        );
2109
2110        let ctx = agent.build_spawn_context(&zeph_config::SubAgentConfig::default());
2111        assert_eq!(
2112            ctx.max_trust_level,
2113            Some(zeph_common::SkillTrustLevel::Quarantined),
2114            "the cap must be the LEAST-trusted of all active skills this turn (weakest-link), \
2115             matching the fold `apply_skill_trust_and_gating` applies to the parent's own gate"
2116        );
2117    }
2118
2119    /// #6701 (S1): before this fix, `parent_effective_trust_level` folded raw
2120    /// `active_skill_names` with no `skill_fallback_mode` guard. In retrieval-fallback mode
2121    /// `active_skill_names` is every registered skill (Quarantined/Blocked included), so a
2122    /// subagent spawned during a fallback-mode turn would have been capped to Quarantined or
2123    /// worse — a new lockout regression the D4 guard on the parent's OWN gate did not cover.
2124    #[test]
2125    fn build_spawn_context_ignores_fallback_mode_registry_trust_for_cap() {
2126        let provider = mock_provider(vec![]);
2127        let channel = MockChannel::new(vec![]);
2128        let registry = create_test_registry();
2129        let mut agent = Agent::new(
2130            provider,
2131            channel,
2132            registry,
2133            None,
2134            5,
2135            MockToolExecutor::no_tools(),
2136        );
2137        // Simulate retrieval-fallback mode: every registered skill is "active" for catalog
2138        // purposes, including one the operator has Blocked.
2139        agent.services.skill.skill_fallback_mode = true;
2140        agent.services.skill.active_skill_names =
2141            vec!["trusted-skill".into(), "blocked-skill".into()];
2142        agent.services.skill.trust_snapshot.write().insert(
2143            "blocked-skill".into(),
2144            crate::skill_invoker::SkillTrustSnapshot {
2145                trust_level: zeph_common::SkillTrustLevel::Blocked,
2146                requires_trust_check: false,
2147                blake3_hash: String::new(),
2148            },
2149        );
2150
2151        let ctx = agent.build_spawn_context(&zeph_config::SubAgentConfig::default());
2152        assert_eq!(
2153            ctx.max_trust_level,
2154            Some(zeph_common::SkillTrustLevel::Trusted),
2155            "skill_fallback_mode must force the subagent cap to Trusted regardless of registry \
2156             contents, matching the D4 guard applied to the parent's own gate"
2157        );
2158    }
2159
2160    /// #6701 (S1/S3): when a `turn_trust_floor` is wired, `parent_effective_trust_level` must
2161    /// read it directly rather than recompute from `active_skill_names` — this is what makes
2162    /// it observe a mid-turn fold (e.g. an `invoke_skill` of a Quarantined skill) that
2163    /// `active_skill_names` alone would miss, and is also immune to the S1 fallback-mode bug
2164    /// since the floor itself is already fallback-mode-aware.
2165    #[test]
2166    fn build_spawn_context_reads_wired_turn_trust_floor_directly() {
2167        let provider = mock_provider(vec![]);
2168        let channel = MockChannel::new(vec![]);
2169        let registry = create_test_registry();
2170        let mut agent = Agent::new(
2171            provider,
2172            channel,
2173            registry,
2174            None,
2175            5,
2176            MockToolExecutor::no_tools(),
2177        );
2178        // No active skills and skill_fallback_mode is false — the no-floor fallback path would
2179        // compute Trusted here. Wire a floor that was independently folded to Quarantined
2180        // (e.g. by a mid-turn invoke_skill) to prove the floor wins.
2181        let floor = zeph_common::TurnTrustFloor::new(zeph_common::SkillTrustLevel::Trusted);
2182        floor.fold(zeph_common::SkillTrustLevel::Quarantined);
2183        agent.services.skill.turn_trust_floor = Some(floor);
2184
2185        let ctx = agent.build_spawn_context(&zeph_config::SubAgentConfig::default());
2186        assert_eq!(
2187            ctx.max_trust_level,
2188            Some(zeph_common::SkillTrustLevel::Quarantined),
2189            "a wired turn_trust_floor must be read directly, reflecting mid-turn folds that \
2190             active_skill_names alone cannot see"
2191        );
2192    }
2193
2194    /// Records every `set_effective_trust` call — unlike `MockToolExecutor`, which falls
2195    /// through to the trait's no-op default. Used by
2196    /// [`spawning_a_subagent_caps_trust_to_parent_effective_level`] to observe the trust level
2197    /// that actually reached the sub-agent's tool executor through the REAL production spawn
2198    /// path (`handle_agent_background` → `build_spawn_context` → `SubAgentManager::spawn` →
2199    /// `FilteredToolExecutor::set_effective_trust` → this executor, the same `Arc` the parent
2200    /// itself uses), not a hand-built `SpawnContext` in a unit test.
2201    #[derive(Default)]
2202    struct TrustRecordingExecutor {
2203        recorded: Arc<Mutex<Option<zeph_tools::SkillTrustLevel>>>,
2204    }
2205
2206    impl ToolExecutor for TrustRecordingExecutor {
2207        async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
2208            Ok(None)
2209        }
2210
2211        fn set_skill_env(&self, _env: Option<std::collections::HashMap<String, String>>) {}
2212
2213        fn set_effective_trust(&self, level: zeph_tools::SkillTrustLevel) {
2214            *self.recorded.lock().unwrap() = Some(level);
2215        }
2216
2217        zeph_tools::tool_executor_no_inner_defaults!();
2218    }
2219
2220    #[tokio::test]
2221    async fn spawning_a_subagent_caps_trust_to_parent_effective_level() {
2222        let provider = mock_provider(vec![]);
2223        let channel = MockChannel::new(vec![]);
2224        let registry = create_test_registry();
2225        let executor = TrustRecordingExecutor::default();
2226        let recorded = Arc::clone(&executor.recorded);
2227        let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
2228
2229        let mut mgr = zeph_subagent::SubAgentManager::new(4);
2230        mgr.definitions_mut().push(subagent_def("helper"));
2231        agent.services.orchestration.subagent_manager = Some(mgr);
2232
2233        // Parent's own current trust is restricted this turn by an active Quarantined skill.
2234        // The row is persisted through a real (in-memory) `SemanticMemory` store rather than
2235        // written directly to `trust_snapshot`, because `handle_agent_background` now calls
2236        // `filtered_skills_for`, which resolves the trust map fresh from the store on every
2237        // spawn (#6713 S1) — a direct write to the cache would just be clobbered by that fresh
2238        // (and, with no memory attached, empty) load before `build_spawn_context` reads it.
2239        let memory = test_memory_for_trust().await;
2240        memory
2241            .sqlite()
2242            .upsert_skill_trust(
2243                "evil-skill",
2244                zeph_common::SkillTrustLevel::Quarantined,
2245                zeph_memory::store::SourceKind::Local,
2246                None,
2247                None,
2248                "hash-evil",
2249            )
2250            .await
2251            .unwrap();
2252        agent = agent.with_memory(memory, zeph_memory::ConversationId(1), 50, 5, 50);
2253        agent.services.skill.active_skill_names = vec!["evil-skill".into()];
2254
2255        let resp = agent.handle_agent_background("helper", "do work").await;
2256        assert!(
2257            resp.is_some_and(|r| r.contains("started in background")),
2258            "test setup: the real production spawn path must succeed"
2259        );
2260
2261        assert_eq!(
2262            *recorded.lock().unwrap(),
2263            Some(zeph_tools::SkillTrustLevel::Quarantined),
2264            "a sub-agent spawned while the parent's own effective trust is Quarantined must \
2265             never receive a higher (Trusted) effective trust on its own tool executor — \
2266             #6493's escalation gap"
2267        );
2268    }
2269
2270    // ── S2 (#6527 critic): spawned sub-agent shares the parent's gated executor ──
2271
2272    /// Records every tool call it receives via a shared counter cloned out *before*
2273    /// `Agent::new` takes ownership of the executor. Only a literal `Arc::clone` of this
2274    /// same allocation (not a fresh/ungated executor of the same shape) can increment the
2275    /// caller's copy of the counter — proving the sub-agent's tool call reached the exact
2276    /// same executor instance the parent itself holds.
2277    #[derive(Default)]
2278    struct RecordingExecutor {
2279        calls: Mutex<u32>,
2280    }
2281
2282    impl ToolExecutor for RecordingExecutor {
2283        async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
2284            Ok(None)
2285        }
2286
2287        async fn execute_tool_call(
2288            &self,
2289            call: &ToolCall,
2290        ) -> Result<Option<ToolOutput>, ToolError> {
2291            *self.calls.lock().unwrap() += 1;
2292            Ok(Some(ToolOutput {
2293                tool_name: call.tool_id.clone(),
2294                summary: "ran".into(),
2295                blocks_executed: 1,
2296                ..Default::default()
2297            }))
2298        }
2299
2300        fn set_skill_env(&self, _env: Option<std::collections::HashMap<String, String>>) {}
2301
2302        zeph_tools::tool_executor_no_inner_defaults!();
2303    }
2304
2305    #[tokio::test]
2306    async fn spawning_a_subagent_tool_call_reaches_parents_own_executor() {
2307        // Backs the invariant comment on `build_spawn_context`'s `inherited_tool_allowlist`
2308        // wiring: `effective_tool_allowlist`'s `None` returns are safe only because the
2309        // child's tool calls are re-checked by whatever gates the parent's own
2310        // `self.tool_executor` (a `TrustGateExecutor` in production). This test proves the
2311        // production spawn path (`handle_agent_background` → `SubAgentManager::spawn` →
2312        // `FilteredToolExecutor` wrapping `Arc::clone(&self.tool_executor)`) really does
2313        // route the child's tool call through the SAME executor allocation the parent
2314        // holds, not a fresh/ungated one.
2315        use zeph_llm::provider::{ChatResponse, ToolUseRequest};
2316
2317        let (mock, _counter) = MockProvider::default().with_tool_use(vec![
2318            ChatResponse::ToolUse {
2319                text: None,
2320                tool_calls: vec![ToolUseRequest {
2321                    id: "call-1".into(),
2322                    name: "bash".into(),
2323                    input: serde_json::json!({"command": "echo hi"}),
2324                }],
2325                thinking_blocks: vec![],
2326            },
2327            ChatResponse::Text("final answer".into()),
2328        ]);
2329
2330        let channel = MockChannel::new(vec![]);
2331        let registry = create_test_registry();
2332        let recorder = Arc::new(RecordingExecutor::default());
2333        let mut agent = Agent::new(
2334            AnyProvider::Mock(mock),
2335            channel,
2336            registry,
2337            None,
2338            5,
2339            RecordingExecutor::default(),
2340        );
2341        // Replace with the tracked Arc so the test can observe calls made against the exact
2342        // instance the production spawn path clones via `Arc::clone(&self.tool_executor)`.
2343        agent.tool_executor = Arc::clone(&recorder) as Arc<dyn ErasedToolExecutor>;
2344
2345        let mut mgr = zeph_subagent::SubAgentManager::new(4);
2346        mgr.definitions_mut().push(subagent_def("helper"));
2347        agent.services.orchestration.subagent_manager = Some(mgr);
2348
2349        let resp = agent.handle_agent_background("helper", "do work").await;
2350        assert!(
2351            resp.is_some_and(|r| r.contains("started in background")),
2352            "test setup: the real production spawn path must succeed"
2353        );
2354
2355        for _ in 0..50 {
2356            if !agent.poll_subagents().await.is_empty() {
2357                break;
2358            }
2359            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
2360        }
2361
2362        assert!(
2363            *recorder.calls.lock().unwrap() >= 1,
2364            "the sub-agent's tool call must reach the parent's own tool executor instance, \
2365             proving no fresh/ungated executor is substituted for the child"
2366        );
2367    }
2368
2369    // ── #6570: background subagent completion notifies the view layer ────────────
2370
2371    /// `notify_completed_subagents` (the `/agent bg` background-poll path, distinct from the
2372    /// foreground `handle_agent_spawn_foreground`/`handle_agent_resume` paths) must call
2373    /// `Channel::notify_background_subagent_completed` with the agent's definition name and
2374    /// success flag, in addition to the plain-text notice. Channels that support a manually
2375    /// opened subagent view (e.g. the TUI sidebar) rely on this to reset the view once the
2376    /// `SubAgentManager` entry backing it disappears, instead of leaving the transcript pane
2377    /// stalled forever.
2378    #[tokio::test]
2379    async fn notify_completed_subagents_notifies_channel_of_background_completion() {
2380        let provider = mock_provider(vec!["done".into()]);
2381        let channel = MockChannel::new(vec![]);
2382        let registry = create_test_registry();
2383        let executor = MockToolExecutor::no_tools();
2384        let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
2385
2386        let mut mgr = zeph_subagent::SubAgentManager::new(4);
2387        mgr.definitions_mut().push(subagent_def("helper"));
2388        agent.services.orchestration.subagent_manager = Some(mgr);
2389
2390        let resp = agent.handle_agent_background("helper", "do work").await;
2391        assert!(
2392            resp.is_some_and(|r| r.contains("started in background")),
2393            "test setup: the background spawn must succeed"
2394        );
2395
2396        let mut notified = Vec::new();
2397        for _ in 0..50 {
2398            agent.notify_completed_subagents().await.unwrap();
2399            notified = agent.channel.notify_background_completed_calls();
2400            if !notified.is_empty() {
2401                break;
2402            }
2403            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
2404        }
2405
2406        assert_eq!(
2407            notified.len(),
2408            1,
2409            "exactly one background-completion notification must be recorded"
2410        );
2411        let (_task_id, name, success) = &notified[0];
2412        assert_eq!(name, "helper");
2413        assert!(
2414            *success,
2415            "MockProvider's clean text response must be treated as a success"
2416        );
2417    }
2418
2419    // ── #6571: generic secret shape masked in the completion notice ──────────
2420
2421    /// A sub-agent that fabricates or echoes an API-key-shaped string in its final response
2422    /// must not have it forwarded verbatim in the plain-text completion notice sent via
2423    /// `channel.send` — the same class of leak #6571 reported for the live-forward path
2424    /// (`zeph-subagent::forward::sanitize_text`), but on the completion-notice surface instead.
2425    #[tokio::test]
2426    async fn notify_completed_subagents_masks_generic_secret_shape_in_notice() {
2427        // Two responses: a text-only first turn always draws a one-time nudge to use tools
2428        // (`handle_no_tool_response`, `turns == 1 && !any_tool_called`), so the completion
2429        // notice reflects the *second* queued response, not the first.
2430        let provider = mock_provider(vec![
2431            "thinking about it".into(),
2432            "here is a key: sk-test-abc123def456, use it wisely".into(),
2433        ]);
2434        let channel = MockChannel::new(vec![]);
2435        let registry = create_test_registry();
2436        let executor = MockToolExecutor::no_tools();
2437        let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
2438
2439        let mut mgr = zeph_subagent::SubAgentManager::new(4);
2440        mgr.definitions_mut().push(subagent_def("helper"));
2441        agent.services.orchestration.subagent_manager = Some(mgr);
2442
2443        let resp = agent.handle_agent_background("helper", "do work").await;
2444        assert!(
2445            resp.is_some_and(|r| r.contains("started in background")),
2446            "test setup: the background spawn must succeed"
2447        );
2448
2449        let mut sent = Vec::new();
2450        for _ in 0..50 {
2451            agent.notify_completed_subagents().await.unwrap();
2452            sent = agent.channel.sent_messages();
2453            if !sent.is_empty() {
2454                break;
2455            }
2456            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
2457        }
2458
2459        let notice = sent
2460            .iter()
2461            .find(|m| m.contains("completed"))
2462            .expect("a completion notice must have been sent");
2463        assert!(
2464            !notice.contains("sk-test-abc123def456"),
2465            "generic secret-shaped string must not appear verbatim in the completion notice: {notice}"
2466        );
2467        assert!(
2468            notice.contains("[REDACTED]"),
2469            "masked placeholder must be present in the completion notice: {notice}"
2470        );
2471    }
2472
2473    // ── filtered_skills_for token-budget cap (#6421) ─────────────────────────
2474
2475    /// Builds a `SkillRegistry` with `count` skills on disk, each named `skill-N` with a body
2476    /// made of `words_per_skill` repeated words — enough real text that `TokenCounter` charges
2477    /// a nontrivial, predictable-in-sign (if not exact) token count per skill.
2478    ///
2479    /// Returns the backing `TempDir` alongside the registry: skill bodies are loaded lazily
2480    /// from disk on first access (`SkillRegistry::skill`/`body`), so the caller must keep the
2481    /// directory alive for as long as the registry is used, not just during `load`.
2482    fn registry_with_skills(
2483        count: usize,
2484        words_per_skill: usize,
2485    ) -> (SkillRegistry, tempfile::TempDir) {
2486        let temp_dir = tempfile::tempdir().unwrap();
2487        for i in 0..count {
2488            let skill_dir = temp_dir.path().join(format!("skill-{i}"));
2489            std::fs::create_dir(&skill_dir).unwrap();
2490            let body = "lorem ".repeat(words_per_skill);
2491            std::fs::write(
2492                skill_dir.join("SKILL.md"),
2493                format!("---\nname: skill-{i}\ndescription: Test skill {i}\n---\n{body}"),
2494            )
2495            .unwrap();
2496        }
2497        let registry = SkillRegistry::load(&[temp_dir.path().to_path_buf()]);
2498        (registry, temp_dir)
2499    }
2500
2501    fn agent_with_skill_registry_and_def(
2502        registry: SkillRegistry,
2503        def: zeph_subagent::SubAgentDef,
2504    ) -> Agent<MockChannel> {
2505        let provider = mock_provider(vec![]);
2506        let channel = MockChannel::new(vec![]);
2507        let executor = MockToolExecutor::no_tools();
2508        let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
2509        let mut mgr = zeph_subagent::SubAgentManager::new(4);
2510        mgr.definitions_mut().push(def);
2511        agent.services.orchestration.subagent_manager = Some(mgr);
2512        agent
2513    }
2514
2515    fn agent_with_skill_registry_and_helper_def(registry: SkillRegistry) -> Agent<MockChannel> {
2516        agent_with_skill_registry_and_def(registry, subagent_def("helper"))
2517    }
2518
2519    #[tokio::test]
2520    async fn filtered_skills_for_under_budget_returns_all_bodies_no_marker() {
2521        // Default `SkillFilter` (empty include/exclude) inherits every registry skill —
2522        // the #6421 scenario — but the budget here is generous enough that nothing is cut.
2523        let (registry, _temp_dir) = registry_with_skills(3, 20);
2524        let mut agent = agent_with_skill_registry_and_helper_def(registry);
2525        agent.services.skill.subagent_skill_token_budget = 1_000_000;
2526
2527        let bodies = agent
2528            .filtered_skills_for("helper")
2529            .await
2530            .expect("3 skills with a huge budget must return Some");
2531
2532        assert_eq!(
2533            bodies.len(),
2534            3,
2535            "no truncation marker expected when everything fits under budget"
2536        );
2537        for body in &bodies {
2538            assert!(
2539                body.contains("lorem"),
2540                "every returned entry must be a real skill body, not a marker: {body}"
2541            );
2542        }
2543    }
2544
2545    #[tokio::test]
2546    async fn filtered_skills_for_over_budget_truncates_with_marker() {
2547        // 5 skills, each with a large body; a tiny budget forces truncation well before the
2548        // full set is accumulated.
2549        let (registry, _temp_dir) = registry_with_skills(5, 500);
2550        let mut agent = agent_with_skill_registry_and_helper_def(registry);
2551        agent.services.skill.subagent_skill_token_budget = 10;
2552
2553        let bodies = agent
2554            .filtered_skills_for("helper")
2555            .await
2556            .expect("at least the first skill must always be included");
2557
2558        let marker_count = bodies
2559            .iter()
2560            .filter(|b| b.starts_with("[skill budget:"))
2561            .count();
2562        assert_eq!(
2563            marker_count, 1,
2564            "exactly one truncation marker entry must be appended, got bodies: {bodies:?}"
2565        );
2566        let marker = bodies
2567            .iter()
2568            .find(|b| b.starts_with("[skill budget:"))
2569            .unwrap();
2570        let included = bodies.len() - 1;
2571        assert!(
2572            included < 5,
2573            "budget=10 tokens must not fit all 5 large skills, included={included}"
2574        );
2575        assert!(
2576            included >= 1,
2577            "the first skill must always be included even when it alone exceeds the budget, \
2578             got included={included}"
2579        );
2580        assert!(
2581            bodies[0].contains("lorem"),
2582            "the always-included first entry must be a real skill body, not the marker: {}",
2583            bodies[0]
2584        );
2585        assert!(
2586            marker.contains(&format!("{included}/5 skills included")),
2587            "marker must report the correct included/total count: {marker}"
2588        );
2589        assert!(
2590            marker.contains("budget=10 tokens"),
2591            "marker must report the configured budget: {marker}"
2592        );
2593    }
2594
2595    #[tokio::test]
2596    async fn filtered_skills_for_mid_budget_greedily_fills_multiple_fitting_skills() {
2597        // 5 identical-body skills so each costs exactly the same token count T (computed via the
2598        // same TokenCounter the fix uses). A budget of `2*T + 1` fits skill-0 and skill-1 exactly
2599        // (running total 2T <= budget) but not a 3rd (3T > budget) — this exercises the greedy
2600        // `running_tokens + skill_tokens > budget` accumulation for a *fitting* 2nd skill, not
2601        // just the always-included first one (S2: the over-budget test alone never reaches this
2602        // arithmetic since its budget is too small to fit even a 2nd skill).
2603        let (registry, _temp_dir) = registry_with_skills(5, 500);
2604        let single_body = "lorem ".repeat(500);
2605        let per_skill_tokens = zeph_memory::TokenCounter::new().count_tokens(&single_body);
2606        assert!(
2607            per_skill_tokens > 1,
2608            "test setup: per-skill token count must be large enough for 2*T+1 to exclude a 3rd \
2609             skill, got {per_skill_tokens}"
2610        );
2611
2612        let mut agent = agent_with_skill_registry_and_helper_def(registry);
2613        agent.services.skill.subagent_skill_token_budget = 2 * per_skill_tokens + 1;
2614
2615        let bodies = agent
2616            .filtered_skills_for("helper")
2617            .await
2618            .expect("at least the first skill must always be included");
2619
2620        let marker = bodies
2621            .iter()
2622            .find(|b| b.starts_with("[skill budget:"))
2623            .unwrap_or_else(|| panic!("expected a truncation marker, got bodies: {bodies:?}"));
2624        let included = bodies.len() - 1;
2625        assert_eq!(
2626            included, 2,
2627            "budget=2*T+1 must fit exactly 2 of the 5 identical-cost skills, got {included}"
2628        );
2629        assert!(
2630            marker.contains("2/5 skills included"),
2631            "marker must report the correct included/total count: {marker}"
2632        );
2633        // Registry order is alphabetical by skill directory name (skill-0..skill-4), so the
2634        // 2 included skills are skill-0/skill-1 and the 3 omitted are skill-2/3/4 — assert the
2635        // marker's omitted-name list matches exactly, not just the count (closes Gap 3).
2636        let omitted_segment = marker
2637            .split("omitted: ")
2638            .nth(1)
2639            .and_then(|s| s.strip_suffix(']'))
2640            .unwrap_or_else(|| panic!("marker missing 'omitted: ...]' segment: {marker}"));
2641        let mut omitted_names: Vec<&str> = omitted_segment.split(", ").collect();
2642        omitted_names.sort_unstable();
2643        assert_eq!(
2644            omitted_names,
2645            vec!["skill-2", "skill-3", "skill-4"],
2646            "marker must name exactly the 3 truncated skills, got marker: {marker}"
2647        );
2648    }
2649
2650    #[tokio::test]
2651    async fn filtered_skills_for_explicit_include_is_never_capped() {
2652        // S1 (scope decision): the budget cap applies only to the empty-include "inherit
2653        // everything" case #6421 is about. A definition with an explicit, hand-curated
2654        // `skills.include` list must be returned uncapped even when its total size would
2655        // otherwise exceed the configured budget — the operator opted into that set on purpose.
2656        use zeph_subagent::def::{SkillFilter, SubAgentPermissions, ToolPolicy};
2657        use zeph_subagent::hooks::SubagentHooks;
2658
2659        let (registry, _temp_dir) = registry_with_skills(5, 500);
2660        let def = zeph_subagent::SubAgentDef {
2661            name: "curated".to_owned(),
2662            description: "A curated helper".into(),
2663            model: None,
2664            tools: ToolPolicy::InheritAll,
2665            disallowed_tools: vec![],
2666            permissions: SubAgentPermissions::default(),
2667            skills: SkillFilter {
2668                include: vec!["skill-*".to_owned()],
2669                exclude: vec![],
2670            },
2671            system_prompt: "You are helpful.".into(),
2672            hooks: SubagentHooks::default(),
2673            memory: None,
2674            source: None,
2675            file_path: None,
2676        };
2677        let mut agent = agent_with_skill_registry_and_def(registry, def);
2678        // Budget far too small to fit all 5 skills — would definitely truncate the empty-include
2679        // path, but must have zero effect here.
2680        agent.services.skill.subagent_skill_token_budget = 10;
2681
2682        let bodies = agent
2683            .filtered_skills_for("curated")
2684            .await
2685            .expect("explicit include must still match all 5 skill-* skills");
2686
2687        assert_eq!(
2688            bodies.len(),
2689            5,
2690            "explicit include list must never be truncated by the budget, got: {bodies:?}"
2691        );
2692        assert!(
2693            bodies.iter().all(|b| b.contains("lorem")),
2694            "every entry must be a real skill body, not a truncation marker: {bodies:?}"
2695        );
2696    }
2697
2698    /// In-memory SQLite-backed `SemanticMemory` for tests exercising `build_skill_trust_map`'s
2699    /// real DB read path (mirrors `trust_commands::tests::test_memory`).
2700    async fn test_memory_for_trust() -> Arc<zeph_memory::semantic::SemanticMemory> {
2701        let provider = zeph_llm::any::AnyProvider::Mock(zeph_llm::mock::MockProvider::default());
2702        Arc::new(
2703            zeph_memory::semantic::SemanticMemory::new(
2704                ":memory:",
2705                "http://127.0.0.1:1",
2706                None,
2707                provider,
2708                "test-model",
2709            )
2710            .await
2711            .unwrap(),
2712        )
2713    }
2714
2715    /// End-to-end regression for #6713: `filter_skills` (called via `filtered_skills_for`)
2716    /// previously had no trust filtering at all, so a Quarantined or Blocked skill's body was
2717    /// injected directly into a freshly-spawned sub-agent's system prompt unconditionally.
2718    ///
2719    /// Trust rows are persisted through a real (in-memory) `SemanticMemory` store rather than
2720    /// written directly to `trust_snapshot`, because `filtered_skills_for` now resolves the
2721    /// trust map fresh from the store on every call (#6713 S1) — writing straight to the cache
2722    /// would just be clobbered by that fresh load.
2723    #[tokio::test]
2724    async fn filtered_skills_for_excludes_quarantined_and_blocked_skill_bodies() {
2725        let temp_dir = tempfile::tempdir().unwrap();
2726        for (name, body) in [
2727            ("trusted-skill", "TRUSTED_BODY_MARKER"),
2728            ("quarantined-skill", "QUARANTINED_BODY_MARKER"),
2729            ("blocked-skill", "BLOCKED_BODY_MARKER"),
2730        ] {
2731            let skill_dir = temp_dir.path().join(name);
2732            std::fs::create_dir(&skill_dir).unwrap();
2733            std::fs::write(
2734                skill_dir.join("SKILL.md"),
2735                format!("---\nname: {name}\ndescription: test skill\n---\n{body}"),
2736            )
2737            .unwrap();
2738        }
2739        let registry = SkillRegistry::load(&[temp_dir.path().to_path_buf()]);
2740        let memory = test_memory_for_trust().await;
2741        memory
2742            .sqlite()
2743            .upsert_skill_trust(
2744                "quarantined-skill",
2745                zeph_common::SkillTrustLevel::Quarantined,
2746                zeph_memory::store::SourceKind::Local,
2747                None,
2748                None,
2749                "hash-quarantined",
2750            )
2751            .await
2752            .unwrap();
2753        memory
2754            .sqlite()
2755            .upsert_skill_trust(
2756                "blocked-skill",
2757                zeph_common::SkillTrustLevel::Blocked,
2758                zeph_memory::store::SourceKind::Local,
2759                None,
2760                None,
2761                "hash-blocked",
2762            )
2763            .await
2764            .unwrap();
2765        let mut agent = agent_with_skill_registry_and_helper_def(registry).with_memory(
2766            memory,
2767            zeph_memory::ConversationId(1),
2768            50,
2769            5,
2770            50,
2771        );
2772        agent.services.skill.subagent_skill_token_budget = 1_000_000;
2773
2774        let bodies = agent
2775            .filtered_skills_for("helper")
2776            .await
2777            .expect("the Trusted skill alone must still be returned");
2778
2779        assert_eq!(
2780            bodies.len(),
2781            1,
2782            "only the Trusted skill's body may be injected, got: {bodies:?}"
2783        );
2784        assert!(bodies[0].contains("TRUSTED_BODY_MARKER"));
2785        assert!(
2786            !bodies.iter().any(|b| b.contains("QUARANTINED_BODY_MARKER")
2787                || b.contains("BLOCKED_BODY_MARKER")),
2788            "Quarantined and Blocked skill bodies must never be injected, got: {bodies:?}"
2789        );
2790    }
2791}