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