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