Skip to main content

ai_memory/
toon.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! TOON (Token-Oriented Object Notation) serializer for ai-memory.
5//!
6//! TOON is a token-efficient alternative to JSON designed for LLM communication.
7//! Arrays of objects declare field names once as a header, then list values row
8//! by row using pipe delimiters — eliminating 40-60% of repeated field-name tokens.
9//!
10//! Reference: <https://www.tensorlake.ai/blog-posts/toon-vs-json>
11
12use crate::models::field_names;
13use serde_json::Value;
14use std::fmt::Write;
15
16/// #1558 batch 5 wave 3 — canonical wire name of the compact TOON
17/// output format (`format: "toon_compact"` on `memory_recall` /
18/// `memory_list` / `memory_search` / `memory_session_start`, and the
19/// MCP dispatch default when the caller omits `format`). The
20/// non-compact variant keeps its short `"toon"` literal at the
21/// dispatch sites.
22pub const FORMAT_TOON_COMPACT: &str = "toon_compact";
23
24/// #1579 B4 — canonical wire name of the JSON format (the HTTP
25/// default; MCP keeps its own `toon_compact` default at the dispatch
26/// layer in `src/mcp/mod.rs`).
27pub const FORMAT_JSON: &str = "json";
28
29/// #1579 B4 — canonical wire name of the non-compact TOON format.
30pub const FORMAT_TOON: &str = "toon";
31
32/// #1579 B4 — negotiated response format for the HTTP recall/search
33/// surfaces (`?format=` query param / `format` body field). The MCP
34/// surface has shipped TOON since v0.6.x with a `toon_compact`
35/// default (~79% smaller than JSON); this enum exposes the SAME
36/// encoder over HTTP with a backwards-compatible `json` default.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
38pub enum WireFormat {
39    /// `application/json` envelope — the HTTP default (v0.6.x
40    /// backwards compat).
41    #[default]
42    Json,
43    /// Non-compact TOON (`text/plain`): full column set.
44    Toon,
45    /// Compact TOON (`text/plain`): trimmed column set, ~79% smaller
46    /// than the JSON envelope on memory rows.
47    ToonCompact,
48}
49
50/// #1579 B4 — SSOT rejection message for an unrecognised `format`
51/// value. Composed from the canonical format-name consts so the wire
52/// message can never drift from the accepted set.
53#[must_use]
54pub fn invalid_format_msg(got: &str) -> String {
55    format!(
56        "invalid format '{got}': expected one of {FORMAT_JSON}, {FORMAT_TOON}, {FORMAT_TOON_COMPACT}"
57    )
58}
59
60impl WireFormat {
61    /// Parse the HTTP `format` parameter. `None` (param omitted)
62    /// resolves to the [`Self::Json`] default; an unrecognised value
63    /// is an `Err` carrying the SSOT message from
64    /// [`invalid_format_msg`] (the handlers map it to `400`).
65    ///
66    /// # Errors
67    ///
68    /// Returns `Err(message)` when `raw` is `Some` of anything other
69    /// than [`FORMAT_JSON`] / [`FORMAT_TOON`] / [`FORMAT_TOON_COMPACT`].
70    pub fn parse_http(raw: Option<&str>) -> Result<Self, String> {
71        match raw {
72            None => Ok(Self::Json),
73            Some(s) if s == FORMAT_JSON => Ok(Self::Json),
74            Some(s) if s == FORMAT_TOON => Ok(Self::Toon),
75            Some(s) if s == FORMAT_TOON_COMPACT => Ok(Self::ToonCompact),
76            Some(other) => Err(invalid_format_msg(other)),
77        }
78    }
79}
80
81/// Standard memory fields in TOON column order.
82const MEMORY_FIELDS: &[&str] = &[
83    "id",
84    "title",
85    "tier",
86    "namespace",
87    "priority",
88    field_names::CONFIDENCE,
89    "score",
90    field_names::ACCESS_COUNT,
91    "tags",
92    "source",
93    field_names::CREATED_AT,
94    field_names::UPDATED_AT,
95    "metadata",
96];
97
98/// Compact memory fields — omits timestamps for tighter output.
99/// Includes `agent_id` (pulled out of `metadata.agent_id`) so AI clients using
100/// the default compact format can see provenance without switching to
101/// non-compact TOON or JSON. See issue #199.
102const MEMORY_FIELDS_COMPACT: &[&str] = &[
103    "id",
104    "title",
105    "tier",
106    "namespace",
107    "priority",
108    "score",
109    "tags",
110    "agent_id",
111];
112
113/// Serialize a recall/list/search response to TOON format.
114///
115/// Input: a JSON object with `"memories"` (array of objects) and optional metadata fields.
116/// Output: TOON string with header + pipe-delimited rows.
117///
118/// Example output:
119/// ```text
120/// count:3|mode:hybrid
121/// memories[id|title|tier|namespace|priority|confidence|score|access_count|tags|source|created_at|updated_at]:
122/// abc123|PostgreSQL 16|long|infra|9|1.0|0.763|2|postgres,database|claude|2026-04-03T15:00:00+00:00|2026-04-03T15:00:00+00:00
123/// def456|Redis cache|long|infra|8|1.0|0.541|0|redis,cache|claude|2026-04-03T15:01:00+00:00|2026-04-03T15:01:00+00:00
124/// ```
125pub fn memories_to_toon(response: &Value, compact: bool) -> String {
126    let fields = if compact {
127        MEMORY_FIELDS_COMPACT
128    } else {
129        MEMORY_FIELDS
130    };
131    let mut out = String::with_capacity(1024);
132
133    // Metadata line — key:value pairs for non-array fields
134    let mut meta = Vec::new();
135    if let Some(count) = response.get("count") {
136        meta.push(format!("count:{count}"));
137    }
138    if let Some(mode) = response.get("mode").and_then(|v| v.as_str()) {
139        meta.push(format!("mode:{mode}"));
140    }
141    // Task 1.11: surface token budget info in the meta line when present.
142    if let Some(used) = response.get(field_names::TOKENS_USED) {
143        meta.push(format!("tokens_used:{used}"));
144    }
145    if let Some(budget) = response.get(field_names::BUDGET_TOKENS) {
146        meta.push(format!("budget_tokens:{budget}"));
147    }
148    if !meta.is_empty() {
149        out.push_str(&meta.join("|"));
150        out.push('\n');
151    }
152
153    // Namespace standards — separate section if present
154    let mut std_list: Vec<&Value> = Vec::new();
155    if let Some(standard) = response.get("standard") {
156        std_list.push(standard);
157    }
158    if let Some(standards) = response.get("standards").and_then(|v| v.as_array()) {
159        std_list.extend(standards.iter());
160    }
161    if !std_list.is_empty() {
162        out.push_str("standards[id|title|content]:\n");
163        for standard in &std_list {
164            let id = format_value(standard.get("id"));
165            let title = format_value(standard.get("title"));
166            let content = format_value(standard.get("content"));
167            let _ = writeln!(out, "{id}|{title}|{content}");
168        }
169    }
170
171    // Header line — field names declared once
172    out.push_str("memories[");
173    out.push_str(&fields.join("|"));
174    out.push_str("]:\n");
175
176    // Data rows — one per memory
177    if let Some(memories) = response.get("memories").and_then(|v| v.as_array()) {
178        for mem in memories {
179            let row: Vec<String> = fields
180                .iter()
181                .map(|&field| {
182                    // #199: `agent_id` is nested inside metadata in the Memory struct.
183                    // Surface it as a top-level TOON column by digging into metadata.
184                    if field == "agent_id" {
185                        format_value(mem.get("metadata").and_then(|m| m.get("agent_id")))
186                    } else {
187                        format_value(mem.get(field))
188                    }
189                })
190                .collect();
191            out.push_str(&row.join("|"));
192            out.push('\n');
193        }
194    }
195
196    out
197}
198
199/// Serialize a search response (which uses "results" key) to TOON.
200pub fn search_to_toon(response: &Value, compact: bool) -> String {
201    // Search uses "results" instead of "memories" — normalize
202    if response.get("results").is_some() && response.get("memories").is_none() {
203        let mut normalized = response.clone();
204        if let Some(results) = response.get("results") {
205            normalized["memories"] = results.clone();
206        }
207        return memories_to_toon(&normalized, compact);
208    }
209    memories_to_toon(response, compact)
210}
211
212/// Format a single JSON value for TOON output.
213fn format_value(val: Option<&Value>) -> String {
214    match val {
215        None | Some(Value::Null) => String::new(),
216        Some(Value::String(s)) => escape_toon(s),
217        Some(Value::Number(n)) => n.to_string(),
218        Some(Value::Bool(b)) => {
219            if *b {
220                "1".to_string()
221            } else {
222                "0".to_string()
223            }
224        }
225        Some(Value::Array(arr)) => {
226            // Tags: join with comma
227            let items: Vec<String> = arr
228                .iter()
229                .filter_map(|v| v.as_str().map(String::from))
230                .collect();
231            escape_toon(&items.join(","))
232        }
233        Some(obj @ Value::Object(m)) => {
234            if m.is_empty() {
235                String::new()
236            } else {
237                escape_toon(&serde_json::to_string(obj).unwrap_or_default())
238            }
239        }
240    }
241}
242
243/// Escape special characters in TOON values.
244fn escape_toon(s: &str) -> String {
245    if s.contains('|')
246        || s.contains('\n')
247        || s.contains('\r')
248        || s.contains('\\')
249        || s.contains(':')
250    {
251        s.replace('\\', "\\\\")
252            .replace('|', "\\|")
253            .replace(':', "\\:")
254            .replace('\n', "\\n")
255            .replace('\r', "\\r")
256    } else {
257        s.to_string()
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use crate::models::Tier;
265    use serde_json::json;
266
267    // -----------------------------------------------------------------
268    // #1579 B4 — HTTP `format` param negotiation
269    // -----------------------------------------------------------------
270
271    #[test]
272    fn issue_1579_b4_wire_format_parse_http() {
273        assert_eq!(WireFormat::parse_http(None), Ok(WireFormat::Json));
274        assert_eq!(
275            WireFormat::parse_http(Some(FORMAT_JSON)),
276            Ok(WireFormat::Json)
277        );
278        assert_eq!(
279            WireFormat::parse_http(Some(FORMAT_TOON)),
280            Ok(WireFormat::Toon)
281        );
282        assert_eq!(
283            WireFormat::parse_http(Some(FORMAT_TOON_COMPACT)),
284            Ok(WireFormat::ToonCompact)
285        );
286    }
287
288    #[test]
289    fn issue_1579_b4_wire_format_rejects_unknown_with_ssot_message() {
290        let err = WireFormat::parse_http(Some("yaml")).unwrap_err();
291        assert_eq!(err, invalid_format_msg("yaml"));
292        assert!(err.contains("json") && err.contains("toon") && err.contains("toon_compact"));
293        // Case-sensitive on purpose: the MCP dispatch matches the
294        // exact literals too, so the two surfaces agree.
295        assert!(WireFormat::parse_http(Some("TOON")).is_err());
296    }
297
298    #[test]
299    fn empty_memories() {
300        let resp = json!({"memories": [], "count": 0, "mode": "keyword"});
301        let toon = memories_to_toon(&resp, false);
302        assert!(toon.contains("count:0"));
303        assert!(toon.contains("mode:keyword"));
304        assert!(toon.contains("memories["));
305        // No data rows
306        let lines: Vec<&str> = toon.lines().collect();
307        assert_eq!(lines.len(), 2); // meta + header
308    }
309
310    #[test]
311    fn single_memory() {
312        let resp = json!({
313            "memories": [{
314                "id": "abc-123",
315                "title": "PostgreSQL config",
316                "tier": Tier::Long.as_str(),
317                "namespace": "infra",
318                "priority": 9,
319                "confidence": 1.0,
320                "score": 0.763,
321                "access_count": 2,
322                "tags": ["postgres", "database"],
323                "source": "claude",
324                "created_at": "2026-04-03T15:00:00+00:00",
325                "updated_at": "2026-04-03T15:00:00+00:00"
326            }],
327            "count": 1,
328            "mode": "hybrid"
329        });
330        let toon = memories_to_toon(&resp, false);
331        let lines: Vec<&str> = toon.lines().collect();
332        assert_eq!(lines.len(), 3); // meta + header + 1 row
333        assert!(
334            lines[2].starts_with("abc-123|PostgreSQL config|long|infra|9|"),
335            "got: {}",
336            lines[2]
337        );
338        assert!(lines[2].contains("postgres,database"));
339        assert!(lines[2].contains("claude"));
340    }
341
342    #[test]
343    fn compact_mode_fewer_fields() {
344        let resp = json!({
345            "memories": [{"id": "x", "title": "Test", "tier": Tier::Mid.as_str(), "namespace": "test", "priority": 5, "score": 0.5, "tags": []}],
346            "count": 1
347        });
348        let toon = memories_to_toon(&resp, true);
349        // #199: agent_id is in the compact header; it's empty when metadata is absent
350        assert!(toon.contains("memories[id|title|tier|namespace|priority|score|tags|agent_id]:"));
351        assert!(!toon.contains("created_at"));
352        assert!(!toon.contains("confidence"));
353    }
354
355    #[test]
356    fn compact_mode_surfaces_agent_id_from_metadata() {
357        let resp = json!({
358            "memories": [{
359                "id": "x",
360                "title": "Test",
361                "tier": Tier::Mid.as_str(),
362                "namespace": "test",
363                "priority": 5,
364                "score": 0.5,
365                "tags": [],
366                "metadata": {"agent_id": "alice"}
367            }],
368            "count": 1
369        });
370        let toon = memories_to_toon(&resp, true);
371        let row = toon.lines().last().unwrap();
372        assert!(
373            row.ends_with("|alice"),
374            "agent_id must be the last compact column; row: {row}"
375        );
376    }
377
378    #[test]
379    fn pipe_in_title_escaped() {
380        let resp = json!({"memories": [{"id": "x", "title": "A|B", "tier": Tier::Mid.as_str()}], "count": 1});
381        let toon = memories_to_toon(&resp, true);
382        assert!(toon.contains("A\\|B"));
383    }
384
385    #[test]
386    fn multiple_memories_token_savings() {
387        // Demonstrate: 3 memories, field names appear only once
388        let resp = json!({
389            "memories": [
390                {"id": "a", "title": "Memory 1", "tier": Tier::Long.as_str(), "namespace": "test", "priority": 9, "score": 0.9, "tags": ["t1"]},
391                {"id": "b", "title": "Memory 2", "tier": Tier::Mid.as_str(), "namespace": "test", "priority": 7, "score": 0.7, "tags": ["t2"]},
392                {"id": "c", "title": "Memory 3", "tier": Tier::Short.as_str(), "namespace": "test", "priority": 5, "score": 0.5, "tags": ["t3"]}
393            ],
394            "count": 3,
395            "mode": "hybrid"
396        });
397        let toon = memories_to_toon(&resp, true);
398        let json_str = serde_json::to_string(&resp).unwrap();
399        // TOON should be significantly shorter than JSON
400        assert!(
401            toon.len() < json_str.len(),
402            "TOON ({}) should be shorter than JSON ({})",
403            toon.len(),
404            json_str.len()
405        );
406    }
407
408    #[test]
409    fn search_results_key() {
410        let resp = json!({"results": [{"id": "x", "title": "Found", "tier": Tier::Mid.as_str()}], "count": 1});
411        let toon = search_to_toon(&resp, true);
412        assert!(toon.contains("memories["));
413        assert!(toon.contains("Found"));
414    }
415
416    // -----------------------------------------------------------------
417    // W11/S11b — token-savings size invariant + round-trip-ish check
418    // -----------------------------------------------------------------
419
420    /// Build a fixed 5-memory fixture so the size invariant is reproducible.
421    fn five_memory_fixture() -> Value {
422        json!({
423            "memories": [
424                {
425                    "id": "01",
426                    "title": "PostgreSQL config",
427                    "tier": Tier::Long.as_str(),
428                    "namespace": "infra",
429                    "priority": 9,
430                    "confidence": 1.0,
431                    "score": 0.91,
432                    "access_count": 4,
433                    "tags": ["postgres", "database"],
434                    "source": "claude",
435                    "created_at": "2026-04-03T15:00:00+00:00",
436                    "updated_at": "2026-04-03T15:00:00+00:00",
437                    "metadata": {"agent_id": "alice"}
438                },
439                {
440                    "id": "02",
441                    "title": "Redis cache strategy",
442                    "tier": Tier::Long.as_str(),
443                    "namespace": "infra",
444                    "priority": 8,
445                    "confidence": 0.95,
446                    "score": 0.84,
447                    "access_count": 2,
448                    "tags": ["redis", "cache"],
449                    "source": "claude",
450                    "created_at": "2026-04-03T15:01:00+00:00",
451                    "updated_at": "2026-04-03T15:01:00+00:00",
452                    "metadata": {"agent_id": "alice"}
453                },
454                {
455                    "id": "03",
456                    "title": "BIND9 custom build",
457                    "tier": Tier::Mid.as_str(),
458                    "namespace": "infra/dns",
459                    "priority": 7,
460                    "confidence": 0.9,
461                    "score": 0.71,
462                    "access_count": 1,
463                    "tags": ["bind", "dns"],
464                    "source": "user",
465                    "created_at": "2026-04-03T15:02:00+00:00",
466                    "updated_at": "2026-04-03T15:02:00+00:00",
467                    "metadata": {"agent_id": "bob"}
468                },
469                {
470                    "id": "04",
471                    "title": "Kubernetes pod recovery",
472                    "tier": Tier::Mid.as_str(),
473                    "namespace": "platform/k8s",
474                    "priority": 6,
475                    "confidence": 0.85,
476                    "score": 0.62,
477                    "access_count": 0,
478                    "tags": ["k8s", "ops"],
479                    "source": "hook",
480                    "created_at": "2026-04-03T15:03:00+00:00",
481                    "updated_at": "2026-04-03T15:03:00+00:00",
482                    "metadata": {"agent_id": "carol"}
483                },
484                {
485                    "id": "05",
486                    "title": "Vault secrets rotation",
487                    "tier": Tier::Short.as_str(),
488                    "namespace": "security",
489                    "priority": 5,
490                    "confidence": 0.8,
491                    "score": 0.55,
492                    "access_count": 3,
493                    "tags": ["vault", "secrets"],
494                    "source": "api",
495                    "created_at": "2026-04-03T15:04:00+00:00",
496                    "updated_at": "2026-04-03T15:04:00+00:00",
497                    "metadata": {"agent_id": "dave"}
498                }
499            ],
500            "count": 5,
501            "mode": "hybrid"
502        })
503    }
504
505    #[test]
506    fn test_toon_size_invariant_5_memories_under_threshold() {
507        // Published claim: TOON shaves ~40-79% off JSON for memory rows.
508        // We pin a lenient 65% upper bound (≤ 0.65 * JSON_BYTES) for the
509        // compact format on a fixed 5-memory fixture. Catches regressions
510        // without being so tight that minor format tweaks break CI.
511        let fixture = five_memory_fixture();
512        let json_bytes = serde_json::to_string(&fixture).unwrap().len();
513        let toon_bytes = memories_to_toon(&fixture, true).len();
514
515        let ratio = (toon_bytes as f64) / (json_bytes as f64);
516        assert!(
517            ratio < 0.65,
518            "TOON size invariant violated: toon={toon_bytes} json={json_bytes} \
519             ratio={ratio:.3} (must be < 0.65 for 5-memory compact fixture)"
520        );
521
522        // Lower-bound sanity: TOON output must be non-empty and contain
523        // at least all 5 ids.
524        let toon = memories_to_toon(&fixture, true);
525        for id in ["01", "02", "03", "04", "05"] {
526            assert!(toon.contains(id), "TOON output missing id `{id}`");
527        }
528    }
529
530    // -----------------------------------------------------------------
531    // W12-H — escape_toon char-by-char + format_value branch coverage
532    // -----------------------------------------------------------------
533
534    #[test]
535    fn escape_toon_pipe() {
536        let s = escape_toon("a|b");
537        assert_eq!(s, "a\\|b");
538    }
539
540    #[test]
541    fn escape_toon_newline() {
542        let s = escape_toon("a\nb");
543        assert_eq!(s, "a\\nb");
544    }
545
546    #[test]
547    fn escape_toon_carriage_return() {
548        let s = escape_toon("a\rb");
549        assert_eq!(s, "a\\rb");
550    }
551
552    #[test]
553    fn escape_toon_backslash() {
554        let s = escape_toon("a\\b");
555        // Backslash is doubled first, so `\` → `\\`.
556        assert_eq!(s, "a\\\\b");
557    }
558
559    #[test]
560    fn escape_toon_colon() {
561        let s = escape_toon("a:b");
562        assert_eq!(s, "a\\:b");
563    }
564
565    #[test]
566    fn escape_toon_no_special_chars_passthrough() {
567        let s = escape_toon("plain text 123");
568        assert_eq!(s, "plain text 123");
569    }
570
571    #[test]
572    fn escape_toon_multiple_specials() {
573        let s = escape_toon("a|b:c\nd");
574        assert!(s.contains("\\|"));
575        assert!(s.contains("\\:"));
576        assert!(s.contains("\\n"));
577    }
578
579    #[test]
580    fn format_value_null_is_empty() {
581        let resp = json!({
582            "memories": [{"id": null, "title": "t"}],
583            "count": 1,
584        });
585        let toon = memories_to_toon(&resp, true);
586        let row = toon.lines().last().unwrap();
587        // First field (id) is null → empty string before pipe.
588        assert!(row.starts_with("|t|"), "got: {row}");
589    }
590
591    #[test]
592    fn format_value_bool_serializes_as_zero_one() {
593        // Bool → "1" or "0" via format_value. Use a synthetic field.
594        let resp = json!({
595            "memories": [{"id": "x", "title": true}],
596            "count": 1,
597        });
598        let toon = memories_to_toon(&resp, true);
599        let row = toon.lines().last().unwrap();
600        assert!(row.contains("|1|"), "true → 1; got: {row}");
601    }
602
603    #[test]
604    fn format_value_bool_false() {
605        let resp = json!({
606            "memories": [{"id": "x", "title": false}],
607            "count": 1,
608        });
609        let toon = memories_to_toon(&resp, true);
610        let row = toon.lines().last().unwrap();
611        assert!(row.contains("|0|"), "false → 0; got: {row}");
612    }
613
614    #[test]
615    fn format_value_object_empty_is_empty_string() {
616        // Empty metadata object → empty string in TOON output.
617        let resp = json!({
618            "memories": [{
619                "id": "x", "title": "t", "tier": Tier::Long.as_str(), "namespace": "n",
620                "priority": 1, "confidence": 1.0, "score": 0.5, "access_count": 0,
621                "tags": [], "source": "", "created_at": "", "updated_at": "",
622                "metadata": {}
623            }],
624            "count": 1,
625        });
626        let toon = memories_to_toon(&resp, false);
627        // Metadata column (last) is empty.
628        let row = toon.lines().last().unwrap();
629        assert!(row.ends_with('|') || row.ends_with("||"), "got: {row}");
630    }
631
632    #[test]
633    fn format_value_object_non_empty_serialized_json() {
634        let resp = json!({
635            "memories": [{
636                "id": "x", "title": "t", "tier": Tier::Long.as_str(), "namespace": "n",
637                "priority": 1, "confidence": 1.0, "score": 0.5, "access_count": 0,
638                "tags": [], "source": "", "created_at": "", "updated_at": "",
639                "metadata": {"k": "v"}
640            }],
641            "count": 1,
642        });
643        let toon = memories_to_toon(&resp, false);
644        // Object becomes JSON-serialized + escaped (`:` → `\:`).
645        assert!(toon.contains("k") && toon.contains("v"));
646    }
647
648    #[test]
649    fn standards_section_emitted_when_present() {
650        let resp = json!({
651            "memories": [],
652            "count": 0,
653            "standard": {"id": "s1", "title": "policy", "content": "be nice"}
654        });
655        let toon = memories_to_toon(&resp, true);
656        assert!(toon.contains("standards[id|title|content]:"));
657        assert!(toon.contains("s1"));
658    }
659
660    #[test]
661    fn standards_array_emitted_when_present() {
662        let resp = json!({
663            "memories": [],
664            "count": 0,
665            "standards": [
666                {"id": "s1", "title": "p1", "content": "c1"},
667                {"id": "s2", "title": "p2", "content": "c2"},
668            ],
669        });
670        let toon = memories_to_toon(&resp, true);
671        assert!(toon.contains("standards["));
672        assert!(toon.contains("s1"));
673        assert!(toon.contains("s2"));
674    }
675
676    #[test]
677    fn meta_line_includes_token_budget() {
678        let resp = json!({
679            "memories": [],
680            "count": 0,
681            "tokens_used": 100,
682            "budget_tokens": 500,
683        });
684        let toon = memories_to_toon(&resp, true);
685        assert!(toon.contains("tokens_used:100"));
686        assert!(toon.contains("budget_tokens:500"));
687    }
688
689    #[test]
690    fn search_to_toon_passes_through_when_memories_present() {
691        // When both `results` and `memories` exist, `memories_to_toon` is
692        // called directly without normalizing.
693        let resp = json!({
694            "memories": [{"id": "a", "title": "t1"}],
695            "results": [{"id": "b", "title": "t2"}],
696            "count": 1,
697        });
698        let toon = search_to_toon(&resp, true);
699        // Should use `memories` path, not `results`.
700        assert!(toon.contains("a"));
701        assert!(toon.contains("t1"));
702    }
703
704    #[test]
705    fn test_toon_round_trip_preserves_visible_fields() {
706        // No bidirectional parser exists in-tree (TOON is one-way for
707        // LLM output). Instead we assert "round-trip-ish": every input
708        // field that maps to a TOON column appears verbatim in the output
709        // for the non-compact format on a single memory.
710        let resp = json!({
711            "memories": [{
712                "id": "abc-xyz",
713                "title": "Round-trip test",
714                "tier": Tier::Long.as_str(),
715                "namespace": "test",
716                "priority": 9,
717                "confidence": 1.0,
718                "score": 0.5,
719                "access_count": 7,
720                "tags": ["alpha", "beta"],
721                "source": "claude",
722                "created_at": "2026-04-03T15:00:00+00:00",
723                "updated_at": "2026-04-03T15:00:30+00:00",
724                "metadata": {"agent_id": "alice"}
725            }],
726            "count": 1
727        });
728        let toon = memories_to_toon(&resp, false);
729        // Header lists every non-compact column.
730        for col in [
731            "id",
732            "title",
733            "tier",
734            "namespace",
735            "priority",
736            "confidence",
737            "score",
738            "access_count",
739            "tags",
740            "source",
741            "created_at",
742            "updated_at",
743            "metadata",
744        ] {
745            assert!(
746                toon.contains(col),
747                "TOON header must list column `{col}`; got:\n{toon}"
748            );
749        }
750        // Data row preserves visible string values.
751        assert!(toon.contains("abc-xyz"));
752        assert!(toon.contains("Round-trip test"));
753        assert!(toon.contains("alpha,beta")); // tag array joined w/ comma
754        // Timestamps contain `:` which TOON escapes as `\:`. Both forms ship
755        // the same logical value; check the escaped variant emitted by
756        // `escape_toon` when ':' triggers the escape branch.
757        assert!(
758            toon.contains(r"2026-04-03T15\:00\:00+00\:00"),
759            "TOON should contain timestamp (with escaped ':'): {toon}"
760        );
761    }
762}