Skip to main content

origin_mcp/
tools.rs

1use crate::client::OriginClient;
2use crate::types::*;
3use rmcp::{
4    handler::server::router::tool::ToolRouter,
5    handler::server::wrapper::Parameters,
6    model::{CallToolResult, Content, Implementation, InitializeResult, ServerCapabilities},
7    service::{NotificationContext, RoleServer},
8    tool, tool_handler, tool_router, ErrorData as McpError, ServerHandler,
9};
10use serde::{Deserialize, Deserializer};
11
12/// Deserialize an `Option<usize>` that also accepts stringified numbers (e.g. `"10"`).
13/// MCP clients like Claude Desktop sometimes send numeric params as strings.
14fn deserialize_optional_usize_lenient<'de, D>(deserializer: D) -> Result<Option<usize>, D::Error>
15where
16    D: Deserializer<'de>,
17{
18    #[derive(Deserialize)]
19    #[serde(untagged)]
20    enum StringOrNumber {
21        Number(usize),
22        Str(String),
23    }
24
25    match Option::<StringOrNumber>::deserialize(deserializer)? {
26        None => Ok(None),
27        Some(StringOrNumber::Number(n)) => Ok(Some(n)),
28        Some(StringOrNumber::Str(s)) => s
29            .parse::<usize>()
30            .map(Some)
31            .map_err(serde::de::Error::custom),
32    }
33}
34
35/// Controls which operations are allowed based on transport.
36#[derive(Clone, Debug, PartialEq)]
37pub enum TransportMode {
38    /// Local stdio — full access, all tools
39    Stdio,
40    /// Remote HTTP — block deletes, inject source_agent
41    Http,
42}
43
44#[derive(Clone)]
45pub struct OriginMcpServer {
46    #[allow(dead_code)]
47    tool_router: ToolRouter<Self>,
48    client: OriginClient,
49    transport: TransportMode,
50    agent_name: String,
51    /// Client name from MCP initialize handshake (e.g., "Claude Code", "Claude Desktop")
52    client_name: std::sync::Arc<std::sync::Mutex<Option<String>>>,
53    user_id: Option<String>,
54}
55
56// ===== Parameter Structs =====
57
58// --- Primary tool params ---
59
60#[derive(Debug, Deserialize, schemars::JsonSchema)]
61pub struct RememberParams {
62    #[schemars(
63        description = "The memory content. Write as a complete statement with context and reasoning, not shorthand. One idea per memory."
64    )]
65    pub content: String,
66    #[schemars(
67        description = "\"profile\" (about the user) or \"knowledge\" (about the world) — or precise: \"identity\", \"preference\", \"goal\", \"fact\", \"decision\" — auto-classified if omitted"
68    )]
69    pub memory_type: Option<String>,
70    #[schemars(
71        description = "Topic scope (e.g. 'rust', 'work', 'health', 'origin'). Auto-detected if omitted."
72    )]
73    pub domain: Option<String>,
74    #[schemars(
75        description = "Person, project, or tool name to anchor to (e.g. 'Alice', 'Origin', 'PostgreSQL'). Helps build the knowledge graph."
76    )]
77    pub entity: Option<String>,
78    #[schemars(
79        description = "0.0-1.0. Leave unset for auto-calculation based on type and trust level. Set low (0.3-0.5) for uncertain info, high (0.8-1.0) for user-stated facts."
80    )]
81    pub confidence: Option<f32>,
82    #[schemars(
83        description = "source_id of a memory this replaces. Use when correcting or updating an existing memory — get the ID from recall first."
84    )]
85    pub supersedes: Option<String>,
86    #[schemars(
87        description = "Pre-extracted structured fields as a JSON object. Auto-extracted by backend; only supply if you have high-quality structured data already."
88    )]
89    pub structured_fields: Option<serde_json::Map<String, serde_json::Value>>,
90    #[schemars(
91        description = "A question this memory answers, for search matching. Auto-generated by backend; only supply to override."
92    )]
93    pub retrieval_cue: Option<String>,
94}
95
96#[derive(Debug, Deserialize, schemars::JsonSchema)]
97pub struct RecallParams {
98    #[schemars(
99        description = "Natural language search. Be specific: 'Alice database preference' finds more than 'database stuff'."
100    )]
101    pub query: String,
102    #[schemars(
103        description = "Max results, default 10. Use 3-5 for quick lookups, 10-20 for exploration."
104    )]
105    #[serde(default, deserialize_with = "deserialize_optional_usize_lenient")]
106    pub limit: Option<usize>,
107    #[schemars(
108        description = "Filter by type. Two-level filter: \"profile\" (user-facing) or \"knowledge\" (world-facing), or precise: identity, preference, goal, fact, decision."
109    )]
110    pub memory_type: Option<String>,
111    #[schemars(description = "Filter by topic scope.")]
112    pub domain: Option<String>,
113}
114
115#[derive(Debug, Deserialize, schemars::JsonSchema)]
116pub struct ContextParams {
117    #[schemars(
118        description = "Topic or conversation summary to focus context retrieval. Omit at session start for general orientation; provide when shifting topics."
119    )]
120    pub topic: Option<String>,
121    #[schemars(
122        description = "Max context chunks, default 20. Increase for complex topics, decrease for quick check-ins."
123    )]
124    #[serde(default, deserialize_with = "deserialize_optional_usize_lenient")]
125    pub limit: Option<usize>,
126    #[schemars(
127        description = "Scope context to a domain/space (e.g. 'work', 'personal'). Auto-detected from conversation if omitted."
128    )]
129    pub domain: Option<String>,
130}
131
132#[derive(Debug, Deserialize, schemars::JsonSchema)]
133pub struct ForgetParams {
134    #[schemars(
135        description = "The source_id of the memory to delete. Get this from recall results first."
136    )]
137    pub memory_id: String,
138}
139
140// ===== Internal Implementations =====
141
142fn format_remember_success(resp: &StoreMemoryResponse) -> String {
143    let mut msg = format!("Stored {}", resp.source_id);
144    if !resp.warnings.is_empty() {
145        msg.push_str("\nWarnings:");
146        for warning in &resp.warnings {
147            msg.push_str(&format!("\n  - {}", warning));
148        }
149    }
150    msg
151}
152
153impl OriginMcpServer {
154    /// Resolve the source_agent for a write operation.
155    /// Priority: explicit param > MCP client name (from initialize) > configured agent_name.
156    fn resolve_source_agent(&self, param_agent: Option<String>) -> Option<String> {
157        // 1. Explicit param from tool call
158        if let Some(ref agent) = param_agent {
159            if !agent.is_empty() {
160                return param_agent;
161            }
162        }
163        // 2. Client name captured from MCP initialize handshake
164        if let Ok(guard) = self.client_name.lock() {
165            if let Some(ref name) = *guard {
166                return Some(name.clone());
167            }
168        }
169        // 3. Configured --agent-name flag
170        Some(self.agent_name.clone())
171    }
172
173    /// Resolve a local user_id for logging or future use.
174    /// This value is intentionally not sent on the wire (D4).
175    fn resolve_user_id(&self, param_user_id: Option<String>) -> Option<String> {
176        if self.transport == TransportMode::Http {
177            self.user_id.clone().or(param_user_id)
178        } else {
179            param_user_id
180        }
181    }
182
183    pub async fn remember_impl(&self, params: RememberParams) -> Result<CallToolResult, McpError> {
184        let source_agent = self.resolve_source_agent(None);
185        if let Some(uid) = self.resolve_user_id(None) {
186            tracing::debug!(user_id = %uid, "remember invoked");
187        }
188
189        let req = StoreMemoryRequest {
190            content: params.content,
191            memory_type: params.memory_type,
192            domain: params.domain,
193            source_agent,
194            title: None,
195            confidence: params.confidence,
196            supersedes: params.supersedes,
197            entity: params.entity,
198            entity_id: None,
199            structured_fields: params.structured_fields.map(serde_json::Value::Object),
200            retrieval_cue: params.retrieval_cue,
201        };
202
203        let resp: StoreMemoryResponse = self
204            .client
205            .post("/api/memory/store", &req)
206            .await
207            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
208
209        Ok(CallToolResult::success(vec![Content::text(
210            format_remember_success(&resp),
211        )]))
212    }
213
214    pub async fn recall_impl(&self, params: RecallParams) -> Result<CallToolResult, McpError> {
215        let req = SearchMemoryRequest {
216            query: params.query,
217            limit: params.limit.unwrap_or(10),
218            memory_type: params.memory_type,
219            domain: params.domain,
220            source_agent: self.resolve_source_agent(None),
221        };
222
223        let resp: SearchMemoryResponse = self
224            .client
225            .post("/api/memory/search", &req)
226            .await
227            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
228
229        let json = serde_json::to_string_pretty(&resp.results)
230            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
231
232        Ok(CallToolResult::success(vec![Content::text(format!(
233            "{} results ({:.1}ms)\n{}",
234            resp.results.len(),
235            resp.took_ms,
236            json
237        ))]))
238    }
239
240    pub async fn context_impl(&self, params: ContextParams) -> Result<CallToolResult, McpError> {
241        let req = ChatContextRequest {
242            query: None,
243            conversation_id: params.topic,
244            max_chunks: params.limit.unwrap_or(20),
245            relevance_threshold: None,
246            include_goals: true,
247            domain: params.domain,
248        };
249
250        // Extract only the `context` string field from the response.
251        //
252        // The full ChatContextResponse embeds Vec<SearchResult> which may
253        // contain fields added after the published origin-types version.
254        // Since context_impl only uses `resp.context`, we parse the raw
255        // JSON and pull that field directly — this makes the tool forward-
256        // compatible with any new fields the daemon might add.
257        let raw: serde_json::Value = self
258            .client
259            .post("/api/chat-context", &req)
260            .await
261            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
262
263        let context = raw
264            .get("context")
265            .and_then(|v| v.as_str())
266            .unwrap_or_default()
267            .to_string();
268
269        if context.is_empty() {
270            Ok(CallToolResult::success(vec![Content::text(
271                "No relevant context found".to_string(),
272            )]))
273        } else {
274            Ok(CallToolResult::success(vec![Content::text(context)]))
275        }
276    }
277
278    pub async fn forget_impl(&self, memory_id: &str) -> Result<CallToolResult, McpError> {
279        if self.transport == TransportMode::Http {
280            return Ok(CallToolResult::error(vec![Content::text(
281                "Delete operations are not available over remote connections. \
282                 Use the Origin desktop app to delete memories."
283                    .to_string(),
284            )]));
285        }
286
287        let resp: DeleteResponse = self
288            .client
289            .delete(&format!("/api/memory/delete/{}", memory_id))
290            .await
291            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
292
293        Ok(CallToolResult::success(vec![Content::text(
294            if resp.deleted {
295                "Memory deleted"
296            } else {
297                "Memory not found"
298            }
299            .to_string(),
300        )]))
301    }
302}
303
304// ===== Tool Registrations =====
305
306#[tool_router]
307impl OriginMcpServer {
308    pub fn new(
309        client: OriginClient,
310        transport: TransportMode,
311        agent_name: String,
312        user_id: Option<String>,
313    ) -> Self {
314        Self {
315            tool_router: Self::tool_router(),
316            client,
317            transport,
318            agent_name,
319            client_name: std::sync::Arc::new(std::sync::Mutex::new(None)),
320            user_id,
321        }
322    }
323
324    // --- Primary Tools ---
325
326    #[tool(
327        description = "Store a memory. Call PROACTIVELY when you learn something durable about the user — preferences, decisions, corrections, or facts about people/projects/tools they care about. Don't wait for the user to say 'remember this' — that's a floor, not a trigger.\n\nWrite content as a complete, self-contained statement — someone reading it months later with no conversation context should understand it. Include the WHY, not just the WHAT. Name people, projects, and tools explicitly.\n\nThe backend auto-classifies type, extracts structured fields, detects entities, and links to the knowledge graph. You don't need to set memory_type or structured_fields unless you're confident — omitting them gets better results than guessing wrong.\n\nDo NOT store: system prompts, boot logs, heartbeat/health checks, transient task state ('currently working on...'), tool output/responses, architecture dumps, single-word acknowledgments, or content you have already stored. Focus on durable facts, preferences, decisions, goals, and identity information. Each call is one atomic idea — \"prefers TDD\" and \"uses pytest\" are two calls, not one.",
328        annotations(
329            title = "Remember",
330            read_only_hint = false,
331            destructive_hint = false,
332            idempotent_hint = false,
333            open_world_hint = false
334        )
335    )]
336    async fn remember(
337        &self,
338        Parameters(params): Parameters<RememberParams>,
339    ) -> Result<CallToolResult, McpError> {
340        self.remember_impl(params).await
341    }
342
343    #[tool(
344        description = "Search memories by query. Use when the user asks 'do you remember', 'what do you know about', 'look up', or when you need a specific fact before acting.\n\nWrite queries as natural language — the search engine handles semantic matching. For precision, use filters (memory_type, domain) to narrow results. If you get too many results, add filters rather than making the query longer.\n\nThis is for targeted lookups. For broad session orientation, use context instead.",
345        annotations(title = "Recall", read_only_hint = true, open_world_hint = false)
346    )]
347    async fn recall(
348        &self,
349        Parameters(params): Parameters<RecallParams>,
350    ) -> Result<CallToolResult, McpError> {
351        self.recall_impl(params).await
352    }
353
354    #[tool(
355        description = "Load session context — identity, preferences, goals, and topic-relevant memories. Call this FIRST at the start of every session before doing anything else. Also call on major topic shifts or when the user says 'catch me up' or 'what's the background on'.\n\nThis returns a curated blend of who the user is and what's relevant. For specific factual lookups, use recall instead. Use the result to model how the user thinks, not just to look things up — their preferences and corrections tell you how they want to be helped.",
356        annotations(title = "Context", read_only_hint = true, open_world_hint = false)
357    )]
358    async fn context(
359        &self,
360        Parameters(params): Parameters<ContextParams>,
361    ) -> Result<CallToolResult, McpError> {
362        self.context_impl(params).await
363    }
364
365    #[tool(
366        description = "Delete a memory by ID. Use when the user says 'forget this', 'delete that', 'that's wrong and should be removed'. Requires the source_id — get it from recall first.\n\nThis is destructive and cannot be undone. For corrections, prefer storing a new memory with the supersedes param pointing to the old one — this preserves history.",
367        annotations(
368            title = "Forget",
369            read_only_hint = false,
370            destructive_hint = true,
371            idempotent_hint = true,
372            open_world_hint = false
373        )
374    )]
375    async fn forget(
376        &self,
377        Parameters(params): Parameters<ForgetParams>,
378    ) -> Result<CallToolResult, McpError> {
379        self.forget_impl(&params.memory_id).await
380    }
381}
382
383// ===== ServerHandler =====
384
385#[tool_handler]
386impl ServerHandler for OriginMcpServer {
387    async fn on_initialized(&self, context: NotificationContext<RoleServer>) {
388        // Capture client name from MCP initialize handshake
389        if let Some(client_info) = context.peer.peer_info() {
390            let name = &client_info.client_info.name;
391            if !name.is_empty() {
392                if let Ok(mut guard) = self.client_name.lock() {
393                    tracing::info!("MCP client identified: {}", name);
394                    *guard = Some(name.clone());
395                }
396            }
397        }
398    }
399
400    fn get_info(&self) -> InitializeResult {
401        InitializeResult::new(
402            ServerCapabilities::builder()
403                .enable_tools()
404                .build(),
405        )
406        .with_server_info(
407            Implementation::new("origin-mcp", env!("CARGO_PKG_VERSION"))
408        )
409        .with_instructions(
410            "Origin is your personal memory layer — a local knowledge base that persists across sessions and tools.\n\
411             Think of yourself as a curator, not a logger. Store insights, not conversation artifacts.\n\n\
412             Origin is self-evolving — each memory you store contributes to a knowledge structure that grows over time. \
413             It's also shared across all the user's tools: what you write, other agents (Claude Desktop, Claude Code, \
414             ChatGPT, Cursor, etc.) will read later. Write for any future reader, not just this conversation.\n\n\
415             FIRST THING EVERY SESSION: Call context to load the user's identity, preferences, goals, and\n\
416             topic-relevant memories. This is how you know who you're talking to. Use the result to model how the \
417             user thinks — their preferences, corrections, and past decisions tell you how they want to be helped, \
418             not just what they already know.\n\n\
419             STORE PROACTIVELY — don't wait for the user to ask.\n\
420             - The user states a preference (\"I use X because...\", \"I prefer Y over Z\")\n\
421             - The user makes a decision (\"going with approach A\", \"switching to B\")\n\
422             - The user corrects you or prior info (\"actually, it's C, not D\") — store the correction so it sticks\n\
423             - The user shares a durable fact about themselves, their work, or people/projects/tools they care about — \
424               anchor it to the entity\n\n\
425             If the user asks explicitly (\"remember this\", \"save this\", \"don't forget\"), that's a floor — you \
426             should have already stored it.\n\n\
427             WHEN NOT TO STORE:\n\
428             - Conversation filler (\"ok\", \"thanks\", \"let's move on\")\n\
429             - Things the user can trivially re-derive (file paths, recent git history)\n\
430             - Anything already stored — recall first if unsure\n\
431             - Tool output or command results (file contents, git history, build logs) — these are derivable\n\
432             - General world facts or documentation that aren't personal to this user (e.g., \"Rust has a borrow \
433               checker\", \"PostgreSQL supports JSONB\") — those are not memory material.\n\
434             - Your own inferences about the user that they didn't express. Store what they said; infer from that \
435               when responding.\n\n\
436             CONTENT QUALITY — this is where you make the biggest difference:\n\
437             - Specific beats vague: \"prefers Rust for CLI tools because of compile-time safety\" > \"likes Rust\"\n\
438             - Include the WHY: the backend can classify \"dark mode\" as a preference, but only you know\n\
439               \"switched to dark mode because of migraines from bright screens\"\n\
440             - Name the entities: mention people, projects, tools by name — this powers the knowledge graph\n\
441             - Atomic: one idea per memory — \"prefers TDD\" and \"uses pytest\" should be two memories, not one\n\
442             - Declarative, not narrative: \"User prefers X because Y\" — not \"User said today they prefer X\". \
443               Memories outlive the conversation that produced them.\n\n\
444             MEMORY TYPES — omit and trust the backend.\n\n\
445             By default, do NOT set memory_type. The backend auto-classifies into identity / preference / goal / \
446             fact / decision with more context than you have. Agents that over-specify types tend to pick wrong.\n\n\
447             Opt-in specification:\n\
448             - \"profile\"   — you're sure it's about the user (identity/preference/goal)\n\
449             - \"knowledge\" — you're sure it's about the world (fact/decision)\n\
450             - Precise type — only if you're confident and the distinction matters.\n\n\
451             EXCEPTION — decisions carry structured fields (alternatives considered, reversibility, domain) \
452             that power the Decision Log view. Set memory_type=\"decision\" explicitly ONLY when the user \
453             articulated alternatives weighed AND the reasoning for the choice. A bare \"I'm switching to Cursor\" \
454             is just a preference change — omit the type. \"Switching to Cursor over VSCode because of better \
455             Claude integration, and we can always go back\" — that's a decision.\n\n\
456             RECALL vs CONTEXT:\n\
457             - context: broad orientation, session start, topic shifts, \"catch me up\"\n\
458             - recall: specific lookup (\"what's Alice's role?\", \"database preferences\", \"our auth decision\")\n\n\
459             The backend handles classification, entity extraction, structured fields, quality scoring,\n\
460             and dedup — you don't need to replicate that logic. Focus on what only you know:\n\
461             the conversational context, why something matters, and what the user actually cares about."
462        )
463    }
464}
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469    use crate::client::OriginClient;
470    use crate::types::{
471        ChatContextRequest, ChatContextResponse, SearchMemoryRequest, SearchResult,
472        StoreMemoryRequest, StoreMemoryResponse,
473    };
474
475    fn make_server(
476        transport: TransportMode,
477        agent_name: &str,
478        user_id: Option<&str>,
479    ) -> OriginMcpServer {
480        let client = OriginClient::new("http://127.0.0.1:19999".into());
481        OriginMcpServer::new(
482            client,
483            transport,
484            agent_name.into(),
485            user_id.map(String::from),
486        )
487    }
488
489    // ===== Transport resolution (existing) =====
490
491    #[test]
492    fn test_http_mode_prefers_param_over_agent_name() {
493        let server = make_server(TransportMode::Http, "claude.ai", None);
494        // Explicit param has highest priority
495        let result = server.resolve_source_agent(Some("user-provided".into()));
496        assert_eq!(result, Some("user-provided".into()));
497    }
498
499    #[test]
500    fn test_http_mode_sets_source_agent_when_none() {
501        let server = make_server(TransportMode::Http, "chatgpt", None);
502        let result = server.resolve_source_agent(None);
503        assert_eq!(result, Some("chatgpt".into()));
504    }
505
506    #[test]
507    fn test_stdio_mode_passes_through_source_agent() {
508        let server = make_server(TransportMode::Stdio, "ignored", None);
509        let result = server.resolve_source_agent(Some("user-provided".into()));
510        assert_eq!(result, Some("user-provided".into()));
511    }
512
513    #[test]
514    fn test_stdio_mode_falls_back_to_agent_name() {
515        let server = make_server(TransportMode::Stdio, "fallback", None);
516        // No param, no client_name → falls back to configured agent_name
517        let result = server.resolve_source_agent(None);
518        assert_eq!(result, Some("fallback".into()));
519    }
520
521    #[test]
522    fn test_http_mode_resolves_configured_user_id_for_local_use() {
523        let server = make_server(TransportMode::Http, "agent", Some("lucian"));
524        let result = server.resolve_user_id(None);
525        assert_eq!(result, Some("lucian".into()));
526    }
527
528    #[test]
529    fn test_transport_mode_equality() {
530        assert_eq!(TransportMode::Stdio, TransportMode::Stdio);
531        assert_eq!(TransportMode::Http, TransportMode::Http);
532        assert_ne!(TransportMode::Stdio, TransportMode::Http);
533    }
534
535    // ===== Param deserialization: RememberParams =====
536
537    #[test]
538    fn test_remember_params_minimal() {
539        let json = r#"{"content": "Lucian prefers dark mode"}"#;
540        let params: RememberParams = serde_json::from_str(json).unwrap();
541        assert_eq!(params.content, "Lucian prefers dark mode");
542        assert!(params.memory_type.is_none());
543        assert!(params.domain.is_none());
544        assert!(params.entity.is_none());
545        assert!(params.confidence.is_none());
546        assert!(params.supersedes.is_none());
547    }
548
549    #[test]
550    fn test_remember_params_full() {
551        let json = r#"{
552            "content": "We chose PostgreSQL over MongoDB",
553            "memory_type": "decision",
554            "domain": "origin",
555            "entity": "PostgreSQL",
556            "confidence": 0.95,
557            "supersedes": "mem_abc123"
558        }"#;
559        let params: RememberParams = serde_json::from_str(json).unwrap();
560        assert_eq!(params.content, "We chose PostgreSQL over MongoDB");
561        assert_eq!(params.memory_type.as_deref(), Some("decision"));
562        assert_eq!(params.domain.as_deref(), Some("origin"));
563        assert_eq!(params.entity.as_deref(), Some("PostgreSQL"));
564        assert_eq!(params.confidence, Some(0.95));
565        assert_eq!(params.supersedes.as_deref(), Some("mem_abc123"));
566    }
567
568    #[test]
569    fn test_remember_params_missing_content_fails() {
570        let json = r#"{"memory_type": "fact"}"#;
571        let result = serde_json::from_str::<RememberParams>(json);
572        assert!(result.is_err());
573    }
574
575    // ===== Param deserialization: RecallParams =====
576
577    #[test]
578    fn test_recall_params_minimal() {
579        let json = r#"{"query": "what does Alice work on?"}"#;
580        let params: RecallParams = serde_json::from_str(json).unwrap();
581        assert_eq!(params.query, "what does Alice work on?");
582        assert!(params.limit.is_none());
583    }
584
585    #[test]
586    fn test_recall_params_full() {
587        let json = r#"{
588            "query": "database preferences",
589            "limit": 5,
590            "memory_type": "decision",
591            "domain": "origin"
592        }"#;
593        let params: RecallParams = serde_json::from_str(json).unwrap();
594        assert_eq!(params.query, "database preferences");
595        assert_eq!(params.limit, Some(5));
596        assert_eq!(params.memory_type.as_deref(), Some("decision"));
597        assert_eq!(params.domain.as_deref(), Some("origin"));
598    }
599
600    #[test]
601    fn test_recall_params_limit_as_string() {
602        let json = r#"{"query": "test", "limit": "10"}"#;
603        let params: RecallParams = serde_json::from_str(json).unwrap();
604        assert_eq!(params.limit, Some(10));
605    }
606
607    #[test]
608    fn test_recall_params_missing_query_fails() {
609        let json = r#"{"limit": 5}"#;
610        let result = serde_json::from_str::<RecallParams>(json);
611        assert!(result.is_err());
612    }
613
614    // ===== Param deserialization: ContextParams =====
615
616    #[test]
617    fn test_context_params_empty() {
618        let json = r#"{}"#;
619        let params: ContextParams = serde_json::from_str(json).unwrap();
620        assert!(params.topic.is_none());
621        assert!(params.limit.is_none());
622        assert!(params.domain.is_none());
623    }
624
625    #[test]
626    fn test_context_params_full() {
627        let json = r#"{"topic": "project Origin architecture", "limit": 30, "domain": "work"}"#;
628        let params: ContextParams = serde_json::from_str(json).unwrap();
629        assert_eq!(params.topic.as_deref(), Some("project Origin architecture"));
630        assert_eq!(params.limit, Some(30));
631        assert_eq!(params.domain.as_deref(), Some("work"));
632    }
633
634    #[test]
635    fn test_context_params_limit_as_string() {
636        let json = r#"{"limit": "20"}"#;
637        let params: ContextParams = serde_json::from_str(json).unwrap();
638        assert_eq!(params.limit, Some(20));
639    }
640
641    #[test]
642    fn store_memory_request_serialization_excludes_user_id() {
643        let req = StoreMemoryRequest {
644            content: "test content".into(),
645            memory_type: None,
646            domain: None,
647            source_agent: Some("test-agent".into()),
648            title: None,
649            confidence: None,
650            supersedes: None,
651            entity: None,
652            entity_id: None,
653            structured_fields: None,
654            retrieval_cue: None,
655        };
656        let json = serde_json::to_value(&req).unwrap();
657        let obj = json.as_object().unwrap();
658        assert!(
659            !obj.contains_key("user_id"),
660            "user_id must not be on the wire; got: {:?}",
661            obj.keys().collect::<Vec<_>>()
662        );
663    }
664
665    #[test]
666    fn remember_success_message_is_terse() {
667        let resp = StoreMemoryResponse {
668            source_id: "mem_abc".into(),
669            chunks_created: 3,
670            memory_type: "fact".into(),
671            entity_id: Some("ent_xyz".into()),
672            quality: Some("high".into()),
673            warnings: vec![],
674            extraction_method: "llm".into(),
675        };
676        let msg = format_remember_success(&resp);
677        assert_eq!(msg, "Stored mem_abc");
678        assert!(!msg.contains("chunks"));
679        assert!(!msg.contains("quality"));
680        assert!(!msg.contains("entity"));
681    }
682
683    #[test]
684    fn remember_success_message_surfaces_warnings() {
685        let resp = StoreMemoryResponse {
686            source_id: "mem_abc".into(),
687            chunks_created: 1,
688            memory_type: "decision".into(),
689            entity_id: None,
690            quality: None,
691            warnings: vec!["decision memory missing required 'claim' field".into()],
692            extraction_method: "agent".into(),
693        };
694        let msg = format_remember_success(&resp);
695        assert!(msg.starts_with("Stored mem_abc"));
696        assert!(msg.contains("Warnings:"));
697        assert!(msg.contains("decision memory missing required 'claim' field"));
698    }
699
700    #[test]
701    fn search_memory_request_serialization_excludes_entity() {
702        let req = SearchMemoryRequest {
703            query: "test".into(),
704            limit: 10,
705            memory_type: None,
706            domain: None,
707            source_agent: None,
708        };
709        let json = serde_json::to_value(&req).unwrap();
710        let obj = json.as_object().unwrap();
711        assert!(
712            !obj.contains_key("entity"),
713            "entity must not be on the wire; got keys: {:?}",
714            obj.keys().collect::<Vec<_>>()
715        );
716    }
717
718    #[test]
719    fn chat_context_request_serialization_includes_domain() {
720        let req = ChatContextRequest {
721            query: None,
722            conversation_id: Some("topic".into()),
723            max_chunks: 20,
724            relevance_threshold: None,
725            include_goals: true,
726            domain: Some("work".into()),
727        };
728        let json = serde_json::to_value(&req).unwrap();
729        assert_eq!(json["domain"], serde_json::json!("work"));
730        assert_eq!(json["conversation_id"], serde_json::json!("topic"));
731    }
732
733    #[test]
734    fn chat_context_response_deserializes_with_profile_and_knowledge() {
735        let json = r#"{
736            "context": "user is Lucian, prefers Rust",
737            "profile": {
738                "narrative": "n",
739                "identity": ["rust"],
740                "preferences": [],
741                "goals": []
742            },
743            "knowledge": {
744                "concepts": [],
745                "decisions": [],
746                "relevant_memories": [],
747                "graph_context": []
748            },
749            "took_ms": 42.0,
750            "token_estimates": {
751                "tier1_identity": 10,
752                "tier2_project": 20,
753                "tier3_relevant": 30,
754                "total": 60
755            }
756        }"#;
757        let parsed: ChatContextResponse = serde_json::from_str(json).unwrap();
758        assert_eq!(parsed.context, "user is Lucian, prefers Rust");
759        assert_eq!(parsed.profile.identity, vec!["rust"]);
760        assert_eq!(parsed.token_estimates.total, 60);
761    }
762
763    #[test]
764    fn remember_params_structured_fields_schema_is_object() {
765        use schemars::schema_for;
766
767        let schema = schema_for!(RememberParams);
768        let json = serde_json::to_value(&schema).unwrap();
769        let sf_schema = json
770            .pointer("/properties/structured_fields")
771            .expect("structured_fields property in schema");
772        let type_val = sf_schema
773            .pointer("/type")
774            .unwrap_or(&serde_json::Value::Null);
775        let type_str = match type_val {
776            serde_json::Value::String(s) => s.clone(),
777            serde_json::Value::Array(arr) => arr
778                .iter()
779                .filter_map(|v| v.as_str())
780                .collect::<Vec<_>>()
781                .join(","),
782            other => panic!(
783                "structured_fields schema lacks type constraint; got: {:?}",
784                other
785            ),
786        };
787        assert!(
788            type_str.contains("object"),
789            "expected object type, got: {}",
790            type_str
791        );
792    }
793
794    // ===== Param deserialization: ForgetParams =====
795
796    #[test]
797    fn test_forget_params() {
798        let json = r#"{"memory_id": "mem_abc123"}"#;
799        let params: ForgetParams = serde_json::from_str(json).unwrap();
800        assert_eq!(params.memory_id, "mem_abc123");
801    }
802
803    #[test]
804    fn test_forget_params_missing_id_fails() {
805        let json = r#"{}"#;
806        let result = serde_json::from_str::<ForgetParams>(json);
807        assert!(result.is_err());
808    }
809
810    // ===== Request serialization: StoreMemoryRequest =====
811
812    #[test]
813    fn test_store_request_includes_new_fields() {
814        let req = StoreMemoryRequest {
815            content: "test".into(),
816            memory_type: Some("decision".into()),
817            domain: None,
818            source_agent: Some("claude".into()),
819            title: None,
820            confidence: Some(0.9),
821            supersedes: Some("old_id".into()),
822            entity: Some("PostgreSQL".into()),
823            entity_id: None,
824            structured_fields: None,
825            retrieval_cue: None,
826        };
827        let json = serde_json::to_value(&req).unwrap();
828        assert_eq!(json["entity"], "PostgreSQL");
829        assert_eq!(json["supersedes"], "old_id");
830        assert!(json["confidence"].as_f64().unwrap() > 0.89);
831        assert_eq!(json["source_agent"], "claude");
832        assert!(json.get("user_id").is_none());
833    }
834
835    #[test]
836    fn test_store_request_minimal() {
837        let req = StoreMemoryRequest {
838            content: "hello".into(),
839            memory_type: Some("fact".into()),
840            domain: None,
841            source_agent: None,
842            title: None,
843            confidence: None,
844            supersedes: None,
845            entity: None,
846            entity_id: None,
847            structured_fields: None,
848            retrieval_cue: None,
849        };
850        let json = serde_json::to_value(&req).unwrap();
851        assert_eq!(json["content"], "hello");
852        assert_eq!(json["memory_type"], "fact");
853        assert!(json.get("user_id").is_none());
854    }
855
856    // ===== Response deserialization: StoreMemoryResponse =====
857
858    #[test]
859    fn test_store_response_with_new_fields() {
860        let json = r#"{
861            "source_id": "mem_xyz",
862            "chunks_created": 2,
863            "memory_type": "fact",
864            "entity_id": "ent_abc",
865            "quality": "high",
866            "warnings": ["decision memory missing claim"],
867            "extraction_method": "agent"
868        }"#;
869        let resp: StoreMemoryResponse = serde_json::from_str(json).unwrap();
870        assert_eq!(resp.source_id, "mem_xyz");
871        assert_eq!(resp.chunks_created, 2);
872        assert_eq!(resp.memory_type, "fact");
873        assert_eq!(resp.entity_id.as_deref(), Some("ent_abc"));
874        assert_eq!(resp.quality.as_deref(), Some("high"));
875        assert_eq!(resp.warnings, vec!["decision memory missing claim"]);
876        assert_eq!(resp.extraction_method, "agent");
877    }
878
879    #[test]
880    fn test_store_response_backward_compat_no_new_fields() {
881        // Old backend response without warnings/extraction_method
882        let json = r#"{
883            "source_id": "mem_old",
884            "chunks_created": 1,
885            "memory_type": "fact"
886        }"#;
887        let resp: StoreMemoryResponse = serde_json::from_str(json).unwrap();
888        assert_eq!(resp.source_id, "mem_old");
889        assert_eq!(resp.chunks_created, 1);
890        assert_eq!(resp.memory_type, "fact");
891        assert!(resp.entity_id.is_none());
892        assert!(resp.quality.is_none());
893        assert!(resp.warnings.is_empty());
894        assert_eq!(resp.extraction_method, "unknown");
895    }
896
897    #[test]
898    fn test_store_response_with_warnings_and_extraction_method() {
899        let json = r#"{
900            "source_id": "mem_xyz",
901            "chunks_created": 1,
902            "memory_type": "decision",
903            "warnings": ["decision memory missing required 'claim' field"],
904            "extraction_method": "llm"
905        }"#;
906        let resp: StoreMemoryResponse = serde_json::from_str(json).unwrap();
907        assert_eq!(resp.memory_type, "decision");
908        assert_eq!(
909            resp.warnings,
910            vec!["decision memory missing required 'claim' field"]
911        );
912        assert_eq!(resp.extraction_method, "llm");
913    }
914
915    // ===== Response deserialization: SearchResult =====
916
917    #[test]
918    fn test_search_result_with_new_fields() {
919        let json = r#"{
920            "id": "1",
921            "content": "We chose Postgres",
922            "source": "memory",
923            "source_id": "mem_1",
924            "title": "DB decision",
925            "url": null,
926            "chunk_index": 0,
927            "last_modified": 1711000000,
928            "score": 0.95,
929            "chunk_type": "memory",
930            "language": "en",
931            "semantic_unit": "sentence",
932            "memory_type": "decision",
933            "domain": "origin",
934            "source_agent": "claude",
935            "confidence": 0.9,
936            "confirmed": true,
937            "stability": "standard",
938            "supersedes": "mem_0",
939            "summary": "DB choice",
940            "entity_id": "ent_pg",
941            "entity_name": "PostgreSQL",
942            "quality": "high",
943            "is_archived": false,
944            "is_recap": false,
945            "source_text": "We chose Postgres",
946            "raw_score": 0.42
947        }"#;
948        let result: SearchResult = serde_json::from_str(json).unwrap();
949        assert_eq!(result.chunk_type.as_deref(), Some("memory"));
950        assert_eq!(result.language.as_deref(), Some("en"));
951        assert_eq!(result.semantic_unit.as_deref(), Some("sentence"));
952        assert_eq!(result.stability.as_deref(), Some("standard"));
953        assert_eq!(result.supersedes.as_deref(), Some("mem_0"));
954        assert_eq!(result.summary.as_deref(), Some("DB choice"));
955        assert_eq!(result.entity_id.as_deref(), Some("ent_pg"));
956        assert_eq!(result.entity_name.as_deref(), Some("PostgreSQL"));
957        assert_eq!(result.quality.as_deref(), Some("high"));
958        assert!(!result.is_archived);
959        assert!(!result.is_recap);
960        assert_eq!(result.source_text.as_deref(), Some("We chose Postgres"));
961        assert!((result.raw_score - 0.42).abs() < f32::EPSILON);
962    }
963
964    #[test]
965    fn test_search_result_backward_compat_no_new_fields() {
966        // Old backend response without entity/quality/archive/recap
967        let json = r#"{
968            "id": "1",
969            "content": "test",
970            "source": "memory",
971            "source_id": "mem_1",
972            "title": "test",
973            "url": null,
974            "chunk_index": 0,
975            "last_modified": 1711000000,
976            "score": 0.8,
977            "memory_type": "fact",
978            "domain": null,
979            "source_agent": null,
980            "confidence": null,
981            "confirmed": null
982        }"#;
983        let result: SearchResult = serde_json::from_str(json).unwrap();
984        assert!(result.entity_id.is_none());
985        assert!(result.entity_name.is_none());
986        assert!(result.quality.is_none());
987        assert!(!result.is_archived);
988        assert!(!result.is_recap);
989        assert!(result.structured_fields.is_none());
990        assert!(result.retrieval_cue.is_none());
991        assert_eq!(result.raw_score, 0.0);
992    }
993
994    #[test]
995    fn test_search_result_with_structured_fields_and_retrieval_cue() {
996        let json = r#"{
997            "id": "1",
998            "content": "Lucian prefers dark mode",
999            "source": "memory",
1000            "source_id": "mem_1",
1001            "title": "Dark mode preference",
1002            "url": null,
1003            "chunk_index": 0,
1004            "last_modified": 1711000000,
1005            "score": 0.92,
1006            "memory_type": "preference",
1007            "domain": null,
1008            "source_agent": null,
1009            "confidence": null,
1010            "confirmed": null,
1011            "structured_fields": "{\"theme\":\"dark\",\"applies_to\":\"all_apps\"}",
1012            "retrieval_cue": "What UI theme does Lucian prefer?"
1013        }"#;
1014        let result: SearchResult = serde_json::from_str(json).unwrap();
1015        assert_eq!(
1016            result.structured_fields.as_deref(),
1017            Some("{\"theme\":\"dark\",\"applies_to\":\"all_apps\"}")
1018        );
1019        assert_eq!(
1020            result.retrieval_cue.as_deref(),
1021            Some("What UI theme does Lucian prefer?")
1022        );
1023        assert!(!result.is_archived);
1024        assert!(!result.is_recap);
1025        assert_eq!(result.raw_score, 0.0);
1026    }
1027
1028    #[test]
1029    fn test_search_result_knowledge_graph_source() {
1030        // Entity-boosted observation results from knowledge graph
1031        let json = r#"{
1032            "id": "obs_1",
1033            "content": "Prefers Rust over Go",
1034            "source": "knowledge_graph",
1035            "source_id": "ent_lucian",
1036            "title": "Lucian",
1037            "url": null,
1038            "chunk_index": 0,
1039            "last_modified": 1711000000,
1040            "score": 1.14,
1041            "memory_type": null,
1042            "domain": null,
1043            "source_agent": null,
1044            "confidence": null,
1045            "confirmed": null,
1046            "entity_id": "ent_lucian",
1047            "entity_name": "Lucian"
1048        }"#;
1049        let result: SearchResult = serde_json::from_str(json).unwrap();
1050        assert_eq!(result.source, "knowledge_graph");
1051        assert_eq!(result.entity_id.as_deref(), Some("ent_lucian"));
1052        assert_eq!(result.entity_name.as_deref(), Some("Lucian"));
1053        assert!(!result.is_archived);
1054        assert!(!result.is_recap);
1055        assert_eq!(result.raw_score, 0.0);
1056    }
1057
1058    // ===== Transport security: forget blocks on HTTP =====
1059
1060    #[tokio::test]
1061    async fn test_forget_blocked_on_http_transport() {
1062        let server = make_server(TransportMode::Http, "agent", None);
1063        let result = server.forget_impl("mem_123").await.unwrap();
1064        // Should return error content, not an Err
1065        let content = &result.content[0];
1066        match content.raw {
1067            rmcp::model::RawContent::Text(ref tc) => {
1068                assert!(tc.text.contains("not available over remote connections"));
1069            }
1070            _ => panic!("expected text content"),
1071        }
1072    }
1073
1074    #[tokio::test]
1075    async fn test_forget_allowed_on_stdio_transport() {
1076        // This will fail with connection error (no server), which proves
1077        // the transport check passed and it tried to make the HTTP call
1078        let server = make_server(TransportMode::Stdio, "agent", None);
1079        let result = server.forget_impl("mem_123").await;
1080        assert!(
1081            result.is_err(),
1082            "should fail with connection error, not transport block"
1083        );
1084    }
1085
1086    // ===== Context default limit =====
1087
1088    #[test]
1089    fn test_context_request_default_limit() {
1090        let params = ContextParams {
1091            topic: Some("test".into()),
1092            limit: None,
1093            domain: None,
1094        };
1095        let req = ChatContextRequest {
1096            query: None,
1097            conversation_id: params.topic,
1098            max_chunks: params.limit.unwrap_or(20),
1099            relevance_threshold: None,
1100            include_goals: true,
1101            domain: params.domain,
1102        };
1103        assert_eq!(req.max_chunks, 20);
1104    }
1105
1106    #[test]
1107    fn test_context_request_custom_limit() {
1108        let params = ContextParams {
1109            topic: None,
1110            limit: Some(5),
1111            domain: Some("work".into()),
1112        };
1113        let req = ChatContextRequest {
1114            query: None,
1115            conversation_id: params.topic,
1116            max_chunks: params.limit.unwrap_or(20),
1117            relevance_threshold: None,
1118            include_goals: true,
1119            domain: params.domain,
1120        };
1121        assert_eq!(req.max_chunks, 5);
1122        assert_eq!(req.domain.as_deref(), Some("work"));
1123    }
1124
1125    #[test]
1126    fn test_context_maps_topic_to_conversation_id() {
1127        let params = ContextParams {
1128            topic: Some("project Origin".into()),
1129            limit: None,
1130            domain: None,
1131        };
1132        let req = ChatContextRequest {
1133            query: None,
1134            conversation_id: params.topic.clone(),
1135            max_chunks: params.limit.unwrap_or(20),
1136            relevance_threshold: None,
1137            include_goals: true,
1138            domain: params.domain,
1139        };
1140        assert_eq!(req.conversation_id.as_deref(), Some("project Origin"));
1141    }
1142
1143    // ===== Remember request construction =====
1144
1145    #[test]
1146    fn test_remember_constructs_store_request_with_entity() {
1147        let server = make_server(TransportMode::Stdio, "claude", None);
1148        let params = RememberParams {
1149            content: "Alice manages the frontend team".into(),
1150            memory_type: Some("fact".into()),
1151            domain: Some("work".into()),
1152            entity: Some("Alice".into()),
1153            confidence: Some(0.9),
1154            supersedes: None,
1155            structured_fields: None,
1156            retrieval_cue: None,
1157        };
1158
1159        // Replicate remember_impl's request construction
1160        let source_agent = server.resolve_source_agent(None);
1161
1162        let req = StoreMemoryRequest {
1163            content: params.content,
1164            memory_type: params.memory_type,
1165            domain: params.domain,
1166            source_agent,
1167            title: None,
1168            confidence: params.confidence,
1169            supersedes: params.supersedes,
1170            entity: params.entity,
1171            entity_id: None,
1172            structured_fields: params.structured_fields.map(serde_json::Value::Object),
1173            retrieval_cue: params.retrieval_cue,
1174        };
1175
1176        let json = serde_json::to_value(&req).unwrap();
1177        assert_eq!(json["content"], "Alice manages the frontend team");
1178        assert_eq!(json["memory_type"], "fact");
1179        assert_eq!(json["domain"], "work");
1180        assert_eq!(json["entity"], "Alice");
1181        assert!(json["confidence"].as_f64().unwrap() > 0.89);
1182        // stdio mode: no param, no client_name → falls back to agent_name "claude"
1183        assert_eq!(json["source_agent"], "claude");
1184    }
1185
1186    #[test]
1187    fn test_remember_http_mode_injects_agent() {
1188        let server = make_server(TransportMode::Http, "claude.ai", Some("lucian"));
1189        let source_agent = server.resolve_source_agent(None);
1190
1191        assert_eq!(source_agent, Some("claude.ai".into()));
1192    }
1193
1194    // ===== Recall request construction =====
1195
1196    #[test]
1197    fn test_recall_constructs_search_request() {
1198        let params = RecallParams {
1199            query: "database choices".into(),
1200            limit: Some(5),
1201            memory_type: Some("decision".into()),
1202            domain: None,
1203        };
1204
1205        let req = SearchMemoryRequest {
1206            query: params.query,
1207            limit: params.limit.unwrap_or(10),
1208            memory_type: params.memory_type,
1209            domain: params.domain,
1210            source_agent: None,
1211        };
1212
1213        let json = serde_json::to_value(&req).unwrap();
1214        assert_eq!(json["query"], "database choices");
1215        assert_eq!(json["limit"], 5);
1216        assert_eq!(json["memory_type"], "decision");
1217        assert!(json.get("entity").is_none());
1218        assert!(json["domain"].is_null());
1219        assert!(json["source_agent"].is_null());
1220    }
1221
1222    // ===== Memory type backward compat =====
1223
1224    #[test]
1225    fn test_remember_passes_through_all_5_types() {
1226        for t in &["identity", "preference", "fact", "decision", "goal"] {
1227            let params = RememberParams {
1228                content: "test".into(),
1229                memory_type: Some(t.to_string()),
1230                domain: None,
1231                entity: None,
1232                confidence: None,
1233                supersedes: None,
1234                structured_fields: None,
1235                retrieval_cue: None,
1236            };
1237            assert_eq!(params.memory_type.as_deref(), Some(*t));
1238        }
1239    }
1240
1241    // ===== Structured fields in remember params =====
1242
1243    #[test]
1244    fn test_remember_params_with_structured_fields_and_cue() {
1245        let json = r#"{
1246            "content": "Lucian prefers dark mode",
1247            "structured_fields": {"theme":"dark"},
1248            "retrieval_cue": "What theme does Lucian prefer?"
1249        }"#;
1250        let params: RememberParams = serde_json::from_str(json).unwrap();
1251        let structured_fields = params.structured_fields.expect("structured_fields");
1252        assert_eq!(
1253            structured_fields.get("theme"),
1254            Some(&serde_json::Value::String("dark".into()))
1255        );
1256        assert_eq!(
1257            params.retrieval_cue.as_deref(),
1258            Some("What theme does Lucian prefer?")
1259        );
1260    }
1261
1262    #[test]
1263    fn test_store_request_with_structured_fields() {
1264        let req = StoreMemoryRequest {
1265            content: "test".into(),
1266            memory_type: Some("fact".into()),
1267            domain: None,
1268            source_agent: None,
1269            title: None,
1270            confidence: None,
1271            supersedes: None,
1272            entity: None,
1273            entity_id: None,
1274            structured_fields: Some(serde_json::json!({"key":"val"})),
1275            retrieval_cue: Some("What is the key?".into()),
1276        };
1277        let json = serde_json::to_value(&req).unwrap();
1278        assert_eq!(json["structured_fields"], serde_json::json!({"key":"val"}));
1279        assert_eq!(json["retrieval_cue"], "What is the key?");
1280    }
1281
1282    // ===== ChatContextResponse deserialization =====
1283
1284    #[test]
1285    fn test_chat_context_response() {
1286        let json = r#"{
1287            "context": "User prefers dark mode. Works on Origin project.",
1288            "profile": {
1289                "narrative": "narrative",
1290                "identity": [],
1291                "preferences": [],
1292                "goals": []
1293            },
1294            "knowledge": {
1295                "concepts": [],
1296                "decisions": [],
1297                "relevant_memories": [],
1298                "graph_context": []
1299            },
1300            "took_ms": 12.5,
1301            "token_estimates": {
1302                "tier1_identity": 1,
1303                "tier2_project": 2,
1304                "tier3_relevant": 3,
1305                "total": 6
1306            }
1307        }"#;
1308        let resp: ChatContextResponse = serde_json::from_str(json).unwrap();
1309        assert!(!resp.context.is_empty());
1310        assert!(resp.profile.identity.is_empty());
1311        assert_eq!(resp.took_ms, 12.5);
1312        assert_eq!(resp.token_estimates.total, 6);
1313    }
1314
1315    #[test]
1316    fn test_chat_context_response_empty() {
1317        let json = r#"{
1318            "context": "",
1319            "profile": {
1320                "narrative": "",
1321                "identity": [],
1322                "preferences": [],
1323                "goals": []
1324            },
1325            "knowledge": {
1326                "concepts": [],
1327                "decisions": [],
1328                "relevant_memories": [],
1329                "graph_context": []
1330            },
1331            "took_ms": 1.0,
1332            "token_estimates": {
1333                "tier1_identity": 0,
1334                "tier2_project": 0,
1335                "tier3_relevant": 0,
1336                "total": 0
1337            }
1338        }"#;
1339        let resp: ChatContextResponse = serde_json::from_str(json).unwrap();
1340        assert!(resp.context.is_empty());
1341    }
1342
1343    // ===== with_instructions content assertions =====
1344    // These tests lock in the refined agent-facing guidance. If any
1345    // assertion fails, either the rule was intentionally changed
1346    // (update the test) or the refinement was accidentally dropped
1347    // (restore the rule).
1348
1349    fn server_instructions() -> String {
1350        let s = make_server(TransportMode::Stdio, "test", None);
1351        s.get_info()
1352            .instructions
1353            .expect("server must ship with_instructions")
1354    }
1355
1356    #[test]
1357    fn instructions_mention_self_evolving_knowledge() {
1358        assert!(
1359            server_instructions().contains("self-evolving"),
1360            "with_instructions must describe Origin as self-evolving"
1361        );
1362    }
1363
1364    #[test]
1365    fn instructions_mention_shared_across_tools() {
1366        assert!(
1367            server_instructions().contains("shared across all"),
1368            "with_instructions must tell agents the store is shared across tools"
1369        );
1370    }
1371
1372    #[test]
1373    fn instructions_mention_how_user_thinks() {
1374        assert!(
1375            server_instructions().contains("how the user thinks"),
1376            "with_instructions must frame context as modeling how the user thinks"
1377        );
1378    }
1379
1380    #[test]
1381    fn instructions_use_proactive_framing() {
1382        assert!(
1383            server_instructions().contains("STORE PROACTIVELY"),
1384            "with_instructions must use STORE PROACTIVELY framing (not passive WHEN TO STORE)"
1385        );
1386    }
1387
1388    #[test]
1389    fn instructions_ban_tool_output_storage() {
1390        assert!(
1391            server_instructions().contains("Tool output or command results"),
1392            "with_instructions must explicitly rule out tool output as storage material"
1393        );
1394    }
1395
1396    #[test]
1397    fn instructions_ban_ghost_inferences() {
1398        assert!(
1399            server_instructions().contains("Your own inferences"),
1400            "with_instructions must rule out storing agent's own inferences user didn't express"
1401        );
1402    }
1403
1404    #[test]
1405    fn instructions_call_out_atomic_memory() {
1406        assert!(
1407            server_instructions().contains("Atomic: one idea per memory"),
1408            "with_instructions must call out the atomic-memory rule explicitly by name"
1409        );
1410    }
1411
1412    #[test]
1413    fn instructions_specify_declarative_writing() {
1414        assert!(
1415            server_instructions().contains("Declarative, not narrative"),
1416            "with_instructions must require declarative (not narrative) writing style"
1417        );
1418    }
1419
1420    #[test]
1421    fn instructions_default_to_omit_memory_type() {
1422        let i = server_instructions();
1423        assert!(
1424            i.contains("omit and trust the backend"),
1425            "with_instructions must default agents to omitting memory_type"
1426        );
1427        assert!(
1428            i.contains("do NOT set memory_type"),
1429            "with_instructions must explicitly say do NOT set memory_type by default"
1430        );
1431    }
1432
1433    #[test]
1434    fn instructions_carve_out_decisions_for_decision_log() {
1435        let i = server_instructions();
1436        assert!(
1437            i.contains("Decision Log"),
1438            "with_instructions must name the Decision Log as the reason for explicit decision typing"
1439        );
1440        assert!(
1441            i.contains("memory_type=\"decision\""),
1442            "with_instructions must tell agents to set memory_type=\"decision\" explicitly for decisions"
1443        );
1444    }
1445
1446    // ===== tool-level and param-level description assertions =====
1447
1448    fn tool_descriptions() -> std::collections::HashMap<String, String> {
1449        let server = make_server(TransportMode::Stdio, "test", None);
1450        server
1451            .tool_router
1452            .list_all()
1453            .into_iter()
1454            .filter_map(|t| {
1455                let desc = t.description.as_ref()?.to_string();
1456                Some((t.name.to_string(), desc))
1457            })
1458            .collect()
1459    }
1460
1461    #[test]
1462    fn remember_description_calls_out_atomic() {
1463        let descriptions = tool_descriptions();
1464        let remember = descriptions.get("remember").expect("remember tool exists");
1465        assert!(
1466            remember.contains("Each call is one atomic idea"),
1467            "remember description must call out atomic-per-call explicitly, got: {remember}"
1468        );
1469    }
1470
1471    #[test]
1472    fn context_description_frames_modeling_user() {
1473        let descriptions = tool_descriptions();
1474        let ctx = descriptions.get("context").expect("context tool exists");
1475        assert!(
1476            ctx.contains("how the user thinks"),
1477            "context description must frame the result as modeling how the user thinks, got: {ctx}"
1478        );
1479    }
1480
1481    #[test]
1482    fn recall_memory_type_param_lists_two_level_filter() {
1483        let params_schema = serde_json::to_string(&schemars::schema_for!(RecallParams))
1484            .expect("RecallParams schema serializes");
1485        assert!(
1486            params_schema.contains("Two-level filter"),
1487            "RecallParams.memory_type must advertise the two-level filter, got schema: {params_schema}"
1488        );
1489        assert!(
1490            params_schema.contains("profile"),
1491            "RecallParams.memory_type must mention profile alias"
1492        );
1493        assert!(
1494            params_schema.contains("knowledge"),
1495            "RecallParams.memory_type must mention knowledge alias"
1496        );
1497    }
1498}