Skip to main content

agent_engine/engine/
commands.rs

1//! Engine-level command results — TUI-agnostic outcomes of slash commands.
2//!
3//! The engine processes a command and returns a `CommandResult`.
4//! Renderers (TUI, headless) decide how to display the result.
5
6use agent_core::reasoning::ReasoningLevel;
7
8/// A thinking specification — either a named level or a custom numeric budget.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum ThinkingSpec {
11    /// A named canonical level (off/adaptive/low/medium/high/xhigh/max/ultra).
12    Named(ReasoningLevel),
13    /// A custom numeric budget (e.g. `/thinking 8192`).
14    /// `level` is the nearest named level for display; `budget` is the exact value.
15    Custom { level: ReasoningLevel, budget: u32 },
16}
17
18impl ThinkingSpec {
19    /// Named level (used for validation and display).
20    pub fn level(self) -> ReasoningLevel {
21        match self {
22            ThinkingSpec::Named(l) => l,
23            ThinkingSpec::Custom { level, .. } => level,
24        }
25    }
26
27    /// Exact budget, if applicable. `None` for Max/Ultra which have no numeric budget.
28    pub fn budget(self) -> Option<u32> {
29        match self {
30            ThinkingSpec::Named(l) => l.to_legacy_budget(),
31            ThinkingSpec::Custom { budget, .. } => Some(budget),
32        }
33    }
34
35    /// Config-file string: exact budget digits for Custom, named string otherwise.
36    pub fn config_value(self) -> String {
37        match self {
38            ThinkingSpec::Named(l) => l.as_str().to_string(),
39            ThinkingSpec::Custom { budget, .. } => budget.to_string(),
40        }
41    }
42}
43
44/// Result of processing a slash command in the engine.
45#[derive(Debug, Clone)]
46pub enum CommandResult {
47    /// No output, continue.
48    None,
49
50    /// Text output to display to the user.
51    Output(String),
52
53    /// Error message.
54    Error(String),
55
56    /// Model was changed. `reasoning_clamped` is populated when the runtime
57    /// had to substitute the active reasoning level because the new model does
58    /// not support it (see `Runtime::try_set_model`).
59    ModelChanged {
60        model: String,
61        reasoning_clamped: Option<crate::runtime::ReasoningClamp>,
62    },
63
64    /// Thinking level was changed.
65    ThinkingChanged {
66        spec: ThinkingSpec,
67    },
68
69    /// System prompt was updated.
70    SystemPromptSet {
71        source: String, // "inline", "file", "saved"
72    },
73
74    /// System prompt displayed.
75    SystemPromptShow {
76        prompt: String,
77    },
78
79    /// Session list.
80    SessionList {
81        sessions: Vec<SessionSummary>,
82    },
83
84    /// Session cleared. New session returned.
85    Cleared,
86
87    /// Quit requested.
88    Quit,
89
90    /// Compaction requested (engine should trigger it).
91    Compact {
92        custom_instructions: Option<String>,
93    },
94
95    /// Session resumed.
96    Resumed {
97        session_id: String,
98        model: String,
99    },
100
101    /// Session named/saved.
102    Named {
103        name: String,
104    },
105
106    /// Chain info.
107    ChainInfo(String),
108
109    /// Request to open a TUI-specific modal (TUI handles, headless ignores).
110    OpenModal(ModalRequest),
111
112    /// Status/usage info.
113    Status {
114        text: String,
115    },
116
117    /// Ping results.
118    PingStarted,
119
120    /// Keybind list.
121    KeybindList(String),
122
123    /// Skill loaded — needs to be injected into the conversation.
124    SkillLoaded {
125        skill: std::sync::Arc<crate::skills::LoadedSkill>,
126        arg: String,
127    },
128
129    /// Plugin command to execute.
130    PluginCommand {
131        command: std::sync::Arc<crate::skills::registry::RegisteredPluginCommand>,
132        arg: String,
133    },
134
135    /// Sidecar toggle/status.
136    SidecarToggle {
137        plugin_id: Option<String>,
138    },
139    SidecarStatus {
140        plugin_id: Option<String>,
141    },
142}
143
144/// TUI-specific modals the engine can request.
145#[derive(Debug, Clone)]
146pub enum ModalRequest {
147    Models,
148    Settings,
149    Plugins,
150    HelpFind { query: String },
151    Extensions { sub: String },
152}
153
154/// Summary of a session for listing.
155#[derive(Debug, Clone)]
156pub struct SessionSummary {
157    pub id: String,
158    pub model: String,
159    pub title: Option<String>,
160    pub cost: f64,
161    pub message_count: usize,
162    pub is_current: bool,
163}
164
165/// Parse a slash command into (command, arg).
166pub fn parse_command(input: &str) -> Option<(&str, &str)> {
167    let trimmed = input.trim();
168    if !trimmed.starts_with('/') {
169        return None;
170    }
171    let without_slash = &trimmed[1..];
172    let (cmd, arg) = match without_slash.find(char::is_whitespace) {
173        Some(pos) => (&without_slash[..pos], without_slash[pos..].trim()),
174        None => (without_slash, ""),
175    };
176    Some((cmd, arg))
177}
178
179/// Process commands that are pure engine logic — no TUI state needed.
180/// Returns None if the command needs TUI-level handling.
181pub fn handle_engine_command(
182    cmd: &str,
183    arg: &str,
184    runtime: &mut crate::Runtime,
185) -> Option<CommandResult> {
186    // Runtime-backed commands (Task 12): shared across TUI, headless chat,
187    // and server. `/context` here has no conversation history (the surface
188    // owns it) — the TUI intercepts `/context` earlier and passes its own
189    // history through `context_command`.
190    match cmd {
191        "context" if !arg.is_empty() => {
192            return Some(match runtime.context_management_command(arg) {
193                Ok(s) => CommandResult::Output(s),
194                Err(e) => CommandResult::Error(e),
195            })
196        }
197        "context" => return Some(context_command(runtime, None)),
198        "trace" => return Some(trace_command(arg, runtime)),
199        "budget" => return Some(budget_command(arg, runtime)),
200        "memory" => return Some(memory_command(arg, runtime)),
201        _ => {}
202    }
203    let mut result = evaluate_engine_command(cmd, arg)?;
204    match &mut result {
205        CommandResult::ModelChanged {
206            model,
207            reasoning_clamped,
208        } => *reasoning_clamped = runtime.set_model(model.clone()),
209        CommandResult::ThinkingChanged { spec } => {
210            // Validate and apply the spec against model capabilities BEFORE
211            // mutating runtime. State is unchanged on Err.
212            match spec {
213                ThinkingSpec::Named(level) => {
214                    if let Err(msg) = runtime.set_reasoning_level_checked(*level) {
215                        return Some(CommandResult::Error(msg));
216                    }
217                }
218                ThinkingSpec::Custom { budget, .. } => {
219                    // Custom budget: validate the derived level against the
220                    // exact-model capability tables BEFORE mutating; on Ok the
221                    // exact budget is retained (Anthropic wire uses it as-is).
222                    if let Err(msg) = runtime.set_thinking_budget_checked(*budget) {
223                        return Some(CommandResult::Error(msg));
224                    }
225                }
226            }
227        }
228        _ => {}
229    }
230    Some(result)
231}
232
233/// Pure command → result mapping (no runtime mutation).
234pub fn evaluate_engine_command(cmd: &str, arg: &str) -> Option<CommandResult> {
235    match cmd {
236        "model" | "models" if !arg.is_empty() => Some(CommandResult::ModelChanged {
237            model: arg.to_string(),
238            reasoning_clamped: None,
239        }),
240        "thinking" if !arg.is_empty() => match parse_thinking_arg(arg) {
241            Ok(spec) => Some(CommandResult::ThinkingChanged { spec }),
242            Err(e) => Some(CommandResult::Error(e)),
243        },
244        "quit" | "exit" => Some(CommandResult::Quit),
245        "compact" => Some(CommandResult::Compact {
246            custom_instructions: if arg.is_empty() {
247                None
248            } else {
249                Some(arg.to_string())
250            },
251        }),
252        _ => None,
253    }
254}
255
256/// Parse a `/thinking` argument into a `ThinkingSpec`.
257pub fn parse_thinking_arg(arg: &str) -> Result<ThinkingSpec, String> {
258    match ReasoningLevel::parse(arg) {
259        Some(level) => Ok(ThinkingSpec::Named(level)),
260        None => {
261            if let Ok(n) = arg.trim().parse::<u32>() {
262                // Custom numeric budget: bucketize to nearest named level for display.
263                Ok(ThinkingSpec::Custom {
264                    level: ReasoningLevel::from_legacy_budget(n),
265                    budget: n,
266                })
267            } else {
268                Err(format!(
269                    "unknown thinking level: {} \
270                     (use off/adaptive/low/medium/high/xhigh/max/ultra/ultracode or a number)",
271                    arg
272                ))
273            }
274        }
275    }
276}
277
278/// Canonical config-file string for a thinking change.
279/// Returns the exact budget digits for Custom, named string for Named.
280pub fn thinking_config_value(spec: ThinkingSpec) -> String {
281    spec.config_value()
282}
283
284/// `/context` (Task 12, spec §6.6 acceptance): structured metadata-only
285/// report — system/tool/history counts and bytes, latest cache component
286/// change and reuse estimates, writer counters. Surfaces that own the
287/// conversation pass it as `history`; others get honest `unavailable`.
288pub fn context_command(
289    runtime: &crate::Runtime,
290    history: Option<&[crate::SharedMessage]>,
291) -> CommandResult {
292    CommandResult::Output(format!(
293        "{}\n{}",
294        runtime.context_report(history).render(),
295        runtime.context_management_status()
296    ))
297}
298
299/// Explicit operator control of future turns. No config writes, inference,
300/// history mutation, or changes to an already-running stream's budget snapshot.
301/// This is a frontend slash command, never a model-callable budget renewal.
302pub fn budget_command(arg: &str, runtime: &mut crate::Runtime) -> CommandResult {
303    const USAGE: &str = "usage: /budget [status] | /budget time <duration> (positive whole seconds/minutes/hours, e.g. 30m or 4h; maximum 24h)";
304    let words: Vec<_> = arg.split_whitespace().collect();
305    match words.as_slice() {
306        [] | ["status"] => {
307            let budget = runtime.turn_budget();
308            CommandResult::Output(format!(
309                "Turn budget (configured for future turns, not live usage):\n\
310                 wall-clock: {}s; provider rounds: {} (+{} bounded renewals); tool calls: {}; tool-result bytes: {}\n\
311                 context tokens: {}; cost USD: {}\n\
312                 Committed context rollovers reset only elapsed time; other limits remain cumulative. /budget time 4h changes only the next-turn time allowance in this runtime; it is not saved to config or session storage.",
313                budget.max_elapsed.as_secs(),
314                budget.max_provider_rounds,
315                budget.max_round_renewals,
316                budget.max_tool_calls,
317                budget.max_accumulated_tool_result_bytes,
318                budget.max_context_tokens.map_or_else(|| "unset".into(), |v| v.to_string()),
319                budget.max_cost_usd.map_or_else(|| "unset".into(), |v| v.to_string()),
320            ))
321        }
322        ["time", duration] => {
323            let Some(seconds) = parse_turn_duration(duration) else {
324                return CommandResult::Error(USAGE.into());
325            };
326            let mut budget = runtime.turn_budget().clone();
327            budget.max_elapsed = std::time::Duration::from_secs(seconds);
328            runtime.set_turn_budget(budget);
329            CommandResult::Output(format!(
330                "Turn wall-clock limit set to {seconds}s for future turns in this runtime only (not saved). Running turns, worker budgets and all other limits are unchanged. Send a prompt to continue retained history; this command does not start a turn."
331            ))
332        }
333        _ => CommandResult::Error(USAGE.into()),
334    }
335}
336
337fn parse_turn_duration(value: &str) -> Option<u64> {
338    let (digits, multiplier) = if let Some(digits) = value.strip_suffix('s') {
339        (digits, 1)
340    } else if let Some(digits) = value.strip_suffix('m') {
341        (digits, 60)
342    } else if let Some(digits) = value.strip_suffix('h') {
343        (digits, 3600)
344    } else {
345        return None;
346    };
347    if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
348        return None;
349    }
350    let seconds = digits.parse::<u64>().ok()?.checked_mul(multiplier)?;
351    (1..=86_400).contains(&seconds).then_some(seconds)
352}
353
354/// `/trace next|next content|status` (Task 12): explicit trace controls.
355pub fn trace_command(arg: &str, runtime: &crate::Runtime) -> CommandResult {
356    match arg.trim() {
357        "next" => {
358            runtime.trace_arm_next(false);
359            CommandResult::Output(
360                "trace armed for the next request (metadata only, then auto-disabled)".to_string(),
361            )
362        }
363        "next content" => {
364            runtime.trace_arm_next(true);
365            CommandResult::Output(
366                "trace armed for the next request WITH redacted content capture.\n\
367                 The request body (never headers/credentials) is recursively \
368                 redacted and kept in a private, short-lived capture bundle; \
369                 export it with `synaps trace export <request-id> \
370                 --include-content --allow-content-export --output PATH` \
371                 before it expires."
372                    .to_string(),
373            )
374        }
375        "" | "status" => CommandResult::Output(runtime.trace_status().render()),
376        other => CommandResult::Error(format!(
377            "unknown /trace subcommand: `{other}` (try: next, next content, status)"
378        )),
379    }
380}
381
382/// `/memory on|recall|capture|once|status|off|index-history|why` (task A5,
383/// spec §7.3): the ONE deterministic engine entry point every frontend —
384/// TUI, headless chat, RPC, server, watcher, agent — shares via
385/// [`handle_engine_command`]. Frontends must not duplicate lease or budget
386/// logic.
387///
388/// Every enabling arm mints host-owned
389/// [`crate::runtime::memory_context::UserIntentProof::ExplicitCommand`]
390/// proof right here: a deterministic slash command is authoritative (spec
391/// assumption 5), and proof never derives from model text.
392pub fn memory_command(arg: &str, runtime: &crate::Runtime) -> CommandResult {
393    use crate::runtime::memory_context::{mint_explicit_command_proof, MemoryContextMode};
394    let enable = |mode: MemoryContextMode| match runtime
395        .memory_context_enable(mode, mint_explicit_command_proof())
396    {
397        Ok(status) => CommandResult::Output(status.render()),
398        Err(e) => CommandResult::Error(e.to_string()),
399    };
400    match arg.trim() {
401        "on" => enable(MemoryContextMode::CaptureAndRecall),
402        "recall" => enable(MemoryContextMode::RecallEachPrompt),
403        "capture" => enable(MemoryContextMode::CaptureOnly),
404        "once" => match runtime.memory_context_recall_once(mint_explicit_command_proof()) {
405            Ok(status) => CommandResult::Output(status.render()),
406            Err(e) => CommandResult::Error(e.to_string()),
407        },
408        "" | "status" => CommandResult::Output(runtime.memory_context_status().render()),
409        // Disable is applied to session state BEFORE this returns: `render`
410        // runs on the post-revocation status snapshot.
411        "off" => CommandResult::Output(runtime.memory_context_disable().render()),
412        "index-history" => match runtime.memory_history_preview() {
413            Ok(preview) => CommandResult::Output(preview.render()),
414            Err(error) => CommandResult::Error(error.to_string()),
415        },
416        "index-history confirm" => match runtime.memory_history_confirm() {
417            Ok(report) => CommandResult::Output(format!(
418                "History import complete: {} session(s) loaded, {} bounded capture record(s) submitted in {} batch(es).",
419                report.sessions_loaded, report.captures_built, report.batches_submitted,
420            )),
421            Err(error) => CommandResult::Error(error.to_string()),
422        },
423        "index-history decline" => {
424            if runtime.memory_history_decline() {
425                CommandResult::Output(
426                    "History import declined; no session content was read and no Axel data was written."
427                        .to_string(),
428                )
429            } else {
430                CommandResult::Error(
431                    "history import requires a pending disclosure preview before decline"
432                        .to_string(),
433                )
434            }
435        }
436        "why" => CommandResult::Output(crate::runtime::memory_context::render_recall_why(
437            runtime.memory_recall_why().as_ref(),
438        )),
439        other => CommandResult::Error(format!(
440            "unknown /memory subcommand: `{other}` \
441             (try: on, recall, capture, once, status, off, index-history, why)"
442        )),
443    }
444}
445
446/// Persist a config key and return a user-visible status suffix.
447pub fn persist_to_config(key: &str, value: &str) -> String {
448    match crate::config::write_config_value(key, value) {
449        Ok(()) => "(saved to config)".to_string(),
450        Err(e) => format!("(session only — failed to persist: {})", e),
451    }
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457
458    #[test]
459    fn model_command_carries_model_name() {
460        match evaluate_engine_command("model", "claude-sonnet-4-6") {
461            Some(CommandResult::ModelChanged { model, .. }) => {
462                assert_eq!(model, "claude-sonnet-4-6")
463            }
464            other => panic!("expected ModelChanged, got {:?}", other),
465        }
466        assert!(matches!(
467            evaluate_engine_command("models", "claude-opus-4-6"),
468            Some(CommandResult::ModelChanged { .. })
469        ));
470        assert!(evaluate_engine_command("model", "").is_none());
471    }
472
473    #[test]
474    fn thinking_command_normalizes_levels() {
475        match evaluate_engine_command("thinking", "high") {
476            Some(CommandResult::ThinkingChanged { spec }) => {
477                assert_eq!(spec.level(), ReasoningLevel::High);
478                assert_eq!(spec.budget(), Some(16384));
479                assert!(matches!(spec, ThinkingSpec::Named(ReasoningLevel::High)));
480            }
481            other => panic!("expected ThinkingChanged, got {:?}", other),
482        }
483        let med = parse_thinking_arg("med").unwrap();
484        assert_eq!(med.level(), ReasoningLevel::Medium);
485        assert_eq!(med.budget(), Some(4096));
486
487        // Custom numeric: produces ThinkingSpec::Custom, not Named.
488        let custom = parse_thinking_arg("8192").unwrap();
489        assert!(
490            matches!(
491                custom,
492                ThinkingSpec::Custom {
493                    level: ReasoningLevel::High,
494                    budget: 8192
495                }
496            ),
497            "expected Custom{{High, 8192}}, got {:?}",
498            custom
499        );
500        assert_eq!(custom.budget(), Some(8192));
501
502        assert!(parse_thinking_arg("bogus").is_err());
503        assert!(evaluate_engine_command("thinking", "").is_none());
504    }
505
506    #[test]
507    fn thinking_command_off_and_adaptive_are_distinct() {
508        let off = parse_thinking_arg("off").unwrap();
509        let adp = parse_thinking_arg("adaptive").unwrap();
510        assert_eq!(off.level(), ReasoningLevel::Off);
511        assert_eq!(off.budget(), Some(0));
512        assert_eq!(adp.level(), ReasoningLevel::Adaptive);
513        assert_eq!(adp.budget(), Some(0));
514        assert_ne!(off.level(), adp.level());
515    }
516
517    #[test]
518    fn max_and_ultra_have_no_budget() {
519        let max_spec = parse_thinking_arg("max").unwrap();
520        assert_eq!(max_spec.level(), ReasoningLevel::Max);
521        assert_eq!(max_spec.budget(), None, "max has no numeric budget");
522
523        let ultra_spec = parse_thinking_arg("ultra").unwrap();
524        assert_eq!(ultra_spec.level(), ReasoningLevel::Ultra);
525        assert_eq!(ultra_spec.budget(), None, "ultra has no numeric budget");
526
527        // xhigh still has a numeric budget
528        let xhigh_spec = parse_thinking_arg("xhigh").unwrap();
529        assert_eq!(xhigh_spec.level(), ReasoningLevel::XHigh);
530        assert_eq!(xhigh_spec.budget(), Some(32768));
531    }
532
533    #[test]
534    fn thinking_config_value_is_named_for_named_levels() {
535        assert_eq!(
536            thinking_config_value(ThinkingSpec::Named(ReasoningLevel::Off)),
537            "off"
538        );
539        assert_eq!(
540            thinking_config_value(ThinkingSpec::Named(ReasoningLevel::Adaptive)),
541            "adaptive"
542        );
543        assert_eq!(
544            thinking_config_value(ThinkingSpec::Named(ReasoningLevel::Low)),
545            "low"
546        );
547        assert_eq!(
548            thinking_config_value(ThinkingSpec::Named(ReasoningLevel::Medium)),
549            "medium"
550        );
551        assert_eq!(
552            thinking_config_value(ThinkingSpec::Named(ReasoningLevel::High)),
553            "high"
554        );
555        assert_eq!(
556            thinking_config_value(ThinkingSpec::Named(ReasoningLevel::XHigh)),
557            "xhigh"
558        );
559        assert_eq!(
560            thinking_config_value(ThinkingSpec::Named(ReasoningLevel::Max)),
561            "max"
562        );
563        assert_eq!(
564            thinking_config_value(ThinkingSpec::Named(ReasoningLevel::Ultra)),
565            "ultra"
566        );
567        assert_eq!(
568            thinking_config_value(ThinkingSpec::Named(ReasoningLevel::UltraCode)),
569            "ultracode"
570        );
571    }
572
573    #[test]
574    fn thinking_config_value_is_exact_digits_for_custom_budget() {
575        // B3: /thinking 8192 must persist "8192", not the named level "high".
576        let spec = ThinkingSpec::Custom {
577            level: ReasoningLevel::High,
578            budget: 8192,
579        };
580        assert_eq!(thinking_config_value(spec), "8192");
581        let spec2 = ThinkingSpec::Custom {
582            level: ReasoningLevel::Medium,
583            budget: 3000,
584        };
585        assert_eq!(thinking_config_value(spec2), "3000");
586    }
587
588    #[test]
589    fn compact_carries_custom_instructions() {
590        match evaluate_engine_command("compact", "focus on auth") {
591            Some(CommandResult::Compact {
592                custom_instructions,
593            }) => {
594                assert_eq!(custom_instructions.as_deref(), Some("focus on auth"));
595            }
596            other => panic!("expected Compact, got {:?}", other),
597        }
598        assert!(matches!(
599            evaluate_engine_command("compact", ""),
600            Some(CommandResult::Compact {
601                custom_instructions: None
602            })
603        ));
604    }
605
606    #[test]
607    #[serial_test::serial(synaps_base_dir)]
608    fn persist_to_config_reports_write_result() {
609        let home = std::path::PathBuf::from("/tmp/synaps-engine-persist-test");
610        let _ = std::fs::remove_dir_all(&home);
611        std::fs::create_dir_all(home.join(".synaps-cli")).unwrap();
612        let original = std::env::var("HOME").ok();
613        std::env::set_var("HOME", &home);
614
615        let status = persist_to_config("model", "claude-sonnet-4-6");
616
617        if let Some(h) = original {
618            std::env::set_var("HOME", h);
619        } else {
620            std::env::remove_var("HOME");
621        }
622
623        assert_eq!(status, "(saved to config)");
624        let contents = std::fs::read_to_string(home.join(".synaps-cli/config")).unwrap();
625        assert!(contents.contains("model = claude-sonnet-4-6"));
626        let _ = std::fs::remove_dir_all(&home);
627    }
628
629    #[test]
630    fn validate_level_codex_sol_accepts_ultra() {
631        let mut rt = crate::Runtime::new_headless();
632        rt.set_model("openai-codex/gpt-5.6-sol".to_string());
633        assert!(rt
634            .set_reasoning_level_checked(ReasoningLevel::Ultra)
635            .is_ok());
636    }
637
638    #[test]
639    fn validate_level_codex_luna_rejects_ultra_leaves_state_unchanged() {
640        let mut rt = crate::Runtime::new_headless();
641        rt.set_model("openai-codex/gpt-5.6-luna".to_string());
642        rt.set_reasoning_level(ReasoningLevel::Low);
643        let err = rt
644            .set_reasoning_level_checked(ReasoningLevel::Ultra)
645            .unwrap_err();
646        assert!(err.contains("ultra"));
647        assert!(err.contains("gpt-5.6-luna"));
648        // State must be unchanged after rejection.
649        assert_eq!(rt.reasoning_level(), ReasoningLevel::Low);
650    }
651
652    #[test]
653    fn validate_level_non_codex_rejects_ultra() {
654        for model in [
655            "claude-sonnet-4-6",
656            "anthropic/claude-opus-4-7",
657            "groq/llama-3",
658        ] {
659            let mut rt = crate::Runtime::new_headless();
660            rt.set_model(model.to_string());
661            assert!(
662                rt.set_reasoning_level_checked(ReasoningLevel::Ultra)
663                    .is_err(),
664                "non-Codex {model} must not gain ultra without exact metadata"
665            );
666        }
667    }
668
669    // B3: provenance tests -------------------------------------------------------
670
671    #[test]
672    fn set_reasoning_level_is_not_explicit() {
673        // set_reasoning_level (config/restore path) must NOT set explicit flag.
674        let mut rt = crate::Runtime::new_headless();
675        rt.set_reasoning_level(ReasoningLevel::High);
676        assert!(
677            !rt.is_reasoning_explicit(),
678            "set_reasoning_level must not mark explicit"
679        );
680    }
681
682    #[test]
683    fn set_reasoning_level_explicit_marks_flag() {
684        let mut rt = crate::Runtime::new_headless();
685        rt.set_reasoning_level_explicit(ReasoningLevel::High);
686        assert!(
687            rt.is_reasoning_explicit(),
688            "set_reasoning_level_explicit must mark explicit"
689        );
690    }
691
692    #[test]
693    fn set_model_overwrites_non_explicit_codex_default() {
694        // No explicit choice → set_model applies model's default.
695        let mut rt = crate::Runtime::new_headless();
696        rt.set_model("openai-codex/gpt-5.6-luna".to_string());
697        // luna default is Medium per static table
698        assert_eq!(rt.reasoning_level(), ReasoningLevel::Medium);
699        assert!(!rt.is_reasoning_explicit());
700    }
701
702    #[test]
703    fn set_model_preserves_explicit_user_choice() {
704        // Explicit user choice survives model switch.
705        let mut rt = crate::Runtime::new_headless();
706        rt.set_reasoning_level_explicit(ReasoningLevel::Low);
707        rt.set_model("openai-codex/gpt-5.6-sol".to_string());
708        // sol default is Low anyway, but let's use a level that differs from default
709        assert!(
710            rt.is_reasoning_explicit(),
711            "explicit flag must survive set_model"
712        );
713    }
714
715    #[test]
716    fn apply_config_thinking_sets_explicit() {
717        // Config-specified thinking level is explicit — preserved across model switch.
718        use crate::config::SynapsConfig;
719        let mut rt = crate::Runtime::new_headless();
720        let config = SynapsConfig {
721            thinking_level: Some(ReasoningLevel::XHigh),
722            ..Default::default()
723        };
724        rt.apply_config(&config);
725        assert!(
726            rt.is_reasoning_explicit(),
727            "config thinking must be explicit"
728        );
729        assert_eq!(rt.reasoning_level(), ReasoningLevel::XHigh);
730        // Model switch must not overwrite the explicit config level.
731        rt.set_model("openai-codex/gpt-5.6-luna".to_string());
732        assert_eq!(
733            rt.reasoning_level(),
734            ReasoningLevel::XHigh,
735            "explicit config level must survive set_model"
736        );
737    }
738
739    #[test]
740    fn set_thinking_budget_explicit_retains_exact_budget_and_marks_flag() {
741        // B3: /thinking 8192 → set_thinking_budget_explicit(8192) must retain 8192.
742        let mut rt = crate::Runtime::new_headless();
743        rt.set_thinking_budget_explicit(8192);
744        assert_eq!(
745            rt.thinking_budget_raw(),
746            8192,
747            "exact budget must be retained"
748        );
749        assert!(rt.is_reasoning_explicit());
750        // Named level for display is High (4097..=16384 range).
751        assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
752    }
753
754    // ── Numeric `/thinking <N>` mutation-time validation (review corrective) ──
755
756    #[test]
757    fn custom_budget_rejected_on_xai_intrinsic_reasoning_leaves_state_unchanged() {
758        // /thinking 8192 derives High; grok-4.3 has no effort control → reject
759        // immediately, no mutation, no silent downgrade.
760        let mut rt = crate::Runtime::new_headless();
761        rt.set_model("xai-auth/grok-4.3".to_string());
762        let before_budget = rt.thinking_budget_raw();
763        let before_level = rt.reasoning_level();
764        let before_explicit = rt.is_reasoning_explicit();
765        match handle_engine_command("thinking", "8192", &mut rt) {
766            Some(CommandResult::Error(msg)) => {
767                assert!(
768                    msg.contains("xai-auth/grok-4.3"),
769                    "message names model: {msg}"
770                )
771            }
772            other => panic!("expected Error, got {:?}", other),
773        }
774        assert_eq!(
775            rt.thinking_budget_raw(),
776            before_budget,
777            "budget must be unchanged"
778        );
779        assert_eq!(
780            rt.reasoning_level(),
781            before_level,
782            "level must be unchanged"
783        );
784        assert_eq!(
785            rt.is_reasoning_explicit(),
786            before_explicit,
787            "provenance unchanged"
788        );
789    }
790
791    #[test]
792    fn custom_budget_rejected_on_xai_non_reasoning_model() {
793        let mut rt = crate::Runtime::new_headless();
794        rt.set_model("xai-auth/grok-4.20-0309-non-reasoning".to_string());
795        let before_budget = rt.thinking_budget_raw();
796        match handle_engine_command("thinking", "8192", &mut rt) {
797            Some(CommandResult::Error(msg)) => {
798                assert!(msg.contains("non-reasoning"), "{msg}")
799            }
800            other => panic!("expected Error, got {:?}", other),
801        }
802        assert_eq!(
803            rt.thinking_budget_raw(),
804            before_budget,
805            "state must be unchanged"
806        );
807    }
808
809    #[test]
810    fn custom_budget_maps_to_high_and_is_accepted_on_xai_45() {
811        // 8192 → High, which grok-4.5 supports exactly → accepted.
812        let mut rt = crate::Runtime::new_headless();
813        rt.set_model("xai-auth/grok-4.5".to_string());
814        match handle_engine_command("thinking", "8192", &mut rt) {
815            Some(CommandResult::ThinkingChanged { spec }) => {
816                assert_eq!(spec.level(), ReasoningLevel::High);
817                assert_eq!(spec.budget(), Some(8192));
818            }
819            other => panic!("expected ThinkingChanged, got {:?}", other),
820        }
821        assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
822        assert!(rt.is_reasoning_explicit());
823    }
824
825    /// `/model` onto a model that rejects the explicit level reports the clamp
826    /// so surfaces can tell the user, and leaves the runtime in a usable state.
827    #[test]
828    fn model_command_reports_reasoning_clamp_for_unsupported_explicit_level() {
829        let mut rt = crate::Runtime::new_headless();
830        handle_engine_command("thinking", "xhigh", &mut rt).expect("thinking applies");
831        assert_eq!(rt.reasoning_level(), ReasoningLevel::XHigh);
832        match handle_engine_command("model", "xai-auth/grok-4.6", &mut rt) {
833            Some(CommandResult::ModelChanged {
834                model,
835                reasoning_clamped,
836            }) => {
837                assert_eq!(model, "xai-auth/grok-4.6");
838                assert_eq!(
839                    reasoning_clamped,
840                    Some(crate::runtime::ReasoningClamp {
841                        from: ReasoningLevel::XHigh,
842                        to: ReasoningLevel::High,
843                    })
844                );
845            }
846            other => panic!("expected ModelChanged, got {:?}", other),
847        }
848        assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
849        assert!(rt.is_reasoning_explicit());
850        // Supported explicit level: no clamp reported.
851        match handle_engine_command("model", "xai-auth/grok-4.5", &mut rt) {
852            Some(CommandResult::ModelChanged {
853                reasoning_clamped, ..
854            }) => assert_eq!(reasoning_clamped, None),
855            other => panic!("expected ModelChanged, got {:?}", other),
856        }
857    }
858
859    #[test]
860    fn custom_budget_preserved_exactly_on_anthropic_fixed_budget_model() {
861        // Anthropic compatibility: numeric budgets stay exact (never bucketized
862        // away) and are accepted on fixed-budget thinking models.
863        let mut rt = crate::Runtime::new_headless();
864        rt.set_model("claude-sonnet-4-6".to_string());
865        match handle_engine_command("thinking", "8192", &mut rt) {
866            Some(CommandResult::ThinkingChanged { spec }) => {
867                assert_eq!(spec.config_value(), "8192", "persists exact digits");
868            }
869            other => panic!("expected ThinkingChanged, got {:?}", other),
870        }
871        assert_eq!(
872            rt.thinking_budget_raw(),
873            8192,
874            "exact budget must be retained"
875        );
876        assert!(rt.is_reasoning_explicit());
877    }
878
879    #[test]
880    fn custom_budget_config_value_is_digits_not_named() {
881        // B3: ThinkingSpec::Custom config_value() must be the digit string.
882        let spec = parse_thinking_arg("8192").unwrap();
883        assert_eq!(
884            spec.config_value(),
885            "8192",
886            "custom budget must persist as digits, not named level"
887        );
888    }
889
890    // ── Task 12: /trace and /context surfaces ──
891
892    #[test]
893    fn trace_next_enables_exactly_the_next_request_then_auto_disables() {
894        let runtime = crate::Runtime::new_headless();
895        // Telemetry off: the base session context is disabled.
896        assert!(!runtime.trace_context().enabled());
897
898        match trace_command("next", &runtime) {
899            CommandResult::Output(text) => assert!(text.contains("next request")),
900            other => panic!("expected Output, got {other:?}"),
901        }
902        let status = runtime.trace_status();
903        assert!(!status.persistent_enabled);
904        assert_eq!(status.arm, crate::runtime::trace::TraceArm::NextMetadata);
905
906        // Exactly one effective context is enabled, even with telemetry Off…
907        let armed = runtime.effective_trace_context();
908        assert!(armed.enabled(), "armed context must trace the next request");
909        // …then the arm auto-disables: the following request is untraced.
910        let after = runtime.effective_trace_context();
911        assert!(!after.enabled(), "arm must cover exactly one request");
912        assert_eq!(
913            runtime.trace_status().arm,
914            crate::runtime::trace::TraceArm::Off
915        );
916    }
917
918    #[test]
919    fn trace_status_reports_mode_path_and_counters_without_secrets() {
920        let runtime = crate::Runtime::new_headless();
921        match trace_command("status", &runtime) {
922            CommandResult::Output(text) => {
923                assert!(text.contains("trace: disabled"), "got: {text}");
924                assert!(text.contains("degraded"));
925            }
926            other => panic!("expected Output, got {other:?}"),
927        }
928        runtime.trace_arm_next(true);
929        match trace_command("", &runtime) {
930            CommandResult::Output(text) => {
931                assert!(text.contains("armed for next request"), "got: {text}");
932                assert!(text.contains("content capture"));
933            }
934            other => panic!("expected Output, got {other:?}"),
935        }
936        assert!(matches!(
937            trace_command("bogus", &runtime),
938            CommandResult::Error(_)
939        ));
940    }
941
942    #[test]
943    fn context_command_reports_metadata_never_content() {
944        let mut runtime = crate::Runtime::new_headless();
945        let secret = "CONTEXT-SENTINEL-5a5a";
946        runtime.set_system_prompt(format!("you are {secret}"));
947        let history = vec![std::sync::Arc::new(serde_json::json!({
948            "role": "user", "content": secret
949        }))];
950        match context_command(&runtime, Some(&history)) {
951            CommandResult::Output(text) => {
952                assert!(!text.contains(secret), "sentinel leaked: {text}");
953                assert!(text.contains("history: 1 messages"));
954                assert!(text.contains("system prompt:"));
955                assert!(text.contains("skills: unavailable"));
956            }
957            other => panic!("expected Output, got {other:?}"),
958        }
959        // The engine-shared path (no history) reports honestly.
960        match handle_engine_command("context", "", &mut runtime) {
961            Some(CommandResult::Output(text)) => {
962                assert!(
963                    text.contains("history: unavailable messages"),
964                    "got: {text}"
965                );
966            }
967            other => panic!("expected Output, got {other:?}"),
968        }
969    }
970
971    #[test]
972    fn budget_duration_parser_is_strict_and_bounded() {
973        for (input, seconds) in [
974            ("1s", 1),
975            ("30m", 1800),
976            ("4h", 14400),
977            ("24h", 86400),
978            ("1440m", 86400),
979            ("86400s", 86400),
980        ] {
981            assert_eq!(parse_turn_duration(input), Some(seconds), "{input}");
982        }
983        for input in [
984            "",
985            "s",
986            "0s",
987            "0h",
988            "25h",
989            "86401s",
990            "1441m",
991            "4",
992            "4H",
993            "-1h",
994            "+1h",
995            "1.5h",
996            "1h30m",
997            " 4h",
998            "4 h",
999            "4h ",
1000            "1e3s",
1001            "∞h",
1002            "12h",
1003            "18446744073709551615h",
1004            "18446744073709551616s",
1005            "off",
1006            "unlimited",
1007        ] {
1008            assert_eq!(parse_turn_duration(input), None, "{input}");
1009        }
1010    }
1011
1012    #[test]
1013    fn budget_command_status_and_invalid_arguments_do_not_mutate() {
1014        let mut runtime = crate::Runtime::new_headless();
1015        let original = runtime.turn_budget().clone();
1016        for arg in ["", "status", "  status  "] {
1017            let Some(CommandResult::Output(text)) =
1018                handle_engine_command("budget", arg, &mut runtime)
1019            else {
1020                panic!("{arg}");
1021            };
1022            assert!(text.contains("wall-clock: 7200s"));
1023            assert!(text.contains("not live usage"));
1024            assert!(text.contains("tool calls: 512"));
1025            assert_eq!(runtime.turn_budget(), &original);
1026        }
1027        for arg in [
1028            "time",
1029            "time 0s",
1030            "time 25h",
1031            "time 1h trailing",
1032            "status 4h",
1033            "off",
1034            "reset",
1035        ] {
1036            assert!(
1037                matches!(
1038                    handle_engine_command("budget", arg, &mut runtime),
1039                    Some(CommandResult::Error(_))
1040                ),
1041                "{arg}"
1042            );
1043            assert_eq!(runtime.turn_budget(), &original);
1044        }
1045    }
1046
1047    #[test]
1048    fn budget_command_changes_only_future_runtime_time_limit() {
1049        use crate::runtime::budget::{TurnBudget, TurnBudgetMeter, TurnRole};
1050        let mut runtime = crate::Runtime::new_headless();
1051        let original = TurnBudget {
1052            max_elapsed: std::time::Duration::ZERO,
1053            max_tool_calls: 11,
1054            max_context_tokens: Some(456),
1055            max_cost_usd: Some(0.75),
1056            ..TurnBudget::for_role(TurnRole::Foreground)
1057        };
1058        runtime.set_turn_budget(original.clone());
1059        let mut in_flight = TurnBudgetMeter::new(runtime.turn_budget().clone());
1060        let other_runtime = runtime.clone();
1061        let Some(CommandResult::Output(text)) =
1062            handle_engine_command("budget", "time 4h", &mut runtime)
1063        else {
1064            panic!("expected output");
1065        };
1066        assert!(text.contains("14400s"));
1067        assert!(text.contains("not saved"));
1068        let mut expected = original.clone();
1069        expected.max_elapsed = std::time::Duration::from_secs(14400);
1070        assert_eq!(runtime.turn_budget(), &expected);
1071        assert_eq!(other_runtime.turn_budget(), &original);
1072        assert_eq!(
1073            in_flight.begin_round(),
1074            Err(agent_core::BudgetDimension::WallClock)
1075        );
1076        assert!(TurnBudgetMeter::new(runtime.turn_budget().clone())
1077            .begin_round()
1078            .is_ok());
1079        assert_eq!(
1080            crate::Runtime::new_headless()
1081                .turn_budget()
1082                .max_elapsed
1083                .as_secs(),
1084            7200
1085        );
1086        assert_eq!(
1087            TurnBudget::for_role(TurnRole::Worker).max_elapsed.as_secs(),
1088            3600
1089        );
1090        assert_eq!(
1091            TurnBudget::for_role(TurnRole::Autonomous)
1092                .max_elapsed
1093                .as_secs(),
1094            900
1095        );
1096        assert!(crate::skills::BUILTIN_COMMANDS.contains(&"budget"));
1097    }
1098
1099    // ── /memory (task A5, spec §7.3) ────────────────────────────────────────
1100
1101    use crate::runtime::memory_context::{
1102        DurableStatus, MemoryContextMode, OneShotStatus, UserIntentProof,
1103    };
1104
1105    fn output_text(result: CommandResult) -> String {
1106        match result {
1107            CommandResult::Output(text) => text,
1108            other => panic!("expected Output, got {other:?}"),
1109        }
1110    }
1111
1112    /// Spec §7.3: every enabling arg form transitions the session state to
1113    /// its exact mode — `on` → CaptureAndRecall, `recall` →
1114    /// RecallEachPrompt, `capture` → CaptureOnly.
1115    #[test]
1116    fn memory_enable_args_transition_to_exact_modes() {
1117        for (arg, mode) in [
1118            ("on", MemoryContextMode::CaptureAndRecall),
1119            ("recall", MemoryContextMode::RecallEachPrompt),
1120            ("capture", MemoryContextMode::CaptureOnly),
1121        ] {
1122            let runtime = crate::Runtime::new_headless();
1123            let text = output_text(memory_command(arg, &runtime));
1124            assert!(
1125                text.contains(&format!("mode {}", mode.as_str())),
1126                "/memory {arg} output must name the mode; got: {text}"
1127            );
1128            match runtime.memory_context_status().durable {
1129                DurableStatus::Active { mode: active, .. } => {
1130                    assert_eq!(active, mode, "/memory {arg} must install exactly {mode:?}")
1131                }
1132                DurableStatus::Off => panic!("/memory {arg} must install a session lease"),
1133            }
1134        }
1135    }
1136
1137    /// `/memory once` installs a pending one-shot recall and leaves the
1138    /// durable slot untouched.
1139    #[test]
1140    fn memory_once_installs_pending_one_shot_only() {
1141        let runtime = crate::Runtime::new_headless();
1142        let text = output_text(memory_command("once", &runtime));
1143        assert!(text.contains("one-shot recall: pending"), "got: {text}");
1144        let status = runtime.memory_context_status();
1145        assert_eq!(status.durable, DurableStatus::Off, "durable slot untouched");
1146        assert!(matches!(status.one_shot, OneShotStatus::Pending { .. }));
1147        // A second grant while one is pending fails typed, not silently.
1148        assert!(matches!(
1149            memory_command("once", &runtime),
1150            CommandResult::Error(e) if e.contains("already pending")
1151        ));
1152    }
1153
1154    /// Rendering: `/memory status` (and the empty default) mentions the
1155    /// mode and the lease expiry.
1156    #[test]
1157    fn memory_status_render_mentions_mode_and_lease_expiry() {
1158        let runtime = crate::Runtime::new_headless();
1159        // Off default (spec §21): status names the off mode.
1160        let text = output_text(memory_command("status", &runtime));
1161        assert!(text.contains("mode off"), "got: {text}");
1162
1163        output_text(memory_command("on", &runtime));
1164        for arg in ["status", ""] {
1165            let text = output_text(memory_command(arg, &runtime));
1166            assert!(
1167                text.contains("mode capture_and_recall"),
1168                "status must mention the mode; got: {text}"
1169            );
1170            assert!(
1171                text.contains("expiry"),
1172                "status must mention the lease expiry; got: {text}"
1173            );
1174        }
1175    }
1176
1177    /// `/memory off` revokes session state BEFORE returning: the runtime
1178    /// state check and the returned render both show Off.
1179    #[test]
1180    fn memory_off_takes_effect_before_returning() {
1181        let runtime = crate::Runtime::new_headless();
1182        output_text(memory_command("on", &runtime));
1183        assert!(matches!(
1184            runtime.memory_context_status().durable,
1185            DurableStatus::Active { .. }
1186        ));
1187        let text = output_text(memory_command("off", &runtime));
1188        // State check: revocation already applied when the command returned.
1189        assert_eq!(runtime.memory_context_status().durable, DurableStatus::Off);
1190        // The returned render is the post-revocation snapshot.
1191        assert!(text.contains("mode off"), "got: {text}");
1192    }
1193
1194    /// Task B5 (spec §10.4): `/memory why` with no accepted recall this
1195    /// session is clear OUTPUT (never `CommandResult::Error`) explaining the
1196    /// three no-metadata causes — both while memory is off and after enable
1197    /// but before any eligible prompt has run.
1198    #[test]
1199    fn memory_why_without_recall_metadata_is_clear_output_not_error() {
1200        let runtime = crate::Runtime::new_headless();
1201        let text = output_text(memory_command("why", &runtime));
1202        assert!(text.contains("no recall metadata available"), "got: {text}");
1203        assert!(text.contains("memory is off"), "got: {text}");
1204        assert!(text.contains("no eligible prompt"), "got: {text}");
1205        assert!(text.contains("skipped"), "got: {text}");
1206
1207        // Enabled but no eligible prompt yet: still the clear non-error text.
1208        output_text(memory_command("on", &runtime));
1209        let text = output_text(memory_command("why", &runtime));
1210        assert!(text.contains("no recall metadata available"), "got: {text}");
1211    }
1212
1213    /// Spec §6.3 / assumption 5: every enable path grants under host-minted
1214    /// `ExplicitCommand` proof — never model-supplied text.
1215    #[test]
1216    fn memory_enable_always_uses_explicit_command_proof() {
1217        for arg in ["on", "recall", "capture"] {
1218            let runtime = crate::Runtime::new_headless();
1219            output_text(memory_command(arg, &runtime));
1220            assert!(
1221                matches!(
1222                    runtime.memory_durable_proof_for_test(),
1223                    Some(UserIntentProof::ExplicitCommand { .. })
1224                ),
1225                "/memory {arg} must grant under ExplicitCommand proof"
1226            );
1227        }
1228        let runtime = crate::Runtime::new_headless();
1229        output_text(memory_command("once", &runtime));
1230        assert!(
1231            matches!(
1232                runtime.memory_one_shot_proof_for_test(),
1233                Some(UserIntentProof::ExplicitCommand { .. })
1234            ),
1235            "/memory once must grant under ExplicitCommand proof"
1236        );
1237    }
1238
1239    /// Unknown args are a typed error naming the valid forms.
1240    #[test]
1241    fn memory_unknown_arg_is_typed_error() {
1242        let runtime = crate::Runtime::new_headless();
1243        match memory_command("bogus", &runtime) {
1244            CommandResult::Error(e) => {
1245                assert!(e.contains("unknown /memory subcommand"), "got: {e}");
1246                assert!(e.contains("index-history"), "hint lists forms; got: {e}");
1247            }
1248            other => panic!("expected Error, got {other:?}"),
1249        }
1250        assert_eq!(runtime.memory_context_status().durable, DurableStatus::Off);
1251    }
1252
1253    /// `index-history` (task D1) returns the host-computed disclosure preview
1254    /// and does not begin import.
1255    #[test]
1256    fn memory_index_history_surfaces_disclosure_preview() {
1257        let runtime = crate::Runtime::new_headless();
1258        match memory_command("index-history", &runtime) {
1259            CommandResult::Output(text) => {
1260                assert!(text.contains("History import preview"), "got: {text}");
1261                assert!(text.contains("project:"), "got: {text}");
1262                assert!(text.contains("sessions:"), "got: {text}");
1263                assert!(text.contains("approximate bytes:"), "got: {text}");
1264                assert!(text.contains("included date range:"), "got: {text}");
1265                assert!(text.contains("included classes:"), "got: {text}");
1266                assert!(text.contains("excluded classes:"), "got: {text}");
1267                assert!(text.contains("retention policy:"), "got: {text}");
1268                assert!(text.contains("redaction policy:"), "got: {text}");
1269                assert!(text.contains("destination .r8:"), "got: {text}");
1270                assert!(
1271                    text.contains("explicit confirmation required: true"),
1272                    "got: {text}"
1273                );
1274                assert!(text.contains("no import has started"), "got: {text}");
1275            }
1276            other => panic!("expected Output, got {other:?}"),
1277        }
1278        let status = runtime.memory_context_status();
1279        assert_eq!(status.durable, DurableStatus::Off);
1280        assert_eq!(status.one_shot, OneShotStatus::Idle);
1281    }
1282
1283    #[test]
1284    fn memory_index_history_decline_clears_pending_preview() {
1285        let runtime = crate::Runtime::new_headless();
1286        assert!(matches!(
1287            memory_command("index-history", &runtime),
1288            CommandResult::Output(_)
1289        ));
1290        assert!(matches!(
1291            memory_command("index-history decline", &runtime),
1292            CommandResult::Output(text) if text.contains("no session content was read")
1293        ));
1294        assert!(matches!(
1295            memory_command("index-history confirm", &runtime),
1296            CommandResult::Error(error) if error.contains("explicit user confirmation")
1297        ));
1298    }
1299
1300    #[test]
1301    fn memory_index_history_confirmation_requires_prior_preview() {
1302        let runtime = crate::Runtime::new_headless();
1303        assert!(matches!(
1304            memory_command("index-history confirm", &runtime),
1305            CommandResult::Error(error) if error.contains("explicit user confirmation")
1306        ));
1307    }
1308
1309    /// All frontends route `/memory` through the single
1310    /// `handle_engine_command` dispatch point (spec §7.3: one engine API).
1311    #[test]
1312    fn memory_dispatches_through_handle_engine_command() {
1313        let mut runtime = crate::Runtime::new_headless();
1314        match handle_engine_command("memory", "on", &mut runtime) {
1315            Some(CommandResult::Output(text)) => {
1316                assert!(text.contains("mode capture_and_recall"), "got: {text}");
1317            }
1318            other => panic!("expected Some(Output), got {other:?}"),
1319        }
1320        match handle_engine_command("memory", "", &mut runtime) {
1321            Some(CommandResult::Output(text)) => {
1322                assert!(text.contains("mode capture_and_recall"), "got: {text}");
1323                assert!(text.contains("expiry"), "got: {text}");
1324            }
1325            other => panic!("expected Some(Output), got {other:?}"),
1326        }
1327    }
1328}