Skip to main content

zeph_core/agent/
agent_access_impl.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Implementation of [`zeph_commands::traits::agent::AgentAccess`] for [`Agent<C>`].
5//!
6//! Each method in `AgentAccess` returns a formatted `String` result (without sending to the
7//! channel directly), so that `CommandContext::sink` does not conflict with this borrow.
8//! The one exception is methods for subsystems that are already channel-free (memory, graph).
9//!
10//! [`Agent<C>`]: super::Agent
11
12use 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
46/// Outcome of resolving an entity by display name against the graph store: either the
47/// entity was found, or a user-facing message that the caller should return as-is (no
48/// match, or the store timed out).
49enum EntityLookup {
50    Found(Entity),
51    Message(String),
52}
53
54/// Outcome of a graph-store call bounded by [`with_graph_store_timeout`]'s 5s deadline:
55/// either it completed, or it timed out (Qdrant unreachable).
56enum StoreCallOutcome<T> {
57    Completed(T),
58    TimedOut,
59}
60
61/// Runs `fut` under a 5s timeout — the deadline shared by `resolve_entity_by_name` and the
62/// edge lookups in `graph_facts`/`graph_history`. Maps a store error to [`CommandError`];
63/// logs and reports a timeout via [`StoreCallOutcome::TimedOut`] so callers only need to
64/// turn that into their own user-facing message.
65async 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
78/// Resolves `name` to an [`Entity`] via [`GraphStore::find_entity_by_name`], bounded by a
79/// 5s timeout — the lookup block shared by `graph_facts` and `graph_history`.
80async 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
100/// Builds the `entity_id -> display_name` lookup map shared by `graph_facts` and
101/// `graph_history`: seeds `entity`'s own name, inserts a placeholder for every edge
102/// endpoint, then resolves each placeholder via [`GraphStore::find_entity_by_id`] (5s
103/// timeout), falling back to `#{id}` when the lookup fails or times out.
104async 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
129/// Run Stage-2 LLM semantic scan for all skills in the plugin at `source`.
130///
131/// Skills are scanned concurrently with up to 4 in-flight at a time
132/// (`buffer_unordered(4)`). An aggregate 5-minute timeout wraps the whole
133/// batch; each individual scan is already bounded by `SCAN_TIMEOUT` (30 s) in
134/// `SkillSemanticScanner`. Returns `Some(err_msg)` when any skill is blocked,
135/// `None` when all skills pass.
136///
137/// Each future carries its own `skill_name` so that the rejection message names
138/// the correct skill regardless of completion order (which differs from input
139/// order when futures complete out-of-order with `buffer_unordered`).
140async 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    // Scan all skills concurrently with up to 4 in-flight. Each individual scan is
169    // already bounded by SCAN_TIMEOUT (30 s); the outer 5-min cap guards the batch.
170    // Each future owns its skill_name so verdicts carry the correct name regardless
171    // of buffer_unordered completion order (which is not the same as input order).
172    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    // ----- /memory -----
227
228    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    // ----- /graph -----
281
282    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    // ----- /knowledge -----
683
684    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    // ----- /guidelines -----
794
795    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    // ----- /caveman -----
842
843    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    // ----- /model, /provider -----
878
879    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    // ----- /policy -----
901
902    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    // ----- /scheduler -----
910
911    #[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    // ----- /lsp -----
932
933    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    // ----- /recap -----
944
945    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                        // /recap is an explicit user command — surface a fixed message so that
954                        // LlmError internals (URLs with embedded credentials, response excerpts)
955                        // are never forwarded to the user channel. Full detail goes to the log.
956                        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    // ----- /compact -----
966
967    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    // ----- /new -----
977
978    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    // ----- /cache-stats -----
999
1000    fn cache_stats(&self) -> String {
1001        self.tool_orchestrator.cache_stats()
1002    }
1003
1004    // ----- /status -----
1005
1006    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    // ----- /guardrail -----
1013
1014    fn guardrail_status(&self) -> String {
1015        self.format_guardrail_status()
1016    }
1017
1018    // ----- /focus -----
1019
1020    fn focus_status(&self) -> String {
1021        self.format_focus_status()
1022    }
1023
1024    // ----- /sidequest -----
1025
1026    fn sidequest_status(&self) -> String {
1027        self.format_sidequest_status()
1028    }
1029
1030    // ----- /image -----
1031
1032    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    // ----- /mcp -----
1083
1084    fn handle_mcp<'a>(
1085        &'a mut self,
1086        args: &'a str,
1087    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1088        // Extract all owned data before the async block so no &mut self reference is
1089        // held across an .await point, satisfying the `for<'a>` Send bound.
1090        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                // Read-only: clone all data before async.
1097                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                // Read-only: collect tool info before async.
1127                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            // add/remove require mutating self after async I/O.
1160            // handle_mcp_command is structured so the only .await crossing a &mut self
1161            // boundary goes through a cloned Arc<McpManager> — no &self fields are held
1162            // across that .await.  The subsequent state-change methods (rebuild_semantic_index,
1163            // sync_mcp_registry) are also async fn(&mut self), but they only hold owned locals
1164            // across their own .await points (cloned tools Vec, cloned Arcs).
1165            _ => 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    // ----- /skill -----
1174
1175    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    // ----- /skills -----
1188
1189    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    // ----- /feedback -----
1202
1203    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    // ----- /plan -----
1216
1217    #[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    // ----- /experiment -----
1238
1239    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    // ----- /agent, @mention -----
1251
1252    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    // ----- /plugins -----
1265
1266    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        // Clone the fields needed by PluginManager before entering the async block.
1272        // spawn_blocking requires 'static, so we cannot borrow &self inside the closure.
1273        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        // Collect manifest paths for ephemeral plugins. Reading the actual files is
1277        // deferred into the async block below to avoid blocking the tokio worker thread.
1278        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        // Resolve scanner once, before the async block captures `self`.
1286        // Fail-closed: if semantic_scan is enabled but no provider is configured, refuse
1287        // to proceed rather than silently falling back to the primary provider (#4706, #4709).
1288        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            // Stage-2 LLM semantic scan runs before the blocking add(), fail-closed.
1331            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            // Resolve ephemeral plugin names asynchronously before entering the blocking task.
1348            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            // PluginManager performs synchronous filesystem I/O (copy, remove_dir_all,
1361            // read_dir). Run on a blocking thread to avoid stalling the tokio worker.
1362            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    // ----- /acp -----
1377
1378    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    // ----- /cocoon -----
1389
1390    #[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    // ----- /loop -----
1413
1414    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    // ----- /goal -----
1490
1491    fn handle_goal<'a>(
1492        &'a mut self,
1493        args: &'a str,
1494    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1495        // Extract all non-Send data synchronously before entering the async block.
1496        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        // S1: `goal_create` may need to arm `AutonomousDriver` with a new session.
1526        // We capture a clone of the pending_start Arc that lives on the driver.
1527        // The async block fills it; the main agent loop (which has `&mut self`) drains it
1528        // via `AutonomousDriver::flush_pending_start()` after each command handler returns.
1529        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    // ----- /undo, /redo -----
1586
1587    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    // ----- /agents -----
1662
1663    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            // Fleet view: bare `/agents` or `/agents fleet` shows autonomous sessions + definitions.
1673            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            // Sub-agent definitions section.
1694            let definitions_section = if show_fleet || args_owned == "list" {
1695                self.handle_agents_definitions_list()
1696            } else {
1697                // CRUD subcommands: show, create, edit, delete.
1698                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    // ----- /conv -----
1722
1723    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            // `resume`/`fork` need `&mut self` (mid-session live conversation swap, D-9) —
1730            // handled first so `self` isn't already borrowed by the `list`/`show` path below.
1731            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    /// `/conv resume <id>` (spec-068, #5343, D-9): mid-session live swap onto an existing
1761    /// durable session. Resolves `conversation_id` via the `SessionId`<->`ConversationId`
1762    /// bijection (spec §5.2) — reuses the session's existing linked conversation if one exists,
1763    /// otherwise mints one and links it (a session created via the HTTP API's `POST /sessions`,
1764    /// or a legacy session, may not have one yet).
1765    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        // #5487 fix 3: `load_and_resume_conversation` now opens the target session's event log
1770        // exclusively (INV-D2). Resuming into the session already live in this agent would try
1771        // to acquire a second exclusive lock on the same directory this agent's own
1772        // `SessionSink` already holds open, deadlocking on `AlreadyLocked` — short-circuit with a
1773        // clear message instead of attempting a self-conflicting reopen.
1774        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    /// `/conv fork <id>` (spec-068, #5343, D-9): eager-copies `id`'s durable log into a fresh
1821    /// child session via `ForkEngine::fork` (P2), then immediately live-swaps onto the child —
1822    /// same effect as `POST /sessions/:id/fork` (spec §9.4) but for the current CLI/TUI session
1823    /// instead of spawning a new `SessionActor`.
1824    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
1868/// Formats `/conv list` — mirrors `sessions list`'s CLI table layout
1869/// (`src/commands/sessions.rs`) and `zeph serve-sessions`'s `GET /sessions`.
1870async 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
1903/// Formats `/conv show <id>` — one session's metadata, mirroring `zeph serve-sessions`'s
1904/// `GET /sessions/:id` (metadata only; use `zeph sessions show --events <id>` on the CLI for a
1905/// full event-log dump).
1906async 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
1936/// Hard cap on `--turns` to prevent runaway autonomous loops (Security Low).
1937const 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
1960/// Returns `(display_message, auto_start_request)`.
1961///
1962/// `auto_start_request` is `Some((goal_id, goal_text, max_turns))` when `--auto` was passed and
1963/// the goal was successfully created. The caller must relay this to `AutonomousDriver` via the
1964/// `pending_start_arc` side-channel before the future resolves.
1965async 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    // Strip --auto / --turns before passing text to the budget parser.
1977    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
2171/// Parse `--auto` and `--turns N` flags from the remainder of a `/goal create` argument string.
2172///
2173/// Returns `(text_without_auto_flags, is_auto, explicit_turns)`.
2174fn 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
2193/// Convert `AgentError` to `CommandError` for the trait boundary.
2194impl 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    // R-CRIT-4111: when graph is enabled in config but graph_store is None
2222    // (Qdrant unreachable), graph command handlers must report
2223    // "unavailable" rather than "not enabled".
2224    #[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    // R-CRIT-4136: graph_backfill must resolve extract_provider before entering the async block.
2281    // When extract_provider is set to an unknown name, resolve_background_provider falls back to
2282    // the primary provider — the backfill still completes (no messages to process).
2283    // This test confirms that the provider-resolution code path executes without panic or borrow
2284    // errors, which would occur if the old code tried to access `&mut self` inside `async move`.
2285    #[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        // Install a real SQLite-backed GraphStore so resolve_graph_store succeeds.
2294        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        // With an empty store there are zero unprocessed messages → backfill completes immediately.
2315        assert!(
2316            result.contains("Backfill complete"),
2317            "expected 'Backfill complete' but got: {result}"
2318        );
2319    }
2320
2321    // R-4139: graph_entities with enabled graph but no store (Qdrant unreachable) must
2322    // report unavailable, not panic or hang.
2323    #[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    // R-4139: graph_communities with enabled graph but no store must report unavailable.
2350    #[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    // R-4139: verify that the tokio::time::timeout pattern used in graph handlers
2377    // correctly returns Err on a never-resolving future. This is a direct regression
2378    // guard for the fix introduced in #4139: before the fix, these calls had no
2379    // timeout guard and would block indefinitely when Qdrant was unreachable.
2380    #[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    // ── #5770: with_graph_store_timeout had zero coverage of its own timeout branch —
2395    // existing tests only reached the "no store configured" short-circuit in
2396    // resolve_graph_store(), never the 5s deadline shared by resolve_entity_by_name,
2397    // graph_facts, and graph_history. Exercise the extracted helper directly with a
2398    // paused clock so the deadline fires deterministically without a real wall-clock wait.
2399
2400    #[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); // EXEMPT: test-only tokio::time::pause harness
2424        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    // ── #5764: graph_facts / graph_history had zero dedicated test coverage ──────
2433
2434    /// Installs a real SQLite-backed `GraphStore` on `memory` (mirrors
2435    /// `graph_backfill_with_extract_provider_resolves_without_panic`), returning an `Arc`
2436    /// clone so callers can seed entities/edges before handing `memory` to `with_memory`.
2437    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    // Mirrors graph_entities_enabled_but_no_store_reports_unavailable (R-4139): when the graph
2526    // store is None (Qdrant unreachable) but graph is enabled, report unavailable rather than
2527    // hang or panic.
2528    #[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    // Self-loop edges (source == target) are rejected both by `GraphStore::insert_edge_typed`
2555    // and by a DB-level trigger (migration 044_graph_edges_no_self_loops) — so a real one can
2556    // only arise from data written before that migration. Drop the trigger to simulate that
2557    // legacy row and confirm graph_facts' defensive `entity_names` bookkeeping (which already
2558    // knows the entity's own name before resolving edge endpoints) handles it without panicking
2559    // or falling back to a raw `#id` placeholder.
2560    #[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    // See graph_facts_self_loop_edge_does_not_panic for why the DB trigger must be dropped.
2728    #[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    // R-4706/R-4709: when semantic_scan is enabled but semantic_scan_provider is empty,
2790    // `plugin add` must return a CommandError immediately (fail-closed). Before this fix
2791    // the code fell through to resolve_background_provider which silently used the primary
2792    // provider, bypassing the intent that an unconfigured scanner means "do not proceed".
2793    #[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    // R-4706/R-4709: when semantic_scan is disabled, plugin subcommands must proceed
2818    // normally regardless of whether semantic_scan_provider is set.
2819    #[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        // "list" does not trigger scan logic; it should succeed without error.
2832        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    // R-4706/R-4709: "plugin add" with semantic_scan disabled must reach the install path
2840    // rather than return a scan-related error. The install itself may fail (no real plugin
2841    // source), but it must NOT fail with the fail-closed error message.
2842    #[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        // The call may succeed or fail for unrelated reasons (no real plugin source),
2856        // but must NOT fail with the fail-closed error about semantic_scan_provider.
2857        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    // R-4705: semantic_scan_plugin_add must scan all skills concurrently and return
2866    // None when every scanner call returns Allow. Verifies buffer_unordered path
2867    // processes N inputs without sequential bottleneck.
2868    #[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        // MockProvider returns `{"verdict":"allow","reason":"ok"}` for every call.
2875        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        // Build a minimal plugin layout with two skills so scan_targets returns
2883        // two SkillScanInput entries.
2884        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        // All skills allowed → no error message returned.
2913        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    // R-4705 regression: buffer_unordered yields in completion order, not input order.
2921    // A Block verdict on the *second* skill (index 1) must name that second skill, not the
2922    // first. Before the fix, the code zipped verdicts against scan_inputs by position and
2923    // discarded the tuple's skill_name, so the wrong skill was reported.
2924    #[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        // First call returns Allow, second returns Block — only the second skill is rejected.
2931        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    // R-4706/R-4709: unknown provider name must also fail-closed rather than silently
2980    // falling back to the primary provider via resolve_background_provider.
2981    #[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    // #5487 fix 3: `handle_conv_resume` had zero prior test coverage. Resuming into the
3006    // session already live in this agent must short-circuit before attempting to re-acquire
3007    // the exclusive lock this agent's own SessionSink already holds (a guaranteed
3008    // `AlreadyLocked` self-deadlock, since flock conflicts are per open-file-description, not
3009    // per-process).
3010    #[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    // Regression check for the guard above: resuming into a genuinely different session (not
3054    // the one already live in this agent) must still hydrate normally.
3055    #[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        // Agent is currently "in" session s1, whose own lock is held by its SessionSink.
3063        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        // Target session s2 exists in the store (unlocked directory) — this is what should be
3076        // resumed into.
3077        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    // #5764: `/conv fork` had zero test coverage — only `/conv resume` was tested above.
3106    // Forks session "s1" into a fresh child and confirms the agent live-swaps onto it.
3107    #[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    // `AgentAccess::load_image` had zero direct coverage — only
3166    // `Agent::handle_image_as_string` (slash_commands.rs) and `cli.rs`'s inline check
3167    // were tested. These exercise the real `Agent<C>` impl via the trait.
3168
3169    #[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}