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