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 {
58 model: String,
59 },
60
61 ThinkingChanged {
63 spec: ThinkingSpec,
64 },
65
66 SystemPromptSet {
68 source: String, },
70
71 SystemPromptShow {
73 prompt: String,
74 },
75
76 SessionList {
78 sessions: Vec<SessionSummary>,
79 },
80
81 Cleared,
83
84 Quit,
86
87 Compact {
89 custom_instructions: Option<String>,
90 },
91
92 Resumed {
94 session_id: String,
95 model: String,
96 },
97
98 Named {
100 name: String,
101 },
102
103 ChainInfo(String),
105
106 OpenModal(ModalRequest),
108
109 Status {
111 text: String,
112 },
113
114 PingStarted,
116
117 KeybindList(String),
119
120 SkillLoaded {
122 skill: std::sync::Arc<crate::skills::LoadedSkill>,
123 arg: String,
124 },
125
126 PluginCommand {
128 command: std::sync::Arc<crate::skills::registry::RegisteredPluginCommand>,
129 arg: String,
130 },
131
132 SidecarToggle {
134 plugin_id: Option<String>,
135 },
136 SidecarStatus {
137 plugin_id: Option<String>,
138 },
139}
140
141#[derive(Debug, Clone)]
143pub enum ModalRequest {
144 Models,
145 Settings,
146 Plugins,
147 HelpFind { query: String },
148 Extensions { sub: String },
149}
150
151#[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
162pub 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
176pub fn handle_engine_command(
179 cmd: &str,
180 arg: &str,
181 runtime: &mut crate::Runtime,
182) -> Option<CommandResult> {
183 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 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 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
220pub 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
242pub 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 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
264pub fn thinking_config_value(spec: ThinkingSpec) -> String {
267 spec.config_value()
268}
269
270pub fn context_command(
275 runtime: &crate::Runtime,
276 history: Option<&[crate::SharedMessage]>,
277) -> CommandResult {
278 CommandResult::Output(runtime.context_report(history).render())
279}
280
281pub 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
309pub 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 "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
373pub 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 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 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 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 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 #[test]
597 fn set_reasoning_level_is_not_explicit() {
598 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 let mut rt = crate::Runtime::new_headless();
621 rt.set_model("openai-codex/gpt-5.6-luna".to_string());
622 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 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 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 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 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 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 assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
677 }
678
679 #[test]
682 fn custom_budget_rejected_on_xai_intrinsic_reasoning_leaves_state_unchanged() {
683 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 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 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 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 #[test]
784 fn trace_next_enables_exactly_the_next_request_then_auto_disables() {
785 let runtime = crate::Runtime::new_headless();
786 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 let armed = runtime.effective_trace_context();
799 assert!(armed.enabled(), "armed context must trace the next request");
800 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 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 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 #[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 #[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 assert!(matches!(
912 memory_command("once", &runtime),
913 CommandResult::Error(e) if e.contains("already pending")
914 ));
915 }
916
917 #[test]
920 fn memory_status_render_mentions_mode_and_lease_expiry() {
921 let runtime = crate::Runtime::new_headless();
922 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 #[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 assert_eq!(runtime.memory_context_status().durable, DurableStatus::Off);
953 assert!(text.contains("mode off"), "got: {text}");
955 }
956
957 #[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 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 #[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 #[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 #[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 #[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}