1use super::{Tool, ToolContext};
19use crate::runtime::memory_context::{
20 DurableStatus, MemoryContextCapability, MemoryContextError, MemoryContextMode,
21 MemoryContextStatus, OneShotStatus,
22};
23use crate::runtime::memory_history::{
24 propose_history_import, CanonicalHistoryMetadataIo, HistoryImportHostState,
25};
26use crate::{Result, RuntimeError};
27use serde_json::{json, Value};
28
29pub struct MemoryContextTool;
31
32const SCHEMA_PROPERTIES: [&str; 4] = ["action", "mode", "capture_tools", "expires_minutes"];
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40enum MemoryContextRequest {
41 Enable,
43 Disable,
45 Status,
47 RecallOnce { expires_minutes: Option<u32> },
49 IndexHistory,
52}
53
54impl MemoryContextRequest {
55 fn action_name(self) -> &'static str {
57 match self {
58 MemoryContextRequest::Enable => "enable",
59 MemoryContextRequest::Disable => "disable",
60 MemoryContextRequest::Status => "status",
61 MemoryContextRequest::RecallOnce { .. } => "recall_once",
62 MemoryContextRequest::IndexHistory => "index_history",
63 }
64 }
65}
66
67fn parse_request(params: &Value) -> std::result::Result<MemoryContextRequest, String> {
70 let object = params
71 .as_object()
72 .ok_or_else(|| "memory_context parameters must be a JSON object".to_string())?;
73
74 if object
76 .keys()
77 .any(|key| !SCHEMA_PROPERTIES.contains(&key.as_str()))
78 {
79 return Err(
80 "memory_context rejects unknown parameters (additionalProperties is false)".to_string(),
81 );
82 }
83
84 let mode_supplied = match object.get("mode") {
89 None | Some(Value::Null) => false,
90 Some(Value::String(mode))
91 if matches!(
92 mode.as_str(),
93 "recall_each_prompt" | "capture_only" | "capture_and_recall"
94 ) =>
95 {
96 true
97 }
98 Some(_) => return Err("malformed 'mode': not a value from the schema enum".to_string()),
99 };
100 let capture_tools_supplied = match object.get("capture_tools") {
101 None | Some(Value::Null) => false,
102 Some(Value::Bool(_)) => true,
103 Some(_) => return Err("malformed 'capture_tools': expected a boolean".to_string()),
104 };
105 let expires_minutes = match object.get("expires_minutes") {
106 None | Some(Value::Null) => None,
107 Some(value) => {
108 let minutes = value
109 .as_u64()
110 .filter(|minutes| (1..=1440).contains(minutes))
111 .ok_or_else(|| {
112 "malformed 'expires_minutes': expected an integer in 1..=1440".to_string()
113 })?;
114 Some(u32::try_from(minutes).expect("bounded by 1440"))
115 }
116 };
117
118 let action = object
119 .get("action")
120 .and_then(Value::as_str)
121 .ok_or_else(|| "missing or malformed 'action'".to_string())?;
122
123 let reject_inapplicable = |action_name: &'static str| -> std::result::Result<(), String> {
126 if mode_supplied {
127 return Err(format!(
128 "'mode' is not applicable to action '{action_name}'"
129 ));
130 }
131 if capture_tools_supplied {
132 return Err(format!(
133 "'capture_tools' is not applicable to action '{action_name}'"
134 ));
135 }
136 Ok(())
137 };
138 let reject_expiry = |action_name: &'static str| -> std::result::Result<(), String> {
139 if expires_minutes.is_some() {
140 return Err(format!(
141 "'expires_minutes' is not applicable to action '{action_name}'"
142 ));
143 }
144 Ok(())
145 };
146
147 match action {
148 "enable" => Ok(MemoryContextRequest::Enable),
151 "disable" => {
152 reject_inapplicable("disable")?;
153 reject_expiry("disable")?;
154 Ok(MemoryContextRequest::Disable)
155 }
156 "status" => {
157 reject_inapplicable("status")?;
158 reject_expiry("status")?;
159 Ok(MemoryContextRequest::Status)
160 }
161 "recall_once" => {
162 reject_inapplicable("recall_once")?;
163 Ok(MemoryContextRequest::RecallOnce { expires_minutes })
164 }
165 "index_history" => {
166 reject_inapplicable("index_history")?;
167 reject_expiry("index_history")?;
168 Ok(MemoryContextRequest::IndexHistory)
169 }
170 _ => Err("unknown 'action': not a value from the schema enum".to_string()),
172 }
173}
174
175fn mode_label(mode: MemoryContextMode) -> &'static str {
177 match mode {
178 MemoryContextMode::Off => "off",
179 MemoryContextMode::RecallOnce => "recall_once",
180 MemoryContextMode::RecallEachPrompt => "recall_each_prompt",
181 MemoryContextMode::CaptureOnly => "capture_only",
182 MemoryContextMode::CaptureAndRecall => "capture_and_recall",
183 }
184}
185
186fn render_summary(action: &'static str, status: Option<&MemoryContextStatus>) -> String {
190 let (mode, one_shot, expires_at) = match status {
191 None => ("off", "idle", Value::Null),
192 Some(status) => {
193 let (mode, expires_at) = match &status.durable {
194 DurableStatus::Off => ("off", Value::Null),
195 DurableStatus::Active {
196 mode, expires_at, ..
197 } => (
198 mode_label(*mode),
199 expires_at
200 .and_then(|at| at.duration_since(std::time::UNIX_EPOCH).ok())
201 .map_or(Value::Null, |since| json!(since.as_secs())),
202 ),
203 };
204 let one_shot = match &status.one_shot {
205 OneShotStatus::Idle => "idle",
206 OneShotStatus::Pending { .. } => "pending",
207 OneShotStatus::Consumed { .. } => "consumed",
208 };
209 (mode, one_shot, expires_at)
210 }
211 };
212 json!({
213 "action": action,
214 "mode": mode,
215 "one_shot_recall": one_shot,
216 "project_digest": Value::Null,
218 "expires_at": expires_at,
219 })
220 .to_string()
221}
222
223fn typed_failure(error: MemoryContextError) -> RuntimeError {
224 RuntimeError::Tool(format!("memory_context denied: {error}"))
225}
226
227#[async_trait::async_trait]
228impl Tool for MemoryContextTool {
229 fn origin(&self) -> crate::tools::ToolOrigin {
230 crate::tools::ToolOrigin::Builtin
231 }
232
233 fn name(&self) -> &str {
234 "memory_context"
235 }
236
237 fn description(&self) -> &str {
238 "Control continuous-memory consent, not context-window token usage (use /context status for that). For status, disable, or index_history send only action; if all fields are required, set mode, capture_tools, and expires_minutes to null. Never fill unused options with defaults. 'status' reads consent; 'disable' revokes it. 'recall_once' requires a host-authorized capability; use /memory once when denied. 'enable' requires the deterministic /memory command. 'index_history' returns a host-computed metadata preview only; explicit frontend confirmation is still required and no import begins from a model tool call."
239 }
240
241 fn parameters(&self) -> Value {
242 json!({
245 "type": "object",
246 "additionalProperties": false,
247 "properties": {
248 "action": {
249 "type": "string",
250 "enum": ["enable", "disable", "status", "recall_once", "index_history"],
251 "description": "Memory-context action. index_history previews metadata only; import still requires explicit frontend confirmation."
252 },
253 "mode": {
254 "type": ["string", "null"],
255 "enum": ["recall_each_prompt", "capture_only", "capture_and_recall", null],
256 "description": "Enable proposals only. Omit or use null for every other action."
257 },
258 "capture_tools": {
259 "type": ["boolean", "null"],
260 "description": "Whether tool activity is eligible for capture (enable proposals only). Omit or use null for every other action; false is still a supplied value."
261 },
262 "expires_minutes": {
263 "type": ["integer", "null"],
264 "minimum": 1,
265 "maximum": 1440,
266 "description": "Bounded lease expiry for enable or recall_once only. Omit or use null otherwise."
267 }
268 },
269 "required": ["action"]
270 })
271 }
272
273 async fn execute(&self, params: Value, ctx: ToolContext) -> Result<String> {
274 let request = parse_request(¶ms).map_err(RuntimeError::Tool)?;
275 let capability: Option<&MemoryContextCapability> = ctx.capabilities.memory_context.as_ref();
276
277 match request {
278 MemoryContextRequest::Status => Ok(render_summary(
281 request.action_name(),
282 capability.map(MemoryContextCapability::status).as_ref(),
283 )),
284 MemoryContextRequest::Disable => Ok(render_summary(
287 request.action_name(),
288 capability.map(MemoryContextCapability::disable).as_ref(),
289 )),
290 MemoryContextRequest::RecallOnce { expires_minutes } => {
291 let capability = capability
292 .ok_or_else(|| typed_failure(MemoryContextError::CapabilityUnavailable))?;
293 let status = capability
294 .recall_once(expires_minutes)
295 .map_err(typed_failure)?;
296 Ok(render_summary(request.action_name(), Some(&status)))
297 }
298 MemoryContextRequest::IndexHistory => {
302 let host = match ctx
303 .capabilities
304 .memory_backend
305 .as_ref()
306 .filter(|b| b.exclusive())
307 {
308 Some(binding) => {
309 let scope = binding.scope().map_err(|_| {
310 typed_failure(MemoryContextError::ProviderNotRegistered)
311 })?;
312 let brain = binding
313 .brain_path()
314 .filter(|_| binding.is_axel())
315 .ok_or_else(|| {
316 typed_failure(MemoryContextError::ProviderNotRegistered)
317 })?;
318 Ok(HistoryImportHostState {
319 project_id: scope.key().to_owned(),
320 project_root: scope.root().to_path_buf(),
321 destination_r8_path: brain.to_path_buf(),
322 })
323 }
324 None => HistoryImportHostState::from_current_host(),
325 }
326 .map_err(|error| RuntimeError::Tool(error.to_string()))?;
327 let mut io = CanonicalHistoryMetadataIo::new();
328 let preview = propose_history_import(&host, &mut io)
329 .map_err(|error| RuntimeError::Tool(error.to_string()))?;
330 Ok(preview.render())
331 }
332 MemoryContextRequest::Enable => {
336 if capability.is_none() {
337 return Err(typed_failure(MemoryContextError::CapabilityUnavailable));
338 }
339 Err(typed_failure(MemoryContextError::RequiresHostConfirmation))
340 }
341 }
342 }
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348 use crate::runtime::memory_context::{
349 CapturePolicy, ContextProviderId, MemoryContextLease, MemoryLeaseId, ProjectId,
350 RecallPolicy, RequestId, SessionId, SessionMemoryState, UserIntentProof,
351 };
352 use crate::tools::test_helpers::create_tool_context;
353 use std::sync::{Arc, Mutex};
354 use std::time::SystemTime;
355
356 fn test_state() -> Arc<Mutex<SessionMemoryState>> {
357 Arc::new(Mutex::new(SessionMemoryState::new(
358 SessionId::parse("sess-tool").expect("valid session id"),
359 )))
360 }
361
362 fn capability_over(state: Arc<Mutex<SessionMemoryState>>) -> MemoryContextCapability {
363 MemoryContextCapability::new(
364 state,
365 ProjectId::parse("proj-tool").expect("valid project id"),
366 ContextProviderId::parse("axel-memory").expect("valid provider id"),
367 UserIntentProof::ExplicitCommand {
368 command_id: RequestId::parse("cmd-test").expect("valid request id"),
369 },
370 )
371 }
372
373 fn wired_context() -> (ToolContext, MemoryContextCapability) {
376 let capability = capability_over(test_state());
377 let mut ctx = create_tool_context();
378 ctx.capabilities.memory_context = Some(capability.clone());
379 (ctx, capability)
380 }
381
382 fn assert_fully_off(capability: &MemoryContextCapability) {
383 let status = capability.status();
384 assert_eq!(status.durable, DurableStatus::Off);
385 assert_eq!(status.one_shot, OneShotStatus::Idle);
386 }
387
388 fn parsed(output: &str) -> Value {
389 serde_json::from_str(output).expect("tool output is valid JSON")
390 }
391
392 #[test]
395 fn null_options_are_equivalent_to_omission_for_every_action() {
396 for action in [
397 "status",
398 "disable",
399 "recall_once",
400 "index_history",
401 "enable",
402 ] {
403 assert_eq!(
404 parse_request(&json!({"action": action})),
405 parse_request(&json!({
406 "action": action,
407 "mode": null,
408 "capture_tools": null,
409 "expires_minutes": null
410 })),
411 "null options must mean omission for {action}"
412 );
413 }
414 assert_eq!(
415 parse_request(&json!({
416 "action": "recall_once", "mode": null,
417 "capture_tools": null, "expires_minutes": 5
418 })),
419 Ok(MemoryContextRequest::RecallOnce {
420 expires_minutes: Some(5)
421 })
422 );
423 }
424
425 #[test]
426 fn null_does_not_relax_required_or_unknown_fields() {
427 for params in [
428 json!({"action": null, "mode": null}),
429 json!({"mode": null, "capture_tools": null, "expires_minutes": null}),
430 json!({"action": "status", "surprise_grant": null}),
431 ] {
432 assert!(parse_request(¶ms).is_err());
433 }
434 }
435
436 #[test]
437 fn every_inapplicable_non_null_option_still_fails_closed() {
438 for action in ["status", "disable", "recall_once", "index_history"] {
439 for mode in ["recall_each_prompt", "capture_only", "capture_and_recall"] {
440 let error = parse_request(&json!({"action": action, "mode": mode})).unwrap_err();
441 assert!(error.contains("not applicable"), "{error}");
442 }
443 for capture_tools in [false, true] {
444 let error = parse_request(&json!({
445 "action": action, "mode": null, "capture_tools": capture_tools
446 }))
447 .unwrap_err();
448 assert!(error.contains("not applicable"), "{error}");
449 }
450 }
451 for action in ["status", "disable", "index_history"] {
452 let error = parse_request(&json!({
453 "action": action, "mode": null, "capture_tools": null,
454 "expires_minutes": 60
455 }))
456 .unwrap_err();
457 assert!(error.contains("not applicable"), "{error}");
458 }
459 }
460
461 #[tokio::test]
462 async fn nullable_status_is_read_only_and_enable_still_needs_consent() {
463 let (ctx, capability) = wired_context();
464 capability.recall_once(Some(5)).unwrap();
466 let before = capability.status();
467 let output = MemoryContextTool
468 .execute(
469 json!({
470 "action": "status", "mode": null, "capture_tools": null,
471 "expires_minutes": null
472 }),
473 ctx,
474 )
475 .await
476 .unwrap();
477 assert_eq!(parsed(&output)["one_shot_recall"], "pending");
478 assert_eq!(capability.status(), before);
479
480 let (ctx, capability) = wired_context();
481 let error = MemoryContextTool
482 .execute(
483 json!({
484 "action": "enable", "mode": null, "capture_tools": null,
485 "expires_minutes": null
486 }),
487 ctx,
488 )
489 .await
490 .unwrap_err()
491 .to_string();
492 assert!(error.contains("host confirmation"), "{error}");
493 assert_fully_off(&capability);
494 }
495
496 #[tokio::test]
497 async fn status_null_options_without_capability_remains_available() {
498 let output = MemoryContextTool
499 .execute(
500 json!({
501 "action": "status", "mode": null, "capture_tools": null,
502 "expires_minutes": null
503 }),
504 create_tool_context(),
505 )
506 .await
507 .unwrap();
508 assert_eq!(parsed(&output)["mode"], "off");
509 }
510
511 #[tokio::test]
512 async fn nullable_options_cannot_grant_through_control_only_capability() {
513 for action in ["enable", "recall_once"] {
514 let state = test_state();
515 let capability = MemoryContextCapability::control_only(
516 state,
517 ProjectId::parse("proj-tool").unwrap(),
518 ContextProviderId::parse("axel-host").unwrap(),
519 Arc::new(|| panic!("a denied grant must not invoke host revocation")),
520 );
521 let mut ctx = create_tool_context();
522 ctx.capabilities.memory_context = Some(capability.clone());
523 let error = MemoryContextTool
524 .execute(
525 json!({
526 "action": action, "mode": null, "capture_tools": null,
527 "expires_minutes": null
528 }),
529 ctx,
530 )
531 .await
532 .unwrap_err()
533 .to_string();
534 assert!(error.contains("host confirmation"), "{error}");
535 assert_fully_off(&capability);
536 }
537 }
538
539 #[tokio::test]
540 async fn observed_invalid_status_calls_leave_active_consent_unchanged() {
541 let state = test_state();
542 state
543 .lock()
544 .unwrap()
545 .install(
546 MemoryContextLease::grant(
547 MemoryLeaseId::parse("lease-status-regression").unwrap(),
548 SessionId::parse("sess-tool").unwrap(),
549 ProjectId::parse("proj-tool").unwrap(),
550 ContextProviderId::parse("axel-memory").unwrap(),
551 MemoryContextMode::CaptureAndRecall,
552 CapturePolicy::default(),
553 RecallPolicy::default(),
554 UserIntentProof::ExplicitCommand {
555 command_id: RequestId::parse("cmd-host").unwrap(),
556 },
557 SystemTime::now(),
558 None,
559 )
560 .unwrap(),
561 )
562 .unwrap();
563 let capability = capability_over(state);
564 let before = capability.status();
565 for mode in ["capture_and_recall", "capture_only"] {
566 let mut ctx = create_tool_context();
567 ctx.capabilities.memory_context = Some(capability.clone());
568 MemoryContextTool
569 .execute(
570 json!({
571 "action": "status", "capture_tools": false,
572 "expires_minutes": 60, "mode": mode
573 }),
574 ctx,
575 )
576 .await
577 .expect_err("observed invalid status call must fail");
578 assert_eq!(capability.status(), before);
579 }
580 let mut ctx = create_tool_context();
581 ctx.capabilities.memory_context = Some(capability.clone());
582 let output = MemoryContextTool
583 .execute(
584 json!({
585 "action": "status", "mode": null, "capture_tools": null,
586 "expires_minutes": null
587 }),
588 ctx,
589 )
590 .await
591 .unwrap();
592 assert_eq!(parsed(&output)["mode"], "capture_and_recall");
593 assert_eq!(capability.status(), before);
594 }
595
596 #[tokio::test]
597 async fn all_required_provider_shape_can_execute_status_through_registry() {
598 use crate::runtime::openai::translate::tools_to_oai;
599 use crate::tools::registry::ToolRegistry;
600
601 let mut registry = ToolRegistry::empty();
602 registry.register(Arc::new(MemoryContextTool));
603 let (mut tools, _) = tools_to_oai(®istry.tools_schema());
604 let tool = &mut tools[0].function;
605 let required: Vec<String> = tool.parameters["properties"]
608 .as_object()
609 .unwrap()
610 .keys()
611 .cloned()
612 .collect();
613 tool.parameters["required"] = json!(required);
614 let args: Value = serde_json::from_str(
615 r#"{"action":"status","mode":null,"capture_tools":null,"expires_minutes":null}"#,
616 )
617 .unwrap();
618 for name in required {
619 assert!(args.get(&name).is_some(), "missing required {name}");
620 if name != "action" {
621 let property = &tool.parameters["properties"][&name];
622 assert!(property["type"]
623 .as_array()
624 .unwrap()
625 .contains(&json!("null")));
626 if let Some(values) = property.get("enum") {
627 assert!(values.as_array().unwrap().contains(&Value::Null));
628 }
629 }
630 }
631 let args = registry.translate_input_for_api_tool(&tool.name, args);
632 let output = MemoryContextTool
633 .execute(args, create_tool_context())
634 .await
635 .unwrap();
636 assert_eq!(parsed(&output)["action"], "status");
637 }
638
639 #[test]
640 fn registry_and_provider_schemas_preserve_nullable_options() {
641 use crate::runtime::google_gemini::runtime::translate_tool_schemas;
642 use crate::runtime::openai::translate::tools_to_oai;
643 use crate::tools::registry::ToolRegistry;
644
645 let mut registry = ToolRegistry::empty();
646 registry.register(Arc::new(MemoryContextTool));
647 let schema = registry.tools_schema();
648 assert_eq!(schema.len(), 1);
649 let parameters = &schema[0]["input_schema"];
650 assert_eq!(parameters, &MemoryContextTool.parameters());
651 assert_eq!(parameters["required"], json!(["action"]));
652 assert_eq!(parameters["additionalProperties"], false);
653 let props = ¶meters["properties"];
654 assert_eq!(props["action"]["type"], "string");
655 assert_eq!(props["mode"]["type"], json!(["string", "null"]));
656 assert!(props["mode"]["enum"]
657 .as_array()
658 .unwrap()
659 .contains(&Value::Null));
660 assert_eq!(props["capture_tools"]["type"], json!(["boolean", "null"]));
661 assert_eq!(props["expires_minutes"]["type"], json!(["integer", "null"]));
662 assert_eq!(props["expires_minutes"]["minimum"], 1);
663 assert_eq!(props["expires_minutes"]["maximum"], 1440);
664
665 let (oai_tools, _) = tools_to_oai(&schema);
667 assert_eq!(oai_tools[0].function.parameters, *parameters);
668 let gemini_tools = translate_tool_schemas(&schema);
669 assert_eq!(
670 gemini_tools[0].parameters_json_schema.as_ref(),
671 Some(parameters)
672 );
673 }
674
675 #[tokio::test]
678 async fn forged_enable_is_denied_and_installs_no_lease() {
679 let (ctx, capability) = wired_context();
680
681 let error = MemoryContextTool
682 .execute(
683 json!({"action": "enable", "mode": "capture_and_recall", "capture_tools": true, "expires_minutes": 60}),
684 ctx,
685 )
686 .await
687 .unwrap_err()
688 .to_string();
689
690 assert!(error.contains("host confirmation"), "{error}");
691 assert_fully_off(&capability);
692 }
693
694 #[tokio::test]
695 async fn model_index_history_returns_preview_but_cannot_confirm() {
696 let (ctx, capability) = wired_context();
697
698 let preview = MemoryContextTool
699 .execute(json!({"action": "index_history"}), ctx)
700 .await
701 .expect("model proposal receives metadata-only preview");
702
703 assert!(preview.contains("History import preview"), "{preview}");
704 assert!(
705 preview.contains("explicit confirmation required: true"),
706 "{preview}"
707 );
708 assert!(preview.contains("no import has started"), "{preview}");
709 assert_fully_off(&capability);
710 }
711
712 #[tokio::test]
713 async fn enable_without_capability_fails_typed_unavailable() {
714 let error = MemoryContextTool
715 .execute(json!({"action": "enable"}), create_tool_context())
716 .await
717 .unwrap_err()
718 .to_string();
719
720 assert!(error.contains("unavailable in this context"), "{error}");
721 }
722
723 #[tokio::test]
726 async fn status_without_capability_returns_off_without_erroring() {
727 let output = MemoryContextTool
728 .execute(json!({"action": "status"}), create_tool_context())
729 .await
730 .expect("status must never error: Off requires no infrastructure");
731
732 let output = parsed(&output);
733 assert_eq!(output["action"], "status");
734 assert_eq!(output["mode"], "off");
735 assert_eq!(output["one_shot_recall"], "idle");
736 assert_eq!(output["project_digest"], Value::Null);
737 assert_eq!(output["expires_at"], Value::Null);
738 }
739
740 #[tokio::test]
741 async fn status_with_capability_reports_current_state() {
742 let (ctx, _capability) = wired_context();
743
744 let output = MemoryContextTool
745 .execute(json!({"action": "status"}), ctx)
746 .await
747 .unwrap();
748
749 let output = parsed(&output);
750 assert_eq!(output["mode"], "off");
751 assert_eq!(output["one_shot_recall"], "idle");
752 }
753
754 #[tokio::test]
757 async fn disable_without_capability_is_an_idempotent_off_noop() {
758 for _ in 0..2 {
759 let output = MemoryContextTool
760 .execute(json!({"action": "disable"}), create_tool_context())
761 .await
762 .expect("disable is always locally allowed");
763 let output = parsed(&output);
764 assert_eq!(output["action"], "disable");
765 assert_eq!(output["mode"], "off");
766 assert_eq!(output["one_shot_recall"], "idle");
767 }
768 }
769
770 #[tokio::test]
771 async fn disable_revokes_an_active_lease_and_stays_idempotent() {
772 let state = test_state();
773 let lease = MemoryContextLease::grant(
774 MemoryLeaseId::parse("lease-durable").unwrap(),
775 SessionId::parse("sess-tool").unwrap(),
776 ProjectId::parse("proj-tool").unwrap(),
777 ContextProviderId::parse("axel-memory").unwrap(),
778 MemoryContextMode::CaptureAndRecall,
779 CapturePolicy::default(),
780 RecallPolicy::default(),
781 UserIntentProof::ExplicitCommand {
782 command_id: RequestId::parse("cmd-host").unwrap(),
783 },
784 SystemTime::now(),
785 None,
786 )
787 .unwrap();
788 state.lock().unwrap().install(lease).unwrap();
789
790 let capability = capability_over(state);
791 assert_eq!(
792 capability.status().durable,
793 DurableStatus::Active {
794 mode: MemoryContextMode::CaptureAndRecall,
795 lease_id: MemoryLeaseId::parse("lease-durable").unwrap(),
796 expires_at: None,
797 }
798 );
799
800 for _ in 0..2 {
801 let mut ctx = create_tool_context();
802 ctx.capabilities.memory_context = Some(capability.clone());
803 let output = MemoryContextTool
804 .execute(json!({"action": "disable"}), ctx)
805 .await
806 .unwrap();
807 assert_eq!(parsed(&output)["mode"], "off");
808 assert_eq!(capability.status().durable, DurableStatus::Off);
809 }
810 }
811
812 #[tokio::test]
815 async fn recall_once_installs_exactly_one_pending_one_shot() {
816 let (ctx, capability) = wired_context();
817
818 let output = MemoryContextTool
819 .execute(json!({"action": "recall_once"}), ctx)
820 .await
821 .unwrap();
822
823 let output = parsed(&output);
824 assert_eq!(output["action"], "recall_once");
825 assert_eq!(
826 output["mode"], "off",
827 "one-shot never occupies the durable slot"
828 );
829 assert_eq!(output["one_shot_recall"], "pending");
830 assert!(matches!(
831 capability.status().one_shot,
832 OneShotStatus::Pending { .. }
833 ));
834
835 let mut ctx = create_tool_context();
838 ctx.capabilities.memory_context = Some(capability.clone());
839 let error = MemoryContextTool
840 .execute(json!({"action": "recall_once"}), ctx)
841 .await
842 .unwrap_err()
843 .to_string();
844 assert!(error.contains("already pending"), "{error}");
845 assert!(matches!(
846 capability.status().one_shot,
847 OneShotStatus::Pending { .. }
848 ));
849 }
850
851 #[tokio::test]
852 async fn recall_once_accepts_a_bounded_expiry() {
853 let (ctx, capability) = wired_context();
854
855 let output = MemoryContextTool
856 .execute(json!({"action": "recall_once", "expires_minutes": 5}), ctx)
857 .await
858 .unwrap();
859
860 assert_eq!(parsed(&output)["one_shot_recall"], "pending");
861 assert!(matches!(
862 capability.status().one_shot,
863 OneShotStatus::Pending { .. }
864 ));
865 }
866
867 #[tokio::test]
868 async fn recall_once_without_capability_fails_typed() {
869 let error = MemoryContextTool
870 .execute(json!({"action": "recall_once"}), create_tool_context())
871 .await
872 .unwrap_err()
873 .to_string();
874
875 assert!(error.contains("unavailable in this context"), "{error}");
876 }
877
878 #[tokio::test]
881 async fn unknown_action_fails_closed_without_echoing_input() {
882 let (ctx, capability) = wired_context();
883
884 let error = MemoryContextTool
885 .execute(json!({"action": "hijack_everything"}), ctx)
886 .await
887 .unwrap_err()
888 .to_string();
889
890 assert!(error.contains("unknown 'action'"), "{error}");
891 assert!(
892 !error.contains("hijack_everything"),
893 "raw model input must never be echoed back: {error}"
894 );
895 assert_fully_off(&capability);
896 }
897
898 #[tokio::test]
899 async fn malformed_action_and_missing_action_fail_closed() {
900 for params in [
901 json!({}),
902 json!({"action": 5}),
903 json!({"action": null}),
904 json!([]),
905 ] {
906 let (ctx, capability) = wired_context();
907 let error = MemoryContextTool
908 .execute(params, ctx)
909 .await
910 .unwrap_err()
911 .to_string();
912 assert!(
913 error.contains("'action'") || error.contains("JSON object"),
914 "{error}"
915 );
916 assert_fully_off(&capability);
917 }
918 }
919
920 #[tokio::test]
921 async fn extra_properties_fail_closed() {
922 let (ctx, capability) = wired_context();
923
924 let error = MemoryContextTool
925 .execute(json!({"action": "status", "surprise_grant": true}), ctx)
926 .await
927 .unwrap_err()
928 .to_string();
929
930 assert!(error.contains("unknown parameters"), "{error}");
931 assert!(
932 !error.contains("surprise_grant"),
933 "raw model input must never be echoed back: {error}"
934 );
935 assert_fully_off(&capability);
936 }
937
938 #[tokio::test]
939 async fn inapplicable_and_out_of_range_parameters_fail_closed() {
940 let cases = [
941 json!({"action": "disable", "mode": "capture_only"}),
942 json!({"action": "status", "expires_minutes": 5}),
943 json!({"action": "recall_once", "capture_tools": true}),
944 json!({"action": "recall_once", "mode": "recall_each_prompt"}),
945 json!({"action": "recall_once", "expires_minutes": 0}),
946 json!({"action": "recall_once", "expires_minutes": 1441}),
947 json!({"action": "recall_once", "expires_minutes": "5"}),
948 json!({"action": "recall_once", "expires_minutes": 5.5}),
949 json!({"action": "enable", "mode": "off"}),
950 json!({"action": "enable", "capture_tools": "yes"}),
951 ];
952 for params in cases {
953 let (ctx, capability) = wired_context();
954 MemoryContextTool
955 .execute(params.clone(), ctx)
956 .await
957 .expect_err(&format!("must fail closed: {params}"));
958 assert_fully_off(&capability);
959 }
960 }
961}