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}