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