1use std::fmt::Write as _;
13use std::future::Future;
14use std::pin::Pin;
15use std::sync::Arc;
16use std::time::Duration;
17
18use tracing::Instrument as _;
19use zeph_commands::CommandError;
20use zeph_commands::traits::agent::AgentAccess;
21use zeph_db;
22use zeph_memory::semantic::SemanticMemory;
23use zeph_memory::{Edge, Entity, GraphExtractionConfig, GraphStore, MessageId, extract_and_store};
24
25use super::{Agent, error::AgentError};
26use crate::channel::Channel;
27
28impl<C: Channel + Send + 'static> Agent<C> {
29 fn resolve_graph_store(&self) -> Result<(Arc<SemanticMemory>, Arc<GraphStore>), String> {
30 let Some(memory) = self.services.memory.persistence.memory.clone() else {
31 return Err("Graph memory is not enabled.".to_owned());
32 };
33 let Some(store) = memory.graph_store.clone() else {
34 if self.services.memory.extraction.graph_config.enabled {
35 return Err(
36 "Graph memory enabled but vector store unavailable (Qdrant unreachable)."
37 .to_owned(),
38 );
39 }
40 return Err("Graph memory is not enabled.".to_owned());
41 };
42 Ok((memory, store))
43 }
44}
45
46enum EntityLookup {
50 Found(Entity),
51 Message(String),
52}
53
54enum StoreCallOutcome<T> {
57 Completed(T),
58 TimedOut,
59}
60
61async fn with_graph_store_timeout<T>(
66 fut: impl Future<Output = Result<T, zeph_memory::MemoryError>>,
67) -> Result<StoreCallOutcome<T>, CommandError> {
68 match tokio::time::timeout(Duration::from_secs(5), fut).await {
69 Ok(Ok(v)) => Ok(StoreCallOutcome::Completed(v)),
70 Ok(Err(e)) => Err(CommandError::new(e.to_string())),
71 Err(_) => {
72 tracing::warn!("graph store call timed out after 5s (Qdrant unreachable)");
73 Ok(StoreCallOutcome::TimedOut)
74 }
75 }
76}
77
78async fn resolve_entity_by_name(
81 store: &GraphStore,
82 name: &str,
83) -> Result<EntityLookup, CommandError> {
84 let matches = match with_graph_store_timeout(store.find_entity_by_name(name)).await? {
85 StoreCallOutcome::Completed(v) => v,
86 StoreCallOutcome::TimedOut => {
87 return Ok(EntityLookup::Message(
88 "Graph store unavailable (Qdrant unreachable).".to_owned(),
89 ));
90 }
91 };
92 let Some(entity) = matches.into_iter().next() else {
93 return Ok(EntityLookup::Message(format!(
94 "No entity found matching '{name}'."
95 )));
96 };
97 Ok(EntityLookup::Found(entity))
98}
99
100async fn build_entity_name_map(
105 store: &GraphStore,
106 entity: &Entity,
107 edges: &[Edge],
108) -> std::collections::HashMap<i64, String> {
109 let mut entity_names: std::collections::HashMap<i64, String> = std::collections::HashMap::new();
110 entity_names.insert(entity.id.0, entity.name.clone());
111 for edge in edges {
112 entity_names.entry(edge.source_entity_id).or_default();
113 entity_names.entry(edge.target_entity_id).or_default();
114 }
115 for (&id, name_val) in &mut entity_names {
116 if name_val.is_empty() {
117 let result =
118 tokio::time::timeout(Duration::from_secs(5), store.find_entity_by_id(id)).await;
119 if let Ok(Ok(Some(other))) = result {
120 *name_val = other.name;
121 } else {
122 *name_val = format!("#{id}");
123 }
124 }
125 }
126 entity_names
127}
128
129async fn semantic_scan_plugin_add(
141 scanner: &zeph_skills::semantic_scanner::SkillSemanticScanner,
142 source: &str,
143 managed_dir: Option<std::path::PathBuf>,
144 mcp_allowed: Vec<String>,
145 base_shell_allowed: Vec<String>,
146) -> Result<Option<String>, CommandError> {
147 use futures::stream::StreamExt as _;
148 use zeph_skills::semantic_scanner::ScanVerdict;
149
150 let plugins_dir = zeph_plugins::PluginManager::default_plugins_dir();
151 let mgr_dir =
152 managed_dir.unwrap_or_else(|| zeph_config::defaults::default_vault_dir().join("skills"));
153 let mgr =
154 zeph_plugins::PluginManager::new(plugins_dir, mgr_dir, mcp_allowed, base_shell_allowed);
155
156 let source_owned = source.to_owned();
157 let scan_inputs = tokio::task::spawn_blocking(move || mgr.scan_targets(&source_owned))
158 .await
159 .map_err(|e| CommandError(format!("plugin scan_targets panicked: {e}")))?
160 .map_err(|e| CommandError(format!("plugin add failed: {e}")))?;
161
162 tracing::info!(
163 plugin.source = %source,
164 skills_count = scan_inputs.len(),
165 "plugins.add: running Stage-2 semantic scan"
166 );
167
168 let scan_futs: Vec<_> = scan_inputs
173 .iter()
174 .map(|input| {
175 let name = input.skill_name.clone();
176 let purpose = input.declared_purpose.clone();
177 let md = input.skill_md.clone();
178 async move {
179 let verdict = scanner.scan(&name, &purpose, &md).await;
180 (name, verdict)
181 }
182 })
183 .collect();
184
185 let verdicts: Vec<_> = tokio::time::timeout(
186 std::time::Duration::from_mins(5),
187 futures::stream::iter(scan_futs)
188 .buffer_unordered(4)
189 .collect::<Vec<_>>(),
190 )
191 .await
192 .map_err(|_| CommandError("plugin scan timed out after 300s".to_owned()))?;
193
194 for (skill_name, verdict_result) in verdicts {
195 let verdict = verdict_result.map_err(|e| {
196 CommandError(format!(
197 "plugin add failed: semantic scan error for skill {skill_name:?}: {e}"
198 ))
199 })?;
200 match verdict {
201 ScanVerdict::Allow => {
202 tracing::debug!(
203 skill = %skill_name,
204 "plugins.add: skill passed semantic scan"
205 );
206 }
207 ScanVerdict::Warn(ref reason) => {
208 tracing::warn!(
209 skill = %skill_name,
210 reason = %reason,
211 "plugins.add: skill passed with warning"
212 );
213 }
214 ScanVerdict::Block(reason) => {
215 return Ok(Some(format!(
216 "plugin add failed: skill {skill_name:?} rejected by semantic scan: {reason}"
217 )));
218 }
219 _ => {}
220 }
221 }
222 Ok(None)
223}
224
225impl<C: Channel + Send + 'static> AgentAccess for Agent<C> {
226 fn memory_tiers<'a>(
229 &'a mut self,
230 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
231 Box::pin(
232 async move {
233 let Some(memory) = self.services.memory.persistence.memory.clone() else {
234 return Ok("Memory not configured.".to_owned());
235 };
236 match memory.sqlite().count_messages_by_tier().await {
237 Ok((episodic, semantic)) => {
238 let mut out = String::new();
239 let _ = writeln!(out, "Memory tiers:");
240 let _ = writeln!(out, " Working: (current context window — virtual)");
241 let _ = writeln!(out, " Episodic: {episodic} messages");
242 let _ = writeln!(out, " Semantic: {semantic} facts");
243 Ok(out.trim_end().to_owned())
244 }
245 Err(e) => Ok(format!("Failed to query tier stats: {e}")),
246 }
247 }
248 .instrument(tracing::info_span!("core.agent_access.memory_tiers")),
249 )
250 }
251
252 fn memory_promote<'a>(
253 &'a mut self,
254 ids_str: &'a str,
255 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
256 Box::pin(
257 async move {
258 let Some(memory) = self.services.memory.persistence.memory.clone() else {
259 return Ok("Memory not configured.".to_owned());
260 };
261 let ids: Vec<MessageId> = ids_str
262 .split_whitespace()
263 .filter_map(|s| s.parse::<i64>().ok().map(MessageId))
264 .collect();
265 if ids.is_empty() {
266 return Ok(
267 "Usage: /memory promote <id> [id...]\nExample: /memory promote 42 43 44"
268 .to_owned(),
269 );
270 }
271 match memory.sqlite().manual_promote(&ids).await {
272 Ok(count) => Ok(format!("Promoted {count} message(s) to semantic tier.")),
273 Err(e) => Ok(format!("Promotion failed: {e}")),
274 }
275 }
276 .instrument(tracing::info_span!("core.agent_access.memory_promote")),
277 )
278 }
279
280 fn graph_stats<'a>(
283 &'a mut self,
284 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
285 Box::pin(
286 async move {
287 let (_, store) = match self.resolve_graph_store() {
288 Ok(pair) => pair,
289 Err(msg) => return Ok(msg),
290 };
291
292 let stats_future = async {
293 tokio::join!(
294 store.entity_count(),
295 store.active_edge_count(),
296 store.community_count(),
297 store.edge_type_distribution()
298 )
299 };
300 let Ok((entities, edges, communities, distribution)) =
301 tokio::time::timeout(Duration::from_secs(5), stats_future).await
302 else {
303 tracing::warn!("graph store call timed out after 5s (Qdrant unreachable)");
304 return Ok("Graph store unavailable (Qdrant unreachable).".to_owned());
305 };
306 let mut msg = format!(
307 "Graph memory: {} entities, {} edges, {} communities",
308 entities.unwrap_or(0),
309 edges.unwrap_or(0),
310 communities.unwrap_or(0)
311 );
312 if let Ok(dist) = distribution
313 && !dist.is_empty()
314 {
315 let dist_str: Vec<String> =
316 dist.iter().map(|(t, c)| format!("{t}={c}")).collect();
317 write!(msg, "\nEdge types: {}", dist_str.join(", ")).unwrap_or(());
318 }
319 Ok(msg)
320 }
321 .instrument(tracing::info_span!("core.agent_access.graph_stats")),
322 )
323 }
324
325 fn graph_entities<'a>(
326 &'a mut self,
327 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
328 Box::pin(
329 async move {
330 let (_, store) = match self.resolve_graph_store() {
331 Ok(pair) => pair,
332 Err(msg) => return Ok(msg),
333 };
334
335 let entities = match tokio::time::timeout(
336 Duration::from_secs(5),
337 store.all_entities(),
338 )
339 .await
340 {
341 Ok(Ok(v)) => v,
342 Ok(Err(e)) => return Err(CommandError::new(e.to_string())),
343 Err(_) => {
344 tracing::warn!("graph store call timed out after 5s (Qdrant unreachable)");
345 return Ok("Graph store unavailable (Qdrant unreachable).".to_owned());
346 }
347 };
348 if entities.is_empty() {
349 return Ok("No entities found.".to_owned());
350 }
351
352 let total = entities.len();
353 let display: Vec<String> = entities
354 .iter()
355 .take(50)
356 .map(|e| {
357 format!(
358 " {:<40} {:<15} {}",
359 e.name,
360 e.entity_type.as_str(),
361 e.last_seen_at.split('T').next().unwrap_or(&e.last_seen_at)
362 )
363 })
364 .collect();
365 let mut msg = format!(
366 "Entities ({total} total):\n {:<40} {:<15} {}\n{}",
367 "NAME",
368 "TYPE",
369 "LAST SEEN",
370 display.join("\n")
371 );
372 if total > 50 {
373 write!(msg, "\n ...and {} more", total - 50).unwrap_or(());
374 }
375 Ok(msg)
376 }
377 .instrument(tracing::info_span!("core.agent_access.graph_entities")),
378 )
379 }
380
381 fn graph_facts<'a>(
382 &'a mut self,
383 name: &'a str,
384 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
385 Box::pin(
386 async move {
387 let (_, store) = match self.resolve_graph_store() {
388 Ok(pair) => pair,
389 Err(msg) => return Ok(msg),
390 };
391
392 let entity = match resolve_entity_by_name(&store, name).await? {
393 EntityLookup::Found(e) => e,
394 EntityLookup::Message(msg) => return Ok(msg),
395 };
396
397 let edges =
398 match with_graph_store_timeout(store.edges_for_entity(entity.id.0)).await? {
399 StoreCallOutcome::Completed(v) => v,
400 StoreCallOutcome::TimedOut => {
401 return Ok("Graph store unavailable (Qdrant unreachable).".to_owned());
402 }
403 };
404 if edges.is_empty() {
405 return Ok(format!("Entity '{}' has no known facts.", entity.name));
406 }
407
408 let entity_names = build_entity_name_map(&store, &entity, &edges).await;
409
410 let lines: Vec<String> = edges
411 .iter()
412 .map(|e| {
413 let src = entity_names
414 .get(&e.source_entity_id)
415 .cloned()
416 .unwrap_or_else(|| format!("#{}", e.source_entity_id));
417 let tgt = entity_names
418 .get(&e.target_entity_id)
419 .cloned()
420 .unwrap_or_else(|| format!("#{}", e.target_entity_id));
421 format!(
422 " {} --[{}/{}]--> {}: {} (confidence: {:.2})",
423 src, e.relation, e.edge_type, tgt, e.fact, e.confidence
424 )
425 })
426 .collect();
427 Ok(format!(
428 "Facts for '{}':\n{}",
429 entity.name,
430 lines.join("\n")
431 ))
432 }
433 .instrument(tracing::info_span!("core.agent_access.graph_facts")),
434 )
435 }
436
437 fn graph_history<'a>(
438 &'a mut self,
439 name: &'a str,
440 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
441 Box::pin(
442 async move {
443 let (_, store) = match self.resolve_graph_store() {
444 Ok(pair) => pair,
445 Err(msg) => return Ok(msg),
446 };
447
448 let entity = match resolve_entity_by_name(&store, name).await? {
449 EntityLookup::Found(e) => e,
450 EntityLookup::Message(msg) => return Ok(msg),
451 };
452
453 let edges =
454 match with_graph_store_timeout(store.edge_history_for_entity(entity.id.0, 50))
455 .await?
456 {
457 StoreCallOutcome::Completed(v) => v,
458 StoreCallOutcome::TimedOut => {
459 return Ok("Graph store unavailable (Qdrant unreachable).".to_owned());
460 }
461 };
462 if edges.is_empty() {
463 return Ok(format!("Entity '{}' has no edge history.", entity.name));
464 }
465
466 let entity_names = build_entity_name_map(&store, &entity, &edges).await;
467
468 let n = edges.len();
469 let lines: Vec<String> = edges
470 .iter()
471 .map(|e| {
472 let status = if e.valid_to.is_some() {
473 let date = e
474 .valid_to
475 .as_deref()
476 .and_then(|s| s.split('T').next().or_else(|| s.split(' ').next()))
477 .unwrap_or("?");
478 format!("[expired {date}]")
479 } else {
480 "[active]".to_string()
481 };
482 let src = entity_names
483 .get(&e.source_entity_id)
484 .cloned()
485 .unwrap_or_else(|| format!("#{}", e.source_entity_id));
486 let tgt = entity_names
487 .get(&e.target_entity_id)
488 .cloned()
489 .unwrap_or_else(|| format!("#{}", e.target_entity_id));
490 format!(
491 " {status} {} --[{}/{}]--> {}: {} (confidence: {:.2})",
492 src, e.relation, e.edge_type, tgt, e.fact, e.confidence
493 )
494 })
495 .collect();
496 Ok(format!(
497 "Edge history for '{}' ({n} edges):\n{}",
498 entity.name,
499 lines.join("\n")
500 ))
501 }
502 .instrument(tracing::info_span!("core.agent_access.graph_history")),
503 )
504 }
505
506 fn graph_communities<'a>(
507 &'a mut self,
508 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
509 Box::pin(
510 async move {
511 let (_, store) = match self.resolve_graph_store() {
512 Ok(pair) => pair,
513 Err(msg) => return Ok(msg),
514 };
515
516 let communities =
517 match tokio::time::timeout(Duration::from_secs(5), store.all_communities())
518 .await
519 {
520 Ok(Ok(v)) => v,
521 Ok(Err(e)) => return Err(CommandError::new(e.to_string())),
522 Err(_) => {
523 tracing::warn!(
524 "graph store call timed out after 5s (Qdrant unreachable)"
525 );
526 return Ok("Graph store unavailable (Qdrant unreachable).".to_owned());
527 }
528 };
529 if communities.is_empty() {
530 return Ok("No communities detected yet. Run graph backfill first.".to_owned());
531 }
532
533 let lines: Vec<String> = communities
534 .iter()
535 .map(|c| format!(" [{}]: {}", c.name, c.summary))
536 .collect();
537 Ok(format!(
538 "Communities ({}):\n{}",
539 communities.len(),
540 lines.join("\n")
541 ))
542 }
543 .instrument(tracing::info_span!("core.agent_access.graph_communities")),
544 )
545 }
546
547 #[allow(clippy::too_many_lines)]
548 fn graph_backfill<'a>(
549 &'a mut self,
550 limit: Option<usize>,
551 progress_cb: &'a mut (dyn FnMut(String) + Send),
552 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
553 let store = match self.resolve_graph_store() {
554 Ok((_, s)) => s,
555 Err(msg) => return Box::pin(async move { Ok(msg) }),
556 };
557 let graph_cfg = self.services.memory.extraction.graph_config.clone();
558 let embed_timeout_secs = self
559 .services
560 .memory
561 .persistence
562 .memory
563 .as_ref()
564 .map_or(5, |m| m.embed_timeout().as_secs());
565 let provider = if graph_cfg.extract_provider.as_str().is_empty() {
566 self.provider.clone()
567 } else {
568 self.resolve_background_provider(graph_cfg.extract_provider.as_str())
569 };
570 Box::pin(
571 async move {
572 let total = store.unprocessed_message_count().await.unwrap_or(0);
573 let cap = limit.unwrap_or(usize::MAX);
574
575 progress_cb(format!(
576 "Starting graph backfill... ({total} unprocessed messages)"
577 ));
578
579 let batch_size = 50usize;
580 let mut processed = 0usize;
581 let mut total_entities = 0usize;
582 let mut total_edges = 0usize;
583
584 loop {
585 let remaining_cap = cap.saturating_sub(processed);
586 if remaining_cap == 0 {
587 break;
588 }
589 let batch_limit = batch_size.min(remaining_cap);
590 let messages = store
591 .unprocessed_messages_for_backfill(batch_limit)
592 .await
593 .map_err(|e| CommandError::new(e.to_string()))?;
594 if messages.is_empty() {
595 break;
596 }
597
598 let ids: Vec<zeph_memory::types::MessageId> =
599 messages.iter().map(|(id, _)| *id).collect();
600
601 for (_id, content) in &messages {
602 if content.trim().is_empty() {
603 continue;
604 }
605 let extraction_cfg = GraphExtractionConfig {
606 max_entities: graph_cfg.max_entities_per_message,
607 max_edges: graph_cfg.max_edges_per_message,
608 extraction_timeout_secs: graph_cfg.extraction_timeout_secs,
609 community_refresh_interval: 0,
610 expired_edge_retention_days: graph_cfg.expired_edge_retention_days,
611 max_entities_cap: graph_cfg.max_entities,
612 community_summary_max_prompt_bytes: graph_cfg
613 .community_summary_max_prompt_bytes,
614 community_summary_concurrency: graph_cfg.community_summary_concurrency,
615 lpa_edge_chunk_size: graph_cfg.lpa_edge_chunk_size,
616 note_linking: zeph_memory::NoteLinkingConfig::default(),
617 link_weight_decay_lambda: graph_cfg.link_weight_decay_lambda,
618 link_weight_decay_interval_secs: graph_cfg
619 .link_weight_decay_interval_secs,
620 belief_revision_enabled: graph_cfg.belief_revision.enabled,
621 belief_revision_similarity_threshold: graph_cfg
622 .belief_revision
623 .similarity_threshold,
624 conversation_id: None,
625 apex_mem_enabled: graph_cfg.apex_mem.enabled,
626 llm_timeout_secs: graph_cfg.llm_timeout_secs,
627 embed_timeout_secs,
628 turn_index: None,
629 write_gate_min_relevance: graph_cfg
630 .write_gate
631 .enabled
632 .then_some(graph_cfg.write_gate.min_edge_relevance),
633 benna_fast_rate: graph_cfg.spreading_activation.benna_fast_rate,
634 benna_slow_rate: graph_cfg.spreading_activation.benna_slow_rate,
635 provenance: None,
636 system_prompt: None,
637 recall_include_imported: graph_cfg.recall_include_imported,
638 };
639 let pool = store.pool().clone();
640 match extract_and_store(
641 content.clone(),
642 vec![],
643 provider.clone(),
644 pool,
645 extraction_cfg,
646 None,
647 None,
648 )
649 .await
650 {
651 Ok(result) => {
652 total_entities += result.stats.entities_upserted;
653 total_edges += result.stats.edges_inserted;
654 }
655 Err(e) => {
656 tracing::warn!("backfill extraction error: {e:#}");
657 }
658 }
659 }
660
661 store
662 .mark_messages_graph_processed(&ids)
663 .await
664 .map_err(|e| CommandError::new(e.to_string()))?;
665 processed += messages.len();
666
667 progress_cb(format!(
668 "Backfill progress: {processed} messages processed, \
669 {total_entities} entities, {total_edges} edges"
670 ));
671 }
672
673 Ok(format!(
674 "Backfill complete: {total_entities} entities, {total_edges} edges \
675 extracted from {processed} messages"
676 ))
677 }
678 .instrument(tracing::info_span!("core.agent_access.graph_backfill")),
679 )
680 }
681
682 fn knowledge_status<'a>(
685 &'a mut self,
686 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
687 Box::pin(
688 async move {
689 use zeph_memory::graph::ingest::IngestLedger;
690
691 let Some(memory) = self.services.memory.persistence.memory.clone() else {
692 return Ok("Memory subsystem not available.".to_owned());
693 };
694 let pool = memory.sqlite().pool().clone();
695 let ledger = IngestLedger::new(pool);
696
697 let rows = match ledger.summary().await {
698 Ok(r) => r,
699 Err(e) => return Err(CommandError(e.to_string())),
700 };
701
702 if rows.is_empty() {
703 return Ok("No knowledge has been ingested yet. \
704 Run `zeph knowledge ingest --source <src>`."
705 .to_owned());
706 }
707
708 let mut out = format!("Knowledge ingest ledger ({} entries):\n\n", rows.len());
709 let mut current_batch = String::new();
710 for row in &rows {
711 let batch_short = &row.import_batch_id[..row.import_batch_id.len().min(8)];
712 if current_batch != row.import_batch_id {
713 if !current_batch.is_empty() {
714 out.push('\n');
715 }
716 current_batch.clone_from(&row.import_batch_id);
717 }
718 let uri_display = &row.source_uri[..row.source_uri.floor_char_boundary(40)];
719 let at_display = &row.ingested_at[..row.ingested_at.len().min(19)];
720 let _ = writeln!(
721 out,
722 " {uri_display:<40} batch={batch_short} at={at_display} \
723 e={} edges={}",
724 row.entities, row.edges,
725 );
726 }
727 Ok(out.trim_end().to_owned())
728 }
729 .instrument(tracing::info_span!("core.agent_access.knowledge_status")),
730 )
731 }
732
733 fn knowledge_rollback<'a>(
734 &'a mut self,
735 batch_id: &'a str,
736 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
737 Box::pin(
738 async move {
739 use zeph_memory::graph::ingest::IngestLedger;
740
741 let Some(memory) = self.services.memory.persistence.memory.clone() else {
742 return Ok("Memory subsystem not available.".to_owned());
743 };
744 let pool = memory.sqlite().pool().clone();
745 let ledger = IngestLedger::new(pool.clone());
746
747 match ledger.batch_exists(batch_id).await {
748 Ok(false) => {
749 return Ok(format!("Batch '{batch_id}' not found in ledger."));
750 }
751 Err(e) => return Err(CommandError(e.to_string())),
752 Ok(true) => {}
753 }
754
755 let Some(graph_store) = memory.graph_store.clone() else {
756 return Ok(
757 "Graph store unavailable (Qdrant unreachable or graph not enabled)."
758 .to_owned(),
759 );
760 };
761
762 let mut tx = zeph_db::begin_write(&pool)
763 .await
764 .map_err(|e| CommandError(e.to_string()))?;
765
766 let (edges, entities) = graph_store
767 .delete_batch_in_tx(batch_id, &mut tx)
768 .await
769 .map_err(|e| CommandError(e.to_string()))?;
770 ledger
771 .delete_batch_in_tx(batch_id, &mut tx)
772 .await
773 .map_err(|e| CommandError(e.to_string()))?;
774
775 tx.commit().await.map_err(|e| CommandError(e.to_string()))?;
776
777 let mut msg = format!(
778 "Rolled back batch '{batch_id}': removed {edges} edge(s) and \
779 {entities} entity(ies)."
780 );
781 if edges == 0 && entities == 0 {
782 msg.push_str(
783 "\nNote: no graph rows found. Phase-1 ingest writes to Qdrant notes — \
784 Qdrant embeddings are NOT removed by this rollback.",
785 );
786 }
787 Ok(msg)
788 }
789 .instrument(tracing::info_span!("core.agent_access.knowledge_rollback")),
790 )
791 }
792
793 fn guidelines<'a>(
796 &'a mut self,
797 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
798 Box::pin(
799 async move {
800 const MAX_DISPLAY_CHARS: usize = 4096;
801
802 let Some(memory) = &self.services.memory.persistence.memory else {
803 return Ok("No memory backend initialised.".to_owned());
804 };
805
806 let cid = self.services.memory.persistence.conversation_id;
807 let sqlite = memory.sqlite();
808
809 let (version, text) = sqlite
810 .load_compression_guidelines(cid)
811 .await
812 .map_err(|e: zeph_memory::MemoryError| CommandError::new(e.to_string()))?;
813
814 if version == 0 || text.is_empty() {
815 return Ok("No compression guidelines generated yet.".to_owned());
816 }
817
818 let (_, created_at) = sqlite
819 .load_compression_guidelines_meta(cid)
820 .await
821 .unwrap_or((0, String::new()));
822
823 let (body, truncated) = if text.len() > MAX_DISPLAY_CHARS {
824 let end = text.floor_char_boundary(MAX_DISPLAY_CHARS);
825 (&text[..end], true)
826 } else {
827 (text.as_str(), false)
828 };
829
830 let mut output =
831 format!("Compression Guidelines (v{version}, updated {created_at}):\n\n{body}");
832 if truncated {
833 output.push_str("\n\n[truncated]");
834 }
835 Ok(output)
836 }
837 .instrument(tracing::info_span!("core.agent_access.guidelines")),
838 )
839 }
840
841 fn handle_caveman<'a>(
844 &'a mut self,
845 arg: &'a str,
846 ) -> Pin<Box<dyn Future<Output = String> + Send + 'a>> {
847 Box::pin(async move {
848 let active = &mut self.services.session.caveman_active;
849 match arg.trim() {
850 "on" | "enable" => {
851 *active = true;
852 "caveman: on".to_owned()
853 }
854 "off" | "disable" => {
855 *active = false;
856 "caveman: off".to_owned()
857 }
858 "status" => {
859 if *active {
860 "caveman: on".to_owned()
861 } else {
862 "caveman: off".to_owned()
863 }
864 }
865 _ => {
866 *active = !*active;
867 if *active {
868 "caveman: on".to_owned()
869 } else {
870 "caveman: off".to_owned()
871 }
872 }
873 }
874 })
875 }
876
877 fn handle_model<'a>(
880 &'a mut self,
881 arg: &'a str,
882 ) -> Pin<Box<dyn Future<Output = String> + Send + 'a>> {
883 Box::pin(async move {
884 let input = if arg.is_empty() {
885 "/model".to_owned()
886 } else {
887 format!("/model {arg}")
888 };
889 self.handle_model_command_as_string(&input).await
890 })
891 }
892
893 fn handle_provider<'a>(
894 &'a mut self,
895 arg: &'a str,
896 ) -> Pin<Box<dyn Future<Output = String> + Send + 'a>> {
897 Box::pin(async move { self.handle_provider_command_as_string(arg).await })
898 }
899
900 fn handle_policy<'a>(
903 &'a mut self,
904 args: &'a str,
905 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
906 Box::pin(async move { Ok(self.handle_policy_command_as_string(args)) })
907 }
908
909 #[cfg(feature = "scheduler")]
912 fn list_scheduled_tasks<'a>(
913 &'a mut self,
914 ) -> Pin<Box<dyn Future<Output = Result<Option<String>, CommandError>> + Send + 'a>> {
915 Box::pin(async move {
916 let result = self
917 .handle_scheduler_list_as_string()
918 .await
919 .map_err(|e| CommandError::new(e.to_string()))?;
920 Ok(Some(result))
921 })
922 }
923
924 #[cfg(not(feature = "scheduler"))]
925 fn list_scheduled_tasks<'a>(
926 &'a mut self,
927 ) -> Pin<Box<dyn Future<Output = Result<Option<String>, CommandError>> + Send + 'a>> {
928 Box::pin(async move { Ok(None) })
929 }
930
931 fn lsp_status<'a>(
934 &'a mut self,
935 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
936 Box::pin(async move {
937 self.handle_lsp_status_as_string()
938 .await
939 .map_err(|e| CommandError::new(e.to_string()))
940 })
941 }
942
943 fn session_recap<'a>(
946 &'a mut self,
947 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
948 Box::pin(
949 async move {
950 match self.build_recap().await {
951 Ok(text) => Ok(text),
952 Err(e) => {
953 tracing::warn!("session recap command: {}", e.0);
957 Ok("Recap unavailable — see logs for details".to_string())
958 }
959 }
960 }
961 .instrument(tracing::info_span!("core.agent_access.session_recap")),
962 )
963 }
964
965 fn compact_context<'a>(
968 &'a mut self,
969 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
970 Box::pin(
971 self.compact_context_command()
972 .instrument(tracing::info_span!("core.agent_access.compact_context")),
973 )
974 }
975
976 fn reset_conversation<'a>(
979 &'a mut self,
980 keep_plan: bool,
981 no_digest: bool,
982 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
983 Box::pin(async move {
984 match self.reset_conversation(keep_plan, no_digest).await {
985 Ok((old_id, new_id)) => {
986 let old = old_id.map_or_else(|| "none".to_string(), |id| id.0.to_string());
987 let new = new_id.map_or_else(|| "none".to_string(), |id| id.0.to_string());
988 let keep_note = if keep_plan { " (plan preserved)" } else { "" };
989 Ok(format!(
990 "New conversation started. Previous: {old} → Current: {new}{keep_note}"
991 ))
992 }
993 Err(e) => Ok(format!("Failed to start new conversation: {e}")),
994 }
995 })
996 }
997
998 fn cache_stats(&self) -> String {
1001 self.tool_orchestrator.cache_stats()
1002 }
1003
1004 fn session_status<'a>(
1007 &'a mut self,
1008 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1009 Box::pin(async move { Ok(self.handle_status_as_string()) })
1010 }
1011
1012 fn guardrail_status(&self) -> String {
1015 self.format_guardrail_status()
1016 }
1017
1018 fn focus_status(&self) -> String {
1021 self.format_focus_status()
1022 }
1023
1024 fn sidequest_status(&self) -> String {
1027 self.format_sidequest_status()
1028 }
1029
1030 fn load_image<'a>(
1033 &'a mut self,
1034 path: &'a str,
1035 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1036 use zeph_common::path_guard::{PathRejection, classify_relative_path};
1037 use zeph_llm::provider::{ImageData, MessagePart};
1038
1039 match classify_relative_path(path) {
1040 PathRejection::Allowed => {}
1041 PathRejection::Absolute => {
1042 return Box::pin(async move {
1043 Ok(
1044 "Invalid image path: absolute paths are not supported, use a path \
1045 relative to the working directory"
1046 .to_owned(),
1047 )
1048 });
1049 }
1050 PathRejection::Traversal => {
1051 return Box::pin(async move {
1052 Ok("Invalid image path: path traversal ('..') is not allowed".to_owned())
1053 });
1054 }
1055 }
1056
1057 let path_owned = path.to_owned();
1058 Box::pin(async move {
1059 let path_for_task = path_owned.clone();
1060 let read_result = tokio::task::spawn_blocking(move || std::fs::read(&path_for_task))
1061 .await
1062 .map_err(|e| CommandError::new(format!("spawn_blocking join error: {e}")))?;
1063 let data = match read_result {
1064 Ok(d) => d,
1065 Err(e) => return Ok(format!("Cannot read image {path_owned}: {e}")),
1066 };
1067 if data.len() > crate::agent::message_queue::MAX_IMAGE_BYTES {
1068 return Ok(format!(
1069 "Image {path_owned} exceeds size limit ({} MB), skipping",
1070 crate::agent::message_queue::MAX_IMAGE_BYTES / 1024 / 1024
1071 ));
1072 }
1073 let mime_type =
1074 crate::agent::message_queue::detect_image_mime(Some(&path_owned)).to_string();
1075 self.msg
1076 .pending_image_parts
1077 .push(MessagePart::Image(Box::new(ImageData { data, mime_type })));
1078 Ok(format!("Image loaded: {path_owned}. Send your message."))
1079 })
1080 }
1081
1082 fn handle_mcp<'a>(
1085 &'a mut self,
1086 args: &'a str,
1087 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1088 let args_owned = args.to_owned();
1091 let parts: Vec<String> = args_owned.split_whitespace().map(str::to_owned).collect();
1092 let sub = parts.first().cloned().unwrap_or_default();
1093
1094 match sub.as_str() {
1095 "list" => {
1096 let manager = self.services.mcp.manager.clone();
1098 let tools_snapshot: Vec<(String, String)> = self
1099 .services
1100 .mcp
1101 .tools
1102 .iter()
1103 .map(|t| (t.server_id.clone(), t.name.clone()))
1104 .collect();
1105 Box::pin(async move {
1106 use std::fmt::Write;
1107 let Some(manager) = manager else {
1108 return Ok("MCP is not enabled.".to_owned());
1109 };
1110 let server_ids = manager.list_servers().await;
1111 if server_ids.is_empty() {
1112 return Ok("No MCP servers connected.".to_owned());
1113 }
1114 let mut output = String::from("Connected MCP servers:\n");
1115 let mut total = 0usize;
1116 for id in &server_ids {
1117 let count = tools_snapshot.iter().filter(|(sid, _)| sid == id).count();
1118 total += count;
1119 let _ = writeln!(output, "- {id} ({count} tools)");
1120 }
1121 let _ = write!(output, "Total: {total} tool(s)");
1122 Ok(output)
1123 })
1124 }
1125 "tools" => {
1126 let server_id = parts.get(1).cloned();
1128 let owned_tools: Vec<(String, String)> = if let Some(ref sid) = server_id {
1129 self.services
1130 .mcp
1131 .tools
1132 .iter()
1133 .filter(|t| &t.server_id == sid)
1134 .map(|t| (t.name.clone(), t.description.clone()))
1135 .collect()
1136 } else {
1137 Vec::new()
1138 };
1139 Box::pin(async move {
1140 use std::fmt::Write;
1141 let Some(server_id) = server_id else {
1142 return Ok("Usage: /mcp tools <server_id>".to_owned());
1143 };
1144 if owned_tools.is_empty() {
1145 return Ok(format!("No tools found for server '{server_id}'."));
1146 }
1147 let mut output =
1148 format!("Tools for '{server_id}' ({} total):\n", owned_tools.len());
1149 for (name, desc) in &owned_tools {
1150 if desc.is_empty() {
1151 let _ = writeln!(output, "- {name}");
1152 } else {
1153 let _ = writeln!(output, "- {name} — {desc}");
1154 }
1155 }
1156 Ok(output)
1157 })
1158 }
1159 _ => Box::pin(async move {
1166 self.handle_mcp_command(&args_owned)
1167 .await
1168 .map_err(|e| CommandError::new(e.to_string()))
1169 }),
1170 }
1171 }
1172
1173 fn handle_skill<'a>(
1176 &'a mut self,
1177 args: &'a str,
1178 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1179 let args_owned = args.to_owned();
1180 Box::pin(async move {
1181 self.handle_skill_command_as_string(&args_owned)
1182 .await
1183 .map_err(|e| CommandError::new(e.to_string()))
1184 })
1185 }
1186
1187 fn handle_skills<'a>(
1190 &'a mut self,
1191 args: &'a str,
1192 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1193 let args_owned = args.to_owned();
1194 Box::pin(async move {
1195 self.handle_skills_as_string(&args_owned)
1196 .await
1197 .map_err(|e| CommandError::new(e.to_string()))
1198 })
1199 }
1200
1201 fn handle_feedback_command<'a>(
1204 &'a mut self,
1205 args: &'a str,
1206 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1207 let args_owned = args.to_owned();
1208 Box::pin(async move {
1209 self.handle_feedback_as_string(&args_owned)
1210 .await
1211 .map_err(|e| CommandError::new(e.to_string()))
1212 })
1213 }
1214
1215 #[cfg(feature = "scheduler")]
1218 fn handle_plan<'a>(
1219 &'a mut self,
1220 input: &'a str,
1221 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1222 Box::pin(async move {
1223 self.dispatch_plan_command_as_string(input)
1224 .await
1225 .map_err(|e| CommandError::new(e.to_string()))
1226 })
1227 }
1228
1229 #[cfg(not(feature = "scheduler"))]
1230 fn handle_plan<'a>(
1231 &'a mut self,
1232 _input: &'a str,
1233 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1234 Box::pin(async move { Ok(String::new()) })
1235 }
1236
1237 fn handle_experiment<'a>(
1240 &'a mut self,
1241 input: &'a str,
1242 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1243 Box::pin(async move {
1244 self.handle_experiment_command_as_string(input)
1245 .await
1246 .map_err(|e| CommandError::new(e.to_string()))
1247 })
1248 }
1249
1250 fn handle_agent_dispatch<'a>(
1253 &'a mut self,
1254 input: &'a str,
1255 ) -> Pin<Box<dyn Future<Output = Result<Option<String>, CommandError>> + Send + 'a>> {
1256 Box::pin(async move {
1257 match self.dispatch_agent_command(input).await {
1258 Some(Err(e)) => Err(CommandError::new(e.to_string())),
1259 Some(Ok(())) | None => Ok(None),
1260 }
1261 })
1262 }
1263
1264 fn handle_plugins<'a>(
1267 &'a mut self,
1268 args: &'a str,
1269 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1270 let args_owned = args.to_owned();
1271 let managed_dir = self.services.skill.managed_dir.clone();
1274 let mcp_allowed = self.services.mcp.allowed_commands.clone();
1275 let base_shell_allowed = self.runtime.lifecycle.startup_shell_overlay.allowed.clone();
1276 let ephemeral_manifest_paths: Vec<std::path::PathBuf> = self
1279 .runtime
1280 .ephemeral_plugins
1281 .iter()
1282 .map(|tmp| tmp.path().join("plugin.toml"))
1283 .collect();
1284
1285 let semantic_scan_enabled = self.services.skill.semantic_scan;
1289 let maybe_scanner: Option<zeph_skills::semantic_scanner::SkillSemanticScanner> =
1290 if semantic_scan_enabled {
1291 let provider_name = self.services.skill.semantic_scan_provider.as_str();
1292 if provider_name.trim().is_empty() {
1293 return Box::pin(async move {
1294 Err(CommandError::new(
1295 "semantic_scan is enabled but semantic_scan_provider is not set; \
1296 refusing plugin add to maintain fail-closed security posture",
1297 ))
1298 });
1299 }
1300 let provider_known = self
1301 .runtime
1302 .providers
1303 .provider_pool
1304 .iter()
1305 .any(|e| e.effective_name().eq_ignore_ascii_case(provider_name));
1306 if !provider_known {
1307 let name = provider_name.to_owned();
1308 return Box::pin(async move {
1309 Err(CommandError::new(format!(
1310 "semantic_scan is enabled but semantic_scan_provider '{name}' \
1311 is not configured in [[llm.providers]]; \
1312 refusing plugin add to maintain fail-closed security posture",
1313 )))
1314 });
1315 }
1316 let provider = self.resolve_background_provider(provider_name);
1317 Some(zeph_skills::semantic_scanner::SkillSemanticScanner::new(
1318 provider,
1319 ))
1320 } else {
1321 None
1322 };
1323
1324 Box::pin(async move {
1325 let (subcmd, source) = args_owned
1326 .trim()
1327 .split_once(' ')
1328 .unwrap_or((args_owned.trim(), ""));
1329
1330 if subcmd == "add"
1332 && !source.trim().is_empty()
1333 && let Some(ref scanner) = maybe_scanner
1334 && let Some(err) = semantic_scan_plugin_add(
1335 scanner,
1336 source.trim(),
1337 managed_dir.clone(),
1338 mcp_allowed.clone(),
1339 base_shell_allowed.clone(),
1340 )
1341 .instrument(tracing::info_span!("core.agent.scan_plugin", plugin = %source.trim()))
1342 .await?
1343 {
1344 return Ok(err);
1345 }
1346
1347 let ephemeral_names: Vec<String> = {
1349 use futures::future::join_all;
1350 let futs = ephemeral_manifest_paths.into_iter().map(|p| async move {
1351 tokio::fs::read_to_string(&p)
1352 .await
1353 .ok()
1354 .and_then(|s| toml::from_str::<zeph_plugins::PluginManifest>(&s).ok())
1355 .map(|m| m.plugin.name.to_string())
1356 });
1357 join_all(futs).await.into_iter().flatten().collect()
1358 };
1359
1360 tokio::task::spawn_blocking(move || {
1363 Self::run_plugin_command(
1364 &args_owned,
1365 managed_dir,
1366 mcp_allowed,
1367 base_shell_allowed,
1368 ephemeral_names,
1369 )
1370 })
1371 .await
1372 .map_err(|e| CommandError(format!("plugin task panicked: {e}")))
1373 })
1374 }
1375
1376 fn handle_acp<'a>(
1379 &'a mut self,
1380 args: &'a str,
1381 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1382 Box::pin(async move {
1383 self.handle_acp_as_string(args)
1384 .map_err(|e| CommandError::new(e.to_string()))
1385 })
1386 }
1387
1388 #[cfg(feature = "cocoon")]
1391 fn handle_cocoon<'a>(
1392 &'a mut self,
1393 args: &'a str,
1394 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1395 Box::pin(async move {
1396 self.handle_cocoon_as_string(args)
1397 .await
1398 .map_err(|e| CommandError::new(e.to_string()))
1399 })
1400 }
1401
1402 #[cfg(not(feature = "cocoon"))]
1403 fn handle_cocoon<'a>(
1404 &'a mut self,
1405 _args: &'a str,
1406 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1407 Box::pin(async {
1408 Ok("Cocoon support is not compiled in. Rebuild with `--features cocoon`.".to_owned())
1409 })
1410 }
1411
1412 fn handle_loop<'a>(
1415 &'a mut self,
1416 args: &'a str,
1417 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1418 use zeph_commands::handlers::loop_cmd::parse_loop_args;
1419
1420 let args_owned = args.trim().to_owned();
1421 Box::pin(async move {
1422 if args_owned == "stop" {
1423 return Ok(self.stop_user_loop());
1424 }
1425 if args_owned == "status" {
1426 return Ok(match &self.runtime.lifecycle.user_loop {
1427 Some(ls) => format!(
1428 "Loop active: \"{}\" (iteration {}, interval every {}s).",
1429 ls.prompt,
1430 ls.iteration,
1431 ls.interval.period().as_secs(),
1432 ),
1433 None => "No active loop.".to_owned(),
1434 });
1435 }
1436 let (prompt, interval_secs) = parse_loop_args(&args_owned)?;
1437
1438 if prompt.starts_with('/') {
1439 return Err(CommandError::new(
1440 "Loop prompt must not start with '/'. Slash commands cannot be used as loop prompts.",
1441 ));
1442 }
1443
1444 let min_secs = self.runtime.config.loop_min_interval_secs;
1445 if interval_secs < min_secs {
1446 return Err(CommandError::new(format!(
1447 "Minimum loop interval is {min_secs}s. Got {interval_secs}s."
1448 )));
1449 }
1450 if self.runtime.lifecycle.user_loop.is_some() {
1451 return Err(CommandError::new(
1452 "A loop is already active. Use /loop stop first.",
1453 ));
1454 }
1455
1456 self.start_user_loop(prompt.clone(), interval_secs);
1457 Ok(format!(
1458 "Loop started: \"{prompt}\" every {interval_secs}s. Use /loop stop to cancel."
1459 ))
1460 })
1461 }
1462
1463 fn notify_test<'a>(
1464 &'a mut self,
1465 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1466 let notifier = self.runtime.lifecycle.notifier.clone();
1467 Box::pin(async move {
1468 let Some(notifier) = notifier else {
1469 return Ok(
1470 "Notifications are disabled. Set `notifications.enabled = true` in config."
1471 .to_owned(),
1472 );
1473 };
1474 match notifier.fire_test().await {
1475 Ok(()) => Ok("Test notification sent.".to_owned()),
1476 Err(e) => Err(CommandError::new(format!("notification test failed: {e}"))),
1477 }
1478 })
1479 }
1480
1481 fn handle_trajectory(&mut self, args: &str) -> String {
1482 self.handle_trajectory_command_as_string(args)
1483 }
1484
1485 fn handle_scope(&self, args: &str) -> String {
1486 self.handle_scope_command_as_string(args)
1487 }
1488
1489 fn handle_goal<'a>(
1492 &'a mut self,
1493 args: &'a str,
1494 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1495 if self.services.goal_accounting.is_none() {
1497 if !self.runtime.config.goals.enabled {
1498 return Box::pin(async {
1499 Ok("Goals are disabled. Set `[goals] enabled = true` in config.".to_owned())
1500 });
1501 }
1502 let pool = match self.services.memory.persistence.memory.as_ref() {
1503 Some(m) => std::sync::Arc::new(m.sqlite().pool().clone()),
1504 None => {
1505 return Box::pin(async {
1506 Ok("Goals require a database backend (memory not configured).".to_owned())
1507 });
1508 }
1509 };
1510 let store = std::sync::Arc::new(crate::goal::GoalStore::new(pool));
1511 let accounting = std::sync::Arc::new(crate::goal::GoalAccounting::new(store));
1512 self.services.goal_accounting = Some(accounting);
1513 }
1514
1515 let accounting =
1516 self.services.goal_accounting.clone().expect(
1517 "invariant: goal_accounting is always Some at this point (initialized above)",
1518 );
1519 let max_chars = self.runtime.config.goals.max_text_chars;
1520 let default_budget = self.runtime.config.goals.default_token_budget;
1521 let autonomous_enabled = self.runtime.config.goals.autonomous_enabled;
1522 let autonomous_max_turns = self.runtime.config.goals.autonomous_max_turns;
1523 let args_owned = args.to_owned();
1524
1525 let pending_start_arc = std::sync::Arc::clone(&self.services.autonomous.pending_start_arc);
1530
1531 Box::pin(async move {
1532 let _ = accounting.refresh().await;
1533 let store = accounting.get_store();
1534 let args = args_owned.as_str();
1535
1536 match args {
1537 "" | "status" => goal_status(&accounting).await,
1538 "pause" => goal_pause(&accounting, &store).await,
1539 "resume" => goal_resume(&accounting, &store).await,
1540 "complete" => goal_complete(&accounting, &store).await,
1541 "clear" => goal_clear(&accounting, &store).await,
1542 "list" => goal_list(&store).await,
1543 _ if args.starts_with("create") => {
1544 let (msg, auto_req) = goal_create(
1545 args,
1546 &accounting,
1547 &store,
1548 max_chars,
1549 default_budget,
1550 autonomous_enabled,
1551 autonomous_max_turns,
1552 )
1553 .await?;
1554 if let Some(req) = auto_req {
1555 *pending_start_arc.lock() = Some(req);
1556 }
1557 Ok(msg)
1558 }
1559 _ => Ok(
1560 "Unknown /goal subcommand. Try: create, pause, resume, complete, clear, status, list."
1561 .to_owned(),
1562 ),
1563 }
1564 })
1565 }
1566
1567 fn active_goal_snapshot(&self) -> Option<zeph_commands::GoalSnapshot> {
1568 let accounting = self.services.goal_accounting.as_ref()?;
1569 let snap = accounting.snapshot()?;
1570 Some(zeph_commands::GoalSnapshot {
1571 id: snap.id,
1572 text: snap.text,
1573 status: match snap.status {
1574 crate::goal::GoalStatus::Active => zeph_commands::GoalStatusView::Active,
1575 crate::goal::GoalStatus::Paused => zeph_commands::GoalStatusView::Paused,
1576 crate::goal::GoalStatus::Completed => zeph_commands::GoalStatusView::Completed,
1577 crate::goal::GoalStatus::Cleared => zeph_commands::GoalStatusView::Cleared,
1578 },
1579 turns_used: snap.turns_used,
1580 tokens_used: snap.tokens_used,
1581 token_budget: snap.token_budget,
1582 })
1583 }
1584
1585 fn handle_undo<'a>(
1588 &'a mut self,
1589 args: &'a str,
1590 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1591 let executor = std::sync::Arc::clone(&self.tool_executor);
1592 let args_owned = args.trim().to_owned();
1593 Box::pin(async move {
1594 if args_owned == "list" {
1595 let result = executor.checkpoint_list_erased();
1596 if !result.supported {
1597 return Ok(
1598 "Checkpoints are not enabled. Set `[tools.shell] checkpoints_enabled = true` in config.".to_owned()
1599 );
1600 }
1601 if result.entries.is_empty() {
1602 return Ok("Undo stack is empty.".to_owned());
1603 }
1604 let mut lines = vec![format!("Undo stack ({} entries):", result.entries.len())];
1605 for e in &result.entries {
1606 lines.push(format!(
1607 " [{}] {} ({} file(s))",
1608 e.index, e.command, e.file_count
1609 ));
1610 }
1611 if result.redo_depth > 0 {
1612 lines.push(format!("Redo depth: {}", result.redo_depth));
1613 }
1614 return Ok(lines.join("\n"));
1615 }
1616
1617 let n: usize = if args_owned.is_empty() {
1618 1
1619 } else {
1620 match args_owned.parse::<usize>() {
1621 Ok(v) if v > 0 => v,
1622 _ => {
1623 return Err(CommandError::new(format!(
1624 "Invalid argument: expected a positive integer or 'list', got '{args_owned}'"
1625 )));
1626 }
1627 }
1628 };
1629
1630 let result = tokio::task::spawn_blocking(move || executor.checkpoint_undo_erased(n))
1631 .await
1632 .map_err(|e| CommandError::new(format!("undo task panicked: {e}")))?;
1633 if !result.supported {
1634 return Ok(
1635 "Checkpoints are not enabled. Set `[tools.shell] checkpoints_enabled = true` in config.".to_owned()
1636 );
1637 }
1638 Ok(result.message)
1639 })
1640 }
1641
1642 fn handle_redo<'a>(
1643 &'a mut self,
1644 args: &'a str,
1645 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1646 let _ = args;
1647 let executor = std::sync::Arc::clone(&self.tool_executor);
1648 Box::pin(async move {
1649 let result = tokio::task::spawn_blocking(move || executor.checkpoint_redo_erased())
1650 .await
1651 .map_err(|e| CommandError::new(format!("redo task panicked: {e}")))?;
1652 if !result.supported {
1653 return Ok(
1654 "Checkpoints are not enabled. Set `[tools.shell] checkpoints_enabled = true` in config.".to_owned()
1655 );
1656 }
1657 Ok(result.message)
1658 })
1659 }
1660
1661 fn handle_agents<'a>(
1664 &'a mut self,
1665 args: &'a str,
1666 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1667 use zeph_commands::handlers::agents_fleet::{FleetEntry, format_fleet_section};
1668 use zeph_subagent::AgentsCommand;
1669
1670 let args_owned = args.trim().to_owned();
1671 Box::pin(async move {
1672 let show_fleet = args_owned.is_empty() || args_owned == "fleet";
1674
1675 let fleet_section = if show_fleet {
1676 let snapshots = self.services.autonomous_registry.list();
1677 let entries: Vec<FleetEntry> = snapshots
1678 .into_iter()
1679 .map(|s| FleetEntry {
1680 goal_id: s.goal_id,
1681 goal_text_short: s.goal_text_short,
1682 state: s.state,
1683 turns_executed: s.turns_executed,
1684 max_turns: s.max_turns,
1685 elapsed: s.elapsed,
1686 })
1687 .collect();
1688 format_fleet_section(&entries)
1689 } else {
1690 String::new()
1691 };
1692
1693 let definitions_section = if show_fleet || args_owned == "list" {
1695 self.handle_agents_definitions_list()
1696 } else {
1697 match AgentsCommand::parse(&format!("/agents {args_owned}")) {
1699 Ok(cmd) => self.handle_agents_crud(cmd),
1700 Err(e) => e.to_string(),
1701 }
1702 };
1703
1704 let mut out = fleet_section;
1705 if !definitions_section.is_empty() {
1706 if !out.is_empty() {
1707 out.push('\n');
1708 }
1709 out.push_str(&definitions_section);
1710 }
1711
1712 if out.is_empty() {
1713 "No active autonomous sessions or sub-agent definitions found."
1714 .clone_into(&mut out);
1715 }
1716
1717 Ok(out)
1718 })
1719 }
1720
1721 fn handle_conv<'a>(
1724 &'a mut self,
1725 args: &'a str,
1726 ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1727 let args_owned = args.trim().to_owned();
1728 Box::pin(async move {
1729 if let Some(id) = args_owned.strip_prefix("resume ") {
1732 return self.handle_conv_resume(id.trim()).await;
1733 }
1734 if let Some(id) = args_owned.strip_prefix("fork ") {
1735 return self.handle_conv_fork(id.trim()).await;
1736 }
1737
1738 let Some(memory) = self.services.memory.persistence.memory.clone() else {
1739 return Ok(
1740 "Conversation-session persistence requires memory to be enabled ([memory] enabled = true)."
1741 .to_owned(),
1742 );
1743 };
1744 let store = zeph_session::SessionStore::new(memory.sqlite().pool().clone());
1745
1746 if let Some(id) = args_owned.strip_prefix("show ") {
1747 return handle_conv_show(&store, id.trim()).await;
1748 }
1749 if args_owned.is_empty() || args_owned == "list" {
1750 return handle_conv_list(&store).await;
1751 }
1752 Ok(format!(
1753 "Unknown /conv subcommand '{args_owned}'. Usage: /conv [list | show <id> | resume <id> | fork <id>]"
1754 ))
1755 })
1756 }
1757}
1758
1759impl<C: Channel> Agent<C> {
1760 async fn handle_conv_resume(&mut self, id: &str) -> Result<String, CommandError> {
1766 if id.is_empty() {
1767 return Ok("Usage: /conv resume <id>".to_owned());
1768 }
1769 if let Some(sink) = &self.services.session.session_sink
1775 && sink.session_id().as_str() == id
1776 {
1777 return Ok(format!("Already in session '{id}'."));
1778 }
1779 let Some(memory) = self.services.memory.persistence.memory.clone() else {
1780 return Ok(
1781 "Conversation-session persistence requires memory to be enabled ([memory] enabled = true)."
1782 .to_owned(),
1783 );
1784 };
1785 let store = zeph_session::SessionStore::new(memory.sqlite().pool().clone());
1786 let Some(metadata) = store
1787 .get(id)
1788 .await
1789 .map_err(|e| CommandError::new(e.to_string()))?
1790 else {
1791 return Ok(format!("Session '{id}' not found."));
1792 };
1793
1794 let conversation_id = if let Some(cid) = metadata.conversation_id {
1795 zeph_memory::ConversationId(cid)
1796 } else {
1797 let cid = memory
1798 .sqlite()
1799 .create_conversation()
1800 .await
1801 .map_err(|e| CommandError::new(e.to_string()))?;
1802 store
1803 .link_conversation(id, cid.0)
1804 .await
1805 .map_err(|e| CommandError::new(e.to_string()))?;
1806 cid
1807 };
1808
1809 let session_id = zeph_common::SessionId::new(id);
1810 self.load_and_resume_conversation(&session_id, conversation_id)
1811 .await
1812 .map_err(|e| CommandError::new(e.to_string()))?;
1813
1814 Ok(format!(
1815 "Resumed session {id} ({} event(s) replayed).",
1816 metadata.event_count
1817 ))
1818 }
1819
1820 async fn handle_conv_fork(&mut self, id: &str) -> Result<String, CommandError> {
1825 if id.is_empty() {
1826 return Ok("Usage: /conv fork <id>".to_owned());
1827 }
1828 let Some(memory) = self.services.memory.persistence.memory.clone() else {
1829 return Ok(
1830 "Conversation-session persistence requires memory to be enabled ([memory] enabled = true)."
1831 .to_owned(),
1832 );
1833 };
1834 let Some(session_persistence_config) =
1835 self.services.session.session_persistence_config.clone()
1836 else {
1837 return Ok(
1838 "Conversation-session persistence is not enabled ([session] enabled = true)."
1839 .to_owned(),
1840 );
1841 };
1842 let store = zeph_session::SessionStore::new(memory.sqlite().pool().clone());
1843 let data_dir = std::path::PathBuf::from(&session_persistence_config.data_dir);
1844 let new_id = zeph_common::SessionId::generate();
1845
1846 let fork_result =
1847 zeph_session::ForkEngine::fork(&data_dir, id, new_id.as_str(), None, &store)
1848 .await
1849 .map_err(|e| CommandError::new(e.to_string()))?;
1850
1851 let conversation_id = memory
1852 .sqlite()
1853 .create_conversation()
1854 .await
1855 .map_err(|e| CommandError::new(e.to_string()))?;
1856
1857 self.load_and_resume_conversation(&new_id, conversation_id)
1858 .await
1859 .map_err(|e| CommandError::new(e.to_string()))?;
1860
1861 Ok(format!(
1862 "Forked session {id} -> {} ({} event(s) copied); now the active conversation.",
1863 fork_result.new_session_id, fork_result.events_copied
1864 ))
1865 }
1866}
1867
1868async fn handle_conv_list(store: &zeph_session::SessionStore) -> Result<String, CommandError> {
1871 use std::fmt::Write as _;
1872
1873 let sessions = store
1874 .list(&zeph_session::SessionFilter::default())
1875 .await
1876 .map_err(|e| CommandError::new(format!("failed to list sessions: {e}")))?;
1877
1878 if sessions.is_empty() {
1879 return Ok("No conversation-sessions found.".to_owned());
1880 }
1881
1882 let mut out = format!(
1883 "{:<38} {:<30} {:<9} {:>6} {:<24}\n",
1884 "ID", "TITLE", "STATUS", "EVENTS", "UPDATED"
1885 );
1886 out.push_str(&"-".repeat(110));
1887 out.push('\n');
1888 for s in &sessions {
1889 let title = s.title.as_deref().unwrap_or("(untitled)");
1890 let _ = writeln!(
1891 out,
1892 "{:<38} {:<30} {:<9} {:>6} {:<24}",
1893 s.session_id,
1894 crate::text::truncate_to_chars(title, 30),
1895 s.status.as_str(),
1896 s.event_count,
1897 s.updated_at
1898 );
1899 }
1900 Ok(out.trim_end().to_owned())
1901}
1902
1903async fn handle_conv_show(
1907 store: &zeph_session::SessionStore,
1908 id: &str,
1909) -> Result<String, CommandError> {
1910 if id.is_empty() {
1911 return Ok("Usage: /conv show <id>".to_owned());
1912 }
1913 let metadata = store
1914 .get(id)
1915 .await
1916 .map_err(|e| CommandError::new(format!("failed to read session metadata: {e}")))?;
1917 let Some(m) = metadata else {
1918 return Ok(format!("Session '{id}' not found."));
1919 };
1920 Ok(format!(
1921 "Session {}\n title: {}\n status: {}\n events: {} (last_seq={})\n forked_from: {}\n created: {}\n updated: {}",
1922 m.session_id,
1923 m.title.as_deref().unwrap_or("(untitled)"),
1924 m.status.as_str(),
1925 m.event_count,
1926 m.last_seq,
1927 m.forked_from.as_deref().unwrap_or("-"),
1928 m.created_at,
1929 m.updated_at
1930 ))
1931}
1932
1933type GoalStore = crate::goal::GoalStore;
1934type GoalAccounting = crate::goal::GoalAccounting;
1935
1936const AUTONOMOUS_MAX_TURNS_CAP: u32 = 1000;
1938
1939async fn goal_status(accounting: &GoalAccounting) -> Result<String, CommandError> {
1940 match accounting.get_active().await {
1941 Ok(Some(g)) => {
1942 let budget_line = g.token_budget.map_or_else(
1943 || format!(" tokens used: {}", g.tokens_used),
1944 |b| format!(" budget: {}/{b}", g.tokens_used),
1945 );
1946 Ok(format!(
1947 "Active goal [{}]: {}\n status: {}\n turns: {}\n{}",
1948 &g.id[..8],
1949 g.text,
1950 g.status,
1951 g.turns_used,
1952 budget_line
1953 ))
1954 }
1955 Ok(None) => Ok("No active goal. Use `/goal create <text>` to set one.".to_owned()),
1956 Err(e) => Ok(format!("Goal lookup failed: {e}")),
1957 }
1958}
1959
1960async fn goal_create(
1966 args: &str,
1967 accounting: &GoalAccounting,
1968 store: &GoalStore,
1969 max_chars: usize,
1970 default_budget: Option<u64>,
1971 autonomous_enabled: bool,
1972 autonomous_max_turns: u32,
1973) -> Result<(String, Option<(String, String, u32)>), CommandError> {
1974 let rest = args.strip_prefix("create").unwrap_or("").trim();
1975
1976 let (stripped, is_auto, explicit_turns) = parse_auto_flags(rest);
1978 let (text, explicit_budget) = parse_goal_create_args(&stripped);
1979
1980 if text.is_empty() {
1981 return Ok((
1982 "Usage: /goal create <text> [--budget N] [--auto [--turns N]]".to_owned(),
1983 None,
1984 ));
1985 }
1986 if is_auto && !autonomous_enabled {
1987 return Ok((
1988 "Autonomous mode is disabled. Set `[goals] autonomous_enabled = true` in config."
1989 .to_owned(),
1990 None,
1991 ));
1992 }
1993 let budget = explicit_budget.or(default_budget.filter(|&b| b > 0));
1994
1995 let max_turns = explicit_turns
1996 .unwrap_or(autonomous_max_turns)
1997 .min(AUTONOMOUS_MAX_TURNS_CAP);
1998 if explicit_turns.is_some_and(|t| t > AUTONOMOUS_MAX_TURNS_CAP) {
1999 tracing::warn!(
2000 requested = explicit_turns,
2001 capped = AUTONOMOUS_MAX_TURNS_CAP,
2002 "autonomous max_turns capped to {AUTONOMOUS_MAX_TURNS_CAP}"
2003 );
2004 }
2005
2006 match store.create(text, budget, max_chars).await {
2007 Ok(g) => {
2008 let _ = accounting.refresh().await;
2009 let auto_start = if is_auto {
2010 Some((g.id.clone(), g.text.clone(), max_turns))
2011 } else {
2012 None
2013 };
2014 let auto_note = if is_auto {
2015 " Autonomous mode enabled — use `/goal clear` to stop."
2016 } else {
2017 ""
2018 };
2019 Ok((
2020 format!("Goal created [{}]: {}{auto_note}", &g.id[..8], g.text),
2021 auto_start,
2022 ))
2023 }
2024 Err(crate::goal::store::GoalError::TextTooLong { max }) => Ok((
2025 format!("Goal text exceeds {max} characters. Please shorten it."),
2026 None,
2027 )),
2028 Err(e) => Ok((format!("Failed to create goal: {e}"), None)),
2029 }
2030}
2031
2032async fn goal_pause(
2033 accounting: &GoalAccounting,
2034 store: &GoalStore,
2035) -> Result<String, CommandError> {
2036 match accounting.get_active().await {
2037 Ok(Some(g)) => {
2038 match store
2039 .transition(&g.id, crate::goal::GoalStatus::Paused, g.updated_at)
2040 .await
2041 {
2042 Ok(_) => {
2043 let _ = accounting.refresh().await;
2044 Ok(format!("Goal [{}] paused.", &g.id[..8]))
2045 }
2046 Err(crate::goal::store::GoalError::StaleUpdate(_)) => {
2047 let current = accounting.get_active().await.ok().flatten();
2048 Ok(format!(
2049 "Goal state changed concurrently. Current: {}",
2050 current.map_or_else(|| "none".into(), |g| g.status.to_string())
2051 ))
2052 }
2053 Err(e) => Ok(format!("Pause failed: {e}")),
2054 }
2055 }
2056 Ok(None) => Ok("No active goal to pause.".to_owned()),
2057 Err(e) => Ok(format!("Failed: {e}")),
2058 }
2059}
2060
2061async fn goal_resume(
2062 accounting: &GoalAccounting,
2063 store: &GoalStore,
2064) -> Result<String, CommandError> {
2065 let goals = store.list(10).await.unwrap_or_default();
2066 let paused = goals
2067 .into_iter()
2068 .find(|g| g.status == crate::goal::GoalStatus::Paused);
2069 match paused {
2070 Some(g) => {
2071 match store
2072 .transition(&g.id, crate::goal::GoalStatus::Active, g.updated_at)
2073 .await
2074 {
2075 Ok(_) => {
2076 let _ = accounting.refresh().await;
2077 Ok(format!("Goal [{}] resumed: {}", &g.id[..8], g.text))
2078 }
2079 Err(crate::goal::store::GoalError::StaleUpdate(_)) => {
2080 Ok("Goal state changed concurrently — please retry.".to_owned())
2081 }
2082 Err(e) => Ok(format!("Resume failed: {e}")),
2083 }
2084 }
2085 None => Ok("No paused goal to resume.".to_owned()),
2086 }
2087}
2088
2089async fn goal_complete(
2090 accounting: &GoalAccounting,
2091 store: &GoalStore,
2092) -> Result<String, CommandError> {
2093 match accounting.get_active().await {
2094 Ok(Some(g)) => {
2095 match store
2096 .transition(&g.id, crate::goal::GoalStatus::Completed, g.updated_at)
2097 .await
2098 {
2099 Ok(_) => {
2100 let _ = accounting.refresh().await;
2101 Ok(format!("Goal [{}] marked complete.", &g.id[..8]))
2102 }
2103 Err(e) => Ok(format!("Complete failed: {e}")),
2104 }
2105 }
2106 Ok(None) => Ok("No active goal.".to_owned()),
2107 Err(e) => Ok(format!("Failed: {e}")),
2108 }
2109}
2110
2111async fn goal_clear(
2112 accounting: &GoalAccounting,
2113 store: &GoalStore,
2114) -> Result<String, CommandError> {
2115 let goals = store.list(10).await.unwrap_or_default();
2116 let target = goals.into_iter().find(|g| {
2117 g.status == crate::goal::GoalStatus::Active || g.status == crate::goal::GoalStatus::Paused
2118 });
2119 match target {
2120 Some(g) => {
2121 match store
2122 .transition(&g.id, crate::goal::GoalStatus::Cleared, g.updated_at)
2123 .await
2124 {
2125 Ok(_) => {
2126 let _ = accounting.refresh().await;
2127 Ok(format!("Goal [{}] cleared.", &g.id[..8]))
2128 }
2129 Err(e) => Ok(format!("Clear failed: {e}")),
2130 }
2131 }
2132 None => Ok("No active or paused goal to clear.".to_owned()),
2133 }
2134}
2135
2136async fn goal_list(store: &GoalStore) -> Result<String, CommandError> {
2137 let goals = store.list(20).await.unwrap_or_default();
2138 if goals.is_empty() {
2139 return Ok("No goals recorded.".to_owned());
2140 }
2141 let mut out = String::from("Goals:\n");
2142 for g in goals {
2143 let _ = std::fmt::Write::write_fmt(
2144 &mut out,
2145 format_args!(
2146 " {} [{}] {} — {} turns\n",
2147 g.status.badge_symbol(),
2148 &g.id[..8],
2149 g.text,
2150 g.turns_used
2151 ),
2152 );
2153 }
2154 Ok(out.trim_end().to_owned())
2155}
2156
2157fn parse_goal_create_args(args: &str) -> (&str, Option<u64>) {
2158 if let Some(pos) = args.find("--budget") {
2159 let text = args[..pos].trim();
2160 let rest = args[pos + "--budget".len()..].trim();
2161 let budget = rest
2162 .split_whitespace()
2163 .next()
2164 .and_then(|s| s.parse::<u64>().ok());
2165 (text, budget)
2166 } else {
2167 (args, None)
2168 }
2169}
2170
2171fn parse_auto_flags(args: &str) -> (String, bool, Option<u32>) {
2175 let mut is_auto = false;
2176 let mut turns: Option<u32> = None;
2177 let mut text_words: Vec<&str> = Vec::new();
2178 let mut words = args.split_whitespace();
2179
2180 while let Some(w) = words.next() {
2181 if w == "--auto" {
2182 is_auto = true;
2183 } else if w == "--turns" {
2184 turns = words.next().and_then(|n| n.parse::<u32>().ok());
2185 } else {
2186 text_words.push(w);
2187 }
2188 }
2189
2190 (text_words.join(" "), is_auto, turns)
2191}
2192
2193impl From<AgentError> for CommandError {
2195 fn from(e: AgentError) -> Self {
2196 Self(e.to_string())
2197 }
2198}
2199
2200#[cfg(test)]
2201mod tests {
2202 use super::super::agent_tests::{
2203 MockChannel, MockToolExecutor, create_test_registry, mock_provider,
2204 };
2205 use super::*;
2206 use zeph_commands::traits::agent::AgentAccess;
2207 use zeph_memory::semantic::SemanticMemory;
2208
2209 async fn memory_without_qdrant() -> SemanticMemory {
2210 SemanticMemory::new(
2211 ":memory:",
2212 "http://127.0.0.1:1",
2213 None,
2214 zeph_llm::any::AnyProvider::Mock(zeph_llm::mock::MockProvider::default()),
2215 "test-model",
2216 )
2217 .await
2218 .unwrap()
2219 }
2220
2221 #[tokio::test]
2225 async fn graph_stats_enabled_but_no_store_reports_unavailable() {
2226 let cfg = crate::config::GraphConfig {
2227 enabled: true,
2228 ..Default::default()
2229 };
2230 let memory = memory_without_qdrant().await;
2231 let cid = memory.sqlite().create_conversation().await.unwrap();
2232 let mut agent = Agent::new(
2233 mock_provider(vec![]),
2234 MockChannel::new(vec![]),
2235 create_test_registry(),
2236 None,
2237 5,
2238 MockToolExecutor::no_tools(),
2239 )
2240 .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
2241 .with_graph_config(cfg);
2242
2243 let result = agent.graph_stats().await.unwrap();
2244 assert!(
2245 result.contains("unavailable"),
2246 "expected 'unavailable' but got: {result}"
2247 );
2248 assert!(
2249 !result.contains("not enabled"),
2250 "must not report 'not enabled' when graph is enabled: {result}"
2251 );
2252 }
2253
2254 #[tokio::test]
2255 async fn graph_stats_disabled_reports_not_enabled() {
2256 let cfg = crate::config::GraphConfig {
2257 enabled: false,
2258 ..Default::default()
2259 };
2260 let memory = memory_without_qdrant().await;
2261 let cid = memory.sqlite().create_conversation().await.unwrap();
2262 let mut agent = Agent::new(
2263 mock_provider(vec![]),
2264 MockChannel::new(vec![]),
2265 create_test_registry(),
2266 None,
2267 5,
2268 MockToolExecutor::no_tools(),
2269 )
2270 .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
2271 .with_graph_config(cfg);
2272
2273 let result = agent.graph_stats().await.unwrap();
2274 assert!(
2275 result.contains("not enabled"),
2276 "expected 'not enabled' but got: {result}"
2277 );
2278 }
2279
2280 #[tokio::test]
2286 async fn graph_backfill_with_extract_provider_resolves_without_panic() {
2287 let cfg = crate::config::GraphConfig {
2288 enabled: true,
2289 extract_provider: zeph_config::providers::ProviderName::new("nonexistent-provider"),
2290 ..Default::default()
2291 };
2292 let mut memory = memory_without_qdrant().await;
2293 let pool = memory.sqlite().pool().clone();
2295 memory.graph_store = Some(std::sync::Arc::new(zeph_memory::GraphStore::new(pool)));
2296 let cid = memory.sqlite().create_conversation().await.unwrap();
2297 let mut agent = Agent::new(
2298 mock_provider(vec![]),
2299 MockChannel::new(vec![]),
2300 create_test_registry(),
2301 None,
2302 5,
2303 MockToolExecutor::no_tools(),
2304 )
2305 .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
2306 .with_graph_config(cfg);
2307
2308 let mut progress = vec![];
2309 let result = agent
2310 .graph_backfill(Some(10), &mut |msg| progress.push(msg))
2311 .await
2312 .unwrap();
2313
2314 assert!(
2316 result.contains("Backfill complete"),
2317 "expected 'Backfill complete' but got: {result}"
2318 );
2319 }
2320
2321 #[tokio::test]
2324 async fn graph_entities_enabled_but_no_store_reports_unavailable() {
2325 let cfg = crate::config::GraphConfig {
2326 enabled: true,
2327 ..Default::default()
2328 };
2329 let memory = memory_without_qdrant().await;
2330 let cid = memory.sqlite().create_conversation().await.unwrap();
2331 let mut agent = Agent::new(
2332 mock_provider(vec![]),
2333 MockChannel::new(vec![]),
2334 create_test_registry(),
2335 None,
2336 5,
2337 MockToolExecutor::no_tools(),
2338 )
2339 .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
2340 .with_graph_config(cfg);
2341
2342 let result = agent.graph_entities().await.unwrap();
2343 assert!(
2344 result.contains("unavailable"),
2345 "expected 'unavailable' but got: {result}"
2346 );
2347 }
2348
2349 #[tokio::test]
2351 async fn graph_communities_enabled_but_no_store_reports_unavailable() {
2352 let cfg = crate::config::GraphConfig {
2353 enabled: true,
2354 ..Default::default()
2355 };
2356 let memory = memory_without_qdrant().await;
2357 let cid = memory.sqlite().create_conversation().await.unwrap();
2358 let mut agent = Agent::new(
2359 mock_provider(vec![]),
2360 MockChannel::new(vec![]),
2361 create_test_registry(),
2362 None,
2363 5,
2364 MockToolExecutor::no_tools(),
2365 )
2366 .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
2367 .with_graph_config(cfg);
2368
2369 let result = agent.graph_communities().await.unwrap();
2370 assert!(
2371 result.contains("unavailable"),
2372 "expected 'unavailable' but got: {result}"
2373 );
2374 }
2375
2376 #[tokio::test]
2381 async fn graph_store_timeout_pattern_fires_on_pending_future() {
2382 use std::future;
2383 let result = tokio::time::timeout(
2384 Duration::from_millis(10),
2385 future::pending::<Result<Vec<()>, String>>(),
2386 )
2387 .await;
2388 assert!(
2389 result.is_err(),
2390 "timeout must fire on a never-resolving future"
2391 );
2392 }
2393
2394 #[tokio::test]
2401 async fn with_graph_store_timeout_completes_on_success() {
2402 let result = with_graph_store_timeout(async { Ok::<_, zeph_memory::MemoryError>(42) })
2403 .await
2404 .unwrap();
2405 assert!(matches!(result, StoreCallOutcome::Completed(42)));
2406 }
2407
2408 #[tokio::test]
2409 async fn with_graph_store_timeout_maps_store_error_to_command_error() {
2410 let result = with_graph_store_timeout(async {
2411 Err::<i32, _>(zeph_memory::MemoryError::GraphStore("boom".to_owned()))
2412 })
2413 .await;
2414 assert!(result.is_err(), "store error must surface as CommandError");
2415 }
2416
2417 #[tokio::test]
2418 async fn with_graph_store_timeout_times_out_on_pending_future() {
2419 tokio::time::pause();
2420 let fut = with_graph_store_timeout(std::future::pending::<
2421 Result<i32, zeph_memory::MemoryError>,
2422 >());
2423 let handle = tokio::spawn(fut); tokio::time::advance(std::time::Duration::from_secs(6)).await;
2425 let result = handle.await.expect("task panicked");
2426 assert!(
2427 matches!(result, Ok(StoreCallOutcome::TimedOut)),
2428 "call must resolve to TimedOut once the 5s deadline elapses"
2429 );
2430 }
2431
2432 fn install_graph_store(memory: &mut SemanticMemory) -> std::sync::Arc<zeph_memory::GraphStore> {
2438 let pool = memory.sqlite().pool().clone();
2439 let store = std::sync::Arc::new(zeph_memory::GraphStore::new(pool));
2440 memory.graph_store = Some(store.clone());
2441 store
2442 }
2443
2444 #[tokio::test]
2445 async fn graph_facts_happy_path_returns_formatted_facts() {
2446 let mut memory = memory_without_qdrant().await;
2447 let store = install_graph_store(&mut memory);
2448 let cid = memory.sqlite().create_conversation().await.unwrap();
2449
2450 let alice = store
2451 .upsert_entity(
2452 "Alice",
2453 "alice",
2454 zeph_memory::EntityType::Person,
2455 None,
2456 None,
2457 )
2458 .await
2459 .unwrap();
2460 let bob = store
2461 .upsert_entity("Bob", "bob", zeph_memory::EntityType::Person, None, None)
2462 .await
2463 .unwrap();
2464 store
2465 .insert_edge(alice.0, bob.0, "knows", "Alice knows Bob", 0.9, None, None)
2466 .await
2467 .unwrap();
2468
2469 let cfg = crate::config::GraphConfig {
2470 enabled: true,
2471 ..Default::default()
2472 };
2473 let mut agent = Agent::new(
2474 mock_provider(vec![]),
2475 MockChannel::new(vec![]),
2476 create_test_registry(),
2477 None,
2478 5,
2479 MockToolExecutor::no_tools(),
2480 )
2481 .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
2482 .with_graph_config(cfg);
2483
2484 let result = agent.graph_facts("Alice").await.unwrap();
2485 assert!(
2486 result.contains("Facts for 'Alice'"),
2487 "expected facts header, got: {result}"
2488 );
2489 assert!(
2490 result.contains("Bob"),
2491 "expected target entity name, got: {result}"
2492 );
2493 assert!(result.contains("knows"), "expected relation, got: {result}");
2494 assert!(
2495 result.contains("Alice knows Bob"),
2496 "expected fact text, got: {result}"
2497 );
2498 }
2499
2500 #[tokio::test]
2501 async fn graph_facts_entity_not_found_returns_message() {
2502 let mut memory = memory_without_qdrant().await;
2503 install_graph_store(&mut memory);
2504 let cid = memory.sqlite().create_conversation().await.unwrap();
2505
2506 let cfg = crate::config::GraphConfig {
2507 enabled: true,
2508 ..Default::default()
2509 };
2510 let mut agent = Agent::new(
2511 mock_provider(vec![]),
2512 MockChannel::new(vec![]),
2513 create_test_registry(),
2514 None,
2515 5,
2516 MockToolExecutor::no_tools(),
2517 )
2518 .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
2519 .with_graph_config(cfg);
2520
2521 let result = agent.graph_facts("Nobody").await.unwrap();
2522 assert_eq!(result, "No entity found matching 'Nobody'.");
2523 }
2524
2525 #[tokio::test]
2529 async fn graph_facts_enabled_but_no_store_reports_unavailable() {
2530 let cfg = crate::config::GraphConfig {
2531 enabled: true,
2532 ..Default::default()
2533 };
2534 let memory = memory_without_qdrant().await;
2535 let cid = memory.sqlite().create_conversation().await.unwrap();
2536 let mut agent = Agent::new(
2537 mock_provider(vec![]),
2538 MockChannel::new(vec![]),
2539 create_test_registry(),
2540 None,
2541 5,
2542 MockToolExecutor::no_tools(),
2543 )
2544 .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
2545 .with_graph_config(cfg);
2546
2547 let result = agent.graph_facts("Alice").await.unwrap();
2548 assert!(
2549 result.contains("unavailable"),
2550 "expected 'unavailable' but got: {result}"
2551 );
2552 }
2553
2554 #[tokio::test]
2561 async fn graph_facts_self_loop_edge_does_not_panic() {
2562 let mut memory = memory_without_qdrant().await;
2563 let store = install_graph_store(&mut memory);
2564 let cid = memory.sqlite().create_conversation().await.unwrap();
2565
2566 let self_entity = store
2567 .upsert_entity("Self", "self", zeph_memory::EntityType::Concept, None, None)
2568 .await
2569 .unwrap();
2570 let pool = memory.sqlite().pool().clone();
2571 zeph_db::query(zeph_db::sql!(
2572 "DROP TRIGGER IF EXISTS graph_edges_no_self_loops"
2573 ))
2574 .execute(&pool)
2575 .await
2576 .unwrap();
2577 zeph_db::query(zeph_db::sql!(
2578 "INSERT INTO graph_edges (source_entity_id, target_entity_id, relation, fact, confidence) \
2579 VALUES (?, ?, ?, ?, ?)"
2580 ))
2581 .bind(self_entity.0)
2582 .bind(self_entity.0)
2583 .bind("refers_to")
2584 .bind("Self refers to itself")
2585 .bind(1.0_f64)
2586 .execute(&pool)
2587 .await
2588 .unwrap();
2589
2590 let cfg = crate::config::GraphConfig {
2591 enabled: true,
2592 ..Default::default()
2593 };
2594 let mut agent = Agent::new(
2595 mock_provider(vec![]),
2596 MockChannel::new(vec![]),
2597 create_test_registry(),
2598 None,
2599 5,
2600 MockToolExecutor::no_tools(),
2601 )
2602 .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
2603 .with_graph_config(cfg);
2604
2605 let result = agent.graph_facts("Self").await.unwrap();
2606 assert!(
2607 result.contains("Facts for 'Self'"),
2608 "expected facts header, got: {result}"
2609 );
2610 assert!(
2611 result.contains("refers_to"),
2612 "expected self-loop relation, got: {result}"
2613 );
2614 assert!(
2615 !result.contains('#'),
2616 "self-loop endpoint must resolve to the entity's own name, not a raw #id \
2617 placeholder: {result}"
2618 );
2619 }
2620
2621 #[tokio::test]
2622 async fn graph_history_happy_path_returns_formatted_history() {
2623 let mut memory = memory_without_qdrant().await;
2624 let store = install_graph_store(&mut memory);
2625 let cid = memory.sqlite().create_conversation().await.unwrap();
2626
2627 let alice = store
2628 .upsert_entity(
2629 "Alice",
2630 "alice",
2631 zeph_memory::EntityType::Person,
2632 None,
2633 None,
2634 )
2635 .await
2636 .unwrap();
2637 let bob = store
2638 .upsert_entity("Bob", "bob", zeph_memory::EntityType::Person, None, None)
2639 .await
2640 .unwrap();
2641 store
2642 .insert_edge(alice.0, bob.0, "knows", "Alice knows Bob", 0.9, None, None)
2643 .await
2644 .unwrap();
2645
2646 let cfg = crate::config::GraphConfig {
2647 enabled: true,
2648 ..Default::default()
2649 };
2650 let mut agent = Agent::new(
2651 mock_provider(vec![]),
2652 MockChannel::new(vec![]),
2653 create_test_registry(),
2654 None,
2655 5,
2656 MockToolExecutor::no_tools(),
2657 )
2658 .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
2659 .with_graph_config(cfg);
2660
2661 let result = agent.graph_history("Alice").await.unwrap();
2662 assert!(
2663 result.contains("Edge history for 'Alice'"),
2664 "expected history header, got: {result}"
2665 );
2666 assert!(
2667 result.contains("[active]"),
2668 "expected active tag, got: {result}"
2669 );
2670 assert!(
2671 result.contains("Bob"),
2672 "expected target entity name, got: {result}"
2673 );
2674 }
2675
2676 #[tokio::test]
2677 async fn graph_history_entity_not_found_returns_message() {
2678 let mut memory = memory_without_qdrant().await;
2679 install_graph_store(&mut memory);
2680 let cid = memory.sqlite().create_conversation().await.unwrap();
2681
2682 let cfg = crate::config::GraphConfig {
2683 enabled: true,
2684 ..Default::default()
2685 };
2686 let mut agent = Agent::new(
2687 mock_provider(vec![]),
2688 MockChannel::new(vec![]),
2689 create_test_registry(),
2690 None,
2691 5,
2692 MockToolExecutor::no_tools(),
2693 )
2694 .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
2695 .with_graph_config(cfg);
2696
2697 let result = agent.graph_history("Nobody").await.unwrap();
2698 assert_eq!(result, "No entity found matching 'Nobody'.");
2699 }
2700
2701 #[tokio::test]
2702 async fn graph_history_enabled_but_no_store_reports_unavailable() {
2703 let cfg = crate::config::GraphConfig {
2704 enabled: true,
2705 ..Default::default()
2706 };
2707 let memory = memory_without_qdrant().await;
2708 let cid = memory.sqlite().create_conversation().await.unwrap();
2709 let mut agent = Agent::new(
2710 mock_provider(vec![]),
2711 MockChannel::new(vec![]),
2712 create_test_registry(),
2713 None,
2714 5,
2715 MockToolExecutor::no_tools(),
2716 )
2717 .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
2718 .with_graph_config(cfg);
2719
2720 let result = agent.graph_history("Alice").await.unwrap();
2721 assert!(
2722 result.contains("unavailable"),
2723 "expected 'unavailable' but got: {result}"
2724 );
2725 }
2726
2727 #[tokio::test]
2729 async fn graph_history_self_loop_edge_does_not_panic() {
2730 let mut memory = memory_without_qdrant().await;
2731 let store = install_graph_store(&mut memory);
2732 let cid = memory.sqlite().create_conversation().await.unwrap();
2733
2734 let self_entity = store
2735 .upsert_entity("Self", "self", zeph_memory::EntityType::Concept, None, None)
2736 .await
2737 .unwrap();
2738 let pool = memory.sqlite().pool().clone();
2739 zeph_db::query(zeph_db::sql!(
2740 "DROP TRIGGER IF EXISTS graph_edges_no_self_loops"
2741 ))
2742 .execute(&pool)
2743 .await
2744 .unwrap();
2745 zeph_db::query(zeph_db::sql!(
2746 "INSERT INTO graph_edges (source_entity_id, target_entity_id, relation, fact, confidence) \
2747 VALUES (?, ?, ?, ?, ?)"
2748 ))
2749 .bind(self_entity.0)
2750 .bind(self_entity.0)
2751 .bind("refers_to")
2752 .bind("Self refers to itself")
2753 .bind(1.0_f64)
2754 .execute(&pool)
2755 .await
2756 .unwrap();
2757
2758 let cfg = crate::config::GraphConfig {
2759 enabled: true,
2760 ..Default::default()
2761 };
2762 let mut agent = Agent::new(
2763 mock_provider(vec![]),
2764 MockChannel::new(vec![]),
2765 create_test_registry(),
2766 None,
2767 5,
2768 MockToolExecutor::no_tools(),
2769 )
2770 .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
2771 .with_graph_config(cfg);
2772
2773 let result = agent.graph_history("Self").await.unwrap();
2774 assert!(
2775 result.contains("Edge history for 'Self'"),
2776 "expected history header, got: {result}"
2777 );
2778 assert!(
2779 result.contains("refers_to"),
2780 "expected self-loop relation, got: {result}"
2781 );
2782 assert!(
2783 !result.contains('#'),
2784 "self-loop endpoint must resolve to the entity's own name, not a raw #id \
2785 placeholder: {result}"
2786 );
2787 }
2788
2789 #[tokio::test]
2794 async fn plugin_add_semantic_scan_enabled_empty_provider_returns_error() {
2795 let mut agent = Agent::new(
2796 mock_provider(vec![]),
2797 MockChannel::new(vec![]),
2798 create_test_registry(),
2799 None,
2800 5,
2801 MockToolExecutor::no_tools(),
2802 )
2803 .with_semantic_scan(true, "");
2804
2805 let result = agent.handle_plugins("add some-plugin").await;
2806 assert!(
2807 result.is_err(),
2808 "expected CommandError for missing semantic_scan_provider, got: {result:?}"
2809 );
2810 let msg = result.unwrap_err().to_string();
2811 assert!(
2812 msg.contains("semantic_scan_provider"),
2813 "error message must mention semantic_scan_provider, got: {msg}"
2814 );
2815 }
2816
2817 #[tokio::test]
2820 async fn plugin_list_semantic_scan_disabled_succeeds() {
2821 let mut agent = Agent::new(
2822 mock_provider(vec![]),
2823 MockChannel::new(vec![]),
2824 create_test_registry(),
2825 None,
2826 5,
2827 MockToolExecutor::no_tools(),
2828 )
2829 .with_semantic_scan(false, "");
2830
2831 let result = agent.handle_plugins("list").await;
2833 assert!(
2834 result.is_ok(),
2835 "plugin list must succeed when semantic_scan is disabled, got: {result:?}"
2836 );
2837 }
2838
2839 #[tokio::test]
2843 async fn plugin_add_semantic_scan_disabled_no_scan_error() {
2844 let mut agent = Agent::new(
2845 mock_provider(vec![]),
2846 MockChannel::new(vec![]),
2847 create_test_registry(),
2848 None,
2849 5,
2850 MockToolExecutor::no_tools(),
2851 )
2852 .with_semantic_scan(false, "");
2853
2854 let result = agent.handle_plugins("add some-plugin").await;
2855 if let Err(ref e) = result {
2858 assert!(
2859 !e.to_string().contains("semantic_scan_provider"),
2860 "must not fail with scan error when semantic_scan is disabled, got: {e}"
2861 );
2862 }
2863 }
2864
2865 #[tokio::test]
2869 async fn semantic_scan_plugin_add_concurrent_all_allow_returns_none() {
2870 use zeph_llm::any::AnyProvider;
2871 use zeph_llm::mock::MockProvider;
2872 use zeph_skills::semantic_scanner::SkillSemanticScanner;
2873
2874 let allow_json = r#"{"verdict":"allow","reason":"ok"}"#.to_owned();
2876 let provider = AnyProvider::Mock(MockProvider::with_responses(vec![
2877 allow_json.clone(),
2878 allow_json,
2879 ]));
2880 let scanner = SkillSemanticScanner::new(provider);
2881
2882 let tmp = tempfile::tempdir().unwrap();
2885 let plugin_toml = r#"
2886[plugin]
2887name = "test-plugin"
2888version = "0.1.0"
2889description = "test"
2890
2891[[skills]]
2892path = "skill-a"
2893
2894[[skills]]
2895path = "skill-b"
2896"#;
2897 std::fs::write(tmp.path().join("plugin.toml"), plugin_toml).unwrap();
2898 for name in ["skill-a", "skill-b"] {
2899 let skill_dir = tmp.path().join(name);
2900 std::fs::create_dir_all(&skill_dir).unwrap();
2901 std::fs::write(
2902 skill_dir.join("SKILL.md"),
2903 format!("# {name}\n\n## Purpose\nTest skill.\n"),
2904 )
2905 .unwrap();
2906 }
2907
2908 let result =
2909 semantic_scan_plugin_add(&scanner, tmp.path().to_str().unwrap(), None, vec![], vec![])
2910 .await;
2911
2912 assert!(result.is_ok(), "expected Ok, got: {result:?}");
2914 assert!(
2915 result.unwrap().is_none(),
2916 "expected None (all passed) but got Some(err)"
2917 );
2918 }
2919
2920 #[tokio::test]
2925 async fn semantic_scan_plugin_add_block_names_correct_skill() {
2926 use zeph_llm::any::AnyProvider;
2927 use zeph_llm::mock::MockProvider;
2928 use zeph_skills::semantic_scanner::SkillSemanticScanner;
2929
2930 let allow_json = r#"{"verdict":"allow","reason":"ok"}"#.to_owned();
2932 let block_json = r#"{"verdict":"block","reason":"malicious"}"#.to_owned();
2933 let provider =
2934 AnyProvider::Mock(MockProvider::with_responses(vec![allow_json, block_json]));
2935 let scanner = SkillSemanticScanner::new(provider);
2936
2937 let tmp = tempfile::tempdir().unwrap();
2938 let plugin_toml = r#"
2939[plugin]
2940name = "test-plugin-block"
2941version = "0.1.0"
2942description = "test"
2943
2944[[skills]]
2945path = "skill-first"
2946
2947[[skills]]
2948path = "skill-second"
2949"#;
2950 std::fs::write(tmp.path().join("plugin.toml"), plugin_toml).unwrap();
2951 for name in ["skill-first", "skill-second"] {
2952 let skill_dir = tmp.path().join(name);
2953 std::fs::create_dir_all(&skill_dir).unwrap();
2954 std::fs::write(
2955 skill_dir.join("SKILL.md"),
2956 format!("# {name}\n\n## Purpose\nTest skill.\n"),
2957 )
2958 .unwrap();
2959 }
2960
2961 let result =
2962 semantic_scan_plugin_add(&scanner, tmp.path().to_str().unwrap(), None, vec![], vec![])
2963 .await;
2964
2965 assert!(result.is_ok(), "expected Ok(_), got: {result:?}");
2966 let msg = result
2967 .unwrap()
2968 .expect("expected Some(err) for blocked skill");
2969 assert!(
2970 msg.contains("skill-second"),
2971 "rejection must name the blocked skill 'skill-second', got: {msg}"
2972 );
2973 assert!(
2974 !msg.contains("skill-first"),
2975 "rejection must NOT name the allowed skill 'skill-first', got: {msg}"
2976 );
2977 }
2978
2979 #[tokio::test]
2982 async fn plugin_add_semantic_scan_unknown_provider_returns_error() {
2983 let mut agent = Agent::new(
2984 mock_provider(vec![]),
2985 MockChannel::new(vec![]),
2986 create_test_registry(),
2987 None,
2988 5,
2989 MockToolExecutor::no_tools(),
2990 )
2991 .with_semantic_scan(true, "nonexistent_provider");
2992
2993 let result = agent.handle_plugins("add some-plugin").await;
2994 assert!(
2995 result.is_err(),
2996 "expected CommandError for unknown semantic_scan_provider, got: {result:?}"
2997 );
2998 let msg = result.unwrap_err().to_string();
2999 assert!(
3000 msg.contains("semantic_scan_provider"),
3001 "error message must mention semantic_scan_provider, got: {msg}"
3002 );
3003 }
3004
3005 #[tokio::test]
3011 async fn handle_conv_resume_same_session_short_circuits() {
3012 let memory = memory_without_qdrant().await;
3013 let cid = memory.sqlite().create_conversation().await.unwrap();
3014 let dir = tempfile::tempdir().unwrap();
3015 let data_dir = dir.path().to_path_buf();
3016 let session_id = zeph_common::SessionId::new("s1");
3017 let session_path = zeph_session::session_dir(&data_dir, session_id.as_str());
3018 let log = zeph_session::SessionEventLog::open_exclusive(&session_path)
3019 .await
3020 .unwrap();
3021 let store = zeph_session::SessionStore::new(memory.sqlite().pool().clone());
3022 let sink = zeph_agent_persistence::SessionSink::new(
3023 std::sync::Arc::new(log),
3024 store,
3025 session_id.clone(),
3026 );
3027 let session_config = zeph_config::SessionConfig {
3028 enabled: true,
3029 data_dir: data_dir.to_string_lossy().into_owned(),
3030 ..Default::default()
3031 };
3032
3033 let mut agent = Agent::new(
3034 mock_provider(vec![]),
3035 MockChannel::new(vec![]),
3036 create_test_registry(),
3037 None,
3038 5,
3039 MockToolExecutor::no_tools(),
3040 )
3041 .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
3042 .with_session_sink(Some(std::sync::Arc::new(sink)))
3043 .with_session_persistence_config(Some(session_config));
3044
3045 let result = agent.handle_conv("resume s1").await.unwrap();
3046 assert_eq!(
3047 result, "Already in session 's1'.",
3048 "resuming into the currently-active session must short-circuit, not attempt \
3049 hydration/lock acquisition"
3050 );
3051 }
3052
3053 #[tokio::test]
3056 async fn handle_conv_resume_different_session_still_hydrates() {
3057 let memory = memory_without_qdrant().await;
3058 let cid = memory.sqlite().create_conversation().await.unwrap();
3059 let dir = tempfile::tempdir().unwrap();
3060 let data_dir = dir.path().to_path_buf();
3061
3062 let active_session_id = zeph_common::SessionId::new("s1");
3064 let active_session_path = zeph_session::session_dir(&data_dir, active_session_id.as_str());
3065 let active_log = zeph_session::SessionEventLog::open_exclusive(&active_session_path)
3066 .await
3067 .unwrap();
3068 let active_store = zeph_session::SessionStore::new(memory.sqlite().pool().clone());
3069 let active_sink = zeph_agent_persistence::SessionSink::new(
3070 std::sync::Arc::new(active_log),
3071 active_store,
3072 active_session_id,
3073 );
3074
3075 let store = zeph_session::SessionStore::new(memory.sqlite().pool().clone());
3078 store.create("s2").await.unwrap();
3079
3080 let session_config = zeph_config::SessionConfig {
3081 enabled: true,
3082 data_dir: data_dir.to_string_lossy().into_owned(),
3083 ..Default::default()
3084 };
3085
3086 let mut agent = Agent::new(
3087 mock_provider(vec![]),
3088 MockChannel::new(vec![]),
3089 create_test_registry(),
3090 None,
3091 5,
3092 MockToolExecutor::no_tools(),
3093 )
3094 .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
3095 .with_session_sink(Some(std::sync::Arc::new(active_sink)))
3096 .with_session_persistence_config(Some(session_config));
3097
3098 let result = agent.handle_conv("resume s2").await.unwrap();
3099 assert!(
3100 result.starts_with("Resumed session s2"),
3101 "resuming into a different, unlocked session must still hydrate normally, got: {result}"
3102 );
3103 }
3104
3105 #[tokio::test]
3108 async fn handle_conv_fork_creates_child_session_and_switches_to_it() {
3109 let memory = memory_without_qdrant().await;
3110 let cid = memory.sqlite().create_conversation().await.unwrap();
3111 let dir = tempfile::tempdir().unwrap();
3112 let data_dir = dir.path().to_path_buf();
3113
3114 let store = zeph_session::SessionStore::new(memory.sqlite().pool().clone());
3115 store.create("s1").await.unwrap();
3116 let src_dir = zeph_session::session_dir(&data_dir, "s1");
3117 let log = zeph_session::SessionEventLog::open(&src_dir).await.unwrap();
3118 log.append(
3119 None,
3120 None,
3121 zeph_session::SessionEvent::SessionStarted {
3122 session_id: "s1".to_owned(),
3123 cwd: "/repo".to_owned(),
3124 provider_name: "claude".to_owned(),
3125 model: "opus".to_owned(),
3126 forked_from: None,
3127 },
3128 )
3129 .await
3130 .unwrap();
3131 store
3132 .update_seq("s1", log.last_seq().unwrap(), 1)
3133 .await
3134 .unwrap();
3135 drop(log);
3136
3137 let session_config = zeph_config::SessionConfig {
3138 enabled: true,
3139 data_dir: data_dir.to_string_lossy().into_owned(),
3140 ..Default::default()
3141 };
3142
3143 let mut agent = Agent::new(
3144 mock_provider(vec![]),
3145 MockChannel::new(vec![]),
3146 create_test_registry(),
3147 None,
3148 5,
3149 MockToolExecutor::no_tools(),
3150 )
3151 .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
3152 .with_session_persistence_config(Some(session_config));
3153
3154 let result = agent.handle_conv("fork s1").await.unwrap();
3155 assert!(
3156 result.starts_with("Forked session s1 ->"),
3157 "expected fork confirmation message, got: {result}"
3158 );
3159 assert!(
3160 result.contains("event(s) copied"),
3161 "expected copied-event count in confirmation, got: {result}"
3162 );
3163 }
3164
3165 #[tokio::test]
3170 async fn load_image_rejects_absolute_path() {
3171 let mut agent = Agent::new(
3172 mock_provider(vec![]),
3173 MockChannel::new(vec![]),
3174 create_test_registry(),
3175 None,
3176 5,
3177 MockToolExecutor::no_tools(),
3178 );
3179
3180 let result = AgentAccess::load_image(&mut agent, "/etc/passwd")
3181 .await
3182 .unwrap();
3183 assert!(result.contains("absolute paths are not supported"));
3184 }
3185
3186 #[tokio::test]
3187 async fn load_image_rejects_parent_dir_traversal() {
3188 let mut agent = Agent::new(
3189 mock_provider(vec![]),
3190 MockChannel::new(vec![]),
3191 create_test_registry(),
3192 None,
3193 5,
3194 MockToolExecutor::no_tools(),
3195 );
3196
3197 let result = AgentAccess::load_image(&mut agent, "../../etc/passwd")
3198 .await
3199 .unwrap();
3200 assert!(result.contains("path traversal") && result.contains("not allowed"));
3201 }
3202}