Skip to main content

ai_memory/models/
recall_request.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! `RecallRequest` — canonical Data Transfer Object for the recall pipeline.
5//!
6//! Wave-2 Tier-C2 (issue #967): the three recall surfaces (HTTP, MCP, CLI)
7//! historically extracted ~17 scalars from their wire shapes and threaded
8//! them as positional arguments through `recall_response` (HTTP) /
9//! `handle_recall` (MCP) / `run_with_embedder` (CLI). Adding a new field
10//! (Form 6 `kinds`, Form 4 `has_citations`, `session_id`, etc.) meant
11//! editing four signatures.
12//!
13//! This module promotes the schemars-derived [`RecallRequest`] (originally
14//! defined under `mcp::tools::recall` for D1.3 #984 schema generation)
15//! into a canonical DTO every surface marshals into ONCE. Constructors
16//! land per surface:
17//!
18//! * [`RecallRequest::from_mcp_params`] — accepts a `&serde_json::Value`
19//!   params bag (the MCP `arguments` shape).
20//! * [`RecallRequest::from_http_query`] — accepts a `&RecallQuery`
21//!   (HTTP GET).
22//! * [`RecallRequest::from_http_body`] — accepts a `&RecallBody`
23//!   (HTTP POST).
24//! * [`RecallRequest::from_cli_args`] — accepts a `&crate::cli::recall::RecallArgs`.
25//!
26//! The schemars derivation is preserved verbatim so D1.4 (#985) parity
27//! tests in `mcp::tools::recall::d1_3_984_tests` keep matching the
28//! legacy hand-coded schema byte-for-byte. The schema struct AND the
29//! runtime DTO are now the same type — option (a) in the issue rubric.
30
31use crate::models::MemoryKind;
32use crate::models::field_names;
33use schemars::JsonSchema;
34use serde::{Deserialize, Serialize};
35use serde_json::Value;
36
37/// #1558 batch 5 wave 3 — canonical recall-mode label stamped on the
38/// `mode` response field and the `recall_observations` ledger when the
39/// hybrid (FTS+semantic) pipeline ran AND the cross-encoder reranker
40/// re-ordered the results. The plain `"hybrid"` / `"keyword"` labels
41/// stay short literals at the two emit sites.
42pub const RECALL_MODE_HYBRID_RERANK: &str = "hybrid+rerank";
43
44/// v0.7.0 #972 D1.3 (#984) — `kinds` filter shape for `memory_recall`.
45///
46/// The legacy hand-coded schema declares this field as a `oneOf` union
47/// (array-of-strings OR a single CSV string); modelling it as an
48/// `#[serde(untagged)]` enum replicates the wire shape exactly without
49/// forcing callers to wrap their CSV in an array.
50///
51/// Originally lived under `mcp::tools::recall::KindsFilter`; promoted
52/// here for the #967 canonical-DTO refactor. Re-exported from the
53/// original location for backward compatibility.
54#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
55#[allow(dead_code)]
56#[serde(untagged)]
57pub enum KindsFilter {
58    /// Array of kind tokens, e.g. `["concept", "claim"]`.
59    Array(Vec<String>),
60    /// Comma-separated kinds string, e.g. `"concept,claim"`.
61    Csv(String),
62}
63
64impl KindsFilter {
65    /// Parse the filter into a vector of [`MemoryKind`] tokens. Returns
66    /// `None` when the filter resolves to "no filter declared" — empty
67    /// string, empty array, or the literal `"all"`.
68    ///
69    /// Returns `Some(vec![])` when the caller declared a filter
70    /// (non-empty string or non-empty array) but every token was
71    /// unknown (Cluster E audit COR-4 #767: an explicit zero-match
72    /// filter must NOT silently collapse into "match all").
73    #[must_use]
74    pub fn parse(&self) -> Option<Vec<MemoryKind>> {
75        match self {
76            Self::Csv(s) => {
77                if s.trim().eq_ignore_ascii_case("all") {
78                    return None;
79                }
80                MemoryKind::parse_csv(s)
81            }
82            Self::Array(arr) => {
83                if arr.is_empty() {
84                    return None;
85                }
86                let mut out: Vec<MemoryKind> = Vec::new();
87                for raw in arr {
88                    if let Some(k) = MemoryKind::from_str(raw.trim())
89                        && !out.contains(&k)
90                    {
91                        out.push(k);
92                    }
93                }
94                Some(out)
95            }
96        }
97    }
98}
99
100/// v0.7.0 #972 D1.3 (#984) / #967 — canonical recall-request DTO.
101///
102/// Marshalled once per surface (HTTP / MCP / CLI), then handed to the
103/// downstream recall pipeline. Adding a new field (Form 6 `kinds`,
104/// Form 4 `has_citations`, `confidence_tier`, etc.) lands in one place
105/// instead of four positional-arg lists.
106///
107/// **Schemars contract.** Every doc-comment description and field
108/// attribute is byte-equal to the legacy hand-coded entry in
109/// [`crate::mcp::registry::tool_definitions`] — see the
110/// `d1_3_984_tests::recall_parity_984` parity test which asserts the
111/// derived schema matches byte-for-byte.
112#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
113#[allow(dead_code)]
114pub struct RecallRequest {
115    /// What to recall
116    pub context: String,
117
118    /// Namespace filter
119    #[serde(default)]
120    pub namespace: Option<String>,
121
122    #[serde(default)]
123    pub limit: Option<i64>,
124
125    /// Tag filter
126    #[serde(default)]
127    pub tags: Option<String>,
128
129    /// RFC3339 lower bound on created_at
130    #[serde(default)]
131    pub since: Option<String>,
132
133    /// RFC3339 upper bound on created_at
134    #[serde(default)]
135    pub until: Option<String>,
136
137    #[serde(default)]
138    #[schemars(description = "#151 scope-visibility agent.")]
139    pub as_agent: Option<String>,
140
141    /// P6/R1 cl100k content cap. 0=empty; top kept (meta.budget_overflow=true).
142    #[serde(default)]
143    pub budget_tokens: Option<i64>,
144
145    /// Recent conversation tokens; biases query embedding 70/30 (v0.6.0.0).
146    #[serde(default)]
147    pub context_tokens: Option<Vec<String>>,
148
149    /// Splice [agents.defaults.recall_scope]. explicit > scope > defaults.
150    #[serde(default)]
151    pub session_default: Option<bool>,
152
153    #[serde(default)]
154    #[schemars(description = "#518 session id; +0.05 rerank boost for in-session ring (cap 50).")]
155    pub session_id: Option<String>,
156
157    /// WT-1-E: include atomised sources alongside atoms.
158    #[serde(default)]
159    pub include_archived: Option<bool>,
160
161    /// Form 4 (#757): require non-empty citations array.
162    #[serde(default)]
163    pub has_citations: Option<bool>,
164
165    /// Form 4 (#757): restrict by source_uri prefix (e.g. 'doc:', 'uri:https://').
166    #[serde(default)]
167    pub source_uri_prefix: Option<String>,
168
169    /// Form 6 (#759) kind filter. Array/CSV. OR within; AND across.
170    #[serde(default)]
171    pub kinds: Option<KindsFilter>,
172
173    /// Gap 4 (#887) tier filter.
174    #[serde(default)]
175    pub confidence_tier: Option<String>,
176
177    /// Gap 7 (#890): per-row provenance decoration.
178    #[serde(default)]
179    pub verbose_provenance: Option<bool>,
180
181    /// Response format. toon_compact saves 79% vs json.
182    #[serde(default)]
183    pub format: Option<String>,
184}
185
186impl RecallRequest {
187    /// MCP surface: marshal a `params` JSON bag (the `arguments` field
188    /// of a `tools/call` request) into a typed [`RecallRequest`].
189    ///
190    /// Returns `Err` when `context` is missing — every other field is
191    /// optional and defaults via `#[serde(default)]`. The legacy
192    /// `handle_recall` body used `params["context"].as_str().ok_or(...)`
193    /// to enforce the same invariant; this constructor preserves the
194    /// exact error string for callers that match on it.
195    ///
196    /// On a deserialise failure (e.g. caller passes `limit: "ten"`),
197    /// returns the serde error rendered as a string so the MCP
198    /// dispatcher can return the corresponding `-32602 Invalid params`.
199    ///
200    /// # Errors
201    /// Returns `Err` when:
202    /// * `context` is missing or not a string ("context is required")
203    /// * a typed field receives the wrong JSON shape
204    ///
205    /// **Saturation semantics.** Pre-#967 the legacy code used
206    /// `params["limit"].as_u64()` + `usize::try_from(v).unwrap_or(usize::MAX)`,
207    /// which silently saturated `u64::MAX` rather than erroring. The
208    /// DTO's `limit: Option<i64>` would refuse to deserialize a value
209    /// beyond `i64::MAX`, so the constructor clamps `limit` (and
210    /// `budget_tokens`) values that exceed the signed range to
211    /// `i64::MAX` BEFORE handing the bag to serde. This preserves the
212    /// `limit_overflow_saturates` regression test contract.
213    pub fn from_mcp_params(params: &Value) -> Result<Self, String> {
214        // Pre-flight: legacy callers (and #984 parity tests) expect the
215        // exact "context is required" error when the field is missing.
216        // serde would surface "missing field `context`" instead; pin the
217        // legacy wording here so the wire-level error envelope is stable.
218        if params.get("context").and_then(Value::as_str).is_none() {
219            return Err(crate::errors::msg::CONTEXT_REQUIRED.to_string());
220        }
221        // Clamp `limit` / `budget_tokens` so an unsigned overflow value
222        // (e.g. `u64::MAX` per `limit_overflow_saturates`) doesn't
223        // collapse the constructor into a deserialise error. The recall
224        // pipeline caps `limit` at `min(50)` downstream anyway, so the
225        // precise value above `i64::MAX` is irrelevant to observable
226        // behaviour — only that it doesn't crash.
227        let mut owned = params.clone();
228        if let Some(obj) = owned.as_object_mut() {
229            for key in ["limit", field_names::BUDGET_TOKENS] {
230                if let Some(v) = obj.get(key)
231                    && let Some(n) = v.as_u64()
232                    && n > i64::MAX as u64
233                {
234                    obj.insert(key.to_string(), Value::from(i64::MAX));
235                }
236            }
237        }
238        serde_json::from_value::<Self>(owned).map_err(|e| e.to_string())
239    }
240
241    /// HTTP GET surface: marshal a [`crate::models::RecallQuery`] into
242    /// the canonical DTO. `context` resolution honours the
243    /// `context > query > q` precedence the HTTP handler enforces;
244    /// callers must reject the empty result before recall.
245    #[must_use]
246    pub fn from_http_query(q: &crate::models::RecallQuery) -> Self {
247        let context = q
248            .context
249            .as_deref()
250            .or(q.query.as_deref())
251            .or(q.q.as_deref())
252            .unwrap_or("")
253            .to_string();
254        Self {
255            context,
256            namespace: q.namespace.clone(),
257            limit: q.limit.and_then(|v| i64::try_from(v).ok()),
258            tags: q.tags.clone(),
259            since: q.since.clone(),
260            until: q.until.clone(),
261            as_agent: q.as_agent.clone(),
262            budget_tokens: q.budget_tokens.and_then(|v| i64::try_from(v).ok()),
263            // #1622 — CSV on the GET surface (`context_tokens=a,b`),
264            // mirroring the `kinds` convention; pre-#1622 hard-coded
265            // None so HTTP GET callers could not reach the bias.
266            context_tokens: q.context_tokens.as_deref().map(|s| {
267                s.split(',')
268                    .map(str::trim)
269                    .filter(|t| !t.is_empty())
270                    .map(String::from)
271                    .collect()
272            }),
273            session_default: q.session_default,
274            session_id: q.session_id.clone(),
275            // v0.7.0 #1098 — wired through from RecallQuery; pre-
276            // #1098 these were hard-coded to `None` so HTTP callers
277            // could not reach the toon_compact format selection,
278            // verbose-provenance decoration, confidence-tier filter,
279            // or include-archived widening even though MCP callers
280            // could.
281            include_archived: q.include_archived,
282            has_citations: q.has_citations,
283            source_uri_prefix: q.source_uri_prefix.clone(),
284            kinds: q.kinds.as_deref().map(|s| KindsFilter::Csv(s.to_string())),
285            confidence_tier: q.confidence_tier.clone(),
286            verbose_provenance: q.verbose_provenance,
287            format: q.format.clone(),
288        }
289    }
290
291    /// HTTP POST surface: marshal a [`crate::models::RecallBody`] into
292    /// the canonical DTO. `context` resolution honours the
293    /// `context > query > q` precedence the HTTP handler enforces.
294    #[must_use]
295    pub fn from_http_body(body: &crate::models::RecallBody) -> Self {
296        let kinds = body.kinds.as_ref().and_then(|raw| {
297            if let Some(s) = raw.as_str() {
298                Some(KindsFilter::Csv(s.to_string()))
299            } else if let Some(arr) = raw.as_array() {
300                let strs: Vec<String> = arr
301                    .iter()
302                    .filter_map(|v| v.as_str().map(String::from))
303                    .collect();
304                Some(KindsFilter::Array(strs))
305            } else {
306                None
307            }
308        });
309        Self {
310            context: body.resolved_query(),
311            namespace: body.namespace.clone(),
312            limit: body.limit.and_then(|v| i64::try_from(v).ok()),
313            tags: body.tags.clone(),
314            since: body.since.clone(),
315            until: body.until.clone(),
316            as_agent: body.as_agent.clone(),
317            budget_tokens: body.budget_tokens.and_then(|v| i64::try_from(v).ok()),
318            // #1622 — wired through from RecallBody; pre-#1622 this was
319            // hard-coded None so HTTP POST callers could not reach the
320            // 70/30 context-token embedding bias MCP/CLI callers could.
321            context_tokens: body.context_tokens.clone(),
322            session_default: body.session_default,
323            session_id: body.session_id.clone(),
324            // v0.7.0 #1098 — wired through from RecallBody; pre-#1098
325            // these were hard-coded to `None`.
326            include_archived: body.include_archived,
327            has_citations: body.has_citations,
328            source_uri_prefix: body.source_uri_prefix.clone(),
329            kinds,
330            confidence_tier: body.confidence_tier.clone(),
331            verbose_provenance: body.verbose_provenance,
332            format: body.format.clone(),
333        }
334    }
335
336    /// CLI surface: marshal a [`crate::cli::recall::RecallArgs`] (clap-
337    /// derived) into the canonical DTO.
338    #[must_use]
339    pub fn from_cli_args(args: &crate::cli::recall::RecallArgs) -> Self {
340        Self {
341            context: args.context.clone(),
342            namespace: args.namespace.clone(),
343            limit: i64::try_from(args.limit).ok(),
344            tags: args.tags.clone(),
345            since: args.since.clone(),
346            until: args.until.clone(),
347            as_agent: args.as_agent.clone(),
348            budget_tokens: args.budget_tokens.and_then(|v| i64::try_from(v).ok()),
349            context_tokens: args.context_tokens.clone(),
350            session_default: Some(args.session_default),
351            // v0.7.0 #1257 — CLI parity for the session_id boost
352            // (#518). Pre-#1257 this was hard-coded to `None`, so
353            // a CLI caller could not reach the in-session ring
354            // rerank boost even though MCP / HTTP callers could.
355            session_id: args.session_id.clone(),
356            include_archived: Some(args.include_archived),
357            has_citations: Some(args.has_citations),
358            source_uri_prefix: args.source_uri_prefix.clone(),
359            kinds: args
360                .kind
361                .as_deref()
362                .map(|s| KindsFilter::Csv(s.to_string())),
363            // v0.7.0 #1098 — CLI parity for the 3 recall flags that
364            // landed on MCP / HTTP at RC. Pre-#1098 these were hard-
365            // coded to `None`, so a CLI caller could not reach the
366            // confidence-tier filter, the verbose-provenance
367            // decoration, or the `toon` response format selector
368            // even though MCP / HTTP callers could.
369            confidence_tier: args.confidence_tier.clone(),
370            verbose_provenance: Some(args.verbose_provenance),
371            // The CLI clap parser supplies a default of `"human"` so
372            // the field is never literally absent at this point;
373            // marshal it through unchanged so the downstream DTO
374            // honours an explicit `--format json` / `--format toon`.
375            format: Some(args.format.clone()),
376        }
377    }
378
379    /// Resolved limit clamped to `usize`. The recall pipeline caps the
380    /// returned set at `min(50)` downstream; this constructor just
381    /// converts the wire `Option<i64>` into a usable size with a
382    /// default of 10 when the caller omitted the field.
383    #[must_use]
384    pub fn resolved_limit(&self) -> usize {
385        match self.limit {
386            Some(v) if v > 0 => usize::try_from(v).unwrap_or(usize::MAX),
387            _ => 10,
388        }
389    }
390
391    /// Resolved budget-tokens limit clamped to `usize`. `None` when the
392    /// caller did not request a budget cap; `Some(0)` is preserved per
393    /// the P6/R1 semantics (zero is a legitimate "return nothing"
394    /// request distinct from "no budget set").
395    #[must_use]
396    pub fn resolved_budget_tokens(&self) -> Option<usize> {
397        self.budget_tokens.and_then(|v| {
398            if v < 0 {
399                None
400            } else {
401                Some(usize::try_from(v).unwrap_or(usize::MAX))
402            }
403        })
404    }
405}
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410    use serde_json::json;
411
412    #[test]
413    fn from_mcp_params_requires_context() {
414        let err = RecallRequest::from_mcp_params(&json!({})).unwrap_err();
415        assert!(
416            err.contains("context"),
417            "missing context must surface 'context' in the error: {err}"
418        );
419    }
420
421    #[test]
422    fn from_mcp_params_happy_path_minimal() {
423        let req = RecallRequest::from_mcp_params(&json!({"context": "hello"})).unwrap();
424        assert_eq!(req.context, "hello");
425        assert!(req.namespace.is_none());
426        assert!(req.limit.is_none());
427    }
428
429    #[test]
430    fn from_mcp_params_full_field_set() {
431        let req = RecallRequest::from_mcp_params(&json!({
432            "context": "q",
433            "namespace": "ns",
434            "limit": 25,
435            "tags": "a,b",
436            "since": "2026-01-01T00:00:00Z",
437            "until": "2026-12-31T00:00:00Z",
438            "as_agent": "ai:viewer",
439            "budget_tokens": 100,
440            "context_tokens": ["alpha", "beta"],
441            "session_default": true,
442            "session_id": "sess-1",
443            "include_archived": true,
444            "has_citations": true,
445            "source_uri_prefix": "doc:",
446            "kinds": "concept,claim",
447            "confidence_tier": "confirmed",
448            "verbose_provenance": false,
449            "format": "toon_compact"
450        }))
451        .unwrap();
452        assert_eq!(req.context, "q");
453        assert_eq!(req.namespace.as_deref(), Some("ns"));
454        assert_eq!(req.limit, Some(25));
455        assert_eq!(req.tags.as_deref(), Some("a,b"));
456        assert_eq!(req.budget_tokens, Some(100));
457        assert_eq!(
458            req.context_tokens.as_deref(),
459            Some(&["alpha".to_string(), "beta".to_string()][..])
460        );
461        assert_eq!(req.session_id.as_deref(), Some("sess-1"));
462        assert!(matches!(req.kinds, Some(KindsFilter::Csv(ref s)) if s == "concept,claim"));
463        assert_eq!(req.confidence_tier.as_deref(), Some("confirmed"));
464        assert_eq!(req.verbose_provenance, Some(false));
465    }
466
467    #[test]
468    fn from_mcp_params_limit_u64_max_saturates() {
469        // Pre-#967 the legacy code used `params["limit"].as_u64()` +
470        // `usize::try_from(v).unwrap_or(usize::MAX)`, which silently
471        // saturated `u64::MAX`. The DTO field is `Option<i64>`, so
472        // the constructor must clamp `u64::MAX` to `i64::MAX` before
473        // serde-deserialising; otherwise the existing
474        // `mcp::recall::tests::limit_overflow_saturates` regression
475        // test would surface a `Result::Err` instead of a successful
476        // recall response.
477        let req = RecallRequest::from_mcp_params(&json!({
478            "context": "q",
479            "limit": u64::MAX,
480        }))
481        .expect("u64::MAX limit must saturate, not error");
482        assert_eq!(req.limit, Some(i64::MAX));
483    }
484
485    #[test]
486    fn from_mcp_params_budget_tokens_u64_max_saturates() {
487        // Same saturation contract for budget_tokens.
488        let req = RecallRequest::from_mcp_params(&json!({
489            "context": "q",
490            "budget_tokens": u64::MAX,
491        }))
492        .expect("u64::MAX budget_tokens must saturate, not error");
493        assert_eq!(req.budget_tokens, Some(i64::MAX));
494    }
495
496    #[test]
497    fn from_mcp_params_unknown_field_tolerated_at_runtime() {
498        // v0.7.0 #1052 (Agent-4 F2) — pre-#1052 the struct carried
499        // `#[schemars(deny_unknown_fields)]` so the WIRE schema
500        // advertised `additionalProperties: false`, but
501        // `#[serde(deny_unknown_fields)]` was intentionally omitted so
502        // the RUNTIME silently tolerated unknowns. That asymmetry was
503        // the bug: clients OBEYING the wire schema rejected inputs the
504        // server happily accepted, and clients sending typos (e.g.
505        // `"namespce"` for `"namespace"`) had them silently dropped
506        // (no -32602) and observed surprising "no filter applied"
507        // behaviour.
508        //
509        // The #1052 fix removes `schemars(deny_unknown_fields)` from
510        // every tool-request struct so the wire schema becomes
511        // truthful (no `additionalProperties: false` claim). The
512        // runtime continues to tolerate unknowns — wider compat for
513        // v0.6.x clients with newer field sets — but the schema no
514        // longer lies about it. The corollary contract is pinned by
515        // `tests/mcp_input_schema_no_false_strict_1052.rs`: the
516        // canonical `tool_definitions()` payload must NOT advertise
517        // `additionalProperties: false` on any tool's inputSchema.
518        //
519        // Pinned here so a future re-introduction of the attribute is
520        // a visible, intentional change.
521        let req = RecallRequest::from_mcp_params(&json!({
522            "context": "q",
523            "completely_unknown_field": true
524        }))
525        .expect("unknown fields are tolerated at runtime (post-#1052 contract is wire-truthful)");
526        assert_eq!(req.context, "q");
527    }
528
529    #[test]
530    fn from_mcp_params_kinds_array_shape() {
531        let req = RecallRequest::from_mcp_params(&json!({
532            "context": "q",
533            "kinds": ["concept", "claim"]
534        }))
535        .unwrap();
536        let kinds = req.kinds.expect("kinds present");
537        match &kinds {
538            KindsFilter::Array(v) => {
539                assert_eq!(v, &vec!["concept".to_string(), "claim".to_string()]);
540            }
541            _ => panic!("expected Array variant: {kinds:?}"),
542        }
543        let parsed = kinds.parse().expect("parses to Some");
544        assert_eq!(parsed.len(), 2);
545    }
546
547    #[test]
548    fn kinds_filter_all_treated_as_no_filter() {
549        let csv = KindsFilter::Csv("all".to_string());
550        assert!(csv.parse().is_none());
551        let csv_upper = KindsFilter::Csv("ALL".to_string());
552        assert!(csv_upper.parse().is_none());
553    }
554
555    #[test]
556    fn kinds_filter_empty_array_is_no_filter() {
557        let arr = KindsFilter::Array(vec![]);
558        assert!(arr.parse().is_none());
559    }
560
561    #[test]
562    fn kinds_filter_typo_array_returns_empty_some_cor4() {
563        // Cluster E audit COR-4 #767: declared filter with only-unknown
564        // tokens must NOT collapse into None ("match all"). It returns
565        // Some(vec![]) so the downstream filter applies and matches
566        // zero rows.
567        let arr = KindsFilter::Array(vec!["reflektion".to_string()]);
568        let parsed = arr.parse().expect("declared filter returns Some");
569        assert!(parsed.is_empty(), "typo'd kinds must return empty Some");
570    }
571
572    #[test]
573    fn resolved_limit_default_is_ten() {
574        let req = RecallRequest {
575            context: "q".to_string(),
576            ..Default::default()
577        };
578        assert_eq!(req.resolved_limit(), 10);
579    }
580
581    #[test]
582    fn resolved_limit_uses_explicit_value() {
583        let req = RecallRequest {
584            context: "q".to_string(),
585            limit: Some(25),
586            ..Default::default()
587        };
588        assert_eq!(req.resolved_limit(), 25);
589    }
590
591    #[test]
592    fn resolved_budget_tokens_zero_preserved() {
593        // P6/R1 — `budget_tokens: 0` is a legitimate request meaning
594        // "return zero memories", distinct from `None` ("no cap").
595        let req = RecallRequest {
596            context: "q".to_string(),
597            budget_tokens: Some(0),
598            ..Default::default()
599        };
600        assert_eq!(req.resolved_budget_tokens(), Some(0));
601    }
602
603    #[test]
604    fn resolved_budget_tokens_none_when_negative() {
605        let req = RecallRequest {
606            context: "q".to_string(),
607            budget_tokens: Some(-1),
608            ..Default::default()
609        };
610        assert!(req.resolved_budget_tokens().is_none());
611    }
612
613    #[test]
614    fn from_cli_args_round_trips_all_fields() {
615        // Pin the CLI surface: clap-derived `RecallArgs` collapses
616        // into the canonical DTO via `from_cli_args`. Adding a new
617        // CLI flag means extending this round-trip.
618        let cli_args = crate::cli::recall::RecallArgs {
619            context: "hello".to_string(),
620            namespace: Some("ns".to_string()),
621            limit: 7,
622            tags: Some("rust".to_string()),
623            since: Some("2026-01-01T00:00:00Z".to_string()),
624            until: Some("2026-12-31T00:00:00Z".to_string()),
625            tier: Some("keyword".to_string()),
626            as_agent: Some("ai:viewer".to_string()),
627            budget_tokens: Some(50),
628            context_tokens: Some(vec!["alpha".to_string()]),
629            session_default: true,
630            include_archived: true,
631            has_citations: true,
632            source_uri_prefix: Some("doc:".to_string()),
633            kind: Some("concept,claim".to_string()),
634            // v0.7.0 #1098 — three new CLI parity flags.
635            confidence_tier: Some("high".to_string()),
636            verbose_provenance: true,
637            format: "toon".to_string(),
638            // v0.7.0 #1257 — CLI parity for session_id (DTO C2 #967).
639            session_id: Some("sess-1".to_string()),
640        };
641        let req = RecallRequest::from_cli_args(&cli_args);
642        assert_eq!(req.context, "hello");
643        assert_eq!(req.namespace.as_deref(), Some("ns"));
644        assert_eq!(req.limit, Some(7));
645        assert_eq!(req.tags.as_deref(), Some("rust"));
646        assert_eq!(req.budget_tokens, Some(50));
647        assert_eq!(req.session_default, Some(true));
648        assert_eq!(req.include_archived, Some(true));
649        assert_eq!(req.has_citations, Some(true));
650        assert_eq!(req.source_uri_prefix.as_deref(), Some("doc:"));
651        assert!(matches!(req.kinds, Some(KindsFilter::Csv(ref s)) if s == "concept,claim"));
652        // v0.7.0 #1098 — pin the three flags wired through.
653        assert_eq!(
654            req.confidence_tier.as_deref(),
655            Some("high"),
656            "#1098: --confidence-tier marshals into DTO.confidence_tier"
657        );
658        assert_eq!(
659            req.verbose_provenance,
660            Some(true),
661            "#1098: --verbose-provenance marshals into DTO.verbose_provenance"
662        );
663        assert_eq!(
664            req.format.as_deref(),
665            Some("toon"),
666            "#1098: --format marshals into DTO.format"
667        );
668        // #1257: pin --session-id round-trip into DTO.session_id.
669        assert_eq!(
670            req.session_id.as_deref(),
671            Some("sess-1"),
672            "#1257: --session-id marshals into DTO.session_id"
673        );
674        // CLI `tier` has no DTO field — it's a CLI-only knob that
675        // drives embedder construction, not a wire-level filter.
676    }
677
678    #[test]
679    fn from_http_query_minimal() {
680        let q = crate::models::RecallQuery {
681            context: Some("hello".to_string()),
682            query: None,
683            q: None,
684            namespace: None,
685            limit: Some(15),
686            tags: None,
687            since: None,
688            until: None,
689            as_agent: None,
690            budget_tokens: None,
691            context_tokens: None,
692            session_default: None,
693            has_citations: None,
694            source_uri_prefix: None,
695            kinds: None,
696            session_id: None,
697            // v0.7.0 #1098 — fields wired through from HTTP wire shape.
698            include_archived: None,
699            confidence_tier: None,
700            verbose_provenance: None,
701            format: None,
702        };
703        let req = RecallRequest::from_http_query(&q);
704        assert_eq!(req.context, "hello");
705        assert_eq!(req.limit, Some(15));
706    }
707
708    #[test]
709    fn from_http_query_aliases() {
710        // `q` → `context` fallback honoured.
711        let q = crate::models::RecallQuery {
712            context: None,
713            query: None,
714            q: Some("via-q".to_string()),
715            namespace: None,
716            limit: None,
717            tags: None,
718            since: None,
719            until: None,
720            as_agent: None,
721            budget_tokens: None,
722            context_tokens: None,
723            session_default: None,
724            has_citations: None,
725            source_uri_prefix: None,
726            kinds: None,
727            session_id: None,
728            // v0.7.0 #1098 — fields wired through from HTTP wire shape.
729            include_archived: None,
730            confidence_tier: None,
731            verbose_provenance: None,
732            format: None,
733        };
734        let req = RecallRequest::from_http_query(&q);
735        assert_eq!(req.context, "via-q");
736    }
737
738    #[test]
739    fn round_trip_serialize_deserialize() {
740        let req = RecallRequest {
741            context: "q".to_string(),
742            namespace: Some("ns".to_string()),
743            limit: Some(5),
744            kinds: Some(KindsFilter::Csv("concept".to_string())),
745            ..Default::default()
746        };
747        let json = serde_json::to_string(&req).unwrap();
748        let back: RecallRequest = serde_json::from_str(&json).unwrap();
749        assert_eq!(back.context, req.context);
750        assert_eq!(back.namespace, req.namespace);
751        assert_eq!(back.limit, req.limit);
752    }
753
754    #[test]
755    fn from_http_body_wires_context_tokens_1622() {
756        // #1622 — pre-fix this field was hard-coded None on the HTTP
757        // POST surface while MCP/CLI honored it (the #1098 class).
758        let body: crate::models::RecallBody =
759            serde_json::from_str(r#"{"context":"x","context_tokens":["alpha","beta"]}"#).unwrap();
760        let req = RecallRequest::from_http_body(&body);
761        assert_eq!(
762            req.context_tokens.as_deref(),
763            Some(&["alpha".to_string(), "beta".to_string()][..]),
764            "#1622: POST body context_tokens must reach the DTO"
765        );
766    }
767
768    #[test]
769    fn from_http_query_parses_csv_context_tokens_1622() {
770        // #1622 — GET surface uses the `kinds`-style CSV convention.
771        let mut q = crate::models::RecallQuery {
772            context: Some("x".to_string()),
773            query: None,
774            q: None,
775            namespace: None,
776            limit: None,
777            tags: None,
778            since: None,
779            until: None,
780            as_agent: None,
781            budget_tokens: None,
782            context_tokens: Some(" alpha, beta ,,".to_string()),
783            session_default: None,
784            has_citations: None,
785            source_uri_prefix: None,
786            kinds: None,
787            session_id: None,
788            include_archived: None,
789            confidence_tier: None,
790            verbose_provenance: None,
791            format: None,
792        };
793        let req = RecallRequest::from_http_query(&q);
794        assert_eq!(
795            req.context_tokens.as_deref(),
796            Some(&["alpha".to_string(), "beta".to_string()][..]),
797            "#1622: CSV parses with trim + empty-segment drop"
798        );
799        q.context_tokens = None;
800        let req2 = RecallRequest::from_http_query(&q);
801        assert!(req2.context_tokens.is_none());
802    }
803}