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" if !arg.is_empty() => {
192 return Some(match runtime.context_management_command(arg) {
193 Ok(s) => CommandResult::Output(s),
194 Err(e) => CommandResult::Error(e),
195 })
196 }
197 "context" => return Some(context_command(runtime, None)),
198 "trace" => return Some(trace_command(arg, runtime)),
199 "budget" => return Some(budget_command(arg, runtime)),
200 "memory" => return Some(memory_command(arg, runtime)),
201 _ => {}
202 }
203 let mut result = evaluate_engine_command(cmd, arg)?;
204 match &mut result {
205 CommandResult::ModelChanged {
206 model,
207 reasoning_clamped,
208 } => *reasoning_clamped = runtime.set_model(model.clone()),
209 CommandResult::ThinkingChanged { spec } => {
210 match spec {
213 ThinkingSpec::Named(level) => {
214 if let Err(msg) = runtime.set_reasoning_level_checked(*level) {
215 return Some(CommandResult::Error(msg));
216 }
217 }
218 ThinkingSpec::Custom { budget, .. } => {
219 if let Err(msg) = runtime.set_thinking_budget_checked(*budget) {
223 return Some(CommandResult::Error(msg));
224 }
225 }
226 }
227 }
228 _ => {}
229 }
230 Some(result)
231}
232
233pub fn evaluate_engine_command(cmd: &str, arg: &str) -> Option<CommandResult> {
235 match cmd {
236 "model" | "models" if !arg.is_empty() => Some(CommandResult::ModelChanged {
237 model: arg.to_string(),
238 reasoning_clamped: None,
239 }),
240 "thinking" if !arg.is_empty() => match parse_thinking_arg(arg) {
241 Ok(spec) => Some(CommandResult::ThinkingChanged { spec }),
242 Err(e) => Some(CommandResult::Error(e)),
243 },
244 "quit" | "exit" => Some(CommandResult::Quit),
245 "compact" => Some(CommandResult::Compact {
246 custom_instructions: if arg.is_empty() {
247 None
248 } else {
249 Some(arg.to_string())
250 },
251 }),
252 _ => None,
253 }
254}
255
256pub fn parse_thinking_arg(arg: &str) -> Result<ThinkingSpec, String> {
258 match ReasoningLevel::parse(arg) {
259 Some(level) => Ok(ThinkingSpec::Named(level)),
260 None => {
261 if let Ok(n) = arg.trim().parse::<u32>() {
262 Ok(ThinkingSpec::Custom {
264 level: ReasoningLevel::from_legacy_budget(n),
265 budget: n,
266 })
267 } else {
268 Err(format!(
269 "unknown thinking level: {} \
270 (use off/adaptive/low/medium/high/xhigh/max/ultra/ultracode or a number)",
271 arg
272 ))
273 }
274 }
275 }
276}
277
278pub fn thinking_config_value(spec: ThinkingSpec) -> String {
281 spec.config_value()
282}
283
284pub fn context_command(
289 runtime: &crate::Runtime,
290 history: Option<&[crate::SharedMessage]>,
291) -> CommandResult {
292 CommandResult::Output(format!(
293 "{}\n{}",
294 runtime.context_report(history).render(),
295 runtime.context_management_status()
296 ))
297}
298
299pub fn budget_command(arg: &str, runtime: &mut crate::Runtime) -> CommandResult {
303 const USAGE: &str = "usage: /budget [status] | /budget time <duration> (positive whole seconds/minutes/hours, e.g. 30m or 4h; maximum 24h) | /budget time off";
304 let words: Vec<_> = arg.split_whitespace().collect();
305 match words.as_slice() {
306 [] | ["status"] => {
307 let budget = runtime.turn_budget();
308 CommandResult::Output(format!(
309 "Turn budget (configured for future turns, not live usage):\n\
310 wall-clock: {}; provider rounds: {} (+{} bounded renewals); tool calls: {}; tool-result bytes: {}\n\
311 context tokens: {}; cost USD: {}\n\
312 Committed context rollovers reset only elapsed time; other limits remain cumulative. /budget time 4h (or off) changes only the next-turn time allowance in this runtime; it is not saved to config or session storage.",
313 budget
314 .max_elapsed_secs()
315 .map_or_else(|| "off".into(), |s| format!("{s}s")),
316 budget.max_provider_rounds,
317 budget.max_round_renewals,
318 budget.max_tool_calls,
319 budget.max_accumulated_tool_result_bytes,
320 budget.max_context_tokens.map_or_else(|| "unset".into(), |v| v.to_string()),
321 budget.max_cost_usd.map_or_else(|| "unset".into(), |v| v.to_string()),
322 ))
323 }
324 ["time", "off"] => {
325 let mut budget = runtime.turn_budget().clone();
326 budget.max_elapsed = None;
327 runtime.set_turn_budget(budget);
328 CommandResult::Output(
329 "Turn wall-clock limit removed for future turns in this runtime only (not saved). Running turns, worker budgets and all other limits are unchanged.".into(),
330 )
331 }
332 ["time", duration] => {
333 let Some(seconds) = parse_turn_duration(duration) else {
334 return CommandResult::Error(USAGE.into());
335 };
336 let mut budget = runtime.turn_budget().clone();
337 budget.max_elapsed = Some(std::time::Duration::from_secs(seconds));
338 runtime.set_turn_budget(budget);
339 CommandResult::Output(format!(
340 "Turn wall-clock limit set to {seconds}s for future turns in this runtime only (not saved). Running turns, worker budgets and all other limits are unchanged. Send a prompt to continue retained history; this command does not start a turn."
341 ))
342 }
343 _ => CommandResult::Error(USAGE.into()),
344 }
345}
346
347fn parse_turn_duration(value: &str) -> Option<u64> {
348 let (digits, multiplier) = if let Some(digits) = value.strip_suffix('s') {
349 (digits, 1)
350 } else if let Some(digits) = value.strip_suffix('m') {
351 (digits, 60)
352 } else if let Some(digits) = value.strip_suffix('h') {
353 (digits, 3600)
354 } else {
355 return None;
356 };
357 if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
358 return None;
359 }
360 let seconds = digits.parse::<u64>().ok()?.checked_mul(multiplier)?;
361 (1..=86_400).contains(&seconds).then_some(seconds)
362}
363
364pub fn trace_command(arg: &str, runtime: &crate::Runtime) -> CommandResult {
366 match arg.trim() {
367 "next" => {
368 runtime.trace_arm_next(false);
369 CommandResult::Output(
370 "trace armed for the next request (metadata only, then auto-disabled)".to_string(),
371 )
372 }
373 "next content" => {
374 runtime.trace_arm_next(true);
375 CommandResult::Output(
376 "trace armed for the next request WITH redacted content capture.\n\
377 The request body (never headers/credentials) is recursively \
378 redacted and kept in a private, short-lived capture bundle; \
379 export it with `synaps trace export <request-id> \
380 --include-content --allow-content-export --output PATH` \
381 before it expires."
382 .to_string(),
383 )
384 }
385 "" | "status" => CommandResult::Output(runtime.trace_status().render()),
386 other => CommandResult::Error(format!(
387 "unknown /trace subcommand: `{other}` (try: next, next content, status)"
388 )),
389 }
390}
391
392pub fn memory_command(arg: &str, runtime: &crate::Runtime) -> CommandResult {
403 use crate::runtime::memory_context::{mint_explicit_command_proof, MemoryContextMode};
404 let enable = |mode: MemoryContextMode| match runtime
405 .memory_context_enable(mode, mint_explicit_command_proof())
406 {
407 Ok(status) => CommandResult::Output(status.render()),
408 Err(e) => CommandResult::Error(e.to_string()),
409 };
410 match arg.trim() {
411 "on" => enable(MemoryContextMode::CaptureAndRecall),
412 "recall" => enable(MemoryContextMode::RecallEachPrompt),
413 "capture" => enable(MemoryContextMode::CaptureOnly),
414 "once" => match runtime.memory_context_recall_once(mint_explicit_command_proof()) {
415 Ok(status) => CommandResult::Output(status.render()),
416 Err(e) => CommandResult::Error(e.to_string()),
417 },
418 "" | "status" => CommandResult::Output(runtime.memory_context_status().render()),
419 "off" => CommandResult::Output(runtime.memory_context_disable().render()),
422 "index-history" => match runtime.memory_history_preview() {
423 Ok(preview) => CommandResult::Output(preview.render()),
424 Err(error) => CommandResult::Error(error.to_string()),
425 },
426 "index-history confirm" => match runtime.memory_history_confirm() {
427 Ok(report) => CommandResult::Output(format!(
428 "History import complete: {} session(s) loaded, {} bounded capture record(s) submitted in {} batch(es).",
429 report.sessions_loaded, report.captures_built, report.batches_submitted,
430 )),
431 Err(error) => CommandResult::Error(error.to_string()),
432 },
433 "index-history decline" => {
434 if runtime.memory_history_decline() {
435 CommandResult::Output(
436 "History import declined; no session content was read and no Axel data was written."
437 .to_string(),
438 )
439 } else {
440 CommandResult::Error(
441 "history import requires a pending disclosure preview before decline"
442 .to_string(),
443 )
444 }
445 }
446 "why" => CommandResult::Output(crate::runtime::memory_context::render_recall_why(
447 runtime.memory_recall_why().as_ref(),
448 )),
449 other => CommandResult::Error(format!(
450 "unknown /memory subcommand: `{other}` \
451 (try: on, recall, capture, once, status, off, index-history, why)"
452 )),
453 }
454}
455
456pub fn persist_to_config(key: &str, value: &str) -> String {
458 match crate::config::write_config_value(key, value) {
459 Ok(()) => "(saved to config)".to_string(),
460 Err(e) => format!("(session only — failed to persist: {})", e),
461 }
462}
463
464#[cfg(test)]
465mod tests {
466 use super::*;
467
468 #[test]
469 fn model_command_carries_model_name() {
470 match evaluate_engine_command("model", "claude-sonnet-4-6") {
471 Some(CommandResult::ModelChanged { model, .. }) => {
472 assert_eq!(model, "claude-sonnet-4-6")
473 }
474 other => panic!("expected ModelChanged, got {:?}", other),
475 }
476 assert!(matches!(
477 evaluate_engine_command("models", "claude-opus-4-6"),
478 Some(CommandResult::ModelChanged { .. })
479 ));
480 assert!(evaluate_engine_command("model", "").is_none());
481 }
482
483 #[test]
484 fn thinking_command_normalizes_levels() {
485 match evaluate_engine_command("thinking", "high") {
486 Some(CommandResult::ThinkingChanged { spec }) => {
487 assert_eq!(spec.level(), ReasoningLevel::High);
488 assert_eq!(spec.budget(), Some(16384));
489 assert!(matches!(spec, ThinkingSpec::Named(ReasoningLevel::High)));
490 }
491 other => panic!("expected ThinkingChanged, got {:?}", other),
492 }
493 let med = parse_thinking_arg("med").unwrap();
494 assert_eq!(med.level(), ReasoningLevel::Medium);
495 assert_eq!(med.budget(), Some(4096));
496
497 let custom = parse_thinking_arg("8192").unwrap();
499 assert!(
500 matches!(
501 custom,
502 ThinkingSpec::Custom {
503 level: ReasoningLevel::High,
504 budget: 8192
505 }
506 ),
507 "expected Custom{{High, 8192}}, got {:?}",
508 custom
509 );
510 assert_eq!(custom.budget(), Some(8192));
511
512 assert!(parse_thinking_arg("bogus").is_err());
513 assert!(evaluate_engine_command("thinking", "").is_none());
514 }
515
516 #[test]
517 fn thinking_command_off_and_adaptive_are_distinct() {
518 let off = parse_thinking_arg("off").unwrap();
519 let adp = parse_thinking_arg("adaptive").unwrap();
520 assert_eq!(off.level(), ReasoningLevel::Off);
521 assert_eq!(off.budget(), Some(0));
522 assert_eq!(adp.level(), ReasoningLevel::Adaptive);
523 assert_eq!(adp.budget(), Some(0));
524 assert_ne!(off.level(), adp.level());
525 }
526
527 #[test]
528 fn max_and_ultra_have_no_budget() {
529 let max_spec = parse_thinking_arg("max").unwrap();
530 assert_eq!(max_spec.level(), ReasoningLevel::Max);
531 assert_eq!(max_spec.budget(), None, "max has no numeric budget");
532
533 let ultra_spec = parse_thinking_arg("ultra").unwrap();
534 assert_eq!(ultra_spec.level(), ReasoningLevel::Ultra);
535 assert_eq!(ultra_spec.budget(), None, "ultra has no numeric budget");
536
537 let xhigh_spec = parse_thinking_arg("xhigh").unwrap();
539 assert_eq!(xhigh_spec.level(), ReasoningLevel::XHigh);
540 assert_eq!(xhigh_spec.budget(), Some(32768));
541 }
542
543 #[test]
544 fn thinking_config_value_is_named_for_named_levels() {
545 assert_eq!(
546 thinking_config_value(ThinkingSpec::Named(ReasoningLevel::Off)),
547 "off"
548 );
549 assert_eq!(
550 thinking_config_value(ThinkingSpec::Named(ReasoningLevel::Adaptive)),
551 "adaptive"
552 );
553 assert_eq!(
554 thinking_config_value(ThinkingSpec::Named(ReasoningLevel::Low)),
555 "low"
556 );
557 assert_eq!(
558 thinking_config_value(ThinkingSpec::Named(ReasoningLevel::Medium)),
559 "medium"
560 );
561 assert_eq!(
562 thinking_config_value(ThinkingSpec::Named(ReasoningLevel::High)),
563 "high"
564 );
565 assert_eq!(
566 thinking_config_value(ThinkingSpec::Named(ReasoningLevel::XHigh)),
567 "xhigh"
568 );
569 assert_eq!(
570 thinking_config_value(ThinkingSpec::Named(ReasoningLevel::Max)),
571 "max"
572 );
573 assert_eq!(
574 thinking_config_value(ThinkingSpec::Named(ReasoningLevel::Ultra)),
575 "ultra"
576 );
577 assert_eq!(
578 thinking_config_value(ThinkingSpec::Named(ReasoningLevel::UltraCode)),
579 "ultracode"
580 );
581 }
582
583 #[test]
584 fn thinking_config_value_is_exact_digits_for_custom_budget() {
585 let spec = ThinkingSpec::Custom {
587 level: ReasoningLevel::High,
588 budget: 8192,
589 };
590 assert_eq!(thinking_config_value(spec), "8192");
591 let spec2 = ThinkingSpec::Custom {
592 level: ReasoningLevel::Medium,
593 budget: 3000,
594 };
595 assert_eq!(thinking_config_value(spec2), "3000");
596 }
597
598 #[test]
599 fn compact_carries_custom_instructions() {
600 match evaluate_engine_command("compact", "focus on auth") {
601 Some(CommandResult::Compact {
602 custom_instructions,
603 }) => {
604 assert_eq!(custom_instructions.as_deref(), Some("focus on auth"));
605 }
606 other => panic!("expected Compact, got {:?}", other),
607 }
608 assert!(matches!(
609 evaluate_engine_command("compact", ""),
610 Some(CommandResult::Compact {
611 custom_instructions: None
612 })
613 ));
614 }
615
616 #[test]
617 #[serial_test::serial(synaps_base_dir)]
618 fn persist_to_config_reports_write_result() {
619 let home = std::path::PathBuf::from("/tmp/synaps-engine-persist-test");
620 let _ = std::fs::remove_dir_all(&home);
621 std::fs::create_dir_all(home.join(".synaps-cli")).unwrap();
622 let original = std::env::var("HOME").ok();
623 std::env::set_var("HOME", &home);
624
625 let status = persist_to_config("model", "claude-sonnet-4-6");
626
627 if let Some(h) = original {
628 std::env::set_var("HOME", h);
629 } else {
630 std::env::remove_var("HOME");
631 }
632
633 assert_eq!(status, "(saved to config)");
634 let contents = std::fs::read_to_string(home.join(".synaps-cli/config")).unwrap();
635 assert!(contents.contains("model = claude-sonnet-4-6"));
636 let _ = std::fs::remove_dir_all(&home);
637 }
638
639 #[test]
640 fn validate_level_codex_sol_accepts_ultra() {
641 let mut rt = crate::Runtime::new_headless();
642 rt.set_model("openai-codex/gpt-5.6-sol".to_string());
643 assert!(rt
644 .set_reasoning_level_checked(ReasoningLevel::Ultra)
645 .is_ok());
646 }
647
648 #[test]
649 fn validate_level_codex_luna_rejects_ultra_leaves_state_unchanged() {
650 let mut rt = crate::Runtime::new_headless();
651 rt.set_model("openai-codex/gpt-5.6-luna".to_string());
652 rt.set_reasoning_level(ReasoningLevel::Low);
653 let err = rt
654 .set_reasoning_level_checked(ReasoningLevel::Ultra)
655 .unwrap_err();
656 assert!(err.contains("ultra"));
657 assert!(err.contains("gpt-5.6-luna"));
658 assert_eq!(rt.reasoning_level(), ReasoningLevel::Low);
660 }
661
662 #[test]
663 fn validate_level_non_codex_rejects_ultra() {
664 for model in [
665 "claude-sonnet-4-6",
666 "anthropic/claude-opus-4-7",
667 "groq/llama-3",
668 ] {
669 let mut rt = crate::Runtime::new_headless();
670 rt.set_model(model.to_string());
671 assert!(
672 rt.set_reasoning_level_checked(ReasoningLevel::Ultra)
673 .is_err(),
674 "non-Codex {model} must not gain ultra without exact metadata"
675 );
676 }
677 }
678
679 #[test]
682 fn set_reasoning_level_is_not_explicit() {
683 let mut rt = crate::Runtime::new_headless();
685 rt.set_reasoning_level(ReasoningLevel::High);
686 assert!(
687 !rt.is_reasoning_explicit(),
688 "set_reasoning_level must not mark explicit"
689 );
690 }
691
692 #[test]
693 fn set_reasoning_level_explicit_marks_flag() {
694 let mut rt = crate::Runtime::new_headless();
695 rt.set_reasoning_level_explicit(ReasoningLevel::High);
696 assert!(
697 rt.is_reasoning_explicit(),
698 "set_reasoning_level_explicit must mark explicit"
699 );
700 }
701
702 #[test]
703 fn set_model_overwrites_non_explicit_codex_default() {
704 let mut rt = crate::Runtime::new_headless();
706 rt.set_model("openai-codex/gpt-5.6-luna".to_string());
707 assert_eq!(rt.reasoning_level(), ReasoningLevel::Medium);
709 assert!(!rt.is_reasoning_explicit());
710 }
711
712 #[test]
713 fn set_model_preserves_explicit_user_choice() {
714 let mut rt = crate::Runtime::new_headless();
716 rt.set_reasoning_level_explicit(ReasoningLevel::Low);
717 rt.set_model("openai-codex/gpt-5.6-sol".to_string());
718 assert!(
720 rt.is_reasoning_explicit(),
721 "explicit flag must survive set_model"
722 );
723 }
724
725 #[test]
726 fn apply_config_thinking_sets_explicit() {
727 use crate::config::SynapsConfig;
729 let mut rt = crate::Runtime::new_headless();
730 let config = SynapsConfig {
731 thinking_level: Some(ReasoningLevel::XHigh),
732 ..Default::default()
733 };
734 rt.apply_config(&config);
735 assert!(
736 rt.is_reasoning_explicit(),
737 "config thinking must be explicit"
738 );
739 assert_eq!(rt.reasoning_level(), ReasoningLevel::XHigh);
740 rt.set_model("openai-codex/gpt-5.6-luna".to_string());
742 assert_eq!(
743 rt.reasoning_level(),
744 ReasoningLevel::XHigh,
745 "explicit config level must survive set_model"
746 );
747 }
748
749 #[test]
750 fn set_thinking_budget_explicit_retains_exact_budget_and_marks_flag() {
751 let mut rt = crate::Runtime::new_headless();
753 rt.set_thinking_budget_explicit(8192);
754 assert_eq!(
755 rt.thinking_budget_raw(),
756 8192,
757 "exact budget must be retained"
758 );
759 assert!(rt.is_reasoning_explicit());
760 assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
762 }
763
764 #[test]
767 fn custom_budget_rejected_on_xai_intrinsic_reasoning_leaves_state_unchanged() {
768 let mut rt = crate::Runtime::new_headless();
771 rt.set_model("xai-auth/grok-4.3".to_string());
772 let before_budget = rt.thinking_budget_raw();
773 let before_level = rt.reasoning_level();
774 let before_explicit = rt.is_reasoning_explicit();
775 match handle_engine_command("thinking", "8192", &mut rt) {
776 Some(CommandResult::Error(msg)) => {
777 assert!(
778 msg.contains("xai-auth/grok-4.3"),
779 "message names model: {msg}"
780 )
781 }
782 other => panic!("expected Error, got {:?}", other),
783 }
784 assert_eq!(
785 rt.thinking_budget_raw(),
786 before_budget,
787 "budget must be unchanged"
788 );
789 assert_eq!(
790 rt.reasoning_level(),
791 before_level,
792 "level must be unchanged"
793 );
794 assert_eq!(
795 rt.is_reasoning_explicit(),
796 before_explicit,
797 "provenance unchanged"
798 );
799 }
800
801 #[test]
802 fn custom_budget_rejected_on_xai_non_reasoning_model() {
803 let mut rt = crate::Runtime::new_headless();
804 rt.set_model("xai-auth/grok-4.20-0309-non-reasoning".to_string());
805 let before_budget = rt.thinking_budget_raw();
806 match handle_engine_command("thinking", "8192", &mut rt) {
807 Some(CommandResult::Error(msg)) => {
808 assert!(msg.contains("non-reasoning"), "{msg}")
809 }
810 other => panic!("expected Error, got {:?}", other),
811 }
812 assert_eq!(
813 rt.thinking_budget_raw(),
814 before_budget,
815 "state must be unchanged"
816 );
817 }
818
819 #[test]
820 fn custom_budget_maps_to_high_and_is_accepted_on_xai_45() {
821 let mut rt = crate::Runtime::new_headless();
823 rt.set_model("xai-auth/grok-4.5".to_string());
824 match handle_engine_command("thinking", "8192", &mut rt) {
825 Some(CommandResult::ThinkingChanged { spec }) => {
826 assert_eq!(spec.level(), ReasoningLevel::High);
827 assert_eq!(spec.budget(), Some(8192));
828 }
829 other => panic!("expected ThinkingChanged, got {:?}", other),
830 }
831 assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
832 assert!(rt.is_reasoning_explicit());
833 }
834
835 #[test]
838 fn model_command_reports_reasoning_clamp_for_unsupported_explicit_level() {
839 let mut rt = crate::Runtime::new_headless();
840 handle_engine_command("thinking", "xhigh", &mut rt).expect("thinking applies");
841 assert_eq!(rt.reasoning_level(), ReasoningLevel::XHigh);
842 match handle_engine_command("model", "xai-auth/grok-4.6", &mut rt) {
843 Some(CommandResult::ModelChanged {
844 model,
845 reasoning_clamped,
846 }) => {
847 assert_eq!(model, "xai-auth/grok-4.6");
848 assert_eq!(
849 reasoning_clamped,
850 Some(crate::runtime::ReasoningClamp {
851 from: ReasoningLevel::XHigh,
852 to: ReasoningLevel::High,
853 })
854 );
855 }
856 other => panic!("expected ModelChanged, got {:?}", other),
857 }
858 assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
859 assert!(rt.is_reasoning_explicit());
860 match handle_engine_command("model", "xai-auth/grok-4.5", &mut rt) {
862 Some(CommandResult::ModelChanged {
863 reasoning_clamped, ..
864 }) => assert_eq!(reasoning_clamped, None),
865 other => panic!("expected ModelChanged, got {:?}", other),
866 }
867 }
868
869 #[test]
870 fn custom_budget_preserved_exactly_on_anthropic_fixed_budget_model() {
871 let mut rt = crate::Runtime::new_headless();
874 rt.set_model("claude-sonnet-4-6".to_string());
875 match handle_engine_command("thinking", "8192", &mut rt) {
876 Some(CommandResult::ThinkingChanged { spec }) => {
877 assert_eq!(spec.config_value(), "8192", "persists exact digits");
878 }
879 other => panic!("expected ThinkingChanged, got {:?}", other),
880 }
881 assert_eq!(
882 rt.thinking_budget_raw(),
883 8192,
884 "exact budget must be retained"
885 );
886 assert!(rt.is_reasoning_explicit());
887 }
888
889 #[test]
890 fn custom_budget_config_value_is_digits_not_named() {
891 let spec = parse_thinking_arg("8192").unwrap();
893 assert_eq!(
894 spec.config_value(),
895 "8192",
896 "custom budget must persist as digits, not named level"
897 );
898 }
899
900 #[test]
903 fn trace_next_enables_exactly_the_next_request_then_auto_disables() {
904 let runtime = crate::Runtime::new_headless();
905 assert!(!runtime.trace_context().enabled());
907
908 match trace_command("next", &runtime) {
909 CommandResult::Output(text) => assert!(text.contains("next request")),
910 other => panic!("expected Output, got {other:?}"),
911 }
912 let status = runtime.trace_status();
913 assert!(!status.persistent_enabled);
914 assert_eq!(status.arm, crate::runtime::trace::TraceArm::NextMetadata);
915
916 let armed = runtime.effective_trace_context();
918 assert!(armed.enabled(), "armed context must trace the next request");
919 let after = runtime.effective_trace_context();
921 assert!(!after.enabled(), "arm must cover exactly one request");
922 assert_eq!(
923 runtime.trace_status().arm,
924 crate::runtime::trace::TraceArm::Off
925 );
926 }
927
928 #[test]
929 fn trace_status_reports_mode_path_and_counters_without_secrets() {
930 let runtime = crate::Runtime::new_headless();
931 match trace_command("status", &runtime) {
932 CommandResult::Output(text) => {
933 assert!(text.contains("trace: disabled"), "got: {text}");
934 assert!(text.contains("degraded"));
935 }
936 other => panic!("expected Output, got {other:?}"),
937 }
938 runtime.trace_arm_next(true);
939 match trace_command("", &runtime) {
940 CommandResult::Output(text) => {
941 assert!(text.contains("armed for next request"), "got: {text}");
942 assert!(text.contains("content capture"));
943 }
944 other => panic!("expected Output, got {other:?}"),
945 }
946 assert!(matches!(
947 trace_command("bogus", &runtime),
948 CommandResult::Error(_)
949 ));
950 }
951
952 #[test]
953 fn context_command_reports_metadata_never_content() {
954 let mut runtime = crate::Runtime::new_headless();
955 let secret = "CONTEXT-SENTINEL-5a5a";
956 runtime.set_system_prompt(format!("you are {secret}"));
957 let history = vec![std::sync::Arc::new(serde_json::json!({
958 "role": "user", "content": secret
959 }))];
960 match context_command(&runtime, Some(&history)) {
961 CommandResult::Output(text) => {
962 assert!(!text.contains(secret), "sentinel leaked: {text}");
963 assert!(text.contains("history: 1 messages"));
964 assert!(text.contains("system prompt:"));
965 assert!(text.contains("skills: unavailable"));
966 }
967 other => panic!("expected Output, got {other:?}"),
968 }
969 match handle_engine_command("context", "", &mut runtime) {
971 Some(CommandResult::Output(text)) => {
972 assert!(
973 text.contains("history: unavailable messages"),
974 "got: {text}"
975 );
976 }
977 other => panic!("expected Output, got {other:?}"),
978 }
979 }
980
981 #[test]
982 fn budget_duration_parser_is_strict_and_bounded() {
983 for (input, seconds) in [
984 ("1s", 1),
985 ("30m", 1800),
986 ("4h", 14400),
987 ("24h", 86400),
988 ("1440m", 86400),
989 ("86400s", 86400),
990 ] {
991 assert_eq!(parse_turn_duration(input), Some(seconds), "{input}");
992 }
993 for input in [
994 "",
995 "s",
996 "0s",
997 "0h",
998 "25h",
999 "86401s",
1000 "1441m",
1001 "4",
1002 "4H",
1003 "-1h",
1004 "+1h",
1005 "1.5h",
1006 "1h30m",
1007 " 4h",
1008 "4 h",
1009 "4h ",
1010 "1e3s",
1011 "∞h",
1012 "12h",
1013 "18446744073709551615h",
1014 "18446744073709551616s",
1015 "off",
1016 "unlimited",
1017 ] {
1018 assert_eq!(parse_turn_duration(input), None, "{input}");
1019 }
1020 }
1021
1022 #[test]
1023 fn budget_command_status_and_invalid_arguments_do_not_mutate() {
1024 let mut runtime = crate::Runtime::new_headless();
1025 let original = runtime.turn_budget().clone();
1026 for arg in ["", "status", " status "] {
1027 let Some(CommandResult::Output(text)) =
1028 handle_engine_command("budget", arg, &mut runtime)
1029 else {
1030 panic!("{arg}");
1031 };
1032 assert!(text.contains("wall-clock: off;"));
1033 assert!(text.contains("not live usage"));
1034 assert!(text.contains("tool calls: 512"));
1035 assert_eq!(runtime.turn_budget(), &original);
1036 }
1037 for arg in [
1038 "time",
1039 "time 0s",
1040 "time 25h",
1041 "time 1h trailing",
1042 "status 4h",
1043 "time off now",
1044 "off",
1045 "reset",
1046 ] {
1047 assert!(
1048 matches!(
1049 handle_engine_command("budget", arg, &mut runtime),
1050 Some(CommandResult::Error(_))
1051 ),
1052 "{arg}"
1053 );
1054 assert_eq!(runtime.turn_budget(), &original);
1055 }
1056 }
1057
1058 #[test]
1059 fn budget_command_changes_only_future_runtime_time_limit() {
1060 use crate::runtime::budget::{TurnBudget, TurnBudgetMeter, TurnRole};
1061 let mut runtime = crate::Runtime::new_headless();
1062 let original = TurnBudget {
1063 max_elapsed: Some(std::time::Duration::ZERO),
1064 max_tool_calls: 11,
1065 max_context_tokens: Some(456),
1066 max_cost_usd: Some(0.75),
1067 ..TurnBudget::for_role(TurnRole::Foreground)
1068 };
1069 runtime.set_turn_budget(original.clone());
1070 let mut in_flight = TurnBudgetMeter::new(runtime.turn_budget().clone());
1071 let other_runtime = runtime.clone();
1072 let Some(CommandResult::Output(text)) =
1073 handle_engine_command("budget", "time 4h", &mut runtime)
1074 else {
1075 panic!("expected output");
1076 };
1077 assert!(text.contains("14400s"));
1078 assert!(text.contains("not saved"));
1079 let mut expected = original.clone();
1080 expected.max_elapsed = Some(std::time::Duration::from_secs(14400));
1081 assert_eq!(runtime.turn_budget(), &expected);
1082 assert_eq!(other_runtime.turn_budget(), &original);
1083 assert_eq!(
1084 in_flight.begin_round(),
1085 Err(agent_core::BudgetDimension::WallClock)
1086 );
1087 assert!(TurnBudgetMeter::new(runtime.turn_budget().clone())
1088 .begin_round()
1089 .is_ok());
1090 let Some(CommandResult::Output(text)) =
1091 handle_engine_command("budget", "time off", &mut runtime)
1092 else {
1093 panic!("expected output");
1094 };
1095 assert!(text.contains("removed"));
1096 expected.max_elapsed = None;
1097 assert_eq!(runtime.turn_budget(), &expected);
1098 assert_eq!(
1100 crate::Runtime::new_headless().turn_budget().max_elapsed,
1101 None
1102 );
1103 for role in [TurnRole::Foreground, TurnRole::Worker, TurnRole::Autonomous] {
1104 assert_eq!(TurnBudget::for_role(role).max_elapsed, None);
1105 }
1106 assert!(crate::skills::BUILTIN_COMMANDS.contains(&"budget"));
1107 }
1108
1109 use crate::runtime::memory_context::{
1112 DurableStatus, MemoryContextMode, OneShotStatus, UserIntentProof,
1113 };
1114
1115 fn output_text(result: CommandResult) -> String {
1116 match result {
1117 CommandResult::Output(text) => text,
1118 other => panic!("expected Output, got {other:?}"),
1119 }
1120 }
1121
1122 #[test]
1126 fn memory_enable_args_transition_to_exact_modes() {
1127 for (arg, mode) in [
1128 ("on", MemoryContextMode::CaptureAndRecall),
1129 ("recall", MemoryContextMode::RecallEachPrompt),
1130 ("capture", MemoryContextMode::CaptureOnly),
1131 ] {
1132 let runtime = crate::Runtime::new_headless();
1133 let text = output_text(memory_command(arg, &runtime));
1134 assert!(
1135 text.contains(&format!("mode {}", mode.as_str())),
1136 "/memory {arg} output must name the mode; got: {text}"
1137 );
1138 match runtime.memory_context_status().durable {
1139 DurableStatus::Active { mode: active, .. } => {
1140 assert_eq!(active, mode, "/memory {arg} must install exactly {mode:?}")
1141 }
1142 DurableStatus::Off => panic!("/memory {arg} must install a session lease"),
1143 }
1144 }
1145 }
1146
1147 #[test]
1150 fn memory_once_installs_pending_one_shot_only() {
1151 let runtime = crate::Runtime::new_headless();
1152 let text = output_text(memory_command("once", &runtime));
1153 assert!(text.contains("one-shot recall: pending"), "got: {text}");
1154 let status = runtime.memory_context_status();
1155 assert_eq!(status.durable, DurableStatus::Off, "durable slot untouched");
1156 assert!(matches!(status.one_shot, OneShotStatus::Pending { .. }));
1157 assert!(matches!(
1159 memory_command("once", &runtime),
1160 CommandResult::Error(e) if e.contains("already pending")
1161 ));
1162 }
1163
1164 #[test]
1167 fn memory_status_render_mentions_mode_and_lease_expiry() {
1168 let runtime = crate::Runtime::new_headless();
1169 let text = output_text(memory_command("status", &runtime));
1171 assert!(text.contains("mode off"), "got: {text}");
1172
1173 output_text(memory_command("on", &runtime));
1174 for arg in ["status", ""] {
1175 let text = output_text(memory_command(arg, &runtime));
1176 assert!(
1177 text.contains("mode capture_and_recall"),
1178 "status must mention the mode; got: {text}"
1179 );
1180 assert!(
1181 text.contains("expiry"),
1182 "status must mention the lease expiry; got: {text}"
1183 );
1184 }
1185 }
1186
1187 #[test]
1190 fn memory_off_takes_effect_before_returning() {
1191 let runtime = crate::Runtime::new_headless();
1192 output_text(memory_command("on", &runtime));
1193 assert!(matches!(
1194 runtime.memory_context_status().durable,
1195 DurableStatus::Active { .. }
1196 ));
1197 let text = output_text(memory_command("off", &runtime));
1198 assert_eq!(runtime.memory_context_status().durable, DurableStatus::Off);
1200 assert!(text.contains("mode off"), "got: {text}");
1202 }
1203
1204 #[test]
1209 fn memory_why_without_recall_metadata_is_clear_output_not_error() {
1210 let runtime = crate::Runtime::new_headless();
1211 let text = output_text(memory_command("why", &runtime));
1212 assert!(text.contains("no recall metadata available"), "got: {text}");
1213 assert!(text.contains("memory is off"), "got: {text}");
1214 assert!(text.contains("no eligible prompt"), "got: {text}");
1215 assert!(text.contains("skipped"), "got: {text}");
1216
1217 output_text(memory_command("on", &runtime));
1219 let text = output_text(memory_command("why", &runtime));
1220 assert!(text.contains("no recall metadata available"), "got: {text}");
1221 }
1222
1223 #[test]
1226 fn memory_enable_always_uses_explicit_command_proof() {
1227 for arg in ["on", "recall", "capture"] {
1228 let runtime = crate::Runtime::new_headless();
1229 output_text(memory_command(arg, &runtime));
1230 assert!(
1231 matches!(
1232 runtime.memory_durable_proof_for_test(),
1233 Some(UserIntentProof::ExplicitCommand { .. })
1234 ),
1235 "/memory {arg} must grant under ExplicitCommand proof"
1236 );
1237 }
1238 let runtime = crate::Runtime::new_headless();
1239 output_text(memory_command("once", &runtime));
1240 assert!(
1241 matches!(
1242 runtime.memory_one_shot_proof_for_test(),
1243 Some(UserIntentProof::ExplicitCommand { .. })
1244 ),
1245 "/memory once must grant under ExplicitCommand proof"
1246 );
1247 }
1248
1249 #[test]
1251 fn memory_unknown_arg_is_typed_error() {
1252 let runtime = crate::Runtime::new_headless();
1253 match memory_command("bogus", &runtime) {
1254 CommandResult::Error(e) => {
1255 assert!(e.contains("unknown /memory subcommand"), "got: {e}");
1256 assert!(e.contains("index-history"), "hint lists forms; got: {e}");
1257 }
1258 other => panic!("expected Error, got {other:?}"),
1259 }
1260 assert_eq!(runtime.memory_context_status().durable, DurableStatus::Off);
1261 }
1262
1263 #[test]
1266 fn memory_index_history_surfaces_disclosure_preview() {
1267 let runtime = crate::Runtime::new_headless();
1268 match memory_command("index-history", &runtime) {
1269 CommandResult::Output(text) => {
1270 assert!(text.contains("History import preview"), "got: {text}");
1271 assert!(text.contains("project:"), "got: {text}");
1272 assert!(text.contains("sessions:"), "got: {text}");
1273 assert!(text.contains("approximate bytes:"), "got: {text}");
1274 assert!(text.contains("included date range:"), "got: {text}");
1275 assert!(text.contains("included classes:"), "got: {text}");
1276 assert!(text.contains("excluded classes:"), "got: {text}");
1277 assert!(text.contains("retention policy:"), "got: {text}");
1278 assert!(text.contains("redaction policy:"), "got: {text}");
1279 assert!(text.contains("destination .r8:"), "got: {text}");
1280 assert!(
1281 text.contains("explicit confirmation required: true"),
1282 "got: {text}"
1283 );
1284 assert!(text.contains("no import has started"), "got: {text}");
1285 }
1286 other => panic!("expected Output, got {other:?}"),
1287 }
1288 let status = runtime.memory_context_status();
1289 assert_eq!(status.durable, DurableStatus::Off);
1290 assert_eq!(status.one_shot, OneShotStatus::Idle);
1291 }
1292
1293 #[test]
1294 fn memory_index_history_decline_clears_pending_preview() {
1295 let runtime = crate::Runtime::new_headless();
1296 assert!(matches!(
1297 memory_command("index-history", &runtime),
1298 CommandResult::Output(_)
1299 ));
1300 assert!(matches!(
1301 memory_command("index-history decline", &runtime),
1302 CommandResult::Output(text) if text.contains("no session content was read")
1303 ));
1304 assert!(matches!(
1305 memory_command("index-history confirm", &runtime),
1306 CommandResult::Error(error) if error.contains("explicit user confirmation")
1307 ));
1308 }
1309
1310 #[test]
1311 fn memory_index_history_confirmation_requires_prior_preview() {
1312 let runtime = crate::Runtime::new_headless();
1313 assert!(matches!(
1314 memory_command("index-history confirm", &runtime),
1315 CommandResult::Error(error) if error.contains("explicit user confirmation")
1316 ));
1317 }
1318
1319 #[test]
1322 fn memory_dispatches_through_handle_engine_command() {
1323 let mut runtime = crate::Runtime::new_headless();
1324 match handle_engine_command("memory", "on", &mut runtime) {
1325 Some(CommandResult::Output(text)) => {
1326 assert!(text.contains("mode capture_and_recall"), "got: {text}");
1327 }
1328 other => panic!("expected Some(Output), got {other:?}"),
1329 }
1330 match handle_engine_command("memory", "", &mut runtime) {
1331 Some(CommandResult::Output(text)) => {
1332 assert!(text.contains("mode capture_and_recall"), "got: {text}");
1333 assert!(text.contains("expiry"), "got: {text}");
1334 }
1335 other => panic!("expected Some(Output), got {other:?}"),
1336 }
1337 }
1338}