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