Skip to main content

zeph_core/agent/
slash_commands.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Slash command helpers for `Agent<C>`.
5//!
6//! The `COMMANDS` constant has moved to `zeph-commands::commands`. This module hosts the
7//! [`zeph_commands::SessionControlAccess`] implementation for `Agent<C>` (`/recap`, `/compact`,
8//! `/new`, `/status`, `/guardrail`, `/focus`, `/sidequest`, `/image`, `/undo`, `/redo`, `/conv`)
9//! plus its private helpers (`/conv resume`/`fork`/`list`/`show`, status-string formatting) and
10//! the session/agent command registry builders used by `Agent::run`.
11
12use std::future::Future;
13use std::pin::Pin;
14
15use tracing::Instrument as _;
16use zeph_commands::{CommandError, SessionControlAccess};
17use zeph_llm::provider::LlmProvider;
18
19use super::Agent;
20use super::error;
21use crate::channel::Channel;
22
23/// Returns a formatted overlay summary string for slash/TUI display.
24///
25/// Resolves the active plugin overlay against a scratch `Config::default()`.
26/// Source and skipped plugin lists are accurate; merged config values (e.g.,
27/// `allowed_commands`) are not shown because they depend on the live config base.
28pub(crate) fn format_overlay_section(plugins_dir: &std::path::Path) -> String {
29    let mut cfg = zeph_config::Config::default();
30    match zeph_plugins::apply_plugin_config_overlays(&mut cfg, plugins_dir) {
31        Err(e) => format!("overlay resolution failed: {e}"),
32        Ok(overlay) => {
33            if overlay.source_plugins.is_empty() && overlay.skipped_plugins.is_empty() {
34                return "No plugin overlay active.".to_owned();
35            }
36            let mut out = String::from("Active plugin overlay:\n");
37            if overlay.source_plugins.is_empty() {
38                out.push_str("  Source plugins:  (none)\n");
39            } else {
40                out.push_str("  Source plugins:  ");
41                out.push_str(&overlay.source_plugins.join(", "));
42                out.push('\n');
43            }
44            if overlay.skipped_plugins.is_empty() {
45                out.push_str("  Skipped plugins: (none)\n");
46            } else {
47                out.push_str("  Skipped plugins:\n");
48                for reason in &overlay.skipped_plugins {
49                    out.push_str("    - ");
50                    out.push_str(reason);
51                    out.push('\n');
52                }
53            }
54            out.push_str(
55                "  Note: overlay values shown against default config — run with --config for live intersection.",
56            );
57            out
58        }
59    }
60}
61
62impl<C: crate::channel::Channel> Agent<C> {
63    /// Handle built-in slash commands that short-circuit the main `run` loop.
64    ///
65    /// Returns `Some(true)` to break the loop (exit), `Some(false)` to continue to the next
66    /// iteration, or `None` if the command was not recognized (caller should call
67    /// `process_user_message`).
68    ///
69    /// Most commands are now handled through the session-registry or agent-registry. This
70    /// method is kept for commands that could not be migrated due to non-Sync type constraints.
71    #[allow(clippy::unused_self)]
72    pub(super) fn handle_builtin_command(&self, _trimmed: &str) -> Option<bool> {
73        None
74    }
75
76    /// Dispatch slash commands that cannot be handled by the registry.
77    ///
78    /// Currently handles only `@mention` dispatch. All `/` slash commands are now
79    /// dispatched through the session or agent command registry in `Agent::run`.
80    ///
81    /// Returns `Some(Ok(()))` when handled, `Some(Err(_))` on I/O error, `None` to
82    /// fall through to LLM processing.
83    pub(super) async fn dispatch_slash_command(
84        &mut self,
85        trimmed: &str,
86    ) -> Option<Result<(), error::AgentError>> {
87        // @mention dispatch: not a `/` command, so not in the registry.
88        if trimmed.starts_with('@') {
89            return self.dispatch_agent_command(trimmed).await;
90        }
91
92        // `/subagent spawn <cmd>` — ACP external process spawn (#3302).
93        if trimmed.eq_ignore_ascii_case("/subagent")
94            || trimmed.to_ascii_lowercase().starts_with("/subagent ")
95        {
96            let args = trimmed.get("/subagent".len()..).unwrap_or("").trim();
97            return Some(self.handle_subagent_slash(args).await);
98        }
99
100        None
101    }
102
103    /// Handle `/subagent [spawn <cmd>]` and return a user-visible result.
104    ///
105    /// Routes `/subagent spawn <cmd>` through the ACP spawn callback when available.
106    /// Returns a usage hint when no sub-command or command string is given, and a
107    /// "not available" message when the ACP spawn callback has not been injected.
108    ///
109    /// This path launches an external ACP subagent process (`zeph_acp::run_session` via
110    /// `spawn_fn`, wired in `src/runner.rs`) and never touches `SubAgentManager` or
111    /// `SpawnContext` — the `delegation_mode` gate inside `SubAgentManager::spawn` does not
112    /// see it at all. Spec 042 FR-003 requires `disabled` mode to reject *every* spawn path,
113    /// so the effective-mode check below is an explicit, separate gate at this choke point
114    /// (issue #5857). Uses `DelegationMode::permits_explicit()` — the same allow-list predicate
115    /// `SubAgentManager::resume` uses — rather than a hand-written `== Disabled` deny-list, so
116    /// the two enforcement points cannot drift apart and neither fails open on a future
117    /// `#[non_exhaustive]` variant. `/subagent spawn` is itself an explicit user action, so it
118    /// stays permitted under `explicit_request_only` and `proactive`, blocked only when
119    /// `permits_explicit()` is `false` (currently just `disabled`).
120    ///
121    /// A second, independent policy is enforced at this same choke point: the session-wide
122    /// cumulative spawn budget (issue #6545, `Agent::session_budget`). Since this path spawns
123    /// an out-of-process ACP session and never touches `SubAgentManager`, it would otherwise
124    /// bypass the cap entirely. `AcpSubagentSpawnFn` is fallible — but unlike the manager path
125    /// (where `NotFound`/`ConcurrencyLimit` genuinely mean "no resources allocated"), the real
126    /// callback wired in `src/runner.rs` (`zeph_acp::run_session`) launches the child process
127    /// *before* it can fail: its `Err` arm covers three cases — the launch itself failing
128    /// (nothing ran), a live child that ran for up to `session_timeout_secs` and then timed
129    /// out, and a live child that ran and then failed post-launch I/O/protocol. Only the first
130    /// allocates zero resources; the other two already spent an OS process. Because
131    /// `Result<String, String>` erases which case occurred by the time it crosses this
132    /// boundary, the budget is consumed unconditionally once `spawn_fn` returns at all (both
133    /// `Ok` and `Err`) — accepting a small overcount on launch-failure in exchange for never
134    /// undercounting a real, possibly-hung child process, which is the actual runaway-loop
135    /// threat this cap exists to bound.
136    async fn handle_subagent_slash(&mut self, args: &str) -> Result<(), error::AgentError> {
137        let msg: String = if args.is_empty() {
138            "Usage: /subagent <subcommand>\n\nSubcommands:\n  spawn <command>  Spawn an ACP sub-agent process".to_owned()
139        } else {
140            let (subcmd, rest) = args.split_once(' ').unwrap_or((args, ""));
141            match subcmd {
142                "spawn" => {
143                    let cmd = rest.trim();
144                    let effective_mode = self.effective_delegation_mode();
145                    let max_spawns = self
146                        .services
147                        .orchestration
148                        .subagent_config
149                        .max_spawns_per_session;
150                    if cmd.is_empty() {
151                        "Usage: /subagent spawn <command>\n\nExample: /subagent spawn zeph --acp"
152                            .to_owned()
153                    } else if !effective_mode.permits_explicit() {
154                        tracing::warn!(
155                            mode = ?effective_mode,
156                            "/subagent spawn rejected: delegation disabled by configuration"
157                        );
158                        "Sub-agent delegation is disabled by configuration \
159                         ([agents].delegation_mode = \"disabled\" or [agents].enabled = false)."
160                            .to_owned()
161                    } else if let Err(e) = self.session_budget().check(max_spawns) {
162                        tracing::warn!(
163                            error = %e,
164                            "/subagent spawn rejected: session spawn budget exhausted"
165                        );
166                        format!("Sub-agent error: {e}")
167                    } else if let Some(spawn_fn) = self.runtime.config.acp_subagent_spawn_fn.clone()
168                    {
169                        let cmd = cmd.to_owned();
170                        let result = spawn_fn(cmd).await;
171                        // Commit point (issue #6545): record unconditionally once `spawn_fn`
172                        // has returned at all, in both the `Ok` and `Err` arms — see the doc
173                        // comment above for why an `Err` here cannot be assumed to mean "never
174                        // launched". Re-borrows `self` fresh (rather than holding a reference
175                        // across the `.await` above) since `SessionSpawnBudget` has no
176                        // `Clone`/`Arc` by design — see its own doc comment.
177                        self.session_budget().record_spawn();
178                        match result {
179                            Ok(output) => output,
180                            Err(e) => format!("Sub-agent error: {e}"),
181                        }
182                    } else {
183                        "ACP sub-agent spawning is not available in this mode.\n\
184                         Use `zeph acp run-agent --command <CMD> --prompt <TEXT>` for one-shot sessions."
185                            .to_owned()
186                    }
187                }
188                other => format!("Unknown /subagent subcommand: '{other}'. Available: spawn"),
189            }
190        };
191
192        let _ = self.channel.send(&msg).await;
193        let _ = self.channel.flush_chunks().await;
194        Ok(())
195    }
196
197    pub(super) async fn dispatch_agent_command(
198        &mut self,
199        trimmed: &str,
200    ) -> Option<Result<(), error::AgentError>> {
201        let known: Vec<String> = self
202            .services
203            .orchestration
204            .subagent_manager
205            .as_ref()
206            .map(|m| m.definitions().iter().map(|d| d.name.clone()).collect())
207            .unwrap_or_default();
208        match zeph_subagent::AgentCommand::parse(trimmed, &known) {
209            Ok(cmd) => {
210                if let Some(msg) = self.handle_agent_command(cmd).await
211                    && let Err(e) = self.channel.send(&msg).await
212                {
213                    return Some(Err(e.into()));
214                }
215                let _ = self.channel.flush_chunks().await;
216                Some(Ok(()))
217            }
218            Err(e) if trimmed.starts_with('@') => {
219                tracing::debug!("@mention not matched as agent: {e}");
220                None
221            }
222            Err(e) => {
223                if let Err(send_err) = self.channel.send(&e.to_string()).await {
224                    return Some(Err(send_err.into()));
225                }
226                let _ = self.channel.flush_chunks().await;
227                Some(Ok(()))
228            }
229        }
230    }
231
232    /// Return formatted session status string for use via [`SessionControlAccess::session_status`].
233    pub(super) fn handle_status_as_string(&mut self) -> String {
234        use std::fmt::Write;
235        use zeph_llm::provider::Role;
236
237        let uptime = self.runtime.lifecycle.start_time.elapsed().as_secs();
238        let msg_count = self
239            .msg
240            .messages
241            .iter()
242            .filter(|m| m.role == Role::User)
243            .count();
244
245        let metrics = collect_status_metrics(self.runtime.metrics.metrics_tx.as_ref());
246        let skill_count = self.services.skill.registry.read().all_meta().len();
247
248        let mut out = String::from("Session status:\n\n");
249        let _ = writeln!(out, "Provider:  {}", self.provider.name());
250        let _ = writeln!(out, "Model:     {}", self.runtime.config.model_name);
251        let _ = writeln!(out, "Uptime:    {uptime}s");
252        let _ = writeln!(out, "Turns:     {msg_count}");
253        let _ = writeln!(out, "API calls: {}", metrics.api_calls);
254        if metrics.reasoning_tokens > 0 {
255            let _ = writeln!(
256                out,
257                "Tokens:    {} prompt / {} completion ({} reasoning, subset of completion)",
258                metrics.prompt_tokens, metrics.completion_tokens, metrics.reasoning_tokens
259            );
260        } else {
261            let _ = writeln!(
262                out,
263                "Tokens:    {} prompt / {} completion",
264                metrics.prompt_tokens, metrics.completion_tokens
265            );
266        }
267        let _ = writeln!(out, "Skills:    {skill_count}");
268        let _ = writeln!(out, "MCP:       {} server(s)", metrics.mcp_servers);
269        if let Some(ref tf) = self.services.tool_state.tool_schema_filter {
270            let _ = writeln!(
271                out,
272                "Filter:    enabled (top_k={}, always_on={}, {} embeddings)",
273                tf.top_k(),
274                tf.always_on_count(),
275                tf.embedding_count(),
276            );
277        }
278        if let Some(ref adv) = self.runtime.config.adversarial_policy_info {
279            let provider_display = if adv.provider.is_empty() {
280                "default"
281            } else {
282                adv.provider.as_str()
283            };
284            let _ = writeln!(
285                out,
286                "Adv gate:  enabled (provider={}, policies={}, fail_open={}, timeout_ms={})",
287                provider_display, adv.policy_count, adv.fail_open, adv.timeout_ms
288            );
289        }
290        append_cost_section(&mut out, metrics.cost_cents, &metrics.provider_breakdown);
291        append_orchestration_section(
292            &mut out,
293            metrics.orch_plans,
294            metrics.orch_tasks,
295            metrics.orch_completed,
296            metrics.orch_failed,
297            metrics.orch_skipped,
298        );
299        append_ensemble_section(
300            &mut out,
301            metrics.ensemble_degraded,
302            metrics.ensemble_agreement_ratio,
303            &metrics.ensemble_member_stats,
304        );
305        append_pruning_section(
306            &mut out,
307            self.context_manager.compression.pruning_strategy,
308            self.services.compression.subgoal_registry.subgoals.len(),
309            self.services.compression.subgoal_registry.active_subgoal(),
310        );
311        append_graph_recall_section(&mut out, &self.services.memory.extraction.graph_config);
312
313        out.trim_end().to_owned()
314    }
315
316    /// Return formatted guardrail status string for use via [`SessionControlAccess::guardrail_status`].
317    pub(super) fn format_guardrail_status(&self) -> String {
318        use std::fmt::Write;
319
320        let mut out = String::new();
321        if let Some(ref guardrail) = self.services.security.guardrail {
322            let stats = guardrail.stats();
323            let _ = writeln!(out, "Guardrail: enabled");
324            let _ = writeln!(out, "Action:    {:?}", guardrail.action());
325            let _ = writeln!(out, "Fail strategy: {:?}", guardrail.fail_strategy());
326            let _ = writeln!(out, "Timeout:   {}ms", guardrail.timeout_ms());
327            let _ = writeln!(
328                out,
329                "Tool scan: {}",
330                if guardrail.scan_tool_output() {
331                    "enabled"
332                } else {
333                    "disabled"
334                }
335            );
336            let _ = writeln!(out, "\nStats:");
337            let _ = writeln!(out, "  Total checks:  {}", stats.total_checks);
338            let _ = writeln!(out, "  Flagged:       {}", stats.flagged_count);
339            let _ = writeln!(out, "  Errors:        {}", stats.error_count);
340            let _ = writeln!(out, "  Avg latency:   {}ms", stats.avg_latency_ms());
341        } else {
342            out.push_str("Guardrail: disabled\n");
343            out.push_str(
344                "Enable with: --guardrail flag or [security.guardrail] enabled = true in config",
345            );
346        }
347        out.trim_end().to_owned()
348    }
349
350    /// Return formatted Focus Agent status string for use via [`SessionControlAccess::focus_status`].
351    pub(super) fn format_focus_status(&self) -> String {
352        use std::fmt::Write;
353        let mut out = String::from("Focus Agent status\n\n");
354        let _ = writeln!(
355            out,
356            "Enabled:          {}",
357            self.services.focus.config.enabled
358        );
359        let _ = writeln!(out, "Active session:   {}", self.services.focus.is_active());
360        if let Some(ref scope) = self.services.focus.active_scope {
361            let _ = writeln!(out, "Active scope:     {scope}");
362        }
363        let _ = writeln!(
364            out,
365            "Knowledge blocks: {}",
366            self.services.focus.knowledge_blocks.len()
367        );
368        let _ = writeln!(
369            out,
370            "Turns since focus: {}",
371            self.services.focus.turns_since_focus
372        );
373        out.trim_end().to_owned()
374    }
375
376    /// Return formatted `SideQuest` eviction status string for use via
377    /// [`SessionControlAccess::sidequest_status`].
378    pub(super) fn format_sidequest_status(&self) -> String {
379        use std::fmt::Write;
380        let mut out = String::from("SideQuest status\n\n");
381        let _ = writeln!(
382            out,
383            "Enabled:        {}",
384            self.services.sidequest.config.enabled
385        );
386        let _ = writeln!(
387            out,
388            "Interval turns: {}",
389            self.services.sidequest.config.interval_turns
390        );
391        let _ = writeln!(
392            out,
393            "Turn counter:   {}",
394            self.services.sidequest.turn_counter
395        );
396        let _ = writeln!(
397            out,
398            "Passes run:     {}",
399            self.services.sidequest.passes_run
400        );
401        let _ = writeln!(
402            out,
403            "Total evicted:  {} tool outputs",
404            self.services.sidequest.total_evicted
405        );
406        out.trim_end().to_owned()
407    }
408
409    /// Load an image and return a status string for use via [`SessionControlAccess::load_image`].
410    #[cfg_attr(not(test), allow(dead_code))]
411    pub(super) fn handle_image_as_string(&mut self, path: &str) -> String {
412        use zeph_common::path_guard::{PathRejection, classify_relative_path};
413        use zeph_llm::provider::{ImageData, MessagePart};
414
415        match classify_relative_path(path) {
416            PathRejection::Allowed => {}
417            PathRejection::Absolute => {
418                return "Invalid image path: absolute paths are not supported, use a path \
419                    relative to the working directory"
420                    .to_owned();
421            }
422            PathRejection::Traversal => {
423                return "Invalid image path: path traversal ('..') is not allowed".to_owned();
424            }
425        }
426
427        let data = match std::fs::read(path) {
428            Ok(d) => d,
429            Err(e) => return format!("Cannot read image {path}: {e}"),
430        };
431        if data.len() > super::message_queue::MAX_IMAGE_BYTES {
432            return format!(
433                "Image {path} exceeds size limit ({} MB), skipping",
434                super::message_queue::MAX_IMAGE_BYTES / 1024 / 1024
435            );
436        }
437        let mime_type = super::message_queue::detect_image_mime(Some(path)).to_string();
438        self.msg
439            .pending_image_parts
440            .push(MessagePart::Image(Box::new(ImageData { data, mime_type })));
441        format!("Image loaded: {path}. Send your message.")
442    }
443
444    /// Return the `/skills [subcommand]` output as a `String` without sending via channel.
445    ///
446    /// Execute a `/plugins` command given pre-cloned state, suitable for use inside
447    /// `tokio::task::spawn_blocking` without borrowing `&self`.
448    #[allow(clippy::needless_pass_by_value)]
449    pub(super) fn run_plugin_command(
450        args: &str,
451        managed_dir: Option<std::path::PathBuf>,
452        mcp_allowed: Vec<String>,
453        base_shell_allowed: Vec<String>,
454        ephemeral_plugin_names: Vec<String>,
455        reputation_cfg: &zeph_config::plugins::ReputationConfig,
456    ) -> String {
457        // Use the canonical default so CLI and TUI always reference the same directory.
458        let plugins_dir = zeph_plugins::PluginManager::default_plugins_dir();
459
460        let (subcmd, rest) = args.trim().split_once(' ').unwrap_or((args.trim(), ""));
461
462        // Overlay subcommand does not need PluginManager; resolve early to avoid moving plugins_dir.
463        if subcmd == "overlay" || (matches!(subcmd, "" | "list") && rest.trim() == "--overlay") {
464            return format_overlay_section(&plugins_dir);
465        }
466
467        // Fall back to the canonical default managed skills dir so the conflict check is
468        // never silently disabled by an empty path (M5 fix).
469        let managed_dir = managed_dir
470            .unwrap_or_else(|| zeph_config::defaults::default_vault_dir().join("skills"));
471        let mgr = zeph_plugins::PluginManager::new(
472            plugins_dir,
473            managed_dir,
474            mcp_allowed,
475            base_shell_allowed,
476        )
477        .with_reputation_config(reputation_cfg, false);
478
479        match subcmd {
480            "" | "list" => match mgr.list_installed() {
481                Ok(plugins) if plugins.is_empty() && ephemeral_plugin_names.is_empty() => {
482                    "No plugins installed.".to_owned()
483                }
484                Ok(plugins) => {
485                    let mut lines: Vec<String> = plugins
486                        .iter()
487                        .map(|p| format!("{} v{} — {}", p.name, p.version, p.description))
488                        .collect();
489                    for name in &ephemeral_plugin_names {
490                        lines.push(format!("{name} [ephemeral]"));
491                    }
492                    lines.join("\n")
493                }
494                Err(e) => format!("plugin list failed: {e}"),
495            },
496            "add" => {
497                use std::fmt::Write as _;
498                if rest.is_empty() {
499                    return "Usage: /plugins add <source>".to_owned();
500                }
501                match mgr.add(rest.trim()) {
502                    Ok(r) => {
503                        let mut out = format!("Installed plugin \"{}\"", r.name);
504                        if !r.installed_skills.is_empty() {
505                            let _ = write!(out, "\n  Skills: {}", r.installed_skills.join(", "));
506                        }
507                        if !r.mcp_server_ids.is_empty() {
508                            let _ = write!(
509                                out,
510                                "\n  MCP servers (restart required): {}",
511                                r.mcp_server_ids.join(", ")
512                            );
513                        }
514                        for w in &r.warnings {
515                            let _ = write!(out, "\n  warning: {w}");
516                        }
517                        out
518                    }
519                    Err(e) => format!("plugin add failed: {e}"),
520                }
521            }
522            "remove" => {
523                use std::fmt::Write as _;
524                if rest.is_empty() {
525                    return "Usage: /plugins remove <name>".to_owned();
526                }
527                match mgr.remove(rest.trim()) {
528                    Ok(r) => {
529                        let mut out = format!("Removed plugin \"{}\"", rest.trim());
530                        if !r.removed_skills.is_empty() {
531                            let _ =
532                                write!(out, "\n  Removed skills: {}", r.removed_skills.join(", "));
533                        }
534                        out
535                    }
536                    Err(e) => format!("plugin remove failed: {e}"),
537                }
538            }
539            other => {
540                format!(
541                    "Unknown /plugins subcommand: '{other}'. Available: list, list --overlay, overlay, add, remove"
542                )
543            }
544        }
545    }
546
547    #[tracing::instrument(skip_all, name = "core.agent.handle_skills")]
548    pub(super) async fn handle_skills_as_string(
549        &mut self,
550        subcommand: &str,
551    ) -> Result<String, error::AgentError> {
552        match subcommand {
553            "" => self.handle_skills_command_as_string().await,
554            "confusability" => self.handle_skills_confusability_as_string().await,
555            "injection" => self.handle_skills_injection_as_string(),
556            "trust" => self.handle_skills_trust_as_string(),
557            other => Ok(format!(
558                "Unknown /skills subcommand: '{other}'. Available: confusability, injection, trust"
559            )),
560        }
561    }
562
563    #[tracing::instrument(skip_all, name = "core.agent.handle_skills_command")]
564    async fn handle_skills_command_as_string(&mut self) -> Result<String, error::AgentError> {
565        use std::collections::BTreeMap;
566        use std::fmt::Write;
567
568        let (all_meta, load_errors): (
569            Vec<zeph_skills::loader::SkillMeta>,
570            Vec<(std::path::PathBuf, String)>,
571        ) = {
572            let reg = self.services.skill.registry.read();
573            (
574                reg.all_meta().into_iter().cloned().collect(),
575                reg.load_errors().to_vec(),
576            )
577        };
578
579        // Clone Arc before .await to avoid holding &self across suspension points.
580        let memory = self.services.memory.persistence.memory.clone();
581        let mut trust_map: std::collections::HashMap<String, String> =
582            std::collections::HashMap::new();
583        for meta in &all_meta {
584            if let Some(ref memory) = memory {
585                let info = memory
586                    .sqlite()
587                    .load_skill_trust(&meta.name)
588                    .await
589                    .ok()
590                    .flatten()
591                    .map_or_else(String::new, |r| format!(" [{}]", r.trust_level));
592                trust_map.insert(meta.name.clone(), info);
593            }
594        }
595
596        let mut output = String::from("Available skills:\n\n");
597
598        let has_categories = all_meta.iter().any(|m| m.category.is_some());
599        if has_categories {
600            let mut by_category: BTreeMap<&str, Vec<&zeph_skills::loader::SkillMeta>> =
601                BTreeMap::new();
602            for meta in &all_meta {
603                let cat = meta.category.as_deref().unwrap_or("other");
604                by_category.entry(cat).or_default().push(meta);
605            }
606            for (cat, skills) in &by_category {
607                let _ = writeln!(output, "[{cat}]");
608                for meta in skills {
609                    let trust_info = trust_map.get(&meta.name).map_or("", String::as_str);
610                    let _ = writeln!(output, "- {} — {}{trust_info}", meta.name, meta.description);
611                }
612                output.push('\n');
613            }
614        } else {
615            for meta in &all_meta {
616                let trust_info = trust_map.get(&meta.name).map_or("", String::as_str);
617                let _ = writeln!(output, "- {} — {}{trust_info}", meta.name, meta.description);
618            }
619        }
620
621        if let Some(ref memory) = memory {
622            match memory.sqlite().load_skill_usage().await {
623                Ok(usage) if !usage.is_empty() => {
624                    output.push_str("\nUsage statistics:\n\n");
625                    for row in &usage {
626                        let _ = writeln!(
627                            output,
628                            "- {}: {} invocations (last: {})",
629                            row.skill_name, row.invocation_count, row.last_used_at,
630                        );
631                    }
632                }
633                Ok(_) => {}
634                Err(e) => tracing::warn!("failed to load skill usage: {e:#}"),
635            }
636        }
637
638        if !load_errors.is_empty() {
639            output.push_str("\nFailed to load:\n");
640            for (path, reason) in &load_errors {
641                let _ = writeln!(output, "- {}: {reason}", path.display());
642            }
643        }
644
645        Ok(output)
646    }
647
648    /// Start a user-driven loop that injects `prompt` every `interval_secs` seconds.
649    pub(crate) fn start_user_loop(&mut self, prompt: String, interval_secs: u64) {
650        use std::time::Duration;
651        use tokio::time::{Instant, MissedTickBehavior};
652
653        let period = Duration::from_secs(interval_secs);
654        // interval_at(now + period, period) ensures the first tick fires after one full period,
655        // not immediately. tokio::time::interval() would fire at t=0 which is never desired.
656        let mut interval = tokio::time::interval_at(Instant::now() + period, period);
657        interval.set_missed_tick_behavior(MissedTickBehavior::Skip);
658
659        let cancel_tx = tokio_util::sync::CancellationToken::new();
660        self.runtime.lifecycle.user_loop = Some(crate::agent::state::LoopState {
661            prompt,
662            iteration: 0,
663            interval,
664            cancel_tx,
665        });
666    }
667
668    /// Stop the active user loop and return a user-visible message.
669    pub(crate) fn stop_user_loop(&mut self) -> String {
670        if let Some(ls) = self.runtime.lifecycle.user_loop.take() {
671            let iters = ls.iteration;
672            ls.cancel_tx.cancel();
673            format!("Loop stopped after {iters} iteration(s).")
674        } else {
675            "No active loop.".to_owned()
676        }
677    }
678
679    #[tracing::instrument(skip_all, name = "core.agent.handle_skills_confusability")]
680    async fn handle_skills_confusability_as_string(&mut self) -> Result<String, error::AgentError> {
681        let threshold = self.services.skill.confusability_threshold;
682        if threshold <= 0.0 {
683            return Ok("Confusability monitoring is disabled. \
684                 Set [skills] confusability_threshold in config (e.g. 0.85) to enable."
685                .to_owned());
686        }
687
688        let Some(matcher) = &self.services.skill.matcher else {
689            return Ok(
690                "Skill matcher not available (no embedding provider configured).".to_owned(),
691            );
692        };
693
694        let all_meta: Vec<zeph_skills::loader::SkillMeta> = self
695            .services
696            .skill
697            .registry
698            .read()
699            .all_meta()
700            .into_iter()
701            .cloned()
702            .collect();
703        let refs: Vec<&zeph_skills::loader::SkillMeta> = all_meta.iter().collect();
704
705        let report = matcher.confusability_report(&refs, threshold).await;
706        Ok(report.to_string())
707    }
708
709    /// Report the current `GoSkills` grouping / injection-score config, as applied to this
710    /// `Agent` instance. Exists so tests outside `zeph-core` can observe that
711    /// `group_structured`, `support_similarity_threshold`, and `min_injection_score` reached
712    /// the constructed `Agent` at cold start, mirroring `handle_skills_confusability_as_string`.
713    #[tracing::instrument(skip_all, name = "core.agent.handle_skills_injection")]
714    fn handle_skills_injection_as_string(&self) -> Result<String, error::AgentError> {
715        Ok(format!(
716            "Skill injection config: group_structured={}, support_similarity_threshold={:.2}, min_injection_score={:.2}",
717            self.services.skill.group_structured,
718            self.services.skill.support_similarity_threshold,
719            self.services.skill.min_injection_score,
720        ))
721    }
722
723    /// Report the current skill-trust levels and `SkillOrchestra` RL routing state, as applied
724    /// to this `Agent` instance. Exists so tests outside `zeph-core` can observe that
725    /// `with_trust_config` and `with_rl_routing`/`with_rl_head` reached the constructed `Agent`
726    /// at cold start (#5920/#5921), mirroring `handle_skills_injection_as_string` (#5867) for
727    /// the same wire-X-into-ACP/serve/daemon defect class.
728    #[tracing::instrument(skip_all, name = "core.agent.handle_skills_trust")]
729    fn handle_skills_trust_as_string(&self) -> Result<String, error::AgentError> {
730        let trust = &self.services.skill.trust_config;
731        let rl_enabled = self
732            .services
733            .learning_engine
734            .rl_routing
735            .as_ref()
736            .is_some_and(|r| r.enabled);
737        Ok(format!(
738            "Skill trust config: default_level={:?}, local_level={:?}, bundled_level={:?}, \
739             hash_mismatch_level={:?} | RL routing: enabled={}, rl_head_loaded={}",
740            trust.default_level,
741            trust.local_level,
742            trust.bundled_level,
743            trust.hash_mismatch_level,
744            rl_enabled,
745            self.services.skill.rl_head.is_some(),
746        ))
747    }
748}
749
750/// Builds the phase-1 (session/debug) command registry used by [`Agent::run`]: handlers
751/// that only need `ChannelSink`/`DebugAccess`/`MessageAccess`/`SessionAccess`, not
752/// `&mut Agent<C>` itself.
753///
754/// Extracted into its own function (rather than inlined in `run`) so the exact set of
755/// registered handlers can be inspected by tests without duplicating the `.register()`
756/// call list — see `commands_rs_drift_tests` for the regression guard this enables
757/// against `zeph_commands::COMMANDS` silently drifting from the real registrations (#5987).
758pub(crate) fn build_session_debug_registry<'ctx>()
759-> zeph_commands::CommandRegistry<zeph_commands::CommandContext<'ctx>> {
760    use zeph_commands::CommandRegistry;
761    use zeph_commands::handlers::debug::{DebugDumpCommand, DumpFormatCommand, LogCommand};
762    use zeph_commands::handlers::help::HelpCommand;
763    use zeph_commands::handlers::session::{
764        ClearCommand, ClearQueueCommand, ExitCommand, HistoryCommand, QuitCommand, ResetCommand,
765    };
766
767    let mut reg = CommandRegistry::new();
768    reg.register(ExitCommand);
769    reg.register(QuitCommand);
770    reg.register(ClearCommand);
771    reg.register(ResetCommand);
772    reg.register(ClearQueueCommand);
773    reg.register(HistoryCommand);
774    reg.register(LogCommand);
775    reg.register(DebugDumpCommand);
776    reg.register(DumpFormatCommand);
777    reg.register(HelpCommand);
778    #[cfg(test)]
779    reg.register(super::test_stubs::TestErrorCommand);
780    reg
781}
782
783/// Builds the phase-2 (agent-command) registry used by [`Agent::run`]: handlers that
784/// need `&mut Agent<C>` directly.
785///
786/// See [`build_session_debug_registry`] for why this is extracted.
787pub(crate) fn build_agent_command_registry<'ctx>()
788-> zeph_commands::CommandRegistry<zeph_commands::CommandContext<'ctx>> {
789    use zeph_commands::CommandRegistry;
790    use zeph_commands::handlers::{
791        acp::AcpCommand,
792        agent_cmd::AgentCommand,
793        agents_fleet::AgentsFleetCommand,
794        caveman::CavemanCommand,
795        cd::CdCommand,
796        checkpoint::{RedoCommand, UndoCommand},
797        compaction::{CompactCommand, NewConversationCommand, RecapCommand},
798        conv::ConvCommand,
799        experiment::ExperimentCommand,
800        goal::GoalCommand,
801        loop_cmd::LoopCommand,
802        lsp::LspCommand,
803        mcp::McpCommand,
804        memory::{
805            GraphCommand, GuidelinesCommand, KnowledgeSlashCommand, MemoryCommand,
806            StoreSlashCommand,
807        },
808        misc::{CacheStatsCommand, ImageCommand, NotifyTestCommand},
809        model::{ModelCommand, ProviderCommand},
810        plan::PlanCommand,
811        plugins::PluginsCommand,
812        policy::PolicyCommand,
813        reasoning_effort::ReasoningEffortCommand,
814        scheduler::SchedulerCommand,
815        search::SearchCommand,
816        skill::{FeedbackCommand, SkillCommand, SkillsCommand},
817        status::{FocusCommand, GuardrailCommand, SideQuestCommand, StatusCommand},
818        think_tokens::ThinkTokensCommand,
819        trajectory::{ScopeCommand, TrajectoryCommand},
820        worktree::WorktreeCommand,
821    };
822
823    let mut agent_reg = CommandRegistry::new();
824    agent_reg.register(CavemanCommand);
825    agent_reg.register(CdCommand);
826    agent_reg.register(MemoryCommand);
827    agent_reg.register(StoreSlashCommand);
828    agent_reg.register(GraphCommand);
829    agent_reg.register(KnowledgeSlashCommand);
830    agent_reg.register(GuidelinesCommand);
831    agent_reg.register(ModelCommand);
832    agent_reg.register(ProviderCommand);
833    agent_reg.register(ThinkTokensCommand);
834    agent_reg.register(ReasoningEffortCommand);
835    // Phase 6 migrations: /skill, /skills, /feedback use clone-before-await pattern.
836    agent_reg.register(SkillCommand);
837    agent_reg.register(SkillsCommand);
838    agent_reg.register(FeedbackCommand);
839    agent_reg.register(McpCommand);
840    agent_reg.register(PolicyCommand);
841    agent_reg.register(SchedulerCommand);
842    agent_reg.register(SearchCommand);
843    agent_reg.register(LspCommand);
844    // Phase 4 migrations (Send-safe commands):
845    agent_reg.register(CacheStatsCommand);
846    agent_reg.register(ImageCommand);
847    agent_reg.register(NotifyTestCommand);
848    agent_reg.register(StatusCommand);
849    agent_reg.register(GuardrailCommand);
850    agent_reg.register(FocusCommand);
851    agent_reg.register(SideQuestCommand);
852    agent_reg.register(AgentCommand);
853    agent_reg.register(AgentsFleetCommand);
854    // Phase 5 migrations (Send-compatible):
855    agent_reg.register(CompactCommand);
856    agent_reg.register(NewConversationCommand);
857    agent_reg.register(RecapCommand);
858    agent_reg.register(ExperimentCommand);
859    agent_reg.register(PlanCommand);
860    agent_reg.register(LoopCommand);
861    agent_reg.register(PluginsCommand);
862    agent_reg.register(AcpCommand);
863    #[cfg(feature = "cocoon")]
864    agent_reg.register(zeph_commands::handlers::cocoon::CocoonCommand);
865    agent_reg.register(TrajectoryCommand);
866    agent_reg.register(ScopeCommand);
867    agent_reg.register(GoalCommand);
868    agent_reg.register(UndoCommand);
869    agent_reg.register(RedoCommand);
870    agent_reg.register(ConvCommand);
871    agent_reg.register(WorktreeCommand);
872    agent_reg
873}
874
875struct StatusMetrics {
876    api_calls: u64,
877    prompt_tokens: u64,
878    completion_tokens: u64,
879    reasoning_tokens: u64,
880    cost_cents: f64,
881    mcp_servers: usize,
882    orch_plans: u64,
883    orch_tasks: u64,
884    orch_completed: u64,
885    orch_failed: u64,
886    orch_skipped: u64,
887    ensemble_degraded: u64,
888    ensemble_agreement_ratio: Option<f64>,
889    ensemble_member_stats: Vec<(String, f64, u64)>,
890    provider_breakdown: Vec<(String, crate::cost::ProviderUsage)>,
891}
892
893fn collect_status_metrics(
894    metrics_tx: Option<&tokio::sync::watch::Sender<crate::metrics::MetricsSnapshot>>,
895) -> StatusMetrics {
896    if let Some(tx) = metrics_tx {
897        let m = tx.borrow();
898        StatusMetrics {
899            api_calls: m.api_calls,
900            prompt_tokens: m.prompt_tokens,
901            completion_tokens: m.completion_tokens,
902            reasoning_tokens: m.reasoning_tokens,
903            cost_cents: m.cost_spent_cents,
904            mcp_servers: m.mcp_server_count,
905            orch_plans: m.orchestration.plans_total,
906            orch_tasks: m.orchestration.tasks_total,
907            orch_completed: m.orchestration.tasks_completed,
908            orch_failed: m.orchestration.tasks_failed,
909            orch_skipped: m.orchestration.tasks_skipped,
910            ensemble_degraded: m.orchestration.ensemble_degraded_total,
911            ensemble_agreement_ratio: m.orchestration.ensemble_last_agreement_ratio,
912            ensemble_member_stats: m.orchestration.ensemble_member_stats.clone(),
913            provider_breakdown: m.provider_cost_breakdown.clone(),
914        }
915    } else {
916        StatusMetrics {
917            api_calls: 0,
918            prompt_tokens: 0,
919            completion_tokens: 0,
920            reasoning_tokens: 0,
921            cost_cents: 0.0,
922            mcp_servers: 0,
923            orch_plans: 0,
924            orch_tasks: 0,
925            orch_completed: 0,
926            orch_failed: 0,
927            orch_skipped: 0,
928            ensemble_degraded: 0,
929            ensemble_agreement_ratio: None,
930            ensemble_member_stats: vec![],
931            provider_breakdown: vec![],
932        }
933    }
934}
935
936fn append_cost_section(
937    out: &mut String,
938    cost_cents: f64,
939    provider_breakdown: &[(String, crate::cost::ProviderUsage)],
940) {
941    use std::fmt::Write;
942    if cost_cents > 0.0 {
943        let _ = writeln!(out, "Cost:      ${:.4}", cost_cents / 100.0);
944        if !provider_breakdown.is_empty() {
945            let _ = writeln!(
946                out,
947                "  {:<16} {:>8} {:>8} {:>8}",
948                "Provider", "Requests", "Tokens", "Cost"
949            );
950            for (name, usage) in provider_breakdown {
951                let total_tokens = usage.input_tokens + usage.output_tokens;
952                let _ = writeln!(
953                    out,
954                    "  {:<16} {:>8} {:>8} {:>8}",
955                    name,
956                    usage.request_count,
957                    total_tokens,
958                    format!("${:.4}", usage.cost_cents / 100.0),
959                );
960            }
961        }
962    }
963}
964
965fn append_orchestration_section(
966    out: &mut String,
967    orch_plans: u64,
968    orch_tasks: u64,
969    orch_completed: u64,
970    orch_failed: u64,
971    orch_skipped: u64,
972) {
973    use std::fmt::Write;
974    if orch_plans > 0 {
975        let _ = writeln!(out);
976        let _ = writeln!(out, "Orchestration:");
977        let _ = writeln!(out, "  Plans:     {orch_plans}");
978        let _ = writeln!(out, "  Tasks:     {orch_completed}/{orch_tasks} completed");
979        if orch_failed > 0 {
980            let _ = writeln!(out, "  Failed:    {orch_failed}");
981        }
982        if orch_skipped > 0 {
983            let _ = writeln!(out, "  Skipped:   {orch_skipped}");
984        }
985    }
986}
987
988/// Append ensemble-verified plan verification stats (spec `073-orch-ensemble-merge`) to the
989/// `/status` output. Silent (no section printed) when the ensemble has never run — the
990/// member-stats list is empty and no member has ever cast a ballot.
991fn append_ensemble_section(
992    out: &mut String,
993    ensemble_degraded: u64,
994    ensemble_agreement_ratio: Option<f64>,
995    ensemble_member_stats: &[(String, f64, u64)],
996) {
997    use std::fmt::Write;
998    if ensemble_member_stats.is_empty() && ensemble_degraded == 0 {
999        return;
1000    }
1001    let _ = writeln!(out);
1002    let _ = writeln!(out, "Ensemble verify:");
1003    if let Some(ratio) = ensemble_agreement_ratio {
1004        let _ = writeln!(out, "  Last agreement: {:.0}%", ratio * 100.0);
1005    }
1006    if ensemble_degraded > 0 {
1007        let _ = writeln!(out, "  Degraded:  {ensemble_degraded} (quorum fallback)");
1008    }
1009    for (member, score, observations) in ensemble_member_stats {
1010        let _ = writeln!(out, "  {member:<16} ema={score:.2} (n={observations})");
1011    }
1012}
1013
1014fn append_pruning_section(
1015    out: &mut String,
1016    pruning_strategy: crate::config::PruningStrategy,
1017    subgoal_count: usize,
1018    active_subgoal: Option<&zeph_agent_context::compaction::Subgoal>,
1019) {
1020    use crate::config::PruningStrategy;
1021    use std::fmt::Write;
1022    if matches!(
1023        pruning_strategy,
1024        PruningStrategy::Subgoal | PruningStrategy::SubgoalMig
1025    ) {
1026        let _ = writeln!(out);
1027        let _ = writeln!(
1028            out,
1029            "Pruning:   {}",
1030            match pruning_strategy {
1031                PruningStrategy::SubgoalMig => "subgoal_mig",
1032                _ => "subgoal",
1033            }
1034        );
1035        let _ = writeln!(out, "Subgoals:  {subgoal_count} tracked");
1036        if let Some(active) = active_subgoal {
1037            let _ = writeln!(out, "Active:    \"{}\"", active.description);
1038        } else {
1039            let _ = writeln!(out, "Active:    (none yet)");
1040        }
1041    }
1042}
1043
1044fn append_graph_recall_section(out: &mut String, gc: &zeph_config::memory::GraphConfig) {
1045    use std::fmt::Write;
1046    if gc.enabled {
1047        let _ = writeln!(out);
1048        if gc.spreading_activation.enabled {
1049            let _ = writeln!(
1050                out,
1051                "Graph recall: spreading activation (lambda={:.2}, hops={})",
1052                gc.spreading_activation.decay_lambda, gc.spreading_activation.max_hops,
1053            );
1054        } else {
1055            let _ = writeln!(out, "Graph recall: BFS (hops={})", gc.max_hops);
1056        }
1057    }
1058}
1059
1060impl<C: Channel> Agent<C> {
1061    /// `/conv resume <id>` (spec-068, #5343, D-9): mid-session live swap onto an existing
1062    /// durable session. Resolves `conversation_id` via the `SessionId`<->`ConversationId`
1063    /// bijection (spec §5.2) — reuses the session's existing linked conversation if one exists,
1064    /// otherwise mints one and links it (a session created via the HTTP API's `POST /sessions`,
1065    /// or a legacy session, may not have one yet).
1066    async fn handle_conv_resume(&mut self, id: &str) -> Result<String, CommandError> {
1067        if id.is_empty() {
1068            return Ok("Usage: /conv resume <id>".to_owned());
1069        }
1070        // #5487 fix 3: `load_and_resume_conversation` now opens the target session's event log
1071        // exclusively (INV-D2). Resuming into the session already live in this agent would try
1072        // to acquire a second exclusive lock on the same directory this agent's own
1073        // `SessionSink` already holds open, deadlocking on `AlreadyLocked` — short-circuit with a
1074        // clear message instead of attempting a self-conflicting reopen.
1075        if let Some(sink) = &self.services.session.session_sink
1076            && sink.session_id().as_str() == id
1077        {
1078            return Ok(format!("Already in session '{id}'."));
1079        }
1080        let Some(memory) = self.services.memory.persistence.memory.clone() else {
1081            return Ok(
1082                "Conversation-session persistence requires memory to be enabled ([memory] enabled = true)."
1083                    .to_owned(),
1084            );
1085        };
1086        let store = zeph_session::SessionStore::new(memory.sqlite().pool().clone());
1087        let Some(metadata) = store
1088            .get(id)
1089            .await
1090            .map_err(|e| CommandError::new(e.to_string()))?
1091        else {
1092            return Ok(format!("Session '{id}' not found."));
1093        };
1094
1095        let conversation_id = if let Some(cid) = metadata.conversation_id {
1096            zeph_memory::ConversationId(cid)
1097        } else {
1098            let cid = memory
1099                .sqlite()
1100                .create_conversation()
1101                .await
1102                .map_err(|e| CommandError::new(e.to_string()))?;
1103            store
1104                .link_conversation(id, cid.0)
1105                .await
1106                .map_err(|e| CommandError::new(e.to_string()))?;
1107            cid
1108        };
1109
1110        let session_id = zeph_common::SessionId::new(id);
1111        self.load_and_resume_conversation(&session_id, conversation_id)
1112            .await
1113            .map_err(|e| CommandError::new(e.to_string()))?;
1114
1115        Ok(format!(
1116            "Resumed session {id} ({} event(s) replayed).",
1117            metadata.event_count
1118        ))
1119    }
1120
1121    /// `/conv fork <id>` (spec-068, #5343, D-9): eager-copies `id`'s durable log into a fresh
1122    /// child session via `ForkEngine::fork` (P2), then immediately live-swaps onto the child —
1123    /// same effect as `POST /sessions/:id/fork` (spec §9.4) but for the current CLI/TUI session
1124    /// instead of spawning a new `SessionActor`.
1125    async fn handle_conv_fork(&mut self, id: &str) -> Result<String, CommandError> {
1126        if id.is_empty() {
1127            return Ok("Usage: /conv fork <id>".to_owned());
1128        }
1129        let Some(memory) = self.services.memory.persistence.memory.clone() else {
1130            return Ok(
1131                "Conversation-session persistence requires memory to be enabled ([memory] enabled = true)."
1132                    .to_owned(),
1133            );
1134        };
1135        let Some(session_persistence_config) =
1136            self.services.session.session_persistence_config.clone()
1137        else {
1138            return Ok(
1139                "Conversation-session persistence is not enabled ([session] enabled = true)."
1140                    .to_owned(),
1141            );
1142        };
1143        let store = zeph_session::SessionStore::new(memory.sqlite().pool().clone());
1144        let data_dir = std::path::PathBuf::from(&session_persistence_config.data_dir);
1145        let new_id = zeph_common::SessionId::generate();
1146
1147        let fork_result =
1148            zeph_session::ForkEngine::fork(&data_dir, id, new_id.as_str(), None, &store, None)
1149                .await
1150                .map_err(|e| CommandError::new(e.to_string()))?;
1151
1152        let conversation_id = memory
1153            .sqlite()
1154            .create_conversation()
1155            .await
1156            .map_err(|e| CommandError::new(e.to_string()))?;
1157
1158        self.load_and_resume_conversation(&new_id, conversation_id)
1159            .await
1160            .map_err(|e| CommandError::new(e.to_string()))?;
1161
1162        Ok(format!(
1163            "Forked session {id} -> {} ({} event(s) copied); now the active conversation.",
1164            fork_result.new_session_id, fork_result.events_copied
1165        ))
1166    }
1167}
1168
1169/// Formats `/conv list` — mirrors `sessions list`'s CLI table layout
1170/// (`src/commands/sessions.rs`) and `zeph serve-sessions`'s `GET /sessions`.
1171async fn handle_conv_list(store: &zeph_session::SessionStore) -> Result<String, CommandError> {
1172    use std::fmt::Write as _;
1173
1174    let sessions = store
1175        .list(&zeph_session::SessionFilter::default())
1176        .await
1177        .map_err(|e| CommandError::new(format!("failed to list sessions: {e}")))?;
1178
1179    if sessions.is_empty() {
1180        return Ok("No conversation-sessions found.".to_owned());
1181    }
1182
1183    let mut out = format!(
1184        "{:<38} {:<30} {:<9} {:>6} {:<24}\n",
1185        "ID", "TITLE", "STATUS", "EVENTS", "UPDATED"
1186    );
1187    out.push_str(&"-".repeat(110));
1188    out.push('\n');
1189    for s in &sessions {
1190        let title = s.title.as_deref().unwrap_or("(untitled)");
1191        let _ = writeln!(
1192            out,
1193            "{:<38} {:<30} {:<9} {:>6} {:<24}",
1194            s.session_id,
1195            crate::text::truncate_to_chars(title, 30),
1196            s.status.as_str(),
1197            s.event_count,
1198            s.updated_at
1199        );
1200    }
1201    Ok(out.trim_end().to_owned())
1202}
1203
1204/// Formats `/conv show <id>` — one session's metadata, mirroring `zeph serve-sessions`'s
1205/// `GET /sessions/:id` (metadata only; use `zeph sessions show --events <id>` on the CLI for a
1206/// full event-log dump).
1207async fn handle_conv_show(
1208    store: &zeph_session::SessionStore,
1209    id: &str,
1210) -> Result<String, CommandError> {
1211    if id.is_empty() {
1212        return Ok("Usage: /conv show <id>".to_owned());
1213    }
1214    let metadata = store
1215        .get(id)
1216        .await
1217        .map_err(|e| CommandError::new(format!("failed to read session metadata: {e}")))?;
1218    let Some(m) = metadata else {
1219        return Ok(format!("Session '{id}' not found."));
1220    };
1221    Ok(format!(
1222        "Session {}\n  title: {}\n  status: {}\n  events: {} (last_seq={})\n  forked_from: {}\n  created: {}\n  updated: {}",
1223        m.session_id,
1224        m.title.as_deref().unwrap_or("(untitled)"),
1225        m.status.as_str(),
1226        m.event_count,
1227        m.last_seq,
1228        m.forked_from.as_deref().unwrap_or("-"),
1229        m.created_at,
1230        m.updated_at
1231    ))
1232}
1233
1234impl<C: Channel + Send + 'static> SessionControlAccess for Agent<C> {
1235    // ----- /recap -----
1236
1237    fn session_recap<'a>(
1238        &'a mut self,
1239    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1240        Box::pin(
1241            async move {
1242                match self.build_recap().await {
1243                    Ok(text) => Ok(text),
1244                    Err(e) => {
1245                        // /recap is an explicit user command — surface a fixed message so that
1246                        // LlmError internals (URLs with embedded credentials, response excerpts)
1247                        // are never forwarded to the user channel. Full detail goes to the log.
1248                        tracing::warn!("session recap command: {}", e.0);
1249                        Ok("Recap unavailable — see logs for details".to_string())
1250                    }
1251                }
1252            }
1253            .instrument(tracing::info_span!("core.agent_access.session_recap")),
1254        )
1255    }
1256
1257    // ----- /compact -----
1258
1259    fn compact_context<'a>(
1260        &'a mut self,
1261    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1262        Box::pin(
1263            self.compact_context_command()
1264                .instrument(tracing::info_span!("core.agent_access.compact_context")),
1265        )
1266    }
1267
1268    // ----- /new -----
1269
1270    fn reset_conversation<'a>(
1271        &'a mut self,
1272        keep_plan: bool,
1273        no_digest: bool,
1274    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1275        Box::pin(async move {
1276            match self.reset_conversation(keep_plan, no_digest).await {
1277                Ok((old_id, new_id)) => {
1278                    let old = old_id.map_or_else(|| "none".to_string(), |id| id.0.to_string());
1279                    let new = new_id.map_or_else(|| "none".to_string(), |id| id.0.to_string());
1280                    let keep_note = if keep_plan { " (plan preserved)" } else { "" };
1281                    Ok(format!(
1282                        "New conversation started. Previous: {old} → Current: {new}{keep_note}"
1283                    ))
1284                }
1285                Err(e) => Ok(format!("Failed to start new conversation: {e}")),
1286            }
1287        })
1288    }
1289
1290    // ----- /cache-stats -----
1291
1292    fn cache_stats(&self) -> String {
1293        self.tool_orchestrator.cache_stats()
1294    }
1295
1296    // ----- /status -----
1297
1298    fn session_status<'a>(
1299        &'a mut self,
1300    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1301        Box::pin(async move { Ok(self.handle_status_as_string()) })
1302    }
1303
1304    // ----- /guardrail -----
1305
1306    fn guardrail_status(&self) -> String {
1307        self.format_guardrail_status()
1308    }
1309
1310    // ----- /focus -----
1311
1312    fn focus_status(&self) -> String {
1313        self.format_focus_status()
1314    }
1315
1316    // ----- /sidequest -----
1317
1318    fn sidequest_status(&self) -> String {
1319        self.format_sidequest_status()
1320    }
1321
1322    // ----- /image -----
1323
1324    fn load_image<'a>(
1325        &'a mut self,
1326        path: &'a str,
1327    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1328        use zeph_common::path_guard::{PathRejection, classify_relative_path};
1329        use zeph_llm::provider::{ImageData, MessagePart};
1330
1331        match classify_relative_path(path) {
1332            PathRejection::Allowed => {}
1333            PathRejection::Absolute => {
1334                return Box::pin(async move {
1335                    Ok(
1336                        "Invalid image path: absolute paths are not supported, use a path \
1337                        relative to the working directory"
1338                            .to_owned(),
1339                    )
1340                });
1341            }
1342            PathRejection::Traversal => {
1343                return Box::pin(async move {
1344                    Ok("Invalid image path: path traversal ('..') is not allowed".to_owned())
1345                });
1346            }
1347        }
1348
1349        let path_owned = path.to_owned();
1350        Box::pin(async move {
1351            let path_for_task = path_owned.clone();
1352            let read_result = tokio::task::spawn_blocking(move || std::fs::read(&path_for_task))
1353                .await
1354                .map_err(|e| CommandError::new(format!("spawn_blocking join error: {e}")))?;
1355            let data = match read_result {
1356                Ok(d) => d,
1357                Err(e) => return Ok(format!("Cannot read image {path_owned}: {e}")),
1358            };
1359            if data.len() > crate::agent::message_queue::MAX_IMAGE_BYTES {
1360                return Ok(format!(
1361                    "Image {path_owned} exceeds size limit ({} MB), skipping",
1362                    crate::agent::message_queue::MAX_IMAGE_BYTES / 1024 / 1024
1363                ));
1364            }
1365            let mime_type =
1366                crate::agent::message_queue::detect_image_mime(Some(&path_owned)).to_string();
1367            self.msg
1368                .pending_image_parts
1369                .push(MessagePart::Image(Box::new(ImageData { data, mime_type })));
1370            Ok(format!("Image loaded: {path_owned}. Send your message."))
1371        })
1372    }
1373
1374    // ----- /undo, /redo -----
1375
1376    fn handle_undo<'a>(
1377        &'a mut self,
1378        args: &'a str,
1379    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1380        let executor = std::sync::Arc::clone(&self.tool_executor);
1381        let args_owned = args.trim().to_owned();
1382        Box::pin(async move {
1383            if args_owned == "list" {
1384                let result = executor.checkpoint_list_erased();
1385                if !result.supported {
1386                    return Ok(
1387                        "Checkpoints are not enabled. Set `[tools.shell] checkpoints_enabled = true` in config.".to_owned()
1388                    );
1389                }
1390                if result.entries.is_empty() {
1391                    return Ok("Undo stack is empty.".to_owned());
1392                }
1393                let mut lines = vec![format!("Undo stack ({} entries):", result.entries.len())];
1394                for e in &result.entries {
1395                    lines.push(format!(
1396                        "  [{}] {} ({} file(s))",
1397                        e.index, e.command, e.file_count
1398                    ));
1399                }
1400                if result.redo_depth > 0 {
1401                    lines.push(format!("Redo depth: {}", result.redo_depth));
1402                }
1403                return Ok(lines.join("\n"));
1404            }
1405
1406            let n: usize = if args_owned.is_empty() {
1407                1
1408            } else {
1409                match args_owned.parse::<usize>() {
1410                    Ok(v) if v > 0 => v,
1411                    _ => {
1412                        return Err(CommandError::new(format!(
1413                            "Invalid argument: expected a positive integer or 'list', got '{args_owned}'"
1414                        )));
1415                    }
1416                }
1417            };
1418
1419            let result = tokio::task::spawn_blocking(move || executor.checkpoint_undo_erased(n))
1420                .await
1421                .map_err(|e| CommandError::new(format!("undo task panicked: {e}")))?;
1422            if !result.supported {
1423                return Ok(
1424                    "Checkpoints are not enabled. Set `[tools.shell] checkpoints_enabled = true` in config.".to_owned()
1425                );
1426            }
1427            Ok(result.message)
1428        })
1429    }
1430
1431    fn handle_redo<'a>(
1432        &'a mut self,
1433        args: &'a str,
1434    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1435        let _ = args;
1436        let executor = std::sync::Arc::clone(&self.tool_executor);
1437        Box::pin(async move {
1438            let result = tokio::task::spawn_blocking(move || executor.checkpoint_redo_erased())
1439                .await
1440                .map_err(|e| CommandError::new(format!("redo task panicked: {e}")))?;
1441            if !result.supported {
1442                return Ok(
1443                    "Checkpoints are not enabled. Set `[tools.shell] checkpoints_enabled = true` in config.".to_owned()
1444                );
1445            }
1446            Ok(result.message)
1447        })
1448    }
1449
1450    // ----- /conv -----
1451
1452    fn handle_conv<'a>(
1453        &'a mut self,
1454        args: &'a str,
1455    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1456        let args_owned = args.trim().to_owned();
1457        Box::pin(async move {
1458            // `resume`/`fork` need `&mut self` (mid-session live conversation swap, D-9) —
1459            // handled first so `self` isn't already borrowed by the `list`/`show` path below.
1460            if let Some(id) = args_owned.strip_prefix("resume ") {
1461                return self.handle_conv_resume(id.trim()).await;
1462            }
1463            if let Some(id) = args_owned.strip_prefix("fork ") {
1464                return self.handle_conv_fork(id.trim()).await;
1465            }
1466
1467            let Some(memory) = self.services.memory.persistence.memory.clone() else {
1468                return Ok(
1469                    "Conversation-session persistence requires memory to be enabled ([memory] enabled = true)."
1470                        .to_owned(),
1471                );
1472            };
1473            let store = zeph_session::SessionStore::new(memory.sqlite().pool().clone());
1474
1475            if let Some(id) = args_owned.strip_prefix("show ") {
1476                return handle_conv_show(&store, id.trim()).await;
1477            }
1478            if args_owned.is_empty() || args_owned == "list" {
1479                return handle_conv_list(&store).await;
1480            }
1481            Ok(format!(
1482                "Unknown /conv subcommand '{args_owned}'. Usage: /conv [list | show <id> | resume <id> | fork <id>]"
1483            ))
1484        })
1485    }
1486}
1487
1488#[cfg(test)]
1489mod tests {
1490    use super::super::agent_tests::{
1491        MockChannel, MockToolExecutor, create_test_registry, mock_provider,
1492    };
1493    use super::*;
1494    use zeph_memory::semantic::SemanticMemory;
1495
1496    async fn memory_without_qdrant() -> SemanticMemory {
1497        SemanticMemory::new(
1498            ":memory:",
1499            "http://127.0.0.1:1",
1500            None,
1501            zeph_llm::any::AnyProvider::Mock(zeph_llm::mock::MockProvider::default()),
1502            "test-model",
1503        )
1504        .await
1505        .unwrap()
1506    }
1507
1508    /// #5904 SIGNIFICANT-1: `dispatch_slash_command` is the only slash-command dispatch path
1509    /// with no `trusted`/`requires_auth` check at all — it runs `/subagent spawn <cmd>`
1510    /// (external ACP process spawn) unconditionally regardless of channel trust. HTTP entry
1511    /// points (serve-sessions, gateway) rely on `zeph_commands::is_recognized_command`
1512    /// excluding every command dispatched here, so they never forward such a command raw
1513    /// expecting the registry's trust gate to catch it. If this function ever starts handling
1514    /// another command besides `/subagent` (`@mention` is not `/`-prefixed and is exempt),
1515    /// `zeph_commands::UNGATED_DISPATCH_COMMANDS` must be updated to exclude it too — this test
1516    /// pins the current, single exception so that omission is caught here, at the source of the
1517    /// trust-blind path, not only in `zeph-commands`.
1518    #[test]
1519    fn subagent_is_excluded_from_is_recognized_command() {
1520        assert!(!zeph_commands::is_recognized_command("/subagent"));
1521        assert!(!zeph_commands::is_recognized_command(
1522            "/subagent spawn zeph --acp"
1523        ));
1524    }
1525
1526    #[test]
1527    fn format_overlay_section_empty_dir() {
1528        let tmp = tempfile::tempdir().unwrap();
1529        let out = format_overlay_section(tmp.path());
1530        assert_eq!(out, "No plugin overlay active.");
1531    }
1532
1533    #[test]
1534    fn format_overlay_section_with_source_plugin() {
1535        let tmp = tempfile::tempdir().unwrap();
1536        let plugin_dir = tmp.path().join("myplugin");
1537        std::fs::create_dir_all(&plugin_dir).unwrap();
1538        let manifest = r#"
1539[plugin]
1540name = "myplugin"
1541version = "0.1.0"
1542description = "test"
1543
1544[config.tools.shell]
1545blocked_commands = ["curl"]
1546"#;
1547        std::fs::write(plugin_dir.join(".plugin.toml"), manifest).unwrap();
1548        let out = format_overlay_section(tmp.path());
1549        assert!(out.contains("Active plugin overlay:"));
1550        assert!(out.contains("myplugin"));
1551        assert!(out.contains("Source plugins:"));
1552        assert!(out.contains("Note:"));
1553    }
1554
1555    #[test]
1556    fn run_plugin_command_overlay_subcommand() {
1557        let tmp = tempfile::tempdir().unwrap();
1558        // Override default plugins dir is not possible in run_plugin_command since it uses
1559        // the canonical dir. Test that the function returns the expected prefix on an empty dir.
1560        // We test format_overlay_section directly for correctness; this test guards routing.
1561        let out = format_overlay_section(tmp.path());
1562        assert_eq!(out, "No plugin overlay active.");
1563    }
1564
1565    #[test]
1566    fn format_overlay_section_skipped_plugin_shows_reason() {
1567        let tmp = tempfile::tempdir().unwrap();
1568        // Write a plugin dir with an invalid manifest to trigger skipped_plugins.
1569        let plugin_dir = tmp.path().join("badplugin");
1570        std::fs::create_dir_all(&plugin_dir).unwrap();
1571        std::fs::write(
1572            plugin_dir.join(".plugin.toml"),
1573            b"not valid toml at all {{{{",
1574        )
1575        .unwrap();
1576        let out = format_overlay_section(tmp.path());
1577        // Either skipped with reason or empty overlay — either way must not panic.
1578        assert!(out.contains("No plugin overlay active.") || out.contains("badplugin"));
1579    }
1580
1581    // #5487 fix 3: `handle_conv_resume` had zero prior test coverage. Resuming into the
1582    // session already live in this agent must short-circuit before attempting to re-acquire
1583    // the exclusive lock this agent's own SessionSink already holds (a guaranteed
1584    // `AlreadyLocked` self-deadlock, since flock conflicts are per open-file-description, not
1585    // per-process).
1586    #[tokio::test]
1587    async fn handle_conv_resume_same_session_short_circuits() {
1588        let memory = memory_without_qdrant().await;
1589        let cid = memory.sqlite().create_conversation().await.unwrap();
1590        let dir = tempfile::tempdir().unwrap();
1591        let data_dir = dir.path().to_path_buf();
1592        let session_id = zeph_common::SessionId::new("s1");
1593        let session_path = zeph_session::session_dir(&data_dir, session_id.as_str());
1594        let log = zeph_session::SessionEventLog::open_exclusive(&session_path)
1595            .await
1596            .unwrap();
1597        let store = zeph_session::SessionStore::new(memory.sqlite().pool().clone());
1598        let sink = zeph_agent_persistence::SessionSink::new(
1599            std::sync::Arc::new(log),
1600            store,
1601            session_id.clone(),
1602        );
1603        let session_config = zeph_config::SessionConfig {
1604            enabled: true,
1605            data_dir: data_dir.to_string_lossy().into_owned(),
1606            ..Default::default()
1607        };
1608
1609        let mut agent = Agent::new(
1610            mock_provider(vec![]),
1611            MockChannel::new(vec![]),
1612            create_test_registry(),
1613            None,
1614            5,
1615            MockToolExecutor::no_tools(),
1616        )
1617        .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
1618        .with_session_sink(Some(std::sync::Arc::new(sink)))
1619        .with_session_persistence_config(Some(session_config));
1620
1621        let result = agent.handle_conv("resume s1").await.unwrap();
1622        assert_eq!(
1623            result, "Already in session 's1'.",
1624            "resuming into the currently-active session must short-circuit, not attempt \
1625             hydration/lock acquisition"
1626        );
1627    }
1628
1629    // Regression check for the guard above: resuming into a genuinely different session (not
1630    // the one already live in this agent) must still hydrate normally.
1631    #[tokio::test]
1632    async fn handle_conv_resume_different_session_still_hydrates() {
1633        let memory = memory_without_qdrant().await;
1634        let cid = memory.sqlite().create_conversation().await.unwrap();
1635        let dir = tempfile::tempdir().unwrap();
1636        let data_dir = dir.path().to_path_buf();
1637
1638        // Agent is currently "in" session s1, whose own lock is held by its SessionSink.
1639        let active_session_id = zeph_common::SessionId::new("s1");
1640        let active_session_path = zeph_session::session_dir(&data_dir, active_session_id.as_str());
1641        let active_log = zeph_session::SessionEventLog::open_exclusive(&active_session_path)
1642            .await
1643            .unwrap();
1644        let active_store = zeph_session::SessionStore::new(memory.sqlite().pool().clone());
1645        let active_sink = zeph_agent_persistence::SessionSink::new(
1646            std::sync::Arc::new(active_log),
1647            active_store,
1648            active_session_id,
1649        );
1650
1651        // Target session s2 exists in the store (unlocked directory) — this is what should be
1652        // resumed into.
1653        let store = zeph_session::SessionStore::new(memory.sqlite().pool().clone());
1654        store.create("s2").await.unwrap();
1655
1656        let session_config = zeph_config::SessionConfig {
1657            enabled: true,
1658            data_dir: data_dir.to_string_lossy().into_owned(),
1659            ..Default::default()
1660        };
1661
1662        let mut agent = Agent::new(
1663            mock_provider(vec![]),
1664            MockChannel::new(vec![]),
1665            create_test_registry(),
1666            None,
1667            5,
1668            MockToolExecutor::no_tools(),
1669        )
1670        .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
1671        .with_session_sink(Some(std::sync::Arc::new(active_sink)))
1672        .with_session_persistence_config(Some(session_config));
1673
1674        let result = agent.handle_conv("resume s2").await.unwrap();
1675        assert!(
1676            result.starts_with("Resumed session s2"),
1677            "resuming into a different, unlocked session must still hydrate normally, got: {result}"
1678        );
1679    }
1680
1681    // #5764: `/conv fork` had zero test coverage — only `/conv resume` was tested above.
1682    // Forks session "s1" into a fresh child and confirms the agent live-swaps onto it.
1683    #[tokio::test]
1684    async fn handle_conv_fork_creates_child_session_and_switches_to_it() {
1685        let memory = memory_without_qdrant().await;
1686        let cid = memory.sqlite().create_conversation().await.unwrap();
1687        let dir = tempfile::tempdir().unwrap();
1688        let data_dir = dir.path().to_path_buf();
1689
1690        let store = zeph_session::SessionStore::new(memory.sqlite().pool().clone());
1691        store.create("s1").await.unwrap();
1692        let src_dir = zeph_session::session_dir(&data_dir, "s1");
1693        let log = zeph_session::SessionEventLog::open(&src_dir).await.unwrap();
1694        log.append(
1695            None,
1696            None,
1697            zeph_session::SessionEvent::SessionStarted {
1698                session_id: "s1".to_owned(),
1699                cwd: "/repo".to_owned(),
1700                provider_name: "claude".to_owned(),
1701                model: "opus".to_owned(),
1702                forked_from: None,
1703            },
1704        )
1705        .await
1706        .unwrap();
1707        store
1708            .update_seq("s1", log.last_seq().unwrap(), 1)
1709            .await
1710            .unwrap();
1711        drop(log);
1712
1713        let session_config = zeph_config::SessionConfig {
1714            enabled: true,
1715            data_dir: data_dir.to_string_lossy().into_owned(),
1716            ..Default::default()
1717        };
1718
1719        let mut agent = Agent::new(
1720            mock_provider(vec![]),
1721            MockChannel::new(vec![]),
1722            create_test_registry(),
1723            None,
1724            5,
1725            MockToolExecutor::no_tools(),
1726        )
1727        .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
1728        .with_session_persistence_config(Some(session_config));
1729
1730        let result = agent.handle_conv("fork s1").await.unwrap();
1731        assert!(
1732            result.starts_with("Forked session s1 ->"),
1733            "expected fork confirmation message, got: {result}"
1734        );
1735        assert!(
1736            result.contains("event(s) copied"),
1737            "expected copied-event count in confirmation, got: {result}"
1738        );
1739    }
1740
1741    // Regression test for AC-23 (spec-068 §13.5/§13.9): `/conv fork` is a live in-session swap
1742    // reached via `load_and_resume_conversation`, entirely bypassing the process-startup banner
1743    // computed in `src/runner.rs`. Forks a session with real (non-`SessionStarted`-only) prior
1744    // history and asserts the resume banner is sent through the channel for this path too.
1745    #[tokio::test]
1746    async fn handle_conv_fork_sends_resume_banner_for_non_empty_history() {
1747        let memory = memory_without_qdrant().await;
1748        let cid = memory.sqlite().create_conversation().await.unwrap();
1749        let dir = tempfile::tempdir().unwrap();
1750        let data_dir = dir.path().to_path_buf();
1751
1752        let store = zeph_session::SessionStore::new(memory.sqlite().pool().clone());
1753        store.create("s1").await.unwrap();
1754        let src_dir = zeph_session::session_dir(&data_dir, "s1");
1755        let log = zeph_session::SessionEventLog::open(&src_dir).await.unwrap();
1756        log.append(
1757            None,
1758            None,
1759            zeph_session::SessionEvent::SessionStarted {
1760                session_id: "s1".to_owned(),
1761                cwd: "/repo".to_owned(),
1762                provider_name: "claude".to_owned(),
1763                model: "opus".to_owned(),
1764                forked_from: None,
1765            },
1766        )
1767        .await
1768        .unwrap();
1769        log.append(
1770            None,
1771            None,
1772            zeph_session::SessionEvent::UserMessage {
1773                text: "hello".to_owned(),
1774                image_refs: vec![],
1775            },
1776        )
1777        .await
1778        .unwrap();
1779        store
1780            .update_seq("s1", log.last_seq().unwrap(), 2)
1781            .await
1782            .unwrap();
1783        drop(log);
1784
1785        let session_config = zeph_config::SessionConfig {
1786            enabled: true,
1787            data_dir: data_dir.to_string_lossy().into_owned(),
1788            ..Default::default()
1789        };
1790
1791        let mut agent = Agent::new(
1792            mock_provider(vec![]),
1793            MockChannel::new(vec![]),
1794            create_test_registry(),
1795            None,
1796            5,
1797            MockToolExecutor::no_tools(),
1798        )
1799        .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
1800        .with_session_persistence_config(Some(session_config));
1801
1802        let result = agent.handle_conv("fork s1").await.unwrap();
1803        assert!(
1804            result.starts_with("Forked session s1 ->"),
1805            "expected fork confirmation message, got: {result}"
1806        );
1807
1808        let sent = agent.channel.sent_messages();
1809        assert!(
1810            sent.iter().any(|m| m.contains("Resuming session")),
1811            "forking a session with non-empty prior history must send the resume banner \
1812             through the channel, got sent messages: {sent:?}"
1813        );
1814    }
1815
1816    // `SessionControlAccess::load_image` had zero direct coverage — only
1817    // `Agent::handle_image_as_string` (slash_commands.rs) and `cli.rs`'s inline check
1818    // were tested. These exercise the real `Agent<C>` impl via the trait.
1819
1820    #[tokio::test]
1821    async fn load_image_rejects_absolute_path() {
1822        let mut agent = Agent::new(
1823            mock_provider(vec![]),
1824            MockChannel::new(vec![]),
1825            create_test_registry(),
1826            None,
1827            5,
1828            MockToolExecutor::no_tools(),
1829        );
1830
1831        let result = SessionControlAccess::load_image(&mut agent, "/etc/passwd")
1832            .await
1833            .unwrap();
1834        assert!(result.contains("absolute paths are not supported"));
1835    }
1836
1837    #[tokio::test]
1838    async fn load_image_rejects_parent_dir_traversal() {
1839        let mut agent = Agent::new(
1840            mock_provider(vec![]),
1841            MockChannel::new(vec![]),
1842            create_test_registry(),
1843            None,
1844            5,
1845            MockToolExecutor::no_tools(),
1846        );
1847
1848        let result = SessionControlAccess::load_image(&mut agent, "../../etc/passwd")
1849            .await
1850            .unwrap();
1851        assert!(result.contains("path traversal") && result.contains("not allowed"));
1852    }
1853}