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> {
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 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 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 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 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 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 #[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 #[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 let plugins_dir = zeph_plugins::PluginManager::default_plugins_dir();
459
460 let (subcmd, rest) = args.trim().split_once(' ').unwrap_or((args.trim(), ""));
461
462 if subcmd == "overlay" || (matches!(subcmd, "" | "list") && rest.trim() == "--overlay") {
464 return format_overlay_section(&plugins_dir);
465 }
466
467 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 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 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 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 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 #[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 #[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
750pub(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
783pub(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 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 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 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
988fn 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 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 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 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
1169async 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
1204async 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 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 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 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 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 fn cache_stats(&self) -> String {
1293 self.tool_orchestrator.cache_stats()
1294 }
1295
1296 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 fn guardrail_status(&self) -> String {
1307 self.format_guardrail_status()
1308 }
1309
1310 fn focus_status(&self) -> String {
1313 self.format_focus_status()
1314 }
1315
1316 fn sidequest_status(&self) -> String {
1319 self.format_sidequest_status()
1320 }
1321
1322 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 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 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 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 #[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 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 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 assert!(out.contains("No plugin overlay active.") || out.contains("badplugin"));
1579 }
1580
1581 #[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 #[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 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 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 #[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 #[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 #[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}