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_llm::provider::LlmProvider as _;
23use zeph_memory::semantic::SemanticMemory;
24use zeph_memory::{Edge, Entity, GraphExtractionConfig, GraphStore, MessageId, extract_and_store};
25
26use super::{Agent, error::AgentError};
27use crate::channel::Channel;
28
29impl<C: Channel + Send + 'static> Agent<C> {
30    fn resolve_graph_store(&self) -> Result<(Arc<SemanticMemory>, Arc<GraphStore>), String> {
31        let Some(memory) = self.services.memory.persistence.memory.clone() else {
32            return Err("Graph memory is not enabled.".to_owned());
33        };
34        let Some(store) = memory.graph_store.clone() else {
35            if self.services.memory.extraction.graph_config.enabled {
36                return Err(
37                    "Graph memory enabled but vector store unavailable (Qdrant unreachable)."
38                        .to_owned(),
39                );
40            }
41            return Err("Graph memory is not enabled.".to_owned());
42        };
43        Ok((memory, store))
44    }
45}
46
47/// Outcome of resolving an entity by display name against the graph store: either the
48/// entity was found, or a user-facing message that the caller should return as-is (no
49/// match, or the store timed out).
50enum EntityLookup {
51    Found(Entity),
52    Message(String),
53}
54
55/// Outcome of a graph-store call bounded by [`with_graph_store_timeout`]'s 5s deadline:
56/// either it completed, or it timed out (Qdrant unreachable).
57enum StoreCallOutcome<T> {
58    Completed(T),
59    TimedOut,
60}
61
62/// Runs `fut` under a 5s timeout — the deadline shared by `resolve_entity_by_name` and the
63/// edge lookups in `graph_facts`/`graph_history`. Maps a store error to [`CommandError`];
64/// logs and reports a timeout via [`StoreCallOutcome::TimedOut`] so callers only need to
65/// turn that into their own user-facing message.
66async fn with_graph_store_timeout<T>(
67    fut: impl Future<Output = Result<T, zeph_memory::MemoryError>>,
68) -> Result<StoreCallOutcome<T>, CommandError> {
69    match tokio::time::timeout(Duration::from_secs(5), fut).await {
70        Ok(Ok(v)) => Ok(StoreCallOutcome::Completed(v)),
71        Ok(Err(e)) => Err(CommandError::new(e.to_string())),
72        Err(_) => {
73            tracing::warn!("graph store call timed out after 5s (Qdrant unreachable)");
74            Ok(StoreCallOutcome::TimedOut)
75        }
76    }
77}
78
79/// Resolves `name` to an [`Entity`] via [`GraphStore::find_entity_by_name`], bounded by a
80/// 5s timeout — the lookup block shared by `graph_facts` and `graph_history`.
81async fn resolve_entity_by_name(
82    store: &GraphStore,
83    name: &str,
84) -> Result<EntityLookup, CommandError> {
85    let matches = match with_graph_store_timeout(store.find_entity_by_name(name)).await? {
86        StoreCallOutcome::Completed(v) => v,
87        StoreCallOutcome::TimedOut => {
88            return Ok(EntityLookup::Message(
89                "Graph store unavailable (Qdrant unreachable).".to_owned(),
90            ));
91        }
92    };
93    let Some(entity) = matches.into_iter().next() else {
94        return Ok(EntityLookup::Message(format!(
95            "No entity found matching '{name}'."
96        )));
97    };
98    Ok(EntityLookup::Found(entity))
99}
100
101/// Builds the `entity_id -> display_name` lookup map shared by `graph_facts` and
102/// `graph_history`: seeds `entity`'s own name, inserts a placeholder for every edge
103/// endpoint, then resolves each placeholder via [`GraphStore::find_entity_by_id`] (5s
104/// timeout), falling back to `#{id}` when the lookup fails or times out.
105async fn build_entity_name_map(
106    store: &GraphStore,
107    entity: &Entity,
108    edges: &[Edge],
109) -> std::collections::HashMap<i64, String> {
110    let mut entity_names: std::collections::HashMap<i64, String> = std::collections::HashMap::new();
111    entity_names.insert(entity.id.0, entity.name.clone());
112    for edge in edges {
113        entity_names.entry(edge.source_entity_id).or_default();
114        entity_names.entry(edge.target_entity_id).or_default();
115    }
116    for (&id, name_val) in &mut entity_names {
117        if name_val.is_empty() {
118            let result =
119                tokio::time::timeout(Duration::from_secs(5), store.find_entity_by_id(id)).await;
120            if let Ok(Ok(Some(other))) = result {
121                *name_val = other.name;
122            } else {
123                *name_val = format!("#{id}");
124            }
125        }
126    }
127    entity_names
128}
129
130/// Run Stage-2 LLM semantic scan for all skills in the plugin at `source`.
131///
132/// Skills are scanned concurrently with up to 4 in-flight at a time
133/// (`buffer_unordered(4)`). An aggregate 5-minute timeout wraps the whole
134/// batch; each individual scan is already bounded by `SCAN_TIMEOUT` (30 s) in
135/// `SkillSemanticScanner`. Returns `Some(err_msg)` when any skill is blocked,
136/// `None` when all skills pass.
137///
138/// Each future carries its own `skill_name` so that the rejection message names
139/// the correct skill regardless of completion order (which differs from input
140/// order when futures complete out-of-order with `buffer_unordered`).
141async fn semantic_scan_plugin_add(
142    scanner: &zeph_skills::semantic_scanner::SkillSemanticScanner,
143    source: &str,
144    managed_dir: Option<std::path::PathBuf>,
145    mcp_allowed: Vec<String>,
146    base_shell_allowed: Vec<String>,
147) -> Result<Option<String>, CommandError> {
148    use futures::stream::StreamExt as _;
149    use zeph_skills::semantic_scanner::ScanVerdict;
150
151    let plugins_dir = zeph_plugins::PluginManager::default_plugins_dir();
152    let mgr_dir =
153        managed_dir.unwrap_or_else(|| zeph_config::defaults::default_vault_dir().join("skills"));
154    let mgr =
155        zeph_plugins::PluginManager::new(plugins_dir, mgr_dir, mcp_allowed, base_shell_allowed);
156
157    let source_owned = source.to_owned();
158    let scan_inputs = tokio::task::spawn_blocking(move || mgr.scan_targets(&source_owned))
159        .await
160        .map_err(|e| CommandError(format!("plugin scan_targets panicked: {e}")))?
161        .map_err(|e| CommandError(format!("plugin add failed: {e}")))?;
162
163    tracing::info!(
164        plugin.source = %source,
165        skills_count = scan_inputs.len(),
166        "plugins.add: running Stage-2 semantic scan"
167    );
168
169    // Scan all skills concurrently with up to 4 in-flight. Each individual scan is
170    // already bounded by SCAN_TIMEOUT (30 s); the outer 5-min cap guards the batch.
171    // Each future owns its skill_name so verdicts carry the correct name regardless
172    // of buffer_unordered completion order (which is not the same as input order).
173    let scan_futs: Vec<_> = scan_inputs
174        .iter()
175        .map(|input| {
176            let name = input.skill_name.clone();
177            let purpose = input.declared_purpose.clone();
178            let md = input.skill_md.clone();
179            async move {
180                let verdict = scanner.scan(&name, &purpose, &md).await;
181                (name, verdict)
182            }
183        })
184        .collect();
185
186    let verdicts: Vec<_> = tokio::time::timeout(
187        std::time::Duration::from_mins(5),
188        futures::stream::iter(scan_futs)
189            .buffer_unordered(4)
190            .collect::<Vec<_>>(),
191    )
192    .await
193    .map_err(|_| CommandError("plugin scan timed out after 300s".to_owned()))?;
194
195    for (skill_name, verdict_result) in verdicts {
196        let verdict = verdict_result.map_err(|e| {
197            CommandError(format!(
198                "plugin add failed: semantic scan error for skill {skill_name:?}: {e}"
199            ))
200        })?;
201        match verdict {
202            ScanVerdict::Allow => {
203                tracing::debug!(
204                    skill = %skill_name,
205                    "plugins.add: skill passed semantic scan"
206                );
207            }
208            ScanVerdict::Warn(ref reason) => {
209                tracing::warn!(
210                    skill = %skill_name,
211                    reason = %reason,
212                    "plugins.add: skill passed with warning"
213                );
214            }
215            ScanVerdict::Block(reason) => {
216                return Ok(Some(format!(
217                    "plugin add failed: skill {skill_name:?} rejected by semantic scan: {reason}"
218                )));
219            }
220            _ => {}
221        }
222    }
223    Ok(None)
224}
225
226impl<C: Channel + Send + 'static> AgentAccess for Agent<C> {
227    // ----- /memory -----
228
229    fn memory_tiers<'a>(
230        &'a mut self,
231    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
232        Box::pin(
233            async move {
234                let Some(memory) = self.services.memory.persistence.memory.clone() else {
235                    return Ok("Memory not configured.".to_owned());
236                };
237                match memory.sqlite().count_messages_by_tier().await {
238                    Ok((episodic, semantic)) => {
239                        let mut out = String::new();
240                        let _ = writeln!(out, "Memory tiers:");
241                        let _ = writeln!(out, "  Working:  (current context window — virtual)");
242                        let _ = writeln!(out, "  Episodic: {episodic} messages");
243                        let _ = writeln!(out, "  Semantic: {semantic} facts");
244                        Ok(out.trim_end().to_owned())
245                    }
246                    Err(e) => Ok(format!("Failed to query tier stats: {e}")),
247                }
248            }
249            .instrument(tracing::info_span!("core.agent_access.memory_tiers")),
250        )
251    }
252
253    fn memory_promote<'a>(
254        &'a mut self,
255        ids_str: &'a str,
256    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
257        Box::pin(
258            async move {
259                let Some(memory) = self.services.memory.persistence.memory.clone() else {
260                    return Ok("Memory not configured.".to_owned());
261                };
262                let ids: Vec<MessageId> = ids_str
263                    .split_whitespace()
264                    .filter_map(|s| s.parse::<i64>().ok().map(MessageId))
265                    .collect();
266                if ids.is_empty() {
267                    return Ok(
268                        "Usage: /memory promote <id> [id...]\nExample: /memory promote 42 43 44"
269                            .to_owned(),
270                    );
271                }
272                match memory.sqlite().manual_promote(&ids).await {
273                    Ok(count) => Ok(format!("Promoted {count} message(s) to semantic tier.")),
274                    Err(e) => Ok(format!("Promotion failed: {e}")),
275                }
276            }
277            .instrument(tracing::info_span!("core.agent_access.memory_promote")),
278        )
279    }
280
281    // ----- /graph -----
282
283    fn graph_stats<'a>(
284        &'a mut self,
285    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
286        Box::pin(
287            async move {
288                let (_, store) = match self.resolve_graph_store() {
289                    Ok(pair) => pair,
290                    Err(msg) => return Ok(msg),
291                };
292
293                let stats_future = async {
294                    tokio::join!(
295                        store.entity_count(),
296                        store.active_edge_count(),
297                        store.community_count(),
298                        store.edge_type_distribution()
299                    )
300                };
301                let Ok((entities, edges, communities, distribution)) =
302                    tokio::time::timeout(Duration::from_secs(5), stats_future).await
303                else {
304                    tracing::warn!("graph store call timed out after 5s (Qdrant unreachable)");
305                    return Ok("Graph store unavailable (Qdrant unreachable).".to_owned());
306                };
307                let mut msg = format!(
308                    "Graph memory: {} entities, {} edges, {} communities",
309                    entities.unwrap_or(0),
310                    edges.unwrap_or(0),
311                    communities.unwrap_or(0)
312                );
313                if let Ok(dist) = distribution
314                    && !dist.is_empty()
315                {
316                    let dist_str: Vec<String> =
317                        dist.iter().map(|(t, c)| format!("{t}={c}")).collect();
318                    write!(msg, "\nEdge types: {}", dist_str.join(", ")).unwrap_or(());
319                }
320                Ok(msg)
321            }
322            .instrument(tracing::info_span!("core.agent_access.graph_stats")),
323        )
324    }
325
326    fn graph_entities<'a>(
327        &'a mut self,
328    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
329        Box::pin(
330            async move {
331                let (_, store) = match self.resolve_graph_store() {
332                    Ok(pair) => pair,
333                    Err(msg) => return Ok(msg),
334                };
335
336                let entities = match tokio::time::timeout(
337                    Duration::from_secs(5),
338                    store.all_entities(),
339                )
340                .await
341                {
342                    Ok(Ok(v)) => v,
343                    Ok(Err(e)) => return Err(CommandError::new(e.to_string())),
344                    Err(_) => {
345                        tracing::warn!("graph store call timed out after 5s (Qdrant unreachable)");
346                        return Ok("Graph store unavailable (Qdrant unreachable).".to_owned());
347                    }
348                };
349                if entities.is_empty() {
350                    return Ok("No entities found.".to_owned());
351                }
352
353                let total = entities.len();
354                let display: Vec<String> = entities
355                    .iter()
356                    .take(50)
357                    .map(|e| {
358                        format!(
359                            "  {:<40}  {:<15}  {}",
360                            e.name,
361                            e.entity_type.as_str(),
362                            e.last_seen_at.split('T').next().unwrap_or(&e.last_seen_at)
363                        )
364                    })
365                    .collect();
366                let mut msg = format!(
367                    "Entities ({total} total):\n  {:<40}  {:<15}  {}\n{}",
368                    "NAME",
369                    "TYPE",
370                    "LAST SEEN",
371                    display.join("\n")
372                );
373                if total > 50 {
374                    write!(msg, "\n  ...and {} more", total - 50).unwrap_or(());
375                }
376                Ok(msg)
377            }
378            .instrument(tracing::info_span!("core.agent_access.graph_entities")),
379        )
380    }
381
382    fn graph_facts<'a>(
383        &'a mut self,
384        name: &'a str,
385    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
386        Box::pin(
387            async move {
388                let (_, store) = match self.resolve_graph_store() {
389                    Ok(pair) => pair,
390                    Err(msg) => return Ok(msg),
391                };
392
393                let entity = match resolve_entity_by_name(&store, name).await? {
394                    EntityLookup::Found(e) => e,
395                    EntityLookup::Message(msg) => return Ok(msg),
396                };
397
398                let edges =
399                    match with_graph_store_timeout(store.edges_for_entity(entity.id.0)).await? {
400                        StoreCallOutcome::Completed(v) => v,
401                        StoreCallOutcome::TimedOut => {
402                            return Ok("Graph store unavailable (Qdrant unreachable).".to_owned());
403                        }
404                    };
405                if edges.is_empty() {
406                    return Ok(format!("Entity '{}' has no known facts.", entity.name));
407                }
408
409                let entity_names = build_entity_name_map(&store, &entity, &edges).await;
410
411                let lines: Vec<String> = edges
412                    .iter()
413                    .map(|e| {
414                        let src = entity_names
415                            .get(&e.source_entity_id)
416                            .cloned()
417                            .unwrap_or_else(|| format!("#{}", e.source_entity_id));
418                        let tgt = entity_names
419                            .get(&e.target_entity_id)
420                            .cloned()
421                            .unwrap_or_else(|| format!("#{}", e.target_entity_id));
422                        format!(
423                            "  {} --[{}/{}]--> {}: {} (confidence: {:.2})",
424                            src, e.relation, e.edge_type, tgt, e.fact, e.confidence
425                        )
426                    })
427                    .collect();
428                Ok(format!(
429                    "Facts for '{}':\n{}",
430                    entity.name,
431                    lines.join("\n")
432                ))
433            }
434            .instrument(tracing::info_span!("core.agent_access.graph_facts")),
435        )
436    }
437
438    fn graph_history<'a>(
439        &'a mut self,
440        name: &'a str,
441    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
442        Box::pin(
443            async move {
444                let (_, store) = match self.resolve_graph_store() {
445                    Ok(pair) => pair,
446                    Err(msg) => return Ok(msg),
447                };
448
449                let entity = match resolve_entity_by_name(&store, name).await? {
450                    EntityLookup::Found(e) => e,
451                    EntityLookup::Message(msg) => return Ok(msg),
452                };
453
454                let edges =
455                    match with_graph_store_timeout(store.edge_history_for_entity(entity.id.0, 50))
456                        .await?
457                    {
458                        StoreCallOutcome::Completed(v) => v,
459                        StoreCallOutcome::TimedOut => {
460                            return Ok("Graph store unavailable (Qdrant unreachable).".to_owned());
461                        }
462                    };
463                if edges.is_empty() {
464                    return Ok(format!("Entity '{}' has no edge history.", entity.name));
465                }
466
467                let entity_names = build_entity_name_map(&store, &entity, &edges).await;
468
469                let n = edges.len();
470                let lines: Vec<String> = edges
471                    .iter()
472                    .map(|e| {
473                        let status = if e.valid_to.is_some() {
474                            let date = e
475                                .valid_to
476                                .as_deref()
477                                .and_then(|s| s.split('T').next().or_else(|| s.split(' ').next()))
478                                .unwrap_or("?");
479                            format!("[expired {date}]")
480                        } else {
481                            "[active]".to_string()
482                        };
483                        let src = entity_names
484                            .get(&e.source_entity_id)
485                            .cloned()
486                            .unwrap_or_else(|| format!("#{}", e.source_entity_id));
487                        let tgt = entity_names
488                            .get(&e.target_entity_id)
489                            .cloned()
490                            .unwrap_or_else(|| format!("#{}", e.target_entity_id));
491                        format!(
492                            "  {status} {} --[{}/{}]--> {}: {} (confidence: {:.2})",
493                            src, e.relation, e.edge_type, tgt, e.fact, e.confidence
494                        )
495                    })
496                    .collect();
497                Ok(format!(
498                    "Edge history for '{}' ({n} edges):\n{}",
499                    entity.name,
500                    lines.join("\n")
501                ))
502            }
503            .instrument(tracing::info_span!("core.agent_access.graph_history")),
504        )
505    }
506
507    fn graph_communities<'a>(
508        &'a mut self,
509    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
510        Box::pin(
511            async move {
512                let (_, store) = match self.resolve_graph_store() {
513                    Ok(pair) => pair,
514                    Err(msg) => return Ok(msg),
515                };
516
517                let communities =
518                    match tokio::time::timeout(Duration::from_secs(5), store.all_communities())
519                        .await
520                    {
521                        Ok(Ok(v)) => v,
522                        Ok(Err(e)) => return Err(CommandError::new(e.to_string())),
523                        Err(_) => {
524                            tracing::warn!(
525                                "graph store call timed out after 5s (Qdrant unreachable)"
526                            );
527                            return Ok("Graph store unavailable (Qdrant unreachable).".to_owned());
528                        }
529                    };
530                if communities.is_empty() {
531                    return Ok("No communities detected yet. Run graph backfill first.".to_owned());
532                }
533
534                let lines: Vec<String> = communities
535                    .iter()
536                    .map(|c| format!("  [{}]: {}", c.name, c.summary))
537                    .collect();
538                Ok(format!(
539                    "Communities ({}):\n{}",
540                    communities.len(),
541                    lines.join("\n")
542                ))
543            }
544            .instrument(tracing::info_span!("core.agent_access.graph_communities")),
545        )
546    }
547
548    #[allow(clippy::too_many_lines)]
549    fn graph_backfill<'a>(
550        &'a mut self,
551        limit: Option<usize>,
552        progress_cb: &'a mut (dyn FnMut(String) + Send),
553    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
554        let store = match self.resolve_graph_store() {
555            Ok((_, s)) => s,
556            Err(msg) => return Box::pin(async move { Ok(msg) }),
557        };
558        let graph_cfg = self.services.memory.extraction.graph_config.clone();
559        let embed_timeout_secs = self
560            .services
561            .memory
562            .persistence
563            .memory
564            .as_ref()
565            .map_or(5, |m| m.embed_timeout().as_secs());
566        let provider = if graph_cfg.extract_provider.as_str().is_empty() {
567            self.provider.clone()
568        } else {
569            self.resolve_background_provider(graph_cfg.extract_provider.as_str())
570        };
571        Box::pin(
572            async move {
573                let total = store.unprocessed_message_count().await.unwrap_or(0);
574                let cap = limit.unwrap_or(usize::MAX);
575
576                progress_cb(format!(
577                    "Starting graph backfill... ({total} unprocessed messages)"
578                ));
579
580                let batch_size = 50usize;
581                let mut processed = 0usize;
582                let mut total_entities = 0usize;
583                let mut total_edges = 0usize;
584
585                loop {
586                    let remaining_cap = cap.saturating_sub(processed);
587                    if remaining_cap == 0 {
588                        break;
589                    }
590                    let batch_limit = batch_size.min(remaining_cap);
591                    let messages = store
592                        .unprocessed_messages_for_backfill(batch_limit)
593                        .await
594                        .map_err(|e| CommandError::new(e.to_string()))?;
595                    if messages.is_empty() {
596                        break;
597                    }
598
599                    let ids: Vec<zeph_memory::types::MessageId> =
600                        messages.iter().map(|(id, _)| *id).collect();
601
602                    // extraction_cfg is loop-invariant (derived only from graph_cfg /
603                    // embed_timeout_secs, never from message content), so it is built once per
604                    // batch and cloned per message below.
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.link_weight_decay_interval_secs,
619                        belief_revision_enabled: graph_cfg.belief_revision.enabled,
620                        belief_revision_similarity_threshold: graph_cfg
621                            .belief_revision
622                            .similarity_threshold,
623                        conversation_id: None,
624                        apex_mem_enabled: graph_cfg.apex_mem.enabled,
625                        llm_timeout_secs: graph_cfg.llm_timeout_secs,
626                        embed_timeout_secs,
627                        turn_index: None,
628                        write_gate_min_relevance: graph_cfg
629                            .write_gate
630                            .enabled
631                            .then_some(graph_cfg.write_gate.min_edge_relevance),
632                        benna_fast_rate: graph_cfg.spreading_activation.benna_fast_rate,
633                        benna_slow_rate: graph_cfg.spreading_activation.benna_slow_rate,
634                        provenance: None,
635                        system_prompt: None,
636                        recall_include_imported: graph_cfg.recall_include_imported,
637                    };
638
639                    // Extract concurrently, bounded to 4 in-flight — matches
640                    // semantic_scan_plugin_add's existing batched-LLM-call bound. Safe because
641                    // `extract_and_store` builds a fresh `EntityResolver` per call (its
642                    // `lock_name` guard does not span calls), so the actual concurrency-safety
643                    // mechanism is the DB-level `UNIQUE(canonical_name, entity_type)` constraint
644                    // and `ON CONFLICT ... DO UPDATE ... RETURNING id` upsert in
645                    // `GraphStore::upsert_entity` (plus `add_alias`'s `INSERT OR IGNORE`), which
646                    // makes concurrent entity creation for the same name idempotent regardless of
647                    // in-process locking.
648                    {
649                        use futures::stream::StreamExt as _;
650
651                        let extraction_futs: Vec<_> = messages
652                            .iter()
653                            .filter_map(|(_id, content)| {
654                                if content.trim().is_empty() {
655                                    return None;
656                                }
657                                let content = content.clone();
658                                let provider = provider.clone();
659                                let pool = store.pool().clone();
660                                let extraction_cfg = extraction_cfg.clone();
661                                Some(extract_and_store(
662                                    content,
663                                    vec![],
664                                    provider,
665                                    pool,
666                                    extraction_cfg,
667                                    None,
668                                    None,
669                                ))
670                            })
671                            .collect();
672
673                        let results: Vec<_> = futures::stream::iter(extraction_futs)
674                            .buffer_unordered(4)
675                            .collect()
676                            .await;
677
678                        for result in results {
679                            match result {
680                                Ok(result) => {
681                                    total_entities += result.stats.entities_upserted;
682                                    total_edges += result.stats.edges_inserted;
683                                }
684                                Err(e) => {
685                                    tracing::warn!("backfill extraction error: {e:#}");
686                                }
687                            }
688                        }
689                    }
690
691                    store
692                        .mark_messages_graph_processed(&ids)
693                        .await
694                        .map_err(|e| CommandError::new(e.to_string()))?;
695                    processed += messages.len();
696
697                    progress_cb(format!(
698                        "Backfill progress: {processed} messages processed, \
699                     {total_entities} entities, {total_edges} edges"
700                    ));
701                }
702
703                Ok(format!(
704                    "Backfill complete: {total_entities} entities, {total_edges} edges \
705                 extracted from {processed} messages"
706                ))
707            }
708            .instrument(tracing::info_span!("core.agent_access.graph_backfill")),
709        )
710    }
711
712    // ----- /knowledge -----
713
714    fn knowledge_status<'a>(
715        &'a mut self,
716    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
717        Box::pin(
718            async move {
719                use zeph_memory::graph::ingest::IngestLedger;
720
721                let Some(memory) = self.services.memory.persistence.memory.clone() else {
722                    return Ok("Memory subsystem not available.".to_owned());
723                };
724                let pool = memory.sqlite().pool().clone();
725                let ledger = IngestLedger::new(pool);
726
727                let rows = match ledger.summary().await {
728                    Ok(r) => r,
729                    Err(e) => return Err(CommandError(e.to_string())),
730                };
731
732                if rows.is_empty() {
733                    return Ok("No knowledge has been ingested yet. \
734                         Run `zeph knowledge ingest --source <src>`."
735                        .to_owned());
736                }
737
738                let mut out = format!("Knowledge ingest ledger ({} entries):\n\n", rows.len());
739                let mut current_batch = String::new();
740                for row in &rows {
741                    let batch_short = &row.import_batch_id[..row.import_batch_id.len().min(8)];
742                    if current_batch != row.import_batch_id {
743                        if !current_batch.is_empty() {
744                            out.push('\n');
745                        }
746                        current_batch.clone_from(&row.import_batch_id);
747                    }
748                    let uri_display = &row.source_uri[..row.source_uri.floor_char_boundary(40)];
749                    let at_display = &row.ingested_at[..row.ingested_at.len().min(19)];
750                    let _ = writeln!(
751                        out,
752                        "  {uri_display:<40} batch={batch_short} at={at_display} \
753                         e={} edges={}",
754                        row.entities, row.edges,
755                    );
756                }
757                Ok(out.trim_end().to_owned())
758            }
759            .instrument(tracing::info_span!("core.agent_access.knowledge_status")),
760        )
761    }
762
763    fn knowledge_rollback<'a>(
764        &'a mut self,
765        batch_id: &'a str,
766    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
767        Box::pin(
768            async move {
769                use zeph_memory::graph::ingest::IngestLedger;
770
771                let Some(memory) = self.services.memory.persistence.memory.clone() else {
772                    return Ok("Memory subsystem not available.".to_owned());
773                };
774                let pool = memory.sqlite().pool().clone();
775                let ledger = IngestLedger::new(pool.clone());
776
777                match ledger.batch_exists(batch_id).await {
778                    Ok(false) => {
779                        return Ok(format!("Batch '{batch_id}' not found in ledger."));
780                    }
781                    Err(e) => return Err(CommandError(e.to_string())),
782                    Ok(true) => {}
783                }
784
785                let Some(graph_store) = memory.graph_store.clone() else {
786                    return Ok(
787                        "Graph store unavailable (Qdrant unreachable or graph not enabled)."
788                            .to_owned(),
789                    );
790                };
791
792                let mut tx = zeph_db::begin_write(&pool)
793                    .await
794                    .map_err(|e| CommandError(e.to_string()))?;
795
796                let (edges, entities) = graph_store
797                    .delete_batch_in_tx(batch_id, &mut tx)
798                    .await
799                    .map_err(|e| CommandError(e.to_string()))?;
800                ledger
801                    .delete_batch_in_tx(batch_id, &mut tx)
802                    .await
803                    .map_err(|e| CommandError(e.to_string()))?;
804
805                tx.commit().await.map_err(|e| CommandError(e.to_string()))?;
806
807                let mut msg = format!(
808                    "Rolled back batch '{batch_id}': removed {edges} edge(s) and \
809                     {entities} entity(ies)."
810                );
811                if edges == 0 && entities == 0 {
812                    msg.push_str(
813                        "\nNote: no graph rows found. Phase-1 ingest writes to Qdrant notes — \
814                         Qdrant embeddings are NOT removed by this rollback.",
815                    );
816                }
817                Ok(msg)
818            }
819            .instrument(tracing::info_span!("core.agent_access.knowledge_rollback")),
820        )
821    }
822
823    // ----- /guidelines -----
824
825    fn guidelines<'a>(
826        &'a mut self,
827    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
828        Box::pin(
829            async move {
830                const MAX_DISPLAY_CHARS: usize = 4096;
831
832                let Some(memory) = &self.services.memory.persistence.memory else {
833                    return Ok("No memory backend initialised.".to_owned());
834                };
835
836                let cid = self.services.memory.persistence.conversation_id;
837                let sqlite = memory.sqlite();
838
839                let (version, text) = sqlite
840                    .load_compression_guidelines(cid)
841                    .await
842                    .map_err(|e: zeph_memory::MemoryError| CommandError::new(e.to_string()))?;
843
844                if version == 0 || text.is_empty() {
845                    return Ok("No compression guidelines generated yet.".to_owned());
846                }
847
848                let (_, created_at) = sqlite
849                    .load_compression_guidelines_meta(cid)
850                    .await
851                    .unwrap_or((0, String::new()));
852
853                let (body, truncated) = if text.len() > MAX_DISPLAY_CHARS {
854                    let end = text.floor_char_boundary(MAX_DISPLAY_CHARS);
855                    (&text[..end], true)
856                } else {
857                    (text.as_str(), false)
858                };
859
860                let mut output =
861                    format!("Compression Guidelines (v{version}, updated {created_at}):\n\n{body}");
862                if truncated {
863                    output.push_str("\n\n[truncated]");
864                }
865                Ok(output)
866            }
867            .instrument(tracing::info_span!("core.agent_access.guidelines")),
868        )
869    }
870
871    // ----- /caveman -----
872
873    fn handle_caveman<'a>(
874        &'a mut self,
875        arg: &'a str,
876    ) -> Pin<Box<dyn Future<Output = String> + Send + 'a>> {
877        Box::pin(async move {
878            let active = &mut self.services.session.caveman_active;
879            match arg.trim() {
880                "on" | "enable" => {
881                    *active = true;
882                    "caveman: on".to_owned()
883                }
884                "off" | "disable" => {
885                    *active = false;
886                    "caveman: off".to_owned()
887                }
888                "status" => {
889                    if *active {
890                        "caveman: on".to_owned()
891                    } else {
892                        "caveman: off".to_owned()
893                    }
894                }
895                _ => {
896                    *active = !*active;
897                    if *active {
898                        "caveman: on".to_owned()
899                    } else {
900                        "caveman: off".to_owned()
901                    }
902                }
903            }
904        })
905    }
906
907    // ----- /model, /provider -----
908
909    fn handle_model<'a>(
910        &'a mut self,
911        arg: &'a str,
912    ) -> Pin<Box<dyn Future<Output = String> + Send + 'a>> {
913        Box::pin(async move {
914            let input = if arg.is_empty() {
915                "/model".to_owned()
916            } else {
917                format!("/model {arg}")
918            };
919            self.handle_model_command_as_string(&input).await
920        })
921    }
922
923    fn handle_provider<'a>(
924        &'a mut self,
925        arg: &'a str,
926    ) -> Pin<Box<dyn Future<Output = String> + Send + 'a>> {
927        Box::pin(async move { self.handle_provider_command_as_string(arg).await })
928    }
929
930    // ----- /think-tokens, /reasoning-effort -----
931
932    fn handle_think_tokens<'a>(
933        &'a mut self,
934        arg: &'a str,
935    ) -> Pin<Box<dyn Future<Output = String> + Send + 'a>> {
936        Box::pin(async move {
937            let arg = arg.trim();
938            let provider_name = self.provider.name().to_owned();
939            if arg.is_empty() {
940                return match self.provider.current_thinking_budget() {
941                    Some(n) => format!("think-tokens: {n} (provider: {provider_name})"),
942                    None => format!("think-tokens: off (provider: {provider_name})"),
943                };
944            }
945
946            let budget = match zeph_commands::handlers::think_tokens::parse_token_budget(arg) {
947                Ok(b) => b,
948                Err(e) => return format!("think-tokens: {e}"),
949            };
950
951            // Captured before the mutation so the cross-override note (Claude's Extended and
952            // Adaptive thinking share one config field) only fires when this call actually
953            // cleared a previously active reasoning-effort level.
954            let had_reasoning_effort = self.provider.current_reasoning_effort().is_some();
955            match self.provider.set_thinking_budget(budget) {
956                Ok(()) => {
957                    let mut msg = match budget {
958                        Some(n) => format!("think-tokens: set to {n} (provider: {provider_name})"),
959                        None => format!("think-tokens: disabled (provider: {provider_name})"),
960                    };
961                    if had_reasoning_effort && self.provider.current_reasoning_effort().is_none() {
962                        msg.push_str(
963                            " Note: this overrides the previously set reasoning-effort level \
964                             — Claude's Extended and Adaptive thinking share one config field.",
965                        );
966                    }
967                    if let Some(advisory) = self.provider.capability_delegation_advisory() {
968                        let _ = write!(msg, " Note: {advisory}.");
969                    }
970                    msg
971                }
972                Err(zeph_llm::LlmError::ModelCapabilityMismatch { provider, message }) => {
973                    format!("provider `{provider}` {message}")
974                }
975                Err(e) => format!("think-tokens: {e}"),
976            }
977        })
978    }
979
980    fn handle_reasoning_effort<'a>(
981        &'a mut self,
982        arg: &'a str,
983    ) -> Pin<Box<dyn Future<Output = String> + Send + 'a>> {
984        Box::pin(async move {
985            let arg = arg.trim();
986            let provider_name = self.provider.name().to_owned();
987            if arg.is_empty() {
988                return match self.provider.current_reasoning_effort() {
989                    Some(e) => format!("reasoning-effort: {e} (provider: {provider_name})"),
990                    None => format!("reasoning-effort: off (provider: {provider_name})"),
991                };
992            }
993
994            let effort: zeph_llm::any::ReasoningEffort = match arg.parse() {
995                Ok(e) => e,
996                Err(e) => return format!("reasoning-effort: {e}"),
997            };
998
999            // Captured before the mutation — see the matching comment in handle_think_tokens.
1000            let had_thinking_budget = self.provider.current_thinking_budget().is_some();
1001            match self.provider.apply_reasoning_effort(effort) {
1002                Ok(()) => {
1003                    let mut msg = format!(
1004                        "reasoning-effort: set to {} (provider: {provider_name})",
1005                        effort.as_str()
1006                    );
1007                    if had_thinking_budget && self.provider.current_thinking_budget().is_none() {
1008                        msg.push_str(
1009                            " Note: this overrides the previously set thinking-token budget \
1010                             — Claude's Extended and Adaptive thinking share one config field.",
1011                        );
1012                    }
1013                    if let Some(advisory) = self.provider.capability_delegation_advisory() {
1014                        let _ = write!(msg, " Note: {advisory}.");
1015                    }
1016                    msg
1017                }
1018                Err(zeph_llm::LlmError::ModelCapabilityMismatch { provider, message }) => {
1019                    format!("provider `{provider}` {message}")
1020                }
1021                Err(e) => format!("reasoning-effort: {e}"),
1022            }
1023        })
1024    }
1025
1026    // ----- /policy -----
1027
1028    fn handle_policy<'a>(
1029        &'a mut self,
1030        args: &'a str,
1031    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1032        Box::pin(async move { Ok(self.handle_policy_command_as_string(args)) })
1033    }
1034
1035    // ----- /scheduler -----
1036
1037    #[cfg(feature = "scheduler")]
1038    fn list_scheduled_tasks<'a>(
1039        &'a mut self,
1040    ) -> Pin<Box<dyn Future<Output = Result<Option<String>, CommandError>> + Send + 'a>> {
1041        Box::pin(async move {
1042            let result = self
1043                .handle_scheduler_list_as_string()
1044                .await
1045                .map_err(|e| CommandError::new(e.to_string()))?;
1046            Ok(Some(result))
1047        })
1048    }
1049
1050    #[cfg(not(feature = "scheduler"))]
1051    fn list_scheduled_tasks<'a>(
1052        &'a mut self,
1053    ) -> Pin<Box<dyn Future<Output = Result<Option<String>, CommandError>> + Send + 'a>> {
1054        Box::pin(async move { Ok(None) })
1055    }
1056
1057    // ----- /lsp -----
1058
1059    fn lsp_status<'a>(
1060        &'a mut self,
1061    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1062        Box::pin(async move {
1063            self.handle_lsp_status_as_string()
1064                .await
1065                .map_err(|e| CommandError::new(e.to_string()))
1066        })
1067    }
1068
1069    // ----- /recap -----
1070
1071    fn session_recap<'a>(
1072        &'a mut self,
1073    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1074        Box::pin(
1075            async move {
1076                match self.build_recap().await {
1077                    Ok(text) => Ok(text),
1078                    Err(e) => {
1079                        // /recap is an explicit user command — surface a fixed message so that
1080                        // LlmError internals (URLs with embedded credentials, response excerpts)
1081                        // are never forwarded to the user channel. Full detail goes to the log.
1082                        tracing::warn!("session recap command: {}", e.0);
1083                        Ok("Recap unavailable — see logs for details".to_string())
1084                    }
1085                }
1086            }
1087            .instrument(tracing::info_span!("core.agent_access.session_recap")),
1088        )
1089    }
1090
1091    // ----- /compact -----
1092
1093    fn compact_context<'a>(
1094        &'a mut self,
1095    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1096        Box::pin(
1097            self.compact_context_command()
1098                .instrument(tracing::info_span!("core.agent_access.compact_context")),
1099        )
1100    }
1101
1102    // ----- /new -----
1103
1104    fn reset_conversation<'a>(
1105        &'a mut self,
1106        keep_plan: bool,
1107        no_digest: bool,
1108    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1109        Box::pin(async move {
1110            match self.reset_conversation(keep_plan, no_digest).await {
1111                Ok((old_id, new_id)) => {
1112                    let old = old_id.map_or_else(|| "none".to_string(), |id| id.0.to_string());
1113                    let new = new_id.map_or_else(|| "none".to_string(), |id| id.0.to_string());
1114                    let keep_note = if keep_plan { " (plan preserved)" } else { "" };
1115                    Ok(format!(
1116                        "New conversation started. Previous: {old} → Current: {new}{keep_note}"
1117                    ))
1118                }
1119                Err(e) => Ok(format!("Failed to start new conversation: {e}")),
1120            }
1121        })
1122    }
1123
1124    // ----- /cache-stats -----
1125
1126    fn cache_stats(&self) -> String {
1127        self.tool_orchestrator.cache_stats()
1128    }
1129
1130    // ----- /status -----
1131
1132    fn session_status<'a>(
1133        &'a mut self,
1134    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1135        Box::pin(async move { Ok(self.handle_status_as_string()) })
1136    }
1137
1138    // ----- /guardrail -----
1139
1140    fn guardrail_status(&self) -> String {
1141        self.format_guardrail_status()
1142    }
1143
1144    // ----- /focus -----
1145
1146    fn focus_status(&self) -> String {
1147        self.format_focus_status()
1148    }
1149
1150    // ----- /sidequest -----
1151
1152    fn sidequest_status(&self) -> String {
1153        self.format_sidequest_status()
1154    }
1155
1156    // ----- /image -----
1157
1158    fn load_image<'a>(
1159        &'a mut self,
1160        path: &'a str,
1161    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1162        use zeph_common::path_guard::{PathRejection, classify_relative_path};
1163        use zeph_llm::provider::{ImageData, MessagePart};
1164
1165        match classify_relative_path(path) {
1166            PathRejection::Allowed => {}
1167            PathRejection::Absolute => {
1168                return Box::pin(async move {
1169                    Ok(
1170                        "Invalid image path: absolute paths are not supported, use a path \
1171                        relative to the working directory"
1172                            .to_owned(),
1173                    )
1174                });
1175            }
1176            PathRejection::Traversal => {
1177                return Box::pin(async move {
1178                    Ok("Invalid image path: path traversal ('..') is not allowed".to_owned())
1179                });
1180            }
1181        }
1182
1183        let path_owned = path.to_owned();
1184        Box::pin(async move {
1185            let path_for_task = path_owned.clone();
1186            let read_result = tokio::task::spawn_blocking(move || std::fs::read(&path_for_task))
1187                .await
1188                .map_err(|e| CommandError::new(format!("spawn_blocking join error: {e}")))?;
1189            let data = match read_result {
1190                Ok(d) => d,
1191                Err(e) => return Ok(format!("Cannot read image {path_owned}: {e}")),
1192            };
1193            if data.len() > crate::agent::message_queue::MAX_IMAGE_BYTES {
1194                return Ok(format!(
1195                    "Image {path_owned} exceeds size limit ({} MB), skipping",
1196                    crate::agent::message_queue::MAX_IMAGE_BYTES / 1024 / 1024
1197                ));
1198            }
1199            let mime_type =
1200                crate::agent::message_queue::detect_image_mime(Some(&path_owned)).to_string();
1201            self.msg
1202                .pending_image_parts
1203                .push(MessagePart::Image(Box::new(ImageData { data, mime_type })));
1204            Ok(format!("Image loaded: {path_owned}. Send your message."))
1205        })
1206    }
1207
1208    // ----- /mcp -----
1209
1210    fn handle_mcp<'a>(
1211        &'a mut self,
1212        args: &'a str,
1213    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1214        // Extract all owned data before the async block so no &mut self reference is
1215        // held across an .await point, satisfying the `for<'a>` Send bound.
1216        let args_owned = args.to_owned();
1217        let parts: Vec<String> = args_owned.split_whitespace().map(str::to_owned).collect();
1218        let sub = parts.first().cloned().unwrap_or_default();
1219
1220        match sub.as_str() {
1221            "list" => {
1222                // Read-only: clone all data before async.
1223                let manager = self.services.mcp.manager.clone();
1224                let tools_snapshot: Vec<(String, String)> = self
1225                    .services
1226                    .mcp
1227                    .tools
1228                    .iter()
1229                    .map(|t| (t.server_id.clone(), t.name.clone()))
1230                    .collect();
1231                Box::pin(async move {
1232                    use std::fmt::Write;
1233                    let Some(manager) = manager else {
1234                        return Ok("MCP is not enabled.".to_owned());
1235                    };
1236                    let server_ids = manager.list_servers().await;
1237                    if server_ids.is_empty() {
1238                        return Ok("No MCP servers connected.".to_owned());
1239                    }
1240                    let mut output = String::from("Connected MCP servers:\n");
1241                    let mut total = 0usize;
1242                    for id in &server_ids {
1243                        let count = tools_snapshot.iter().filter(|(sid, _)| sid == id).count();
1244                        total += count;
1245                        let _ = writeln!(output, "- {id} ({count} tools)");
1246                    }
1247                    let _ = write!(output, "Total: {total} tool(s)");
1248                    Ok(output)
1249                })
1250            }
1251            "tools" => {
1252                // Read-only: collect tool info before async.
1253                let server_id = parts.get(1).cloned();
1254                let owned_tools: Vec<(String, String)> = if let Some(ref sid) = server_id {
1255                    self.services
1256                        .mcp
1257                        .tools
1258                        .iter()
1259                        .filter(|t| &t.server_id == sid)
1260                        .map(|t| (t.name.clone(), t.description.clone()))
1261                        .collect()
1262                } else {
1263                    Vec::new()
1264                };
1265                Box::pin(async move {
1266                    use std::fmt::Write;
1267                    let Some(server_id) = server_id else {
1268                        return Ok("Usage: /mcp tools <server_id>".to_owned());
1269                    };
1270                    if owned_tools.is_empty() {
1271                        return Ok(format!("No tools found for server '{server_id}'."));
1272                    }
1273                    let mut output =
1274                        format!("Tools for '{server_id}' ({} total):\n", owned_tools.len());
1275                    for (name, desc) in &owned_tools {
1276                        if desc.is_empty() {
1277                            let _ = writeln!(output, "- {name}");
1278                        } else {
1279                            let _ = writeln!(output, "- {name} — {desc}");
1280                        }
1281                    }
1282                    Ok(output)
1283                })
1284            }
1285            // add/remove require mutating self after async I/O.
1286            // handle_mcp_command is structured so the only .await crossing a &mut self
1287            // boundary goes through a cloned Arc<McpManager> — no &self fields are held
1288            // across that .await.  The subsequent state-change methods (rebuild_semantic_index,
1289            // sync_mcp_registry) are also async fn(&mut self), but they only hold owned locals
1290            // across their own .await points (cloned tools Vec, cloned Arcs).
1291            _ => Box::pin(async move {
1292                self.handle_mcp_command(&args_owned)
1293                    .await
1294                    .map_err(|e| CommandError::new(e.to_string()))
1295            }),
1296        }
1297    }
1298
1299    // ----- /skill -----
1300
1301    fn handle_skill<'a>(
1302        &'a mut self,
1303        args: &'a str,
1304    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1305        let args_owned = args.to_owned();
1306        Box::pin(async move {
1307            self.handle_skill_command_as_string(&args_owned)
1308                .await
1309                .map_err(|e| CommandError::new(e.to_string()))
1310        })
1311    }
1312
1313    // ----- /skills -----
1314
1315    fn handle_skills<'a>(
1316        &'a mut self,
1317        args: &'a str,
1318    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1319        let args_owned = args.to_owned();
1320        Box::pin(async move {
1321            self.handle_skills_as_string(&args_owned)
1322                .await
1323                .map_err(|e| CommandError::new(e.to_string()))
1324        })
1325    }
1326
1327    // ----- /feedback -----
1328
1329    fn handle_feedback_command<'a>(
1330        &'a mut self,
1331        args: &'a str,
1332    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1333        let args_owned = args.to_owned();
1334        Box::pin(async move {
1335            self.handle_feedback_as_string(&args_owned)
1336                .await
1337                .map_err(|e| CommandError::new(e.to_string()))
1338        })
1339    }
1340
1341    // ----- /plan -----
1342
1343    #[cfg(feature = "scheduler")]
1344    fn handle_plan<'a>(
1345        &'a mut self,
1346        input: &'a str,
1347    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1348        Box::pin(async move {
1349            self.dispatch_plan_command_as_string(input)
1350                .await
1351                .map_err(|e| CommandError::new(e.to_string()))
1352        })
1353    }
1354
1355    #[cfg(not(feature = "scheduler"))]
1356    fn handle_plan<'a>(
1357        &'a mut self,
1358        _input: &'a str,
1359    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1360        Box::pin(async move { Ok(String::new()) })
1361    }
1362
1363    // ----- /experiment -----
1364
1365    fn handle_experiment<'a>(
1366        &'a mut self,
1367        input: &'a str,
1368    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1369        Box::pin(async move {
1370            self.handle_experiment_command_as_string(input)
1371                .await
1372                .map_err(|e| CommandError::new(e.to_string()))
1373        })
1374    }
1375
1376    // ----- /agent, @mention -----
1377
1378    fn handle_agent_dispatch<'a>(
1379        &'a mut self,
1380        input: &'a str,
1381    ) -> Pin<Box<dyn Future<Output = Result<Option<String>, CommandError>> + Send + 'a>> {
1382        Box::pin(async move {
1383            match self.dispatch_agent_command(input).await {
1384                Some(Err(e)) => Err(CommandError::new(e.to_string())),
1385                Some(Ok(())) | None => Ok(None),
1386            }
1387        })
1388    }
1389
1390    // ----- /plugins -----
1391
1392    fn handle_plugins<'a>(
1393        &'a mut self,
1394        args: &'a str,
1395    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1396        let args_owned = args.to_owned();
1397        // Clone the fields needed by PluginManager before entering the async block.
1398        // spawn_blocking requires 'static, so we cannot borrow &self inside the closure.
1399        let managed_dir = self.services.skill.managed_dir.clone();
1400        let mcp_allowed = self.services.mcp.allowed_commands.clone();
1401        let base_shell_allowed = self.runtime.lifecycle.startup_shell_overlay.allowed.clone();
1402        // Collect manifest paths for ephemeral plugins. Reading the actual files is
1403        // deferred into the async block below to avoid blocking the tokio worker thread.
1404        let ephemeral_manifest_paths: Vec<std::path::PathBuf> = self
1405            .runtime
1406            .ephemeral_plugins
1407            .iter()
1408            .map(|tmp| tmp.path().join("plugin.toml"))
1409            .collect();
1410
1411        // Resolve scanner once, before the async block captures `self`.
1412        // Fail-closed: if semantic_scan is enabled but no provider is configured, refuse
1413        // to proceed rather than silently falling back to the primary provider (#4706, #4709).
1414        let semantic_scan_enabled = self.services.skill.semantic_scan;
1415        let maybe_scanner: Option<zeph_skills::semantic_scanner::SkillSemanticScanner> =
1416            if semantic_scan_enabled {
1417                let provider_name = self.services.skill.semantic_scan_provider.as_str();
1418                if provider_name.trim().is_empty() {
1419                    return Box::pin(async move {
1420                        Err(CommandError::new(
1421                            "semantic_scan is enabled but semantic_scan_provider is not set; \
1422                             refusing plugin add to maintain fail-closed security posture",
1423                        ))
1424                    });
1425                }
1426                let provider_known = self
1427                    .runtime
1428                    .providers
1429                    .provider_pool
1430                    .iter()
1431                    .any(|e| e.effective_name().eq_ignore_ascii_case(provider_name));
1432                if !provider_known {
1433                    let name = provider_name.to_owned();
1434                    return Box::pin(async move {
1435                        Err(CommandError::new(format!(
1436                            "semantic_scan is enabled but semantic_scan_provider '{name}' \
1437                             is not configured in [[llm.providers]]; \
1438                             refusing plugin add to maintain fail-closed security posture",
1439                        )))
1440                    });
1441                }
1442                let provider = self.resolve_background_provider(provider_name);
1443                Some(zeph_skills::semantic_scanner::SkillSemanticScanner::new(
1444                    provider,
1445                ))
1446            } else {
1447                None
1448            };
1449
1450        Box::pin(async move {
1451            let (subcmd, source) = args_owned
1452                .trim()
1453                .split_once(' ')
1454                .unwrap_or((args_owned.trim(), ""));
1455
1456            // Stage-2 LLM semantic scan runs before the blocking add(), fail-closed.
1457            if subcmd == "add"
1458                && !source.trim().is_empty()
1459                && let Some(ref scanner) = maybe_scanner
1460                && let Some(err) = semantic_scan_plugin_add(
1461                    scanner,
1462                    source.trim(),
1463                    managed_dir.clone(),
1464                    mcp_allowed.clone(),
1465                    base_shell_allowed.clone(),
1466                )
1467                .instrument(tracing::info_span!("core.agent.scan_plugin", plugin = %source.trim()))
1468                .await?
1469            {
1470                return Ok(err);
1471            }
1472
1473            // Resolve ephemeral plugin names asynchronously before entering the blocking task.
1474            let ephemeral_names: Vec<String> = {
1475                use futures::future::join_all;
1476                let futs = ephemeral_manifest_paths.into_iter().map(|p| async move {
1477                    tokio::fs::read_to_string(&p)
1478                        .await
1479                        .ok()
1480                        .and_then(|s| toml::from_str::<zeph_plugins::PluginManifest>(&s).ok())
1481                        .map(|m| m.plugin.name.to_string())
1482                });
1483                join_all(futs).await.into_iter().flatten().collect()
1484            };
1485
1486            // PluginManager performs synchronous filesystem I/O (copy, remove_dir_all,
1487            // read_dir). Run on a blocking thread to avoid stalling the tokio worker.
1488            tokio::task::spawn_blocking(move || {
1489                Self::run_plugin_command(
1490                    &args_owned,
1491                    managed_dir,
1492                    mcp_allowed,
1493                    base_shell_allowed,
1494                    ephemeral_names,
1495                )
1496            })
1497            .await
1498            .map_err(|e| CommandError(format!("plugin task panicked: {e}")))
1499        })
1500    }
1501
1502    // ----- /acp -----
1503
1504    fn handle_acp<'a>(
1505        &'a mut self,
1506        args: &'a str,
1507    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1508        Box::pin(async move {
1509            self.handle_acp_as_string(args)
1510                .map_err(|e| CommandError::new(e.to_string()))
1511        })
1512    }
1513
1514    // ----- /cocoon -----
1515
1516    #[cfg(feature = "cocoon")]
1517    fn handle_cocoon<'a>(
1518        &'a mut self,
1519        args: &'a str,
1520    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1521        Box::pin(async move {
1522            self.handle_cocoon_as_string(args)
1523                .await
1524                .map_err(|e| CommandError::new(e.to_string()))
1525        })
1526    }
1527
1528    #[cfg(not(feature = "cocoon"))]
1529    fn handle_cocoon<'a>(
1530        &'a mut self,
1531        _args: &'a str,
1532    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1533        Box::pin(async {
1534            Ok("Cocoon support is not compiled in. Rebuild with `--features cocoon`.".to_owned())
1535        })
1536    }
1537
1538    // ----- /loop -----
1539
1540    fn handle_loop<'a>(
1541        &'a mut self,
1542        args: &'a str,
1543    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1544        use zeph_commands::handlers::loop_cmd::parse_loop_args;
1545
1546        let args_owned = args.trim().to_owned();
1547        Box::pin(async move {
1548            if args_owned == "stop" {
1549                return Ok(self.stop_user_loop());
1550            }
1551            if args_owned == "status" {
1552                return Ok(match &self.runtime.lifecycle.user_loop {
1553                    Some(ls) => format!(
1554                        "Loop active: \"{}\" (iteration {}, interval every {}s).",
1555                        ls.prompt,
1556                        ls.iteration,
1557                        ls.interval.period().as_secs(),
1558                    ),
1559                    None => "No active loop.".to_owned(),
1560                });
1561            }
1562            let (prompt, interval_secs) = parse_loop_args(&args_owned)?;
1563
1564            if prompt.starts_with('/') {
1565                return Err(CommandError::new(
1566                    "Loop prompt must not start with '/'. Slash commands cannot be used as loop prompts.",
1567                ));
1568            }
1569
1570            let min_secs = self.runtime.config.loop_min_interval_secs;
1571            if interval_secs < min_secs {
1572                return Err(CommandError::new(format!(
1573                    "Minimum loop interval is {min_secs}s. Got {interval_secs}s."
1574                )));
1575            }
1576            if self.runtime.lifecycle.user_loop.is_some() {
1577                return Err(CommandError::new(
1578                    "A loop is already active. Use /loop stop first.",
1579                ));
1580            }
1581
1582            self.start_user_loop(prompt.clone(), interval_secs);
1583            Ok(format!(
1584                "Loop started: \"{prompt}\" every {interval_secs}s. Use /loop stop to cancel."
1585            ))
1586        })
1587    }
1588
1589    fn notify_test<'a>(
1590        &'a mut self,
1591    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1592        let notifier = self.runtime.lifecycle.notifier.clone();
1593        Box::pin(async move {
1594            let Some(notifier) = notifier else {
1595                return Ok(
1596                    "Notifications are disabled. Set `notifications.enabled = true` in config."
1597                        .to_owned(),
1598                );
1599            };
1600            match notifier.fire_test().await {
1601                Ok(()) => Ok("Test notification sent.".to_owned()),
1602                Err(e) => Err(CommandError::new(format!("notification test failed: {e}"))),
1603            }
1604        })
1605    }
1606
1607    fn handle_trajectory(&mut self, args: &str) -> String {
1608        self.handle_trajectory_command_as_string(args)
1609    }
1610
1611    fn handle_scope(&self, args: &str) -> String {
1612        self.handle_scope_command_as_string(args)
1613    }
1614
1615    // ----- /goal -----
1616
1617    fn handle_goal<'a>(
1618        &'a mut self,
1619        args: &'a str,
1620    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1621        // Extract all non-Send data synchronously before entering the async block.
1622        if self.services.goal_accounting.is_none() {
1623            if !self.runtime.config.goals.enabled {
1624                return Box::pin(async {
1625                    Ok("Goals are disabled. Set `[goals] enabled = true` in config.".to_owned())
1626                });
1627            }
1628            let pool = match self.services.memory.persistence.memory.as_ref() {
1629                Some(m) => std::sync::Arc::new(m.sqlite().pool().clone()),
1630                None => {
1631                    return Box::pin(async {
1632                        Ok("Goals require a database backend (memory not configured).".to_owned())
1633                    });
1634                }
1635            };
1636            let store = std::sync::Arc::new(crate::goal::GoalStore::new(pool));
1637            let accounting = std::sync::Arc::new(crate::goal::GoalAccounting::new(store));
1638            self.services.goal_accounting = Some(accounting);
1639        }
1640
1641        let accounting =
1642            self.services.goal_accounting.clone().expect(
1643                "invariant: goal_accounting is always Some at this point (initialized above)",
1644            );
1645        let max_chars = self.runtime.config.goals.max_text_chars;
1646        let default_budget = self.runtime.config.goals.default_token_budget;
1647        let autonomous_enabled = self.runtime.config.goals.autonomous_enabled;
1648        let autonomous_max_turns = self.runtime.config.goals.autonomous_max_turns;
1649        let args_owned = args.to_owned();
1650
1651        // S1: `goal_create` may need to arm `AutonomousDriver` with a new session.
1652        // We capture a clone of the pending_start Arc that lives on the driver.
1653        // The async block fills it; the main agent loop (which has `&mut self`) drains it
1654        // via `AutonomousDriver::flush_pending_start()` after each command handler returns.
1655        let pending_start_arc = std::sync::Arc::clone(&self.services.autonomous.pending_start_arc);
1656
1657        Box::pin(async move {
1658            let _ = accounting.refresh().await;
1659            let store = accounting.get_store();
1660            let args = args_owned.as_str();
1661
1662            match args {
1663                "" | "status" => goal_status(&accounting).await,
1664                "pause" => goal_pause(&accounting, &store).await,
1665                "resume" => goal_resume(&accounting, &store).await,
1666                "complete" => goal_complete(&accounting, &store).await,
1667                "clear" => goal_clear(&accounting, &store).await,
1668                "list" => goal_list(&store).await,
1669                _ if args.starts_with("create") => {
1670                    let (msg, auto_req) = goal_create(
1671                        args,
1672                        &accounting,
1673                        &store,
1674                        max_chars,
1675                        default_budget,
1676                        autonomous_enabled,
1677                        autonomous_max_turns,
1678                    )
1679                    .await?;
1680                    if let Some(req) = auto_req {
1681                        *pending_start_arc.lock() = Some(req);
1682                    }
1683                    Ok(msg)
1684                }
1685                _ => Ok(
1686                    "Unknown /goal subcommand. Try: create, pause, resume, complete, clear, status, list."
1687                        .to_owned(),
1688                ),
1689            }
1690        })
1691    }
1692
1693    fn active_goal_snapshot(&self) -> Option<zeph_commands::GoalSnapshot> {
1694        let accounting = self.services.goal_accounting.as_ref()?;
1695        let snap = accounting.snapshot()?;
1696        Some(zeph_commands::GoalSnapshot {
1697            id: snap.id,
1698            text: snap.text,
1699            status: match snap.status {
1700                crate::goal::GoalStatus::Active => zeph_commands::GoalStatusView::Active,
1701                crate::goal::GoalStatus::Paused => zeph_commands::GoalStatusView::Paused,
1702                crate::goal::GoalStatus::Completed => zeph_commands::GoalStatusView::Completed,
1703                crate::goal::GoalStatus::Cleared => zeph_commands::GoalStatusView::Cleared,
1704            },
1705            turns_used: snap.turns_used,
1706            tokens_used: snap.tokens_used,
1707            token_budget: snap.token_budget,
1708        })
1709    }
1710
1711    // ----- /undo, /redo -----
1712
1713    fn handle_undo<'a>(
1714        &'a mut self,
1715        args: &'a str,
1716    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1717        let executor = std::sync::Arc::clone(&self.tool_executor);
1718        let args_owned = args.trim().to_owned();
1719        Box::pin(async move {
1720            if args_owned == "list" {
1721                let result = executor.checkpoint_list_erased();
1722                if !result.supported {
1723                    return Ok(
1724                        "Checkpoints are not enabled. Set `[tools.shell] checkpoints_enabled = true` in config.".to_owned()
1725                    );
1726                }
1727                if result.entries.is_empty() {
1728                    return Ok("Undo stack is empty.".to_owned());
1729                }
1730                let mut lines = vec![format!("Undo stack ({} entries):", result.entries.len())];
1731                for e in &result.entries {
1732                    lines.push(format!(
1733                        "  [{}] {} ({} file(s))",
1734                        e.index, e.command, e.file_count
1735                    ));
1736                }
1737                if result.redo_depth > 0 {
1738                    lines.push(format!("Redo depth: {}", result.redo_depth));
1739                }
1740                return Ok(lines.join("\n"));
1741            }
1742
1743            let n: usize = if args_owned.is_empty() {
1744                1
1745            } else {
1746                match args_owned.parse::<usize>() {
1747                    Ok(v) if v > 0 => v,
1748                    _ => {
1749                        return Err(CommandError::new(format!(
1750                            "Invalid argument: expected a positive integer or 'list', got '{args_owned}'"
1751                        )));
1752                    }
1753                }
1754            };
1755
1756            let result = tokio::task::spawn_blocking(move || executor.checkpoint_undo_erased(n))
1757                .await
1758                .map_err(|e| CommandError::new(format!("undo task panicked: {e}")))?;
1759            if !result.supported {
1760                return Ok(
1761                    "Checkpoints are not enabled. Set `[tools.shell] checkpoints_enabled = true` in config.".to_owned()
1762                );
1763            }
1764            Ok(result.message)
1765        })
1766    }
1767
1768    fn handle_redo<'a>(
1769        &'a mut self,
1770        args: &'a str,
1771    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1772        let _ = args;
1773        let executor = std::sync::Arc::clone(&self.tool_executor);
1774        Box::pin(async move {
1775            let result = tokio::task::spawn_blocking(move || executor.checkpoint_redo_erased())
1776                .await
1777                .map_err(|e| CommandError::new(format!("redo task panicked: {e}")))?;
1778            if !result.supported {
1779                return Ok(
1780                    "Checkpoints are not enabled. Set `[tools.shell] checkpoints_enabled = true` in config.".to_owned()
1781                );
1782            }
1783            Ok(result.message)
1784        })
1785    }
1786
1787    // ----- /agents -----
1788
1789    fn handle_agents<'a>(
1790        &'a mut self,
1791        args: &'a str,
1792    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1793        use zeph_commands::handlers::agents_fleet::{FleetEntry, format_fleet_section};
1794        use zeph_subagent::AgentsCommand;
1795
1796        let args_owned = args.trim().to_owned();
1797        Box::pin(async move {
1798            // Fleet view: bare `/agents` or `/agents fleet` shows autonomous sessions + definitions.
1799            let show_fleet = args_owned.is_empty() || args_owned == "fleet";
1800
1801            let fleet_section = if show_fleet {
1802                let snapshots = self.services.autonomous_registry.list();
1803                let entries: Vec<FleetEntry> = snapshots
1804                    .into_iter()
1805                    .map(|s| FleetEntry {
1806                        goal_id: s.goal_id,
1807                        goal_text_short: s.goal_text_short,
1808                        state: s.state,
1809                        turns_executed: s.turns_executed,
1810                        max_turns: s.max_turns,
1811                        elapsed: s.elapsed,
1812                    })
1813                    .collect();
1814                format_fleet_section(&entries)
1815            } else {
1816                String::new()
1817            };
1818
1819            // Sub-agent definitions section.
1820            let definitions_section = if show_fleet || args_owned == "list" {
1821                self.handle_agents_definitions_list()
1822            } else {
1823                // CRUD subcommands: show, create, edit, delete.
1824                match AgentsCommand::parse(&format!("/agents {args_owned}")) {
1825                    Ok(cmd) => self.handle_agents_crud(cmd),
1826                    Err(e) => e.to_string(),
1827                }
1828            };
1829
1830            let mut out = fleet_section;
1831            if !definitions_section.is_empty() {
1832                if !out.is_empty() {
1833                    out.push('\n');
1834                }
1835                out.push_str(&definitions_section);
1836            }
1837
1838            if out.is_empty() {
1839                "No active autonomous sessions or sub-agent definitions found."
1840                    .clone_into(&mut out);
1841            }
1842
1843            Ok(out)
1844        })
1845    }
1846
1847    // ----- /conv -----
1848
1849    fn handle_conv<'a>(
1850        &'a mut self,
1851        args: &'a str,
1852    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1853        let args_owned = args.trim().to_owned();
1854        Box::pin(async move {
1855            // `resume`/`fork` need `&mut self` (mid-session live conversation swap, D-9) —
1856            // handled first so `self` isn't already borrowed by the `list`/`show` path below.
1857            if let Some(id) = args_owned.strip_prefix("resume ") {
1858                return self.handle_conv_resume(id.trim()).await;
1859            }
1860            if let Some(id) = args_owned.strip_prefix("fork ") {
1861                return self.handle_conv_fork(id.trim()).await;
1862            }
1863
1864            let Some(memory) = self.services.memory.persistence.memory.clone() else {
1865                return Ok(
1866                    "Conversation-session persistence requires memory to be enabled ([memory] enabled = true)."
1867                        .to_owned(),
1868                );
1869            };
1870            let store = zeph_session::SessionStore::new(memory.sqlite().pool().clone());
1871
1872            if let Some(id) = args_owned.strip_prefix("show ") {
1873                return handle_conv_show(&store, id.trim()).await;
1874            }
1875            if args_owned.is_empty() || args_owned == "list" {
1876                return handle_conv_list(&store).await;
1877            }
1878            Ok(format!(
1879                "Unknown /conv subcommand '{args_owned}'. Usage: /conv [list | show <id> | resume <id> | fork <id>]"
1880            ))
1881        })
1882    }
1883
1884    // ----- /worktree -----
1885
1886    fn list_worktrees<'a>(
1887        &'a mut self,
1888    ) -> Pin<Box<dyn Future<Output = Result<Option<String>, CommandError>> + Send + 'a>> {
1889        Box::pin(async move {
1890            self.handle_worktree_list_as_string()
1891                .await
1892                .map_err(|e| CommandError::new(e.to_string()))
1893        })
1894    }
1895
1896    fn clean_worktrees<'a>(
1897        &'a mut self,
1898        force: bool,
1899    ) -> Pin<Box<dyn Future<Output = Result<Option<String>, CommandError>> + Send + 'a>> {
1900        Box::pin(async move {
1901            self.handle_worktree_clean_as_string(force)
1902                .await
1903                .map_err(|e| CommandError::new(e.to_string()))
1904        })
1905    }
1906
1907    // ----- /cd -----
1908
1909    fn change_working_directory<'a>(
1910        &'a mut self,
1911        path: &'a str,
1912    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
1913        Box::pin(
1914            async move {
1915                let path = path.trim();
1916                if path.is_empty() {
1917                    let cwd = std::env::current_dir().map_err(|e| {
1918                        CommandError::new(format!("failed to read current working directory: {e}"))
1919                    })?;
1920                    return Ok(format!("Current working directory: {}", cwd.display()));
1921                }
1922                // Reuses the same path-resolution + `set_current_dir` logic as the
1923                // LLM-invoked `set_working_directory` tool (#6032 FR-001/FR-011) — no
1924                // parallel implementation. `allowed_paths` empty means no build site has set
1925                // it yet; default to `[cwd]` rather than "allow every path", matching
1926                // `FileExecutor::new`/`SetCwdExecutor::new`'s convention (SEC-2).
1927                let allowed_paths: Vec<std::path::PathBuf> =
1928                    if self.services.tool_state.allowed_paths.is_empty() {
1929                        // Canonicalize for byte-for-byte parity with `FileExecutor::new`/
1930                        // `SetCwdExecutor::new`'s fallback, which both canonicalize their
1931                        // default `[cwd]` entry (`.map(|p| p.canonicalize().unwrap_or(p))`).
1932                        std::env::current_dir()
1933                            .map(|p| p.canonicalize().unwrap_or(p))
1934                            .into_iter()
1935                            .collect()
1936                    } else {
1937                        self.services.tool_state.allowed_paths.clone()
1938                    };
1939                let new_cwd = zeph_tools::resolve_and_set_cwd(path, &allowed_paths)
1940                    .map_err(|e| CommandError::new(format!("cannot change to '{path}': {e}")))?;
1941                // Drives the same post-change pipeline the tool-invoked path gets for free
1942                // after a tool batch (`tier_loop.rs`) — a bare slash command must call it
1943                // explicitly (FR-002/FR-003/FR-004): mirror-update, `cwd_changed` hooks,
1944                // repo-map invalidation, and (unless safe-mode) instruction re-discovery.
1945                self.check_cwd_changed().await;
1946                Ok(format!(
1947                    "Working directory changed to: {}",
1948                    new_cwd.display()
1949                ))
1950            }
1951            .instrument(tracing::info_span!("core.commands.cd")),
1952        )
1953    }
1954}
1955
1956impl<C: Channel> Agent<C> {
1957    /// `/conv resume <id>` (spec-068, #5343, D-9): mid-session live swap onto an existing
1958    /// durable session. Resolves `conversation_id` via the `SessionId`<->`ConversationId`
1959    /// bijection (spec §5.2) — reuses the session's existing linked conversation if one exists,
1960    /// otherwise mints one and links it (a session created via the HTTP API's `POST /sessions`,
1961    /// or a legacy session, may not have one yet).
1962    async fn handle_conv_resume(&mut self, id: &str) -> Result<String, CommandError> {
1963        if id.is_empty() {
1964            return Ok("Usage: /conv resume <id>".to_owned());
1965        }
1966        // #5487 fix 3: `load_and_resume_conversation` now opens the target session's event log
1967        // exclusively (INV-D2). Resuming into the session already live in this agent would try
1968        // to acquire a second exclusive lock on the same directory this agent's own
1969        // `SessionSink` already holds open, deadlocking on `AlreadyLocked` — short-circuit with a
1970        // clear message instead of attempting a self-conflicting reopen.
1971        if let Some(sink) = &self.services.session.session_sink
1972            && sink.session_id().as_str() == id
1973        {
1974            return Ok(format!("Already in session '{id}'."));
1975        }
1976        let Some(memory) = self.services.memory.persistence.memory.clone() else {
1977            return Ok(
1978                "Conversation-session persistence requires memory to be enabled ([memory] enabled = true)."
1979                    .to_owned(),
1980            );
1981        };
1982        let store = zeph_session::SessionStore::new(memory.sqlite().pool().clone());
1983        let Some(metadata) = store
1984            .get(id)
1985            .await
1986            .map_err(|e| CommandError::new(e.to_string()))?
1987        else {
1988            return Ok(format!("Session '{id}' not found."));
1989        };
1990
1991        let conversation_id = if let Some(cid) = metadata.conversation_id {
1992            zeph_memory::ConversationId(cid)
1993        } else {
1994            let cid = memory
1995                .sqlite()
1996                .create_conversation()
1997                .await
1998                .map_err(|e| CommandError::new(e.to_string()))?;
1999            store
2000                .link_conversation(id, cid.0)
2001                .await
2002                .map_err(|e| CommandError::new(e.to_string()))?;
2003            cid
2004        };
2005
2006        let session_id = zeph_common::SessionId::new(id);
2007        self.load_and_resume_conversation(&session_id, conversation_id)
2008            .await
2009            .map_err(|e| CommandError::new(e.to_string()))?;
2010
2011        Ok(format!(
2012            "Resumed session {id} ({} event(s) replayed).",
2013            metadata.event_count
2014        ))
2015    }
2016
2017    /// `/conv fork <id>` (spec-068, #5343, D-9): eager-copies `id`'s durable log into a fresh
2018    /// child session via `ForkEngine::fork` (P2), then immediately live-swaps onto the child —
2019    /// same effect as `POST /sessions/:id/fork` (spec §9.4) but for the current CLI/TUI session
2020    /// instead of spawning a new `SessionActor`.
2021    async fn handle_conv_fork(&mut self, id: &str) -> Result<String, CommandError> {
2022        if id.is_empty() {
2023            return Ok("Usage: /conv fork <id>".to_owned());
2024        }
2025        let Some(memory) = self.services.memory.persistence.memory.clone() else {
2026            return Ok(
2027                "Conversation-session persistence requires memory to be enabled ([memory] enabled = true)."
2028                    .to_owned(),
2029            );
2030        };
2031        let Some(session_persistence_config) =
2032            self.services.session.session_persistence_config.clone()
2033        else {
2034            return Ok(
2035                "Conversation-session persistence is not enabled ([session] enabled = true)."
2036                    .to_owned(),
2037            );
2038        };
2039        let store = zeph_session::SessionStore::new(memory.sqlite().pool().clone());
2040        let data_dir = std::path::PathBuf::from(&session_persistence_config.data_dir);
2041        let new_id = zeph_common::SessionId::generate();
2042
2043        let fork_result =
2044            zeph_session::ForkEngine::fork(&data_dir, id, new_id.as_str(), None, &store, None)
2045                .await
2046                .map_err(|e| CommandError::new(e.to_string()))?;
2047
2048        let conversation_id = memory
2049            .sqlite()
2050            .create_conversation()
2051            .await
2052            .map_err(|e| CommandError::new(e.to_string()))?;
2053
2054        self.load_and_resume_conversation(&new_id, conversation_id)
2055            .await
2056            .map_err(|e| CommandError::new(e.to_string()))?;
2057
2058        Ok(format!(
2059            "Forked session {id} -> {} ({} event(s) copied); now the active conversation.",
2060            fork_result.new_session_id, fork_result.events_copied
2061        ))
2062    }
2063}
2064
2065/// Formats `/conv list` — mirrors `sessions list`'s CLI table layout
2066/// (`src/commands/sessions.rs`) and `zeph serve-sessions`'s `GET /sessions`.
2067async fn handle_conv_list(store: &zeph_session::SessionStore) -> Result<String, CommandError> {
2068    use std::fmt::Write as _;
2069
2070    let sessions = store
2071        .list(&zeph_session::SessionFilter::default())
2072        .await
2073        .map_err(|e| CommandError::new(format!("failed to list sessions: {e}")))?;
2074
2075    if sessions.is_empty() {
2076        return Ok("No conversation-sessions found.".to_owned());
2077    }
2078
2079    let mut out = format!(
2080        "{:<38} {:<30} {:<9} {:>6} {:<24}\n",
2081        "ID", "TITLE", "STATUS", "EVENTS", "UPDATED"
2082    );
2083    out.push_str(&"-".repeat(110));
2084    out.push('\n');
2085    for s in &sessions {
2086        let title = s.title.as_deref().unwrap_or("(untitled)");
2087        let _ = writeln!(
2088            out,
2089            "{:<38} {:<30} {:<9} {:>6} {:<24}",
2090            s.session_id,
2091            crate::text::truncate_to_chars(title, 30),
2092            s.status.as_str(),
2093            s.event_count,
2094            s.updated_at
2095        );
2096    }
2097    Ok(out.trim_end().to_owned())
2098}
2099
2100/// Formats `/conv show <id>` — one session's metadata, mirroring `zeph serve-sessions`'s
2101/// `GET /sessions/:id` (metadata only; use `zeph sessions show --events <id>` on the CLI for a
2102/// full event-log dump).
2103async fn handle_conv_show(
2104    store: &zeph_session::SessionStore,
2105    id: &str,
2106) -> Result<String, CommandError> {
2107    if id.is_empty() {
2108        return Ok("Usage: /conv show <id>".to_owned());
2109    }
2110    let metadata = store
2111        .get(id)
2112        .await
2113        .map_err(|e| CommandError::new(format!("failed to read session metadata: {e}")))?;
2114    let Some(m) = metadata else {
2115        return Ok(format!("Session '{id}' not found."));
2116    };
2117    Ok(format!(
2118        "Session {}\n  title: {}\n  status: {}\n  events: {} (last_seq={})\n  forked_from: {}\n  created: {}\n  updated: {}",
2119        m.session_id,
2120        m.title.as_deref().unwrap_or("(untitled)"),
2121        m.status.as_str(),
2122        m.event_count,
2123        m.last_seq,
2124        m.forked_from.as_deref().unwrap_or("-"),
2125        m.created_at,
2126        m.updated_at
2127    ))
2128}
2129
2130type GoalStore = crate::goal::GoalStore;
2131type GoalAccounting = crate::goal::GoalAccounting;
2132
2133/// Hard cap on `--turns` to prevent runaway autonomous loops (Security Low).
2134const AUTONOMOUS_MAX_TURNS_CAP: u32 = 1000;
2135
2136async fn goal_status(accounting: &GoalAccounting) -> Result<String, CommandError> {
2137    match accounting.get_active().await {
2138        Ok(Some(g)) => {
2139            let budget_line = g.token_budget.map_or_else(
2140                || format!("  tokens used: {}", g.tokens_used),
2141                |b| format!("  budget: {}/{b}", g.tokens_used),
2142            );
2143            Ok(format!(
2144                "Active goal [{}]: {}\n  status: {}\n  turns: {}\n{}",
2145                &g.id[..8],
2146                g.text,
2147                g.status,
2148                g.turns_used,
2149                budget_line
2150            ))
2151        }
2152        Ok(None) => Ok("No active goal. Use `/goal create <text>` to set one.".to_owned()),
2153        Err(e) => Ok(format!("Goal lookup failed: {e}")),
2154    }
2155}
2156
2157/// Returns `(display_message, auto_start_request)`.
2158///
2159/// `auto_start_request` is `Some((goal_id, goal_text, max_turns))` when `--auto` was passed and
2160/// the goal was successfully created. The caller must relay this to `AutonomousDriver` via the
2161/// `pending_start_arc` side-channel before the future resolves.
2162async fn goal_create(
2163    args: &str,
2164    accounting: &GoalAccounting,
2165    store: &GoalStore,
2166    max_chars: usize,
2167    default_budget: Option<u64>,
2168    autonomous_enabled: bool,
2169    autonomous_max_turns: u32,
2170) -> Result<(String, Option<(String, String, u32)>), CommandError> {
2171    let rest = args.strip_prefix("create").unwrap_or("").trim();
2172
2173    // Strip --auto / --turns before passing text to the budget parser.
2174    let (stripped, is_auto, explicit_turns) = parse_auto_flags(rest);
2175    let (text, explicit_budget) = parse_goal_create_args(&stripped);
2176
2177    if text.is_empty() {
2178        return Ok((
2179            "Usage: /goal create <text> [--budget N] [--auto [--turns N]]".to_owned(),
2180            None,
2181        ));
2182    }
2183    if is_auto && !autonomous_enabled {
2184        return Ok((
2185            "Autonomous mode is disabled. Set `[goals] autonomous_enabled = true` in config."
2186                .to_owned(),
2187            None,
2188        ));
2189    }
2190    let budget = explicit_budget.or(default_budget.filter(|&b| b > 0));
2191
2192    let max_turns = explicit_turns
2193        .unwrap_or(autonomous_max_turns)
2194        .min(AUTONOMOUS_MAX_TURNS_CAP);
2195    if explicit_turns.is_some_and(|t| t > AUTONOMOUS_MAX_TURNS_CAP) {
2196        tracing::warn!(
2197            requested = explicit_turns,
2198            capped = AUTONOMOUS_MAX_TURNS_CAP,
2199            "autonomous max_turns capped to {AUTONOMOUS_MAX_TURNS_CAP}"
2200        );
2201    }
2202
2203    match store.create(text, budget, max_chars).await {
2204        Ok(g) => {
2205            let _ = accounting.refresh().await;
2206            let auto_start = if is_auto {
2207                Some((g.id.clone(), g.text.clone(), max_turns))
2208            } else {
2209                None
2210            };
2211            let auto_note = if is_auto {
2212                " Autonomous mode enabled — use `/goal clear` to stop."
2213            } else {
2214                ""
2215            };
2216            Ok((
2217                format!("Goal created [{}]: {}{auto_note}", &g.id[..8], g.text),
2218                auto_start,
2219            ))
2220        }
2221        Err(crate::goal::store::GoalError::TextTooLong { max }) => Ok((
2222            format!("Goal text exceeds {max} characters. Please shorten it."),
2223            None,
2224        )),
2225        Err(e) => Ok((format!("Failed to create goal: {e}"), None)),
2226    }
2227}
2228
2229async fn goal_pause(
2230    accounting: &GoalAccounting,
2231    store: &GoalStore,
2232) -> Result<String, CommandError> {
2233    match accounting.get_active().await {
2234        Ok(Some(g)) => {
2235            match store
2236                .transition(&g.id, crate::goal::GoalStatus::Paused, g.updated_at)
2237                .await
2238            {
2239                Ok(_) => {
2240                    let _ = accounting.refresh().await;
2241                    Ok(format!("Goal [{}] paused.", &g.id[..8]))
2242                }
2243                Err(crate::goal::store::GoalError::StaleUpdate(_)) => {
2244                    let current = accounting.get_active().await.ok().flatten();
2245                    Ok(format!(
2246                        "Goal state changed concurrently. Current: {}",
2247                        current.map_or_else(|| "none".into(), |g| g.status.to_string())
2248                    ))
2249                }
2250                Err(e) => Ok(format!("Pause failed: {e}")),
2251            }
2252        }
2253        Ok(None) => Ok("No active goal to pause.".to_owned()),
2254        Err(e) => Ok(format!("Failed: {e}")),
2255    }
2256}
2257
2258async fn goal_resume(
2259    accounting: &GoalAccounting,
2260    store: &GoalStore,
2261) -> Result<String, CommandError> {
2262    let goals = store.list(10).await.unwrap_or_default();
2263    let paused = goals
2264        .into_iter()
2265        .find(|g| g.status == crate::goal::GoalStatus::Paused);
2266    match paused {
2267        Some(g) => {
2268            match store
2269                .transition(&g.id, crate::goal::GoalStatus::Active, g.updated_at)
2270                .await
2271            {
2272                Ok(_) => {
2273                    let _ = accounting.refresh().await;
2274                    Ok(format!("Goal [{}] resumed: {}", &g.id[..8], g.text))
2275                }
2276                Err(crate::goal::store::GoalError::StaleUpdate(_)) => {
2277                    Ok("Goal state changed concurrently — please retry.".to_owned())
2278                }
2279                Err(e) => Ok(format!("Resume failed: {e}")),
2280            }
2281        }
2282        None => Ok("No paused goal to resume.".to_owned()),
2283    }
2284}
2285
2286async fn goal_complete(
2287    accounting: &GoalAccounting,
2288    store: &GoalStore,
2289) -> Result<String, CommandError> {
2290    match accounting.get_active().await {
2291        Ok(Some(g)) => {
2292            match store
2293                .transition(&g.id, crate::goal::GoalStatus::Completed, g.updated_at)
2294                .await
2295            {
2296                Ok(_) => {
2297                    let _ = accounting.refresh().await;
2298                    Ok(format!("Goal [{}] marked complete.", &g.id[..8]))
2299                }
2300                Err(e) => Ok(format!("Complete failed: {e}")),
2301            }
2302        }
2303        Ok(None) => Ok("No active goal.".to_owned()),
2304        Err(e) => Ok(format!("Failed: {e}")),
2305    }
2306}
2307
2308async fn goal_clear(
2309    accounting: &GoalAccounting,
2310    store: &GoalStore,
2311) -> Result<String, CommandError> {
2312    let goals = store.list(10).await.unwrap_or_default();
2313    let target = goals.into_iter().find(|g| {
2314        g.status == crate::goal::GoalStatus::Active || g.status == crate::goal::GoalStatus::Paused
2315    });
2316    match target {
2317        Some(g) => {
2318            match store
2319                .transition(&g.id, crate::goal::GoalStatus::Cleared, g.updated_at)
2320                .await
2321            {
2322                Ok(_) => {
2323                    let _ = accounting.refresh().await;
2324                    Ok(format!("Goal [{}] cleared.", &g.id[..8]))
2325                }
2326                Err(e) => Ok(format!("Clear failed: {e}")),
2327            }
2328        }
2329        None => Ok("No active or paused goal to clear.".to_owned()),
2330    }
2331}
2332
2333async fn goal_list(store: &GoalStore) -> Result<String, CommandError> {
2334    let goals = store.list(20).await.unwrap_or_default();
2335    if goals.is_empty() {
2336        return Ok("No goals recorded.".to_owned());
2337    }
2338    let mut out = String::from("Goals:\n");
2339    for g in goals {
2340        let _ = std::fmt::Write::write_fmt(
2341            &mut out,
2342            format_args!(
2343                "  {} [{}] {} — {} turns\n",
2344                g.status.badge_symbol(),
2345                &g.id[..8],
2346                g.text,
2347                g.turns_used
2348            ),
2349        );
2350    }
2351    Ok(out.trim_end().to_owned())
2352}
2353
2354fn parse_goal_create_args(args: &str) -> (&str, Option<u64>) {
2355    if let Some(pos) = args.find("--budget") {
2356        let text = args[..pos].trim();
2357        let rest = args[pos + "--budget".len()..].trim();
2358        let budget = rest
2359            .split_whitespace()
2360            .next()
2361            .and_then(|s| s.parse::<u64>().ok());
2362        (text, budget)
2363    } else {
2364        (args, None)
2365    }
2366}
2367
2368/// Parse `--auto` and `--turns N` flags from the remainder of a `/goal create` argument string.
2369///
2370/// Returns `(text_without_auto_flags, is_auto, explicit_turns)`.
2371fn parse_auto_flags(args: &str) -> (String, bool, Option<u32>) {
2372    let mut is_auto = false;
2373    let mut turns: Option<u32> = None;
2374    let mut text_words: Vec<&str> = Vec::new();
2375    let mut words = args.split_whitespace();
2376
2377    while let Some(w) = words.next() {
2378        if w == "--auto" {
2379            is_auto = true;
2380        } else if w == "--turns" {
2381            turns = words.next().and_then(|n| n.parse::<u32>().ok());
2382        } else {
2383            text_words.push(w);
2384        }
2385    }
2386
2387    (text_words.join(" "), is_auto, turns)
2388}
2389
2390/// Convert `AgentError` to `CommandError` for the trait boundary.
2391impl From<AgentError> for CommandError {
2392    fn from(e: AgentError) -> Self {
2393        Self(e.to_string())
2394    }
2395}
2396
2397#[cfg(test)]
2398mod tests {
2399    use super::super::agent_tests::{
2400        MockChannel, MockToolExecutor, create_test_registry, mock_provider,
2401    };
2402    use super::*;
2403    use zeph_commands::traits::agent::AgentAccess;
2404    use zeph_memory::semantic::SemanticMemory;
2405
2406    async fn memory_without_qdrant() -> SemanticMemory {
2407        SemanticMemory::new(
2408            ":memory:",
2409            "http://127.0.0.1:1",
2410            None,
2411            zeph_llm::any::AnyProvider::Mock(zeph_llm::mock::MockProvider::default()),
2412            "test-model",
2413        )
2414        .await
2415        .unwrap()
2416    }
2417
2418    // R-CRIT-4111: when graph is enabled in config but graph_store is None
2419    // (Qdrant unreachable), graph command handlers must report
2420    // "unavailable" rather than "not enabled".
2421    #[tokio::test]
2422    async fn graph_stats_enabled_but_no_store_reports_unavailable() {
2423        let cfg = crate::config::GraphConfig {
2424            enabled: true,
2425            ..Default::default()
2426        };
2427        let memory = memory_without_qdrant().await;
2428        let cid = memory.sqlite().create_conversation().await.unwrap();
2429        let mut agent = Agent::new(
2430            mock_provider(vec![]),
2431            MockChannel::new(vec![]),
2432            create_test_registry(),
2433            None,
2434            5,
2435            MockToolExecutor::no_tools(),
2436        )
2437        .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
2438        .with_graph_config(cfg);
2439
2440        let result = agent.graph_stats().await.unwrap();
2441        assert!(
2442            result.contains("unavailable"),
2443            "expected 'unavailable' but got: {result}"
2444        );
2445        assert!(
2446            !result.contains("not enabled"),
2447            "must not report 'not enabled' when graph is enabled: {result}"
2448        );
2449    }
2450
2451    #[tokio::test]
2452    async fn graph_stats_disabled_reports_not_enabled() {
2453        let cfg = crate::config::GraphConfig {
2454            enabled: false,
2455            ..Default::default()
2456        };
2457        let memory = memory_without_qdrant().await;
2458        let cid = memory.sqlite().create_conversation().await.unwrap();
2459        let mut agent = Agent::new(
2460            mock_provider(vec![]),
2461            MockChannel::new(vec![]),
2462            create_test_registry(),
2463            None,
2464            5,
2465            MockToolExecutor::no_tools(),
2466        )
2467        .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
2468        .with_graph_config(cfg);
2469
2470        let result = agent.graph_stats().await.unwrap();
2471        assert!(
2472            result.contains("not enabled"),
2473            "expected 'not enabled' but got: {result}"
2474        );
2475    }
2476
2477    // R-CRIT-4136: graph_backfill must resolve extract_provider before entering the async block.
2478    // When extract_provider is set to an unknown name, resolve_background_provider falls back to
2479    // the primary provider — the backfill still completes (no messages to process).
2480    // This test confirms that the provider-resolution code path executes without panic or borrow
2481    // errors, which would occur if the old code tried to access `&mut self` inside `async move`.
2482    #[tokio::test]
2483    async fn graph_backfill_with_extract_provider_resolves_without_panic() {
2484        let cfg = crate::config::GraphConfig {
2485            enabled: true,
2486            extract_provider: zeph_config::providers::ProviderName::new("nonexistent-provider"),
2487            ..Default::default()
2488        };
2489        let mut memory = memory_without_qdrant().await;
2490        // Install a real SQLite-backed GraphStore so resolve_graph_store succeeds.
2491        let pool = memory.sqlite().pool().clone();
2492        memory.graph_store = Some(std::sync::Arc::new(zeph_memory::GraphStore::new(pool)));
2493        let cid = memory.sqlite().create_conversation().await.unwrap();
2494        let mut agent = Agent::new(
2495            mock_provider(vec![]),
2496            MockChannel::new(vec![]),
2497            create_test_registry(),
2498            None,
2499            5,
2500            MockToolExecutor::no_tools(),
2501        )
2502        .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
2503        .with_graph_config(cfg);
2504
2505        let mut progress = vec![];
2506        let result = agent
2507            .graph_backfill(Some(10), &mut |msg| progress.push(msg))
2508            .await
2509            .unwrap();
2510
2511        // With an empty store there are zero unprocessed messages → backfill completes immediately.
2512        assert!(
2513            result.contains("Backfill complete"),
2514            "expected 'Backfill complete' but got: {result}"
2515        );
2516    }
2517
2518    // #6261: graph_backfill extracts each batch's unprocessed messages concurrently via
2519    // `futures::stream::iter(...).buffer_unordered(4)` instead of a sequential per-message
2520    // loop. buffer_unordered completes futures in an order that need not match input order, so
2521    // this test asserts on aggregate totals (immune to completion order) and on per-entity /
2522    // per-message presence, proving the concurrent rewrite neither drops nor double-counts
2523    // results relative to the pre-#6261 sequential behavior.
2524    #[tokio::test]
2525    async fn graph_backfill_concurrent_extraction_aggregates_stats_without_dropping_results() {
2526        let n = 6;
2527        let cfg = crate::config::GraphConfig {
2528            enabled: true,
2529            ..Default::default()
2530        };
2531        let mut memory = memory_without_qdrant().await;
2532        let store = install_graph_store(&mut memory);
2533        let cid = memory.sqlite().create_conversation().await.unwrap();
2534
2535        for i in 0..n {
2536            sqlx::query(zeph_db::sql!(
2537                "INSERT INTO messages (conversation_id, role, content) VALUES (?1, 'user', ?2)"
2538            ))
2539            .bind(cid.0)
2540            .bind(format!("message body {i}"))
2541            .execute(memory.sqlite().pool())
2542            .await
2543            .unwrap();
2544        }
2545
2546        // One canned extraction response per message, each yielding exactly one distinct
2547        // entity. MockProvider serves responses in call order (not message order), which
2548        // mirrors buffer_unordered's out-of-order completion.
2549        let responses: Vec<String> = (0..n)
2550            .map(|i| {
2551                format!(
2552                    r#"{{"entities":[{{"name":"Entity{i}","type":"concept","summary":""}}],"edges":[]}}"#
2553                )
2554            })
2555            .collect();
2556
2557        let mut agent = Agent::new(
2558            mock_provider(responses),
2559            MockChannel::new(vec![]),
2560            create_test_registry(),
2561            None,
2562            5,
2563            MockToolExecutor::no_tools(),
2564        )
2565        .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
2566        .with_graph_config(cfg);
2567
2568        let mut progress = vec![];
2569        let result = agent
2570            .graph_backfill(None, &mut |msg| progress.push(msg))
2571            .await
2572            .unwrap();
2573
2574        assert!(
2575            result.contains(&format!("{n} entities")),
2576            "expected all {n} entities aggregated in the result, got: {result}"
2577        );
2578        assert!(
2579            result.contains(&format!("from {n} messages")),
2580            "expected all {n} messages counted as processed, got: {result}"
2581        );
2582
2583        // No drops/double-counts at the store level: every entity must be present exactly once.
2584        for i in 0..n {
2585            let name = format!("entity{i}");
2586            let found = store
2587                .find_entity(&name, zeph_memory::EntityType::Concept)
2588                .await
2589                .unwrap();
2590            assert!(found.is_some(), "entity{i} must have been upserted");
2591        }
2592
2593        // Every message in the batch must be marked processed — none left behind by a
2594        // buffer_unordered future that was dropped or never polled to completion.
2595        let remaining = store.unprocessed_message_count().await.unwrap();
2596        assert_eq!(remaining, 0, "all messages must be marked graph_processed");
2597    }
2598
2599    // #6261 follow-up (impl-critic finding): the aggregation test above uses an in-memory
2600    // SQLite database, which `zeph-db`'s pool forces to a single connection
2601    // (`connect_sqlite`'s `effective_max = if path == ":memory:" { 1 }`, see
2602    // `crates/zeph-db/src/pool.rs`) — so it never actually exercises concurrent writers racing
2603    // for the SQLite write lock. This test uses a real file-backed database instead (default
2604    // pool_size = 5, WAL journal mode + 5s busy_timeout — see `DbConfig::connect_sqlite`) with
2605    // more unprocessed messages than the `buffer_unordered(4)` bound, so multiple pooled
2606    // connections genuinely contend for writes concurrently. It confirms `extract_and_store`'s
2607    // upserts — relying on WAL mode + busy_timeout + `EntityResolver`'s per-entity-name locking,
2608    // the same assumption `semantic_scan_plugin_add`'s existing `buffer_unordered(4)` usage
2609    // relies on — complete without a "database is locked" error under real multi-connection
2610    // write contention.
2611    #[tokio::test]
2612    async fn graph_backfill_concurrent_extraction_survives_real_sqlite_write_contention() {
2613        let n = 8;
2614        let tmp = tempfile::NamedTempFile::new().expect("tempfile");
2615        let path = tmp.path().to_str().expect("valid utf-8 path").to_owned();
2616
2617        let cfg = crate::config::GraphConfig {
2618            enabled: true,
2619            ..Default::default()
2620        };
2621        let mut memory = SemanticMemory::new(
2622            &path,
2623            "http://127.0.0.1:1",
2624            None,
2625            zeph_llm::any::AnyProvider::Mock(zeph_llm::mock::MockProvider::default()),
2626            "test-model",
2627        )
2628        .await
2629        .unwrap();
2630        let store = install_graph_store(&mut memory);
2631        let cid = memory.sqlite().create_conversation().await.unwrap();
2632
2633        for i in 0..n {
2634            sqlx::query(zeph_db::sql!(
2635                "INSERT INTO messages (conversation_id, role, content) VALUES (?1, 'user', ?2)"
2636            ))
2637            .bind(cid.0)
2638            .bind(format!("contention message body {i}"))
2639            .execute(memory.sqlite().pool())
2640            .await
2641            .unwrap();
2642        }
2643
2644        // A small per-call delay forces the (up to 4) concurrently in-flight extraction futures
2645        // to genuinely overlap their subsequent SQLite writes, rather than happening to resolve
2646        // one at a time fast enough to never actually race.
2647        let responses: Vec<String> = (0..n)
2648            .map(|i| {
2649                format!(
2650                    r#"{{"entities":[{{"name":"ContentionEntity{i}","type":"concept","summary":""}}],"edges":[]}}"#
2651                )
2652            })
2653            .collect();
2654        let mut provider = zeph_llm::mock::MockProvider::with_responses(responses);
2655        provider.delay_ms = 15;
2656        let provider = zeph_llm::any::AnyProvider::Mock(provider);
2657
2658        let mut agent = Agent::new(
2659            provider,
2660            MockChannel::new(vec![]),
2661            create_test_registry(),
2662            None,
2663            5,
2664            MockToolExecutor::no_tools(),
2665        )
2666        .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
2667        .with_graph_config(cfg);
2668
2669        let mut progress = vec![];
2670        let result = agent
2671            .graph_backfill(None, &mut |msg| progress.push(msg))
2672            .await
2673            .unwrap();
2674
2675        assert!(
2676            result.contains(&format!("{n} entities")),
2677            "expected all {n} entities aggregated despite concurrent SQLite writers, got: {result}"
2678        );
2679
2680        // The decisive assertion: if a concurrent writer had hit "database is locked"
2681        // (SQLITE_BUSY surfacing as an error instead of the busy_timeout retry succeeding),
2682        // extract_and_store logs a warning and skips that message's upsert (the `Err(e) =>
2683        // tracing::warn!(...)` arm in graph_backfill) rather than failing the whole batch — so a
2684        // missing entity here is the observable symptom of exactly the failure mode flagged.
2685        for i in 0..n {
2686            let name = format!("contentionentity{i}");
2687            let found = store
2688                .find_entity(&name, zeph_memory::EntityType::Concept)
2689                .await
2690                .unwrap();
2691            assert!(
2692                found.is_some(),
2693                "entity {i} must have been upserted; a missing entity indicates a dropped/failed \
2694                 concurrent write (e.g. a 'database is locked' error) under real multi-connection \
2695                 contention"
2696            );
2697        }
2698
2699        let remaining = store.unprocessed_message_count().await.unwrap();
2700        assert_eq!(remaining, 0, "all messages must be marked graph_processed");
2701    }
2702
2703    // R-4139: graph_entities with enabled graph but no store (Qdrant unreachable) must
2704    // report unavailable, not panic or hang.
2705    #[tokio::test]
2706    async fn graph_entities_enabled_but_no_store_reports_unavailable() {
2707        let cfg = crate::config::GraphConfig {
2708            enabled: true,
2709            ..Default::default()
2710        };
2711        let memory = memory_without_qdrant().await;
2712        let cid = memory.sqlite().create_conversation().await.unwrap();
2713        let mut agent = Agent::new(
2714            mock_provider(vec![]),
2715            MockChannel::new(vec![]),
2716            create_test_registry(),
2717            None,
2718            5,
2719            MockToolExecutor::no_tools(),
2720        )
2721        .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
2722        .with_graph_config(cfg);
2723
2724        let result = agent.graph_entities().await.unwrap();
2725        assert!(
2726            result.contains("unavailable"),
2727            "expected 'unavailable' but got: {result}"
2728        );
2729    }
2730
2731    // R-4139: graph_communities with enabled graph but no store must report unavailable.
2732    #[tokio::test]
2733    async fn graph_communities_enabled_but_no_store_reports_unavailable() {
2734        let cfg = crate::config::GraphConfig {
2735            enabled: true,
2736            ..Default::default()
2737        };
2738        let memory = memory_without_qdrant().await;
2739        let cid = memory.sqlite().create_conversation().await.unwrap();
2740        let mut agent = Agent::new(
2741            mock_provider(vec![]),
2742            MockChannel::new(vec![]),
2743            create_test_registry(),
2744            None,
2745            5,
2746            MockToolExecutor::no_tools(),
2747        )
2748        .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
2749        .with_graph_config(cfg);
2750
2751        let result = agent.graph_communities().await.unwrap();
2752        assert!(
2753            result.contains("unavailable"),
2754            "expected 'unavailable' but got: {result}"
2755        );
2756    }
2757
2758    // R-4139: verify that the tokio::time::timeout pattern used in graph handlers
2759    // correctly returns Err on a never-resolving future. This is a direct regression
2760    // guard for the fix introduced in #4139: before the fix, these calls had no
2761    // timeout guard and would block indefinitely when Qdrant was unreachable.
2762    #[tokio::test]
2763    async fn graph_store_timeout_pattern_fires_on_pending_future() {
2764        use std::future;
2765        let result = tokio::time::timeout(
2766            Duration::from_millis(10),
2767            future::pending::<Result<Vec<()>, String>>(),
2768        )
2769        .await;
2770        assert!(
2771            result.is_err(),
2772            "timeout must fire on a never-resolving future"
2773        );
2774    }
2775
2776    // ── #5770: with_graph_store_timeout had zero coverage of its own timeout branch —
2777    // existing tests only reached the "no store configured" short-circuit in
2778    // resolve_graph_store(), never the 5s deadline shared by resolve_entity_by_name,
2779    // graph_facts, and graph_history. Exercise the extracted helper directly with a
2780    // paused clock so the deadline fires deterministically without a real wall-clock wait.
2781
2782    #[tokio::test]
2783    async fn with_graph_store_timeout_completes_on_success() {
2784        let result = with_graph_store_timeout(async { Ok::<_, zeph_memory::MemoryError>(42) })
2785            .await
2786            .unwrap();
2787        assert!(matches!(result, StoreCallOutcome::Completed(42)));
2788    }
2789
2790    #[tokio::test]
2791    async fn with_graph_store_timeout_maps_store_error_to_command_error() {
2792        let result = with_graph_store_timeout(async {
2793            Err::<i32, _>(zeph_memory::MemoryError::GraphStore("boom".to_owned()))
2794        })
2795        .await;
2796        assert!(result.is_err(), "store error must surface as CommandError");
2797    }
2798
2799    #[tokio::test]
2800    async fn with_graph_store_timeout_times_out_on_pending_future() {
2801        tokio::time::pause();
2802        let fut = with_graph_store_timeout(std::future::pending::<
2803            Result<i32, zeph_memory::MemoryError>,
2804        >());
2805        let handle = tokio::spawn(fut); // EXEMPT: test-only tokio::time::pause harness
2806        tokio::time::advance(std::time::Duration::from_secs(6)).await;
2807        let result = handle.await.expect("task panicked");
2808        assert!(
2809            matches!(result, Ok(StoreCallOutcome::TimedOut)),
2810            "call must resolve to TimedOut once the 5s deadline elapses"
2811        );
2812    }
2813
2814    // ── #5764: graph_facts / graph_history had zero dedicated test coverage ──────
2815
2816    /// Installs a real SQLite-backed `GraphStore` on `memory` (mirrors
2817    /// `graph_backfill_with_extract_provider_resolves_without_panic`), returning an `Arc`
2818    /// clone so callers can seed entities/edges before handing `memory` to `with_memory`.
2819    fn install_graph_store(memory: &mut SemanticMemory) -> std::sync::Arc<zeph_memory::GraphStore> {
2820        let pool = memory.sqlite().pool().clone();
2821        let store = std::sync::Arc::new(zeph_memory::GraphStore::new(pool));
2822        memory.graph_store = Some(store.clone());
2823        store
2824    }
2825
2826    #[tokio::test]
2827    async fn graph_facts_happy_path_returns_formatted_facts() {
2828        let mut memory = memory_without_qdrant().await;
2829        let store = install_graph_store(&mut memory);
2830        let cid = memory.sqlite().create_conversation().await.unwrap();
2831
2832        let alice = store
2833            .upsert_entity(
2834                "Alice",
2835                "alice",
2836                zeph_memory::EntityType::Person,
2837                None,
2838                None,
2839            )
2840            .await
2841            .unwrap();
2842        let bob = store
2843            .upsert_entity("Bob", "bob", zeph_memory::EntityType::Person, None, None)
2844            .await
2845            .unwrap();
2846        store
2847            .insert_edge(alice.0, bob.0, "knows", "Alice knows Bob", 0.9, None, None)
2848            .await
2849            .unwrap();
2850
2851        let cfg = crate::config::GraphConfig {
2852            enabled: true,
2853            ..Default::default()
2854        };
2855        let mut agent = Agent::new(
2856            mock_provider(vec![]),
2857            MockChannel::new(vec![]),
2858            create_test_registry(),
2859            None,
2860            5,
2861            MockToolExecutor::no_tools(),
2862        )
2863        .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
2864        .with_graph_config(cfg);
2865
2866        let result = agent.graph_facts("Alice").await.unwrap();
2867        assert!(
2868            result.contains("Facts for 'Alice'"),
2869            "expected facts header, got: {result}"
2870        );
2871        assert!(
2872            result.contains("Bob"),
2873            "expected target entity name, got: {result}"
2874        );
2875        assert!(result.contains("knows"), "expected relation, got: {result}");
2876        assert!(
2877            result.contains("Alice knows Bob"),
2878            "expected fact text, got: {result}"
2879        );
2880    }
2881
2882    #[tokio::test]
2883    async fn graph_facts_entity_not_found_returns_message() {
2884        let mut memory = memory_without_qdrant().await;
2885        install_graph_store(&mut memory);
2886        let cid = memory.sqlite().create_conversation().await.unwrap();
2887
2888        let cfg = crate::config::GraphConfig {
2889            enabled: true,
2890            ..Default::default()
2891        };
2892        let mut agent = Agent::new(
2893            mock_provider(vec![]),
2894            MockChannel::new(vec![]),
2895            create_test_registry(),
2896            None,
2897            5,
2898            MockToolExecutor::no_tools(),
2899        )
2900        .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
2901        .with_graph_config(cfg);
2902
2903        let result = agent.graph_facts("Nobody").await.unwrap();
2904        assert_eq!(result, "No entity found matching 'Nobody'.");
2905    }
2906
2907    // Mirrors graph_entities_enabled_but_no_store_reports_unavailable (R-4139): when the graph
2908    // store is None (Qdrant unreachable) but graph is enabled, report unavailable rather than
2909    // hang or panic.
2910    #[tokio::test]
2911    async fn graph_facts_enabled_but_no_store_reports_unavailable() {
2912        let cfg = crate::config::GraphConfig {
2913            enabled: true,
2914            ..Default::default()
2915        };
2916        let memory = memory_without_qdrant().await;
2917        let cid = memory.sqlite().create_conversation().await.unwrap();
2918        let mut agent = Agent::new(
2919            mock_provider(vec![]),
2920            MockChannel::new(vec![]),
2921            create_test_registry(),
2922            None,
2923            5,
2924            MockToolExecutor::no_tools(),
2925        )
2926        .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
2927        .with_graph_config(cfg);
2928
2929        let result = agent.graph_facts("Alice").await.unwrap();
2930        assert!(
2931            result.contains("unavailable"),
2932            "expected 'unavailable' but got: {result}"
2933        );
2934    }
2935
2936    // Self-loop edges (source == target) are rejected both by `GraphStore::insert_edge_typed`
2937    // and by a DB-level trigger (migration 044_graph_edges_no_self_loops) — so a real one can
2938    // only arise from data written before that migration. Drop the trigger to simulate that
2939    // legacy row and confirm graph_facts' defensive `entity_names` bookkeeping (which already
2940    // knows the entity's own name before resolving edge endpoints) handles it without panicking
2941    // or falling back to a raw `#id` placeholder.
2942    #[tokio::test]
2943    async fn graph_facts_self_loop_edge_does_not_panic() {
2944        let mut memory = memory_without_qdrant().await;
2945        let store = install_graph_store(&mut memory);
2946        let cid = memory.sqlite().create_conversation().await.unwrap();
2947
2948        let self_entity = store
2949            .upsert_entity("Self", "self", zeph_memory::EntityType::Concept, None, None)
2950            .await
2951            .unwrap();
2952        let pool = memory.sqlite().pool().clone();
2953        zeph_db::query(zeph_db::sql!(
2954            "DROP TRIGGER IF EXISTS graph_edges_no_self_loops"
2955        ))
2956        .execute(&pool)
2957        .await
2958        .unwrap();
2959        zeph_db::query(zeph_db::sql!(
2960            "INSERT INTO graph_edges (source_entity_id, target_entity_id, relation, fact, confidence) \
2961             VALUES (?, ?, ?, ?, ?)"
2962        ))
2963        .bind(self_entity.0)
2964        .bind(self_entity.0)
2965        .bind("refers_to")
2966        .bind("Self refers to itself")
2967        .bind(1.0_f64)
2968        .execute(&pool)
2969        .await
2970        .unwrap();
2971
2972        let cfg = crate::config::GraphConfig {
2973            enabled: true,
2974            ..Default::default()
2975        };
2976        let mut agent = Agent::new(
2977            mock_provider(vec![]),
2978            MockChannel::new(vec![]),
2979            create_test_registry(),
2980            None,
2981            5,
2982            MockToolExecutor::no_tools(),
2983        )
2984        .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
2985        .with_graph_config(cfg);
2986
2987        let result = agent.graph_facts("Self").await.unwrap();
2988        assert!(
2989            result.contains("Facts for 'Self'"),
2990            "expected facts header, got: {result}"
2991        );
2992        assert!(
2993            result.contains("refers_to"),
2994            "expected self-loop relation, got: {result}"
2995        );
2996        assert!(
2997            !result.contains('#'),
2998            "self-loop endpoint must resolve to the entity's own name, not a raw #id \
2999             placeholder: {result}"
3000        );
3001    }
3002
3003    #[tokio::test]
3004    async fn graph_history_happy_path_returns_formatted_history() {
3005        let mut memory = memory_without_qdrant().await;
3006        let store = install_graph_store(&mut memory);
3007        let cid = memory.sqlite().create_conversation().await.unwrap();
3008
3009        let alice = store
3010            .upsert_entity(
3011                "Alice",
3012                "alice",
3013                zeph_memory::EntityType::Person,
3014                None,
3015                None,
3016            )
3017            .await
3018            .unwrap();
3019        let bob = store
3020            .upsert_entity("Bob", "bob", zeph_memory::EntityType::Person, None, None)
3021            .await
3022            .unwrap();
3023        store
3024            .insert_edge(alice.0, bob.0, "knows", "Alice knows Bob", 0.9, None, None)
3025            .await
3026            .unwrap();
3027
3028        let cfg = crate::config::GraphConfig {
3029            enabled: true,
3030            ..Default::default()
3031        };
3032        let mut agent = Agent::new(
3033            mock_provider(vec![]),
3034            MockChannel::new(vec![]),
3035            create_test_registry(),
3036            None,
3037            5,
3038            MockToolExecutor::no_tools(),
3039        )
3040        .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
3041        .with_graph_config(cfg);
3042
3043        let result = agent.graph_history("Alice").await.unwrap();
3044        assert!(
3045            result.contains("Edge history for 'Alice'"),
3046            "expected history header, got: {result}"
3047        );
3048        assert!(
3049            result.contains("[active]"),
3050            "expected active tag, got: {result}"
3051        );
3052        assert!(
3053            result.contains("Bob"),
3054            "expected target entity name, got: {result}"
3055        );
3056    }
3057
3058    #[tokio::test]
3059    async fn graph_history_entity_not_found_returns_message() {
3060        let mut memory = memory_without_qdrant().await;
3061        install_graph_store(&mut memory);
3062        let cid = memory.sqlite().create_conversation().await.unwrap();
3063
3064        let cfg = crate::config::GraphConfig {
3065            enabled: true,
3066            ..Default::default()
3067        };
3068        let mut agent = Agent::new(
3069            mock_provider(vec![]),
3070            MockChannel::new(vec![]),
3071            create_test_registry(),
3072            None,
3073            5,
3074            MockToolExecutor::no_tools(),
3075        )
3076        .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
3077        .with_graph_config(cfg);
3078
3079        let result = agent.graph_history("Nobody").await.unwrap();
3080        assert_eq!(result, "No entity found matching 'Nobody'.");
3081    }
3082
3083    #[tokio::test]
3084    async fn graph_history_enabled_but_no_store_reports_unavailable() {
3085        let cfg = crate::config::GraphConfig {
3086            enabled: true,
3087            ..Default::default()
3088        };
3089        let memory = memory_without_qdrant().await;
3090        let cid = memory.sqlite().create_conversation().await.unwrap();
3091        let mut agent = Agent::new(
3092            mock_provider(vec![]),
3093            MockChannel::new(vec![]),
3094            create_test_registry(),
3095            None,
3096            5,
3097            MockToolExecutor::no_tools(),
3098        )
3099        .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
3100        .with_graph_config(cfg);
3101
3102        let result = agent.graph_history("Alice").await.unwrap();
3103        assert!(
3104            result.contains("unavailable"),
3105            "expected 'unavailable' but got: {result}"
3106        );
3107    }
3108
3109    // See graph_facts_self_loop_edge_does_not_panic for why the DB trigger must be dropped.
3110    #[tokio::test]
3111    async fn graph_history_self_loop_edge_does_not_panic() {
3112        let mut memory = memory_without_qdrant().await;
3113        let store = install_graph_store(&mut memory);
3114        let cid = memory.sqlite().create_conversation().await.unwrap();
3115
3116        let self_entity = store
3117            .upsert_entity("Self", "self", zeph_memory::EntityType::Concept, None, None)
3118            .await
3119            .unwrap();
3120        let pool = memory.sqlite().pool().clone();
3121        zeph_db::query(zeph_db::sql!(
3122            "DROP TRIGGER IF EXISTS graph_edges_no_self_loops"
3123        ))
3124        .execute(&pool)
3125        .await
3126        .unwrap();
3127        zeph_db::query(zeph_db::sql!(
3128            "INSERT INTO graph_edges (source_entity_id, target_entity_id, relation, fact, confidence) \
3129             VALUES (?, ?, ?, ?, ?)"
3130        ))
3131        .bind(self_entity.0)
3132        .bind(self_entity.0)
3133        .bind("refers_to")
3134        .bind("Self refers to itself")
3135        .bind(1.0_f64)
3136        .execute(&pool)
3137        .await
3138        .unwrap();
3139
3140        let cfg = crate::config::GraphConfig {
3141            enabled: true,
3142            ..Default::default()
3143        };
3144        let mut agent = Agent::new(
3145            mock_provider(vec![]),
3146            MockChannel::new(vec![]),
3147            create_test_registry(),
3148            None,
3149            5,
3150            MockToolExecutor::no_tools(),
3151        )
3152        .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
3153        .with_graph_config(cfg);
3154
3155        let result = agent.graph_history("Self").await.unwrap();
3156        assert!(
3157            result.contains("Edge history for 'Self'"),
3158            "expected history header, got: {result}"
3159        );
3160        assert!(
3161            result.contains("refers_to"),
3162            "expected self-loop relation, got: {result}"
3163        );
3164        assert!(
3165            !result.contains('#'),
3166            "self-loop endpoint must resolve to the entity's own name, not a raw #id \
3167             placeholder: {result}"
3168        );
3169    }
3170
3171    // R-4706/R-4709: when semantic_scan is enabled but semantic_scan_provider is empty,
3172    // `plugin add` must return a CommandError immediately (fail-closed). Before this fix
3173    // the code fell through to resolve_background_provider which silently used the primary
3174    // provider, bypassing the intent that an unconfigured scanner means "do not proceed".
3175    #[tokio::test]
3176    async fn plugin_add_semantic_scan_enabled_empty_provider_returns_error() {
3177        let mut agent = Agent::new(
3178            mock_provider(vec![]),
3179            MockChannel::new(vec![]),
3180            create_test_registry(),
3181            None,
3182            5,
3183            MockToolExecutor::no_tools(),
3184        )
3185        .with_semantic_scan(true, "");
3186
3187        let result = agent.handle_plugins("add some-plugin").await;
3188        assert!(
3189            result.is_err(),
3190            "expected CommandError for missing semantic_scan_provider, got: {result:?}"
3191        );
3192        let msg = result.unwrap_err().to_string();
3193        assert!(
3194            msg.contains("semantic_scan_provider"),
3195            "error message must mention semantic_scan_provider, got: {msg}"
3196        );
3197    }
3198
3199    // R-4706/R-4709: when semantic_scan is disabled, plugin subcommands must proceed
3200    // normally regardless of whether semantic_scan_provider is set.
3201    #[tokio::test]
3202    async fn plugin_list_semantic_scan_disabled_succeeds() {
3203        let mut agent = Agent::new(
3204            mock_provider(vec![]),
3205            MockChannel::new(vec![]),
3206            create_test_registry(),
3207            None,
3208            5,
3209            MockToolExecutor::no_tools(),
3210        )
3211        .with_semantic_scan(false, "");
3212
3213        // "list" does not trigger scan logic; it should succeed without error.
3214        let result = agent.handle_plugins("list").await;
3215        assert!(
3216            result.is_ok(),
3217            "plugin list must succeed when semantic_scan is disabled, got: {result:?}"
3218        );
3219    }
3220
3221    // R-4706/R-4709: "plugin add" with semantic_scan disabled must reach the install path
3222    // rather than return a scan-related error. The install itself may fail (no real plugin
3223    // source), but it must NOT fail with the fail-closed error message.
3224    #[tokio::test]
3225    async fn plugin_add_semantic_scan_disabled_no_scan_error() {
3226        let mut agent = Agent::new(
3227            mock_provider(vec![]),
3228            MockChannel::new(vec![]),
3229            create_test_registry(),
3230            None,
3231            5,
3232            MockToolExecutor::no_tools(),
3233        )
3234        .with_semantic_scan(false, "");
3235
3236        let result = agent.handle_plugins("add some-plugin").await;
3237        // The call may succeed or fail for unrelated reasons (no real plugin source),
3238        // but must NOT fail with the fail-closed error about semantic_scan_provider.
3239        if let Err(ref e) = result {
3240            assert!(
3241                !e.to_string().contains("semantic_scan_provider"),
3242                "must not fail with scan error when semantic_scan is disabled, got: {e}"
3243            );
3244        }
3245    }
3246
3247    // R-4705: semantic_scan_plugin_add must scan all skills concurrently and return
3248    // None when every scanner call returns Allow. Verifies buffer_unordered path
3249    // processes N inputs without sequential bottleneck.
3250    #[tokio::test]
3251    async fn semantic_scan_plugin_add_concurrent_all_allow_returns_none() {
3252        use zeph_llm::any::AnyProvider;
3253        use zeph_llm::mock::MockProvider;
3254        use zeph_skills::semantic_scanner::SkillSemanticScanner;
3255
3256        // MockProvider returns `{"verdict":"allow","reason":"ok"}` for every call.
3257        let allow_json = r#"{"verdict":"allow","reason":"ok"}"#.to_owned();
3258        let provider = AnyProvider::Mock(MockProvider::with_responses(vec![
3259            allow_json.clone(),
3260            allow_json,
3261        ]));
3262        let scanner = SkillSemanticScanner::new(provider);
3263
3264        // Build a minimal plugin layout with two skills so scan_targets returns
3265        // two SkillScanInput entries.
3266        let tmp = tempfile::tempdir().unwrap();
3267        let plugin_toml = r#"
3268[plugin]
3269name = "test-plugin"
3270version = "0.1.0"
3271description = "test"
3272
3273[[skills]]
3274path = "skill-a"
3275
3276[[skills]]
3277path = "skill-b"
3278"#;
3279        std::fs::write(tmp.path().join("plugin.toml"), plugin_toml).unwrap();
3280        for name in ["skill-a", "skill-b"] {
3281            let skill_dir = tmp.path().join(name);
3282            std::fs::create_dir_all(&skill_dir).unwrap();
3283            std::fs::write(
3284                skill_dir.join("SKILL.md"),
3285                format!("# {name}\n\n## Purpose\nTest skill.\n"),
3286            )
3287            .unwrap();
3288        }
3289
3290        let result =
3291            semantic_scan_plugin_add(&scanner, tmp.path().to_str().unwrap(), None, vec![], vec![])
3292                .await;
3293
3294        // All skills allowed → no error message returned.
3295        assert!(result.is_ok(), "expected Ok, got: {result:?}");
3296        assert!(
3297            result.unwrap().is_none(),
3298            "expected None (all passed) but got Some(err)"
3299        );
3300    }
3301
3302    // R-4705 regression: buffer_unordered yields in completion order, not input order.
3303    // A Block verdict on the *second* skill (index 1) must name that second skill, not the
3304    // first. Before the fix, the code zipped verdicts against scan_inputs by position and
3305    // discarded the tuple's skill_name, so the wrong skill was reported.
3306    #[tokio::test]
3307    async fn semantic_scan_plugin_add_block_names_correct_skill() {
3308        use zeph_llm::any::AnyProvider;
3309        use zeph_llm::mock::MockProvider;
3310        use zeph_skills::semantic_scanner::SkillSemanticScanner;
3311
3312        // First call returns Allow, second returns Block — only the second skill is rejected.
3313        let allow_json = r#"{"verdict":"allow","reason":"ok"}"#.to_owned();
3314        let block_json = r#"{"verdict":"block","reason":"malicious"}"#.to_owned();
3315        let provider =
3316            AnyProvider::Mock(MockProvider::with_responses(vec![allow_json, block_json]));
3317        let scanner = SkillSemanticScanner::new(provider);
3318
3319        let tmp = tempfile::tempdir().unwrap();
3320        let plugin_toml = r#"
3321[plugin]
3322name = "test-plugin-block"
3323version = "0.1.0"
3324description = "test"
3325
3326[[skills]]
3327path = "skill-first"
3328
3329[[skills]]
3330path = "skill-second"
3331"#;
3332        std::fs::write(tmp.path().join("plugin.toml"), plugin_toml).unwrap();
3333        for name in ["skill-first", "skill-second"] {
3334            let skill_dir = tmp.path().join(name);
3335            std::fs::create_dir_all(&skill_dir).unwrap();
3336            std::fs::write(
3337                skill_dir.join("SKILL.md"),
3338                format!("# {name}\n\n## Purpose\nTest skill.\n"),
3339            )
3340            .unwrap();
3341        }
3342
3343        let result =
3344            semantic_scan_plugin_add(&scanner, tmp.path().to_str().unwrap(), None, vec![], vec![])
3345                .await;
3346
3347        assert!(result.is_ok(), "expected Ok(_), got: {result:?}");
3348        let msg = result
3349            .unwrap()
3350            .expect("expected Some(err) for blocked skill");
3351        assert!(
3352            msg.contains("skill-second"),
3353            "rejection must name the blocked skill 'skill-second', got: {msg}"
3354        );
3355        assert!(
3356            !msg.contains("skill-first"),
3357            "rejection must NOT name the allowed skill 'skill-first', got: {msg}"
3358        );
3359    }
3360
3361    // R-4706/R-4709: unknown provider name must also fail-closed rather than silently
3362    // falling back to the primary provider via resolve_background_provider.
3363    #[tokio::test]
3364    async fn plugin_add_semantic_scan_unknown_provider_returns_error() {
3365        let mut agent = Agent::new(
3366            mock_provider(vec![]),
3367            MockChannel::new(vec![]),
3368            create_test_registry(),
3369            None,
3370            5,
3371            MockToolExecutor::no_tools(),
3372        )
3373        .with_semantic_scan(true, "nonexistent_provider");
3374
3375        let result = agent.handle_plugins("add some-plugin").await;
3376        assert!(
3377            result.is_err(),
3378            "expected CommandError for unknown semantic_scan_provider, got: {result:?}"
3379        );
3380        let msg = result.unwrap_err().to_string();
3381        assert!(
3382            msg.contains("semantic_scan_provider"),
3383            "error message must mention semantic_scan_provider, got: {msg}"
3384        );
3385    }
3386
3387    // #5487 fix 3: `handle_conv_resume` had zero prior test coverage. Resuming into the
3388    // session already live in this agent must short-circuit before attempting to re-acquire
3389    // the exclusive lock this agent's own SessionSink already holds (a guaranteed
3390    // `AlreadyLocked` self-deadlock, since flock conflicts are per open-file-description, not
3391    // per-process).
3392    #[tokio::test]
3393    async fn handle_conv_resume_same_session_short_circuits() {
3394        let memory = memory_without_qdrant().await;
3395        let cid = memory.sqlite().create_conversation().await.unwrap();
3396        let dir = tempfile::tempdir().unwrap();
3397        let data_dir = dir.path().to_path_buf();
3398        let session_id = zeph_common::SessionId::new("s1");
3399        let session_path = zeph_session::session_dir(&data_dir, session_id.as_str());
3400        let log = zeph_session::SessionEventLog::open_exclusive(&session_path)
3401            .await
3402            .unwrap();
3403        let store = zeph_session::SessionStore::new(memory.sqlite().pool().clone());
3404        let sink = zeph_agent_persistence::SessionSink::new(
3405            std::sync::Arc::new(log),
3406            store,
3407            session_id.clone(),
3408        );
3409        let session_config = zeph_config::SessionConfig {
3410            enabled: true,
3411            data_dir: data_dir.to_string_lossy().into_owned(),
3412            ..Default::default()
3413        };
3414
3415        let mut agent = Agent::new(
3416            mock_provider(vec![]),
3417            MockChannel::new(vec![]),
3418            create_test_registry(),
3419            None,
3420            5,
3421            MockToolExecutor::no_tools(),
3422        )
3423        .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
3424        .with_session_sink(Some(std::sync::Arc::new(sink)))
3425        .with_session_persistence_config(Some(session_config));
3426
3427        let result = agent.handle_conv("resume s1").await.unwrap();
3428        assert_eq!(
3429            result, "Already in session 's1'.",
3430            "resuming into the currently-active session must short-circuit, not attempt \
3431             hydration/lock acquisition"
3432        );
3433    }
3434
3435    // Regression check for the guard above: resuming into a genuinely different session (not
3436    // the one already live in this agent) must still hydrate normally.
3437    #[tokio::test]
3438    async fn handle_conv_resume_different_session_still_hydrates() {
3439        let memory = memory_without_qdrant().await;
3440        let cid = memory.sqlite().create_conversation().await.unwrap();
3441        let dir = tempfile::tempdir().unwrap();
3442        let data_dir = dir.path().to_path_buf();
3443
3444        // Agent is currently "in" session s1, whose own lock is held by its SessionSink.
3445        let active_session_id = zeph_common::SessionId::new("s1");
3446        let active_session_path = zeph_session::session_dir(&data_dir, active_session_id.as_str());
3447        let active_log = zeph_session::SessionEventLog::open_exclusive(&active_session_path)
3448            .await
3449            .unwrap();
3450        let active_store = zeph_session::SessionStore::new(memory.sqlite().pool().clone());
3451        let active_sink = zeph_agent_persistence::SessionSink::new(
3452            std::sync::Arc::new(active_log),
3453            active_store,
3454            active_session_id,
3455        );
3456
3457        // Target session s2 exists in the store (unlocked directory) — this is what should be
3458        // resumed into.
3459        let store = zeph_session::SessionStore::new(memory.sqlite().pool().clone());
3460        store.create("s2").await.unwrap();
3461
3462        let session_config = zeph_config::SessionConfig {
3463            enabled: true,
3464            data_dir: data_dir.to_string_lossy().into_owned(),
3465            ..Default::default()
3466        };
3467
3468        let mut agent = Agent::new(
3469            mock_provider(vec![]),
3470            MockChannel::new(vec![]),
3471            create_test_registry(),
3472            None,
3473            5,
3474            MockToolExecutor::no_tools(),
3475        )
3476        .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
3477        .with_session_sink(Some(std::sync::Arc::new(active_sink)))
3478        .with_session_persistence_config(Some(session_config));
3479
3480        let result = agent.handle_conv("resume s2").await.unwrap();
3481        assert!(
3482            result.starts_with("Resumed session s2"),
3483            "resuming into a different, unlocked session must still hydrate normally, got: {result}"
3484        );
3485    }
3486
3487    // #5764: `/conv fork` had zero test coverage — only `/conv resume` was tested above.
3488    // Forks session "s1" into a fresh child and confirms the agent live-swaps onto it.
3489    #[tokio::test]
3490    async fn handle_conv_fork_creates_child_session_and_switches_to_it() {
3491        let memory = memory_without_qdrant().await;
3492        let cid = memory.sqlite().create_conversation().await.unwrap();
3493        let dir = tempfile::tempdir().unwrap();
3494        let data_dir = dir.path().to_path_buf();
3495
3496        let store = zeph_session::SessionStore::new(memory.sqlite().pool().clone());
3497        store.create("s1").await.unwrap();
3498        let src_dir = zeph_session::session_dir(&data_dir, "s1");
3499        let log = zeph_session::SessionEventLog::open(&src_dir).await.unwrap();
3500        log.append(
3501            None,
3502            None,
3503            zeph_session::SessionEvent::SessionStarted {
3504                session_id: "s1".to_owned(),
3505                cwd: "/repo".to_owned(),
3506                provider_name: "claude".to_owned(),
3507                model: "opus".to_owned(),
3508                forked_from: None,
3509            },
3510        )
3511        .await
3512        .unwrap();
3513        store
3514            .update_seq("s1", log.last_seq().unwrap(), 1)
3515            .await
3516            .unwrap();
3517        drop(log);
3518
3519        let session_config = zeph_config::SessionConfig {
3520            enabled: true,
3521            data_dir: data_dir.to_string_lossy().into_owned(),
3522            ..Default::default()
3523        };
3524
3525        let mut agent = Agent::new(
3526            mock_provider(vec![]),
3527            MockChannel::new(vec![]),
3528            create_test_registry(),
3529            None,
3530            5,
3531            MockToolExecutor::no_tools(),
3532        )
3533        .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
3534        .with_session_persistence_config(Some(session_config));
3535
3536        let result = agent.handle_conv("fork s1").await.unwrap();
3537        assert!(
3538            result.starts_with("Forked session s1 ->"),
3539            "expected fork confirmation message, got: {result}"
3540        );
3541        assert!(
3542            result.contains("event(s) copied"),
3543            "expected copied-event count in confirmation, got: {result}"
3544        );
3545    }
3546
3547    // `AgentAccess::load_image` had zero direct coverage — only
3548    // `Agent::handle_image_as_string` (slash_commands.rs) and `cli.rs`'s inline check
3549    // were tested. These exercise the real `Agent<C>` impl via the trait.
3550
3551    #[tokio::test]
3552    async fn load_image_rejects_absolute_path() {
3553        let mut agent = Agent::new(
3554            mock_provider(vec![]),
3555            MockChannel::new(vec![]),
3556            create_test_registry(),
3557            None,
3558            5,
3559            MockToolExecutor::no_tools(),
3560        );
3561
3562        let result = AgentAccess::load_image(&mut agent, "/etc/passwd")
3563            .await
3564            .unwrap();
3565        assert!(result.contains("absolute paths are not supported"));
3566    }
3567
3568    #[tokio::test]
3569    async fn load_image_rejects_parent_dir_traversal() {
3570        let mut agent = Agent::new(
3571            mock_provider(vec![]),
3572            MockChannel::new(vec![]),
3573            create_test_registry(),
3574            None,
3575            5,
3576            MockToolExecutor::no_tools(),
3577        );
3578
3579        let result = AgentAccess::load_image(&mut agent, "../../etc/passwd")
3580            .await
3581            .unwrap();
3582        assert!(result.contains("path traversal") && result.contains("not allowed"));
3583    }
3584
3585    // ── /think-tokens, /reasoning-effort (#3098) ─────────────────────────
3586
3587    fn claude_agent() -> Agent<MockChannel> {
3588        let provider = zeph_llm::any::AnyProvider::Claude(zeph_llm::claude::ClaudeProvider::new(
3589            "key".into(),
3590            "claude-sonnet-5".into(),
3591            4096,
3592        ));
3593        Agent::new(
3594            provider,
3595            MockChannel::new(vec![]),
3596            create_test_registry(),
3597            None,
3598            5,
3599            MockToolExecutor::no_tools(),
3600        )
3601    }
3602
3603    #[tokio::test]
3604    async fn handle_think_tokens_empty_arg_displays_off_by_default() {
3605        let mut agent = claude_agent();
3606        let out = agent.handle_think_tokens("").await;
3607        assert!(out.contains("off"), "{out}");
3608        assert!(out.contains("claude"), "{out}");
3609    }
3610
3611    #[tokio::test]
3612    async fn handle_think_tokens_sets_and_displays_budget() {
3613        let mut agent = claude_agent();
3614        let set = agent.handle_think_tokens("8k").await;
3615        assert!(set.contains("8000"), "{set}");
3616
3617        let show = agent.handle_think_tokens("").await;
3618        assert!(show.contains("8000"), "{show}");
3619    }
3620
3621    #[tokio::test]
3622    async fn handle_think_tokens_off_disables() {
3623        let mut agent = claude_agent();
3624        agent.handle_think_tokens("8k").await;
3625        let out = agent.handle_think_tokens("off").await;
3626        assert!(out.contains("disabled"), "{out}");
3627        assert!(agent.provider.current_thinking_budget().is_none());
3628    }
3629
3630    #[tokio::test]
3631    async fn handle_think_tokens_invalid_parse_returns_error_no_mutation() {
3632        let mut agent = claude_agent();
3633        let out = agent.handle_think_tokens("1.2.3k").await;
3634        assert!(out.contains("think-tokens"), "{out}");
3635        assert!(agent.provider.current_thinking_budget().is_none());
3636    }
3637
3638    #[tokio::test]
3639    async fn handle_think_tokens_unsupported_provider_returns_explicit_message() {
3640        let mut agent = Agent::new(
3641            mock_provider(vec![]),
3642            MockChannel::new(vec![]),
3643            create_test_registry(),
3644            None,
3645            5,
3646            MockToolExecutor::no_tools(),
3647        );
3648        let out = agent.handle_think_tokens("8k").await;
3649        assert!(out.contains("does not support"), "{out}");
3650        assert!(out.contains("mock"), "{out}");
3651    }
3652
3653    #[tokio::test]
3654    async fn handle_think_tokens_cross_override_note_when_reasoning_effort_was_active() {
3655        let mut agent = claude_agent();
3656        agent.handle_reasoning_effort("high").await;
3657        let out = agent.handle_think_tokens("8k").await;
3658        assert!(
3659            out.contains("overrides the previously set reasoning-effort"),
3660            "{out}"
3661        );
3662    }
3663
3664    #[tokio::test]
3665    async fn handle_reasoning_effort_empty_arg_displays_off_by_default() {
3666        let mut agent = claude_agent();
3667        let out = agent.handle_reasoning_effort("").await;
3668        assert!(out.contains("off"), "{out}");
3669    }
3670
3671    #[tokio::test]
3672    async fn handle_reasoning_effort_sets_and_displays_level() {
3673        let mut agent = claude_agent();
3674        let set = agent.handle_reasoning_effort("high").await;
3675        assert!(set.contains("high"), "{set}");
3676
3677        let show = agent.handle_reasoning_effort("").await;
3678        assert!(show.contains("high"), "{show}");
3679    }
3680
3681    #[tokio::test]
3682    async fn handle_reasoning_effort_invalid_parse_returns_error_no_mutation() {
3683        let mut agent = claude_agent();
3684        let out = agent.handle_reasoning_effort("minimal").await;
3685        assert!(out.contains("reasoning-effort"), "{out}");
3686        assert!(agent.provider.current_reasoning_effort().is_none());
3687    }
3688
3689    #[tokio::test]
3690    async fn handle_reasoning_effort_unsupported_provider_returns_explicit_message() {
3691        let mut agent = Agent::new(
3692            mock_provider(vec![]),
3693            MockChannel::new(vec![]),
3694            create_test_registry(),
3695            None,
3696            5,
3697            MockToolExecutor::no_tools(),
3698        );
3699        let out = agent.handle_reasoning_effort("high").await;
3700        assert!(out.contains("does not support"), "{out}");
3701    }
3702
3703    #[tokio::test]
3704    async fn handle_reasoning_effort_cross_override_note_when_think_tokens_was_active() {
3705        let mut agent = claude_agent();
3706        agent.handle_think_tokens("8k").await;
3707        let out = agent.handle_reasoning_effort("high").await;
3708        assert!(
3709            out.contains("overrides the previously set thinking-token budget"),
3710            "{out}"
3711        );
3712    }
3713}