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_tools::registry::ToolDef;
14
15use super::{Agent, error};
16use crate::channel::Channel;
17
18impl<C: Channel> Agent<C> {
19    /// Resolve a sub-agent's requested vault-secret key against the custom secrets already
20    /// resolved from the vault at startup (`ZEPH_SECRET_<NAME>` keys — the same pre-resolved
21    /// map used for skill `requires_secrets` injection, see `tool_execution::inject_active_skill_env`).
22    ///
23    /// Matching is case-insensitive with `-` normalized to `_`, mirroring the vault-key
24    /// naming convention (`ZEPH_SECRET_MY-KEY` and `ZEPH_SECRET_MY_KEY` both resolve to
25    /// `my_key`). Returns `None` when `key` was never resolved from the vault at startup.
26    pub(crate) fn resolve_subagent_secret(&self, key: &str) -> Option<crate::vault::Secret> {
27        let normalized = key.to_lowercase().replace('-', "_");
28        self.services
29            .skill
30            .available_custom_secrets
31            .get(&normalized)
32            .map(|s| crate::vault::Secret::new(s.expose().to_owned()))
33    }
34
35    /// Poll all active sub-agents for completed/failed/canceled results.
36    ///
37    /// Non-blocking: returns immediately with a list of `(task_id, result)` pairs
38    /// for agents that have finished. Each completed agent is removed from the manager.
39    #[tracing::instrument(name = "core.agent.poll_subagents", skip_all, level = "debug")]
40    pub async fn poll_subagents(&mut self) -> Vec<(String, String)> {
41        let Some(mgr) = &mut self.services.orchestration.subagent_manager else {
42            return vec![];
43        };
44
45        let finished: Vec<String> = mgr
46            .statuses()
47            .into_iter()
48            .filter_map(|(id, status)| {
49                if matches!(
50                    status.state,
51                    zeph_subagent::SubAgentState::Completed
52                        | zeph_subagent::SubAgentState::Failed
53                        | zeph_subagent::SubAgentState::Canceled
54                ) {
55                    Some(id)
56                } else {
57                    None
58                }
59            })
60            .collect();
61
62        let mut results = vec![];
63        for task_id in finished {
64            match mgr.collect(&task_id).await {
65                Ok(result) => results.push((task_id, result)),
66                Err(e) => {
67                    tracing::warn!(task_id, error = %e, "failed to collect sub-agent result");
68                }
69            }
70        }
71        results
72    }
73    /// Run the chat loop, receiving messages via the channel until EOF or shutdown.
74    ///
75    /// # Errors
76    ///
77    /// Returns an error if channel I/O or LLM communication fails.
78    /// Refresh sub-agent metrics snapshot for the TUI metrics panel.
79    pub(super) fn refresh_subagent_metrics(&mut self) {
80        let Some(ref mgr) = self.services.orchestration.subagent_manager else {
81            return;
82        };
83        let sub_agent_metrics: Vec<crate::metrics::SubAgentMetrics> = mgr
84            .statuses()
85            .into_iter()
86            .map(|(id, s)| {
87                let def = mgr.agents_def(&id);
88                crate::metrics::SubAgentMetrics {
89                    name: def.map_or_else(|| id[..8.min(id.len())].to_owned(), |d| d.name.clone()),
90                    id: id.clone(),
91                    state: format!("{:?}", s.state).to_lowercase(),
92                    turns_used: s.turns_used,
93                    max_turns: def.map_or(20, |d| d.permissions.max_turns),
94                    background: def.is_some_and(|d| d.permissions.background),
95                    elapsed_secs: s.started_at.elapsed().as_secs(),
96                    permission_mode: def.map_or_else(String::new, |d| {
97                        use zeph_subagent::def::PermissionMode;
98                        match d.permissions.permission_mode {
99                            PermissionMode::AcceptEdits => "accept_edits".into(),
100                            PermissionMode::DontAsk => "dont_ask".into(),
101                            PermissionMode::BypassPermissions => "bypass_permissions".into(),
102                            PermissionMode::Plan => "plan".into(),
103                            _ => String::new(),
104                        }
105                    }),
106                    transcript_dir: mgr
107                        .agent_transcript_dir(&id)
108                        .map(|p| p.to_string_lossy().into_owned()),
109                }
110            })
111            .collect();
112        self.update_metrics(|m| m.sub_agents = sub_agent_metrics);
113    }
114    /// Non-blocking poll: notify the user when background sub-agents complete.
115    pub(super) async fn notify_completed_subagents(&mut self) -> Result<(), error::AgentError> {
116        let completed = self.poll_subagents().await;
117        for (task_id, result) in completed {
118            let notice = if result.is_empty() {
119                format!("[sub-agent {id}] completed (no output)", id = &task_id[..8])
120            } else {
121                format!("[sub-agent {id}] completed:\n{result}", id = &task_id[..8])
122            };
123            if let Err(e) = self.channel.send(&notice).await {
124                tracing::warn!(error = %e, "failed to send sub-agent completion notice");
125            }
126        }
127        Ok(())
128    }
129    /// Poll a sub-agent until it reaches a terminal state, bridging secret requests to the
130    /// channel. Returns a human-readable status string and success flag suitable for
131    /// sending to the user and emitting lifecycle events.
132    async fn poll_subagent_until_done(
133        &mut self,
134        task_id: &str,
135        label: &str,
136    ) -> Option<(String, bool)> {
137        use zeph_subagent::SubAgentState;
138        let result = loop {
139            tokio::time::sleep(std::time::Duration::from_millis(500)).await;
140
141            // Bridge secret requests from sub-agent to channel.confirm().
142            // Fetch the pending request first, then release the borrow before
143            // calling channel.confirm() (which requires &mut self).
144            #[allow(clippy::redundant_closure_for_method_calls)]
145            let pending = self
146                .services
147                .orchestration
148                .subagent_manager
149                .as_mut()
150                .and_then(|m| m.try_recv_secret_request());
151            if let Some((req_task_id, req)) = pending {
152                // req.secret_key is pre-validated to [a-zA-Z0-9_-] in manager.rs
153                // (SEC-P1-02), so it is safe to embed in the prompt string.
154                let confirm_prompt = format!(
155                    "Sub-agent requests secret '{}'. Allow?",
156                    crate::text::truncate_to_chars(&req.secret_key, 100)
157                );
158                let approved = self.channel.confirm(&confirm_prompt).await.unwrap_or(false);
159                if approved {
160                    let ttl = std::time::Duration::from_mins(5);
161                    let key = req.secret_key.clone();
162                    let resolved = self.resolve_subagent_secret(&key);
163                    if let Some(mgr) = self.services.orchestration.subagent_manager.as_mut() {
164                        if let Some(secret) = resolved {
165                            if mgr.approve_secret(&req_task_id, &key, ttl).is_ok()
166                                && let Err(e) = mgr.deliver_secret(&req_task_id, &key, secret)
167                            {
168                                tracing::warn!(error = %e, "sub-agent secret delivery failed");
169                                let _ = mgr.deny_secret(&req_task_id);
170                            }
171                        } else {
172                            tracing::warn!(
173                                "sub-agent requested secret not resolvable from vault; denying"
174                            );
175                            let _ = mgr.deny_secret(&req_task_id);
176                        }
177                    }
178                } else if let Some(mgr) = self.services.orchestration.subagent_manager.as_mut() {
179                    let _ = mgr.deny_secret(&req_task_id);
180                }
181            }
182
183            let mgr = self.services.orchestration.subagent_manager.as_ref()?;
184            let statuses = mgr.statuses();
185            let Some((_, status)) = statuses.iter().find(|(id, _)| id == task_id) else {
186                break (format!("{label} completed (no status available)."), true);
187            };
188            match status.state {
189                SubAgentState::Completed => {
190                    let msg = status.last_message.clone().unwrap_or_else(|| "done".into());
191                    break (format!("{label} completed: {msg}"), true);
192                }
193                SubAgentState::Failed => {
194                    let msg = status
195                        .last_message
196                        .clone()
197                        .unwrap_or_else(|| "unknown error".into());
198                    break (format!("{label} failed: {msg}"), false);
199                }
200                SubAgentState::Canceled => {
201                    break (format!("{label} was cancelled."), false);
202                }
203                _ => {
204                    self.channel
205                        .send_status_best_effort(&format!(
206                            "{label}: turn {}/{}",
207                            status.turns_used,
208                            self.services
209                                .orchestration
210                                .subagent_manager
211                                .as_ref()
212                                .and_then(|m| m.agents_def(task_id))
213                                .map_or(20, |d| d.permissions.max_turns)
214                        ))
215                        .await;
216                }
217            }
218        };
219        Some(result)
220    }
221    /// Resolve a unique full `task_id` from a prefix. Returns `None` if the manager is absent,
222    /// `Some(Err(msg))` on ambiguity/not-found, `Some(Ok(full_id))` on success.
223    fn resolve_agent_id_prefix(&mut self, prefix: &str) -> Option<Result<String, String>> {
224        let mgr = self.services.orchestration.subagent_manager.as_mut()?;
225        let full_ids: Vec<String> = mgr
226            .statuses()
227            .into_iter()
228            .map(|(tid, _)| tid)
229            .filter(|tid| tid.starts_with(prefix))
230            .collect();
231        Some(match full_ids.as_slice() {
232            [] => Err(format!("No sub-agent with id prefix '{prefix}'")),
233            [fid] => Ok(fid.clone()),
234            _ => Err(format!(
235                "Ambiguous id prefix '{prefix}': matches {} agents",
236                full_ids.len()
237            )),
238        })
239    }
240    fn handle_agent_list(&self) -> Option<String> {
241        use std::fmt::Write as _;
242        let mgr = self.services.orchestration.subagent_manager.as_ref()?;
243        let defs = mgr.definitions();
244        if defs.is_empty() {
245            return Some("No sub-agent definitions found.".into());
246        }
247        let mut out = String::from("Available sub-agents:\n");
248        for d in defs {
249            let memory_label = match d.memory {
250                Some(zeph_subagent::MemoryScope::User) => " [memory:user]",
251                Some(zeph_subagent::MemoryScope::Project) => " [memory:project]",
252                Some(zeph_subagent::MemoryScope::Local) => " [memory:local]",
253                Some(_) => " [memory:unknown]",
254                None => "",
255            };
256            if let Some(ref src) = d.source {
257                let _ = writeln!(
258                    out,
259                    "  {}{} — {} ({})",
260                    d.name, memory_label, d.description, src
261                );
262            } else {
263                let _ = writeln!(out, "  {}{} — {}", d.name, memory_label, d.description);
264            }
265        }
266        Some(out)
267    }
268    fn handle_agent_status(&self) -> Option<String> {
269        use std::fmt::Write as _;
270        let mgr = self.services.orchestration.subagent_manager.as_ref()?;
271        let statuses = mgr.statuses();
272        if statuses.is_empty() {
273            return Some("No active sub-agents.".into());
274        }
275        let mut out = String::from("Active sub-agents:\n");
276        for (id, s) in &statuses {
277            let state = format!("{:?}", s.state).to_lowercase();
278            let elapsed = s.started_at.elapsed().as_secs();
279            let _ = writeln!(
280                out,
281                "  [{short}] {state}  turns={t}  elapsed={elapsed}s  {msg}",
282                short = &id[..8.min(id.len())],
283                t = s.turns_used,
284                msg = s.last_message.as_deref().unwrap_or(""),
285            );
286            // Show memory directory path for agents with memory enabled.
287            if let Some(def) = mgr.agents_def(id)
288                && let Some(scope) = def.memory
289                && let Ok(dir) = zeph_subagent::memory::resolve_memory_dir(scope, &def.name)
290            {
291                let _ = writeln!(out, "       memory: {}", dir.display());
292            }
293        }
294        Some(out)
295    }
296    fn handle_agent_approve(&mut self, id: &str) -> Option<String> {
297        let full_id = match self.resolve_agent_id_prefix(id)? {
298            Ok(fid) => fid,
299            Err(msg) => return Some(msg),
300        };
301        let req = {
302            let mgr = self.services.orchestration.subagent_manager.as_mut()?;
303            mgr.try_recv_secret_request_for(&full_id)
304        };
305        let Some(req) = req else {
306            return Some(format!(
307                "No pending secret request for sub-agent '{full_id}'."
308            ));
309        };
310        let key = req.secret_key.clone();
311        let ttl = std::time::Duration::from_mins(5);
312        let Some(secret) = self.resolve_subagent_secret(&key) else {
313            let mgr = self.services.orchestration.subagent_manager.as_mut()?;
314            let _ = mgr.deny_secret(&full_id);
315            return Some(format!(
316                "Secret '{key}' could not be resolved from the vault; request denied."
317            ));
318        };
319        let mgr = self.services.orchestration.subagent_manager.as_mut()?;
320        if let Err(e) = mgr.approve_secret(&full_id, &key, ttl) {
321            return Some(format!("Approve failed: {e}"));
322        }
323        if let Err(e) = mgr.deliver_secret(&full_id, &key, secret) {
324            let _ = mgr.deny_secret(&full_id);
325            return Some(format!("Secret delivery failed: {e}"));
326        }
327        Some(format!("Secret '{key}' approved for sub-agent {full_id}."))
328    }
329    fn handle_agent_deny(&mut self, id: &str) -> Option<String> {
330        let full_id = match self.resolve_agent_id_prefix(id)? {
331            Ok(fid) => fid,
332            Err(msg) => return Some(msg),
333        };
334        let mgr = self.services.orchestration.subagent_manager.as_mut()?;
335        match mgr.deny_secret(&full_id) {
336            Ok(()) => Some(format!("Secret request denied for sub-agent '{full_id}'.")),
337            Err(e) => Some(format!("Deny failed: {e}")),
338        }
339    }
340    pub(super) async fn handle_agent_command(
341        &mut self,
342        cmd: zeph_subagent::AgentCommand,
343    ) -> Option<String> {
344        use zeph_subagent::AgentCommand;
345
346        match cmd {
347            AgentCommand::List => self.handle_agent_list(),
348            AgentCommand::Background { name, prompt } => {
349                self.handle_agent_background(&name, &prompt).await
350            }
351            AgentCommand::Spawn { name, prompt }
352            | AgentCommand::Mention {
353                agent: name,
354                prompt,
355            } => self.handle_agent_spawn_foreground(&name, &prompt).await,
356            AgentCommand::Status => self.handle_agent_status(),
357            AgentCommand::Cancel { id } => self.handle_agent_cancel(&id),
358            AgentCommand::Approve { id } => self.handle_agent_approve(&id),
359            AgentCommand::Deny { id } => self.handle_agent_deny(&id),
360            AgentCommand::Resume { id, prompt } => self.handle_agent_resume(&id, &prompt).await,
361            _ => None,
362        }
363    }
364    /// Return the sub-agent definitions section formatted for the `/agents` fleet view.
365    ///
366    /// Produces a "Sub-agents:" header followed by one line per definition.
367    /// Returns an empty string when no sub-agent manager is configured.
368    pub(crate) fn handle_agents_definitions_list(&self) -> String {
369        use std::fmt::Write as _;
370
371        let Some(mgr) = self.services.orchestration.subagent_manager.as_ref() else {
372            return String::new();
373        };
374        let defs = mgr.definitions();
375        if defs.is_empty() {
376            return String::new();
377        }
378        let mut out = String::from("Sub-agents:\n");
379        for d in defs {
380            let memory_label = match d.memory {
381                Some(zeph_subagent::MemoryScope::User) => " [memory:user]",
382                Some(zeph_subagent::MemoryScope::Project) => " [memory:project]",
383                Some(zeph_subagent::MemoryScope::Local) => " [memory:local]",
384                Some(_) => " [memory:unknown]",
385                None => "",
386            };
387            if let Some(ref src) = d.source {
388                let _ = writeln!(
389                    out,
390                    "  {}{} — {} ({})",
391                    d.name, memory_label, d.description, src
392                );
393            } else {
394                let _ = writeln!(out, "  {}{} — {}", d.name, memory_label, d.description);
395            }
396        }
397        out
398    }
399    /// Execute an `/agents` CRUD subcommand and return a formatted string.
400    ///
401    /// Handles `show`, `create`, `edit`, `delete` (the `list` case is handled by
402    /// [`handle_agents_definitions_list`] and never reaches this method).
403    pub(crate) fn handle_agents_crud(&mut self, cmd: zeph_subagent::AgentsCommand) -> String {
404        use zeph_subagent::AgentsCommand;
405
406        let Some(mgr) = self.services.orchestration.subagent_manager.as_ref() else {
407            return "Sub-agent manager is not available.".to_owned();
408        };
409
410        match cmd {
411            AgentsCommand::List => self.handle_agents_definitions_list(),
412            AgentsCommand::Show { name } => {
413                match mgr.definitions().iter().find(|d| d.name == name) {
414                    Some(d) => format!(
415                        "Agent: {}\nDescription: {}\nSource: {}\n",
416                        d.name,
417                        d.description,
418                        d.source.as_deref().unwrap_or("unknown"),
419                    ),
420                    None => format!("No sub-agent definition named '{name}'."),
421                }
422            }
423            AgentsCommand::Create { name } => {
424                format!(
425                    "To create a sub-agent definition, create a file at `.zeph/agents/{name}.md`.\n\
426                     See the sub-agent documentation for the required frontmatter."
427                )
428            }
429            AgentsCommand::Edit { name } => {
430                format!("To edit '{name}', open its definition file in `.zeph/agents/{name}.md`.")
431            }
432            AgentsCommand::Delete { name } => {
433                format!("To delete '{name}', remove the file `.zeph/agents/{name}.md`.")
434            }
435            _ => "Unknown agents command.".to_owned(),
436        }
437    }
438    async fn handle_agent_background(&mut self, name: &str, prompt: &str) -> Option<String> {
439        let provider = self.provider.clone();
440        let tool_executor = Arc::clone(&self.tool_executor);
441        let skills = self.filtered_skills_for(name);
442        let cfg = self.services.orchestration.subagent_config.clone();
443        let mut spawn_ctx = self.build_spawn_context(&cfg);
444        // Background durable: seat wired so child can resolve; on a fresh run the promise
445        // (await side) is dropped — background results are collected via poll_subagents. On a
446        // resumed run whose child already finished, replay short-circuits below instead.
447        self.ensure_session_durable_ctx().await;
448        match resolve_durable_spawn_gate(
449            self.services.session.durable_subagent,
450            self.services.session.durable_ctx.as_deref(),
451        )
452        .await
453        {
454            DurableSpawnGate::Fresh(seat) => spawn_ctx.durable_resolver = Some(seat),
455            DurableSpawnGate::Replayed { result, .. } => {
456                let short = &result.task_id[..8.min(result.task_id.len())];
457                return Some(if result.output.is_empty() {
458                    format!(
459                        "[sub-agent {short}] completed (no output, replayed from durable journal)"
460                    )
461                } else {
462                    format!(
463                        "[sub-agent {short}] completed (replayed from durable journal):\n{}",
464                        result.output
465                    )
466                });
467            }
468            DurableSpawnGate::None => {}
469        }
470        let mgr = self.services.orchestration.subagent_manager.as_mut()?;
471        match mgr
472            .spawn(
473                name,
474                prompt,
475                provider,
476                tool_executor,
477                skills,
478                &cfg,
479                spawn_ctx,
480            )
481            .await
482        {
483            Ok(id) => Some(format!(
484                "Sub-agent '{name}' started in background (id: {short})",
485                short = &id[..8.min(id.len())]
486            )),
487            Err(e) => Some(format!("Failed to spawn sub-agent: {e}")),
488        }
489    }
490    /// Handle a [`DurableSpawnGate::Replayed`] result for a foreground spawn.
491    ///
492    /// Gates the channel side effects (user notice + TUI completion event) behind an
493    /// out-of-band `notified_at` claim on the sub-agent's durable promise, so a parent that
494    /// restarts *again* after already taking the replay branch once does not re-fire them
495    /// (#6027). The claim consumes no durable step id, so unlike a `ctx.step()`-based guard it
496    /// cannot perturb INV-2 step-id determinism or cause `ReplayDivergence`. Returns the
497    /// journaled output/error text either way.
498    async fn notify_replayed_foreground_subagent(
499        &mut self,
500        name: &str,
501        result: zeph_subagent::SubagentResult,
502        promise_id: zeph_durable::PromiseId,
503    ) -> String {
504        let success = result.state == zeph_subagent::SubAgentState::Completed;
505        let task_id = result.task_id.clone();
506
507        // Out-of-band, step-counter-independent claim: the FIRST caller to set `notified_at` fires
508        // the channel side effects; every later replay is suppressed. Unlike a ctx.step this consumes
509        // no StepId, so it cannot cause ReplayDivergence under any restart count (#6027). Degrade to
510        // firing directly when durable is off (no replay can happen) or the claim errors.
511        let should_notify = if let Some(ctx) = self.services.session.durable_ctx.clone() {
512            match ctx.claim_promise_notification(promise_id).await {
513                Ok(claimed) => claimed,
514                Err(e) => {
515                    tracing::warn!(
516                        error = %e,
517                        "durable: promise-notification claim failed; \
518                         firing the replayed sub-agent notice directly"
519                    );
520                    true
521                }
522            }
523        } else {
524            true
525        };
526
527        let text = if success {
528            result.output
529        } else {
530            result.error.unwrap_or_else(|| "unknown error".to_owned())
531        };
532
533        if should_notify {
534            let _ = self
535                .channel
536                .send(&format!(
537                    "Sub-agent '{name}' replayed from durable journal (already finished \
538                     before the parent restarted)."
539                ))
540                .await;
541            let _ = self
542                .channel
543                .notify_foreground_subagent_completed(&task_id, name, success)
544                .await;
545        }
546        text
547    }
548
549    async fn handle_agent_spawn_foreground(&mut self, name: &str, prompt: &str) -> Option<String> {
550        let provider = self.provider.clone();
551        let tool_executor = Arc::clone(&self.tool_executor);
552        let skills = self.filtered_skills_for(name);
553        let cfg = self.services.orchestration.subagent_config.clone();
554        let mut spawn_ctx = self.build_spawn_context(&cfg);
555        // Wire the durable resolver seat so the child can resolve its promise on exit. On a
556        // fresh run the promise (await side) is dropped here; foreground result is collected
557        // via poll_subagent_until_done which reads the join-handle output directly. On a
558        // resumed run whose child already finished, replay short-circuits below instead.
559        self.ensure_session_durable_ctx().await;
560        match resolve_durable_spawn_gate(
561            self.services.session.durable_subagent,
562            self.services.session.durable_ctx.as_deref(),
563        )
564        .await
565        {
566            DurableSpawnGate::Fresh(seat) => spawn_ctx.durable_resolver = Some(seat),
567            DurableSpawnGate::Replayed { result, promise_id } => {
568                return Some(
569                    self.notify_replayed_foreground_subagent(name, result, promise_id)
570                        .await,
571                );
572            }
573            DurableSpawnGate::None => {}
574        }
575        let mgr = self.services.orchestration.subagent_manager.as_mut()?;
576        let task_id = match mgr
577            .spawn(
578                name,
579                prompt,
580                provider,
581                tool_executor,
582                skills,
583                &cfg,
584                spawn_ctx,
585            )
586            .await
587        {
588            Ok(id) => id,
589            Err(e) => return Some(format!("Failed to spawn sub-agent: {e}")),
590        };
591        let short = task_id[..8.min(task_id.len())].to_owned();
592        let _ = self
593            .channel
594            .send(&format!("Sub-agent '{name}' running... (id: {short})"))
595            .await;
596        let _ = self
597            .channel
598            .notify_foreground_subagent_started(&task_id, name)
599            .await;
600        let label = format!("Sub-agent '{name}'");
601        let Some((result, success)) = self.poll_subagent_until_done(&task_id, &label).await else {
602            // Manager was dropped mid-poll; emit completed(false) so TUI does not stay stuck.
603            let _ = self
604                .channel
605                .notify_foreground_subagent_completed(&task_id, name, false)
606                .await;
607            return None;
608        };
609        let _ = self
610            .channel
611            .notify_foreground_subagent_completed(&task_id, name, success)
612            .await;
613        Some(result)
614    }
615    fn handle_agent_cancel(&mut self, id: &str) -> Option<String> {
616        let mgr = self.services.orchestration.subagent_manager.as_mut()?;
617        // Accept prefix match on task_id.
618        let ids: Vec<String> = mgr
619            .statuses()
620            .into_iter()
621            .map(|(task_id, _)| task_id)
622            .filter(|task_id| task_id.starts_with(id))
623            .collect();
624        match ids.as_slice() {
625            [] => Some(format!("No sub-agent with id prefix '{id}'")),
626            [full_id] => {
627                let full_id = full_id.clone();
628                match mgr.cancel(&full_id) {
629                    Ok(()) => Some(format!("Cancelled sub-agent {full_id}.")),
630                    Err(e) => Some(format!("Cancel failed: {e}")),
631                }
632            }
633            _ => Some(format!(
634                "Ambiguous id prefix '{id}': matches {} agents",
635                ids.len()
636            )),
637        }
638    }
639    async fn handle_agent_resume(&mut self, id: &str, prompt: &str) -> Option<String> {
640        let cfg = self.services.orchestration.subagent_config.clone();
641        // Resolve definition name from transcript meta before spawning so we can
642        // look up skills by definition name rather than the UUID prefix (S1 fix).
643        let def_name = {
644            let mgr = self.services.orchestration.subagent_manager.as_ref()?;
645            match mgr.def_name_for_resume(id, &cfg).await {
646                Ok(name) => name,
647                Err(e) => return Some(format!("Failed to resume sub-agent: {e}")),
648            }
649        };
650        let skills = self.filtered_skills_for(&def_name);
651        let provider = self.provider.clone();
652        let tool_executor = Arc::clone(&self.tool_executor);
653        let mgr = self.services.orchestration.subagent_manager.as_mut()?;
654        let (task_id, _) = match mgr
655            .resume(id, prompt, provider, tool_executor, skills, &cfg, None)
656            .await
657        {
658            Ok(pair) => pair,
659            Err(e) => return Some(format!("Failed to resume sub-agent: {e}")),
660        };
661        let short = task_id[..8.min(task_id.len())].to_owned();
662        let _ = self
663            .channel
664            .send(&format!("Resuming sub-agent '{id}'... (new id: {short})"))
665            .await;
666        let _ = self
667            .channel
668            .notify_foreground_subagent_started(&task_id, &def_name)
669            .await;
670        let Some((result, success)) = self
671            .poll_subagent_until_done(&task_id, "Resumed sub-agent")
672            .await
673        else {
674            // Manager was dropped mid-poll; emit completed(false) so TUI does not stay stuck.
675            let _ = self
676                .channel
677                .notify_foreground_subagent_completed(&task_id, &def_name, false)
678                .await;
679            return None;
680        };
681        let _ = self
682            .channel
683            .notify_foreground_subagent_completed(&task_id, &def_name, success)
684            .await;
685        Some(result)
686    }
687    pub(super) fn filtered_skills_for(&self, agent_name: &str) -> Option<Vec<String>> {
688        let mgr = self.services.orchestration.subagent_manager.as_ref()?;
689        let def = mgr.definitions().iter().find(|d| d.name == agent_name)?;
690        let reg = self.services.skill.registry.read();
691        match zeph_subagent::filter_skills(&reg, &def.skills) {
692            Ok(skills) => {
693                let bodies: Vec<String> = skills.into_iter().map(|s| s.body.clone()).collect();
694                if bodies.is_empty() {
695                    None
696                } else {
697                    Some(bodies)
698                }
699            }
700            Err(e) => {
701                tracing::warn!(error = %e, "skill filtering failed for sub-agent");
702                None
703            }
704        }
705    }
706    /// Build a `SpawnContext` from current agent state for sub-agent spawning.
707    pub(super) fn build_spawn_context(
708        &self,
709        cfg: &zeph_config::SubAgentConfig,
710    ) -> zeph_subagent::SpawnContext {
711        zeph_subagent::SpawnContext {
712            parent_messages: self.extract_parent_messages(cfg),
713            parent_cancel: Some(self.runtime.lifecycle.cancel_token.clone()),
714            parent_provider_name: {
715                let name = &self.runtime.config.active_provider_name;
716                if name.is_empty() {
717                    None
718                } else {
719                    Some(name.clone())
720                }
721            },
722            spawn_depth: self.runtime.config.spawn_depth,
723            mcp_tool_names: self.extract_mcp_tool_names(),
724            // F3 spec 050 §4: propagate seeded score when parent is >= Elevated.
725            seed_trajectory_score: {
726                let child = self.services.security.trajectory.spawn_child();
727                let score = child.score_now();
728                if score > 0.0 { Some(score) } else { None }
729            },
730            content_isolation: self.runtime.config.security.content_isolation.clone(),
731            orchestrator_name: Some("zeph".to_owned()),
732            orchestrator_role: Some("orchestrator".to_owned()),
733            session_mcp_servers: Vec::new(),
734            // Constraint propagation (#3993): populated by orchestration layer when spawning
735            // with explicit trust/tool restrictions. Top-level agent sessions leave these None.
736            ..Default::default()
737        }
738    }
739    /// Extract recent parent messages for history propagation (Section 5.7 in spec).
740    ///
741    /// Filters system messages, applies `context_window_turns` and `max_parent_messages` caps,
742    /// applies a 25% context window cap using a 4-chars-per-token heuristic, prunes orphaned
743    /// `ToolUse`/`ToolResult` pairs at the slice boundary, and optionally sanitizes text parts
744    /// through the IPI pipeline according to `parent_context_policy`.
745    fn extract_parent_messages(
746        &self,
747        config: &zeph_config::SubAgentConfig,
748    ) -> Vec<zeph_llm::provider::Message> {
749        use zeph_config::ParentContextPolicy;
750        use zeph_llm::provider::Role;
751
752        if config.parent_context_policy == ParentContextPolicy::None
753            || config.context_window_turns == 0
754        {
755            return Vec::new();
756        }
757
758        let non_system: Vec<_> = self
759            .msg
760            .messages
761            .iter()
762            .filter(|m| m.role != Role::System)
763            .cloned()
764            .collect();
765
766        let take_count = config
767            .context_window_turns
768            .saturating_mul(2)
769            .min(config.max_parent_messages);
770        let start = non_system.len().saturating_sub(take_count);
771        let mut msgs = non_system[start..].to_vec();
772
773        // Cap at 25% of model context window and prune orphaned tool pairs.
774        let max_chars = 128_000usize / 4;
775        let requested = msgs.len();
776        trim_parent_messages(&mut msgs, max_chars);
777        if msgs.len() < requested {
778            tracing::info!(
779                kept = msgs.len(),
780                requested,
781                "[subagent] truncated parent history due to token budget or orphan pruning"
782            );
783        }
784
785        if config.parent_context_policy == ParentContextPolicy::InheritSanitized {
786            use zeph_sanitizer::{ContentSource, ContentSourceKind};
787            let source =
788                ContentSource::new(ContentSourceKind::A2aMessage).with_identifier("parent_history");
789            msgs = sanitize_parent_messages(msgs, &self.services.security.sanitizer, &source);
790        }
791
792        msgs
793    }
794    /// Extract MCP tool names from the tool executor for diagnostic annotation.
795    fn extract_mcp_tool_names(&self) -> Vec<String> {
796        self.tool_executor
797            .tool_definitions_erased()
798            .into_iter()
799            .filter(ToolDef::is_mcp_tool)
800            .map(|t| t.id.to_string())
801            .collect()
802    }
803    /// Classify a skill directory's source kind using on-disk markers and the bundled allowlist.
804    ///
805    /// Must be called from a blocking context (uses synchronous FS I/O).
806    pub(super) fn classify_source_kind(
807        skill_dir: &std::path::Path,
808        managed_dir: Option<&std::path::PathBuf>,
809        bundled_names: &std::collections::HashSet<String>,
810    ) -> zeph_memory::store::SourceKind {
811        if managed_dir.is_some_and(|d| skill_dir.starts_with(d)) {
812            let skill_name = skill_dir.file_name().and_then(|n| n.to_str()).unwrap_or("");
813            let has_marker = skill_dir.join(".bundled").exists();
814            if has_marker && bundled_names.contains(skill_name) {
815                zeph_memory::store::SourceKind::Bundled
816            } else {
817                if has_marker {
818                    tracing::warn!(
819                        skill = %skill_name,
820                        "skill has .bundled marker but is not in the bundled skill \
821                         allowlist — classifying as Hub"
822                    );
823                }
824                zeph_memory::store::SourceKind::Hub
825            }
826        } else {
827            zeph_memory::store::SourceKind::Local
828        }
829    }
830}
831
832/// Outcome of checking the durable-execution gate before a sub-agent spawn (spec-064 §P4).
833enum DurableSpawnGate {
834    /// Fresh run: wire this seat into `SpawnContext::durable_resolver` so the child resolves
835    /// the promise on exit (INV-9 channel rule).
836    Fresh(zeph_subagent::DurableResolverSeat),
837    /// Resumed run whose child already resolved its promise before the parent crashed. The
838    /// caller must skip `spawn` entirely and replay this result instead — spawning here would
839    /// duplicate the LLM calls and any side-effecting tool calls the finished child already
840    /// performed (#5944). `promise_id` lets the foreground caller claim a one-time replay
841    /// notification (#6027) via [`zeph_durable::DurableContext::claim_promise_notification`].
842    Replayed {
843        result: zeph_subagent::SubagentResult,
844        promise_id: zeph_durable::PromiseId,
845    },
846    /// Gate closed: durable subagent support disabled, a resumed run whose child promise is
847    /// still pending (out of v1 scope — see `durable.rs` module docs "Scope boundary"), or an
848    /// error (logged at `warn`). The caller degrades to a plain spawn with no durable wiring.
849    ///
850    /// The still-pending case is safe only because the current architecture is
851    /// LocalBackend-only, in-process tokio tasks (spec-064 INV-9): a parent-process crash
852    /// necessarily kills its in-process children too, so a still-pending promise on resume
853    /// means the original child is genuinely gone, and re-spawning cannot duplicate a live
854    /// child. See `durable.rs` "Scope boundary".
855    None,
856}
857
858/// Check the durable-execution gate for the next sub-agent spawn.
859///
860/// See [`DurableSpawnGate`] for the three possible outcomes.
861async fn resolve_durable_spawn_gate(
862    enabled: bool,
863    ctx: Option<&zeph_durable::DurableContext>,
864) -> DurableSpawnGate {
865    let Some(ctx) = ctx.filter(|_| enabled) else {
866        return DurableSpawnGate::None;
867    };
868    let (promise, seat) = match zeph_subagent::make_durable_promise(ctx).await {
869        Ok(pair) => pair,
870        Err(e) => {
871            tracing::warn!(error = %e, "durable: make_durable_promise failed — degrading to non-durable spawn");
872            return DurableSpawnGate::None;
873        }
874    };
875    if let Some(seat) = seat {
876        return DurableSpawnGate::Fresh(seat);
877    }
878    // Resumed: token unrecoverable (INV-9). Check without blocking whether the child already
879    // resolved the promise before the crash — replay it instead of re-spawning a duplicate.
880    match zeph_subagent::try_replay_durable_subagent(ctx, &promise).await {
881        Ok(Some(result)) => DurableSpawnGate::Replayed {
882            result,
883            promise_id: promise.id(),
884        },
885        Ok(None) => {
886            // Safe to fall back to a plain spawn here only because the current architecture
887            // is LocalBackend-only, in-process tokio tasks: the parent process crashing kills
888            // its in-process children too, so a still-pending promise on resume means the
889            // original child is genuinely gone, not merely unreachable. Re-attaching to a
890            // live child would require cross-process liveness detection, which is out of v1
891            // scope — see `durable.rs` module docs "Scope boundary" and spec-064 INV-9 (the
892            // resolver token is unrecoverable by design, so it cannot be re-minted to attempt
893            // reattachment).
894            tracing::warn!(
895                "durable: resumed sub-agent promise still pending after restart — original \
896                 child did not resolve before the crash; re-spawning may duplicate side effects \
897                 (#5944 residual v1 gap)"
898            );
899            DurableSpawnGate::None
900        }
901        Err(e) => {
902            tracing::warn!(error = %e, "durable: replay check failed on resumed sub-agent promise — degrading to non-durable spawn");
903            DurableSpawnGate::None
904        }
905    }
906}
907
908/// Estimates the JSON payload size of a single [`zeph_llm::provider::Message`] for token-budget
909/// accounting.
910///
911/// When `parts` is empty the message is a legacy text-only message and `content.len()` is used
912/// directly. Otherwise each part is measured individually so that structured variants (images,
913/// tool invocations, thinking blocks) are accounted for rather than relying on the already-flat
914/// `content` string, which may not reflect the actual API payload size.
915pub(crate) fn estimate_parts_size(m: &zeph_llm::provider::Message) -> usize {
916    use zeph_llm::provider::MessagePart;
917    if m.parts.is_empty() {
918        return m.content.len();
919    }
920    m.parts
921        .iter()
922        .map(|p| match p {
923            MessagePart::Text { text }
924            | MessagePart::Recall { text }
925            | MessagePart::CodeContext { text }
926            | MessagePart::Summary { text }
927            | MessagePart::CrossSession { text } => text.len(),
928            MessagePart::ToolOutput { body, .. } => body.len(),
929            MessagePart::ToolUse { id, name, input } => {
930                50 + id.len() + name.len() + input.to_string().len()
931            }
932            MessagePart::ToolResult {
933                tool_use_id,
934                content,
935                ..
936            } => 50 + tool_use_id.len() + content.len(),
937            MessagePart::Image(img) => img.data.len() * 4 / 3,
938            MessagePart::ThinkingBlock {
939                thinking,
940                signature,
941            } => 50 + thinking.len() + signature.len(),
942            MessagePart::RedactedThinkingBlock { data } => data.len(),
943            MessagePart::Compaction { summary } => summary.len(),
944            _ => 0,
945        })
946        .sum()
947}
948
949/// Applies token-budget truncation and orphaned-tool-pair pruning to a parent message slice.
950///
951/// Budget truncation keeps the **most recent** messages that fit within `max_chars`
952/// (a suffix), so the subagent always receives the freshest context.
953///
954/// Two passes are performed after budget truncation:
955///
956/// 1. Remove `ToolResult` parts from user messages whose matching `ToolUse` is no longer in the
957///    slice (truncated away).
958/// 2. Remove `ToolUse` parts from **interior** assistant messages whose matching `ToolResult`
959///    was removed in pass 1 or was already absent. The trailing assistant message is exempt —
960///    its unanswered `ToolUse` calls are not orphaned; the slice just ends before the result.
961///
962/// Messages that become fully empty after pruning are removed from `msgs`.
963///
964/// `rebuild_content` is called **only** when `retain` actually removed parts — preserving the
965/// existing `content` field (and any `ThinkingBlock` text embedded there) for unmodified
966/// messages.
967pub(crate) fn trim_parent_messages(msgs: &mut Vec<zeph_llm::provider::Message>, max_chars: usize) {
968    use zeph_llm::provider::{MessagePart, Role};
969
970    // Token-budget cap: keep the most recent messages that fit within max_chars.
971    // We iterate from the end (newest) and drain from the front once the budget is exceeded,
972    // so the subagent always receives the most recent context rather than stale early messages.
973    let mut total_chars = 0usize;
974    let mut drop_before = 0usize; // index of the first message to keep
975    for (i, m) in msgs.iter().enumerate().rev() {
976        total_chars += estimate_parts_size(m);
977        if total_chars > max_chars {
978            drop_before = i + 1;
979            break;
980        }
981    }
982    if drop_before > 0 {
983        msgs.drain(..drop_before);
984    }
985
986    // Pass 1: collect ToolUse IDs emitted by assistant messages; prune orphaned ToolResult
987    // parts from user messages that reference a ToolUse no longer present in the slice.
988    // Use owned Strings to avoid holding immutable borrows across the subsequent mutable loop.
989    let emitted_tool_ids: std::collections::HashSet<String> = msgs
990        .iter()
991        .filter(|m| m.role == Role::Assistant)
992        .flat_map(|m| m.parts.iter())
993        .filter_map(|p| {
994            if let MessagePart::ToolUse { id, .. } = p {
995                Some(id.clone())
996            } else {
997                None
998            }
999        })
1000        .collect();
1001
1002    let mut orphans_removed = 0usize;
1003    for m in msgs.iter_mut() {
1004        if m.role != Role::User || m.parts.is_empty() {
1005            continue;
1006        }
1007        let before = m.parts.len();
1008        m.parts.retain(|p| match p {
1009            MessagePart::ToolResult { tool_use_id, .. } => {
1010                emitted_tool_ids.contains(tool_use_id.as_str())
1011            }
1012            _ => true,
1013        });
1014        let dropped = before - m.parts.len();
1015        if dropped > 0 {
1016            orphans_removed += dropped;
1017            if m.parts.is_empty() {
1018                m.content.clear();
1019            } else {
1020                m.rebuild_content();
1021            }
1022        }
1023    }
1024
1025    // Pass 2: collect ToolResult IDs present in user messages after pass 1; prune ToolUse
1026    // parts from assistant messages whose result is confirmed absent.
1027    //
1028    // The trailing assistant message is exempt: it may legitimately contain unanswered
1029    // ToolUse calls (the slice ends before the result arrives). Only interior assistant
1030    // messages — those followed by at least one user message — can have provably orphaned
1031    // ToolUse parts (the conversation moved on without answering them).
1032    let consumed_tool_ids: std::collections::HashSet<String> = msgs
1033        .iter()
1034        .filter(|m| m.role == Role::User)
1035        .flat_map(|m| m.parts.iter())
1036        .filter_map(|p| {
1037            if let MessagePart::ToolResult { tool_use_id, .. } = p {
1038                Some(tool_use_id.clone())
1039            } else {
1040                None
1041            }
1042        })
1043        .collect();
1044
1045    // Index of the last assistant message — exempt from pass 2.
1046    let last_assistant_idx = msgs
1047        .iter()
1048        .enumerate()
1049        .rev()
1050        .find(|(_, m)| m.role == Role::Assistant)
1051        .map(|(i, _)| i);
1052
1053    for (idx, m) in msgs.iter_mut().enumerate() {
1054        if m.role != Role::Assistant || m.parts.is_empty() {
1055            continue;
1056        }
1057        // Skip the trailing assistant message — its unanswered ToolUse calls are not orphaned.
1058        if Some(idx) == last_assistant_idx {
1059            continue;
1060        }
1061        let before = m.parts.len();
1062        m.parts.retain(|p| match p {
1063            MessagePart::ToolUse { id, .. } => consumed_tool_ids.contains(id.as_str()),
1064            _ => true,
1065        });
1066        let dropped = before - m.parts.len();
1067        if dropped > 0 {
1068            orphans_removed += dropped;
1069            if m.parts.is_empty() {
1070                m.content.clear();
1071            } else {
1072                m.rebuild_content();
1073            }
1074        }
1075    }
1076
1077    // Remove messages that were emptied by orphan pruning.
1078    msgs.retain(|m| !m.content.is_empty() || !m.parts.is_empty());
1079
1080    if orphans_removed > 0 {
1081        tracing::debug!(
1082            orphans = orphans_removed,
1083            "[subagent] pruned orphaned ToolUse/ToolResult parts from parent context boundary"
1084        );
1085    }
1086}
1087
1088/// Sanitize text parts of `msgs` through the IPI pipeline.
1089///
1090/// Only [`MessagePart::Text`] parts are passed through the sanitizer; structured parts
1091/// (`ToolUse`, `ToolResult`, `Recall`, `CodeContext`) are left untouched.  After sanitization
1092/// the message `content` field is rebuilt to stay consistent with the updated parts.
1093fn sanitize_parent_messages(
1094    mut msgs: Vec<zeph_llm::provider::Message>,
1095    sanitizer: &zeph_sanitizer::ContentSanitizer,
1096    source: &zeph_sanitizer::ContentSource,
1097) -> Vec<zeph_llm::provider::Message> {
1098    use zeph_llm::provider::MessagePart;
1099    for msg in &mut msgs {
1100        let mut changed = false;
1101        for part in &mut msg.parts {
1102            if let MessagePart::Text { text } = part {
1103                let clean = sanitizer.sanitize(text, source.clone());
1104                if clean.body != *text {
1105                    *text = clean.body;
1106                    changed = true;
1107                }
1108            }
1109        }
1110        if changed {
1111            msg.rebuild_content();
1112        }
1113    }
1114    msgs
1115}
1116
1117#[cfg(test)]
1118mod tests {
1119    use super::*;
1120    use crate::agent::agent_tests::*;
1121
1122    // ── resolve_subagent_secret tests (#5941/#5942) ─────────────────────────
1123
1124    fn agent_with_custom_secret(stored_key: &str, value: &str) -> Agent<MockChannel> {
1125        let provider = mock_provider(vec![]);
1126        let channel = MockChannel::new(vec![]);
1127        let registry = create_test_registry();
1128        let executor = MockToolExecutor::no_tools();
1129        let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
1130        agent.services.skill.available_custom_secrets.insert(
1131            stored_key.to_owned(),
1132            crate::vault::Secret::new(value.to_owned()),
1133        );
1134        agent
1135    }
1136
1137    #[test]
1138    fn resolve_subagent_secret_exact_match() {
1139        let agent = agent_with_custom_secret("my_key", "the-value");
1140        let resolved = agent.resolve_subagent_secret("my_key");
1141        assert_eq!(
1142            resolved.map(|s| s.expose().to_owned()),
1143            Some("the-value".to_owned())
1144        );
1145    }
1146
1147    #[test]
1148    fn resolve_subagent_secret_normalizes_dash_to_underscore() {
1149        // Stored key is underscored (as produced by ZEPH_SECRET_<NAME> normalization);
1150        // the sub-agent may request it with dashes instead.
1151        let agent = agent_with_custom_secret("my_api_key", "dash-value");
1152        let resolved = agent.resolve_subagent_secret("my-api-key");
1153        assert_eq!(
1154            resolved.map(|s| s.expose().to_owned()),
1155            Some("dash-value".to_owned())
1156        );
1157    }
1158
1159    #[test]
1160    fn resolve_subagent_secret_normalizes_case() {
1161        let agent = agent_with_custom_secret("upper_key", "case-value");
1162        let resolved = agent.resolve_subagent_secret("UPPER_KEY");
1163        assert_eq!(
1164            resolved.map(|s| s.expose().to_owned()),
1165            Some("case-value".to_owned())
1166        );
1167    }
1168
1169    #[test]
1170    fn resolve_subagent_secret_missing_key_returns_none() {
1171        let agent = agent_with_custom_secret("known_key", "value");
1172        assert!(agent.resolve_subagent_secret("unknown_key").is_none());
1173    }
1174
1175    #[test]
1176    fn resolve_subagent_secret_empty_map_returns_none() {
1177        let provider = mock_provider(vec![]);
1178        let channel = MockChannel::new(vec![]);
1179        let registry = create_test_registry();
1180        let executor = MockToolExecutor::no_tools();
1181        let agent = Agent::new(provider, channel, registry, None, 5, executor);
1182        assert!(agent.resolve_subagent_secret("anything").is_none());
1183    }
1184
1185    /// #5712 regression: MCP tool identification must key off `ToolDef::server_id`, not a
1186    /// `"mcp_"` name prefix that real `McpTool::sanitized_id()` output never produces.
1187    #[tokio::test]
1188    async fn extract_mcp_tool_names_uses_server_id_not_name_prefix() {
1189        use zeph_tools::registry::InvocationHint;
1190
1191        let provider = mock_provider(vec![]);
1192        let channel = MockChannel::new(vec![]);
1193        let registry = create_test_registry();
1194        let executor = MockToolExecutor::no_tools().with_definitions(vec![
1195            ToolDef {
1196                id: "read".into(),
1197                description: "built-in tool".into(),
1198                schema: schemars::Schema::default(),
1199                invocation: InvocationHint::ToolCall,
1200                output_schema: None,
1201                server_id: None,
1202            },
1203            ToolDef {
1204                id: "github_create_issue".into(),
1205                description: "MCP tool".into(),
1206                schema: schemars::Schema::default(),
1207                invocation: InvocationHint::ToolCall,
1208                output_schema: None,
1209                server_id: Some("github".into()),
1210            },
1211        ]);
1212        let agent = Agent::new(provider, channel, registry, None, 5, executor);
1213
1214        assert_eq!(agent.extract_mcp_tool_names(), vec!["github_create_issue"]);
1215    }
1216
1217    /// Agent with `durable_ctx` populated via the real `ensure_session_durable_ctx` bootstrap
1218    /// path (mirrors `durable_bootstrap::tests::agent_with_conversation`), with
1219    /// `durable_subagent` set per `subagent_enabled` — used to test the FR-003/US-002 seat
1220    /// wiring gate at `resolve_durable_spawn_gate`, not just the config-to-builder plumbing.
1221    async fn agent_with_durable_ctx_ready(subagent_enabled: bool) -> Agent<MockChannel> {
1222        let provider = mock_provider(vec!["ok".into()]);
1223        let channel = MockChannel::new(vec![]);
1224        let registry = create_test_registry();
1225        let executor = MockToolExecutor::no_tools();
1226        let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
1227        agent.services.memory.persistence.conversation_id = Some(zeph_memory::ConversationId(42));
1228        agent.services.session.durable_agent_turns_config = Some(zeph_config::DurableConfig {
1229            enabled: true,
1230            agent_turns: true,
1231            ..zeph_config::DurableConfig::default()
1232        });
1233        agent.services.session.durable_agent_turns_db_url = Some(":memory:".to_owned());
1234        agent.services.session.durable_subagent = subagent_enabled;
1235
1236        agent.ensure_session_durable_ctx().await;
1237        assert!(
1238            agent.services.session.durable_ctx.is_some(),
1239            "test setup: durable_ctx must be populated before exercising the seat gate"
1240        );
1241        agent
1242    }
1243
1244    #[tokio::test]
1245    async fn seat_wired_when_subagent_enabled_and_durable_ctx_populated() {
1246        let agent = Box::pin(agent_with_durable_ctx_ready(true)).await;
1247
1248        let gate = resolve_durable_spawn_gate(
1249            agent.services.session.durable_subagent,
1250            agent.services.session.durable_ctx.as_deref(),
1251        )
1252        .await;
1253
1254        assert!(
1255            matches!(gate, DurableSpawnGate::Fresh(_)),
1256            "US-002: [durable] subagent=true with a populated durable_ctx must yield a seat, \
1257             not just wire the config-to-builder plumbing"
1258        );
1259    }
1260
1261    #[tokio::test]
1262    async fn seat_absent_when_subagent_disabled() {
1263        let agent = Box::pin(agent_with_durable_ctx_ready(false)).await;
1264
1265        let gate = resolve_durable_spawn_gate(
1266            agent.services.session.durable_subagent,
1267            agent.services.session.durable_ctx.as_deref(),
1268        )
1269        .await;
1270
1271        assert!(
1272            matches!(gate, DurableSpawnGate::None),
1273            "FR-008: durable_subagent=false must keep the seat gate closed even when \
1274             durable_ctx is populated"
1275        );
1276    }
1277
1278    // ── #5944 end-to-end replay regression tests ────────────────────────────
1279    //
1280    // These simulate a real parent-process restart: two *separate* `Agent` instances
1281    // pointed at the same on-disk sqlite durable journal and the same `conversation_id`,
1282    // so the second instance's `DurableContext` genuinely re-derives the first's
1283    // `ExecutionId`/`PromiseId` (mirrors `try_replay_durable_subagent_sees_already_resolved_promise_on_resume`
1284    // in `zeph-subagent/src/durable.rs`, but at the `handle_agent_background`/
1285    // `handle_agent_spawn_foreground` call-site level rather than the adapter level).
1286
1287    fn subagent_def(name: &str) -> zeph_subagent::SubAgentDef {
1288        use zeph_subagent::def::{SkillFilter, SubAgentPermissions, ToolPolicy};
1289        use zeph_subagent::hooks::SubagentHooks;
1290
1291        zeph_subagent::SubAgentDef {
1292            name: name.to_owned(),
1293            description: "A helper bot".into(),
1294            model: None,
1295            tools: ToolPolicy::InheritAll,
1296            disallowed_tools: vec![],
1297            permissions: SubAgentPermissions::default(),
1298            skills: SkillFilter::default(),
1299            system_prompt: "You are helpful.".into(),
1300            hooks: SubagentHooks::default(),
1301            memory: None,
1302            source: None,
1303            file_path: None,
1304        }
1305    }
1306
1307    /// Builds an `Agent` wired for durable sub-agent spawns against a real sqlite file at
1308    /// `db_url`, with a `SubAgentManager` carrying a single "helper" definition so
1309    /// `handle_agent_background`/`handle_agent_spawn_foreground` can run past the gate check.
1310    async fn agent_with_durable_and_manager(
1311        db_url: &str,
1312        conversation_id: i64,
1313    ) -> Agent<MockChannel> {
1314        let provider = mock_provider(vec!["ok".into()]);
1315        let channel = MockChannel::new(vec![]);
1316        let registry = create_test_registry();
1317        let executor = MockToolExecutor::no_tools();
1318        let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
1319        agent.services.memory.persistence.conversation_id =
1320            Some(zeph_memory::ConversationId(conversation_id));
1321        agent.services.session.durable_agent_turns_config = Some(zeph_config::DurableConfig {
1322            enabled: true,
1323            agent_turns: true,
1324            ..zeph_config::DurableConfig::default()
1325        });
1326        agent.services.session.durable_agent_turns_db_url = Some(db_url.to_owned());
1327        agent.services.session.durable_subagent = true;
1328
1329        let mut mgr = zeph_subagent::SubAgentManager::new(4);
1330        mgr.definitions_mut().push(subagent_def("helper"));
1331        agent.services.orchestration.subagent_manager = Some(mgr);
1332
1333        agent.ensure_session_durable_ctx().await;
1334        assert!(
1335            agent.services.session.durable_ctx.is_some(),
1336            "test setup: durable_ctx must be populated before exercising the handler"
1337        );
1338        agent
1339    }
1340
1341    #[tokio::test]
1342    async fn handle_agent_background_replays_finished_child_without_respawning() {
1343        let dir = tempfile::tempdir().unwrap();
1344        let db_url = dir.path().join("durable.db").to_string_lossy().into_owned();
1345
1346        // "Run 1": the child finishes and resolves its promise before the parent crashes.
1347        let agent1 = Box::pin(agent_with_durable_and_manager(&db_url, 100)).await;
1348        let ctx1 = agent1.services.session.durable_ctx.clone().unwrap();
1349        let (_promise1, seat) = zeph_subagent::make_durable_promise(&ctx1).await.unwrap();
1350        let seat = seat.expect("test setup: run 1 must be fresh and yield a resolver seat");
1351        let loop_result: Result<String, zeph_subagent::SubAgentError> =
1352            Ok("child finished before crash".to_owned());
1353        zeph_subagent::resolve_durable_promise(seat, "task-e2e-01", &loop_result).await;
1354        agent1
1355            .services
1356            .session
1357            .durable_writer
1358            .as_ref()
1359            .unwrap()
1360            .flush()
1361            .await
1362            .unwrap();
1363        // Drop run 1 to release its process-exclusivity lock on the execution (INV-15, #6122) —
1364        // a real crash closes the process's file descriptors (and thus the flock) before the
1365        // restarted parent below re-opens the same execution; without this, run 2's
1366        // `open_execution_exclusive` would see run 1 as still live and correctly refuse to open.
1367        drop(agent1);
1368
1369        // "Run 2": a brand-new `Agent` (simulating the restarted parent) with the same
1370        // conversation_id and db file re-derives the same promise and must see it resolved.
1371        let mut agent2 = Box::pin(agent_with_durable_and_manager(&db_url, 100)).await;
1372
1373        let resp = agent2
1374            .handle_agent_background("helper", "do work")
1375            .await
1376            .unwrap();
1377        assert!(
1378            resp.contains("replayed from durable journal"),
1379            "expected a replay notice, got: {resp}"
1380        );
1381        assert!(
1382            resp.contains("child finished before crash"),
1383            "expected the journaled output to be surfaced, got: {resp}"
1384        );
1385        assert!(
1386            agent2
1387                .services
1388                .orchestration
1389                .subagent_manager
1390                .as_ref()
1391                .unwrap()
1392                .statuses()
1393                .is_empty(),
1394            "mgr.spawn must not be called when the child result is replayed"
1395        );
1396    }
1397
1398    #[tokio::test]
1399    async fn handle_agent_spawn_foreground_replays_finished_child_without_respawning() {
1400        let dir = tempfile::tempdir().unwrap();
1401        let db_url = dir.path().join("durable.db").to_string_lossy().into_owned();
1402
1403        // "Run 1": the child finishes and resolves its promise before the parent crashes.
1404        let agent1 = Box::pin(agent_with_durable_and_manager(&db_url, 200)).await;
1405        let ctx1 = agent1.services.session.durable_ctx.clone().unwrap();
1406        let (_promise1, seat) = zeph_subagent::make_durable_promise(&ctx1).await.unwrap();
1407        let seat = seat.expect("test setup: run 1 must be fresh and yield a resolver seat");
1408        let loop_result: Result<String, zeph_subagent::SubAgentError> =
1409            Ok("foreground child output".to_owned());
1410        zeph_subagent::resolve_durable_promise(seat, "task-e2e-02", &loop_result).await;
1411        // C1 regression guard (#6027): journal a durable step AFTER the promise, exactly the
1412        // foreground-spawn-followed-by-another-turn topology that triggered the original
1413        // ReplayDivergence bug (a replay-only `ctx.step()` used to land at this same ordinal
1414        // position and collide with whatever the fresh run had already recorded there). The
1415        // `notified_at` claim consumes no step id, so it can never collide with this marker —
1416        // if it regressed to a step-based mechanism, the assertions below would fail with a
1417        // `ReplayDivergence` error instead of the expected replayed output.
1418        ctx1.step(
1419            zeph_durable::StepDescriptor::idempotent(
1420                "post_spawn_marker",
1421                b"post_spawn_marker".to_vec(),
1422            ),
1423            |_handle| async move { Ok::<i64, zeph_durable::StepError>(42) },
1424        )
1425        .await
1426        .unwrap();
1427        agent1
1428            .services
1429            .session
1430            .durable_writer
1431            .as_ref()
1432            .unwrap()
1433            .flush()
1434            .await
1435            .unwrap();
1436        // Drop run 1 to release its process-exclusivity lock on the execution (INV-15, #6122) —
1437        // see the comment in `handle_agent_background_replays_finished_child_without_respawning`.
1438        drop(agent1);
1439
1440        // "Run 2": a brand-new `Agent` re-derives the same promise and must see it resolved,
1441        // returning the journaled output directly instead of spawning and polling a new child.
1442        let mut agent2 = Box::pin(agent_with_durable_and_manager(&db_url, 200)).await;
1443
1444        let resp = agent2
1445            .handle_agent_spawn_foreground("helper", "do work")
1446            .await
1447            .unwrap();
1448        assert_eq!(resp, "foreground child output");
1449        assert!(
1450            agent2
1451                .channel
1452                .sent_messages()
1453                .iter()
1454                .any(|m| m.contains("replayed from durable journal")),
1455            "expected the replay notice to be sent to the channel"
1456        );
1457        assert_eq!(
1458            agent2.channel.notify_completed_calls().len(),
1459            1,
1460            "expected exactly one TUI completion notification on the first replay"
1461        );
1462        assert!(
1463            agent2
1464                .services
1465                .orchestration
1466                .subagent_manager
1467                .as_ref()
1468                .unwrap()
1469                .statuses()
1470                .is_empty(),
1471            "mgr.spawn must not be called when the child result is replayed"
1472        );
1473        drop(agent2);
1474
1475        // "Run 3": the parent restarts *again* after already taking the replay branch once.
1476        // Per #6027, the channel side effects (notice + completion event) must not re-fire on
1477        // this second replay — only the first winner of the out-of-band `notified_at` claim
1478        // fires them; the journaled output is still returned.
1479        let mut agent3 = Box::pin(agent_with_durable_and_manager(&db_url, 200)).await;
1480
1481        let resp = agent3
1482            .handle_agent_spawn_foreground("helper", "do work")
1483            .await
1484            .unwrap();
1485        assert_eq!(resp, "foreground child output");
1486        assert!(
1487            !agent3
1488                .channel
1489                .sent_messages()
1490                .iter()
1491                .any(|m| m.contains("replayed from durable journal")),
1492            "replay notice must not re-fire on a second replay after a parent restart"
1493        );
1494        assert!(
1495            agent3.channel.notify_completed_calls().is_empty(),
1496            "TUI completion event must not re-fire on a second replay after a parent restart"
1497        );
1498    }
1499
1500    #[tokio::test]
1501    async fn handle_agent_background_resumed_still_pending_falls_back_to_spawn() {
1502        let dir = tempfile::tempdir().unwrap();
1503        let db_url = dir.path().join("durable.db").to_string_lossy().into_owned();
1504
1505        // "Run 1": the promise is created (child spawned) but never resolved — simulates a
1506        // child that was still genuinely running (or lost) when the parent crashed.
1507        let agent1 = Box::pin(agent_with_durable_and_manager(&db_url, 300)).await;
1508        let ctx1 = agent1.services.session.durable_ctx.clone().unwrap();
1509        let (_promise1, seat) = zeph_subagent::make_durable_promise(&ctx1).await.unwrap();
1510        assert!(
1511            seat.is_some(),
1512            "test setup: run 1 must be fresh and yield a resolver seat"
1513        );
1514        agent1
1515            .services
1516            .session
1517            .durable_writer
1518            .as_ref()
1519            .unwrap()
1520            .flush()
1521            .await
1522            .unwrap();
1523        // Drop run 1 to release its process-exclusivity lock on the execution (INV-15, #6122) —
1524        // see the comment in `handle_agent_background_replays_finished_child_without_respawning`.
1525        drop(agent1);
1526
1527        // "Run 2": resumed execution observes the same promise still pending — per the
1528        // documented v1 scope boundary (INV-9: no way to recover an orphaned resolver token)
1529        // the gate must degrade to a plain spawn rather than replay or block indefinitely.
1530        let mut agent2 = Box::pin(agent_with_durable_and_manager(&db_url, 300)).await;
1531
1532        let resp = agent2
1533            .handle_agent_background("helper", "do work")
1534            .await
1535            .unwrap();
1536        assert!(
1537            resp.contains("started in background"),
1538            "still-pending resumed promise must fall back to a normal spawn, got: {resp}"
1539        );
1540        assert_eq!(
1541            agent2
1542                .services
1543                .orchestration
1544                .subagent_manager
1545                .as_ref()
1546                .unwrap()
1547                .statuses()
1548                .len(),
1549            1,
1550            "exactly one real spawn must occur on the still-pending fallback path"
1551        );
1552    }
1553}