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