1use 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
23pub(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 #[allow(clippy::unused_self)]
72 pub(super) fn handle_builtin_command(&self, _trimmed: &str) -> Option<bool> {
73 None
74 }
75
76 pub(super) async fn dispatch_slash_command(
84 &mut self,
85 trimmed: &str,
86 ) -> Option<Result<(), error::AgentError>> {
87 if trimmed.starts_with('@') {
89 return self.dispatch_agent_command(trimmed).await;
90 }
91
92 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 async fn handle_subagent_slash(&mut self, args: &str) -> Result<(), error::AgentError> {
121 let msg: String = if args.is_empty() {
122 "Usage: /subagent <subcommand>\n\nSubcommands:\n spawn <command> Spawn an ACP sub-agent process".to_owned()
123 } else {
124 let (subcmd, rest) = args.split_once(' ').unwrap_or((args, ""));
125 match subcmd {
126 "spawn" => {
127 let cmd = rest.trim();
128 let effective_mode = self.effective_delegation_mode();
129 if cmd.is_empty() {
130 "Usage: /subagent spawn <command>\n\nExample: /subagent spawn zeph --acp"
131 .to_owned()
132 } else if !effective_mode.permits_explicit() {
133 tracing::warn!(
134 mode = ?effective_mode,
135 "/subagent spawn rejected: delegation disabled by configuration"
136 );
137 "Sub-agent delegation is disabled by configuration \
138 ([agents].delegation_mode = \"disabled\" or [agents].enabled = false)."
139 .to_owned()
140 } else if let Some(spawn_fn) = self.runtime.config.acp_subagent_spawn_fn.clone()
141 {
142 let cmd = cmd.to_owned();
143 match spawn_fn(cmd).await {
144 Ok(output) => output,
145 Err(e) => format!("Sub-agent error: {e}"),
146 }
147 } else {
148 "ACP sub-agent spawning is not available in this mode.\n\
149 Use `zeph acp run-agent --command <CMD> --prompt <TEXT>` for one-shot sessions."
150 .to_owned()
151 }
152 }
153 other => format!("Unknown /subagent subcommand: '{other}'. Available: spawn"),
154 }
155 };
156
157 let _ = self.channel.send(&msg).await;
158 let _ = self.channel.flush_chunks().await;
159 Ok(())
160 }
161
162 pub(super) async fn dispatch_agent_command(
163 &mut self,
164 trimmed: &str,
165 ) -> Option<Result<(), error::AgentError>> {
166 let known: Vec<String> = self
167 .services
168 .orchestration
169 .subagent_manager
170 .as_ref()
171 .map(|m| m.definitions().iter().map(|d| d.name.clone()).collect())
172 .unwrap_or_default();
173 match zeph_subagent::AgentCommand::parse(trimmed, &known) {
174 Ok(cmd) => {
175 if let Some(msg) = self.handle_agent_command(cmd).await
176 && let Err(e) = self.channel.send(&msg).await
177 {
178 return Some(Err(e.into()));
179 }
180 let _ = self.channel.flush_chunks().await;
181 Some(Ok(()))
182 }
183 Err(e) if trimmed.starts_with('@') => {
184 tracing::debug!("@mention not matched as agent: {e}");
185 None
186 }
187 Err(e) => {
188 if let Err(send_err) = self.channel.send(&e.to_string()).await {
189 return Some(Err(send_err.into()));
190 }
191 let _ = self.channel.flush_chunks().await;
192 Some(Ok(()))
193 }
194 }
195 }
196
197 pub(super) fn handle_status_as_string(&mut self) -> String {
199 use std::fmt::Write;
200 use zeph_llm::provider::Role;
201
202 let uptime = self.runtime.lifecycle.start_time.elapsed().as_secs();
203 let msg_count = self
204 .msg
205 .messages
206 .iter()
207 .filter(|m| m.role == Role::User)
208 .count();
209
210 let metrics = collect_status_metrics(self.runtime.metrics.metrics_tx.as_ref());
211 let skill_count = self.services.skill.registry.read().all_meta().len();
212
213 let mut out = String::from("Session status:\n\n");
214 let _ = writeln!(out, "Provider: {}", self.provider.name());
215 let _ = writeln!(out, "Model: {}", self.runtime.config.model_name);
216 let _ = writeln!(out, "Uptime: {uptime}s");
217 let _ = writeln!(out, "Turns: {msg_count}");
218 let _ = writeln!(out, "API calls: {}", metrics.api_calls);
219 if metrics.reasoning_tokens > 0 {
220 let _ = writeln!(
221 out,
222 "Tokens: {} prompt / {} completion ({} reasoning, subset of completion)",
223 metrics.prompt_tokens, metrics.completion_tokens, metrics.reasoning_tokens
224 );
225 } else {
226 let _ = writeln!(
227 out,
228 "Tokens: {} prompt / {} completion",
229 metrics.prompt_tokens, metrics.completion_tokens
230 );
231 }
232 let _ = writeln!(out, "Skills: {skill_count}");
233 let _ = writeln!(out, "MCP: {} server(s)", metrics.mcp_servers);
234 if let Some(ref tf) = self.services.tool_state.tool_schema_filter {
235 let _ = writeln!(
236 out,
237 "Filter: enabled (top_k={}, always_on={}, {} embeddings)",
238 tf.top_k(),
239 tf.always_on_count(),
240 tf.embedding_count(),
241 );
242 }
243 if let Some(ref adv) = self.runtime.config.adversarial_policy_info {
244 let provider_display = if adv.provider.is_empty() {
245 "default"
246 } else {
247 adv.provider.as_str()
248 };
249 let _ = writeln!(
250 out,
251 "Adv gate: enabled (provider={}, policies={}, fail_open={}, timeout_ms={})",
252 provider_display, adv.policy_count, adv.fail_open, adv.timeout_ms
253 );
254 }
255 append_cost_section(&mut out, metrics.cost_cents, &metrics.provider_breakdown);
256 append_orchestration_section(
257 &mut out,
258 metrics.orch_plans,
259 metrics.orch_tasks,
260 metrics.orch_completed,
261 metrics.orch_failed,
262 metrics.orch_skipped,
263 );
264 append_ensemble_section(
265 &mut out,
266 metrics.ensemble_degraded,
267 metrics.ensemble_agreement_ratio,
268 &metrics.ensemble_member_stats,
269 );
270 append_pruning_section(
271 &mut out,
272 self.context_manager.compression.pruning_strategy,
273 self.services.compression.subgoal_registry.subgoals.len(),
274 self.services.compression.subgoal_registry.active_subgoal(),
275 );
276 append_graph_recall_section(&mut out, &self.services.memory.extraction.graph_config);
277
278 out.trim_end().to_owned()
279 }
280
281 pub(super) fn format_guardrail_status(&self) -> String {
283 use std::fmt::Write;
284
285 let mut out = String::new();
286 if let Some(ref guardrail) = self.services.security.guardrail {
287 let stats = guardrail.stats();
288 let _ = writeln!(out, "Guardrail: enabled");
289 let _ = writeln!(out, "Action: {:?}", guardrail.action());
290 let _ = writeln!(out, "Fail strategy: {:?}", guardrail.fail_strategy());
291 let _ = writeln!(out, "Timeout: {}ms", guardrail.timeout_ms());
292 let _ = writeln!(
293 out,
294 "Tool scan: {}",
295 if guardrail.scan_tool_output() {
296 "enabled"
297 } else {
298 "disabled"
299 }
300 );
301 let _ = writeln!(out, "\nStats:");
302 let _ = writeln!(out, " Total checks: {}", stats.total_checks);
303 let _ = writeln!(out, " Flagged: {}", stats.flagged_count);
304 let _ = writeln!(out, " Errors: {}", stats.error_count);
305 let _ = writeln!(out, " Avg latency: {}ms", stats.avg_latency_ms());
306 } else {
307 out.push_str("Guardrail: disabled\n");
308 out.push_str(
309 "Enable with: --guardrail flag or [security.guardrail] enabled = true in config",
310 );
311 }
312 out.trim_end().to_owned()
313 }
314
315 pub(super) fn format_focus_status(&self) -> String {
317 use std::fmt::Write;
318 let mut out = String::from("Focus Agent status\n\n");
319 let _ = writeln!(
320 out,
321 "Enabled: {}",
322 self.services.focus.config.enabled
323 );
324 let _ = writeln!(out, "Active session: {}", self.services.focus.is_active());
325 if let Some(ref scope) = self.services.focus.active_scope {
326 let _ = writeln!(out, "Active scope: {scope}");
327 }
328 let _ = writeln!(
329 out,
330 "Knowledge blocks: {}",
331 self.services.focus.knowledge_blocks.len()
332 );
333 let _ = writeln!(
334 out,
335 "Turns since focus: {}",
336 self.services.focus.turns_since_focus
337 );
338 out.trim_end().to_owned()
339 }
340
341 pub(super) fn format_sidequest_status(&self) -> String {
344 use std::fmt::Write;
345 let mut out = String::from("SideQuest status\n\n");
346 let _ = writeln!(
347 out,
348 "Enabled: {}",
349 self.services.sidequest.config.enabled
350 );
351 let _ = writeln!(
352 out,
353 "Interval turns: {}",
354 self.services.sidequest.config.interval_turns
355 );
356 let _ = writeln!(
357 out,
358 "Turn counter: {}",
359 self.services.sidequest.turn_counter
360 );
361 let _ = writeln!(
362 out,
363 "Passes run: {}",
364 self.services.sidequest.passes_run
365 );
366 let _ = writeln!(
367 out,
368 "Total evicted: {} tool outputs",
369 self.services.sidequest.total_evicted
370 );
371 out.trim_end().to_owned()
372 }
373
374 #[cfg_attr(not(test), allow(dead_code))]
376 pub(super) fn handle_image_as_string(&mut self, path: &str) -> String {
377 use zeph_common::path_guard::{PathRejection, classify_relative_path};
378 use zeph_llm::provider::{ImageData, MessagePart};
379
380 match classify_relative_path(path) {
381 PathRejection::Allowed => {}
382 PathRejection::Absolute => {
383 return "Invalid image path: absolute paths are not supported, use a path \
384 relative to the working directory"
385 .to_owned();
386 }
387 PathRejection::Traversal => {
388 return "Invalid image path: path traversal ('..') is not allowed".to_owned();
389 }
390 }
391
392 let data = match std::fs::read(path) {
393 Ok(d) => d,
394 Err(e) => return format!("Cannot read image {path}: {e}"),
395 };
396 if data.len() > super::message_queue::MAX_IMAGE_BYTES {
397 return format!(
398 "Image {path} exceeds size limit ({} MB), skipping",
399 super::message_queue::MAX_IMAGE_BYTES / 1024 / 1024
400 );
401 }
402 let mime_type = super::message_queue::detect_image_mime(Some(path)).to_string();
403 self.msg
404 .pending_image_parts
405 .push(MessagePart::Image(Box::new(ImageData { data, mime_type })));
406 format!("Image loaded: {path}. Send your message.")
407 }
408
409 #[allow(clippy::needless_pass_by_value)]
414 pub(super) fn run_plugin_command(
415 args: &str,
416 managed_dir: Option<std::path::PathBuf>,
417 mcp_allowed: Vec<String>,
418 base_shell_allowed: Vec<String>,
419 ephemeral_plugin_names: Vec<String>,
420 reputation_cfg: &zeph_config::plugins::ReputationConfig,
421 ) -> String {
422 let plugins_dir = zeph_plugins::PluginManager::default_plugins_dir();
424
425 let (subcmd, rest) = args.trim().split_once(' ').unwrap_or((args.trim(), ""));
426
427 if subcmd == "overlay" || (matches!(subcmd, "" | "list") && rest.trim() == "--overlay") {
429 return format_overlay_section(&plugins_dir);
430 }
431
432 let managed_dir = managed_dir
435 .unwrap_or_else(|| zeph_config::defaults::default_vault_dir().join("skills"));
436 let mgr = zeph_plugins::PluginManager::new(
437 plugins_dir,
438 managed_dir,
439 mcp_allowed,
440 base_shell_allowed,
441 )
442 .with_reputation_config(reputation_cfg, false);
443
444 match subcmd {
445 "" | "list" => match mgr.list_installed() {
446 Ok(plugins) if plugins.is_empty() && ephemeral_plugin_names.is_empty() => {
447 "No plugins installed.".to_owned()
448 }
449 Ok(plugins) => {
450 let mut lines: Vec<String> = plugins
451 .iter()
452 .map(|p| format!("{} v{} — {}", p.name, p.version, p.description))
453 .collect();
454 for name in &ephemeral_plugin_names {
455 lines.push(format!("{name} [ephemeral]"));
456 }
457 lines.join("\n")
458 }
459 Err(e) => format!("plugin list failed: {e}"),
460 },
461 "add" => {
462 use std::fmt::Write as _;
463 if rest.is_empty() {
464 return "Usage: /plugins add <source>".to_owned();
465 }
466 match mgr.add(rest.trim()) {
467 Ok(r) => {
468 let mut out = format!("Installed plugin \"{}\"", r.name);
469 if !r.installed_skills.is_empty() {
470 let _ = write!(out, "\n Skills: {}", r.installed_skills.join(", "));
471 }
472 if !r.mcp_server_ids.is_empty() {
473 let _ = write!(
474 out,
475 "\n MCP servers (restart required): {}",
476 r.mcp_server_ids.join(", ")
477 );
478 }
479 for w in &r.warnings {
480 let _ = write!(out, "\n warning: {w}");
481 }
482 out
483 }
484 Err(e) => format!("plugin add failed: {e}"),
485 }
486 }
487 "remove" => {
488 use std::fmt::Write as _;
489 if rest.is_empty() {
490 return "Usage: /plugins remove <name>".to_owned();
491 }
492 match mgr.remove(rest.trim()) {
493 Ok(r) => {
494 let mut out = format!("Removed plugin \"{}\"", rest.trim());
495 if !r.removed_skills.is_empty() {
496 let _ =
497 write!(out, "\n Removed skills: {}", r.removed_skills.join(", "));
498 }
499 out
500 }
501 Err(e) => format!("plugin remove failed: {e}"),
502 }
503 }
504 other => {
505 format!(
506 "Unknown /plugins subcommand: '{other}'. Available: list, list --overlay, overlay, add, remove"
507 )
508 }
509 }
510 }
511
512 #[tracing::instrument(skip_all, name = "core.agent.handle_skills")]
513 pub(super) async fn handle_skills_as_string(
514 &mut self,
515 subcommand: &str,
516 ) -> Result<String, error::AgentError> {
517 match subcommand {
518 "" => self.handle_skills_command_as_string().await,
519 "confusability" => self.handle_skills_confusability_as_string().await,
520 "injection" => self.handle_skills_injection_as_string(),
521 "trust" => self.handle_skills_trust_as_string(),
522 other => Ok(format!(
523 "Unknown /skills subcommand: '{other}'. Available: confusability, injection, trust"
524 )),
525 }
526 }
527
528 #[tracing::instrument(skip_all, name = "core.agent.handle_skills_command")]
529 async fn handle_skills_command_as_string(&mut self) -> Result<String, error::AgentError> {
530 use std::collections::BTreeMap;
531 use std::fmt::Write;
532
533 let (all_meta, load_errors): (
534 Vec<zeph_skills::loader::SkillMeta>,
535 Vec<(std::path::PathBuf, String)>,
536 ) = {
537 let reg = self.services.skill.registry.read();
538 (
539 reg.all_meta().into_iter().cloned().collect(),
540 reg.load_errors().to_vec(),
541 )
542 };
543
544 let memory = self.services.memory.persistence.memory.clone();
546 let mut trust_map: std::collections::HashMap<String, String> =
547 std::collections::HashMap::new();
548 for meta in &all_meta {
549 if let Some(ref memory) = memory {
550 let info = memory
551 .sqlite()
552 .load_skill_trust(&meta.name)
553 .await
554 .ok()
555 .flatten()
556 .map_or_else(String::new, |r| format!(" [{}]", r.trust_level));
557 trust_map.insert(meta.name.clone(), info);
558 }
559 }
560
561 let mut output = String::from("Available skills:\n\n");
562
563 let has_categories = all_meta.iter().any(|m| m.category.is_some());
564 if has_categories {
565 let mut by_category: BTreeMap<&str, Vec<&zeph_skills::loader::SkillMeta>> =
566 BTreeMap::new();
567 for meta in &all_meta {
568 let cat = meta.category.as_deref().unwrap_or("other");
569 by_category.entry(cat).or_default().push(meta);
570 }
571 for (cat, skills) in &by_category {
572 let _ = writeln!(output, "[{cat}]");
573 for meta in skills {
574 let trust_info = trust_map.get(&meta.name).map_or("", String::as_str);
575 let _ = writeln!(output, "- {} — {}{trust_info}", meta.name, meta.description);
576 }
577 output.push('\n');
578 }
579 } else {
580 for meta in &all_meta {
581 let trust_info = trust_map.get(&meta.name).map_or("", String::as_str);
582 let _ = writeln!(output, "- {} — {}{trust_info}", meta.name, meta.description);
583 }
584 }
585
586 if let Some(ref memory) = memory {
587 match memory.sqlite().load_skill_usage().await {
588 Ok(usage) if !usage.is_empty() => {
589 output.push_str("\nUsage statistics:\n\n");
590 for row in &usage {
591 let _ = writeln!(
592 output,
593 "- {}: {} invocations (last: {})",
594 row.skill_name, row.invocation_count, row.last_used_at,
595 );
596 }
597 }
598 Ok(_) => {}
599 Err(e) => tracing::warn!("failed to load skill usage: {e:#}"),
600 }
601 }
602
603 if !load_errors.is_empty() {
604 output.push_str("\nFailed to load:\n");
605 for (path, reason) in &load_errors {
606 let _ = writeln!(output, "- {}: {reason}", path.display());
607 }
608 }
609
610 Ok(output)
611 }
612
613 pub(crate) fn start_user_loop(&mut self, prompt: String, interval_secs: u64) {
615 use std::time::Duration;
616 use tokio::time::{Instant, MissedTickBehavior};
617
618 let period = Duration::from_secs(interval_secs);
619 let mut interval = tokio::time::interval_at(Instant::now() + period, period);
622 interval.set_missed_tick_behavior(MissedTickBehavior::Skip);
623
624 let cancel_tx = tokio_util::sync::CancellationToken::new();
625 self.runtime.lifecycle.user_loop = Some(crate::agent::state::LoopState {
626 prompt,
627 iteration: 0,
628 interval,
629 cancel_tx,
630 });
631 }
632
633 pub(crate) fn stop_user_loop(&mut self) -> String {
635 if let Some(ls) = self.runtime.lifecycle.user_loop.take() {
636 let iters = ls.iteration;
637 ls.cancel_tx.cancel();
638 format!("Loop stopped after {iters} iteration(s).")
639 } else {
640 "No active loop.".to_owned()
641 }
642 }
643
644 #[tracing::instrument(skip_all, name = "core.agent.handle_skills_confusability")]
645 async fn handle_skills_confusability_as_string(&mut self) -> Result<String, error::AgentError> {
646 let threshold = self.services.skill.confusability_threshold;
647 if threshold <= 0.0 {
648 return Ok("Confusability monitoring is disabled. \
649 Set [skills] confusability_threshold in config (e.g. 0.85) to enable."
650 .to_owned());
651 }
652
653 let Some(matcher) = &self.services.skill.matcher else {
654 return Ok(
655 "Skill matcher not available (no embedding provider configured).".to_owned(),
656 );
657 };
658
659 let all_meta: Vec<zeph_skills::loader::SkillMeta> = self
660 .services
661 .skill
662 .registry
663 .read()
664 .all_meta()
665 .into_iter()
666 .cloned()
667 .collect();
668 let refs: Vec<&zeph_skills::loader::SkillMeta> = all_meta.iter().collect();
669
670 let report = matcher.confusability_report(&refs, threshold).await;
671 Ok(report.to_string())
672 }
673
674 #[tracing::instrument(skip_all, name = "core.agent.handle_skills_injection")]
679 fn handle_skills_injection_as_string(&self) -> Result<String, error::AgentError> {
680 Ok(format!(
681 "Skill injection config: group_structured={}, support_similarity_threshold={:.2}, min_injection_score={:.2}",
682 self.services.skill.group_structured,
683 self.services.skill.support_similarity_threshold,
684 self.services.skill.min_injection_score,
685 ))
686 }
687
688 #[tracing::instrument(skip_all, name = "core.agent.handle_skills_trust")]
694 fn handle_skills_trust_as_string(&self) -> Result<String, error::AgentError> {
695 let trust = &self.services.skill.trust_config;
696 let rl_enabled = self
697 .services
698 .learning_engine
699 .rl_routing
700 .as_ref()
701 .is_some_and(|r| r.enabled);
702 Ok(format!(
703 "Skill trust config: default_level={:?}, local_level={:?}, bundled_level={:?}, \
704 hash_mismatch_level={:?} | RL routing: enabled={}, rl_head_loaded={}",
705 trust.default_level,
706 trust.local_level,
707 trust.bundled_level,
708 trust.hash_mismatch_level,
709 rl_enabled,
710 self.services.skill.rl_head.is_some(),
711 ))
712 }
713}
714
715pub(crate) fn build_session_debug_registry<'ctx>()
724-> zeph_commands::CommandRegistry<zeph_commands::CommandContext<'ctx>> {
725 use zeph_commands::CommandRegistry;
726 use zeph_commands::handlers::debug::{DebugDumpCommand, DumpFormatCommand, LogCommand};
727 use zeph_commands::handlers::help::HelpCommand;
728 use zeph_commands::handlers::session::{
729 ClearCommand, ClearQueueCommand, ExitCommand, HistoryCommand, QuitCommand, ResetCommand,
730 };
731
732 let mut reg = CommandRegistry::new();
733 reg.register(ExitCommand);
734 reg.register(QuitCommand);
735 reg.register(ClearCommand);
736 reg.register(ResetCommand);
737 reg.register(ClearQueueCommand);
738 reg.register(HistoryCommand);
739 reg.register(LogCommand);
740 reg.register(DebugDumpCommand);
741 reg.register(DumpFormatCommand);
742 reg.register(HelpCommand);
743 #[cfg(test)]
744 reg.register(super::test_stubs::TestErrorCommand);
745 reg
746}
747
748pub(crate) fn build_agent_command_registry<'ctx>()
753-> zeph_commands::CommandRegistry<zeph_commands::CommandContext<'ctx>> {
754 use zeph_commands::CommandRegistry;
755 use zeph_commands::handlers::{
756 acp::AcpCommand,
757 agent_cmd::AgentCommand,
758 agents_fleet::AgentsFleetCommand,
759 caveman::CavemanCommand,
760 cd::CdCommand,
761 checkpoint::{RedoCommand, UndoCommand},
762 compaction::{CompactCommand, NewConversationCommand, RecapCommand},
763 conv::ConvCommand,
764 experiment::ExperimentCommand,
765 goal::GoalCommand,
766 loop_cmd::LoopCommand,
767 lsp::LspCommand,
768 mcp::McpCommand,
769 memory::{
770 GraphCommand, GuidelinesCommand, KnowledgeSlashCommand, MemoryCommand,
771 StoreSlashCommand,
772 },
773 misc::{CacheStatsCommand, ImageCommand, NotifyTestCommand},
774 model::{ModelCommand, ProviderCommand},
775 plan::PlanCommand,
776 plugins::PluginsCommand,
777 policy::PolicyCommand,
778 reasoning_effort::ReasoningEffortCommand,
779 scheduler::SchedulerCommand,
780 search::SearchCommand,
781 skill::{FeedbackCommand, SkillCommand, SkillsCommand},
782 status::{FocusCommand, GuardrailCommand, SideQuestCommand, StatusCommand},
783 think_tokens::ThinkTokensCommand,
784 trajectory::{ScopeCommand, TrajectoryCommand},
785 worktree::WorktreeCommand,
786 };
787
788 let mut agent_reg = CommandRegistry::new();
789 agent_reg.register(CavemanCommand);
790 agent_reg.register(CdCommand);
791 agent_reg.register(MemoryCommand);
792 agent_reg.register(StoreSlashCommand);
793 agent_reg.register(GraphCommand);
794 agent_reg.register(KnowledgeSlashCommand);
795 agent_reg.register(GuidelinesCommand);
796 agent_reg.register(ModelCommand);
797 agent_reg.register(ProviderCommand);
798 agent_reg.register(ThinkTokensCommand);
799 agent_reg.register(ReasoningEffortCommand);
800 agent_reg.register(SkillCommand);
802 agent_reg.register(SkillsCommand);
803 agent_reg.register(FeedbackCommand);
804 agent_reg.register(McpCommand);
805 agent_reg.register(PolicyCommand);
806 agent_reg.register(SchedulerCommand);
807 agent_reg.register(SearchCommand);
808 agent_reg.register(LspCommand);
809 agent_reg.register(CacheStatsCommand);
811 agent_reg.register(ImageCommand);
812 agent_reg.register(NotifyTestCommand);
813 agent_reg.register(StatusCommand);
814 agent_reg.register(GuardrailCommand);
815 agent_reg.register(FocusCommand);
816 agent_reg.register(SideQuestCommand);
817 agent_reg.register(AgentCommand);
818 agent_reg.register(AgentsFleetCommand);
819 agent_reg.register(CompactCommand);
821 agent_reg.register(NewConversationCommand);
822 agent_reg.register(RecapCommand);
823 agent_reg.register(ExperimentCommand);
824 agent_reg.register(PlanCommand);
825 agent_reg.register(LoopCommand);
826 agent_reg.register(PluginsCommand);
827 agent_reg.register(AcpCommand);
828 #[cfg(feature = "cocoon")]
829 agent_reg.register(zeph_commands::handlers::cocoon::CocoonCommand);
830 agent_reg.register(TrajectoryCommand);
831 agent_reg.register(ScopeCommand);
832 agent_reg.register(GoalCommand);
833 agent_reg.register(UndoCommand);
834 agent_reg.register(RedoCommand);
835 agent_reg.register(ConvCommand);
836 agent_reg.register(WorktreeCommand);
837 agent_reg
838}
839
840struct StatusMetrics {
841 api_calls: u64,
842 prompt_tokens: u64,
843 completion_tokens: u64,
844 reasoning_tokens: u64,
845 cost_cents: f64,
846 mcp_servers: usize,
847 orch_plans: u64,
848 orch_tasks: u64,
849 orch_completed: u64,
850 orch_failed: u64,
851 orch_skipped: u64,
852 ensemble_degraded: u64,
853 ensemble_agreement_ratio: Option<f64>,
854 ensemble_member_stats: Vec<(String, f64, u64)>,
855 provider_breakdown: Vec<(String, crate::cost::ProviderUsage)>,
856}
857
858fn collect_status_metrics(
859 metrics_tx: Option<&tokio::sync::watch::Sender<crate::metrics::MetricsSnapshot>>,
860) -> StatusMetrics {
861 if let Some(tx) = metrics_tx {
862 let m = tx.borrow();
863 StatusMetrics {
864 api_calls: m.api_calls,
865 prompt_tokens: m.prompt_tokens,
866 completion_tokens: m.completion_tokens,
867 reasoning_tokens: m.reasoning_tokens,
868 cost_cents: m.cost_spent_cents,
869 mcp_servers: m.mcp_server_count,
870 orch_plans: m.orchestration.plans_total,
871 orch_tasks: m.orchestration.tasks_total,
872 orch_completed: m.orchestration.tasks_completed,
873 orch_failed: m.orchestration.tasks_failed,
874 orch_skipped: m.orchestration.tasks_skipped,
875 ensemble_degraded: m.orchestration.ensemble_degraded_total,
876 ensemble_agreement_ratio: m.orchestration.ensemble_last_agreement_ratio,
877 ensemble_member_stats: m.orchestration.ensemble_member_stats.clone(),
878 provider_breakdown: m.provider_cost_breakdown.clone(),
879 }
880 } else {
881 StatusMetrics {
882 api_calls: 0,
883 prompt_tokens: 0,
884 completion_tokens: 0,
885 reasoning_tokens: 0,
886 cost_cents: 0.0,
887 mcp_servers: 0,
888 orch_plans: 0,
889 orch_tasks: 0,
890 orch_completed: 0,
891 orch_failed: 0,
892 orch_skipped: 0,
893 ensemble_degraded: 0,
894 ensemble_agreement_ratio: None,
895 ensemble_member_stats: vec![],
896 provider_breakdown: vec![],
897 }
898 }
899}
900
901fn append_cost_section(
902 out: &mut String,
903 cost_cents: f64,
904 provider_breakdown: &[(String, crate::cost::ProviderUsage)],
905) {
906 use std::fmt::Write;
907 if cost_cents > 0.0 {
908 let _ = writeln!(out, "Cost: ${:.4}", cost_cents / 100.0);
909 if !provider_breakdown.is_empty() {
910 let _ = writeln!(
911 out,
912 " {:<16} {:>8} {:>8} {:>8}",
913 "Provider", "Requests", "Tokens", "Cost"
914 );
915 for (name, usage) in provider_breakdown {
916 let total_tokens = usage.input_tokens + usage.output_tokens;
917 let _ = writeln!(
918 out,
919 " {:<16} {:>8} {:>8} {:>8}",
920 name,
921 usage.request_count,
922 total_tokens,
923 format!("${:.4}", usage.cost_cents / 100.0),
924 );
925 }
926 }
927 }
928}
929
930fn append_orchestration_section(
931 out: &mut String,
932 orch_plans: u64,
933 orch_tasks: u64,
934 orch_completed: u64,
935 orch_failed: u64,
936 orch_skipped: u64,
937) {
938 use std::fmt::Write;
939 if orch_plans > 0 {
940 let _ = writeln!(out);
941 let _ = writeln!(out, "Orchestration:");
942 let _ = writeln!(out, " Plans: {orch_plans}");
943 let _ = writeln!(out, " Tasks: {orch_completed}/{orch_tasks} completed");
944 if orch_failed > 0 {
945 let _ = writeln!(out, " Failed: {orch_failed}");
946 }
947 if orch_skipped > 0 {
948 let _ = writeln!(out, " Skipped: {orch_skipped}");
949 }
950 }
951}
952
953fn append_ensemble_section(
957 out: &mut String,
958 ensemble_degraded: u64,
959 ensemble_agreement_ratio: Option<f64>,
960 ensemble_member_stats: &[(String, f64, u64)],
961) {
962 use std::fmt::Write;
963 if ensemble_member_stats.is_empty() && ensemble_degraded == 0 {
964 return;
965 }
966 let _ = writeln!(out);
967 let _ = writeln!(out, "Ensemble verify:");
968 if let Some(ratio) = ensemble_agreement_ratio {
969 let _ = writeln!(out, " Last agreement: {:.0}%", ratio * 100.0);
970 }
971 if ensemble_degraded > 0 {
972 let _ = writeln!(out, " Degraded: {ensemble_degraded} (quorum fallback)");
973 }
974 for (member, score, observations) in ensemble_member_stats {
975 let _ = writeln!(out, " {member:<16} ema={score:.2} (n={observations})");
976 }
977}
978
979fn append_pruning_section(
980 out: &mut String,
981 pruning_strategy: crate::config::PruningStrategy,
982 subgoal_count: usize,
983 active_subgoal: Option<&zeph_agent_context::compaction::Subgoal>,
984) {
985 use crate::config::PruningStrategy;
986 use std::fmt::Write;
987 if matches!(
988 pruning_strategy,
989 PruningStrategy::Subgoal | PruningStrategy::SubgoalMig
990 ) {
991 let _ = writeln!(out);
992 let _ = writeln!(
993 out,
994 "Pruning: {}",
995 match pruning_strategy {
996 PruningStrategy::SubgoalMig => "subgoal_mig",
997 _ => "subgoal",
998 }
999 );
1000 let _ = writeln!(out, "Subgoals: {subgoal_count} tracked");
1001 if let Some(active) = active_subgoal {
1002 let _ = writeln!(out, "Active: \"{}\"", active.description);
1003 } else {
1004 let _ = writeln!(out, "Active: (none yet)");
1005 }
1006 }
1007}
1008
1009fn append_graph_recall_section(out: &mut String, gc: &zeph_config::memory::GraphConfig) {
1010 use std::fmt::Write;
1011 if gc.enabled {
1012 let _ = writeln!(out);
1013 if gc.spreading_activation.enabled {
1014 let _ = writeln!(
1015 out,
1016 "Graph recall: spreading activation (lambda={:.2}, hops={})",
1017 gc.spreading_activation.decay_lambda, gc.spreading_activation.max_hops,
1018 );
1019 } else {
1020 let _ = writeln!(out, "Graph recall: BFS (hops={})", gc.max_hops);
1021 }
1022 }
1023}
1024
1025impl<C: Channel> Agent<C> {
1026 async fn handle_conv_resume(&mut self, id: &str) -> Result<String, CommandError> {
1032 if id.is_empty() {
1033 return Ok("Usage: /conv resume <id>".to_owned());
1034 }
1035 if let Some(sink) = &self.services.session.session_sink
1041 && sink.session_id().as_str() == id
1042 {
1043 return Ok(format!("Already in session '{id}'."));
1044 }
1045 let Some(memory) = self.services.memory.persistence.memory.clone() else {
1046 return Ok(
1047 "Conversation-session persistence requires memory to be enabled ([memory] enabled = true)."
1048 .to_owned(),
1049 );
1050 };
1051 let store = zeph_session::SessionStore::new(memory.sqlite().pool().clone());
1052 let Some(metadata) = store
1053 .get(id)
1054 .await
1055 .map_err(|e| CommandError::new(e.to_string()))?
1056 else {
1057 return Ok(format!("Session '{id}' not found."));
1058 };
1059
1060 let conversation_id = if let Some(cid) = metadata.conversation_id {
1061 zeph_memory::ConversationId(cid)
1062 } else {
1063 let cid = memory
1064 .sqlite()
1065 .create_conversation()
1066 .await
1067 .map_err(|e| CommandError::new(e.to_string()))?;
1068 store
1069 .link_conversation(id, cid.0)
1070 .await
1071 .map_err(|e| CommandError::new(e.to_string()))?;
1072 cid
1073 };
1074
1075 let session_id = zeph_common::SessionId::new(id);
1076 self.load_and_resume_conversation(&session_id, conversation_id)
1077 .await
1078 .map_err(|e| CommandError::new(e.to_string()))?;
1079
1080 Ok(format!(
1081 "Resumed session {id} ({} event(s) replayed).",
1082 metadata.event_count
1083 ))
1084 }
1085
1086 async fn handle_conv_fork(&mut self, id: &str) -> Result<String, CommandError> {
1091 if id.is_empty() {
1092 return Ok("Usage: /conv fork <id>".to_owned());
1093 }
1094 let Some(memory) = self.services.memory.persistence.memory.clone() else {
1095 return Ok(
1096 "Conversation-session persistence requires memory to be enabled ([memory] enabled = true)."
1097 .to_owned(),
1098 );
1099 };
1100 let Some(session_persistence_config) =
1101 self.services.session.session_persistence_config.clone()
1102 else {
1103 return Ok(
1104 "Conversation-session persistence is not enabled ([session] enabled = true)."
1105 .to_owned(),
1106 );
1107 };
1108 let store = zeph_session::SessionStore::new(memory.sqlite().pool().clone());
1109 let data_dir = std::path::PathBuf::from(&session_persistence_config.data_dir);
1110 let new_id = zeph_common::SessionId::generate();
1111
1112 let fork_result =
1113 zeph_session::ForkEngine::fork(&data_dir, id, new_id.as_str(), None, &store, None)
1114 .await
1115 .map_err(|e| CommandError::new(e.to_string()))?;
1116
1117 let conversation_id = memory
1118 .sqlite()
1119 .create_conversation()
1120 .await
1121 .map_err(|e| CommandError::new(e.to_string()))?;
1122
1123 self.load_and_resume_conversation(&new_id, conversation_id)
1124 .await
1125 .map_err(|e| CommandError::new(e.to_string()))?;
1126
1127 Ok(format!(
1128 "Forked session {id} -> {} ({} event(s) copied); now the active conversation.",
1129 fork_result.new_session_id, fork_result.events_copied
1130 ))
1131 }
1132}
1133
1134async fn handle_conv_list(store: &zeph_session::SessionStore) -> Result<String, CommandError> {
1137 use std::fmt::Write as _;
1138
1139 let sessions = store
1140 .list(&zeph_session::SessionFilter::default())
1141 .await
1142 .map_err(|e| CommandError::new(format!("failed to list sessions: {e}")))?;
1143
1144 if sessions.is_empty() {
1145 return Ok("No conversation-sessions found.".to_owned());
1146 }
1147
1148 let mut out = format!(
1149 "{:<38} {:<30} {:<9} {:>6} {:<24}\n",
1150 "ID", "TITLE", "STATUS", "EVENTS", "UPDATED"
1151 );
1152 out.push_str(&"-".repeat(110));
1153 out.push('\n');
1154 for s in &sessions {
1155 let title = s.title.as_deref().unwrap_or("(untitled)");
1156 let _ = writeln!(
1157 out,
1158 "{:<38} {:<30} {:<9} {:>6} {:<24}",
1159 s.session_id,
1160 crate::text::truncate_to_chars(title, 30),
1161 s.status.as_str(),
1162 s.event_count,
1163 s.updated_at
1164 );
1165 }
1166 Ok(out.trim_end().to_owned())
1167}
1168
1169async fn handle_conv_show(
1173 store: &zeph_session::SessionStore,
1174 id: &str,
1175) -> Result<String, CommandError> {
1176 if id.is_empty() {
1177 return Ok("Usage: /conv show <id>".to_owned());
1178 }
1179 let metadata = store
1180 .get(id)
1181 .await
1182 .map_err(|e| CommandError::new(format!("failed to read session metadata: {e}")))?;
1183 let Some(m) = metadata else {
1184 return Ok(format!("Session '{id}' not found."));
1185 };
1186 Ok(format!(
1187 "Session {}\n title: {}\n status: {}\n events: {} (last_seq={})\n forked_from: {}\n created: {}\n updated: {}",
1188 m.session_id,
1189 m.title.as_deref().unwrap_or("(untitled)"),
1190 m.status.as_str(),
1191 m.event_count,
1192 m.last_seq,
1193 m.forked_from.as_deref().unwrap_or("-"),
1194 m.created_at,
1195 m.updated_at
1196 ))
1197}
1198
1199impl<C: Channel + Send + 'static> SessionControlAccess for Agent<C> {
1200 fn session_recap<'a>(
1203 &'a mut self,
1204 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1205 Box::pin(
1206 async move {
1207 match self.build_recap().await {
1208 Ok(text) => Ok(text),
1209 Err(e) => {
1210 tracing::warn!("session recap command: {}", e.0);
1214 Ok("Recap unavailable — see logs for details".to_string())
1215 }
1216 }
1217 }
1218 .instrument(tracing::info_span!("core.agent_access.session_recap")),
1219 )
1220 }
1221
1222 fn compact_context<'a>(
1225 &'a mut self,
1226 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1227 Box::pin(
1228 self.compact_context_command()
1229 .instrument(tracing::info_span!("core.agent_access.compact_context")),
1230 )
1231 }
1232
1233 fn reset_conversation<'a>(
1236 &'a mut self,
1237 keep_plan: bool,
1238 no_digest: bool,
1239 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1240 Box::pin(async move {
1241 match self.reset_conversation(keep_plan, no_digest).await {
1242 Ok((old_id, new_id)) => {
1243 let old = old_id.map_or_else(|| "none".to_string(), |id| id.0.to_string());
1244 let new = new_id.map_or_else(|| "none".to_string(), |id| id.0.to_string());
1245 let keep_note = if keep_plan { " (plan preserved)" } else { "" };
1246 Ok(format!(
1247 "New conversation started. Previous: {old} → Current: {new}{keep_note}"
1248 ))
1249 }
1250 Err(e) => Ok(format!("Failed to start new conversation: {e}")),
1251 }
1252 })
1253 }
1254
1255 fn cache_stats(&self) -> String {
1258 self.tool_orchestrator.cache_stats()
1259 }
1260
1261 fn session_status<'a>(
1264 &'a mut self,
1265 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1266 Box::pin(async move { Ok(self.handle_status_as_string()) })
1267 }
1268
1269 fn guardrail_status(&self) -> String {
1272 self.format_guardrail_status()
1273 }
1274
1275 fn focus_status(&self) -> String {
1278 self.format_focus_status()
1279 }
1280
1281 fn sidequest_status(&self) -> String {
1284 self.format_sidequest_status()
1285 }
1286
1287 fn load_image<'a>(
1290 &'a mut self,
1291 path: &'a str,
1292 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1293 use zeph_common::path_guard::{PathRejection, classify_relative_path};
1294 use zeph_llm::provider::{ImageData, MessagePart};
1295
1296 match classify_relative_path(path) {
1297 PathRejection::Allowed => {}
1298 PathRejection::Absolute => {
1299 return Box::pin(async move {
1300 Ok(
1301 "Invalid image path: absolute paths are not supported, use a path \
1302 relative to the working directory"
1303 .to_owned(),
1304 )
1305 });
1306 }
1307 PathRejection::Traversal => {
1308 return Box::pin(async move {
1309 Ok("Invalid image path: path traversal ('..') is not allowed".to_owned())
1310 });
1311 }
1312 }
1313
1314 let path_owned = path.to_owned();
1315 Box::pin(async move {
1316 let path_for_task = path_owned.clone();
1317 let read_result = tokio::task::spawn_blocking(move || std::fs::read(&path_for_task))
1318 .await
1319 .map_err(|e| CommandError::new(format!("spawn_blocking join error: {e}")))?;
1320 let data = match read_result {
1321 Ok(d) => d,
1322 Err(e) => return Ok(format!("Cannot read image {path_owned}: {e}")),
1323 };
1324 if data.len() > crate::agent::message_queue::MAX_IMAGE_BYTES {
1325 return Ok(format!(
1326 "Image {path_owned} exceeds size limit ({} MB), skipping",
1327 crate::agent::message_queue::MAX_IMAGE_BYTES / 1024 / 1024
1328 ));
1329 }
1330 let mime_type =
1331 crate::agent::message_queue::detect_image_mime(Some(&path_owned)).to_string();
1332 self.msg
1333 .pending_image_parts
1334 .push(MessagePart::Image(Box::new(ImageData { data, mime_type })));
1335 Ok(format!("Image loaded: {path_owned}. Send your message."))
1336 })
1337 }
1338
1339 fn handle_undo<'a>(
1342 &'a mut self,
1343 args: &'a str,
1344 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1345 let executor = std::sync::Arc::clone(&self.tool_executor);
1346 let args_owned = args.trim().to_owned();
1347 Box::pin(async move {
1348 if args_owned == "list" {
1349 let result = executor.checkpoint_list_erased();
1350 if !result.supported {
1351 return Ok(
1352 "Checkpoints are not enabled. Set `[tools.shell] checkpoints_enabled = true` in config.".to_owned()
1353 );
1354 }
1355 if result.entries.is_empty() {
1356 return Ok("Undo stack is empty.".to_owned());
1357 }
1358 let mut lines = vec![format!("Undo stack ({} entries):", result.entries.len())];
1359 for e in &result.entries {
1360 lines.push(format!(
1361 " [{}] {} ({} file(s))",
1362 e.index, e.command, e.file_count
1363 ));
1364 }
1365 if result.redo_depth > 0 {
1366 lines.push(format!("Redo depth: {}", result.redo_depth));
1367 }
1368 return Ok(lines.join("\n"));
1369 }
1370
1371 let n: usize = if args_owned.is_empty() {
1372 1
1373 } else {
1374 match args_owned.parse::<usize>() {
1375 Ok(v) if v > 0 => v,
1376 _ => {
1377 return Err(CommandError::new(format!(
1378 "Invalid argument: expected a positive integer or 'list', got '{args_owned}'"
1379 )));
1380 }
1381 }
1382 };
1383
1384 let result = tokio::task::spawn_blocking(move || executor.checkpoint_undo_erased(n))
1385 .await
1386 .map_err(|e| CommandError::new(format!("undo task panicked: {e}")))?;
1387 if !result.supported {
1388 return Ok(
1389 "Checkpoints are not enabled. Set `[tools.shell] checkpoints_enabled = true` in config.".to_owned()
1390 );
1391 }
1392 Ok(result.message)
1393 })
1394 }
1395
1396 fn handle_redo<'a>(
1397 &'a mut self,
1398 args: &'a str,
1399 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1400 let _ = args;
1401 let executor = std::sync::Arc::clone(&self.tool_executor);
1402 Box::pin(async move {
1403 let result = tokio::task::spawn_blocking(move || executor.checkpoint_redo_erased())
1404 .await
1405 .map_err(|e| CommandError::new(format!("redo task panicked: {e}")))?;
1406 if !result.supported {
1407 return Ok(
1408 "Checkpoints are not enabled. Set `[tools.shell] checkpoints_enabled = true` in config.".to_owned()
1409 );
1410 }
1411 Ok(result.message)
1412 })
1413 }
1414
1415 fn handle_conv<'a>(
1418 &'a mut self,
1419 args: &'a str,
1420 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1421 let args_owned = args.trim().to_owned();
1422 Box::pin(async move {
1423 if let Some(id) = args_owned.strip_prefix("resume ") {
1426 return self.handle_conv_resume(id.trim()).await;
1427 }
1428 if let Some(id) = args_owned.strip_prefix("fork ") {
1429 return self.handle_conv_fork(id.trim()).await;
1430 }
1431
1432 let Some(memory) = self.services.memory.persistence.memory.clone() else {
1433 return Ok(
1434 "Conversation-session persistence requires memory to be enabled ([memory] enabled = true)."
1435 .to_owned(),
1436 );
1437 };
1438 let store = zeph_session::SessionStore::new(memory.sqlite().pool().clone());
1439
1440 if let Some(id) = args_owned.strip_prefix("show ") {
1441 return handle_conv_show(&store, id.trim()).await;
1442 }
1443 if args_owned.is_empty() || args_owned == "list" {
1444 return handle_conv_list(&store).await;
1445 }
1446 Ok(format!(
1447 "Unknown /conv subcommand '{args_owned}'. Usage: /conv [list | show <id> | resume <id> | fork <id>]"
1448 ))
1449 })
1450 }
1451}
1452
1453#[cfg(test)]
1454mod tests {
1455 use super::super::agent_tests::{
1456 MockChannel, MockToolExecutor, create_test_registry, mock_provider,
1457 };
1458 use super::*;
1459 use zeph_memory::semantic::SemanticMemory;
1460
1461 async fn memory_without_qdrant() -> SemanticMemory {
1462 SemanticMemory::new(
1463 ":memory:",
1464 "http://127.0.0.1:1",
1465 None,
1466 zeph_llm::any::AnyProvider::Mock(zeph_llm::mock::MockProvider::default()),
1467 "test-model",
1468 )
1469 .await
1470 .unwrap()
1471 }
1472
1473 #[test]
1484 fn subagent_is_excluded_from_is_recognized_command() {
1485 assert!(!zeph_commands::is_recognized_command("/subagent"));
1486 assert!(!zeph_commands::is_recognized_command(
1487 "/subagent spawn zeph --acp"
1488 ));
1489 }
1490
1491 #[test]
1492 fn format_overlay_section_empty_dir() {
1493 let tmp = tempfile::tempdir().unwrap();
1494 let out = format_overlay_section(tmp.path());
1495 assert_eq!(out, "No plugin overlay active.");
1496 }
1497
1498 #[test]
1499 fn format_overlay_section_with_source_plugin() {
1500 let tmp = tempfile::tempdir().unwrap();
1501 let plugin_dir = tmp.path().join("myplugin");
1502 std::fs::create_dir_all(&plugin_dir).unwrap();
1503 let manifest = r#"
1504[plugin]
1505name = "myplugin"
1506version = "0.1.0"
1507description = "test"
1508
1509[config.tools.shell]
1510blocked_commands = ["curl"]
1511"#;
1512 std::fs::write(plugin_dir.join(".plugin.toml"), manifest).unwrap();
1513 let out = format_overlay_section(tmp.path());
1514 assert!(out.contains("Active plugin overlay:"));
1515 assert!(out.contains("myplugin"));
1516 assert!(out.contains("Source plugins:"));
1517 assert!(out.contains("Note:"));
1518 }
1519
1520 #[test]
1521 fn run_plugin_command_overlay_subcommand() {
1522 let tmp = tempfile::tempdir().unwrap();
1523 let out = format_overlay_section(tmp.path());
1527 assert_eq!(out, "No plugin overlay active.");
1528 }
1529
1530 #[test]
1531 fn format_overlay_section_skipped_plugin_shows_reason() {
1532 let tmp = tempfile::tempdir().unwrap();
1533 let plugin_dir = tmp.path().join("badplugin");
1535 std::fs::create_dir_all(&plugin_dir).unwrap();
1536 std::fs::write(
1537 plugin_dir.join(".plugin.toml"),
1538 b"not valid toml at all {{{{",
1539 )
1540 .unwrap();
1541 let out = format_overlay_section(tmp.path());
1542 assert!(out.contains("No plugin overlay active.") || out.contains("badplugin"));
1544 }
1545
1546 #[tokio::test]
1552 async fn handle_conv_resume_same_session_short_circuits() {
1553 let memory = memory_without_qdrant().await;
1554 let cid = memory.sqlite().create_conversation().await.unwrap();
1555 let dir = tempfile::tempdir().unwrap();
1556 let data_dir = dir.path().to_path_buf();
1557 let session_id = zeph_common::SessionId::new("s1");
1558 let session_path = zeph_session::session_dir(&data_dir, session_id.as_str());
1559 let log = zeph_session::SessionEventLog::open_exclusive(&session_path)
1560 .await
1561 .unwrap();
1562 let store = zeph_session::SessionStore::new(memory.sqlite().pool().clone());
1563 let sink = zeph_agent_persistence::SessionSink::new(
1564 std::sync::Arc::new(log),
1565 store,
1566 session_id.clone(),
1567 );
1568 let session_config = zeph_config::SessionConfig {
1569 enabled: true,
1570 data_dir: data_dir.to_string_lossy().into_owned(),
1571 ..Default::default()
1572 };
1573
1574 let mut agent = Agent::new(
1575 mock_provider(vec![]),
1576 MockChannel::new(vec![]),
1577 create_test_registry(),
1578 None,
1579 5,
1580 MockToolExecutor::no_tools(),
1581 )
1582 .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
1583 .with_session_sink(Some(std::sync::Arc::new(sink)))
1584 .with_session_persistence_config(Some(session_config));
1585
1586 let result = agent.handle_conv("resume s1").await.unwrap();
1587 assert_eq!(
1588 result, "Already in session 's1'.",
1589 "resuming into the currently-active session must short-circuit, not attempt \
1590 hydration/lock acquisition"
1591 );
1592 }
1593
1594 #[tokio::test]
1597 async fn handle_conv_resume_different_session_still_hydrates() {
1598 let memory = memory_without_qdrant().await;
1599 let cid = memory.sqlite().create_conversation().await.unwrap();
1600 let dir = tempfile::tempdir().unwrap();
1601 let data_dir = dir.path().to_path_buf();
1602
1603 let active_session_id = zeph_common::SessionId::new("s1");
1605 let active_session_path = zeph_session::session_dir(&data_dir, active_session_id.as_str());
1606 let active_log = zeph_session::SessionEventLog::open_exclusive(&active_session_path)
1607 .await
1608 .unwrap();
1609 let active_store = zeph_session::SessionStore::new(memory.sqlite().pool().clone());
1610 let active_sink = zeph_agent_persistence::SessionSink::new(
1611 std::sync::Arc::new(active_log),
1612 active_store,
1613 active_session_id,
1614 );
1615
1616 let store = zeph_session::SessionStore::new(memory.sqlite().pool().clone());
1619 store.create("s2").await.unwrap();
1620
1621 let session_config = zeph_config::SessionConfig {
1622 enabled: true,
1623 data_dir: data_dir.to_string_lossy().into_owned(),
1624 ..Default::default()
1625 };
1626
1627 let mut agent = Agent::new(
1628 mock_provider(vec![]),
1629 MockChannel::new(vec![]),
1630 create_test_registry(),
1631 None,
1632 5,
1633 MockToolExecutor::no_tools(),
1634 )
1635 .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
1636 .with_session_sink(Some(std::sync::Arc::new(active_sink)))
1637 .with_session_persistence_config(Some(session_config));
1638
1639 let result = agent.handle_conv("resume s2").await.unwrap();
1640 assert!(
1641 result.starts_with("Resumed session s2"),
1642 "resuming into a different, unlocked session must still hydrate normally, got: {result}"
1643 );
1644 }
1645
1646 #[tokio::test]
1649 async fn handle_conv_fork_creates_child_session_and_switches_to_it() {
1650 let memory = memory_without_qdrant().await;
1651 let cid = memory.sqlite().create_conversation().await.unwrap();
1652 let dir = tempfile::tempdir().unwrap();
1653 let data_dir = dir.path().to_path_buf();
1654
1655 let store = zeph_session::SessionStore::new(memory.sqlite().pool().clone());
1656 store.create("s1").await.unwrap();
1657 let src_dir = zeph_session::session_dir(&data_dir, "s1");
1658 let log = zeph_session::SessionEventLog::open(&src_dir).await.unwrap();
1659 log.append(
1660 None,
1661 None,
1662 zeph_session::SessionEvent::SessionStarted {
1663 session_id: "s1".to_owned(),
1664 cwd: "/repo".to_owned(),
1665 provider_name: "claude".to_owned(),
1666 model: "opus".to_owned(),
1667 forked_from: None,
1668 },
1669 )
1670 .await
1671 .unwrap();
1672 store
1673 .update_seq("s1", log.last_seq().unwrap(), 1)
1674 .await
1675 .unwrap();
1676 drop(log);
1677
1678 let session_config = zeph_config::SessionConfig {
1679 enabled: true,
1680 data_dir: data_dir.to_string_lossy().into_owned(),
1681 ..Default::default()
1682 };
1683
1684 let mut agent = Agent::new(
1685 mock_provider(vec![]),
1686 MockChannel::new(vec![]),
1687 create_test_registry(),
1688 None,
1689 5,
1690 MockToolExecutor::no_tools(),
1691 )
1692 .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
1693 .with_session_persistence_config(Some(session_config));
1694
1695 let result = agent.handle_conv("fork s1").await.unwrap();
1696 assert!(
1697 result.starts_with("Forked session s1 ->"),
1698 "expected fork confirmation message, got: {result}"
1699 );
1700 assert!(
1701 result.contains("event(s) copied"),
1702 "expected copied-event count in confirmation, got: {result}"
1703 );
1704 }
1705
1706 #[tokio::test]
1711 async fn handle_conv_fork_sends_resume_banner_for_non_empty_history() {
1712 let memory = memory_without_qdrant().await;
1713 let cid = memory.sqlite().create_conversation().await.unwrap();
1714 let dir = tempfile::tempdir().unwrap();
1715 let data_dir = dir.path().to_path_buf();
1716
1717 let store = zeph_session::SessionStore::new(memory.sqlite().pool().clone());
1718 store.create("s1").await.unwrap();
1719 let src_dir = zeph_session::session_dir(&data_dir, "s1");
1720 let log = zeph_session::SessionEventLog::open(&src_dir).await.unwrap();
1721 log.append(
1722 None,
1723 None,
1724 zeph_session::SessionEvent::SessionStarted {
1725 session_id: "s1".to_owned(),
1726 cwd: "/repo".to_owned(),
1727 provider_name: "claude".to_owned(),
1728 model: "opus".to_owned(),
1729 forked_from: None,
1730 },
1731 )
1732 .await
1733 .unwrap();
1734 log.append(
1735 None,
1736 None,
1737 zeph_session::SessionEvent::UserMessage {
1738 text: "hello".to_owned(),
1739 image_refs: vec![],
1740 },
1741 )
1742 .await
1743 .unwrap();
1744 store
1745 .update_seq("s1", log.last_seq().unwrap(), 2)
1746 .await
1747 .unwrap();
1748 drop(log);
1749
1750 let session_config = zeph_config::SessionConfig {
1751 enabled: true,
1752 data_dir: data_dir.to_string_lossy().into_owned(),
1753 ..Default::default()
1754 };
1755
1756 let mut agent = Agent::new(
1757 mock_provider(vec![]),
1758 MockChannel::new(vec![]),
1759 create_test_registry(),
1760 None,
1761 5,
1762 MockToolExecutor::no_tools(),
1763 )
1764 .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
1765 .with_session_persistence_config(Some(session_config));
1766
1767 let result = agent.handle_conv("fork s1").await.unwrap();
1768 assert!(
1769 result.starts_with("Forked session s1 ->"),
1770 "expected fork confirmation message, got: {result}"
1771 );
1772
1773 let sent = agent.channel.sent_messages();
1774 assert!(
1775 sent.iter().any(|m| m.contains("Resuming session")),
1776 "forking a session with non-empty prior history must send the resume banner \
1777 through the channel, got sent messages: {sent:?}"
1778 );
1779 }
1780
1781 #[tokio::test]
1786 async fn load_image_rejects_absolute_path() {
1787 let mut agent = Agent::new(
1788 mock_provider(vec![]),
1789 MockChannel::new(vec![]),
1790 create_test_registry(),
1791 None,
1792 5,
1793 MockToolExecutor::no_tools(),
1794 );
1795
1796 let result = SessionControlAccess::load_image(&mut agent, "/etc/passwd")
1797 .await
1798 .unwrap();
1799 assert!(result.contains("absolute paths are not supported"));
1800 }
1801
1802 #[tokio::test]
1803 async fn load_image_rejects_parent_dir_traversal() {
1804 let mut agent = Agent::new(
1805 mock_provider(vec![]),
1806 MockChannel::new(vec![]),
1807 create_test_registry(),
1808 None,
1809 5,
1810 MockToolExecutor::no_tools(),
1811 );
1812
1813 let result = SessionControlAccess::load_image(&mut agent, "../../etc/passwd")
1814 .await
1815 .unwrap();
1816 assert!(result.contains("path traversal") && result.contains("not allowed"));
1817 }
1818}