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) | /budget time off";
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: {}; 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 (or off) changes only the next-turn time allowance in this runtime; it is not saved to config or session storage.",
313                budget
314                    .max_elapsed_secs()
315                    .map_or_else(|| "off".into(), |s| format!("{s}s")),
316                budget.max_provider_rounds,
317                budget.max_round_renewals,
318                budget.max_tool_calls,
319                budget.max_accumulated_tool_result_bytes,
320                budget.max_context_tokens.map_or_else(|| "unset".into(), |v| v.to_string()),
321                budget.max_cost_usd.map_or_else(|| "unset".into(), |v| v.to_string()),
322            ))
323        }
324        ["time", "off"] => {
325            let mut budget = runtime.turn_budget().clone();
326            budget.max_elapsed = None;
327            runtime.set_turn_budget(budget);
328            CommandResult::Output(
329                "Turn wall-clock limit removed for future turns in this runtime only (not saved). Running turns, worker budgets and all other limits are unchanged.".into(),
330            )
331        }
332        ["time", duration] => {
333            let Some(seconds) = parse_turn_duration(duration) else {
334                return CommandResult::Error(USAGE.into());
335            };
336            let mut budget = runtime.turn_budget().clone();
337            budget.max_elapsed = Some(std::time::Duration::from_secs(seconds));
338            runtime.set_turn_budget(budget);
339            CommandResult::Output(format!(
340                "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."
341            ))
342        }
343        _ => CommandResult::Error(USAGE.into()),
344    }
345}
346
347fn parse_turn_duration(value: &str) -> Option<u64> {
348    let (digits, multiplier) = if let Some(digits) = value.strip_suffix('s') {
349        (digits, 1)
350    } else if let Some(digits) = value.strip_suffix('m') {
351        (digits, 60)
352    } else if let Some(digits) = value.strip_suffix('h') {
353        (digits, 3600)
354    } else {
355        return None;
356    };
357    if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
358        return None;
359    }
360    let seconds = digits.parse::<u64>().ok()?.checked_mul(multiplier)?;
361    (1..=86_400).contains(&seconds).then_some(seconds)
362}
363
364/// `/trace next|next content|status` (Task 12): explicit trace controls.
365pub fn trace_command(arg: &str, runtime: &crate::Runtime) -> CommandResult {
366    match arg.trim() {
367        "next" => {
368            runtime.trace_arm_next(false);
369            CommandResult::Output(
370                "trace armed for the next request (metadata only, then auto-disabled)".to_string(),
371            )
372        }
373        "next content" => {
374            runtime.trace_arm_next(true);
375            CommandResult::Output(
376                "trace armed for the next request WITH redacted content capture.\n\
377                 The request body (never headers/credentials) is recursively \
378                 redacted and kept in a private, short-lived capture bundle; \
379                 export it with `synaps trace export <request-id> \
380                 --include-content --allow-content-export --output PATH` \
381                 before it expires."
382                    .to_string(),
383            )
384        }
385        "" | "status" => CommandResult::Output(runtime.trace_status().render()),
386        other => CommandResult::Error(format!(
387            "unknown /trace subcommand: `{other}` (try: next, next content, status)"
388        )),
389    }
390}
391
392/// `/memory on|recall|capture|once|status|off|index-history|why` (task A5,
393/// spec §7.3): the ONE deterministic engine entry point every frontend —
394/// TUI, headless chat, RPC, server, watcher, agent — shares via
395/// [`handle_engine_command`]. Frontends must not duplicate lease or budget
396/// logic.
397///
398/// Every enabling arm mints host-owned
399/// [`crate::runtime::memory_context::UserIntentProof::ExplicitCommand`]
400/// proof right here: a deterministic slash command is authoritative (spec
401/// assumption 5), and proof never derives from model text.
402pub fn memory_command(arg: &str, runtime: &crate::Runtime) -> CommandResult {
403    use crate::runtime::memory_context::{mint_explicit_command_proof, MemoryContextMode};
404    let enable = |mode: MemoryContextMode| match runtime
405        .memory_context_enable(mode, mint_explicit_command_proof())
406    {
407        Ok(status) => CommandResult::Output(status.render()),
408        Err(e) => CommandResult::Error(e.to_string()),
409    };
410    match arg.trim() {
411        "on" => enable(MemoryContextMode::CaptureAndRecall),
412        "recall" => enable(MemoryContextMode::RecallEachPrompt),
413        "capture" => enable(MemoryContextMode::CaptureOnly),
414        "once" => match runtime.memory_context_recall_once(mint_explicit_command_proof()) {
415            Ok(status) => CommandResult::Output(status.render()),
416            Err(e) => CommandResult::Error(e.to_string()),
417        },
418        "" | "status" => CommandResult::Output(runtime.memory_context_status().render()),
419        // Disable is applied to session state BEFORE this returns: `render`
420        // runs on the post-revocation status snapshot.
421        "off" => CommandResult::Output(runtime.memory_context_disable().render()),
422        "index-history" => match runtime.memory_history_preview() {
423            Ok(preview) => CommandResult::Output(preview.render()),
424            Err(error) => CommandResult::Error(error.to_string()),
425        },
426        "index-history confirm" => match runtime.memory_history_confirm() {
427            Ok(report) => CommandResult::Output(format!(
428                "History import complete: {} session(s) loaded, {} bounded capture record(s) submitted in {} batch(es).",
429                report.sessions_loaded, report.captures_built, report.batches_submitted,
430            )),
431            Err(error) => CommandResult::Error(error.to_string()),
432        },
433        "index-history decline" => {
434            if runtime.memory_history_decline() {
435                CommandResult::Output(
436                    "History import declined; no session content was read and no Axel data was written."
437                        .to_string(),
438                )
439            } else {
440                CommandResult::Error(
441                    "history import requires a pending disclosure preview before decline"
442                        .to_string(),
443                )
444            }
445        }
446        "why" => CommandResult::Output(crate::runtime::memory_context::render_recall_why(
447            runtime.memory_recall_why().as_ref(),
448        )),
449        other => CommandResult::Error(format!(
450            "unknown /memory subcommand: `{other}` \
451             (try: on, recall, capture, once, status, off, index-history, why)"
452        )),
453    }
454}
455
456/// Persist a config key and return a user-visible status suffix.
457pub fn persist_to_config(key: &str, value: &str) -> String {
458    match crate::config::write_config_value(key, value) {
459        Ok(()) => "(saved to config)".to_string(),
460        Err(e) => format!("(session only — failed to persist: {})", e),
461    }
462}
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467
468    #[test]
469    fn model_command_carries_model_name() {
470        match evaluate_engine_command("model", "claude-sonnet-4-6") {
471            Some(CommandResult::ModelChanged { model, .. }) => {
472                assert_eq!(model, "claude-sonnet-4-6")
473            }
474            other => panic!("expected ModelChanged, got {:?}", other),
475        }
476        assert!(matches!(
477            evaluate_engine_command("models", "claude-opus-4-6"),
478            Some(CommandResult::ModelChanged { .. })
479        ));
480        assert!(evaluate_engine_command("model", "").is_none());
481    }
482
483    #[test]
484    fn thinking_command_normalizes_levels() {
485        match evaluate_engine_command("thinking", "high") {
486            Some(CommandResult::ThinkingChanged { spec }) => {
487                assert_eq!(spec.level(), ReasoningLevel::High);
488                assert_eq!(spec.budget(), Some(16384));
489                assert!(matches!(spec, ThinkingSpec::Named(ReasoningLevel::High)));
490            }
491            other => panic!("expected ThinkingChanged, got {:?}", other),
492        }
493        let med = parse_thinking_arg("med").unwrap();
494        assert_eq!(med.level(), ReasoningLevel::Medium);
495        assert_eq!(med.budget(), Some(4096));
496
497        // Custom numeric: produces ThinkingSpec::Custom, not Named.
498        let custom = parse_thinking_arg("8192").unwrap();
499        assert!(
500            matches!(
501                custom,
502                ThinkingSpec::Custom {
503                    level: ReasoningLevel::High,
504                    budget: 8192
505                }
506            ),
507            "expected Custom{{High, 8192}}, got {:?}",
508            custom
509        );
510        assert_eq!(custom.budget(), Some(8192));
511
512        assert!(parse_thinking_arg("bogus").is_err());
513        assert!(evaluate_engine_command("thinking", "").is_none());
514    }
515
516    #[test]
517    fn thinking_command_off_and_adaptive_are_distinct() {
518        let off = parse_thinking_arg("off").unwrap();
519        let adp = parse_thinking_arg("adaptive").unwrap();
520        assert_eq!(off.level(), ReasoningLevel::Off);
521        assert_eq!(off.budget(), Some(0));
522        assert_eq!(adp.level(), ReasoningLevel::Adaptive);
523        assert_eq!(adp.budget(), Some(0));
524        assert_ne!(off.level(), adp.level());
525    }
526
527    #[test]
528    fn max_and_ultra_have_no_budget() {
529        let max_spec = parse_thinking_arg("max").unwrap();
530        assert_eq!(max_spec.level(), ReasoningLevel::Max);
531        assert_eq!(max_spec.budget(), None, "max has no numeric budget");
532
533        let ultra_spec = parse_thinking_arg("ultra").unwrap();
534        assert_eq!(ultra_spec.level(), ReasoningLevel::Ultra);
535        assert_eq!(ultra_spec.budget(), None, "ultra has no numeric budget");
536
537        // xhigh still has a numeric budget
538        let xhigh_spec = parse_thinking_arg("xhigh").unwrap();
539        assert_eq!(xhigh_spec.level(), ReasoningLevel::XHigh);
540        assert_eq!(xhigh_spec.budget(), Some(32768));
541    }
542
543    #[test]
544    fn thinking_config_value_is_named_for_named_levels() {
545        assert_eq!(
546            thinking_config_value(ThinkingSpec::Named(ReasoningLevel::Off)),
547            "off"
548        );
549        assert_eq!(
550            thinking_config_value(ThinkingSpec::Named(ReasoningLevel::Adaptive)),
551            "adaptive"
552        );
553        assert_eq!(
554            thinking_config_value(ThinkingSpec::Named(ReasoningLevel::Low)),
555            "low"
556        );
557        assert_eq!(
558            thinking_config_value(ThinkingSpec::Named(ReasoningLevel::Medium)),
559            "medium"
560        );
561        assert_eq!(
562            thinking_config_value(ThinkingSpec::Named(ReasoningLevel::High)),
563            "high"
564        );
565        assert_eq!(
566            thinking_config_value(ThinkingSpec::Named(ReasoningLevel::XHigh)),
567            "xhigh"
568        );
569        assert_eq!(
570            thinking_config_value(ThinkingSpec::Named(ReasoningLevel::Max)),
571            "max"
572        );
573        assert_eq!(
574            thinking_config_value(ThinkingSpec::Named(ReasoningLevel::Ultra)),
575            "ultra"
576        );
577        assert_eq!(
578            thinking_config_value(ThinkingSpec::Named(ReasoningLevel::UltraCode)),
579            "ultracode"
580        );
581    }
582
583    #[test]
584    fn thinking_config_value_is_exact_digits_for_custom_budget() {
585        // B3: /thinking 8192 must persist "8192", not the named level "high".
586        let spec = ThinkingSpec::Custom {
587            level: ReasoningLevel::High,
588            budget: 8192,
589        };
590        assert_eq!(thinking_config_value(spec), "8192");
591        let spec2 = ThinkingSpec::Custom {
592            level: ReasoningLevel::Medium,
593            budget: 3000,
594        };
595        assert_eq!(thinking_config_value(spec2), "3000");
596    }
597
598    #[test]
599    fn compact_carries_custom_instructions() {
600        match evaluate_engine_command("compact", "focus on auth") {
601            Some(CommandResult::Compact {
602                custom_instructions,
603            }) => {
604                assert_eq!(custom_instructions.as_deref(), Some("focus on auth"));
605            }
606            other => panic!("expected Compact, got {:?}", other),
607        }
608        assert!(matches!(
609            evaluate_engine_command("compact", ""),
610            Some(CommandResult::Compact {
611                custom_instructions: None
612            })
613        ));
614    }
615
616    #[test]
617    #[serial_test::serial(synaps_base_dir)]
618    fn persist_to_config_reports_write_result() {
619        let home = std::path::PathBuf::from("/tmp/synaps-engine-persist-test");
620        let _ = std::fs::remove_dir_all(&home);
621        std::fs::create_dir_all(home.join(".synaps-cli")).unwrap();
622        let original = std::env::var("HOME").ok();
623        std::env::set_var("HOME", &home);
624
625        let status = persist_to_config("model", "claude-sonnet-4-6");
626
627        if let Some(h) = original {
628            std::env::set_var("HOME", h);
629        } else {
630            std::env::remove_var("HOME");
631        }
632
633        assert_eq!(status, "(saved to config)");
634        let contents = std::fs::read_to_string(home.join(".synaps-cli/config")).unwrap();
635        assert!(contents.contains("model = claude-sonnet-4-6"));
636        let _ = std::fs::remove_dir_all(&home);
637    }
638
639    #[test]
640    fn validate_level_codex_sol_accepts_ultra() {
641        let mut rt = crate::Runtime::new_headless();
642        rt.set_model("openai-codex/gpt-5.6-sol".to_string());
643        assert!(rt
644            .set_reasoning_level_checked(ReasoningLevel::Ultra)
645            .is_ok());
646    }
647
648    #[test]
649    fn validate_level_codex_luna_rejects_ultra_leaves_state_unchanged() {
650        let mut rt = crate::Runtime::new_headless();
651        rt.set_model("openai-codex/gpt-5.6-luna".to_string());
652        rt.set_reasoning_level(ReasoningLevel::Low);
653        let err = rt
654            .set_reasoning_level_checked(ReasoningLevel::Ultra)
655            .unwrap_err();
656        assert!(err.contains("ultra"));
657        assert!(err.contains("gpt-5.6-luna"));
658        // State must be unchanged after rejection.
659        assert_eq!(rt.reasoning_level(), ReasoningLevel::Low);
660    }
661
662    #[test]
663    fn validate_level_non_codex_rejects_ultra() {
664        for model in [
665            "claude-sonnet-4-6",
666            "anthropic/claude-opus-4-7",
667            "groq/llama-3",
668        ] {
669            let mut rt = crate::Runtime::new_headless();
670            rt.set_model(model.to_string());
671            assert!(
672                rt.set_reasoning_level_checked(ReasoningLevel::Ultra)
673                    .is_err(),
674                "non-Codex {model} must not gain ultra without exact metadata"
675            );
676        }
677    }
678
679    // B3: provenance tests -------------------------------------------------------
680
681    #[test]
682    fn set_reasoning_level_is_not_explicit() {
683        // set_reasoning_level (config/restore path) must NOT set explicit flag.
684        let mut rt = crate::Runtime::new_headless();
685        rt.set_reasoning_level(ReasoningLevel::High);
686        assert!(
687            !rt.is_reasoning_explicit(),
688            "set_reasoning_level must not mark explicit"
689        );
690    }
691
692    #[test]
693    fn set_reasoning_level_explicit_marks_flag() {
694        let mut rt = crate::Runtime::new_headless();
695        rt.set_reasoning_level_explicit(ReasoningLevel::High);
696        assert!(
697            rt.is_reasoning_explicit(),
698            "set_reasoning_level_explicit must mark explicit"
699        );
700    }
701
702    #[test]
703    fn set_model_overwrites_non_explicit_codex_default() {
704        // No explicit choice → set_model applies model's default.
705        let mut rt = crate::Runtime::new_headless();
706        rt.set_model("openai-codex/gpt-5.6-luna".to_string());
707        // luna default is Medium per static table
708        assert_eq!(rt.reasoning_level(), ReasoningLevel::Medium);
709        assert!(!rt.is_reasoning_explicit());
710    }
711
712    #[test]
713    fn set_model_preserves_explicit_user_choice() {
714        // Explicit user choice survives model switch.
715        let mut rt = crate::Runtime::new_headless();
716        rt.set_reasoning_level_explicit(ReasoningLevel::Low);
717        rt.set_model("openai-codex/gpt-5.6-sol".to_string());
718        // sol default is Low anyway, but let's use a level that differs from default
719        assert!(
720            rt.is_reasoning_explicit(),
721            "explicit flag must survive set_model"
722        );
723    }
724
725    #[test]
726    fn apply_config_thinking_sets_explicit() {
727        // Config-specified thinking level is explicit — preserved across model switch.
728        use crate::config::SynapsConfig;
729        let mut rt = crate::Runtime::new_headless();
730        let config = SynapsConfig {
731            thinking_level: Some(ReasoningLevel::XHigh),
732            ..Default::default()
733        };
734        rt.apply_config(&config);
735        assert!(
736            rt.is_reasoning_explicit(),
737            "config thinking must be explicit"
738        );
739        assert_eq!(rt.reasoning_level(), ReasoningLevel::XHigh);
740        // Model switch must not overwrite the explicit config level.
741        rt.set_model("openai-codex/gpt-5.6-luna".to_string());
742        assert_eq!(
743            rt.reasoning_level(),
744            ReasoningLevel::XHigh,
745            "explicit config level must survive set_model"
746        );
747    }
748
749    #[test]
750    fn set_thinking_budget_explicit_retains_exact_budget_and_marks_flag() {
751        // B3: /thinking 8192 → set_thinking_budget_explicit(8192) must retain 8192.
752        let mut rt = crate::Runtime::new_headless();
753        rt.set_thinking_budget_explicit(8192);
754        assert_eq!(
755            rt.thinking_budget_raw(),
756            8192,
757            "exact budget must be retained"
758        );
759        assert!(rt.is_reasoning_explicit());
760        // Named level for display is High (4097..=16384 range).
761        assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
762    }
763
764    // ── Numeric `/thinking <N>` mutation-time validation (review corrective) ──
765
766    #[test]
767    fn custom_budget_rejected_on_xai_intrinsic_reasoning_leaves_state_unchanged() {
768        // /thinking 8192 derives High; grok-4.3 has no effort control → reject
769        // immediately, no mutation, no silent downgrade.
770        let mut rt = crate::Runtime::new_headless();
771        rt.set_model("xai-auth/grok-4.3".to_string());
772        let before_budget = rt.thinking_budget_raw();
773        let before_level = rt.reasoning_level();
774        let before_explicit = rt.is_reasoning_explicit();
775        match handle_engine_command("thinking", "8192", &mut rt) {
776            Some(CommandResult::Error(msg)) => {
777                assert!(
778                    msg.contains("xai-auth/grok-4.3"),
779                    "message names model: {msg}"
780                )
781            }
782            other => panic!("expected Error, got {:?}", other),
783        }
784        assert_eq!(
785            rt.thinking_budget_raw(),
786            before_budget,
787            "budget must be unchanged"
788        );
789        assert_eq!(
790            rt.reasoning_level(),
791            before_level,
792            "level must be unchanged"
793        );
794        assert_eq!(
795            rt.is_reasoning_explicit(),
796            before_explicit,
797            "provenance unchanged"
798        );
799    }
800
801    #[test]
802    fn custom_budget_rejected_on_xai_non_reasoning_model() {
803        let mut rt = crate::Runtime::new_headless();
804        rt.set_model("xai-auth/grok-4.20-0309-non-reasoning".to_string());
805        let before_budget = rt.thinking_budget_raw();
806        match handle_engine_command("thinking", "8192", &mut rt) {
807            Some(CommandResult::Error(msg)) => {
808                assert!(msg.contains("non-reasoning"), "{msg}")
809            }
810            other => panic!("expected Error, got {:?}", other),
811        }
812        assert_eq!(
813            rt.thinking_budget_raw(),
814            before_budget,
815            "state must be unchanged"
816        );
817    }
818
819    #[test]
820    fn custom_budget_maps_to_high_and_is_accepted_on_xai_45() {
821        // 8192 → High, which grok-4.5 supports exactly → accepted.
822        let mut rt = crate::Runtime::new_headless();
823        rt.set_model("xai-auth/grok-4.5".to_string());
824        match handle_engine_command("thinking", "8192", &mut rt) {
825            Some(CommandResult::ThinkingChanged { spec }) => {
826                assert_eq!(spec.level(), ReasoningLevel::High);
827                assert_eq!(spec.budget(), Some(8192));
828            }
829            other => panic!("expected ThinkingChanged, got {:?}", other),
830        }
831        assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
832        assert!(rt.is_reasoning_explicit());
833    }
834
835    /// `/model` onto a model that rejects the explicit level reports the clamp
836    /// so surfaces can tell the user, and leaves the runtime in a usable state.
837    #[test]
838    fn model_command_reports_reasoning_clamp_for_unsupported_explicit_level() {
839        let mut rt = crate::Runtime::new_headless();
840        handle_engine_command("thinking", "xhigh", &mut rt).expect("thinking applies");
841        assert_eq!(rt.reasoning_level(), ReasoningLevel::XHigh);
842        match handle_engine_command("model", "xai-auth/grok-4.6", &mut rt) {
843            Some(CommandResult::ModelChanged {
844                model,
845                reasoning_clamped,
846            }) => {
847                assert_eq!(model, "xai-auth/grok-4.6");
848                assert_eq!(
849                    reasoning_clamped,
850                    Some(crate::runtime::ReasoningClamp {
851                        from: ReasoningLevel::XHigh,
852                        to: ReasoningLevel::High,
853                    })
854                );
855            }
856            other => panic!("expected ModelChanged, got {:?}", other),
857        }
858        assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
859        assert!(rt.is_reasoning_explicit());
860        // Supported explicit level: no clamp reported.
861        match handle_engine_command("model", "xai-auth/grok-4.5", &mut rt) {
862            Some(CommandResult::ModelChanged {
863                reasoning_clamped, ..
864            }) => assert_eq!(reasoning_clamped, None),
865            other => panic!("expected ModelChanged, got {:?}", other),
866        }
867    }
868
869    #[test]
870    fn custom_budget_preserved_exactly_on_anthropic_fixed_budget_model() {
871        // Anthropic compatibility: numeric budgets stay exact (never bucketized
872        // away) and are accepted on fixed-budget thinking models.
873        let mut rt = crate::Runtime::new_headless();
874        rt.set_model("claude-sonnet-4-6".to_string());
875        match handle_engine_command("thinking", "8192", &mut rt) {
876            Some(CommandResult::ThinkingChanged { spec }) => {
877                assert_eq!(spec.config_value(), "8192", "persists exact digits");
878            }
879            other => panic!("expected ThinkingChanged, got {:?}", other),
880        }
881        assert_eq!(
882            rt.thinking_budget_raw(),
883            8192,
884            "exact budget must be retained"
885        );
886        assert!(rt.is_reasoning_explicit());
887    }
888
889    #[test]
890    fn custom_budget_config_value_is_digits_not_named() {
891        // B3: ThinkingSpec::Custom config_value() must be the digit string.
892        let spec = parse_thinking_arg("8192").unwrap();
893        assert_eq!(
894            spec.config_value(),
895            "8192",
896            "custom budget must persist as digits, not named level"
897        );
898    }
899
900    // ── Task 12: /trace and /context surfaces ──
901
902    #[test]
903    fn trace_next_enables_exactly_the_next_request_then_auto_disables() {
904        let runtime = crate::Runtime::new_headless();
905        // Telemetry off: the base session context is disabled.
906        assert!(!runtime.trace_context().enabled());
907
908        match trace_command("next", &runtime) {
909            CommandResult::Output(text) => assert!(text.contains("next request")),
910            other => panic!("expected Output, got {other:?}"),
911        }
912        let status = runtime.trace_status();
913        assert!(!status.persistent_enabled);
914        assert_eq!(status.arm, crate::runtime::trace::TraceArm::NextMetadata);
915
916        // Exactly one effective context is enabled, even with telemetry Off…
917        let armed = runtime.effective_trace_context();
918        assert!(armed.enabled(), "armed context must trace the next request");
919        // …then the arm auto-disables: the following request is untraced.
920        let after = runtime.effective_trace_context();
921        assert!(!after.enabled(), "arm must cover exactly one request");
922        assert_eq!(
923            runtime.trace_status().arm,
924            crate::runtime::trace::TraceArm::Off
925        );
926    }
927
928    #[test]
929    fn trace_status_reports_mode_path_and_counters_without_secrets() {
930        let runtime = crate::Runtime::new_headless();
931        match trace_command("status", &runtime) {
932            CommandResult::Output(text) => {
933                assert!(text.contains("trace: disabled"), "got: {text}");
934                assert!(text.contains("degraded"));
935            }
936            other => panic!("expected Output, got {other:?}"),
937        }
938        runtime.trace_arm_next(true);
939        match trace_command("", &runtime) {
940            CommandResult::Output(text) => {
941                assert!(text.contains("armed for next request"), "got: {text}");
942                assert!(text.contains("content capture"));
943            }
944            other => panic!("expected Output, got {other:?}"),
945        }
946        assert!(matches!(
947            trace_command("bogus", &runtime),
948            CommandResult::Error(_)
949        ));
950    }
951
952    #[test]
953    fn context_command_reports_metadata_never_content() {
954        let mut runtime = crate::Runtime::new_headless();
955        let secret = "CONTEXT-SENTINEL-5a5a";
956        runtime.set_system_prompt(format!("you are {secret}"));
957        let history = vec![std::sync::Arc::new(serde_json::json!({
958            "role": "user", "content": secret
959        }))];
960        match context_command(&runtime, Some(&history)) {
961            CommandResult::Output(text) => {
962                assert!(!text.contains(secret), "sentinel leaked: {text}");
963                assert!(text.contains("history: 1 messages"));
964                assert!(text.contains("system prompt:"));
965                assert!(text.contains("skills: unavailable"));
966            }
967            other => panic!("expected Output, got {other:?}"),
968        }
969        // The engine-shared path (no history) reports honestly.
970        match handle_engine_command("context", "", &mut runtime) {
971            Some(CommandResult::Output(text)) => {
972                assert!(
973                    text.contains("history: unavailable messages"),
974                    "got: {text}"
975                );
976            }
977            other => panic!("expected Output, got {other:?}"),
978        }
979    }
980
981    #[test]
982    fn budget_duration_parser_is_strict_and_bounded() {
983        for (input, seconds) in [
984            ("1s", 1),
985            ("30m", 1800),
986            ("4h", 14400),
987            ("24h", 86400),
988            ("1440m", 86400),
989            ("86400s", 86400),
990        ] {
991            assert_eq!(parse_turn_duration(input), Some(seconds), "{input}");
992        }
993        for input in [
994            "",
995            "s",
996            "0s",
997            "0h",
998            "25h",
999            "86401s",
1000            "1441m",
1001            "4",
1002            "4H",
1003            "-1h",
1004            "+1h",
1005            "1.5h",
1006            "1h30m",
1007            " 4h",
1008            "4 h",
1009            "4h ",
1010            "1e3s",
1011            "∞h",
1012            "12h",
1013            "18446744073709551615h",
1014            "18446744073709551616s",
1015            "off",
1016            "unlimited",
1017        ] {
1018            assert_eq!(parse_turn_duration(input), None, "{input}");
1019        }
1020    }
1021
1022    #[test]
1023    fn budget_command_status_and_invalid_arguments_do_not_mutate() {
1024        let mut runtime = crate::Runtime::new_headless();
1025        let original = runtime.turn_budget().clone();
1026        for arg in ["", "status", "  status  "] {
1027            let Some(CommandResult::Output(text)) =
1028                handle_engine_command("budget", arg, &mut runtime)
1029            else {
1030                panic!("{arg}");
1031            };
1032            assert!(text.contains("wall-clock: off;"));
1033            assert!(text.contains("not live usage"));
1034            assert!(text.contains("tool calls: 512"));
1035            assert_eq!(runtime.turn_budget(), &original);
1036        }
1037        for arg in [
1038            "time",
1039            "time 0s",
1040            "time 25h",
1041            "time 1h trailing",
1042            "status 4h",
1043            "time off now",
1044            "off",
1045            "reset",
1046        ] {
1047            assert!(
1048                matches!(
1049                    handle_engine_command("budget", arg, &mut runtime),
1050                    Some(CommandResult::Error(_))
1051                ),
1052                "{arg}"
1053            );
1054            assert_eq!(runtime.turn_budget(), &original);
1055        }
1056    }
1057
1058    #[test]
1059    fn budget_command_changes_only_future_runtime_time_limit() {
1060        use crate::runtime::budget::{TurnBudget, TurnBudgetMeter, TurnRole};
1061        let mut runtime = crate::Runtime::new_headless();
1062        let original = TurnBudget {
1063            max_elapsed: Some(std::time::Duration::ZERO),
1064            max_tool_calls: 11,
1065            max_context_tokens: Some(456),
1066            max_cost_usd: Some(0.75),
1067            ..TurnBudget::for_role(TurnRole::Foreground)
1068        };
1069        runtime.set_turn_budget(original.clone());
1070        let mut in_flight = TurnBudgetMeter::new(runtime.turn_budget().clone());
1071        let other_runtime = runtime.clone();
1072        let Some(CommandResult::Output(text)) =
1073            handle_engine_command("budget", "time 4h", &mut runtime)
1074        else {
1075            panic!("expected output");
1076        };
1077        assert!(text.contains("14400s"));
1078        assert!(text.contains("not saved"));
1079        let mut expected = original.clone();
1080        expected.max_elapsed = Some(std::time::Duration::from_secs(14400));
1081        assert_eq!(runtime.turn_budget(), &expected);
1082        assert_eq!(other_runtime.turn_budget(), &original);
1083        assert_eq!(
1084            in_flight.begin_round(),
1085            Err(agent_core::BudgetDimension::WallClock)
1086        );
1087        assert!(TurnBudgetMeter::new(runtime.turn_budget().clone())
1088            .begin_round()
1089            .is_ok());
1090        let Some(CommandResult::Output(text)) =
1091            handle_engine_command("budget", "time off", &mut runtime)
1092        else {
1093            panic!("expected output");
1094        };
1095        assert!(text.contains("removed"));
1096        expected.max_elapsed = None;
1097        assert_eq!(runtime.turn_budget(), &expected);
1098        // No role has a time limit by default.
1099        assert_eq!(
1100            crate::Runtime::new_headless().turn_budget().max_elapsed,
1101            None
1102        );
1103        for role in [TurnRole::Foreground, TurnRole::Worker, TurnRole::Autonomous] {
1104            assert_eq!(TurnBudget::for_role(role).max_elapsed, None);
1105        }
1106        assert!(crate::skills::BUILTIN_COMMANDS.contains(&"budget"));
1107    }
1108
1109    // ── /memory (task A5, spec §7.3) ────────────────────────────────────────
1110
1111    use crate::runtime::memory_context::{
1112        DurableStatus, MemoryContextMode, OneShotStatus, UserIntentProof,
1113    };
1114
1115    fn output_text(result: CommandResult) -> String {
1116        match result {
1117            CommandResult::Output(text) => text,
1118            other => panic!("expected Output, got {other:?}"),
1119        }
1120    }
1121
1122    /// Spec §7.3: every enabling arg form transitions the session state to
1123    /// its exact mode — `on` → CaptureAndRecall, `recall` →
1124    /// RecallEachPrompt, `capture` → CaptureOnly.
1125    #[test]
1126    fn memory_enable_args_transition_to_exact_modes() {
1127        for (arg, mode) in [
1128            ("on", MemoryContextMode::CaptureAndRecall),
1129            ("recall", MemoryContextMode::RecallEachPrompt),
1130            ("capture", MemoryContextMode::CaptureOnly),
1131        ] {
1132            let runtime = crate::Runtime::new_headless();
1133            let text = output_text(memory_command(arg, &runtime));
1134            assert!(
1135                text.contains(&format!("mode {}", mode.as_str())),
1136                "/memory {arg} output must name the mode; got: {text}"
1137            );
1138            match runtime.memory_context_status().durable {
1139                DurableStatus::Active { mode: active, .. } => {
1140                    assert_eq!(active, mode, "/memory {arg} must install exactly {mode:?}")
1141                }
1142                DurableStatus::Off => panic!("/memory {arg} must install a session lease"),
1143            }
1144        }
1145    }
1146
1147    /// `/memory once` installs a pending one-shot recall and leaves the
1148    /// durable slot untouched.
1149    #[test]
1150    fn memory_once_installs_pending_one_shot_only() {
1151        let runtime = crate::Runtime::new_headless();
1152        let text = output_text(memory_command("once", &runtime));
1153        assert!(text.contains("one-shot recall: pending"), "got: {text}");
1154        let status = runtime.memory_context_status();
1155        assert_eq!(status.durable, DurableStatus::Off, "durable slot untouched");
1156        assert!(matches!(status.one_shot, OneShotStatus::Pending { .. }));
1157        // A second grant while one is pending fails typed, not silently.
1158        assert!(matches!(
1159            memory_command("once", &runtime),
1160            CommandResult::Error(e) if e.contains("already pending")
1161        ));
1162    }
1163
1164    /// Rendering: `/memory status` (and the empty default) mentions the
1165    /// mode and the lease expiry.
1166    #[test]
1167    fn memory_status_render_mentions_mode_and_lease_expiry() {
1168        let runtime = crate::Runtime::new_headless();
1169        // Off default (spec §21): status names the off mode.
1170        let text = output_text(memory_command("status", &runtime));
1171        assert!(text.contains("mode off"), "got: {text}");
1172
1173        output_text(memory_command("on", &runtime));
1174        for arg in ["status", ""] {
1175            let text = output_text(memory_command(arg, &runtime));
1176            assert!(
1177                text.contains("mode capture_and_recall"),
1178                "status must mention the mode; got: {text}"
1179            );
1180            assert!(
1181                text.contains("expiry"),
1182                "status must mention the lease expiry; got: {text}"
1183            );
1184        }
1185    }
1186
1187    /// `/memory off` revokes session state BEFORE returning: the runtime
1188    /// state check and the returned render both show Off.
1189    #[test]
1190    fn memory_off_takes_effect_before_returning() {
1191        let runtime = crate::Runtime::new_headless();
1192        output_text(memory_command("on", &runtime));
1193        assert!(matches!(
1194            runtime.memory_context_status().durable,
1195            DurableStatus::Active { .. }
1196        ));
1197        let text = output_text(memory_command("off", &runtime));
1198        // State check: revocation already applied when the command returned.
1199        assert_eq!(runtime.memory_context_status().durable, DurableStatus::Off);
1200        // The returned render is the post-revocation snapshot.
1201        assert!(text.contains("mode off"), "got: {text}");
1202    }
1203
1204    /// Task B5 (spec §10.4): `/memory why` with no accepted recall this
1205    /// session is clear OUTPUT (never `CommandResult::Error`) explaining the
1206    /// three no-metadata causes — both while memory is off and after enable
1207    /// but before any eligible prompt has run.
1208    #[test]
1209    fn memory_why_without_recall_metadata_is_clear_output_not_error() {
1210        let runtime = crate::Runtime::new_headless();
1211        let text = output_text(memory_command("why", &runtime));
1212        assert!(text.contains("no recall metadata available"), "got: {text}");
1213        assert!(text.contains("memory is off"), "got: {text}");
1214        assert!(text.contains("no eligible prompt"), "got: {text}");
1215        assert!(text.contains("skipped"), "got: {text}");
1216
1217        // Enabled but no eligible prompt yet: still the clear non-error text.
1218        output_text(memory_command("on", &runtime));
1219        let text = output_text(memory_command("why", &runtime));
1220        assert!(text.contains("no recall metadata available"), "got: {text}");
1221    }
1222
1223    /// Spec §6.3 / assumption 5: every enable path grants under host-minted
1224    /// `ExplicitCommand` proof — never model-supplied text.
1225    #[test]
1226    fn memory_enable_always_uses_explicit_command_proof() {
1227        for arg in ["on", "recall", "capture"] {
1228            let runtime = crate::Runtime::new_headless();
1229            output_text(memory_command(arg, &runtime));
1230            assert!(
1231                matches!(
1232                    runtime.memory_durable_proof_for_test(),
1233                    Some(UserIntentProof::ExplicitCommand { .. })
1234                ),
1235                "/memory {arg} must grant under ExplicitCommand proof"
1236            );
1237        }
1238        let runtime = crate::Runtime::new_headless();
1239        output_text(memory_command("once", &runtime));
1240        assert!(
1241            matches!(
1242                runtime.memory_one_shot_proof_for_test(),
1243                Some(UserIntentProof::ExplicitCommand { .. })
1244            ),
1245            "/memory once must grant under ExplicitCommand proof"
1246        );
1247    }
1248
1249    /// Unknown args are a typed error naming the valid forms.
1250    #[test]
1251    fn memory_unknown_arg_is_typed_error() {
1252        let runtime = crate::Runtime::new_headless();
1253        match memory_command("bogus", &runtime) {
1254            CommandResult::Error(e) => {
1255                assert!(e.contains("unknown /memory subcommand"), "got: {e}");
1256                assert!(e.contains("index-history"), "hint lists forms; got: {e}");
1257            }
1258            other => panic!("expected Error, got {other:?}"),
1259        }
1260        assert_eq!(runtime.memory_context_status().durable, DurableStatus::Off);
1261    }
1262
1263    /// `index-history` (task D1) returns the host-computed disclosure preview
1264    /// and does not begin import.
1265    #[test]
1266    fn memory_index_history_surfaces_disclosure_preview() {
1267        let runtime = crate::Runtime::new_headless();
1268        match memory_command("index-history", &runtime) {
1269            CommandResult::Output(text) => {
1270                assert!(text.contains("History import preview"), "got: {text}");
1271                assert!(text.contains("project:"), "got: {text}");
1272                assert!(text.contains("sessions:"), "got: {text}");
1273                assert!(text.contains("approximate bytes:"), "got: {text}");
1274                assert!(text.contains("included date range:"), "got: {text}");
1275                assert!(text.contains("included classes:"), "got: {text}");
1276                assert!(text.contains("excluded classes:"), "got: {text}");
1277                assert!(text.contains("retention policy:"), "got: {text}");
1278                assert!(text.contains("redaction policy:"), "got: {text}");
1279                assert!(text.contains("destination .r8:"), "got: {text}");
1280                assert!(
1281                    text.contains("explicit confirmation required: true"),
1282                    "got: {text}"
1283                );
1284                assert!(text.contains("no import has started"), "got: {text}");
1285            }
1286            other => panic!("expected Output, got {other:?}"),
1287        }
1288        let status = runtime.memory_context_status();
1289        assert_eq!(status.durable, DurableStatus::Off);
1290        assert_eq!(status.one_shot, OneShotStatus::Idle);
1291    }
1292
1293    #[test]
1294    fn memory_index_history_decline_clears_pending_preview() {
1295        let runtime = crate::Runtime::new_headless();
1296        assert!(matches!(
1297            memory_command("index-history", &runtime),
1298            CommandResult::Output(_)
1299        ));
1300        assert!(matches!(
1301            memory_command("index-history decline", &runtime),
1302            CommandResult::Output(text) if text.contains("no session content was read")
1303        ));
1304        assert!(matches!(
1305            memory_command("index-history confirm", &runtime),
1306            CommandResult::Error(error) if error.contains("explicit user confirmation")
1307        ));
1308    }
1309
1310    #[test]
1311    fn memory_index_history_confirmation_requires_prior_preview() {
1312        let runtime = crate::Runtime::new_headless();
1313        assert!(matches!(
1314            memory_command("index-history confirm", &runtime),
1315            CommandResult::Error(error) if error.contains("explicit user confirmation")
1316        ));
1317    }
1318
1319    /// All frontends route `/memory` through the single
1320    /// `handle_engine_command` dispatch point (spec §7.3: one engine API).
1321    #[test]
1322    fn memory_dispatches_through_handle_engine_command() {
1323        let mut runtime = crate::Runtime::new_headless();
1324        match handle_engine_command("memory", "on", &mut runtime) {
1325            Some(CommandResult::Output(text)) => {
1326                assert!(text.contains("mode capture_and_recall"), "got: {text}");
1327            }
1328            other => panic!("expected Some(Output), got {other:?}"),
1329        }
1330        match handle_engine_command("memory", "", &mut runtime) {
1331            Some(CommandResult::Output(text)) => {
1332                assert!(text.contains("mode capture_and_recall"), "got: {text}");
1333                assert!(text.contains("expiry"), "got: {text}");
1334            }
1335            other => panic!("expected Some(Output), got {other:?}"),
1336        }
1337    }
1338}