Skip to main content

agent_engine/tools/
memory_context.rs

1//! `memory_context` control tool (task A4, spec §7.2).
2//!
3//! Scope: `disable` and `status` are locally safe/revocable. `recall_once`
4//! additionally needs a host-authorized capability (Axel wires control-only).
5//! `enable` requires
6//! deterministic host-owned proof of user intent (`ExplicitCommand` from the
7//! `/memory` frontend command, task A5) that a model tool call cannot supply
8//! through JSON parameters alone, so it fails with the typed
9//! [`MemoryContextError::RequiresHostConfirmation`] refusal. `index_history`
10//! returns the host-computed D1 disclosure preview, but a model call remains
11//! only a proposal and cannot confirm or begin import.
12//!
13//! Boundary discipline: raw JSON arguments are parsed into the typed
14//! [`MemoryContextRequest`] here at the boundary — malformed values, unknown
15//! actions, schema-foreign properties, and action-inapplicable parameters all
16//! fail closed with static messages that never echo raw model input.
17
18use 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
29/// Control the continuous-memory context of the current session (spec §7.2).
30pub struct MemoryContextTool;
31
32/// The complete, closed set of schema properties (spec §7.2,
33/// `additionalProperties: false`). Enforced manually because provider-side
34/// schema validation is advisory, never trusted.
35const SCHEMA_PROPERTIES: [&str; 4] = ["action", "mode", "capture_tools", "expires_minutes"];
36
37/// Typed, parse-at-the-boundary form of one tool invocation. Only data that
38/// survived validation exists past this point — no raw JSON leaks through.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40enum MemoryContextRequest {
41    /// Proposal for a durable session lease — always refused (host-only).
42    Enable,
43    /// Revoke this session's memory context (always locally allowed).
44    Disable,
45    /// Metadata-only status snapshot (never spawns a provider).
46    Status,
47    /// Grant a one-shot recall lease, optionally with a bounded expiry.
48    RecallOnce { expires_minutes: Option<u32> },
49    /// Metadata-only proposal to index prior history. Confirmation/import
50    /// require separate host-owned consent (spec §7.2).
51    IndexHistory,
52}
53
54impl MemoryContextRequest {
55    /// Static action name for the typed response — never the raw input.
56    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
67/// Parse raw model JSON into a [`MemoryContextRequest`]. Every failure is a
68/// static, content-free message: raw model input is never echoed back.
69fn 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    // additionalProperties: false — schema-foreign keys fail closed.
75    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    // Optional fields may be explicit null: strict provider schemas can require
85    // every property, so null is the wire spelling of an omitted option. Only
86    // these three fields accept null; action/unknown keys still fail closed.
87    // Validate every non-null known property's type/range regardless of action.
88    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    // Reject parameters inapplicable to the requested action — a fail-closed
124    // strictness on top of the schema's flat property list.
125    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` accepts the full parameter surface (mode/capture_tools/
149        // expires_minutes) per the schema; it is refused later regardless.
150        "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        // Static message only — the raw action value is never echoed.
171        _ => Err("unknown 'action': not a value from the schema enum".to_string()),
172    }
173}
174
175/// Static mode label for the bounded response summary.
176fn 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
186/// Render the bounded JSON status summary (mode, project digest placeholder,
187/// expiry). `None` means no capability is wired: memory is deterministically
188/// `Off` — `Off` requires no infrastructure (task A4 scope).
189fn 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        // Placeholder until task A5 wires the host-computed project digest.
217        "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        // Flat schema stays compatible with provider strictification: optional
243        // fields explicitly accept null, and parse_request treats it as omission.
244        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(&params).map_err(RuntimeError::Tool)?;
275        let capability: Option<&MemoryContextCapability> = ctx.capabilities.memory_context.as_ref();
276
277        match request {
278            // `status` is deterministic even with no capability wired: memory
279            // is `Off`, and `Off` requires no infrastructure.
280            MemoryContextRequest::Status => Ok(render_summary(
281                request.action_name(),
282                capability.map(MemoryContextCapability::status).as_ref(),
283            )),
284            // `disable` is always locally allowed and idempotent; with no
285            // capability there is nothing to revoke — already `Off`.
286            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            // A model may request the host-owned preview, but the request is
299            // only a proposal: this branch never receives a confirmation proof
300            // and cannot begin import.
301            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            // Durable enable needs deterministic host-owned proof
333            // (ExplicitCommand from /memory, task A5). A tool call cannot carry
334            // it — typed refusal, no lease installed, nothing mutated.
335            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    /// Tool context with the capability wired; also returns the capability
374    /// so tests can observe the shared session state.
375    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    // ── nullable optional parameters / provider interoperability ────────────
393
394    #[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(&params).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        // A pending one-shot must remain pending after status, not be consumed.
465        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(&registry.tools_schema());
604        let tool = &mut tools[0].function;
605        // Simulate the all-required shape observed by the model. Each unused
606        // property must have a schema-valid omission representation (null).
607        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 = &parameters["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        // Chat Completions and both Responses routes share tools_to_oai.
666        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    // ── forged enable / index_history ───────────────────────────────────────
676
677    #[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    // ── status ──────────────────────────────────────────────────────────────
724
725    #[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    // ── disable ─────────────────────────────────────────────────────────────
755
756    #[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    // ── recall_once ─────────────────────────────────────────────────────────
813
814    #[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        // A second grant while one is pending fails typed and leaves the
836        // original pending lease in place.
837        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    // ── fail-closed parsing ─────────────────────────────────────────────────
879
880    #[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}