1use agent_core::reasoning::ReasoningLevel;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum ThinkingSpec {
11 Named(ReasoningLevel),
13 Custom { level: ReasoningLevel, budget: u32 },
16}
17
18impl ThinkingSpec {
19 pub fn level(self) -> ReasoningLevel {
21 match self {
22 ThinkingSpec::Named(l) => l,
23 ThinkingSpec::Custom { level, .. } => level,
24 }
25 }
26
27 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 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#[derive(Debug, Clone)]
46pub enum CommandResult {
47 None,
49
50 Output(String),
52
53 Error(String),
55
56 ModelChanged {
60 model: String,
61 reasoning_clamped: Option<crate::runtime::ReasoningClamp>,
62 },
63
64 ThinkingChanged {
66 spec: ThinkingSpec,
67 },
68
69 SystemPromptSet {
71 source: String, },
73
74 SystemPromptShow {
76 prompt: String,
77 },
78
79 SessionList {
81 sessions: Vec<SessionSummary>,
82 },
83
84 Cleared,
86
87 Quit,
89
90 Compact {
92 custom_instructions: Option<String>,
93 },
94
95 Resumed {
97 session_id: String,
98 model: String,
99 },
100
101 Named {
103 name: String,
104 },
105
106 ChainInfo(String),
108
109 OpenModal(ModalRequest),
111
112 Status {
114 text: String,
115 },
116
117 PingStarted,
119
120 KeybindList(String),
122
123 SkillLoaded {
125 skill: std::sync::Arc<crate::skills::LoadedSkill>,
126 arg: String,
127 },
128
129 PluginCommand {
131 command: std::sync::Arc<crate::skills::registry::RegisteredPluginCommand>,
132 arg: String,
133 },
134
135 SidecarToggle {
137 plugin_id: Option<String>,
138 },
139 SidecarStatus {
140 plugin_id: Option<String>,
141 },
142}
143
144#[derive(Debug, Clone)]
146pub enum ModalRequest {
147 Models,
148 Settings,
149 Plugins,
150 HelpFind { query: String },
151 Extensions { sub: String },
152}
153
154#[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
165pub 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
179pub fn handle_engine_command(
182 cmd: &str,
183 arg: &str,
184 runtime: &mut crate::Runtime,
185) -> Option<CommandResult> {
186 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 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 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
226pub 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
249pub 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 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
271pub fn thinking_config_value(spec: ThinkingSpec) -> String {
274 spec.config_value()
275}
276
277pub fn context_command(
282 runtime: &crate::Runtime,
283 history: Option<&[crate::SharedMessage]>,
284) -> CommandResult {
285 CommandResult::Output(runtime.context_report(history).render())
286}
287
288pub 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
316pub 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 "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
380pub 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 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 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 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 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 #[test]
606 fn set_reasoning_level_is_not_explicit() {
607 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 let mut rt = crate::Runtime::new_headless();
630 rt.set_model("openai-codex/gpt-5.6-luna".to_string());
631 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 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 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 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 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 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 assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
686 }
687
688 #[test]
691 fn custom_budget_rejected_on_xai_intrinsic_reasoning_leaves_state_unchanged() {
692 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 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 #[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 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 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 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 #[test]
827 fn trace_next_enables_exactly_the_next_request_then_auto_disables() {
828 let runtime = crate::Runtime::new_headless();
829 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 let armed = runtime.effective_trace_context();
842 assert!(armed.enabled(), "armed context must trace the next request");
843 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 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 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 #[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 #[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 assert!(matches!(
955 memory_command("once", &runtime),
956 CommandResult::Error(e) if e.contains("already pending")
957 ));
958 }
959
960 #[test]
963 fn memory_status_render_mentions_mode_and_lease_expiry() {
964 let runtime = crate::Runtime::new_headless();
965 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 #[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 assert_eq!(runtime.memory_context_status().durable, DurableStatus::Off);
996 assert!(text.contains("mode off"), "got: {text}");
998 }
999
1000 #[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 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 #[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 #[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 #[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 #[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}