Skip to main content

zeph_core/agent/
graph_commands.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! [`zeph_commands::GraphAccess`] implementation for [`Agent<C>`]: graph memory (entities,
5//! edges, communities, backfill) and the knowledge-ingest ledger.
6//!
7//! Each method returns a formatted `String` result (without sending to the channel
8//! directly), so that `CommandContext::sink` does not conflict with this borrow — these
9//! subsystems are already channel-free.
10//!
11//! [`Agent<C>`]: super::Agent
12
13use std::fmt::Write as _;
14use std::future::Future;
15use std::pin::Pin;
16use std::sync::Arc;
17use std::time::Duration;
18
19use tracing::Instrument as _;
20use zeph_commands::{CommandError, GraphAccess};
21use zeph_memory::semantic::SemanticMemory;
22use zeph_memory::{Edge, Entity, GraphExtractionConfig, GraphStore, extract_and_store};
23
24use super::Agent;
25use crate::channel::Channel;
26
27impl<C: Channel + Send + 'static> Agent<C> {
28    fn resolve_graph_store(&self) -> Result<(Arc<SemanticMemory>, Arc<GraphStore>), String> {
29        let Some(memory) = self.services.memory.persistence.memory.clone() else {
30            return Err("Graph memory is not enabled.".to_owned());
31        };
32        let Some(store) = memory.graph_store.clone() else {
33            if self.services.memory.extraction.graph_config.enabled {
34                return Err(
35                    "Graph memory enabled but vector store unavailable (Qdrant unreachable)."
36                        .to_owned(),
37                );
38            }
39            return Err("Graph memory is not enabled.".to_owned());
40        };
41        Ok((memory, store))
42    }
43}
44
45/// Outcome of resolving an entity by display name against the graph store: either the
46/// entity was found, or a user-facing message that the caller should return as-is (no
47/// match, or the store timed out).
48enum EntityLookup {
49    Found(Entity),
50    Message(String),
51}
52
53/// Outcome of a graph-store call bounded by [`with_graph_store_timeout`]'s 5s deadline:
54/// either it completed, or it timed out (Qdrant unreachable).
55enum StoreCallOutcome<T> {
56    Completed(T),
57    TimedOut,
58}
59
60/// Runs `fut` under a 5s timeout — the deadline shared by `resolve_entity_by_name` and the
61/// edge lookups in `graph_facts`/`graph_history`. Maps a store error to [`CommandError`];
62/// logs and reports a timeout via [`StoreCallOutcome::TimedOut`] so callers only need to
63/// turn that into their own user-facing message.
64async fn with_graph_store_timeout<T>(
65    fut: impl Future<Output = Result<T, zeph_memory::MemoryError>>,
66) -> Result<StoreCallOutcome<T>, CommandError> {
67    match tokio::time::timeout(Duration::from_secs(5), fut).await {
68        Ok(Ok(v)) => Ok(StoreCallOutcome::Completed(v)),
69        Ok(Err(e)) => Err(CommandError::new(e.to_string())),
70        Err(_) => {
71            tracing::warn!("graph store call timed out after 5s (Qdrant unreachable)");
72            Ok(StoreCallOutcome::TimedOut)
73        }
74    }
75}
76
77/// Resolves `name` to an [`Entity`] via [`GraphStore::find_entity_by_name`], bounded by a
78/// 5s timeout — the lookup block shared by `graph_facts` and `graph_history`.
79async fn resolve_entity_by_name(
80    store: &GraphStore,
81    name: &str,
82) -> Result<EntityLookup, CommandError> {
83    let matches = match with_graph_store_timeout(store.find_entity_by_name(name)).await? {
84        StoreCallOutcome::Completed(v) => v,
85        StoreCallOutcome::TimedOut => {
86            return Ok(EntityLookup::Message(
87                "Graph store unavailable (Qdrant unreachable).".to_owned(),
88            ));
89        }
90    };
91    let Some(entity) = matches.into_iter().next() else {
92        return Ok(EntityLookup::Message(format!(
93            "No entity found matching '{name}'."
94        )));
95    };
96    Ok(EntityLookup::Found(entity))
97}
98
99/// Builds the `entity_id -> display_name` lookup map shared by `graph_facts` and
100/// `graph_history`: seeds `entity`'s own name, inserts a placeholder for every edge
101/// endpoint, then resolves each placeholder via [`GraphStore::find_entity_by_id`] (5s
102/// timeout), falling back to `#{id}` when the lookup fails or times out.
103async fn build_entity_name_map(
104    store: &GraphStore,
105    entity: &Entity,
106    edges: &[Edge],
107) -> std::collections::HashMap<i64, String> {
108    let mut entity_names: std::collections::HashMap<i64, String> = std::collections::HashMap::new();
109    entity_names.insert(entity.id.0, entity.name.clone());
110    for edge in edges {
111        entity_names.entry(edge.source_entity_id).or_default();
112        entity_names.entry(edge.target_entity_id).or_default();
113    }
114    for (&id, name_val) in &mut entity_names {
115        if name_val.is_empty() {
116            let result =
117                tokio::time::timeout(Duration::from_secs(5), store.find_entity_by_id(id)).await;
118            if let Ok(Ok(Some(other))) = result {
119                *name_val = other.name;
120            } else {
121                *name_val = format!("#{id}");
122            }
123        }
124    }
125    entity_names
126}
127
128impl<C: Channel + Send + 'static> GraphAccess for Agent<C> {
129    // ----- /graph -----
130
131    fn graph_stats<'a>(
132        &'a mut self,
133    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
134        Box::pin(
135            async move {
136                let (_, store) = match self.resolve_graph_store() {
137                    Ok(pair) => pair,
138                    Err(msg) => return Ok(msg),
139                };
140
141                let stats_future = async {
142                    tokio::join!(
143                        store.entity_count(),
144                        store.active_edge_count(),
145                        store.community_count(),
146                        store.edge_type_distribution()
147                    )
148                };
149                let Ok((entities, edges, communities, distribution)) =
150                    tokio::time::timeout(Duration::from_secs(5), stats_future).await
151                else {
152                    tracing::warn!("graph store call timed out after 5s (Qdrant unreachable)");
153                    return Ok("Graph store unavailable (Qdrant unreachable).".to_owned());
154                };
155                let mut msg = format!(
156                    "Graph memory: {} entities, {} edges, {} communities",
157                    entities.unwrap_or(0),
158                    edges.unwrap_or(0),
159                    communities.unwrap_or(0)
160                );
161                if let Ok(dist) = distribution
162                    && !dist.is_empty()
163                {
164                    let dist_str: Vec<String> =
165                        dist.iter().map(|(t, c)| format!("{t}={c}")).collect();
166                    write!(msg, "\nEdge types: {}", dist_str.join(", ")).unwrap_or(());
167                }
168                Ok(msg)
169            }
170            .instrument(tracing::info_span!("core.agent_access.graph_stats")),
171        )
172    }
173
174    fn graph_entities<'a>(
175        &'a mut self,
176    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
177        Box::pin(
178            async move {
179                let (_, store) = match self.resolve_graph_store() {
180                    Ok(pair) => pair,
181                    Err(msg) => return Ok(msg),
182                };
183
184                let entities = match tokio::time::timeout(
185                    Duration::from_secs(5),
186                    store.all_entities(),
187                )
188                .await
189                {
190                    Ok(Ok(v)) => v,
191                    Ok(Err(e)) => return Err(CommandError::new(e.to_string())),
192                    Err(_) => {
193                        tracing::warn!("graph store call timed out after 5s (Qdrant unreachable)");
194                        return Ok("Graph store unavailable (Qdrant unreachable).".to_owned());
195                    }
196                };
197                if entities.is_empty() {
198                    return Ok("No entities found.".to_owned());
199                }
200
201                let total = entities.len();
202                let display: Vec<String> = entities
203                    .iter()
204                    .take(50)
205                    .map(|e| {
206                        format!(
207                            "  {:<40}  {:<15}  {}",
208                            e.name,
209                            e.entity_type.as_str(),
210                            e.last_seen_at.split('T').next().unwrap_or(&e.last_seen_at)
211                        )
212                    })
213                    .collect();
214                let mut msg = format!(
215                    "Entities ({total} total):\n  {:<40}  {:<15}  {}\n{}",
216                    "NAME",
217                    "TYPE",
218                    "LAST SEEN",
219                    display.join("\n")
220                );
221                if total > 50 {
222                    write!(msg, "\n  ...and {} more", total - 50).unwrap_or(());
223                }
224                Ok(msg)
225            }
226            .instrument(tracing::info_span!("core.agent_access.graph_entities")),
227        )
228    }
229
230    fn graph_facts<'a>(
231        &'a mut self,
232        name: &'a str,
233    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
234        Box::pin(
235            async move {
236                let (_, store) = match self.resolve_graph_store() {
237                    Ok(pair) => pair,
238                    Err(msg) => return Ok(msg),
239                };
240
241                let entity = match resolve_entity_by_name(&store, name).await? {
242                    EntityLookup::Found(e) => e,
243                    EntityLookup::Message(msg) => return Ok(msg),
244                };
245
246                let edges =
247                    match with_graph_store_timeout(store.edges_for_entity(entity.id.0)).await? {
248                        StoreCallOutcome::Completed(v) => v,
249                        StoreCallOutcome::TimedOut => {
250                            return Ok("Graph store unavailable (Qdrant unreachable).".to_owned());
251                        }
252                    };
253                if edges.is_empty() {
254                    return Ok(format!("Entity '{}' has no known facts.", entity.name));
255                }
256
257                let entity_names = build_entity_name_map(&store, &entity, &edges).await;
258
259                let lines: Vec<String> = edges
260                    .iter()
261                    .map(|e| {
262                        let src = entity_names
263                            .get(&e.source_entity_id)
264                            .cloned()
265                            .unwrap_or_else(|| format!("#{}", e.source_entity_id));
266                        let tgt = entity_names
267                            .get(&e.target_entity_id)
268                            .cloned()
269                            .unwrap_or_else(|| format!("#{}", e.target_entity_id));
270                        format!(
271                            "  {} --[{}/{}]--> {}: {} (confidence: {:.2})",
272                            src, e.relation, e.edge_type, tgt, e.fact, e.confidence
273                        )
274                    })
275                    .collect();
276                Ok(format!(
277                    "Facts for '{}':\n{}",
278                    entity.name,
279                    lines.join("\n")
280                ))
281            }
282            .instrument(tracing::info_span!("core.agent_access.graph_facts")),
283        )
284    }
285
286    fn graph_history<'a>(
287        &'a mut self,
288        name: &'a str,
289    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
290        Box::pin(
291            async move {
292                let (_, store) = match self.resolve_graph_store() {
293                    Ok(pair) => pair,
294                    Err(msg) => return Ok(msg),
295                };
296
297                let entity = match resolve_entity_by_name(&store, name).await? {
298                    EntityLookup::Found(e) => e,
299                    EntityLookup::Message(msg) => return Ok(msg),
300                };
301
302                let edges =
303                    match with_graph_store_timeout(store.edge_history_for_entity(entity.id.0, 50))
304                        .await?
305                    {
306                        StoreCallOutcome::Completed(v) => v,
307                        StoreCallOutcome::TimedOut => {
308                            return Ok("Graph store unavailable (Qdrant unreachable).".to_owned());
309                        }
310                    };
311                if edges.is_empty() {
312                    return Ok(format!("Entity '{}' has no edge history.", entity.name));
313                }
314
315                let entity_names = build_entity_name_map(&store, &entity, &edges).await;
316
317                let n = edges.len();
318                let lines: Vec<String> = edges
319                    .iter()
320                    .map(|e| {
321                        let status = if e.valid_to.is_some() {
322                            let date = e
323                                .valid_to
324                                .as_deref()
325                                .and_then(|s| s.split('T').next().or_else(|| s.split(' ').next()))
326                                .unwrap_or("?");
327                            format!("[expired {date}]")
328                        } else {
329                            "[active]".to_string()
330                        };
331                        let src = entity_names
332                            .get(&e.source_entity_id)
333                            .cloned()
334                            .unwrap_or_else(|| format!("#{}", e.source_entity_id));
335                        let tgt = entity_names
336                            .get(&e.target_entity_id)
337                            .cloned()
338                            .unwrap_or_else(|| format!("#{}", e.target_entity_id));
339                        format!(
340                            "  {status} {} --[{}/{}]--> {}: {} (confidence: {:.2})",
341                            src, e.relation, e.edge_type, tgt, e.fact, e.confidence
342                        )
343                    })
344                    .collect();
345                Ok(format!(
346                    "Edge history for '{}' ({n} edges):\n{}",
347                    entity.name,
348                    lines.join("\n")
349                ))
350            }
351            .instrument(tracing::info_span!("core.agent_access.graph_history")),
352        )
353    }
354
355    fn graph_communities<'a>(
356        &'a mut self,
357    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
358        Box::pin(
359            async move {
360                let (_, store) = match self.resolve_graph_store() {
361                    Ok(pair) => pair,
362                    Err(msg) => return Ok(msg),
363                };
364
365                let communities =
366                    match tokio::time::timeout(Duration::from_secs(5), store.all_communities())
367                        .await
368                    {
369                        Ok(Ok(v)) => v,
370                        Ok(Err(e)) => return Err(CommandError::new(e.to_string())),
371                        Err(_) => {
372                            tracing::warn!(
373                                "graph store call timed out after 5s (Qdrant unreachable)"
374                            );
375                            return Ok("Graph store unavailable (Qdrant unreachable).".to_owned());
376                        }
377                    };
378                if communities.is_empty() {
379                    return Ok("No communities detected yet. Run graph backfill first.".to_owned());
380                }
381
382                let lines: Vec<String> = communities
383                    .iter()
384                    .map(|c| format!("  [{}]: {}", c.name, c.summary))
385                    .collect();
386                Ok(format!(
387                    "Communities ({}):\n{}",
388                    communities.len(),
389                    lines.join("\n")
390                ))
391            }
392            .instrument(tracing::info_span!("core.agent_access.graph_communities")),
393        )
394    }
395
396    #[allow(clippy::too_many_lines)]
397    fn graph_backfill<'a>(
398        &'a mut self,
399        limit: Option<usize>,
400        progress_cb: &'a mut (dyn FnMut(String) + Send),
401    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
402        let store = match self.resolve_graph_store() {
403            Ok((_, s)) => s,
404            Err(msg) => return Box::pin(async move { Ok(msg) }),
405        };
406        let graph_cfg = self.services.memory.extraction.graph_config.clone();
407        let embed_timeout_secs = self
408            .services
409            .memory
410            .persistence
411            .memory
412            .as_ref()
413            .map_or(5, |m| m.embed_timeout().as_secs());
414        let provider = if graph_cfg.extract_provider.as_str().is_empty() {
415            self.provider.clone()
416        } else {
417            self.resolve_background_provider(graph_cfg.extract_provider.as_str())
418        };
419        Box::pin(
420            async move {
421                let total = store.unprocessed_message_count().await.unwrap_or(0);
422                let cap = limit.unwrap_or(usize::MAX);
423
424                progress_cb(format!(
425                    "Starting graph backfill... ({total} unprocessed messages)"
426                ));
427
428                let batch_size = 50usize;
429                let mut processed = 0usize;
430                let mut total_entities = 0usize;
431                let mut total_edges = 0usize;
432
433                loop {
434                    let remaining_cap = cap.saturating_sub(processed);
435                    if remaining_cap == 0 {
436                        break;
437                    }
438                    let batch_limit = batch_size.min(remaining_cap);
439                    let messages = store
440                        .unprocessed_messages_for_backfill(batch_limit)
441                        .await
442                        .map_err(|e| CommandError::new(e.to_string()))?;
443                    if messages.is_empty() {
444                        break;
445                    }
446
447                    let ids: Vec<zeph_memory::types::MessageId> =
448                        messages.iter().map(|(id, _)| *id).collect();
449
450                    // extraction_cfg is loop-invariant (derived only from graph_cfg /
451                    // embed_timeout_secs, never from message content), so it is built once per
452                    // batch and cloned per message below.
453                    let extraction_cfg = GraphExtractionConfig {
454                        max_entities: graph_cfg.max_entities_per_message,
455                        max_edges: graph_cfg.max_edges_per_message,
456                        extraction_timeout_secs: graph_cfg.extraction_timeout_secs,
457                        community_refresh_interval: 0,
458                        expired_edge_retention_days: graph_cfg.expired_edge_retention_days,
459                        max_entities_cap: graph_cfg.max_entities,
460                        community_summary_max_prompt_bytes: graph_cfg
461                            .community_summary_max_prompt_bytes,
462                        community_summary_concurrency: graph_cfg.community_summary_concurrency,
463                        lpa_edge_chunk_size: graph_cfg.lpa_edge_chunk_size,
464                        note_linking: zeph_memory::NoteLinkingConfig::default(),
465                        link_weight_decay_lambda: graph_cfg.link_weight_decay_lambda,
466                        link_weight_decay_interval_secs: graph_cfg.link_weight_decay_interval_secs,
467                        belief_revision_enabled: graph_cfg.belief_revision.enabled,
468                        belief_revision_similarity_threshold: graph_cfg
469                            .belief_revision
470                            .similarity_threshold,
471                        conversation_id: None,
472                        apex_mem_enabled: graph_cfg.apex_mem.enabled,
473                        llm_timeout_secs: graph_cfg.llm_timeout_secs,
474                        embed_timeout_secs,
475                        turn_index: None,
476                        write_gate_min_relevance: graph_cfg
477                            .write_gate
478                            .enabled
479                            .then_some(graph_cfg.write_gate.min_edge_relevance),
480                        benna_fast_rate: graph_cfg.spreading_activation.benna_fast_rate,
481                        benna_slow_rate: graph_cfg.spreading_activation.benna_slow_rate,
482                        provenance: None,
483                        system_prompt: None,
484                        recall_include_imported: graph_cfg.recall_include_imported,
485                    };
486
487                    // Extract concurrently, bounded to 4 in-flight — matches
488                    // semantic_scan_plugin_add's existing batched-LLM-call bound. Safe because
489                    // `extract_and_store` builds a fresh `EntityResolver` per call (its
490                    // `lock_name` guard does not span calls), so the actual concurrency-safety
491                    // mechanism is the DB-level `UNIQUE(canonical_name, entity_type)` constraint
492                    // and `ON CONFLICT ... DO UPDATE ... RETURNING id` upsert in
493                    // `GraphStore::upsert_entity` (plus `add_alias`'s `INSERT OR IGNORE`), which
494                    // makes concurrent entity creation for the same name idempotent regardless of
495                    // in-process locking.
496                    {
497                        use futures::stream::StreamExt as _;
498
499                        let extraction_futs: Vec<_> = messages
500                            .iter()
501                            .filter_map(|(_id, content)| {
502                                if content.trim().is_empty() {
503                                    return None;
504                                }
505                                let content = content.clone();
506                                let provider = provider.clone();
507                                let pool = store.pool().clone();
508                                let extraction_cfg = extraction_cfg.clone();
509                                Some(extract_and_store(
510                                    content,
511                                    vec![],
512                                    provider,
513                                    pool,
514                                    extraction_cfg,
515                                    None,
516                                    None,
517                                ))
518                            })
519                            .collect();
520
521                        let results: Vec<_> = futures::stream::iter(extraction_futs)
522                            .buffer_unordered(4)
523                            .collect()
524                            .await;
525
526                        for result in results {
527                            match result {
528                                Ok(result) => {
529                                    total_entities += result.stats.entities_upserted;
530                                    total_edges += result.stats.edges_inserted;
531                                }
532                                Err(e) => {
533                                    tracing::warn!("backfill extraction error: {e:#}");
534                                }
535                            }
536                        }
537                    }
538
539                    store
540                        .mark_messages_graph_processed(&ids)
541                        .await
542                        .map_err(|e| CommandError::new(e.to_string()))?;
543                    processed += messages.len();
544
545                    progress_cb(format!(
546                        "Backfill progress: {processed} messages processed, \
547                     {total_entities} entities, {total_edges} edges"
548                    ));
549                }
550
551                Ok(format!(
552                    "Backfill complete: {total_entities} entities, {total_edges} edges \
553                 extracted from {processed} messages"
554                ))
555            }
556            .instrument(tracing::info_span!("core.agent_access.graph_backfill")),
557        )
558    }
559
560    // ----- /knowledge -----
561
562    fn knowledge_status<'a>(
563        &'a mut self,
564    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
565        Box::pin(
566            async move {
567                use zeph_memory::graph::ingest::IngestLedger;
568
569                let Some(memory) = self.services.memory.persistence.memory.clone() else {
570                    return Ok("Memory subsystem not available.".to_owned());
571                };
572                let pool = memory.sqlite().pool().clone();
573                let ledger = IngestLedger::new(pool);
574
575                let rows = match ledger.summary().await {
576                    Ok(r) => r,
577                    Err(e) => return Err(CommandError(e.to_string())),
578                };
579
580                if rows.is_empty() {
581                    return Ok("No knowledge has been ingested yet. \
582                         Run `zeph knowledge ingest --source <src>`."
583                        .to_owned());
584                }
585
586                let mut out = format!("Knowledge ingest ledger ({} entries):\n\n", rows.len());
587                let mut current_batch = String::new();
588                for row in &rows {
589                    let batch_short = &row.import_batch_id[..row.import_batch_id.len().min(8)];
590                    if current_batch != row.import_batch_id {
591                        if !current_batch.is_empty() {
592                            out.push('\n');
593                        }
594                        current_batch.clone_from(&row.import_batch_id);
595                    }
596                    let uri_display = &row.source_uri[..row.source_uri.floor_char_boundary(40)];
597                    let at_display = &row.ingested_at[..row.ingested_at.len().min(19)];
598                    let _ = writeln!(
599                        out,
600                        "  {uri_display:<40} batch={batch_short} at={at_display} \
601                         e={} edges={}",
602                        row.entities, row.edges,
603                    );
604                }
605                Ok(out.trim_end().to_owned())
606            }
607            .instrument(tracing::info_span!("core.agent_access.knowledge_status")),
608        )
609    }
610
611    fn knowledge_rollback<'a>(
612        &'a mut self,
613        batch_id: &'a str,
614    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
615        Box::pin(
616            async move {
617                use zeph_memory::graph::ingest::IngestLedger;
618
619                let Some(memory) = self.services.memory.persistence.memory.clone() else {
620                    return Ok("Memory subsystem not available.".to_owned());
621                };
622                let pool = memory.sqlite().pool().clone();
623                let ledger = IngestLedger::new(pool.clone());
624
625                match ledger.batch_exists(batch_id).await {
626                    Ok(false) => {
627                        return Ok(format!("Batch '{batch_id}' not found in ledger."));
628                    }
629                    Err(e) => return Err(CommandError(e.to_string())),
630                    Ok(true) => {}
631                }
632
633                let Some(graph_store) = memory.graph_store.clone() else {
634                    return Ok(
635                        "Graph store unavailable (Qdrant unreachable or graph not enabled)."
636                            .to_owned(),
637                    );
638                };
639
640                let mut tx = zeph_db::begin_write(&pool)
641                    .await
642                    .map_err(|e| CommandError(e.to_string()))?;
643
644                let (edges, entities) = graph_store
645                    .delete_batch_in_tx(batch_id, &mut tx)
646                    .await
647                    .map_err(|e| CommandError(e.to_string()))?;
648                ledger
649                    .delete_batch_in_tx(batch_id, &mut tx)
650                    .await
651                    .map_err(|e| CommandError(e.to_string()))?;
652
653                tx.commit().await.map_err(|e| CommandError(e.to_string()))?;
654
655                let mut msg = format!(
656                    "Rolled back batch '{batch_id}': removed {edges} edge(s) and \
657                     {entities} entity(ies)."
658                );
659                if edges == 0 && entities == 0 {
660                    msg.push_str(
661                        "\nNote: no graph rows found. Phase-1 ingest writes to Qdrant notes — \
662                         Qdrant embeddings are NOT removed by this rollback.",
663                    );
664                }
665                Ok(msg)
666            }
667            .instrument(tracing::info_span!("core.agent_access.knowledge_rollback")),
668        )
669    }
670}
671
672#[cfg(test)]
673mod tests {
674    use super::super::agent_tests::{
675        MockChannel, MockToolExecutor, create_test_registry, mock_provider,
676    };
677    use super::*;
678
679    async fn memory_without_qdrant() -> SemanticMemory {
680        SemanticMemory::new(
681            ":memory:",
682            "http://127.0.0.1:1",
683            None,
684            zeph_llm::any::AnyProvider::Mock(zeph_llm::mock::MockProvider::default()),
685            "test-model",
686        )
687        .await
688        .unwrap()
689    }
690
691    // R-CRIT-4111: when graph is enabled in config but graph_store is None
692    // (Qdrant unreachable), graph command handlers must report
693    // "unavailable" rather than "not enabled".
694    #[tokio::test]
695    async fn graph_stats_enabled_but_no_store_reports_unavailable() {
696        let cfg = crate::config::GraphConfig {
697            enabled: true,
698            ..Default::default()
699        };
700        let memory = memory_without_qdrant().await;
701        let cid = memory.sqlite().create_conversation().await.unwrap();
702        let mut agent = Agent::new(
703            mock_provider(vec![]),
704            MockChannel::new(vec![]),
705            create_test_registry(),
706            None,
707            5,
708            MockToolExecutor::no_tools(),
709        )
710        .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
711        .with_graph_config(cfg);
712
713        let result = agent.graph_stats().await.unwrap();
714        assert!(
715            result.contains("unavailable"),
716            "expected 'unavailable' but got: {result}"
717        );
718        assert!(
719            !result.contains("not enabled"),
720            "must not report 'not enabled' when graph is enabled: {result}"
721        );
722    }
723
724    #[tokio::test]
725    async fn graph_stats_disabled_reports_not_enabled() {
726        let cfg = crate::config::GraphConfig {
727            enabled: false,
728            ..Default::default()
729        };
730        let memory = memory_without_qdrant().await;
731        let cid = memory.sqlite().create_conversation().await.unwrap();
732        let mut agent = Agent::new(
733            mock_provider(vec![]),
734            MockChannel::new(vec![]),
735            create_test_registry(),
736            None,
737            5,
738            MockToolExecutor::no_tools(),
739        )
740        .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
741        .with_graph_config(cfg);
742
743        let result = agent.graph_stats().await.unwrap();
744        assert!(
745            result.contains("not enabled"),
746            "expected 'not enabled' but got: {result}"
747        );
748    }
749
750    // R-CRIT-4136: graph_backfill must resolve extract_provider before entering the async block.
751    // When extract_provider is set to an unknown name, resolve_background_provider falls back to
752    // the primary provider — the backfill still completes (no messages to process).
753    // This test confirms that the provider-resolution code path executes without panic or borrow
754    // errors, which would occur if the old code tried to access `&mut self` inside `async move`.
755    #[tokio::test]
756    async fn graph_backfill_with_extract_provider_resolves_without_panic() {
757        let cfg = crate::config::GraphConfig {
758            enabled: true,
759            extract_provider: zeph_config::providers::ProviderName::new("nonexistent-provider"),
760            ..Default::default()
761        };
762        let mut memory = memory_without_qdrant().await;
763        // Install a real SQLite-backed GraphStore so resolve_graph_store succeeds.
764        let pool = memory.sqlite().pool().clone();
765        memory.graph_store = Some(std::sync::Arc::new(zeph_memory::GraphStore::new(pool)));
766        let cid = memory.sqlite().create_conversation().await.unwrap();
767        let mut agent = Agent::new(
768            mock_provider(vec![]),
769            MockChannel::new(vec![]),
770            create_test_registry(),
771            None,
772            5,
773            MockToolExecutor::no_tools(),
774        )
775        .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
776        .with_graph_config(cfg);
777
778        let mut progress = vec![];
779        let result = agent
780            .graph_backfill(Some(10), &mut |msg| progress.push(msg))
781            .await
782            .unwrap();
783
784        // With an empty store there are zero unprocessed messages → backfill completes immediately.
785        assert!(
786            result.contains("Backfill complete"),
787            "expected 'Backfill complete' but got: {result}"
788        );
789    }
790
791    // #6261: graph_backfill extracts each batch's unprocessed messages concurrently via
792    // `futures::stream::iter(...).buffer_unordered(4)` instead of a sequential per-message
793    // loop. buffer_unordered completes futures in an order that need not match input order, so
794    // this test asserts on aggregate totals (immune to completion order) and on per-entity /
795    // per-message presence, proving the concurrent rewrite neither drops nor double-counts
796    // results relative to the pre-#6261 sequential behavior.
797    #[tokio::test]
798    async fn graph_backfill_concurrent_extraction_aggregates_stats_without_dropping_results() {
799        let n = 6;
800        let cfg = crate::config::GraphConfig {
801            enabled: true,
802            ..Default::default()
803        };
804        let mut memory = memory_without_qdrant().await;
805        let store = install_graph_store(&mut memory);
806        let cid = memory.sqlite().create_conversation().await.unwrap();
807
808        for i in 0..n {
809            sqlx::query(zeph_db::sql!(
810                "INSERT INTO messages (conversation_id, role, content) VALUES (?1, 'user', ?2)"
811            ))
812            .bind(cid.0)
813            .bind(format!("message body {i}"))
814            .execute(memory.sqlite().pool())
815            .await
816            .unwrap();
817        }
818
819        // One canned extraction response per message, each yielding exactly one distinct
820        // entity. MockProvider serves responses in call order (not message order), which
821        // mirrors buffer_unordered's out-of-order completion.
822        let responses: Vec<String> = (0..n)
823            .map(|i| {
824                format!(
825                    r#"{{"entities":[{{"name":"Entity{i}","type":"concept","summary":""}}],"edges":[]}}"#
826                )
827            })
828            .collect();
829
830        let mut agent = Agent::new(
831            mock_provider(responses),
832            MockChannel::new(vec![]),
833            create_test_registry(),
834            None,
835            5,
836            MockToolExecutor::no_tools(),
837        )
838        .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
839        .with_graph_config(cfg);
840
841        let mut progress = vec![];
842        let result = agent
843            .graph_backfill(None, &mut |msg| progress.push(msg))
844            .await
845            .unwrap();
846
847        assert!(
848            result.contains(&format!("{n} entities")),
849            "expected all {n} entities aggregated in the result, got: {result}"
850        );
851        assert!(
852            result.contains(&format!("from {n} messages")),
853            "expected all {n} messages counted as processed, got: {result}"
854        );
855
856        // No drops/double-counts at the store level: every entity must be present exactly once.
857        for i in 0..n {
858            let name = format!("entity{i}");
859            let found = store
860                .find_entity(&name, zeph_memory::EntityType::Concept)
861                .await
862                .unwrap();
863            assert!(found.is_some(), "entity{i} must have been upserted");
864        }
865
866        // Every message in the batch must be marked processed — none left behind by a
867        // buffer_unordered future that was dropped or never polled to completion.
868        let remaining = store.unprocessed_message_count().await.unwrap();
869        assert_eq!(remaining, 0, "all messages must be marked graph_processed");
870    }
871
872    // #6261 follow-up (impl-critic finding): the aggregation test above uses an in-memory
873    // SQLite database, which `zeph-db`'s pool forces to a single connection
874    // (`connect_sqlite`'s `effective_max = if path == ":memory:" { 1 }`, see
875    // `crates/zeph-db/src/pool.rs`) — so it never actually exercises concurrent writers racing
876    // for the SQLite write lock. This test uses a real file-backed database instead (default
877    // pool_size = 5, WAL journal mode + 5s busy_timeout — see `DbConfig::connect_sqlite`) with
878    // more unprocessed messages than the `buffer_unordered(4)` bound, so multiple pooled
879    // connections genuinely contend for writes concurrently. It confirms `extract_and_store`'s
880    // upserts — relying on WAL mode + busy_timeout + `EntityResolver`'s per-entity-name locking,
881    // the same assumption `semantic_scan_plugin_add`'s existing `buffer_unordered(4)` usage
882    // relies on — complete without a "database is locked" error under real multi-connection
883    // write contention.
884    #[tokio::test]
885    async fn graph_backfill_concurrent_extraction_survives_real_sqlite_write_contention() {
886        let n = 8;
887        let tmp = tempfile::NamedTempFile::new().expect("tempfile");
888        let path = tmp.path().to_str().expect("valid utf-8 path").to_owned();
889
890        let cfg = crate::config::GraphConfig {
891            enabled: true,
892            ..Default::default()
893        };
894        let mut memory = SemanticMemory::new(
895            &path,
896            "http://127.0.0.1:1",
897            None,
898            zeph_llm::any::AnyProvider::Mock(zeph_llm::mock::MockProvider::default()),
899            "test-model",
900        )
901        .await
902        .unwrap();
903        let store = install_graph_store(&mut memory);
904        let cid = memory.sqlite().create_conversation().await.unwrap();
905
906        for i in 0..n {
907            sqlx::query(zeph_db::sql!(
908                "INSERT INTO messages (conversation_id, role, content) VALUES (?1, 'user', ?2)"
909            ))
910            .bind(cid.0)
911            .bind(format!("contention message body {i}"))
912            .execute(memory.sqlite().pool())
913            .await
914            .unwrap();
915        }
916
917        // A small per-call delay forces the (up to 4) concurrently in-flight extraction futures
918        // to genuinely overlap their subsequent SQLite writes, rather than happening to resolve
919        // one at a time fast enough to never actually race.
920        let responses: Vec<String> = (0..n)
921            .map(|i| {
922                format!(
923                    r#"{{"entities":[{{"name":"ContentionEntity{i}","type":"concept","summary":""}}],"edges":[]}}"#
924                )
925            })
926            .collect();
927        let mut provider = zeph_llm::mock::MockProvider::with_responses(responses);
928        provider.delay_ms = 15;
929        let provider = zeph_llm::any::AnyProvider::Mock(provider);
930
931        let mut agent = Agent::new(
932            provider,
933            MockChannel::new(vec![]),
934            create_test_registry(),
935            None,
936            5,
937            MockToolExecutor::no_tools(),
938        )
939        .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
940        .with_graph_config(cfg);
941
942        let mut progress = vec![];
943        let result = agent
944            .graph_backfill(None, &mut |msg| progress.push(msg))
945            .await
946            .unwrap();
947
948        assert!(
949            result.contains(&format!("{n} entities")),
950            "expected all {n} entities aggregated despite concurrent SQLite writers, got: {result}"
951        );
952
953        // The decisive assertion: if a concurrent writer had hit "database is locked"
954        // (SQLITE_BUSY surfacing as an error instead of the busy_timeout retry succeeding),
955        // extract_and_store logs a warning and skips that message's upsert (the `Err(e) =>
956        // tracing::warn!(...)` arm in graph_backfill) rather than failing the whole batch — so a
957        // missing entity here is the observable symptom of exactly the failure mode flagged.
958        for i in 0..n {
959            let name = format!("contentionentity{i}");
960            let found = store
961                .find_entity(&name, zeph_memory::EntityType::Concept)
962                .await
963                .unwrap();
964            assert!(
965                found.is_some(),
966                "entity {i} must have been upserted; a missing entity indicates a dropped/failed \
967                 concurrent write (e.g. a 'database is locked' error) under real multi-connection \
968                 contention"
969            );
970        }
971
972        let remaining = store.unprocessed_message_count().await.unwrap();
973        assert_eq!(remaining, 0, "all messages must be marked graph_processed");
974    }
975
976    // R-4139: graph_entities with enabled graph but no store (Qdrant unreachable) must
977    // report unavailable, not panic or hang.
978    #[tokio::test]
979    async fn graph_entities_enabled_but_no_store_reports_unavailable() {
980        let cfg = crate::config::GraphConfig {
981            enabled: true,
982            ..Default::default()
983        };
984        let memory = memory_without_qdrant().await;
985        let cid = memory.sqlite().create_conversation().await.unwrap();
986        let mut agent = Agent::new(
987            mock_provider(vec![]),
988            MockChannel::new(vec![]),
989            create_test_registry(),
990            None,
991            5,
992            MockToolExecutor::no_tools(),
993        )
994        .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
995        .with_graph_config(cfg);
996
997        let result = agent.graph_entities().await.unwrap();
998        assert!(
999            result.contains("unavailable"),
1000            "expected 'unavailable' but got: {result}"
1001        );
1002    }
1003
1004    // R-4139: graph_communities with enabled graph but no store must report unavailable.
1005    #[tokio::test]
1006    async fn graph_communities_enabled_but_no_store_reports_unavailable() {
1007        let cfg = crate::config::GraphConfig {
1008            enabled: true,
1009            ..Default::default()
1010        };
1011        let memory = memory_without_qdrant().await;
1012        let cid = memory.sqlite().create_conversation().await.unwrap();
1013        let mut agent = Agent::new(
1014            mock_provider(vec![]),
1015            MockChannel::new(vec![]),
1016            create_test_registry(),
1017            None,
1018            5,
1019            MockToolExecutor::no_tools(),
1020        )
1021        .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
1022        .with_graph_config(cfg);
1023
1024        let result = agent.graph_communities().await.unwrap();
1025        assert!(
1026            result.contains("unavailable"),
1027            "expected 'unavailable' but got: {result}"
1028        );
1029    }
1030
1031    // R-4139: verify that the tokio::time::timeout pattern used in graph handlers
1032    // correctly returns Err on a never-resolving future. This is a direct regression
1033    // guard for the fix introduced in #4139: before the fix, these calls had no
1034    // timeout guard and would block indefinitely when Qdrant was unreachable.
1035    #[tokio::test]
1036    async fn graph_store_timeout_pattern_fires_on_pending_future() {
1037        use std::future;
1038        let result = tokio::time::timeout(
1039            Duration::from_millis(10),
1040            future::pending::<Result<Vec<()>, String>>(),
1041        )
1042        .await;
1043        assert!(
1044            result.is_err(),
1045            "timeout must fire on a never-resolving future"
1046        );
1047    }
1048
1049    // ── #5770: with_graph_store_timeout had zero coverage of its own timeout branch —
1050    // existing tests only reached the "no store configured" short-circuit in
1051    // resolve_graph_store(), never the 5s deadline shared by resolve_entity_by_name,
1052    // graph_facts, and graph_history. Exercise the extracted helper directly with a
1053    // paused clock so the deadline fires deterministically without a real wall-clock wait.
1054
1055    #[tokio::test]
1056    async fn with_graph_store_timeout_completes_on_success() {
1057        let result = with_graph_store_timeout(async { Ok::<_, zeph_memory::MemoryError>(42) })
1058            .await
1059            .unwrap();
1060        assert!(matches!(result, StoreCallOutcome::Completed(42)));
1061    }
1062
1063    #[tokio::test]
1064    async fn with_graph_store_timeout_maps_store_error_to_command_error() {
1065        let result = with_graph_store_timeout(async {
1066            Err::<i32, _>(zeph_memory::MemoryError::GraphStore("boom".to_owned()))
1067        })
1068        .await;
1069        assert!(result.is_err(), "store error must surface as CommandError");
1070    }
1071
1072    #[tokio::test]
1073    async fn with_graph_store_timeout_times_out_on_pending_future() {
1074        tokio::time::pause();
1075        let fut = with_graph_store_timeout(std::future::pending::<
1076            Result<i32, zeph_memory::MemoryError>,
1077        >());
1078        let handle = tokio::spawn(fut); // EXEMPT: test-only tokio::time::pause harness
1079        tokio::time::advance(std::time::Duration::from_secs(6)).await;
1080        let result = handle.await.expect("task panicked");
1081        assert!(
1082            matches!(result, Ok(StoreCallOutcome::TimedOut)),
1083            "call must resolve to TimedOut once the 5s deadline elapses"
1084        );
1085    }
1086
1087    // ── #5764: graph_facts / graph_history had zero dedicated test coverage ──────
1088
1089    /// Installs a real SQLite-backed `GraphStore` on `memory` (mirrors
1090    /// `graph_backfill_with_extract_provider_resolves_without_panic`), returning an `Arc`
1091    /// clone so callers can seed entities/edges before handing `memory` to `with_memory`.
1092    fn install_graph_store(memory: &mut SemanticMemory) -> std::sync::Arc<zeph_memory::GraphStore> {
1093        let pool = memory.sqlite().pool().clone();
1094        let store = std::sync::Arc::new(zeph_memory::GraphStore::new(pool));
1095        memory.graph_store = Some(store.clone());
1096        store
1097    }
1098
1099    #[tokio::test]
1100    async fn graph_facts_happy_path_returns_formatted_facts() {
1101        let mut memory = memory_without_qdrant().await;
1102        let store = install_graph_store(&mut memory);
1103        let cid = memory.sqlite().create_conversation().await.unwrap();
1104
1105        let alice = store
1106            .upsert_entity(
1107                "Alice",
1108                "alice",
1109                zeph_memory::EntityType::Person,
1110                None,
1111                None,
1112            )
1113            .await
1114            .unwrap();
1115        let bob = store
1116            .upsert_entity("Bob", "bob", zeph_memory::EntityType::Person, None, None)
1117            .await
1118            .unwrap();
1119        store
1120            .insert_edge(alice.0, bob.0, "knows", "Alice knows Bob", 0.9, None, None)
1121            .await
1122            .unwrap();
1123
1124        let cfg = crate::config::GraphConfig {
1125            enabled: true,
1126            ..Default::default()
1127        };
1128        let mut agent = Agent::new(
1129            mock_provider(vec![]),
1130            MockChannel::new(vec![]),
1131            create_test_registry(),
1132            None,
1133            5,
1134            MockToolExecutor::no_tools(),
1135        )
1136        .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
1137        .with_graph_config(cfg);
1138
1139        let result = agent.graph_facts("Alice").await.unwrap();
1140        assert!(
1141            result.contains("Facts for 'Alice'"),
1142            "expected facts header, got: {result}"
1143        );
1144        assert!(
1145            result.contains("Bob"),
1146            "expected target entity name, got: {result}"
1147        );
1148        assert!(result.contains("knows"), "expected relation, got: {result}");
1149        assert!(
1150            result.contains("Alice knows Bob"),
1151            "expected fact text, got: {result}"
1152        );
1153    }
1154
1155    #[tokio::test]
1156    async fn graph_facts_entity_not_found_returns_message() {
1157        let mut memory = memory_without_qdrant().await;
1158        install_graph_store(&mut memory);
1159        let cid = memory.sqlite().create_conversation().await.unwrap();
1160
1161        let cfg = crate::config::GraphConfig {
1162            enabled: true,
1163            ..Default::default()
1164        };
1165        let mut agent = Agent::new(
1166            mock_provider(vec![]),
1167            MockChannel::new(vec![]),
1168            create_test_registry(),
1169            None,
1170            5,
1171            MockToolExecutor::no_tools(),
1172        )
1173        .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
1174        .with_graph_config(cfg);
1175
1176        let result = agent.graph_facts("Nobody").await.unwrap();
1177        assert_eq!(result, "No entity found matching 'Nobody'.");
1178    }
1179
1180    // Mirrors graph_entities_enabled_but_no_store_reports_unavailable (R-4139): when the graph
1181    // store is None (Qdrant unreachable) but graph is enabled, report unavailable rather than
1182    // hang or panic.
1183    #[tokio::test]
1184    async fn graph_facts_enabled_but_no_store_reports_unavailable() {
1185        let cfg = crate::config::GraphConfig {
1186            enabled: true,
1187            ..Default::default()
1188        };
1189        let memory = memory_without_qdrant().await;
1190        let cid = memory.sqlite().create_conversation().await.unwrap();
1191        let mut agent = Agent::new(
1192            mock_provider(vec![]),
1193            MockChannel::new(vec![]),
1194            create_test_registry(),
1195            None,
1196            5,
1197            MockToolExecutor::no_tools(),
1198        )
1199        .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
1200        .with_graph_config(cfg);
1201
1202        let result = agent.graph_facts("Alice").await.unwrap();
1203        assert!(
1204            result.contains("unavailable"),
1205            "expected 'unavailable' but got: {result}"
1206        );
1207    }
1208
1209    // Self-loop edges (source == target) are rejected both by `GraphStore::insert_edge_typed`
1210    // and by a DB-level trigger (migration 044_graph_edges_no_self_loops) — so a real one can
1211    // only arise from data written before that migration. Drop the trigger to simulate that
1212    // legacy row and confirm graph_facts' defensive `entity_names` bookkeeping (which already
1213    // knows the entity's own name before resolving edge endpoints) handles it without panicking
1214    // or falling back to a raw `#id` placeholder.
1215    #[tokio::test]
1216    async fn graph_facts_self_loop_edge_does_not_panic() {
1217        let mut memory = memory_without_qdrant().await;
1218        let store = install_graph_store(&mut memory);
1219        let cid = memory.sqlite().create_conversation().await.unwrap();
1220
1221        let self_entity = store
1222            .upsert_entity("Self", "self", zeph_memory::EntityType::Concept, None, None)
1223            .await
1224            .unwrap();
1225        let pool = memory.sqlite().pool().clone();
1226        zeph_db::query(zeph_db::sql!(
1227            "DROP TRIGGER IF EXISTS graph_edges_no_self_loops"
1228        ))
1229        .execute(&pool)
1230        .await
1231        .unwrap();
1232        zeph_db::query(zeph_db::sql!(
1233            "INSERT INTO graph_edges (source_entity_id, target_entity_id, relation, fact, confidence) \
1234             VALUES (?, ?, ?, ?, ?)"
1235        ))
1236        .bind(self_entity.0)
1237        .bind(self_entity.0)
1238        .bind("refers_to")
1239        .bind("Self refers to itself")
1240        .bind(1.0_f64)
1241        .execute(&pool)
1242        .await
1243        .unwrap();
1244
1245        let cfg = crate::config::GraphConfig {
1246            enabled: true,
1247            ..Default::default()
1248        };
1249        let mut agent = Agent::new(
1250            mock_provider(vec![]),
1251            MockChannel::new(vec![]),
1252            create_test_registry(),
1253            None,
1254            5,
1255            MockToolExecutor::no_tools(),
1256        )
1257        .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
1258        .with_graph_config(cfg);
1259
1260        let result = agent.graph_facts("Self").await.unwrap();
1261        assert!(
1262            result.contains("Facts for 'Self'"),
1263            "expected facts header, got: {result}"
1264        );
1265        assert!(
1266            result.contains("refers_to"),
1267            "expected self-loop relation, got: {result}"
1268        );
1269        assert!(
1270            !result.contains('#'),
1271            "self-loop endpoint must resolve to the entity's own name, not a raw #id \
1272             placeholder: {result}"
1273        );
1274    }
1275
1276    #[tokio::test]
1277    async fn graph_history_happy_path_returns_formatted_history() {
1278        let mut memory = memory_without_qdrant().await;
1279        let store = install_graph_store(&mut memory);
1280        let cid = memory.sqlite().create_conversation().await.unwrap();
1281
1282        let alice = store
1283            .upsert_entity(
1284                "Alice",
1285                "alice",
1286                zeph_memory::EntityType::Person,
1287                None,
1288                None,
1289            )
1290            .await
1291            .unwrap();
1292        let bob = store
1293            .upsert_entity("Bob", "bob", zeph_memory::EntityType::Person, None, None)
1294            .await
1295            .unwrap();
1296        store
1297            .insert_edge(alice.0, bob.0, "knows", "Alice knows Bob", 0.9, None, None)
1298            .await
1299            .unwrap();
1300
1301        let cfg = crate::config::GraphConfig {
1302            enabled: true,
1303            ..Default::default()
1304        };
1305        let mut agent = Agent::new(
1306            mock_provider(vec![]),
1307            MockChannel::new(vec![]),
1308            create_test_registry(),
1309            None,
1310            5,
1311            MockToolExecutor::no_tools(),
1312        )
1313        .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
1314        .with_graph_config(cfg);
1315
1316        let result = agent.graph_history("Alice").await.unwrap();
1317        assert!(
1318            result.contains("Edge history for 'Alice'"),
1319            "expected history header, got: {result}"
1320        );
1321        assert!(
1322            result.contains("[active]"),
1323            "expected active tag, got: {result}"
1324        );
1325        assert!(
1326            result.contains("Bob"),
1327            "expected target entity name, got: {result}"
1328        );
1329    }
1330
1331    #[tokio::test]
1332    async fn graph_history_entity_not_found_returns_message() {
1333        let mut memory = memory_without_qdrant().await;
1334        install_graph_store(&mut memory);
1335        let cid = memory.sqlite().create_conversation().await.unwrap();
1336
1337        let cfg = crate::config::GraphConfig {
1338            enabled: true,
1339            ..Default::default()
1340        };
1341        let mut agent = Agent::new(
1342            mock_provider(vec![]),
1343            MockChannel::new(vec![]),
1344            create_test_registry(),
1345            None,
1346            5,
1347            MockToolExecutor::no_tools(),
1348        )
1349        .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
1350        .with_graph_config(cfg);
1351
1352        let result = agent.graph_history("Nobody").await.unwrap();
1353        assert_eq!(result, "No entity found matching 'Nobody'.");
1354    }
1355
1356    #[tokio::test]
1357    async fn graph_history_enabled_but_no_store_reports_unavailable() {
1358        let cfg = crate::config::GraphConfig {
1359            enabled: true,
1360            ..Default::default()
1361        };
1362        let memory = memory_without_qdrant().await;
1363        let cid = memory.sqlite().create_conversation().await.unwrap();
1364        let mut agent = Agent::new(
1365            mock_provider(vec![]),
1366            MockChannel::new(vec![]),
1367            create_test_registry(),
1368            None,
1369            5,
1370            MockToolExecutor::no_tools(),
1371        )
1372        .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
1373        .with_graph_config(cfg);
1374
1375        let result = agent.graph_history("Alice").await.unwrap();
1376        assert!(
1377            result.contains("unavailable"),
1378            "expected 'unavailable' but got: {result}"
1379        );
1380    }
1381
1382    // See graph_facts_self_loop_edge_does_not_panic for why the DB trigger must be dropped.
1383    #[tokio::test]
1384    async fn graph_history_self_loop_edge_does_not_panic() {
1385        let mut memory = memory_without_qdrant().await;
1386        let store = install_graph_store(&mut memory);
1387        let cid = memory.sqlite().create_conversation().await.unwrap();
1388
1389        let self_entity = store
1390            .upsert_entity("Self", "self", zeph_memory::EntityType::Concept, None, None)
1391            .await
1392            .unwrap();
1393        let pool = memory.sqlite().pool().clone();
1394        zeph_db::query(zeph_db::sql!(
1395            "DROP TRIGGER IF EXISTS graph_edges_no_self_loops"
1396        ))
1397        .execute(&pool)
1398        .await
1399        .unwrap();
1400        zeph_db::query(zeph_db::sql!(
1401            "INSERT INTO graph_edges (source_entity_id, target_entity_id, relation, fact, confidence) \
1402             VALUES (?, ?, ?, ?, ?)"
1403        ))
1404        .bind(self_entity.0)
1405        .bind(self_entity.0)
1406        .bind("refers_to")
1407        .bind("Self refers to itself")
1408        .bind(1.0_f64)
1409        .execute(&pool)
1410        .await
1411        .unwrap();
1412
1413        let cfg = crate::config::GraphConfig {
1414            enabled: true,
1415            ..Default::default()
1416        };
1417        let mut agent = Agent::new(
1418            mock_provider(vec![]),
1419            MockChannel::new(vec![]),
1420            create_test_registry(),
1421            None,
1422            5,
1423            MockToolExecutor::no_tools(),
1424        )
1425        .with_memory(std::sync::Arc::new(memory), cid, 50, 5, 100)
1426        .with_graph_config(cfg);
1427
1428        let result = agent.graph_history("Self").await.unwrap();
1429        assert!(
1430            result.contains("Edge history for 'Self'"),
1431            "expected history header, got: {result}"
1432        );
1433        assert!(
1434            result.contains("refers_to"),
1435            "expected self-loop relation, got: {result}"
1436        );
1437        assert!(
1438            !result.contains('#'),
1439            "self-loop endpoint must resolve to the entity's own name, not a raw #id \
1440             placeholder: {result}"
1441        );
1442    }
1443}