Skip to main content

newt_core/agentic/
memory_fetch.rs

1//! The `memory_fetch` tool seam — model-driven pull of an ADDRESSED memory
2//! item (progressive-disclosure memory, Workstream A MVP, #319).
3//!
4//! Mirrors the `recall` architecture ([`super::recall`]) exactly: the loop
5//! cannot name [`crate::store::ConversationStore`] / [`crate::notes::NoteStore`]
6//! directly without dragging persistence wiring into `newt-core::agentic`, so
7//! the seam is a minimal trait — the TUI injects [`StoreMemorySource`]
8//! (workspace-fenced by the store itself) and passes it through `ChatCtx` as
9//! `Option<&dyn MemorySource>`. `None` ⇒ the tool is not advertised and the
10//! loop never fetches — eval/headless/ACP callers are unaffected, bit-for-bit.
11//!
12//! Where `recall` returns ranked *snippets* over PAST conversations,
13//! `memory_fetch` returns the *full verbatim body* of one item the model
14//! already saw an address for (a note id in the memory index, a `seq` in a
15//! recall hit). It is `use_skill` for memory: index in the prompt, body on the
16//! tool call.
17//!
18//! Design lineage (the recall lesson): coaching schema text tuned for small
19//! local models — the three address forms shown by example, and when to reach
20//! for a fetch vs. a re-read vs. a recall. Every dispatch branch returns a
21//! tool *result* (found / not-found / malformed address → friendly coaching
22//! text), never a loop-aborting error.
23//!
24//! ## MVP scope (#319, the design note's §9 MVP)
25//!
26//! Resolves `note:<id>` (a [`crate::notes::NoteStore`] body), `turn:<conv>#<seq>`
27//! (a [`crate::store::ConversationStore`] turn), `spill:<id>` (an offloaded tool
28//! payload), and — #661 group B — `compaction:<id>` (the verbatim, redacted
29//! middle span the compressor evicted, retrievable losslessly from the session
30//! compaction store). Compaction spans are redacted on store (the same closed
31//! `redact_secrets` table `spill:` uses) and session-scoped; an absent/expired
32//! id resolves to labelled coaching, never a crash.
33
34use super::display::{print_tool_call, print_tool_output};
35
36/// A parsed, tagged memory address. Each variant resolves against its own
37/// surface ([`Self::Note`]/[`Self::Turn`] read existing stores; [`Self::Spill`]/
38/// [`Self::Compaction`] read session-scoped redacted stores).
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum MemAddr {
41    /// `note:<id>` — the verbatim body of one [`crate::notes::NoteStore`]
42    /// entry, addressed by the 1-based id the memory index renders.
43    Note { id: String },
44    /// `turn:<conv>#<seq>` — one past [`crate::store::ConversationStore`]
45    /// turn's verbatim user/assistant text, §6-ordered by `seq` (never
46    /// re-sorted by clock).
47    Turn { conversation: String, seq: i64 },
48    /// `compaction:<id>` — the verbatim (redacted) middle span the compressor
49    /// evicted, retrievable losslessly from the session compaction store
50    /// (#661 group B). Session-scoped; expires at `/new`.
51    Compaction { id: String },
52    /// `spill:<id>` — the full (redacted) payload of a tool result that was
53    /// offloaded by the `tool_offload` feature (Step 26.3, #584). Session-scoped.
54    Spill { id: String },
55}
56
57impl MemAddr {
58    /// Parse a tagged address. Returns `None` for anything that does not match
59    /// `note:…`, `turn:…#…`, or `compaction:…` — the executor turns a `None`
60    /// into coaching, never an error.
61    pub fn parse(raw: &str) -> Option<Self> {
62        let raw = raw.trim();
63        if let Some(id) = raw.strip_prefix("note:") {
64            let id = id.trim();
65            if id.is_empty() {
66                return None;
67            }
68            return Some(Self::Note { id: id.to_string() });
69        }
70        if let Some(rest) = raw.strip_prefix("turn:") {
71            // `turn:<conv>#<seq>` — the `#` separates the conversation id from
72            // the §6 seq tick. Both halves must be present and non-empty.
73            let (conv, seq) = rest.rsplit_once('#')?;
74            let conv = conv.trim();
75            let seq: i64 = seq.trim().parse().ok()?;
76            if conv.is_empty() {
77                return None;
78            }
79            return Some(Self::Turn {
80                conversation: conv.to_string(),
81                seq,
82            });
83        }
84        if let Some(id) = raw.strip_prefix("compaction:") {
85            let id = id.trim();
86            if id.is_empty() {
87                return None;
88            }
89            return Some(Self::Compaction { id: id.to_string() });
90        }
91        if let Some(id) = raw.strip_prefix("spill:") {
92            let id = id.trim();
93            if id.is_empty() {
94                return None;
95            }
96            return Some(Self::Spill { id: id.to_string() });
97        }
98        None
99    }
100}
101
102/// The verbatim payload one [`MemorySource::fetch`] returns, or labelled
103/// absence. Never an empty string — every variant carries text the executor
104/// renders back to the model.
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub enum MemPayload {
107    /// The item was found: its full verbatim body.
108    Found(String),
109    /// The address was well-formed but no such item exists (unknown note id,
110    /// unknown conversation, no turn at that seq, or — in this MVP — a
111    /// `compaction:` address whose retention surface is not yet built).
112    /// `reason` is a short human-readable why, shown to the model as coaching.
113    NotFound { reason: String },
114}
115
116/// Read-only pull of an ADDRESSED memory item behind the `memory_fetch` tool.
117///
118/// Object-safe and shareable (the loop holds `&dyn MemorySource`; `Sync`
119/// because the borrow crosses `.await` points) — the exact bounds and seam as
120/// [`super::recall::RecallSource`]. Implementations MUST be scoped to the
121/// active workspace: a `turn:`/`compaction:` address from another workspace
122/// resolves to [`MemPayload::NotFound`], never a cross-workspace leak (§7).
123pub trait MemorySource: Send + Sync {
124    /// Resolve `addr` to its verbatim body or labelled absence. A genuine
125    /// backend failure (e.g. a corrupt store row) is an `Err`; an unknown but
126    /// well-formed address is `Ok(MemPayload::NotFound)` — the executor
127    /// distinguishes the two (a backend error surfaces as `error:` verbatim;
128    /// absence surfaces as coaching).
129    fn fetch(&self, addr: &MemAddr) -> anyhow::Result<MemPayload>;
130}
131
132/// The canonical [`MemorySource`]: a [`crate::notes::NoteStore`] for `note:`
133/// bodies and a [`crate::store::ConversationStore`] for `turn:` bodies (already
134/// fenced to its workspace key). The TUI constructs one per turn next to its
135/// `NoteSink` / `StoreRecallSource`.
136///
137/// Both surfaces already exist (the MVP adds no persistence): `note:` reads the
138/// live `NoteStore` entries, `turn:` reads a single past turn by `(conv, seq)`.
139pub struct StoreMemorySource<'a> {
140    notes: &'a crate::notes::NoteStore,
141    store: &'a crate::store::ConversationStore,
142    /// Session spill store for `spill:` re-reads (Step 26.3, #584). `None` when
143    /// the `tool_offload` feature is off / headless — `spill:` then resolves to
144    /// a labelled absence, never a panic.
145    spill: Option<&'a dyn super::spill::SpillStore>,
146    /// Session store for `compaction:` re-reads (#661 group B): the verbatim
147    /// (redacted) middle span the compressor evicted, retrievable losslessly.
148    /// A SEPARATE store from `spill` (its own id space). `None` headless / when
149    /// progressive disclosure is off — `compaction:` then resolves to a labelled
150    /// absence.
151    compaction: Option<&'a dyn super::spill::SpillStore>,
152}
153
154impl<'a> StoreMemorySource<'a> {
155    pub fn new(
156        notes: &'a crate::notes::NoteStore,
157        store: &'a crate::store::ConversationStore,
158    ) -> Self {
159        Self {
160            notes,
161            store,
162            spill: None,
163            compaction: None,
164        }
165    }
166
167    /// Attach a session spill store so `spill:<id>` re-reads resolve (Step 26.3).
168    #[must_use]
169    pub fn with_spill_store(mut self, spill: &'a dyn super::spill::SpillStore) -> Self {
170        self.spill = Some(spill);
171        self
172    }
173
174    /// Attach the session compaction store so `compaction:<id>` re-reads resolve
175    /// (#661 group B — lossless progressive disclosure of evicted spans).
176    #[must_use]
177    pub fn with_compaction_store(mut self, compaction: &'a dyn super::spill::SpillStore) -> Self {
178        self.compaction = Some(compaction);
179        self
180    }
181}
182
183impl MemorySource for StoreMemorySource<'_> {
184    fn fetch(&self, addr: &MemAddr) -> anyhow::Result<MemPayload> {
185        match addr {
186            MemAddr::Note { id } => match self.notes.body_by_id(id) {
187                Some(body) => Ok(MemPayload::Found(body.to_string())),
188                None => Ok(MemPayload::NotFound {
189                    reason: format!(
190                        "no note with id {id:?} — copy a `note:<id>` from the memory index"
191                    ),
192                }),
193            },
194            MemAddr::Turn { conversation, seq } => {
195                // Workspace-fenced + §6-ordered by the store (load_turn joins
196                // on workspace_key and keys on the seq tick; nothing here
197                // re-sorts by clock).
198                match self.store.load_turn(conversation, *seq)? {
199                    Some(turn) => Ok(MemPayload::Found(render_turn(&turn))),
200                    None => Ok(MemPayload::NotFound {
201                        reason: format!(
202                            "no turn at seq {seq} in conversation {conversation:?} \
203                             (or it is in another workspace)"
204                        ),
205                    }),
206                }
207            }
208            // #661 group B: the verbatim (redacted) middle span the compressor
209            // evicted, if the session compaction store still holds it. Redacted
210            // on store (same closed-table contract as `spill:`), session-scoped.
211            MemAddr::Compaction { id } => match self.compaction.and_then(|s| s.fetch(id)) {
212                Some(body) => Ok(MemPayload::Found(body)),
213                None => Ok(MemPayload::NotFound {
214                    reason: format!(
215                        "no compaction span with id {id:?} — spans are session-scoped \
216                         and may have expired; re-read the file the breadcrumb names, \
217                         or `recall` the topic"
218                    ),
219                }),
220            },
221            // Step 26.3 (#584): the offloaded (redacted) tool payload, if the
222            // session spill store still holds it.
223            MemAddr::Spill { id } => match self.spill.and_then(|s| s.fetch(id)) {
224                Some(body) => Ok(MemPayload::Found(body)),
225                None => Ok(MemPayload::NotFound {
226                    reason: format!(
227                        "no spilled payload with id {id:?} — spills are session-scoped \
228                         and may have expired (re-run the tool if you still need it)"
229                    ),
230                }),
231            },
232        }
233    }
234}
235
236/// Render one past turn's verbatim user/assistant text for the tool result.
237/// A reply-less turn (a restored compaction record) shows only its user side.
238fn render_turn(turn: &crate::ConversationTurn) -> String {
239    let mut out = String::new();
240    if !turn.user.is_empty() {
241        out.push_str("user: ");
242        out.push_str(&turn.user);
243    }
244    if !turn.assistant.is_empty() {
245        if !out.is_empty() {
246            out.push('\n');
247        }
248        out.push_str("assistant: ");
249        out.push_str(&turn.assistant);
250    }
251    out
252}
253
254// ---------------------------------------------------------------------------
255// Tool schema
256// ---------------------------------------------------------------------------
257
258/// The model-facing contract for `memory_fetch` — coaching text for a small
259/// local LLM (the recall lesson, the design note's §8.4): the three address
260/// forms shown BY EXAMPLE, and when to reach for a fetch vs. a re-read vs. a
261/// recall. Kept tight: schema tokens ride in every request.
262const MEMORY_FETCH_DESCRIPTION: &str =
263    "Fetch the FULL verbatim body of one memory item by its address — a note, \
264     or one past turn. Use this to pull back an exact body you only have an \
265     INDEX line or a recall snippet for, instead of guessing its content. \
266     Addresses look like `note:3` (a numbered note from the memory index) or \
267     `turn:<conversation-id>#<seq>` (one past turn, e.g. \
268     `turn:174856320012#7` — copy the id and `seq N` from a recall hit), \
269     or `spill:<id>` (the full secret-redacted body of a tool output that was \
270     truncated for length — the `[… truncated …]` marker carries the id; add \
271     `grep` to return matching lines instead of the whole payload), \
272     or `compaction:<id>` (the full secret-redacted text of an earlier \
273     conversation span the compressor summarized away — the compaction summary \
274     names the id; use it to recover an exact detail the summary dropped). \
275     Reach for memory_fetch when you have an address but not the body; \
276     re-read the file with read_file if the content is a file still on disk; \
277     use recall to SEARCH past conversations when you don't have an address \
278     yet. One item per call; copy the address exactly as it was shown to you.";
279
280/// The `memory_fetch` tool definition. NOT part of [`super::tool_definitions`]:
281/// the loop advertises it only when a [`MemorySource`] is present, so headless
282/// / eval / ACP callers (which pass `memory_source: None`) never see it — the
283/// exact gating discipline as `recall` and `save_note`.
284pub fn memory_fetch_tool_definition() -> serde_json::Value {
285    serde_json::json!({
286        "type": "function",
287        "function": {
288            "name": "memory_fetch",
289            "description": MEMORY_FETCH_DESCRIPTION,
290            "parameters": {
291                "type": "object",
292                "properties": {
293                    "address": {
294                        "type": "string",
295                        "description": "The tagged address to fetch, e.g. \
296                                        'note:3', 'turn:174856320012#7', 'spill:s3', \
297                                        or 'compaction:s1' — copy it exactly as the \
298                                        index, a recall hit, a truncation marker, or \
299                                        a compaction summary showed it"
300                    },
301                    "grep": {
302                        "type": "string",
303                        "description": "Optional substring to search inside the fetched body. \
304                                        Useful for spill:<id> command output; returns matching \
305                                        lines with a small amount of context instead of the full \
306                                        payload."
307                    }
308                },
309                "required": ["address"]
310            }
311        }
312    })
313}
314
315// ---------------------------------------------------------------------------
316// Dispatch
317// ---------------------------------------------------------------------------
318
319/// Execute one `memory_fetch` call against the source and return the result
320/// text fed back to the model.
321///
322/// Outcome contract (every branch is a *tool result*, never a loop abort —
323/// the `execute_recall` template):
324/// - missing/empty `address` → coaching naming the address forms;
325/// - a malformed address (no recognized tag) → coaching naming the three
326///   forms by example;
327/// - a well-formed but unknown address → "no such memory item …" (the
328///   labelled-absence the #319 design turns on — never an empty string);
329/// - a real backend failure → `error: …`, verbatim, like every other tool;
330/// - found → the verbatim body.
331pub(crate) fn execute_memory_fetch(
332    args: &serde_json::Value,
333    source: &dyn MemorySource,
334    color: bool,
335    tool_output_lines: usize,
336) -> String {
337    let address = args["address"].as_str().unwrap_or("").trim();
338    let grep = args
339        .get("grep")
340        .and_then(serde_json::Value::as_str)
341        .map(str::trim)
342        .filter(|s| !s.is_empty());
343
344    print_tool_call("memory_fetch", address, color);
345
346    if address.is_empty() {
347        return "error: memory_fetch requires `address` — e.g. `note:3` or \
348                `turn:<conversation-id>#<seq>`"
349            .to_string();
350    }
351
352    let Some(addr) = MemAddr::parse(address) else {
353        let out = format!(
354            "{address:?} is not a memory address — they look like `note:3` \
355             (a numbered note) or `turn:174856320012#7` (a conversation id \
356             and `seq` from a recall hit). Copy one exactly as it was shown."
357        );
358        print_tool_output(&out, tool_output_lines, color);
359        return out;
360    };
361
362    let payload = match source.fetch(&addr) {
363        Ok(p) => p,
364        Err(e) => return format!("error: {e}"),
365    };
366
367    let out = match payload {
368        MemPayload::Found(body) => match grep {
369            Some(pattern) => grep_payload(&body, pattern),
370            None => body,
371        },
372        MemPayload::NotFound { reason } => format!("no such memory item: {reason}"),
373    };
374    print_tool_output(&out, tool_output_lines, color);
375    out
376}
377
378fn grep_payload(body: &str, pattern: &str) -> String {
379    const CONTEXT: usize = 2;
380    const MAX_MATCHES: usize = 40;
381    let lines: Vec<&str> = body.lines().collect();
382    let mut ranges: Vec<(usize, usize)> = Vec::new();
383    for (idx, line) in lines.iter().enumerate() {
384        if line.contains(pattern) {
385            if ranges.len() >= MAX_MATCHES {
386                break;
387            }
388            let start = idx.saturating_sub(CONTEXT);
389            let end = (idx + CONTEXT + 1).min(lines.len());
390            if let Some((_, prev_end)) = ranges.last_mut() {
391                if start <= *prev_end {
392                    *prev_end = end.max(*prev_end);
393                    continue;
394                }
395            }
396            ranges.push((start, end));
397        }
398    }
399    if ranges.is_empty() {
400        return format!("no matches for {pattern:?} in fetched payload");
401    }
402    let mut out = format!("matches for {pattern:?} in fetched payload:");
403    for (range_idx, (start, end)) in ranges.iter().enumerate() {
404        if range_idx > 0 {
405            out.push_str("\n--");
406        }
407        for (line_idx, line) in lines.iter().enumerate().take(*end).skip(*start) {
408            use std::fmt::Write as _;
409            let _ = write!(out, "\n{:>6}: {}", line_idx + 1, line);
410        }
411    }
412    if lines.iter().filter(|line| line.contains(pattern)).count() > MAX_MATCHES {
413        out.push_str("\n[additional matches omitted; use a narrower grep pattern]");
414    }
415    out
416}
417
418// ---------------------------------------------------------------------------
419// Tests
420// ---------------------------------------------------------------------------
421
422#[cfg(test)]
423pub(crate) mod tests {
424    use super::*;
425    use std::sync::Mutex;
426
427    /// Open a real `ConversationStore` for a test — `root` and `workspace`
428    /// are separate dirs (`new(root, workspace, max)`), the idiom the store's
429    /// own tests use.
430    fn test_store(dir: &tempfile::TempDir) -> crate::store::ConversationStore {
431        let ws = dir.path().join("ws");
432        std::fs::create_dir_all(&ws).unwrap();
433        crate::store::ConversationStore::new(dir.path().join("root"), &ws, 10).unwrap()
434    }
435
436    /// Scriptable mock: records every fetched address, serves a canned payload
437    /// keyed on the address form, or a canned error. Shared with the dispatch
438    /// tests in `agentic::tools` (the `recall::MockSource` pattern).
439    #[derive(Default)]
440    pub(crate) struct MockSource {
441        pub calls: Mutex<Vec<MemAddr>>,
442        /// Body returned for any `Note`/`Turn` (None ⇒ NotFound).
443        pub body: Option<String>,
444        pub fail_with: Option<String>,
445    }
446
447    impl MemorySource for MockSource {
448        fn fetch(&self, addr: &MemAddr) -> anyhow::Result<MemPayload> {
449            self.calls.lock().unwrap().push(addr.clone());
450            if let Some(e) = &self.fail_with {
451                return Err(anyhow::anyhow!("{e}"));
452            }
453            match &self.body {
454                Some(b) => Ok(MemPayload::Found(b.clone())),
455                None => Ok(MemPayload::NotFound {
456                    reason: "mock has no such item".to_string(),
457                }),
458            }
459        }
460    }
461
462    // -- address parsing -----------------------------------------------------
463
464    #[test]
465    fn parse_note_turn_and_compaction_forms() {
466        assert_eq!(
467            MemAddr::parse("note:3"),
468            Some(MemAddr::Note { id: "3".into() })
469        );
470        assert_eq!(
471            MemAddr::parse("  turn:174856320012#7 "),
472            Some(MemAddr::Turn {
473                conversation: "174856320012".into(),
474                seq: 7
475            })
476        );
477        assert_eq!(
478            MemAddr::parse("compaction:abc"),
479            Some(MemAddr::Compaction { id: "abc".into() })
480        );
481        // Step 26.3 (#584): the spill: form (trims; non-empty).
482        assert_eq!(
483            MemAddr::parse("  spill:s3 "),
484            Some(MemAddr::Spill { id: "s3".into() })
485        );
486        assert_eq!(MemAddr::parse("spill:"), None);
487    }
488
489    #[test]
490    fn parse_rejects_malformed_addresses() {
491        // No tag, empty body, missing seq, non-numeric seq, empty conversation.
492        assert_eq!(MemAddr::parse("just some text"), None);
493        assert_eq!(MemAddr::parse("note:"), None);
494        assert_eq!(MemAddr::parse("turn:174856320012"), None);
495        assert_eq!(MemAddr::parse("turn:174856320012#notanumber"), None);
496        assert_eq!(MemAddr::parse("turn:#7"), None);
497        assert_eq!(MemAddr::parse("compaction:"), None);
498    }
499
500    #[test]
501    fn parse_turn_takes_last_hash_so_conversation_ids_may_contain_hash() {
502        // rsplit on '#' — the seq is always the final segment.
503        assert_eq!(
504            MemAddr::parse("turn:conv#with#hash#42"),
505            Some(MemAddr::Turn {
506                conversation: "conv#with#hash".into(),
507                seq: 42
508            })
509        );
510    }
511
512    // -- schema text: the model-facing coaching ------------------------------
513
514    #[test]
515    fn schema_shows_the_address_forms_by_example() {
516        let def = memory_fetch_tool_definition();
517        let desc = def["function"]["description"].as_str().unwrap();
518        assert!(desc.contains("note:3"), "got: {desc}");
519        assert!(desc.contains("turn:174856320012#7"), "got: {desc}");
520    }
521
522    #[test]
523    fn schema_distinguishes_fetch_from_reread_and_recall() {
524        let def = memory_fetch_tool_definition();
525        let desc = def["function"]["description"].as_str().unwrap();
526        assert!(desc.contains("re-read the file"), "got: {desc}");
527        assert!(desc.contains("read_file"), "got: {desc}");
528        assert!(desc.contains("use recall to SEARCH"), "got: {desc}");
529    }
530
531    #[test]
532    fn schema_shape_address_required() {
533        let def = memory_fetch_tool_definition();
534        assert_eq!(def["function"]["name"], "memory_fetch");
535        let required: Vec<&str> = def["function"]["parameters"]["required"]
536            .as_array()
537            .unwrap()
538            .iter()
539            .filter_map(|v| v.as_str())
540            .collect();
541        assert_eq!(required, vec!["address"]);
542        assert!(def["function"]["parameters"]["properties"]["address"].is_object());
543    }
544
545    // -- dispatch ------------------------------------------------------------
546
547    #[test]
548    fn found_returns_the_verbatim_body() {
549        let source = MockSource {
550            body: Some("the exact bytes".to_string()),
551            ..Default::default()
552        };
553        let out = execute_memory_fetch(
554            &serde_json::json!({"address": "note:2"}),
555            &source,
556            false,
557            20,
558        );
559        assert_eq!(out, "the exact bytes");
560        assert_eq!(
561            *source.calls.lock().unwrap(),
562            vec![MemAddr::Note { id: "2".into() }]
563        );
564    }
565
566    #[test]
567    fn grep_returns_matching_lines_with_context() {
568        let source = MockSource {
569            body: Some("first\nbefore\nneedle here\nafter\nlast\n".to_string()),
570            ..Default::default()
571        };
572        let out = execute_memory_fetch(
573            &serde_json::json!({"address": "spill:s0", "grep": "needle"}),
574            &source,
575            false,
576            20,
577        );
578        assert!(out.contains("matches for \"needle\""), "{out}");
579        assert!(out.contains("     2: before"), "{out}");
580        assert!(out.contains("     3: needle here"), "{out}");
581        assert!(out.contains("     4: after"), "{out}");
582        assert!(
583            !out.contains("first\nbefore"),
584            "should render numbered lines"
585        );
586    }
587
588    #[test]
589    fn grep_reports_no_matches_without_returning_full_payload() {
590        let source = MockSource {
591            body: Some("alpha\nbeta\ngamma\n".to_string()),
592            ..Default::default()
593        };
594        let out = execute_memory_fetch(
595            &serde_json::json!({"address": "spill:s0", "grep": "delta"}),
596            &source,
597            false,
598            20,
599        );
600        assert!(out.contains("no matches for \"delta\""), "{out}");
601        assert!(!out.contains("alpha\nbeta"), "{out}");
602    }
603
604    #[test]
605    fn not_found_is_labelled_absence_never_empty() {
606        let source = MockSource::default(); // body None ⇒ NotFound
607        let out = execute_memory_fetch(
608            &serde_json::json!({"address": "note:99"}),
609            &source,
610            false,
611            20,
612        );
613        assert!(out.starts_with("no such memory item:"), "got: {out}");
614        assert!(!out.is_empty());
615        assert!(!out.starts_with("error:"), "must not abort-shape: {out}");
616    }
617
618    #[test]
619    fn malformed_address_is_coaching_not_error_and_never_hits_backend() {
620        let source = MockSource::default();
621        let out = execute_memory_fetch(
622            &serde_json::json!({"address": "give me the api signatures"}),
623            &source,
624            false,
625            20,
626        );
627        assert!(out.contains("is not a memory address"), "got: {out}");
628        assert!(out.contains("note:3"), "coaching shows the forms: {out}");
629        assert!(!out.starts_with("error:"), "must not abort-shape: {out}");
630        assert!(
631            source.calls.lock().unwrap().is_empty(),
632            "a malformed address must never reach the backend"
633        );
634    }
635
636    #[test]
637    fn missing_address_is_a_clear_error() {
638        let source = MockSource::default();
639        let out = execute_memory_fetch(&serde_json::json!({}), &source, false, 20);
640        assert!(out.contains("requires `address`"), "got: {out}");
641        assert!(source.calls.lock().unwrap().is_empty());
642    }
643
644    #[test]
645    fn backend_failure_surfaces_as_tool_error_text() {
646        let source = MockSource {
647            fail_with: Some("store is on fire".to_string()),
648            ..Default::default()
649        };
650        let out = execute_memory_fetch(
651            &serde_json::json!({"address": "turn:abc#1"}),
652            &source,
653            false,
654            20,
655        );
656        assert_eq!(out, "error: store is on fire");
657    }
658
659    #[test]
660    fn compaction_address_resolves_via_the_session_compaction_store() {
661        use crate::agentic::spill::{SessionSpillStore, SpillStore};
662        // #661 group B: a `compaction:` address returns the verbatim (redacted)
663        // evicted span while the session store holds it; unknown / no-store →
664        // labelled absence, never a malformed-address answer or a panic.
665        let dir = tempfile::tempdir().unwrap();
666        let notes = crate::notes::NoteStore::new(dir.path().join("NOTES.md"), 2_200);
667        let store = test_store(&dir);
668        let compaction = SessionSpillStore::default();
669        let id = compaction.store("the verbatim evicted middle".to_string());
670
671        let source = StoreMemorySource::new(&notes, &store).with_compaction_store(&compaction);
672        let hit = execute_memory_fetch(
673            &serde_json::json!({ "address": format!("compaction:{id}") }),
674            &source,
675            false,
676            20,
677        );
678        assert_eq!(hit, "the verbatim evicted middle");
679
680        let miss = execute_memory_fetch(
681            &serde_json::json!({"address": "compaction:s99"}),
682            &source,
683            false,
684            20,
685        );
686        assert!(miss.contains("session-scoped"), "got: {miss}");
687
688        // The compaction store is SEPARATE from the spill store (own id space):
689        // a `compaction:` address never resolves against `with_spill_store`.
690        let spill = SessionSpillStore::default();
691        spill.store("a tool payload".to_string()); // id s0 in the SPILL space
692        let wrong = StoreMemorySource::new(&notes, &store).with_spill_store(&spill);
693        let none = execute_memory_fetch(
694            &serde_json::json!({"address": "compaction:s0"}),
695            &wrong,
696            false,
697            20,
698        );
699        assert!(none.starts_with("no such memory item:"), "got: {none}");
700    }
701
702    #[test]
703    fn spill_address_resolves_via_the_session_spill_store() {
704        use crate::agentic::spill::{SessionSpillStore, SpillStore};
705        // Step 26.3 (#584): a `spill:` address returns the offloaded (redacted)
706        // payload while the session store holds it; unknown / no-store → NotFound.
707        let dir = tempfile::tempdir().unwrap();
708        let notes = crate::notes::NoteStore::new(dir.path().join("NOTES.md"), 2_200);
709        let store = test_store(&dir);
710        let spill = SessionSpillStore::default();
711        let id = spill.store("the full redacted payload".to_string());
712
713        let source = StoreMemorySource::new(&notes, &store).with_spill_store(&spill);
714        let hit = execute_memory_fetch(
715            &serde_json::json!({ "address": format!("spill:{id}") }),
716            &source,
717            false,
718            20,
719        );
720        assert_eq!(hit, "the full redacted payload");
721
722        let miss = execute_memory_fetch(
723            &serde_json::json!({"address": "spill:s99"}),
724            &source,
725            false,
726            20,
727        );
728        assert!(miss.contains("session-scoped"), "got: {miss}");
729
730        // No spill store attached → labelled absence, never a panic.
731        let no_store = StoreMemorySource::new(&notes, &store);
732        let none = execute_memory_fetch(
733            &serde_json::json!({"address": "spill:s0"}),
734            &no_store,
735            false,
736            20,
737        );
738        assert!(none.starts_with("no such memory item:"), "got: {none}");
739    }
740
741    // -- StoreMemorySource against real surfaces (tempdir) -------------------
742
743    #[tokio::test]
744    async fn resolves_a_real_note_body() {
745        use crate::memory::{MemoryProvider, SessionContext};
746        let dir = tempfile::tempdir().unwrap();
747        let mut notes = crate::notes::NoteStore::new(dir.path().join("NOTES.md"), 2_200);
748        notes
749            .initialize(&SessionContext {
750                workspace: dir.path().to_string_lossy().into(),
751                session_id: "s".into(),
752            })
753            .await
754            .unwrap();
755        notes.add("first note body").unwrap();
756        notes.add("second\nmulti-line note").unwrap();
757        let store = test_store(&dir);
758        let source = StoreMemorySource::new(&notes, &store);
759
760        // note:2 → the second entry's verbatim body.
761        let out = execute_memory_fetch(
762            &serde_json::json!({"address": "note:2"}),
763            &source,
764            false,
765            20,
766        );
767        assert_eq!(out, "second\nmulti-line note");
768        // note:9 → labelled absence.
769        let miss = execute_memory_fetch(
770            &serde_json::json!({"address": "note:9"}),
771            &source,
772            false,
773            20,
774        );
775        assert!(miss.starts_with("no such memory item:"), "got: {miss}");
776    }
777
778    #[test]
779    fn resolves_a_real_turn_by_conv_and_seq() {
780        let dir = tempfile::tempdir().unwrap();
781        let notes = crate::notes::NoteStore::new(dir.path().join("NOTES.md"), 2_200);
782        let store = test_store(&dir);
783        let conv = store.create("a conversation", None).unwrap();
784        store
785            .append_turn(
786                &conv,
787                "what is the connect signature?",
788                "fn connect(addr: &str)",
789            )
790            .unwrap();
791        // The seq the model would have seen in a recall hit: the matching
792        // turn's §6 tick. Read the record back to learn the real seq.
793        let hits = store.search("connect", 5).unwrap();
794        assert!(!hits.is_empty(), "the turn must be searchable");
795        let seq = hits[0].seq;
796
797        let source = StoreMemorySource::new(&notes, &store);
798        let out = execute_memory_fetch(
799            &serde_json::json!({"address": format!("turn:{conv}#{seq}")}),
800            &source,
801            false,
802            20,
803        );
804        assert!(
805            out.contains("user: what is the connect signature?"),
806            "got: {out}"
807        );
808        assert!(
809            out.contains("assistant: fn connect(addr: &str)"),
810            "got: {out}"
811        );
812
813        // A seq that doesn't exist → labelled absence, never an error.
814        let miss = execute_memory_fetch(
815            &serde_json::json!({"address": format!("turn:{conv}#999999")}),
816            &source,
817            false,
818            20,
819        );
820        assert!(miss.starts_with("no such memory item:"), "got: {miss}");
821        assert!(!miss.starts_with("error:"), "got: {miss}");
822    }
823
824    #[test]
825    fn turn_from_another_conversation_id_is_absence_not_leak() {
826        let dir = tempfile::tempdir().unwrap();
827        let notes = crate::notes::NoteStore::new(dir.path().join("NOTES.md"), 2_200);
828        let store = test_store(&dir);
829        let source = StoreMemorySource::new(&notes, &store);
830        // An unknown conversation id resolves to absence, never an error.
831        let out = execute_memory_fetch(
832            &serde_json::json!({"address": "turn:nonexistent-conv#1"}),
833            &source,
834            false,
835            20,
836        );
837        assert!(out.starts_with("no such memory item:"), "got: {out}");
838    }
839}