Skip to main content

zeph_context/
assembler.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Stateless context assembler.
5//!
6//! [`ContextAssembler`] gathers all memory-sourced context for a single agent turn by running
7//! all async fetch operations concurrently. It takes only borrowed references via
8//! [`ContextAssemblyInput`] and returns a [`PreparedContext`] ready for injection.
9//!
10//! Invariants:
11//! - No `Agent` field mutations inside `gather()`.
12//! - No channel communication inside `gather()`.
13//! - All `send_status` calls remain in `Agent::prepare_context`.
14//! - `session_digest` is cached (not async) and stays in `Agent::apply_prepared_context`.
15
16use std::future::Future;
17use std::pin::Pin;
18
19use futures::StreamExt as _;
20use futures::stream::FuturesUnordered;
21
22use zeph_common::memory::{
23    AsyncMemoryRouter, CompressionLevel, FunctionalType, GraphRecallParams, TokenCounting,
24};
25use zeph_llm::provider::{Message, MessageMetadata, MessagePart, Role};
26
27use crate::budget::BudgetAllocation;
28use crate::error::AssemblerError;
29use crate::input::ContextAssemblyInput;
30use crate::slot::ContextSlot;
31
32/// Map a slice of active compression levels to per-tier boolean flags.
33///
34/// Returns `(episodic_active, procedural_active, declarative_active)`.
35///
36/// An empty slice means "no tier filtering": all three flags are `true`. This is the defensive
37/// default — passing an empty slice preserves legacy behaviour instead of silently suppressing
38/// all memory recall.
39pub(crate) fn levels_to_flags(levels: &[CompressionLevel]) -> (bool, bool, bool) {
40    if levels.is_empty() {
41        return (true, true, true);
42    }
43    let episodic = levels.contains(&CompressionLevel::Episodic);
44    let procedural = levels.contains(&CompressionLevel::Procedural);
45    let declarative = levels.contains(&CompressionLevel::Declarative);
46    (episodic, procedural, declarative)
47}
48
49/// Whether `t` is in the active `FunctionalType` set (spec 004-16, `MemGuard` type-aware retrieval,
50/// #6086).
51///
52/// An empty `active` slice means "no type filtering": every type is active. This mirrors
53/// [`levels_to_flags`]'s empty-slice-is-permissive default and is what makes both
54/// `type_aware_compose.enabled = false` and `default_compose_types = []` resolve to today's
55/// unfiltered composition — `zeph-agent-context` maps both cases to an empty slice, so this
56/// function only needs one code path to be behavior-preserving for either.
57pub(crate) fn type_active(active: &[FunctionalType], t: FunctionalType) -> bool {
58    active.is_empty() || active.contains(&t)
59}
60
61/// Prefix for past-session summary injections.
62pub const SUMMARY_PREFIX: &str = "[conversation summaries]\n";
63/// Prefix for cross-session context injections.
64pub const CROSS_SESSION_PREFIX: &str = "[cross-session context]\n";
65/// Prefix for semantic recall injections.
66pub const RECALL_PREFIX: &str = "[semantic recall]\n";
67/// Prefix for past-correction injections.
68pub const CORRECTIONS_PREFIX: &str = "[past corrections]\n";
69/// Prefix for document RAG injections.
70pub const DOCUMENT_RAG_PREFIX: &str = "## Relevant documents\n";
71/// Prefix for knowledge graph fact injections.
72pub const GRAPH_FACTS_PREFIX: &str = "[known facts]\n";
73
74/// Timeout for a single per-source fetch call during context assembly.
75///
76/// Bounds every per-source memory fetch (persona, trajectory, tree, summaries, cross-session,
77/// document RAG, semantic recall, corrections, reasoning strategies) and the code-index RAG
78/// fetch (`IndexAccess::fetch_code_rag`) so one stalled backend degrades only its own
79/// [`ContextSlot`] instead of the whole [`ContextAssembler::gather`] pass. Mirrors the default
80/// used for graph spreading-activation recall (`SpreadingActivationConfig::recall_timeout_ms`).
81const MEMORY_FETCH_TIMEOUT_MS: u64 = 1000;
82
83/// Result of one context-assembly pass.
84///
85/// All source fields are `Option` — `None` means disabled, empty, or budget-exhausted.
86/// `session_digest` is excluded: it is a cached value injected by `Agent::apply_prepared_context`.
87#[derive(Default)]
88pub struct PreparedContext {
89    /// Knowledge graph fact recall.
90    pub graph_facts: Option<Message>,
91    /// Document RAG context.
92    pub doc_rag: Option<Message>,
93    /// Past user corrections.
94    pub corrections: Option<Message>,
95    /// Semantic recall results.
96    pub recall: Option<Message>,
97    /// Top-1 similarity score from semantic recall.
98    pub recall_confidence: Option<f32>,
99    /// Cross-session memory context.
100    pub cross_session: Option<Message>,
101    /// Past-conversation summaries.
102    pub summaries: Option<Message>,
103    /// Code-index RAG context (repo map or file context).
104    pub code_context: Option<String>,
105    /// Persona memory facts.
106    pub persona_facts: Option<Message>,
107    /// Trajectory hints.
108    pub trajectory_hints: Option<Message>,
109    /// `TiMem` tree memory summary.
110    pub tree_memory: Option<Message>,
111    /// Distilled reasoning strategies from the `ReasoningBank` (#3343).
112    pub reasoning_hints: Option<Message>,
113    /// Whether the memory-first context strategy is active for this turn.
114    pub memory_first: bool,
115    /// Token budget for recent conversation history (passed to trim step in apply).
116    pub recent_history_budget: usize,
117    /// Background tasks spawned during context assembly that must be tracked to completion.
118    ///
119    /// Callers are responsible for awaiting or aborting these handles at an appropriate boundary
120    /// (e.g., turn end). See async discipline rule: fire-and-forget tasks MUST be tracked.
121    pub background_tasks: Vec<tokio::task::JoinHandle<()>>,
122}
123
124/// Stateless coordinator for parallel context fetching.
125///
126/// All logic is in [`ContextAssembler::gather`]. No state is stored on this type.
127pub struct ContextAssembler;
128
129type CtxFuture<'a> = Pin<Box<dyn Future<Output = Result<ContextSlot, AssemblerError>> + Send + 'a>>;
130
131fn empty_prepared_context() -> PreparedContext {
132    PreparedContext::default()
133}
134
135fn resolve_effective_strategy(
136    memory: &crate::input::ContextMemoryView,
137    sidequest_turn_counter: u64,
138) -> zeph_config::ContextStrategy {
139    match memory.context_strategy {
140        zeph_config::ContextStrategy::MemoryFirst => zeph_config::ContextStrategy::MemoryFirst,
141        zeph_config::ContextStrategy::Adaptive => {
142            if sidequest_turn_counter >= u64::from(memory.crossover_turn_threshold) {
143                zeph_config::ContextStrategy::MemoryFirst
144            } else {
145                zeph_config::ContextStrategy::FullHistory
146            }
147        }
148        _ => zeph_config::ContextStrategy::FullHistory,
149    }
150}
151
152fn correction_params(cfg: Option<&crate::input::CorrectionConfig>) -> (usize, f32) {
153    cfg.filter(|c| c.correction_detection)
154        .map_or((3, 0.75), |c| {
155            (
156                c.correction_recall_limit as usize,
157                c.correction_min_similarity,
158            )
159        })
160}
161
162/// Schedules all enabled context fetchers and returns them as a set of concurrent futures.
163///
164/// `router_ref` borrows from `router`, which is a local owned by `gather`. Using a separate
165/// lifetime `'r` for `router_ref` avoids tying it to `'a` (the input lifetime), which would
166/// require `router` to outlive `input`. Per-slot budgets are read from `alloc`, borrowed for
167/// `'r` from the caller's local `BudgetAllocation`, rather than passed as five individually
168/// ordered `usize` parameters — this removes the risk of an undetected argument-order swap
169/// between same-typed budget fields (#6573).
170#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
171fn schedule_context_fetchers<'r>(
172    memory: &'r crate::input::ContextMemoryView,
173    tc: &'r dyn TokenCounting,
174    query: &'r str,
175    scrub: fn(&str) -> std::borrow::Cow<'_, str>,
176    index: Option<&'r dyn crate::input::IndexAccess>,
177    router_ref: &'r dyn AsyncMemoryRouter,
178    alloc: &'r BudgetAllocation,
179    recall_limit: usize,
180    min_sim: f32,
181    active_levels: &[CompressionLevel],
182    active_types: &[FunctionalType],
183) -> FuturesUnordered<CtxFuture<'r>> {
184    // episodic_active gates summaries + cross-session + recall + doc_rag together at the
185    // compression-tier level. If future RetrievalPolicy variants ever drop Episodic, the cheap
186    // summary fetchers will be silently disabled — split into raw vs compressed sub-tiers
187    // (#3455 follow-up; unrelated to the FunctionalType gate below).
188    //
189    // The semantic-recall vs document-RAG bundling that this TODO originally flagged has been
190    // split (#6086, spec 004-16 N2): the FunctionalType::Episodic gate below applies only to the
191    // semantic-recall push, so doc_rag stays scheduled whenever `episodic_active` is true
192    // regardless of whether Episodic is in the active functional-type set.
193    let (episodic_active, procedural_active, declarative_active) = levels_to_flags(active_levels);
194
195    let fetchers: FuturesUnordered<CtxFuture<'r>> = FuturesUnordered::new();
196
197    if episodic_active
198        && alloc.summaries > 0
199        && type_active(active_types, FunctionalType::CrossSessionSummary)
200    {
201        fetchers.push(Box::pin(async move {
202            fetch_summaries(memory, alloc.summaries, tc)
203                .await
204                .map(ContextSlot::Summaries)
205        }));
206    }
207    if episodic_active
208        && alloc.cross_session > 0
209        && type_active(active_types, FunctionalType::CrossSessionSummary)
210    {
211        fetchers.push(Box::pin(async move {
212            fetch_cross_session(memory, query, alloc.cross_session, tc)
213                .await
214                .map(ContextSlot::CrossSession)
215        }));
216    }
217    if episodic_active
218        && alloc.semantic_recall > 0
219        && type_active(active_types, FunctionalType::Episodic)
220    {
221        fetchers.push(Box::pin(async move {
222            fetch_semantic_recall(memory, query, alloc.semantic_recall, tc, Some(router_ref))
223                .await
224                .map(|(msg, score)| ContextSlot::SemanticRecall(msg, score))
225        }));
226    }
227    // Document RAG is not yet a gated FunctionalType (spec 004-16 §4: v2 extension) — it stays
228    // always-composed within its existing `episodic_active` activity gate, independent of
229    // whether FunctionalType::Episodic is in the active set (N2: must not be silently disabled
230    // by the Episodic gate above).
231    if episodic_active && alloc.semantic_recall > 0 {
232        fetchers.push(Box::pin(async move {
233            fetch_document_rag(memory, query, alloc.semantic_recall, tc)
234                .await
235                .map(ContextSlot::DocumentRag)
236        }));
237    }
238    // Corrections are safety-critical and never budget-gated, tier-gated, or type-gated.
239    fetchers.push(Box::pin(async move {
240        fetch_corrections(memory, query, recall_limit, min_sim, scrub)
241            .await
242            .map(ContextSlot::Corrections)
243    }));
244    // Code RAG is request-driven, not memory-tier; exempt from tier and type filtering.
245    if alloc.code_context > 0
246        && let Some(idx) = index
247    {
248        fetchers.push(Box::pin(async move {
249            let result: Result<Option<String>, AssemblerError> = if let Ok(r) =
250                tokio::time::timeout(
251                    std::time::Duration::from_millis(MEMORY_FETCH_TIMEOUT_MS),
252                    idx.fetch_code_rag(query, alloc.code_context),
253                )
254                .await
255            {
256                r
257            } else {
258                tracing::warn!("code RAG fetch timed out ({MEMORY_FETCH_TIMEOUT_MS}ms)");
259                Ok(None)
260            };
261            result.map(ContextSlot::CodeContext)
262        }));
263    }
264    if declarative_active
265        && alloc.graph_facts > 0
266        && type_active(active_types, FunctionalType::GraphFact)
267    {
268        fetchers.push(Box::pin(async move {
269            fetch_graph_facts(memory, query, alloc.graph_facts, tc)
270                .await
271                .map(ContextSlot::GraphFacts)
272        }));
273    }
274    if declarative_active
275        && memory.persona_config.context_budget_tokens > 0
276        && type_active(active_types, FunctionalType::UserFact)
277    {
278        fetchers.push(Box::pin(async move {
279            let persona_budget = memory.persona_config.context_budget_tokens;
280            fetch_persona_facts(memory, persona_budget, tc)
281                .await
282                .map(ContextSlot::PersonaFacts)
283        }));
284    }
285    // Trajectory hints are not yet a gated FunctionalType (spec 004-16 §4: v2 extension) — stays
286    // always-composed within its existing `procedural_active` activity gate.
287    if procedural_active && memory.trajectory_config.context_budget_tokens > 0 {
288        fetchers.push(Box::pin(async move {
289            let tbudget = memory.trajectory_config.context_budget_tokens;
290            fetch_trajectory_hints(memory, tbudget, tc)
291                .await
292                .map(ContextSlot::TrajectoryHints)
293        }));
294    }
295    // Tree memory is not yet a gated FunctionalType (spec 004-16 §4: v2 extension) — stays
296    // always-composed within its existing `declarative_active` activity gate.
297    if declarative_active && memory.tree_config.context_budget_tokens > 0 {
298        fetchers.push(Box::pin(async move {
299            let tbudget = memory.tree_config.context_budget_tokens;
300            fetch_tree_memory(memory, tbudget, tc)
301                .await
302                .map(ContextSlot::TreeMemory)
303        }));
304    }
305    if procedural_active
306        && memory.reasoning_config.enabled
307        && memory.reasoning_config.context_budget_tokens > 0
308        && type_active(active_types, FunctionalType::ReasoningStrategy)
309    {
310        fetchers.push(Box::pin(async move {
311            let rbudget = memory.reasoning_config.context_budget_tokens;
312            let top_k = memory.reasoning_config.top_k;
313            fetch_reasoning_strategies(memory, query, rbudget, top_k, tc)
314                .await
315                .map(|(msg, handle)| ContextSlot::ReasoningStrategies(msg, handle))
316        }));
317    }
318
319    fetchers
320}
321
322async fn drive_fetchers(
323    mut fetchers: FuturesUnordered<CtxFuture<'_>>,
324    prepared: &mut PreparedContext,
325) -> Result<(), AssemblerError> {
326    while let Some(result) = fetchers.next().await {
327        match result {
328            Ok(slot) => match slot {
329                ContextSlot::Summaries(msg) => prepared.summaries = msg,
330                ContextSlot::CrossSession(msg) => prepared.cross_session = msg,
331                ContextSlot::SemanticRecall(msg, score) => {
332                    prepared.recall = msg;
333                    prepared.recall_confidence = score;
334                }
335                ContextSlot::DocumentRag(msg) => prepared.doc_rag = msg,
336                ContextSlot::Corrections(msg) => prepared.corrections = msg,
337                ContextSlot::CodeContext(text) => prepared.code_context = text,
338                ContextSlot::GraphFacts(msg) => prepared.graph_facts = msg,
339                ContextSlot::PersonaFacts(msg) => prepared.persona_facts = msg,
340                ContextSlot::TrajectoryHints(msg) => prepared.trajectory_hints = msg,
341                ContextSlot::TreeMemory(msg) => prepared.tree_memory = msg,
342                ContextSlot::ReasoningStrategies(msg, handle) => {
343                    prepared.reasoning_hints = msg;
344                    if let Some(h) = handle {
345                        prepared.background_tasks.push(h);
346                    }
347                }
348            },
349            Err(e) => return Err(e),
350        }
351    }
352    Ok(())
353}
354
355impl ContextAssembler {
356    /// Gather all context sources concurrently and return a [`PreparedContext`].
357    ///
358    /// Returns an empty `PreparedContext` immediately when `context_manager.budget` is `None`.
359    ///
360    /// # Errors
361    ///
362    /// Propagates errors from any async fetch operation.
363    #[tracing::instrument(
364        name = "context.assembler.gather",
365        skip_all,
366        fields(active_types = ?input.active_types)
367    )]
368    pub async fn gather(
369        input: &ContextAssemblyInput<'_>,
370    ) -> Result<PreparedContext, AssemblerError> {
371        let Some(ref budget) = input.context_manager.budget else {
372            return Ok(empty_prepared_context());
373        };
374
375        let memory = input.memory;
376        let tc = input.token_counter;
377
378        let effective_strategy = resolve_effective_strategy(memory, input.sidequest_turn_counter);
379        let memory_first = effective_strategy == zeph_config::ContextStrategy::MemoryFirst;
380
381        let system_prompt = input
382            .messages
383            .first()
384            .filter(|m| m.role == Role::System)
385            .map_or("", |m| m.content.as_str());
386
387        let digest_tokens = memory
388            .cached_session_digest
389            .as_ref()
390            .map_or(0, |(_, tokens)| *tokens);
391
392        let alloc = budget.allocate_with_opts(
393            system_prompt,
394            input.skills_prompt,
395            tc,
396            memory.graph_config.enabled,
397            digest_tokens,
398            memory_first,
399        );
400
401        let (recall_limit, min_sim) = correction_params(input.correction_config.as_ref());
402
403        let router_ref: &dyn AsyncMemoryRouter = input.router.as_ref();
404
405        tracing::debug!(
406            active_sources = alloc.active_sources(),
407            active_levels = ?input.active_levels,
408            "context budget allocated"
409        );
410
411        let fetchers = schedule_context_fetchers(
412            memory,
413            tc,
414            input.query,
415            input.scrub,
416            input.index,
417            router_ref,
418            &alloc,
419            recall_limit,
420            min_sim,
421            input.active_levels,
422            input.active_types,
423        );
424
425        let mut prepared = empty_prepared_context();
426        prepared.memory_first = memory_first;
427        prepared.recent_history_budget = alloc.recent_history;
428
429        drive_fetchers(fetchers, &mut prepared).await?;
430        Ok(prepared)
431    }
432}
433
434/// Clamp recall timeout to a safe minimum.
435///
436/// A configured value of 0 would disable spreading activation recall entirely;
437/// clamping to 100ms preserves the user's intent while preventing a silent no-op.
438pub fn effective_recall_timeout_ms(configured: u64) -> u64 {
439    if configured == 0 {
440        tracing::warn!(
441            "recall_timeout_ms is 0, which would disable spreading activation recall; \
442             clamping to 100ms"
443        );
444        100
445    } else {
446        configured
447    }
448}
449
450use crate::input::ContextMemoryView;
451
452#[tracing::instrument(name = "context.graph_facts", skip_all)]
453#[allow(clippy::too_many_lines)] // single-pass view-aware enrichment pipeline
454pub(crate) async fn fetch_graph_facts(
455    memory: &ContextMemoryView,
456    query: &str,
457    budget_tokens: usize,
458    tc: &dyn TokenCounting,
459) -> Result<Option<Message>, AssemblerError> {
460    use zeph_common::memory::{RecallView, SpreadingActivationParams, classify_graph_subgraph};
461
462    if budget_tokens == 0 || !memory.graph_config.enabled {
463        return Ok(None);
464    }
465    let Some(ref mem) = memory.memory else {
466        return Ok(None);
467    };
468    let recall_limit = memory.graph_config.recall_limit;
469    let temporal_decay_rate = memory.graph_config.temporal_decay_rate;
470    let sa_config = &memory.graph_config.spreading_activation;
471
472    // Fuse MemCoT semantic state into the recall query (spec §A8: state ≤ 2 × query.len()).
473    let fused_query;
474    let effective_query = if let Some(ref state) = memory.memcot_state {
475        let max_state_chars = 2 * query.len();
476        let state_slice = if state.len() > max_state_chars {
477            let boundary = state.floor_char_boundary(max_state_chars);
478            &state[..boundary]
479        } else {
480            state.as_str()
481        };
482        fused_query = format!("[state] {state_slice}\n{query}");
483        &fused_query as &str
484    } else {
485        query
486    };
487
488    let edge_types = classify_graph_subgraph(effective_query);
489
490    let view = match memory.memcot_config.recall_view {
491        zeph_config::RecallViewConfig::ZoomIn => RecallView::ZoomIn,
492        zeph_config::RecallViewConfig::ZoomOut => RecallView::ZoomOut,
493        _ => RecallView::Head,
494    };
495
496    // Built unconditionally (not gated on `sa_config.enabled`) so it is available both for
497    // an explicit `Synapse` strategy selection and for `Hybrid`'s classifier-fallback arm.
498    let sa_params = Some(SpreadingActivationParams {
499        decay_lambda: sa_config.decay_lambda,
500        max_hops: sa_config.max_hops,
501        activation_threshold: sa_config.activation_threshold,
502        inhibition_threshold: sa_config.inhibition_threshold,
503        max_activated_nodes: sa_config.max_activated_nodes,
504        temporal_decay_rate,
505        seed_structural_weight: sa_config.seed_structural_weight,
506        seed_community_cap: sa_config.seed_community_cap,
507        alpha: sa_config.alpha,
508    });
509
510    // `sa_config.enabled` overrides any configured strategy to `Synapse`; otherwise the
511    // configured `retrieval_strategy` takes effect verbatim.
512    let retrieval_strategy = if sa_config.enabled {
513        zeph_common::memory::GraphRetrievalStrategy::Synapse
514    } else {
515        match memory.graph_config.retrieval_strategy {
516            zeph_config::memory::GraphRetrievalStrategy::Synapse => {
517                zeph_common::memory::GraphRetrievalStrategy::Synapse
518            }
519            zeph_config::memory::GraphRetrievalStrategy::Bfs => {
520                zeph_common::memory::GraphRetrievalStrategy::Bfs
521            }
522            zeph_config::memory::GraphRetrievalStrategy::AStar => {
523                zeph_common::memory::GraphRetrievalStrategy::AStar
524            }
525            zeph_config::memory::GraphRetrievalStrategy::WaterCircles => {
526                zeph_common::memory::GraphRetrievalStrategy::WaterCircles
527            }
528            zeph_config::memory::GraphRetrievalStrategy::BeamSearch => {
529                zeph_common::memory::GraphRetrievalStrategy::BeamSearch
530            }
531            zeph_config::memory::GraphRetrievalStrategy::Hybrid => {
532                zeph_common::memory::GraphRetrievalStrategy::Hybrid
533            }
534            _ => zeph_common::memory::GraphRetrievalStrategy::Synapse,
535        }
536    };
537
538    let timeout_ms = effective_recall_timeout_ms(sa_config.recall_timeout_ms);
539    let recall_fut = mem.recall_graph_facts(
540        effective_query,
541        GraphRecallParams {
542            limit: recall_limit,
543            view,
544            zoom_out_neighbor_cap: memory.memcot_config.zoom_out_neighbor_cap,
545            max_hops: memory.graph_config.max_hops,
546            temporal_decay_rate,
547            edge_types: &edge_types,
548            spreading_activation: sa_params,
549            retrieval_strategy,
550            beam_width: memory.graph_config.beam_search.beam_width,
551            ring_limit: memory.graph_config.watercircles.ring_limit,
552        },
553    );
554    let recalled = match tokio::time::timeout(
555        std::time::Duration::from_millis(timeout_ms),
556        recall_fut,
557    )
558    .await
559    {
560        Ok(Ok(facts)) => facts,
561        Ok(Err(e)) => {
562            tracing::warn!("graph recall failed: {e:#}");
563            Vec::new()
564        }
565        Err(_) => {
566            tracing::warn!("graph recall timed out ({timeout_ms}ms)");
567            Vec::new()
568        }
569    };
570
571    if recalled.is_empty() {
572        return Ok(None);
573    }
574
575    let mut body = String::from(GRAPH_FACTS_PREFIX);
576    let mut tokens_so_far = tc.count_tokens(&body);
577
578    for rf in &recalled {
579        let fact_text = rf.fact.replace(['\n', '\r', '<', '>'], " ");
580        let line = if let Some(score) = rf.activation_score {
581            format!(
582                "- {} (confidence: {:.2}, activation: {:.2})\n",
583                fact_text, rf.confidence, score
584            )
585        } else {
586            format!("- {} (confidence: {:.2})\n", fact_text, rf.confidence)
587        };
588        let line_tokens = tc.count_tokens(&line);
589        if tokens_so_far + line_tokens > budget_tokens {
590            break;
591        }
592        body.push_str(&line);
593        tokens_so_far += line_tokens;
594
595        // Append ZoomOut neighbors after the head fact.
596        for nb in &rf.neighbors {
597            let nb_text = nb.fact.replace(['\n', '\r', '<', '>'], " ");
598            let nb_line = format!("  ~ {} (confidence: {:.2})\n", nb_text, nb.confidence);
599            let nb_tokens = tc.count_tokens(&nb_line);
600            if tokens_so_far + nb_tokens > budget_tokens {
601                break;
602            }
603            body.push_str(&nb_line);
604            tokens_so_far += nb_tokens;
605        }
606
607        // Append ZoomIn provenance snippet if present.
608        if let Some(ref snippet) = rf.provenance_snippet {
609            let snip_line = format!(
610                "  [source: {}]\n",
611                snippet.replace(['\n', '\r', '<', '>'], " ")
612            );
613            let snip_tokens = tc.count_tokens(&snip_line);
614            if tokens_so_far + snip_tokens <= budget_tokens {
615                body.push_str(&snip_line);
616                tokens_so_far += snip_tokens;
617            }
618        }
619    }
620
621    if body == GRAPH_FACTS_PREFIX {
622        return Ok(None);
623    }
624
625    Ok(Some(Message::from_legacy(Role::System, body)))
626}
627
628/// Greedily append pre-formatted `lines` to a `prefix` while staying within `budget_tokens`.
629///
630/// Shared by fetchers whose body is "prefix + one line per recalled item, truncated at
631/// budget" (persona facts, trajectory hints, tree memory, semantic recall, document RAG,
632/// summaries, cross-session recall). Returns `None` when no line fit (i.e. the body is
633/// still just `prefix`), signalling the caller to skip injection entirely.
634fn append_budgeted_lines(
635    prefix: &str,
636    lines: impl Iterator<Item = String>,
637    budget_tokens: usize,
638    tc: &dyn TokenCounting,
639) -> Option<String> {
640    let mut body = String::from(prefix);
641    let mut tokens_so_far = tc.count_tokens(&body);
642
643    for line in lines {
644        let line_tokens = tc.count_tokens(&line);
645        if tokens_so_far + line_tokens > budget_tokens {
646            break;
647        }
648        body.push_str(&line);
649        tokens_so_far += line_tokens;
650    }
651
652    if body == prefix { None } else { Some(body) }
653}
654
655/// Runs `fut` under [`MEMORY_FETCH_TIMEOUT_MS`], degrading to an empty `Vec` and logging a
656/// warning tagged with `label` on timeout, while still propagating a genuine backend error.
657///
658/// Shared by fetchers whose failure mode is "stalled backend degrades to no results" (persona
659/// facts, trajectory hints, tree memory, reasoning strategies, corrections, semantic recall,
660/// document RAG, summaries, cross-session recall). `fetch_graph_facts` needs a three-way
661/// timeout/error/success split — it treats a backend error the same as a timeout — and is not
662/// a fit for this helper.
663async fn fetch_with_timeout<T>(
664    label: &str,
665    fut: impl Future<Output = Result<Vec<T>, Box<dyn std::error::Error + Send + Sync>>>,
666) -> Result<Vec<T>, AssemblerError> {
667    if let Ok(result) = tokio::time::timeout(
668        std::time::Duration::from_millis(MEMORY_FETCH_TIMEOUT_MS),
669        fut,
670    )
671    .await
672    {
673        result.map_err(AssemblerError::Memory)
674    } else {
675        tracing::warn!("{label} timed out ({MEMORY_FETCH_TIMEOUT_MS}ms)");
676        Ok(Vec::new())
677    }
678}
679
680#[tracing::instrument(name = "context.persona_facts", skip_all)]
681pub(crate) async fn fetch_persona_facts(
682    memory: &ContextMemoryView,
683    budget_tokens: usize,
684    tc: &dyn TokenCounting,
685) -> Result<Option<Message>, AssemblerError> {
686    if budget_tokens == 0 || !memory.persona_config.enabled {
687        return Ok(None);
688    }
689    let Some(ref mem) = memory.memory else {
690        return Ok(None);
691    };
692
693    let min_confidence = memory.persona_config.min_confidence;
694    let facts =
695        fetch_with_timeout("persona facts load", mem.load_persona_facts(min_confidence)).await?;
696
697    if facts.is_empty() {
698        return Ok(None);
699    }
700
701    let lines = facts
702        .iter()
703        .map(|fact| format!("[{}] {}\n", fact.category, fact.content));
704    Ok(
705        append_budgeted_lines(crate::slot::PERSONA_PREFIX, lines, budget_tokens, tc)
706            .map(|body| Message::from_legacy(Role::System, body)),
707    )
708}
709
710#[tracing::instrument(name = "context.trajectory_hints", skip_all)]
711pub(crate) async fn fetch_trajectory_hints(
712    memory: &ContextMemoryView,
713    budget_tokens: usize,
714    tc: &dyn TokenCounting,
715) -> Result<Option<Message>, AssemblerError> {
716    if budget_tokens == 0 || !memory.trajectory_config.enabled {
717        return Ok(None);
718    }
719    let Some(ref mem) = memory.memory else {
720        return Ok(None);
721    };
722
723    let top_k = memory.trajectory_config.recall_top_k;
724    let min_conf = memory.trajectory_config.min_confidence;
725    // Load procedural trajectory entries via the backend abstraction.
726    // The "procedural" filter maps to the same tier used by the original
727    // sqlite().load_trajectory_entries(Some("procedural"), top_k) call.
728    let entries = fetch_with_timeout(
729        "trajectory entries load",
730        mem.load_trajectory_entries(Some("procedural"), top_k),
731    )
732    .await?;
733
734    if entries.is_empty() {
735        return Ok(None);
736    }
737
738    let lines = entries
739        .iter()
740        .filter(|e| e.confidence >= min_conf)
741        .take(top_k)
742        .map(|entry| format!("- {}: {}\n", entry.intent, entry.outcome));
743    Ok(
744        append_budgeted_lines(crate::slot::TRAJECTORY_PREFIX, lines, budget_tokens, tc)
745            .map(|body| Message::from_legacy(Role::System, body)),
746    )
747}
748
749#[tracing::instrument(name = "context.tree_memory", skip_all)]
750pub(crate) async fn fetch_tree_memory(
751    memory: &ContextMemoryView,
752    budget_tokens: usize,
753    tc: &dyn TokenCounting,
754) -> Result<Option<Message>, AssemblerError> {
755    if budget_tokens == 0 || !memory.tree_config.enabled {
756        return Ok(None);
757    }
758    let Some(ref mem) = memory.memory else {
759        return Ok(None);
760    };
761
762    let top_k = memory.tree_config.recall_top_k;
763    let nodes = fetch_with_timeout("tree nodes load", mem.load_tree_nodes(1, top_k)).await?;
764
765    if nodes.is_empty() {
766        return Ok(None);
767    }
768
769    let lines = nodes
770        .iter()
771        .take(top_k)
772        .map(|node| format!("- {}\n", node.content));
773    Ok(
774        append_budgeted_lines(crate::slot::TREE_MEMORY_PREFIX, lines, budget_tokens, tc)
775            .map(|body| Message::from_legacy(Role::System, body)),
776    )
777}
778
779#[tracing::instrument(name = "context.reasoning_strategies", skip_all)]
780pub(crate) async fn fetch_reasoning_strategies(
781    memory: &ContextMemoryView,
782    query: &str,
783    budget_tokens: usize,
784    top_k: usize,
785    tc: &dyn TokenCounting,
786) -> Result<(Option<Message>, Option<tokio::task::JoinHandle<()>>), AssemblerError> {
787    // S1: enforce the ≤500-token spec cap documented in ReasoningConfig.
788    let budget_tokens = budget_tokens.min(500);
789    if budget_tokens == 0 {
790        return Ok((None, None));
791    }
792    let Some(ref mem) = memory.memory else {
793        return Ok((None, None));
794    };
795
796    let strategies = fetch_with_timeout(
797        "reasoning strategies retrieval",
798        mem.retrieve_reasoning_strategies(query, top_k),
799    )
800    .await?;
801
802    if strategies.is_empty() {
803        return Ok((None, None));
804    }
805
806    let mut body = String::from(crate::slot::REASONING_PREFIX);
807    let mut tokens_so_far = tc.count_tokens(&body);
808    let mut injected_ids: Vec<String> = Vec::new();
809
810    for s in strategies.iter().take(top_k) {
811        // S-Med1: sanitize distilled summaries to prevent stored injection payloads
812        // from reaching the system prompt (mirrors fetch_graph_facts scrub pattern).
813        let safe_summary = s.summary.replace(['\n', '\r', '<', '>'], " ");
814        let line = format!("- [{}] {}\n", s.outcome, safe_summary);
815        let line_tokens = tc.count_tokens(&line);
816        if tokens_so_far + line_tokens > budget_tokens {
817            break;
818        }
819        body.push_str(&line);
820        tokens_so_far += line_tokens;
821        injected_ids.push(s.id.clone());
822    }
823
824    if body == crate::slot::REASONING_PREFIX {
825        return Ok((None, None));
826    }
827
828    // C4 split: mark_used only for strategies that made it past budget truncation.
829    // Spawn the task and return the handle so the caller can track it (async discipline rule:
830    // fire-and-forget tasks MUST be tracked; handle stored in PreparedContext::background_tasks).
831    let handle = if injected_ids.is_empty() {
832        None
833    } else {
834        let mem_clone = mem.clone();
835        let mark_used = async move {
836            if let Err(e) = mem_clone.mark_reasoning_used(&injected_ids).await {
837                tracing::warn!(error = %e, "reasoning: mark_used failed");
838            }
839        };
840        Some(tokio::spawn(mark_used)) // EXEMPT: handle returned to caller via PreparedContext::background_tasks
841    };
842
843    Ok((Some(Message::from_legacy(Role::System, body)), handle))
844}
845
846#[tracing::instrument(name = "context.corrections", skip_all)]
847pub(crate) async fn fetch_corrections(
848    memory: &ContextMemoryView,
849    query: &str,
850    limit: usize,
851    min_score: f32,
852    scrub: fn(&str) -> std::borrow::Cow<'_, str>,
853) -> Result<Option<Message>, AssemblerError> {
854    let Some(ref mem) = memory.memory else {
855        return Ok(None);
856    };
857    let corrections = fetch_with_timeout(
858        "corrections retrieval",
859        mem.retrieve_corrections(query, limit, min_score),
860    )
861    .await?;
862    if corrections.is_empty() {
863        return Ok(None);
864    }
865    let mut text = String::from(CORRECTIONS_PREFIX);
866    for c in &corrections {
867        text.push_str("- Past user correction: \"");
868        text.push_str(&scrub(&c.correction_text));
869        text.push_str("\"\n");
870    }
871    Ok(Some(Message::from_legacy(Role::System, text)))
872}
873
874#[tracing::instrument(name = "context.semantic_recall", skip_all)]
875pub(crate) async fn fetch_semantic_recall(
876    memory: &ContextMemoryView,
877    query: &str,
878    token_budget: usize,
879    tc: &dyn TokenCounting,
880    router: Option<&dyn AsyncMemoryRouter>,
881) -> Result<(Option<Message>, Option<f32>), AssemblerError> {
882    let Some(ref mem) = memory.memory else {
883        return Ok((None, None));
884    };
885    if memory.recall_limit == 0 || token_budget == 0 {
886        return Ok((None, None));
887    }
888
889    let recalled = fetch_with_timeout(
890        "semantic recall",
891        mem.recall(query, memory.recall_limit, router),
892    )
893    .await?;
894    if recalled.is_empty() {
895        return Ok((None, None));
896    }
897
898    let top_score = recalled.first().map(|r| r.score);
899
900    let lines = recalled
901        .iter()
902        .filter(|item| {
903            !item.content.starts_with("[skipped]") && !item.content.starts_with("[stopped]")
904        })
905        .map(|item| format!("- [{}] {}\n", item.role, item.content));
906
907    match append_budgeted_lines(RECALL_PREFIX, lines, token_budget, tc) {
908        Some(text) => Ok((
909            Some(Message::from_parts(
910                Role::System,
911                vec![MessagePart::Recall { text }],
912            )),
913            top_score,
914        )),
915        None => Ok((None, None)),
916    }
917}
918
919#[tracing::instrument(name = "context.document_rag", skip_all)]
920pub(crate) async fn fetch_document_rag(
921    memory: &ContextMemoryView,
922    query: &str,
923    token_budget: usize,
924    tc: &dyn TokenCounting,
925) -> Result<Option<Message>, AssemblerError> {
926    if !memory.document_config.rag_enabled || token_budget == 0 {
927        return Ok(None);
928    }
929    let Some(ref mem) = memory.memory else {
930        return Ok(None);
931    };
932
933    let collection = &memory.document_config.collection;
934    let top_k = memory.document_config.top_k;
935    let chunks = fetch_with_timeout(
936        "document RAG search",
937        mem.search_document_collection(collection, query, top_k),
938    )
939    .await?;
940    if chunks.is_empty() {
941        return Ok(None);
942    }
943
944    let lines = chunks
945        .iter()
946        .filter(|chunk| !chunk.text.is_empty())
947        .map(|chunk| format!("{}\n", chunk.text));
948
949    Ok(
950        append_budgeted_lines(DOCUMENT_RAG_PREFIX, lines, token_budget, tc).map(|text| Message {
951            role: Role::System,
952            content: text,
953            parts: vec![],
954            metadata: MessageMetadata::default(),
955        }),
956    )
957}
958
959#[tracing::instrument(name = "context.summaries", skip_all)]
960pub(crate) async fn fetch_summaries(
961    memory: &ContextMemoryView,
962    token_budget: usize,
963    tc: &dyn TokenCounting,
964) -> Result<Option<Message>, AssemblerError> {
965    let (Some(mem), Some(cid)) = (&memory.memory, memory.conversation_id) else {
966        return Ok(None);
967    };
968    if token_budget == 0 {
969        return Ok(None);
970    }
971
972    let summaries = fetch_with_timeout("summaries load", mem.load_summaries(cid)).await?;
973    if summaries.is_empty() {
974        return Ok(None);
975    }
976
977    let lines = summaries.iter().rev().map(|summary| {
978        let first = summary.first_message_id.unwrap_or(0);
979        let last = summary.last_message_id.unwrap_or(0);
980        format!("- Messages {first}-{last}: {}\n", summary.content)
981    });
982
983    Ok(
984        append_budgeted_lines(SUMMARY_PREFIX, lines, token_budget, tc)
985            .map(|text| Message::from_parts(Role::System, vec![MessagePart::Summary { text }])),
986    )
987}
988
989#[tracing::instrument(name = "context.cross_session", skip_all)]
990pub(crate) async fn fetch_cross_session(
991    memory: &ContextMemoryView,
992    query: &str,
993    token_budget: usize,
994    tc: &dyn TokenCounting,
995) -> Result<Option<Message>, AssemblerError> {
996    let (Some(mem), Some(cid)) = (&memory.memory, memory.conversation_id) else {
997        return Ok(None);
998    };
999    if token_budget == 0 {
1000        return Ok(None);
1001    }
1002
1003    let threshold = memory.cross_session_score_threshold;
1004    let summaries = fetch_with_timeout(
1005        "cross-session search",
1006        mem.search_session_summaries(query, 5, Some(cid)),
1007    )
1008    .await?;
1009    let results: Vec<_> = summaries
1010        .into_iter()
1011        .filter(|r| r.score >= threshold)
1012        .collect();
1013    if results.is_empty() {
1014        return Ok(None);
1015    }
1016
1017    let lines = results
1018        .iter()
1019        .map(|item| format!("- {}\n", item.summary_text));
1020
1021    Ok(
1022        append_budgeted_lines(CROSS_SESSION_PREFIX, lines, token_budget, tc).map(|text| {
1023            Message::from_parts(Role::System, vec![MessagePart::CrossSession { text }])
1024        }),
1025    )
1026}
1027
1028/// Maximum number of messages scanned backward by [`memory_first_keep_tail`] before
1029/// stopping at the next non-`ToolResult` boundary, to avoid O(N) scans on long sessions.
1030pub const MAX_KEEP_TAIL_SCAN: usize = 50;
1031
1032/// Compute how many tail messages to keep when the `MemoryFirst` strategy is active.
1033///
1034/// Always keeps at least 2 messages. Extends the tail as long as the boundary message is
1035/// a `ToolResult` (user message with a `ToolResult` part) to avoid splitting a tool-call
1036/// round-trip. Capped at `MAX_KEEP_TAIL_SCAN` to prevent O(N) scans on long sessions.
1037///
1038/// `history_start` is the index of the first non-system message (typically 1).
1039#[must_use]
1040pub fn memory_first_keep_tail(messages: &[Message], history_start: usize) -> usize {
1041    use zeph_llm::provider::MessagePart;
1042
1043    let mut keep_tail = 2usize;
1044    let len = messages.len();
1045    let max = len.saturating_sub(history_start);
1046
1047    while keep_tail < max {
1048        let first_retained = &messages[len - keep_tail];
1049        let is_tool_result = first_retained.role == Role::User
1050            && first_retained
1051                .parts
1052                .iter()
1053                .any(|p| matches!(p, MessagePart::ToolResult { .. }));
1054
1055        if is_tool_result {
1056            keep_tail += 1;
1057        } else {
1058            break;
1059        }
1060
1061        if keep_tail >= MAX_KEEP_TAIL_SCAN {
1062            let preceding_idx = len.saturating_sub(keep_tail + 1);
1063            if preceding_idx >= history_start {
1064                let preceding = &messages[preceding_idx];
1065                let is_tool_use = preceding.role == Role::Assistant
1066                    && preceding
1067                        .parts
1068                        .iter()
1069                        .any(|p| matches!(p, MessagePart::ToolUse { .. }));
1070                if is_tool_use {
1071                    keep_tail += 1;
1072                }
1073            }
1074            break;
1075        }
1076    }
1077
1078    keep_tail
1079}
1080
1081#[cfg(test)]
1082mod tests {
1083    use super::*;
1084    use crate::input::ContextMemoryView;
1085    use zeph_common::memory::CompressionLevel;
1086    use zeph_config::{
1087        ContextStrategy, DocumentConfig, GraphConfig, PersonaConfig, ReasoningConfig,
1088        TrajectoryConfig, TreeConfig,
1089    };
1090
1091    struct NaiveTokenCounter;
1092    impl zeph_common::memory::TokenCounting for NaiveTokenCounter {
1093        fn count_tokens(&self, text: &str) -> usize {
1094            text.split_whitespace().count()
1095        }
1096        fn count_tool_schema_tokens(&self, schema: &serde_json::Value) -> usize {
1097            schema.to_string().split_whitespace().count()
1098        }
1099    }
1100
1101    fn empty_view() -> ContextMemoryView {
1102        ContextMemoryView {
1103            memory: None,
1104            conversation_id: None,
1105            recall_limit: 10,
1106            cross_session_score_threshold: 0.5,
1107            context_strategy: ContextStrategy::default(),
1108            crossover_turn_threshold: 5,
1109            cached_session_digest: None,
1110            graph_config: GraphConfig::default(),
1111            document_config: DocumentConfig::default(),
1112            persona_config: PersonaConfig::default(),
1113            trajectory_config: TrajectoryConfig::default(),
1114            reasoning_config: ReasoningConfig::default(),
1115            memcot_config: zeph_config::MemCotConfig::default(),
1116            memcot_state: None,
1117            tree_config: TreeConfig::default(),
1118        }
1119    }
1120
1121    // ── fetch_graph_facts ─────────────────────────────────────────────────────
1122
1123    #[tokio::test]
1124    async fn fetch_graph_facts_returns_none_when_memory_is_none() {
1125        let view = empty_view();
1126        let tc = NaiveTokenCounter;
1127        let result = fetch_graph_facts(&view, "test", 1000, &tc).await.unwrap();
1128        assert!(result.is_none());
1129    }
1130
1131    #[tokio::test]
1132    async fn fetch_graph_facts_returns_none_when_budget_zero() {
1133        let mut view = empty_view();
1134        view.graph_config.enabled = true;
1135        let tc = NaiveTokenCounter;
1136        let result = fetch_graph_facts(&view, "test", 0, &tc).await.unwrap();
1137        assert!(result.is_none());
1138    }
1139
1140    #[tokio::test]
1141    async fn fetch_graph_facts_returns_none_when_graph_disabled() {
1142        let mut view = empty_view();
1143        view.graph_config.enabled = false;
1144        let tc = NaiveTokenCounter;
1145        let result = fetch_graph_facts(&view, "test", 1000, &tc).await.unwrap();
1146        assert!(result.is_none());
1147    }
1148
1149    // ── fetch_persona_facts ───────────────────────────────────────────────────
1150
1151    #[tokio::test]
1152    async fn fetch_persona_facts_returns_none_when_memory_is_none() {
1153        let view = empty_view();
1154        let tc = NaiveTokenCounter;
1155        let result = fetch_persona_facts(&view, 1000, &tc).await.unwrap();
1156        assert!(result.is_none());
1157    }
1158
1159    #[tokio::test]
1160    async fn fetch_persona_facts_returns_none_when_budget_zero() {
1161        let mut view = empty_view();
1162        view.persona_config.enabled = true;
1163        let tc = NaiveTokenCounter;
1164        let result = fetch_persona_facts(&view, 0, &tc).await.unwrap();
1165        assert!(result.is_none());
1166    }
1167
1168    // ── fetch_trajectory_hints ────────────────────────────────────────────────
1169
1170    #[tokio::test]
1171    async fn fetch_trajectory_hints_returns_none_when_memory_is_none() {
1172        let view = empty_view();
1173        let tc = NaiveTokenCounter;
1174        let result = fetch_trajectory_hints(&view, 1000, &tc).await.unwrap();
1175        assert!(result.is_none());
1176    }
1177
1178    #[tokio::test]
1179    async fn fetch_trajectory_hints_returns_none_when_budget_zero() {
1180        let mut view = empty_view();
1181        view.trajectory_config.enabled = true;
1182        let tc = NaiveTokenCounter;
1183        let result = fetch_trajectory_hints(&view, 0, &tc).await.unwrap();
1184        assert!(result.is_none());
1185    }
1186
1187    // ── fetch_tree_memory ─────────────────────────────────────────────────────
1188
1189    #[tokio::test]
1190    async fn fetch_tree_memory_returns_none_when_memory_is_none() {
1191        let view = empty_view();
1192        let tc = NaiveTokenCounter;
1193        let result = fetch_tree_memory(&view, 1000, &tc).await.unwrap();
1194        assert!(result.is_none());
1195    }
1196
1197    #[tokio::test]
1198    async fn fetch_tree_memory_returns_none_when_budget_zero() {
1199        let mut view = empty_view();
1200        view.tree_config.enabled = true;
1201        let tc = NaiveTokenCounter;
1202        let result = fetch_tree_memory(&view, 0, &tc).await.unwrap();
1203        assert!(result.is_none());
1204    }
1205
1206    // ── fetch_corrections ─────────────────────────────────────────────────────
1207
1208    #[tokio::test]
1209    async fn fetch_corrections_returns_none_when_memory_is_none() {
1210        let view = empty_view();
1211        let result = fetch_corrections(&view, "test", 10, 0.5, |s| s.into())
1212            .await
1213            .unwrap();
1214        assert!(result.is_none());
1215    }
1216
1217    // ── fetch_semantic_recall ─────────────────────────────────────────────────
1218
1219    #[tokio::test]
1220    async fn fetch_semantic_recall_returns_none_when_memory_is_none() {
1221        let view = empty_view();
1222        let tc = NaiveTokenCounter;
1223        let result = fetch_semantic_recall(&view, "test", 1000, &tc, None)
1224            .await
1225            .unwrap();
1226        assert!(result.0.is_none() && result.1.is_none());
1227    }
1228
1229    #[tokio::test]
1230    async fn fetch_semantic_recall_returns_none_when_budget_zero() {
1231        let view = empty_view();
1232        let tc = NaiveTokenCounter;
1233        let result = fetch_semantic_recall(&view, "test", 0, &tc, None)
1234            .await
1235            .unwrap();
1236        assert!(result.0.is_none() && result.1.is_none());
1237    }
1238
1239    // ── fetch_document_rag ────────────────────────────────────────────────────
1240
1241    #[tokio::test]
1242    async fn fetch_document_rag_returns_none_when_memory_is_none() {
1243        let mut view = empty_view();
1244        view.document_config.rag_enabled = true;
1245        let tc = NaiveTokenCounter;
1246        let result = fetch_document_rag(&view, "test", 1000, &tc).await.unwrap();
1247        assert!(result.is_none());
1248    }
1249
1250    #[tokio::test]
1251    async fn fetch_document_rag_returns_none_when_rag_disabled() {
1252        let view = empty_view();
1253        let tc = NaiveTokenCounter;
1254        let result = fetch_document_rag(&view, "test", 1000, &tc).await.unwrap();
1255        assert!(result.is_none());
1256    }
1257
1258    // ── fetch_summaries ───────────────────────────────────────────────────────
1259
1260    #[tokio::test]
1261    async fn fetch_summaries_returns_none_when_memory_is_none() {
1262        let view = empty_view();
1263        let tc = NaiveTokenCounter;
1264        let result = fetch_summaries(&view, 1000, &tc).await.unwrap();
1265        assert!(result.is_none());
1266    }
1267
1268    // ── fetch_cross_session ───────────────────────────────────────────────────
1269
1270    #[tokio::test]
1271    async fn fetch_cross_session_returns_none_when_memory_is_none() {
1272        let view = empty_view();
1273        let tc = NaiveTokenCounter;
1274        let result = fetch_cross_session(&view, "test", 1000, &tc).await.unwrap();
1275        assert!(result.is_none());
1276    }
1277
1278    // ── levels_to_flags ───────────────────────────────────────────────────────
1279
1280    #[test]
1281    fn levels_to_flags_empty_slice_enables_all_tiers() {
1282        let (e, p, d) = levels_to_flags(&[]);
1283        assert!(e, "episodic should be active for empty slice");
1284        assert!(p, "procedural should be active for empty slice");
1285        assert!(d, "declarative should be active for empty slice");
1286    }
1287
1288    #[test]
1289    fn levels_to_flags_full_set_enables_all_tiers() {
1290        let all = &[
1291            CompressionLevel::Episodic,
1292            CompressionLevel::Procedural,
1293            CompressionLevel::Declarative,
1294        ];
1295        let (e, p, d) = levels_to_flags(all);
1296        assert!(e);
1297        assert!(p);
1298        assert!(d);
1299    }
1300
1301    #[test]
1302    fn levels_to_flags_episodic_only() {
1303        let (e, p, d) = levels_to_flags(&[CompressionLevel::Episodic]);
1304        assert!(e);
1305        assert!(!p, "procedural should be inactive");
1306        assert!(!d, "declarative should be inactive");
1307    }
1308
1309    #[test]
1310    fn levels_to_flags_episodic_and_procedural() {
1311        let (e, p, d) =
1312            levels_to_flags(&[CompressionLevel::Episodic, CompressionLevel::Procedural]);
1313        assert!(e);
1314        assert!(p);
1315        assert!(!d, "declarative should be inactive");
1316    }
1317
1318    #[test]
1319    fn levels_to_flags_declarative_only() {
1320        let (e, p, d) = levels_to_flags(&[CompressionLevel::Declarative]);
1321        assert!(!e, "episodic should be inactive");
1322        assert!(!p, "procedural should be inactive");
1323        assert!(d);
1324    }
1325
1326    // ── type_active (spec 004-16, MemGuard type-aware retrieval, #6086) ───────────
1327
1328    #[test]
1329    fn type_active_empty_active_set_means_all_types() {
1330        assert!(type_active(&[], FunctionalType::Episodic));
1331        assert!(type_active(&[], FunctionalType::GraphFact));
1332        assert!(type_active(&[], FunctionalType::BehavioralRule));
1333    }
1334
1335    #[test]
1336    fn type_active_nonempty_set_gates_by_membership() {
1337        let active = [FunctionalType::UserFact];
1338        assert!(type_active(&active, FunctionalType::UserFact));
1339        assert!(!type_active(&active, FunctionalType::Episodic));
1340        assert!(!type_active(&active, FunctionalType::GraphFact));
1341    }
1342
1343    // ── schedule_context_fetchers type gating (spec 004-16, #6086) ────────────────
1344
1345    struct NoopRouter;
1346    impl zeph_common::memory::MemoryRouter for NoopRouter {
1347        fn route(&self, _query: &str) -> zeph_common::memory::MemoryRoute {
1348            zeph_common::memory::MemoryRoute::default()
1349        }
1350    }
1351    impl AsyncMemoryRouter for NoopRouter {
1352        fn route_async<'a>(
1353            &'a self,
1354            _query: &'a str,
1355        ) -> std::pin::Pin<
1356            Box<dyn std::future::Future<Output = zeph_common::memory::RoutingDecision> + Send + 'a>,
1357        > {
1358            Box::pin(async move {
1359                zeph_common::memory::RoutingDecision {
1360                    route: zeph_common::memory::MemoryRoute::default(),
1361                    confidence: 1.0,
1362                    reasoning: None,
1363                }
1364            })
1365        }
1366    }
1367
1368    /// View with every budget-gated fetcher's own config enabled, so that whether a fetcher is
1369    /// *scheduled* depends only on the tier/type gate under test, not on the fetcher's own
1370    /// budget/enabled guard. Mirrors `empty_view` but flips every relevant flag on.
1371    fn full_active_view() -> ContextMemoryView {
1372        let mut view = empty_view();
1373        view.persona_config.context_budget_tokens = 100;
1374        view.trajectory_config.context_budget_tokens = 100;
1375        view.tree_config.context_budget_tokens = 100;
1376        view.reasoning_config.enabled = true;
1377        view.reasoning_config.context_budget_tokens = 100;
1378        view.document_config.rag_enabled = true;
1379        view
1380    }
1381
1382    /// Budget allocation shared by [`schedule_all_budgeted`] callers: 100 tokens for every
1383    /// budget-gated slot, so scheduling depends only on the tier/type gate under test.
1384    fn test_budget_alloc() -> crate::budget::BudgetAllocation {
1385        crate::budget::BudgetAllocation {
1386            system_prompt: 0,
1387            skills: 0,
1388            summaries: 100,
1389            semantic_recall: 100,
1390            cross_session: 100,
1391            code_context: 100,
1392            graph_facts: 100,
1393            recent_history: 0,
1394            response_reserve: 0,
1395            session_digest: 0,
1396        }
1397    }
1398
1399    fn schedule_all_budgeted<'r>(
1400        view: &'r ContextMemoryView,
1401        tc: &'r NaiveTokenCounter,
1402        router: &'r NoopRouter,
1403        alloc: &'r crate::budget::BudgetAllocation,
1404        active_types: &'r [FunctionalType],
1405    ) -> FuturesUnordered<CtxFuture<'r>> {
1406        schedule_context_fetchers(
1407            view,
1408            tc,
1409            "query",
1410            |s| s.into(),
1411            None,
1412            router,
1413            alloc,
1414            10,
1415            0.5,
1416            &[],
1417            active_types,
1418        )
1419    }
1420
1421    #[test]
1422    fn schedule_context_fetchers_schedules_everything_when_active_types_empty() {
1423        let view = full_active_view();
1424        let tc = NaiveTokenCounter;
1425        let router = NoopRouter;
1426        let alloc = test_budget_alloc();
1427        let fetchers = schedule_all_budgeted(&view, &tc, &router, &alloc, &[]);
1428        // summaries, cross_session, semantic_recall, document_rag, corrections, graph_facts,
1429        // persona_facts, trajectory_hints, tree_memory, reasoning_strategies (code RAG excluded:
1430        // no IndexAccess passed).
1431        assert_eq!(fetchers.len(), 10);
1432    }
1433
1434    #[test]
1435    fn schedule_context_fetchers_gates_to_user_fact_only_sc1() {
1436        // SC#1: with an active set of [UserFact], only fetch_persona_facts (plus the always-on
1437        // fetch_corrections) should be scheduled among the type-gated sources. Un-type-gated v2
1438        // slots (trajectory_hints, tree_memory, document_rag) still schedule under their own
1439        // existing activity/budget gate — they are not yet a FunctionalType axis (spec 004-16 §4).
1440        let view = full_active_view();
1441        let tc = NaiveTokenCounter;
1442        let router = NoopRouter;
1443        let active = [FunctionalType::UserFact];
1444        let alloc = test_budget_alloc();
1445        let fetchers = schedule_all_budgeted(&view, &tc, &router, &alloc, &active);
1446        // persona_facts + corrections + trajectory_hints + tree_memory + document_rag.
1447        assert_eq!(fetchers.len(), 5);
1448    }
1449
1450    #[test]
1451    fn schedule_context_fetchers_document_rag_survives_episodic_exclusion_n2() {
1452        // N2 regression: excluding Episodic from the active set must not silently disable
1453        // document_rag — it shares a budget gate with semantic_recall but is not yet a gated
1454        // FunctionalType. Use GraphFact as the sole active type so Episodic is excluded.
1455        let view = full_active_view();
1456        let tc = NaiveTokenCounter;
1457        let router = NoopRouter;
1458        let active = [FunctionalType::GraphFact];
1459        let alloc = test_budget_alloc();
1460        let fetchers = schedule_all_budgeted(&view, &tc, &router, &alloc, &active);
1461        // graph_facts + corrections + trajectory_hints + tree_memory + document_rag.
1462        // Crucially: semantic_recall is absent (Episodic excluded) while document_rag is present.
1463        assert_eq!(fetchers.len(), 5);
1464    }
1465
1466    #[test]
1467    fn schedule_context_fetchers_gates_cross_session_summary_both_slots() {
1468        // CrossSessionSummary gates both fetch_summaries and fetch_cross_session (spec 004-16 §4).
1469        let view = full_active_view();
1470        let tc = NaiveTokenCounter;
1471        let router = NoopRouter;
1472        let active = [FunctionalType::CrossSessionSummary];
1473        let alloc = test_budget_alloc();
1474        let fetchers = schedule_all_budgeted(&view, &tc, &router, &alloc, &active);
1475        // summaries + cross_session + corrections + trajectory_hints + tree_memory + document_rag.
1476        assert_eq!(fetchers.len(), 6);
1477    }
1478
1479    // ── ContextAssembler::gather (SC#4, spec 004-16 §12.4) ─────────────────────────
1480    //
1481    // SC#4 requires the type-exclusion half to be measured at the PreparedContext/token layer,
1482    // not re-derived from `schedule_context_fetchers`'s scheduling counts alone (round-1 critic
1483    // finding S3: a count-proxy would stay green even if the gate scheduled the wrong fetcher).
1484    // This test drives the real `gather()` entry point end-to-end and asserts on the resulting
1485    // `PreparedContext` slots directly.
1486
1487    #[tokio::test]
1488    async fn gather_with_user_fact_active_type_excludes_other_slots_sc4() {
1489        let mock = MockMemoryBackend {
1490            persona_facts: vec![MemPersonaFact {
1491                category: "preference".to_string(),
1492                content: "prefers concise answers".to_string(),
1493            }],
1494            ..Default::default()
1495        };
1496        let mut memory = mock_view(mock);
1497        memory.persona_config.enabled = true;
1498        memory.persona_config.context_budget_tokens = 1000;
1499        memory.graph_config.enabled = true;
1500        memory.reasoning_config.enabled = true;
1501        memory.reasoning_config.context_budget_tokens = 500;
1502        memory.document_config.rag_enabled = false;
1503
1504        let mut context_manager = crate::manager::ContextManager::new();
1505        context_manager.budget = Some(crate::budget::ContextBudget::new(128_000, 0.1));
1506
1507        let tc = NaiveTokenCounter;
1508        let active_types = [FunctionalType::UserFact];
1509
1510        let input = crate::input::ContextAssemblyInput {
1511            memory: &memory,
1512            context_manager: &context_manager,
1513            token_counter: &tc,
1514            skills_prompt: "",
1515            index: None,
1516            correction_config: None,
1517            sidequest_turn_counter: 0,
1518            messages: &[],
1519            query: "what do you know about me?",
1520            scrub: |s| s.into(),
1521            active_levels: &[],
1522            active_types: &active_types,
1523            router: Box::new(NoopRouter),
1524            planned_next_tools: &[],
1525        };
1526
1527        let prepared = ContextAssembler::gather(&input).await.unwrap();
1528
1529        assert!(
1530            prepared.recall.is_none(),
1531            "Episodic excluded from active set: recall must be None"
1532        );
1533        assert!(
1534            prepared.reasoning_hints.is_none(),
1535            "ReasoningStrategy excluded from active set: reasoning_hints must be None"
1536        );
1537        assert!(
1538            prepared.graph_facts.is_none(),
1539            "GraphFact excluded from active set: graph_facts must be None"
1540        );
1541        assert!(
1542            prepared.summaries.is_none(),
1543            "CrossSessionSummary excluded from active set: summaries must be None"
1544        );
1545        assert!(
1546            prepared.persona_facts.is_some(),
1547            "UserFact is in the active set: persona_facts must be Some"
1548        );
1549    }
1550
1551    // ── fetch_reasoning_strategies ────────────────────────────────────────────
1552
1553    #[tokio::test]
1554    async fn fetch_reasoning_strategies_returns_none_when_memory_is_none() {
1555        let mut view = empty_view();
1556        view.reasoning_config.enabled = true;
1557        let tc = NaiveTokenCounter;
1558        let (result, handle) = fetch_reasoning_strategies(&view, "query", 1000, 3, &tc)
1559            .await
1560            .unwrap();
1561        assert!(result.is_none());
1562        assert!(handle.is_none());
1563    }
1564
1565    #[tokio::test]
1566    async fn fetch_reasoning_strategies_returns_none_when_budget_zero() {
1567        let mut view = empty_view();
1568        view.reasoning_config.enabled = true;
1569        let tc = NaiveTokenCounter;
1570        let (result, handle) = fetch_reasoning_strategies(&view, "query", 0, 3, &tc)
1571            .await
1572            .unwrap();
1573        assert!(result.is_none());
1574        assert!(handle.is_none());
1575    }
1576
1577    // ── MockMemoryBackend ─────────────────────────────────────────────────────
1578
1579    use std::sync::{Arc, Mutex};
1580    use zeph_common::memory::{
1581        ContextMemoryBackend, GraphRecallParams, GraphRetrievalStrategy, MemCorrection,
1582        MemDocumentChunk, MemGraphFact, MemPersonaFact, MemReasoningStrategy, MemRecalledMessage,
1583        MemSessionSummary, MemSummary, MemTrajectoryEntry, MemTreeNode,
1584    };
1585
1586    /// Known method names accepted by [`MockMemoryBackend::fail_on`].
1587    const KNOWN_FAIL_ON: &[&str] = &[
1588        "load_persona_facts",
1589        "load_trajectory_entries",
1590        "load_tree_nodes",
1591        "load_summaries",
1592        "retrieve_reasoning_strategies",
1593        "mark_reasoning_used",
1594        "retrieve_corrections",
1595        "recall",
1596        "recall_graph_facts",
1597        "search_session_summaries",
1598        "search_document_collection",
1599    ];
1600
1601    #[derive(Default)]
1602    struct MockMemoryBackend {
1603        persona_facts: Vec<MemPersonaFact>,
1604        trajectory_entries: Vec<MemTrajectoryEntry>,
1605        tree_nodes: Vec<MemTreeNode>,
1606        summaries: Vec<MemSummary>,
1607        reasoning_strategies: Vec<MemReasoningStrategy>,
1608        corrections: Vec<MemCorrection>,
1609        recalled: Vec<MemRecalledMessage>,
1610        graph_facts: Vec<MemGraphFact>,
1611        session_summaries: Vec<MemSessionSummary>,
1612        document_chunks: Vec<MemDocumentChunk>,
1613        /// When `Some("method_name")`, that method returns `Err(...)`.
1614        fail_on: Option<&'static str>,
1615        /// When `Some(duration)`, `load_persona_facts` and `recall` sleep for `duration`
1616        /// before resolving — used to simulate a stalled backend for timeout-path tests.
1617        delay: Option<std::time::Duration>,
1618        /// Tracks IDs passed to `mark_reasoning_used`.
1619        marked_ids: Mutex<Vec<String>>,
1620        /// Records the `retrieval_strategy` passed to the most recent `recall_graph_facts`
1621        /// call — lets tests assert on the *effective* strategy resolved by `fetch_graph_facts`
1622        /// (e.g. the `sa_config.enabled` override rule, issue #6566) without needing a real
1623        /// `SemanticMemory` graph store.
1624        captured_retrieval_strategy: Mutex<Option<GraphRetrievalStrategy>>,
1625    }
1626
1627    impl MockMemoryBackend {
1628        fn with_fail_on(method: &'static str) -> Self {
1629            debug_assert!(
1630                KNOWN_FAIL_ON.contains(&method),
1631                "unknown fail_on method name: {method}"
1632            );
1633            Self {
1634                fail_on: Some(method),
1635                ..Default::default()
1636            }
1637        }
1638
1639        fn fail_err(method: &str) -> Box<dyn std::error::Error + Send + Sync> {
1640            format!("mock error in {method}").into()
1641        }
1642    }
1643
1644    impl ContextMemoryBackend for MockMemoryBackend {
1645        fn load_persona_facts<'a>(
1646            &'a self,
1647            _min_confidence: f64,
1648        ) -> std::pin::Pin<
1649            Box<
1650                dyn std::future::Future<
1651                        Output = Result<
1652                            Vec<MemPersonaFact>,
1653                            Box<dyn std::error::Error + Send + Sync>,
1654                        >,
1655                    > + Send
1656                    + 'a,
1657            >,
1658        > {
1659            let result = if self.fail_on == Some("load_persona_facts") {
1660                Err(Self::fail_err("load_persona_facts"))
1661            } else {
1662                Ok(self.persona_facts.clone())
1663            };
1664            let delay = self.delay;
1665            Box::pin(async move {
1666                if let Some(d) = delay {
1667                    tokio::time::sleep(d).await;
1668                }
1669                result
1670            })
1671        }
1672
1673        fn load_trajectory_entries<'a>(
1674            &'a self,
1675            _tier: Option<&'a str>,
1676            _top_k: usize,
1677        ) -> std::pin::Pin<
1678            Box<
1679                dyn std::future::Future<
1680                        Output = Result<
1681                            Vec<MemTrajectoryEntry>,
1682                            Box<dyn std::error::Error + Send + Sync>,
1683                        >,
1684                    > + Send
1685                    + 'a,
1686            >,
1687        > {
1688            let result = if self.fail_on == Some("load_trajectory_entries") {
1689                Err(Self::fail_err("load_trajectory_entries"))
1690            } else {
1691                Ok(self.trajectory_entries.clone())
1692            };
1693            Box::pin(async move { result })
1694        }
1695
1696        fn load_tree_nodes<'a>(
1697            &'a self,
1698            _level: u32,
1699            _top_k: usize,
1700        ) -> std::pin::Pin<
1701            Box<
1702                dyn std::future::Future<
1703                        Output = Result<Vec<MemTreeNode>, Box<dyn std::error::Error + Send + Sync>>,
1704                    > + Send
1705                    + 'a,
1706            >,
1707        > {
1708            let result = if self.fail_on == Some("load_tree_nodes") {
1709                Err(Self::fail_err("load_tree_nodes"))
1710            } else {
1711                Ok(self.tree_nodes.clone())
1712            };
1713            Box::pin(async move { result })
1714        }
1715
1716        fn load_summaries<'a>(
1717            &'a self,
1718            _conversation_id: i64,
1719        ) -> std::pin::Pin<
1720            Box<
1721                dyn std::future::Future<
1722                        Output = Result<Vec<MemSummary>, Box<dyn std::error::Error + Send + Sync>>,
1723                    > + Send
1724                    + 'a,
1725            >,
1726        > {
1727            let result = if self.fail_on == Some("load_summaries") {
1728                Err(Self::fail_err("load_summaries"))
1729            } else {
1730                Ok(self.summaries.clone())
1731            };
1732            Box::pin(async move { result })
1733        }
1734
1735        fn retrieve_reasoning_strategies<'a>(
1736            &'a self,
1737            _query: &'a str,
1738            _top_k: usize,
1739        ) -> std::pin::Pin<
1740            Box<
1741                dyn std::future::Future<
1742                        Output = Result<
1743                            Vec<MemReasoningStrategy>,
1744                            Box<dyn std::error::Error + Send + Sync>,
1745                        >,
1746                    > + Send
1747                    + 'a,
1748            >,
1749        > {
1750            let result = if self.fail_on == Some("retrieve_reasoning_strategies") {
1751                Err(Self::fail_err("retrieve_reasoning_strategies"))
1752            } else {
1753                Ok(self.reasoning_strategies.clone())
1754            };
1755            Box::pin(async move { result })
1756        }
1757
1758        fn mark_reasoning_used<'a>(
1759            &'a self,
1760            ids: &'a [String],
1761        ) -> std::pin::Pin<
1762            Box<
1763                dyn std::future::Future<
1764                        Output = Result<(), Box<dyn std::error::Error + Send + Sync>>,
1765                    > + Send
1766                    + 'a,
1767            >,
1768        > {
1769            if self.fail_on == Some("mark_reasoning_used") {
1770                return Box::pin(async move { Err(Self::fail_err("mark_reasoning_used")) });
1771            }
1772            let mut guard = self.marked_ids.lock().expect("marked_ids poisoned");
1773            guard.extend_from_slice(ids);
1774            Box::pin(async move { Ok(()) })
1775        }
1776
1777        fn retrieve_corrections<'a>(
1778            &'a self,
1779            _query: &'a str,
1780            _limit: usize,
1781            _min_score: f32,
1782        ) -> std::pin::Pin<
1783            Box<
1784                dyn std::future::Future<
1785                        Output = Result<
1786                            Vec<MemCorrection>,
1787                            Box<dyn std::error::Error + Send + Sync>,
1788                        >,
1789                    > + Send
1790                    + 'a,
1791            >,
1792        > {
1793            let result = if self.fail_on == Some("retrieve_corrections") {
1794                Err(Self::fail_err("retrieve_corrections"))
1795            } else {
1796                Ok(self.corrections.clone())
1797            };
1798            Box::pin(async move { result })
1799        }
1800
1801        fn recall<'a>(
1802            &'a self,
1803            _query: &'a str,
1804            _limit: usize,
1805            _router: Option<&'a dyn zeph_common::memory::AsyncMemoryRouter>,
1806        ) -> std::pin::Pin<
1807            Box<
1808                dyn std::future::Future<
1809                        Output = Result<
1810                            Vec<MemRecalledMessage>,
1811                            Box<dyn std::error::Error + Send + Sync>,
1812                        >,
1813                    > + Send
1814                    + 'a,
1815            >,
1816        > {
1817            let result = if self.fail_on == Some("recall") {
1818                Err(Self::fail_err("recall"))
1819            } else {
1820                Ok(self.recalled.clone())
1821            };
1822            let delay = self.delay;
1823            Box::pin(async move {
1824                if let Some(d) = delay {
1825                    tokio::time::sleep(d).await;
1826                }
1827                result
1828            })
1829        }
1830
1831        fn recall_graph_facts<'a>(
1832            &'a self,
1833            _query: &'a str,
1834            params: GraphRecallParams<'a>,
1835        ) -> std::pin::Pin<
1836            Box<
1837                dyn std::future::Future<
1838                        Output = Result<
1839                            Vec<MemGraphFact>,
1840                            Box<dyn std::error::Error + Send + Sync>,
1841                        >,
1842                    > + Send
1843                    + 'a,
1844            >,
1845        > {
1846            *self.captured_retrieval_strategy.lock().unwrap() = Some(params.retrieval_strategy);
1847            let result = if self.fail_on == Some("recall_graph_facts") {
1848                Err(Self::fail_err("recall_graph_facts"))
1849            } else {
1850                Ok(self.graph_facts.clone())
1851            };
1852            Box::pin(async move { result })
1853        }
1854
1855        fn search_session_summaries<'a>(
1856            &'a self,
1857            _query: &'a str,
1858            _limit: usize,
1859            _current_conversation_id: Option<i64>,
1860        ) -> std::pin::Pin<
1861            Box<
1862                dyn std::future::Future<
1863                        Output = Result<
1864                            Vec<MemSessionSummary>,
1865                            Box<dyn std::error::Error + Send + Sync>,
1866                        >,
1867                    > + Send
1868                    + 'a,
1869            >,
1870        > {
1871            let result = if self.fail_on == Some("search_session_summaries") {
1872                Err(Self::fail_err("search_session_summaries"))
1873            } else {
1874                Ok(self.session_summaries.clone())
1875            };
1876            Box::pin(async move { result })
1877        }
1878
1879        fn search_document_collection<'a>(
1880            &'a self,
1881            _collection: &'a str,
1882            _query: &'a str,
1883            _top_k: usize,
1884        ) -> std::pin::Pin<
1885            Box<
1886                dyn std::future::Future<
1887                        Output = Result<
1888                            Vec<MemDocumentChunk>,
1889                            Box<dyn std::error::Error + Send + Sync>,
1890                        >,
1891                    > + Send
1892                    + 'a,
1893            >,
1894        > {
1895            let result = if self.fail_on == Some("search_document_collection") {
1896                Err(Self::fail_err("search_document_collection"))
1897            } else {
1898                Ok(self.document_chunks.clone())
1899            };
1900            Box::pin(async move { result })
1901        }
1902    }
1903
1904    fn mock_view(mock: MockMemoryBackend) -> ContextMemoryView {
1905        let mut v = empty_view();
1906        v.memory = Some(Arc::new(mock));
1907        v
1908    }
1909
1910    // ── fetch_graph_facts (happy path) ────────────────────────────────────────
1911
1912    #[tokio::test]
1913    async fn fetch_graph_facts_returns_message_when_memory_present() {
1914        let mock = MockMemoryBackend {
1915            graph_facts: vec![zeph_common::memory::MemGraphFact {
1916                fact: "Rust is fast".to_string(),
1917                confidence: 0.9,
1918                activation_score: None,
1919                neighbors: vec![],
1920                provenance_snippet: None,
1921            }],
1922            ..Default::default()
1923        };
1924        let mut view = mock_view(mock);
1925        view.graph_config.enabled = true;
1926        // recall_timeout_ms must be non-zero or it gets clamped to 100ms
1927        view.graph_config.spreading_activation.recall_timeout_ms = 5000;
1928        let tc = NaiveTokenCounter;
1929        let result = fetch_graph_facts(&view, "test", 1000, &tc).await.unwrap();
1930        assert!(result.is_some(), "expected Some message");
1931        let msg = result.unwrap();
1932        assert!(
1933            msg.content.contains("Rust is fast"),
1934            "expected fact text in output, got: {}",
1935            msg.content
1936        );
1937        assert!(
1938            msg.content.starts_with(GRAPH_FACTS_PREFIX),
1939            "expected GRAPH_FACTS_PREFIX"
1940        );
1941    }
1942
1943    #[tokio::test]
1944    async fn fetch_graph_facts_swallows_error_and_returns_none() {
1945        let mock = MockMemoryBackend::with_fail_on("recall_graph_facts");
1946        let mut view = mock_view(mock);
1947        view.graph_config.enabled = true;
1948        view.graph_config.spreading_activation.recall_timeout_ms = 5000;
1949        let tc = NaiveTokenCounter;
1950        // B1: fetch_graph_facts swallows errors via tracing::warn! and returns Ok(None)
1951        let result = fetch_graph_facts(&view, "test", 1000, &tc).await.unwrap();
1952        assert!(
1953            result.is_none(),
1954            "expected None when recall_graph_facts errors"
1955        );
1956    }
1957
1958    #[tokio::test]
1959    async fn fetch_graph_facts_returns_none_when_facts_empty() {
1960        let mock = MockMemoryBackend::default(); // empty graph_facts
1961        let mut view = mock_view(mock);
1962        view.graph_config.enabled = true;
1963        view.graph_config.spreading_activation.recall_timeout_ms = 5000;
1964        let tc = NaiveTokenCounter;
1965        let result = fetch_graph_facts(&view, "test", 1000, &tc).await.unwrap();
1966        assert!(result.is_none());
1967    }
1968
1969    // ── fetch_graph_facts retrieval_strategy resolution (issue #6566) ─────────
1970
1971    /// `sa_config.enabled = true` must force the effective strategy to `Synapse`
1972    /// regardless of the configured `retrieval_strategy` — the override rule ported
1973    /// verbatim from the pre-existing (previously dead) dispatch code.
1974    #[tokio::test]
1975    async fn fetch_graph_facts_forces_synapse_when_spreading_activation_enabled_regardless_of_configured_strategy()
1976     {
1977        let mock = Arc::new(MockMemoryBackend::default());
1978        let mut view = empty_view();
1979        view.memory = Some(mock.clone());
1980        view.graph_config.enabled = true;
1981        view.graph_config.spreading_activation.recall_timeout_ms = 5000;
1982        view.graph_config.spreading_activation.enabled = true;
1983        // Configured strategy is deliberately non-default/non-Synapse to prove the override
1984        // rule wins regardless of what `retrieval_strategy` is set to.
1985        view.graph_config.retrieval_strategy =
1986            zeph_config::memory::GraphRetrievalStrategy::BeamSearch;
1987        let tc = NaiveTokenCounter;
1988
1989        fetch_graph_facts(&view, "test", 1000, &tc).await.unwrap();
1990
1991        assert_eq!(
1992            *mock.captured_retrieval_strategy.lock().unwrap(),
1993            Some(GraphRetrievalStrategy::Synapse),
1994            "sa_config.enabled=true must override retrieval_strategy=BeamSearch to Synapse"
1995        );
1996    }
1997
1998    /// Documents a real behavior change introduced by the #6566 fix: with a fully-default
1999    /// `GraphConfig` (`spreading_activation.enabled = false`, `retrieval_strategy` at its own
2000    /// default), the live path now resolves to `Synapse` (`recall_graph_activated`) — not the
2001    /// pre-fix live behavior of plain BFS (`recall_graph`), since `sa_params` was previously
2002    /// only built when `enabled = true`. `GraphRetrievalStrategy::default()` is `Synapse`, and
2003    /// the override rule only forces `Synapse` when SA is *on*; when SA is off it now trusts
2004    /// `retrieval_strategy` verbatim, which itself defaults to `Synapse`. This test exists so
2005    /// the change is impossible to miss in review — it is not something to "fix" here; see
2006    /// the developer's handoff (`.local/handoff/2026-07-20T18-05-09-developer.md`) for the
2007    /// full trade-off discussion.
2008    #[tokio::test]
2009    async fn fetch_graph_facts_default_config_resolves_to_synapse_not_bfs() {
2010        let mock = Arc::new(MockMemoryBackend::default());
2011        let mut view = empty_view();
2012        view.memory = Some(mock.clone());
2013        view.graph_config.enabled = true;
2014        view.graph_config.spreading_activation.recall_timeout_ms = 5000;
2015        // Everything else left at `GraphConfig::default()`: spreading_activation.enabled =
2016        // false, retrieval_strategy = GraphRetrievalStrategy::default() = Synapse.
2017        let tc = NaiveTokenCounter;
2018
2019        fetch_graph_facts(&view, "test", 1000, &tc).await.unwrap();
2020
2021        assert_eq!(
2022            *mock.captured_retrieval_strategy.lock().unwrap(),
2023            Some(GraphRetrievalStrategy::Synapse),
2024            "fully-default GraphConfig must resolve to Synapse, not Bfs — a real behavior \
2025             change versus the pre-#6566 live path, flagged for reviewer sign-off"
2026        );
2027    }
2028
2029    // ── fetch_persona_facts ───────────────────────────────────────────────────
2030
2031    #[tokio::test]
2032    async fn fetch_persona_facts_returns_message_when_persona_enabled() {
2033        let mock = MockMemoryBackend {
2034            persona_facts: vec![MemPersonaFact {
2035                category: "preference".to_string(),
2036                content: "prefers concise answers".to_string(),
2037            }],
2038            ..Default::default()
2039        };
2040        let mut view = mock_view(mock);
2041        view.persona_config.enabled = true;
2042        view.persona_config.context_budget_tokens = 1000;
2043        let tc = NaiveTokenCounter;
2044        let result = fetch_persona_facts(&view, 1000, &tc).await.unwrap();
2045        assert!(result.is_some());
2046        let msg = result.unwrap();
2047        assert!(msg.content.contains("preference"));
2048        assert!(msg.content.contains("prefers concise answers"));
2049        assert!(msg.content.starts_with(crate::slot::PERSONA_PREFIX));
2050    }
2051
2052    #[tokio::test]
2053    async fn fetch_persona_facts_propagates_error() {
2054        let mock = MockMemoryBackend::with_fail_on("load_persona_facts");
2055        let mut view = mock_view(mock);
2056        view.persona_config.enabled = true;
2057        let tc = NaiveTokenCounter;
2058        let result = fetch_persona_facts(&view, 1000, &tc).await;
2059        assert!(
2060            result.is_err(),
2061            "expected Err from load_persona_facts failure"
2062        );
2063    }
2064
2065    // ── fetch_trajectory_hints ────────────────────────────────────────────────
2066
2067    #[tokio::test]
2068    async fn fetch_trajectory_hints_returns_message_when_trajectory_enabled() {
2069        let mock = MockMemoryBackend {
2070            trajectory_entries: vec![MemTrajectoryEntry {
2071                intent: "summarize code".to_string(),
2072                outcome: "produced concise summary".to_string(),
2073                confidence: 0.9,
2074            }],
2075            ..Default::default()
2076        };
2077        let mut view = mock_view(mock);
2078        view.trajectory_config.enabled = true;
2079        view.trajectory_config.context_budget_tokens = 1000;
2080        view.trajectory_config.min_confidence = 0.5;
2081        let tc = NaiveTokenCounter;
2082        let result = fetch_trajectory_hints(&view, 1000, &tc).await.unwrap();
2083        assert!(result.is_some());
2084        let msg = result.unwrap();
2085        assert!(msg.content.contains("summarize code"));
2086        assert!(msg.content.starts_with(crate::slot::TRAJECTORY_PREFIX));
2087    }
2088
2089    #[tokio::test]
2090    async fn fetch_trajectory_hints_passes_tier_filter() {
2091        // I1: confidence filtering — entry below min_confidence must be excluded,
2092        // entry above must be present. Verifies the .filter(|e| e.confidence >= min_conf) branch.
2093        let mock = MockMemoryBackend {
2094            trajectory_entries: vec![
2095                MemTrajectoryEntry {
2096                    intent: "debug async code".to_string(),
2097                    outcome: "fixed deadlock".to_string(),
2098                    confidence: 0.85,
2099                },
2100                MemTrajectoryEntry {
2101                    intent: "low confidence task".to_string(),
2102                    outcome: "irrelevant".to_string(),
2103                    confidence: 0.3,
2104                },
2105            ],
2106            ..Default::default()
2107        };
2108        let mut view = mock_view(mock);
2109        view.trajectory_config.enabled = true;
2110        view.trajectory_config.context_budget_tokens = 1000;
2111        view.trajectory_config.min_confidence = 0.5;
2112        let tc = NaiveTokenCounter;
2113        let result = fetch_trajectory_hints(&view, 1000, &tc).await.unwrap();
2114        assert!(result.is_some(), "expected Some message");
2115        let msg = result.unwrap();
2116        assert!(
2117            msg.content.contains("debug async code"),
2118            "high-confidence entry must be included"
2119        );
2120        assert!(
2121            !msg.content.contains("low confidence task"),
2122            "entry below min_confidence must be filtered out"
2123        );
2124    }
2125
2126    #[tokio::test]
2127    async fn fetch_trajectory_hints_propagates_error() {
2128        let mock = MockMemoryBackend::with_fail_on("load_trajectory_entries");
2129        let mut view = mock_view(mock);
2130        view.trajectory_config.enabled = true;
2131        let tc = NaiveTokenCounter;
2132        let result = fetch_trajectory_hints(&view, 1000, &tc).await;
2133        assert!(result.is_err());
2134    }
2135
2136    // ── fetch_tree_memory ─────────────────────────────────────────────────────
2137
2138    #[tokio::test]
2139    async fn fetch_tree_memory_returns_message_when_tree_enabled() {
2140        let mock = MockMemoryBackend {
2141            tree_nodes: vec![MemTreeNode {
2142                content: "Topic: async Rust patterns".to_string(),
2143            }],
2144            ..Default::default()
2145        };
2146        let mut view = mock_view(mock);
2147        view.tree_config.enabled = true;
2148        view.tree_config.context_budget_tokens = 1000;
2149        let tc = NaiveTokenCounter;
2150        let result = fetch_tree_memory(&view, 1000, &tc).await.unwrap();
2151        assert!(result.is_some());
2152        let msg = result.unwrap();
2153        assert!(msg.content.contains("async Rust patterns"));
2154        assert!(msg.content.starts_with(crate::slot::TREE_MEMORY_PREFIX));
2155    }
2156
2157    #[tokio::test]
2158    async fn fetch_tree_memory_propagates_error() {
2159        let mock = MockMemoryBackend::with_fail_on("load_tree_nodes");
2160        let mut view = mock_view(mock);
2161        view.tree_config.enabled = true;
2162        let tc = NaiveTokenCounter;
2163        let result = fetch_tree_memory(&view, 1000, &tc).await;
2164        assert!(result.is_err());
2165    }
2166
2167    // ── fetch_corrections ─────────────────────────────────────────────────────
2168
2169    #[tokio::test]
2170    async fn fetch_corrections_returns_message_when_corrections_present() {
2171        let mock = MockMemoryBackend {
2172            corrections: vec![MemCorrection {
2173                correction_text: "use snake_case not camelCase".to_string(),
2174            }],
2175            ..Default::default()
2176        };
2177        let view = mock_view(mock);
2178        let result = fetch_corrections(&view, "query", 10, 0.5, |s| s.into())
2179            .await
2180            .unwrap();
2181        assert!(result.is_some());
2182        let msg = result.unwrap();
2183        assert!(msg.content.contains("snake_case"));
2184        assert!(msg.content.starts_with(CORRECTIONS_PREFIX));
2185    }
2186
2187    #[tokio::test]
2188    async fn fetch_corrections_propagates_error() {
2189        // fetch_corrections uses map_err(AssemblerError::Memory)? so retrieve_corrections
2190        // errors are propagated instead of silently discarded.
2191        let mock = MockMemoryBackend::with_fail_on("retrieve_corrections");
2192        let view = mock_view(mock);
2193        let result = fetch_corrections(&view, "query", 10, 0.5, |s| s.into()).await;
2194        assert!(result.is_err(), "expected Err, got {result:?}");
2195    }
2196
2197    // ── fetch_semantic_recall ─────────────────────────────────────────────────
2198
2199    #[tokio::test]
2200    async fn fetch_semantic_recall_returns_message_with_content() {
2201        let mock = MockMemoryBackend {
2202            recalled: vec![
2203                MemRecalledMessage {
2204                    role: "user".to_string(),
2205                    content: "how does tokio work".to_string(),
2206                    score: 0.95,
2207                },
2208                MemRecalledMessage {
2209                    role: "assistant".to_string(),
2210                    content: "tokio is an async runtime".to_string(),
2211                    score: 0.88,
2212                },
2213            ],
2214            ..Default::default()
2215        };
2216        let mut view = mock_view(mock);
2217        view.recall_limit = 10;
2218        let tc = NaiveTokenCounter;
2219        let (msg, score) = fetch_semantic_recall(&view, "tokio", 1000, &tc, None)
2220            .await
2221            .unwrap();
2222        assert!(msg.is_some(), "expected Some message");
2223        // I4: verify score equals first message's score
2224        assert!(score.is_some_and(|s| (s - 0.95_f32).abs() < f32::EPSILON));
2225        let msg = msg.unwrap();
2226        // content is in parts.Recall so check parts
2227        let has_recall_part = msg.parts.iter().any(|p| {
2228            if let zeph_llm::provider::MessagePart::Recall { text } = p {
2229                text.contains("how does tokio work")
2230            } else {
2231                false
2232            }
2233        });
2234        assert!(has_recall_part, "expected recalled content in Recall part");
2235    }
2236
2237    #[tokio::test]
2238    async fn fetch_semantic_recall_returns_none_when_recalled_empty() {
2239        let mock = MockMemoryBackend::default();
2240        let mut view = mock_view(mock);
2241        view.recall_limit = 10;
2242        let tc = NaiveTokenCounter;
2243        let (msg, score) = fetch_semantic_recall(&view, "query", 1000, &tc, None)
2244            .await
2245            .unwrap();
2246        assert!(msg.is_none());
2247        assert!(score.is_none());
2248    }
2249
2250    #[tokio::test]
2251    async fn fetch_semantic_recall_propagates_error() {
2252        let mock = MockMemoryBackend::with_fail_on("recall");
2253        let mut view = mock_view(mock);
2254        view.recall_limit = 10;
2255        let tc = NaiveTokenCounter;
2256        let result = fetch_semantic_recall(&view, "query", 1000, &tc, None).await;
2257        assert!(result.is_err());
2258    }
2259
2260    // ── fetch_document_rag ────────────────────────────────────────────────────
2261
2262    #[tokio::test]
2263    async fn fetch_document_rag_returns_message_when_rag_enabled() {
2264        let mock = MockMemoryBackend {
2265            document_chunks: vec![MemDocumentChunk {
2266                text: "Rust ownership rules prevent data races".to_string(),
2267            }],
2268            ..Default::default()
2269        };
2270        let mut view = mock_view(mock);
2271        view.document_config.rag_enabled = true;
2272        let tc = NaiveTokenCounter;
2273        let result = fetch_document_rag(&view, "ownership", 1000, &tc)
2274            .await
2275            .unwrap();
2276        assert!(result.is_some());
2277        let msg = result.unwrap();
2278        assert!(msg.content.contains("ownership rules"));
2279        assert!(msg.content.starts_with(DOCUMENT_RAG_PREFIX));
2280    }
2281
2282    #[tokio::test]
2283    async fn fetch_document_rag_propagates_error() {
2284        let mock = MockMemoryBackend::with_fail_on("search_document_collection");
2285        let mut view = mock_view(mock);
2286        view.document_config.rag_enabled = true;
2287        let tc = NaiveTokenCounter;
2288        let result = fetch_document_rag(&view, "query", 1000, &tc).await;
2289        assert!(result.is_err());
2290    }
2291
2292    // ── fetch_summaries ───────────────────────────────────────────────────────
2293
2294    #[tokio::test]
2295    async fn fetch_summaries_returns_message_when_summaries_present() {
2296        let mock = MockMemoryBackend {
2297            summaries: vec![MemSummary {
2298                first_message_id: Some(1),
2299                last_message_id: Some(5),
2300                content: "User asked about async Rust".to_string(),
2301            }],
2302            ..Default::default()
2303        };
2304        let mut view = mock_view(mock);
2305        view.conversation_id = Some(42);
2306        let tc = NaiveTokenCounter;
2307        let result = fetch_summaries(&view, 1000, &tc).await.unwrap();
2308        assert!(result.is_some());
2309        let msg = result.unwrap();
2310        let has_summary_part = msg.parts.iter().any(|p| {
2311            if let zeph_llm::provider::MessagePart::Summary { text } = p {
2312                text.contains("Messages 1-5") && text.contains("async Rust")
2313            } else {
2314                false
2315            }
2316        });
2317        assert!(
2318            has_summary_part,
2319            "expected Summary part with messages range"
2320        );
2321    }
2322
2323    #[tokio::test]
2324    async fn fetch_summaries_returns_none_without_conversation_id() {
2325        let mock = MockMemoryBackend {
2326            summaries: vec![MemSummary {
2327                first_message_id: Some(1),
2328                last_message_id: Some(5),
2329                content: "some content".to_string(),
2330            }],
2331            ..Default::default()
2332        };
2333        let mut view = mock_view(mock);
2334        view.conversation_id = None; // no conversation_id → must return None
2335        let tc = NaiveTokenCounter;
2336        let result = fetch_summaries(&view, 1000, &tc).await.unwrap();
2337        assert!(result.is_none());
2338    }
2339
2340    #[tokio::test]
2341    async fn fetch_summaries_propagates_error() {
2342        let mock = MockMemoryBackend::with_fail_on("load_summaries");
2343        let mut view = mock_view(mock);
2344        view.conversation_id = Some(42);
2345        let tc = NaiveTokenCounter;
2346        let result = fetch_summaries(&view, 1000, &tc).await;
2347        assert!(result.is_err());
2348    }
2349
2350    // ── fetch_cross_session ───────────────────────────────────────────────────
2351
2352    #[tokio::test]
2353    async fn fetch_cross_session_returns_message_when_results_present() {
2354        let mock = MockMemoryBackend {
2355            session_summaries: vec![MemSessionSummary {
2356                summary_text: "Previous session: debugging tokio deadlock".to_string(),
2357                score: 0.9,
2358            }],
2359            ..Default::default()
2360        };
2361        let mut view = mock_view(mock);
2362        view.conversation_id = Some(1);
2363        view.cross_session_score_threshold = 0.5;
2364        let tc = NaiveTokenCounter;
2365        let result = fetch_cross_session(&view, "async", 1000, &tc)
2366            .await
2367            .unwrap();
2368        assert!(result.is_some());
2369        let msg = result.unwrap();
2370        let has_cross_session_part = msg.parts.iter().any(|p| {
2371            if let zeph_llm::provider::MessagePart::CrossSession { text } = p {
2372                text.contains("tokio deadlock")
2373            } else {
2374                false
2375            }
2376        });
2377        assert!(has_cross_session_part);
2378    }
2379
2380    #[tokio::test]
2381    async fn fetch_cross_session_propagates_error() {
2382        let mock = MockMemoryBackend::with_fail_on("search_session_summaries");
2383        let mut view = mock_view(mock);
2384        view.conversation_id = Some(1);
2385        let tc = NaiveTokenCounter;
2386        let result = fetch_cross_session(&view, "query", 1000, &tc).await;
2387        assert!(result.is_err());
2388    }
2389
2390    // ── fetch_reasoning_strategies (happy path + mark_used) ──────────────────
2391
2392    #[tokio::test]
2393    async fn fetch_reasoning_strategies_returns_message_and_marks_used() {
2394        let mock = Arc::new(MockMemoryBackend {
2395            reasoning_strategies: vec![
2396                MemReasoningStrategy {
2397                    id: "strat-1".to_string(),
2398                    outcome: "success".to_string(),
2399                    summary: "break the problem into small steps".to_string(),
2400                },
2401                MemReasoningStrategy {
2402                    id: "strat-2".to_string(),
2403                    outcome: "success".to_string(),
2404                    summary: "use tracing spans for debugging".to_string(),
2405                },
2406            ],
2407            ..Default::default()
2408        });
2409        let marked_ids = Arc::clone(&mock);
2410        let mut view = empty_view();
2411        view.memory = Some(mock);
2412        view.reasoning_config.enabled = true;
2413        view.reasoning_config.context_budget_tokens = 1000;
2414        let tc = NaiveTokenCounter;
2415        let (result, handle) = fetch_reasoning_strategies(&view, "debug", 1000, 5, &tc)
2416            .await
2417            .unwrap();
2418        assert!(result.is_some());
2419        let msg = result.unwrap();
2420        assert!(msg.content.starts_with(crate::slot::REASONING_PREFIX));
2421        assert!(msg.content.contains("break the problem"));
2422
2423        // Await the returned JoinHandle to ensure mark_reasoning_used completes before assertion.
2424        if let Some(h) = handle {
2425            h.await.expect("mark_reasoning_used task panicked");
2426        }
2427
2428        let ids = marked_ids.marked_ids.lock().expect("marked_ids poisoned");
2429        assert!(
2430            ids.contains(&"strat-1".to_string()),
2431            "expected strat-1 marked"
2432        );
2433        assert!(
2434            ids.contains(&"strat-2".to_string()),
2435            "expected strat-2 marked"
2436        );
2437    }
2438
2439    #[tokio::test]
2440    async fn fetch_reasoning_strategies_propagates_error() {
2441        let mock = MockMemoryBackend::with_fail_on("retrieve_reasoning_strategies");
2442        let mut view = mock_view(mock);
2443        view.reasoning_config.enabled = true;
2444        let tc = NaiveTokenCounter;
2445        let result = fetch_reasoning_strategies(&view, "query", 1000, 3, &tc).await;
2446        assert!(result.is_err());
2447    }
2448
2449    // ── edge cases ────────────────────────────────────────────────────────────
2450
2451    #[tokio::test]
2452    async fn fetch_semantic_recall_skips_skipped_and_stopped_messages() {
2453        let mock = MockMemoryBackend {
2454            recalled: vec![
2455                MemRecalledMessage {
2456                    role: "user".to_string(),
2457                    content: "[skipped] some content".to_string(),
2458                    score: 0.95,
2459                },
2460                MemRecalledMessage {
2461                    role: "user".to_string(),
2462                    content: "[stopped] other content".to_string(),
2463                    score: 0.90,
2464                },
2465                MemRecalledMessage {
2466                    role: "user".to_string(),
2467                    content: "valid content to recall".to_string(),
2468                    score: 0.85,
2469                },
2470            ],
2471            ..Default::default()
2472        };
2473        let mut view = mock_view(mock);
2474        view.recall_limit = 10;
2475        let tc = NaiveTokenCounter;
2476        let (msg, _) = fetch_semantic_recall(&view, "query", 1000, &tc, None)
2477            .await
2478            .unwrap();
2479        assert!(msg.is_some());
2480        let msg = msg.unwrap();
2481        let full_text = msg.parts.iter().find_map(|p| {
2482            if let zeph_llm::provider::MessagePart::Recall { text } = p {
2483                Some(text.clone())
2484            } else {
2485                None
2486            }
2487        });
2488        let text = full_text.unwrap_or_default();
2489        assert!(
2490            !text.contains("[skipped]"),
2491            "skipped messages must be excluded"
2492        );
2493        assert!(
2494            !text.contains("[stopped]"),
2495            "stopped messages must be excluded"
2496        );
2497        assert!(
2498            text.contains("valid content to recall"),
2499            "valid messages must be included"
2500        );
2501    }
2502
2503    #[tokio::test]
2504    async fn fetch_cross_session_filters_below_threshold() {
2505        let mock = MockMemoryBackend {
2506            session_summaries: vec![
2507                MemSessionSummary {
2508                    summary_text: "high relevance session".to_string(),
2509                    score: 0.9,
2510                },
2511                MemSessionSummary {
2512                    summary_text: "low relevance session".to_string(),
2513                    score: 0.2,
2514                },
2515            ],
2516            ..Default::default()
2517        };
2518        let mut view = mock_view(mock);
2519        view.conversation_id = Some(1);
2520        view.cross_session_score_threshold = 0.5;
2521        let tc = NaiveTokenCounter;
2522        let result = fetch_cross_session(&view, "query", 1000, &tc)
2523            .await
2524            .unwrap();
2525        assert!(result.is_some());
2526        let msg = result.unwrap();
2527        let text = msg
2528            .parts
2529            .iter()
2530            .find_map(|p| {
2531                if let zeph_llm::provider::MessagePart::CrossSession { text } = p {
2532                    Some(text.clone())
2533                } else {
2534                    None
2535                }
2536            })
2537            .unwrap_or_default();
2538        assert!(
2539            text.contains("high relevance"),
2540            "high score must be included"
2541        );
2542        assert!(
2543            !text.contains("low relevance"),
2544            "low score must be filtered out"
2545        );
2546    }
2547
2548    #[tokio::test]
2549    async fn fetch_document_rag_skips_empty_chunks() {
2550        let mock = MockMemoryBackend {
2551            document_chunks: vec![
2552                MemDocumentChunk {
2553                    text: String::new(),
2554                }, // empty — must be skipped
2555                MemDocumentChunk {
2556                    text: "real content here".to_string(),
2557                },
2558            ],
2559            ..Default::default()
2560        };
2561        let mut view = mock_view(mock);
2562        view.document_config.rag_enabled = true;
2563        let tc = NaiveTokenCounter;
2564        let result = fetch_document_rag(&view, "query", 1000, &tc).await.unwrap();
2565        assert!(result.is_some());
2566        let msg = result.unwrap();
2567        assert!(msg.content.contains("real content here"));
2568        // empty chunk text should not produce an empty line before prefix
2569        assert!(!msg.content.contains("\n\n\n"));
2570    }
2571
2572    #[tokio::test]
2573    async fn fetch_graph_facts_sanitizes_injection_payloads() {
2574        // I3: newlines and angle brackets are replaced with spaces
2575        let mock = MockMemoryBackend {
2576            graph_facts: vec![zeph_common::memory::MemGraphFact {
2577                fact: "fact with <script>alert(1)</script> and\nnewline".to_string(),
2578                confidence: 0.8,
2579                activation_score: None,
2580                neighbors: vec![],
2581                provenance_snippet: None,
2582            }],
2583            ..Default::default()
2584        };
2585        let mut view = mock_view(mock);
2586        view.graph_config.enabled = true;
2587        view.graph_config.spreading_activation.recall_timeout_ms = 5000;
2588        let tc = NaiveTokenCounter;
2589        let result = fetch_graph_facts(&view, "test", 1000, &tc).await.unwrap();
2590        assert!(result.is_some());
2591        let msg = result.unwrap();
2592        assert!(
2593            !msg.content.contains('<'),
2594            "angle brackets must be sanitized"
2595        );
2596        // The formatter adds trailing \n to each line, but embedded \n in fact text is replaced
2597        // with spaces. Verify no double-newline sequences exist (would indicate unsanitized \n).
2598        assert!(
2599            !msg.content.contains("\n\n"),
2600            "embedded newlines must be sanitized, no double-newline sequences expected"
2601        );
2602    }
2603
2604    #[tokio::test]
2605    async fn fetch_reasoning_strategies_sanitizes_injection_payloads() {
2606        // I3: newlines and angle brackets are replaced with spaces in strategy summaries
2607        let mock = MockMemoryBackend {
2608            reasoning_strategies: vec![MemReasoningStrategy {
2609                id: "s1".to_string(),
2610                outcome: "success".to_string(),
2611                summary: "strategy with <b>bold</b> and\nnewline".to_string(),
2612            }],
2613            ..Default::default()
2614        };
2615        let mut view = mock_view(mock);
2616        view.reasoning_config.enabled = true;
2617        let tc = NaiveTokenCounter;
2618        let (result, _handle) = fetch_reasoning_strategies(&view, "query", 1000, 3, &tc)
2619            .await
2620            .unwrap();
2621        assert!(result.is_some());
2622        let msg = result.unwrap();
2623        assert!(
2624            !msg.content.contains('<'),
2625            "angle brackets must be sanitized in strategy summaries"
2626        );
2627    }
2628
2629    // ── budget truncation (CR-1) ──────────────────────────────────────────────
2630
2631    #[tokio::test]
2632    async fn fetch_persona_facts_truncates_at_budget() {
2633        let tc = NaiveTokenCounter;
2634        // Tight budget: fits prefix + exactly 1 fact line, second must be omitted.
2635        let first_line = "[pref] brief\n";
2636        let budget = tc.count_tokens(crate::slot::PERSONA_PREFIX) + tc.count_tokens(first_line);
2637        let mock = MockMemoryBackend {
2638            persona_facts: vec![
2639                MemPersonaFact {
2640                    category: "pref".to_string(),
2641                    content: "brief".to_string(),
2642                },
2643                MemPersonaFact {
2644                    category: "lang".to_string(),
2645                    content: "english".to_string(),
2646                },
2647            ],
2648            ..Default::default()
2649        };
2650        let mut view = mock_view(mock);
2651        view.persona_config.enabled = true;
2652        let result = fetch_persona_facts(&view, budget, &tc).await.unwrap();
2653        let msg = result.unwrap();
2654        assert!(msg.content.contains("brief"), "first fact must be included");
2655        assert!(
2656            !msg.content.contains("english"),
2657            "second fact must be truncated by budget"
2658        );
2659    }
2660
2661    #[tokio::test]
2662    async fn fetch_semantic_recall_truncates_at_budget() {
2663        let tc = NaiveTokenCounter;
2664        // Tight budget: fits prefix + exactly 1 recall entry, second must be omitted.
2665        let first_entry = "- [user] first message\n";
2666        let budget = tc.count_tokens(RECALL_PREFIX) + tc.count_tokens(first_entry);
2667        let mock = MockMemoryBackend {
2668            recalled: vec![
2669                MemRecalledMessage {
2670                    role: "user".to_string(),
2671                    content: "first message".to_string(),
2672                    score: 0.95,
2673                },
2674                MemRecalledMessage {
2675                    role: "user".to_string(),
2676                    content: "second message that should be truncated".to_string(),
2677                    score: 0.80,
2678                },
2679            ],
2680            ..Default::default()
2681        };
2682        let mut view = mock_view(mock);
2683        view.recall_limit = 10;
2684        let (msg, _) = fetch_semantic_recall(&view, "query", budget, &tc, None)
2685            .await
2686            .unwrap();
2687        assert!(msg.is_some());
2688        let text = msg
2689            .unwrap()
2690            .parts
2691            .iter()
2692            .find_map(|p| {
2693                if let zeph_llm::provider::MessagePart::Recall { text } = p {
2694                    Some(text.clone())
2695                } else {
2696                    None
2697                }
2698            })
2699            .unwrap_or_default();
2700        assert!(
2701            text.contains("first message"),
2702            "first entry must be included"
2703        );
2704        assert!(
2705            !text.contains("second message"),
2706            "second entry must be truncated by budget"
2707        );
2708    }
2709
2710    // ── provenance_snippet sanitization (CR-2 test) ───────────────────────────
2711
2712    #[tokio::test]
2713    async fn fetch_graph_facts_sanitizes_provenance_snippet() {
2714        use zeph_common::memory::MemGraphNeighbor;
2715        let mock = MockMemoryBackend {
2716            graph_facts: vec![zeph_common::memory::MemGraphFact {
2717                fact: "safe fact".to_string(),
2718                confidence: 0.9,
2719                activation_score: None,
2720                neighbors: vec![MemGraphNeighbor {
2721                    fact: "neighbor".to_string(),
2722                    confidence: 0.7,
2723                }],
2724                provenance_snippet: Some("source with <injection>\nand newline".to_string()),
2725            }],
2726            ..Default::default()
2727        };
2728        let mut view = mock_view(mock);
2729        view.graph_config.enabled = true;
2730        view.graph_config.spreading_activation.recall_timeout_ms = 5000;
2731        let tc = NaiveTokenCounter;
2732        let result = fetch_graph_facts(&view, "test", 1000, &tc).await.unwrap();
2733        assert!(result.is_some());
2734        let msg = result.unwrap();
2735        assert!(
2736            !msg.content.contains('<'),
2737            "angle brackets in provenance_snippet must be sanitized"
2738        );
2739        assert!(
2740            !msg.content.contains("\n\n"),
2741            "newlines in provenance_snippet must be sanitized"
2742        );
2743        assert!(
2744            msg.content.contains("[source:"),
2745            "provenance snippet must be rendered"
2746        );
2747    }
2748
2749    // ── timeout guard (#5481) ─────────────────────────────────────────────────
2750    //
2751    // Uses `start_paused = true` so the mock's artificial delay and the fetcher's
2752    // internal `tokio::time::timeout` race on tokio's virtual clock: since nothing
2753    // else is runnable, the executor auto-advances straight to the earlier deadline
2754    // (the 1s `MEMORY_FETCH_TIMEOUT_MS`), so the test resolves instantly in real time.
2755
2756    #[tokio::test(start_paused = true)]
2757    async fn fetch_persona_facts_degrades_to_empty_on_timeout() {
2758        let mock = MockMemoryBackend {
2759            persona_facts: vec![MemPersonaFact {
2760                category: "pref".to_string(),
2761                content: "would have been returned".to_string(),
2762            }],
2763            delay: Some(std::time::Duration::from_millis(
2764                MEMORY_FETCH_TIMEOUT_MS + 1000,
2765            )),
2766            ..Default::default()
2767        };
2768        let mut view = mock_view(mock);
2769        view.persona_config.enabled = true;
2770        let tc = NaiveTokenCounter;
2771        let result = fetch_persona_facts(&view, 1000, &tc).await;
2772        assert!(
2773            result.is_ok(),
2774            "timeout must degrade gracefully, not propagate as an error: {result:?}"
2775        );
2776        assert!(
2777            result.unwrap().is_none(),
2778            "timed-out fetch must yield no message, not the stale backend data"
2779        );
2780    }
2781
2782    #[tokio::test(start_paused = true)]
2783    async fn fetch_semantic_recall_degrades_to_empty_on_timeout() {
2784        let mock = MockMemoryBackend {
2785            recalled: vec![MemRecalledMessage {
2786                role: "user".to_string(),
2787                content: "would have been returned".to_string(),
2788                score: 0.95,
2789            }],
2790            delay: Some(std::time::Duration::from_millis(
2791                MEMORY_FETCH_TIMEOUT_MS + 1000,
2792            )),
2793            ..Default::default()
2794        };
2795        let mut view = mock_view(mock);
2796        view.recall_limit = 10;
2797        let tc = NaiveTokenCounter;
2798        let result = fetch_semantic_recall(&view, "query", 1000, &tc, None).await;
2799        assert!(
2800            result.is_ok(),
2801            "timeout must degrade gracefully, not propagate as an error: {result:?}"
2802        );
2803        let (msg, score) = result.unwrap();
2804        assert!(msg.is_none(), "timed-out recall must yield no message");
2805        assert!(score.is_none(), "timed-out recall must yield no score");
2806    }
2807
2808    // ── append_budgeted_lines (#5482 shared helper) ───────────────────────────
2809
2810    #[test]
2811    fn append_budgeted_lines_empty_input_returns_none() {
2812        let tc = NaiveTokenCounter;
2813        let result = append_budgeted_lines("prefix\n", std::iter::empty(), 1000, &tc);
2814        assert!(result.is_none());
2815    }
2816
2817    #[test]
2818    fn append_budgeted_lines_all_items_fit() {
2819        let tc = NaiveTokenCounter;
2820        let lines = vec![
2821            "one\n".to_string(),
2822            "two\n".to_string(),
2823            "three\n".to_string(),
2824        ];
2825        let result = append_budgeted_lines("prefix\n", lines.into_iter(), 1000, &tc).unwrap();
2826        assert!(result.starts_with("prefix\n"));
2827        assert!(result.contains("one"));
2828        assert!(result.contains("two"));
2829        assert!(result.contains("three"));
2830    }
2831
2832    #[test]
2833    fn append_budgeted_lines_truncates_at_budget() {
2834        let tc = NaiveTokenCounter;
2835        let prefix = "prefix\n";
2836        let first = "one\n";
2837        // Budget fits prefix + exactly the first line; the second must be dropped.
2838        let budget = tc.count_tokens(prefix) + tc.count_tokens(first);
2839        let lines = vec![first.to_string(), "two extra words here\n".to_string()];
2840        let result = append_budgeted_lines(prefix, lines.into_iter(), budget, &tc).unwrap();
2841        assert!(result.contains("one"), "first line must fit in budget");
2842        assert!(
2843            !result.contains("two extra words"),
2844            "second line must be truncated by budget"
2845        );
2846    }
2847
2848    #[test]
2849    fn append_budgeted_lines_zero_budget_returns_none() {
2850        let tc = NaiveTokenCounter;
2851        let lines = vec!["one\n".to_string()];
2852        let result = append_budgeted_lines("prefix\n", lines.into_iter(), 0, &tc);
2853        assert!(result.is_none(), "no line can fit within a zero budget");
2854    }
2855}