use crate::mcp::param_names;
use crate::observations;
use serde_json::{Value, json};
pub(crate) const DEFAULT_LIMIT: usize = 200;
pub(crate) const MAX_LIMIT: usize = 1000;
pub fn handle_recall_observations(
conn: &rusqlite::Connection,
params: &Value,
) -> Result<Value, String> {
let recall_id = params
.get("recall_id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty());
let consumed = params.get(param_names::CONSUMED).and_then(Value::as_bool);
let since = params
.get(param_names::SINCE)
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty());
let until = params
.get(param_names::UNTIL)
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty());
let limit = params
.get(param_names::LIMIT)
.and_then(Value::as_u64)
.and_then(|n| usize::try_from(n).ok())
.map_or(DEFAULT_LIMIT, |n| n.min(MAX_LIMIT));
let rows = observations::list_observations(conn, recall_id, consumed, since, until, limit)
.map_err(|e| e.to_string())?;
let count = rows.len();
Ok(json!({
(crate::models::field_names::OBSERVATIONS): rows,
"count": count,
}))
}
use crate::mcp::registry::McpTool;
use schemars::JsonSchema;
use serde::Deserialize;
#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
#[allow(dead_code)]
pub struct RecallObservationsRequest {
#[serde(default)]
pub recall_id: Option<String>,
#[serde(default)]
pub consumed: Option<bool>,
#[serde(default)]
pub since: Option<String>,
#[serde(default)]
pub until: Option<String>,
#[serde(default)]
pub limit: Option<i64>,
}
#[allow(dead_code)]
pub struct RecallObservationsTool;
impl McpTool for RecallObservationsTool {
fn name() -> &'static str {
crate::mcp::registry::tool_names::MEMORY_RECALL_OBSERVATIONS
}
fn description() -> &'static str {
"List recall_observations (#886)."
}
fn docs() -> &'static str {
"Gap 3 (#886): recall-consumption ledger filter."
}
fn input_schema() -> Value {
crate::mcp::registry::input_schema_for::<RecallObservationsRequest>()
}
fn family() -> &'static str {
crate::profile::Family::Meta.name()
}
}
#[cfg(test)]
mod d1_5_986_tests {
use super::*;
use crate::mcp::parity_test_helpers::{
assert_descriptions_match, assert_property_set_parity, derived_props_for,
};
#[test]
fn recall_observations_parity_986() {
let derived = derived_props_for::<RecallObservationsRequest>();
assert_property_set_parity("memory_recall_observations", &derived);
assert_descriptions_match("memory_recall_observations", &derived);
}
#[test]
fn recall_observations_tool_metadata_986() {
assert_eq!(RecallObservationsTool::name(), "memory_recall_observations");
assert_eq!(RecallObservationsTool::family(), "meta");
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::observations::{Candidate, mark_consumed, record_recall};
use rusqlite::params;
fn fresh() -> rusqlite::Connection {
crate::storage::open(std::path::Path::new(":memory:")).expect("open in-memory db")
}
fn seed_memory(conn: &rusqlite::Connection, id: &str) {
conn.execute(
"INSERT INTO memories (id, tier, namespace, title, content, created_at, updated_at) \
VALUES (?1, 'long', 'test', ?2, 'c', '2025-01-01T00:00:00Z', '2025-01-01T00:00:00Z')",
params![id, format!("title-{id}")],
)
.unwrap();
}
#[test]
fn handle_recall_observations_filters_by_recall_id_and_consumed() {
let conn = fresh();
for id in &["m1", "m2", "m3", "consumer"] {
seed_memory(&conn, id);
}
record_recall(
&conn,
"r1",
&[
Candidate {
memory_id: "m1",
retriever: "hybrid",
rank: 1,
score: 0.9,
},
Candidate {
memory_id: "m2",
retriever: "hybrid",
rank: 2,
score: 0.8,
},
],
)
.unwrap();
record_recall(
&conn,
"r2",
&[Candidate {
memory_id: "m3",
retriever: "fts5",
rank: 1,
score: 0.4,
}],
)
.unwrap();
mark_consumed(&conn, "r1", &["m1"], "consumer").unwrap();
let r = handle_recall_observations(&conn, &json!({"recall_id": "r1"})).expect("ok");
assert_eq!(r["count"].as_u64(), Some(2));
let only_consumed =
handle_recall_observations(&conn, &json!({"consumed": true})).expect("ok");
assert_eq!(only_consumed["count"].as_u64(), Some(1));
assert_eq!(
only_consumed["observations"][0]["memory_id"].as_str(),
Some("m1")
);
}
}