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
136#[derive(Debug, Clone)]
138pub enum ModalRequest {
139 Models,
140 Settings,
141 Plugins,
142 HelpFind { query: String },
143 Extensions { sub: String },
144}
145
146#[derive(Debug, Clone)]
148pub struct SessionSummary {
149 pub id: String,
150 pub model: String,
151 pub title: Option<String>,
152 pub cost: f64,
153 pub message_count: usize,
154 pub is_current: bool,
155}
156
157pub fn parse_command(input: &str) -> Option<(&str, &str)> {
159 let trimmed = input.trim();
160 if !trimmed.starts_with('/') {
161 return None;
162 }
163 let without_slash = &trimmed[1..];
164 let (cmd, arg) = match without_slash.find(char::is_whitespace) {
165 Some(pos) => (&without_slash[..pos], without_slash[pos..].trim()),
166 None => (without_slash, ""),
167 };
168 Some((cmd, arg))
169}
170
171pub async fn prepare_engine_command(cmd: &str, arg: &str, runtime: &crate::Runtime) {
178 match cmd {
179 "model" | "models" if !arg.trim().is_empty() => {
180 runtime.prepare_model_capabilities(arg.trim()).await
181 }
182 "thinking" if !arg.trim().is_empty() => {
183 let model = runtime.model().to_string();
184 runtime.prepare_model_capabilities(&model).await;
185 }
186 _ => {}
187 }
188}
189
190pub fn handle_engine_command(
193 cmd: &str,
194 arg: &str,
195 runtime: &mut crate::Runtime,
196) -> Option<CommandResult> {
197 match cmd {
202 "context" if !arg.is_empty() => {
203 return Some(match runtime.context_management_command(arg) {
204 Ok(s) => CommandResult::Output(s),
205 Err(e) => CommandResult::Error(e),
206 })
207 }
208 "context" => return Some(context_command(runtime, None)),
209 "trace" => return Some(trace_command(arg, runtime)),
210 "budget" => return Some(budget_command(arg, runtime)),
211 "workers" => return Some(workers_command(arg, runtime)),
212 "confirms" => return Some(confirms_command(arg, runtime)),
213 "memory" => return Some(CommandResult::Output(memory_removed_line())),
214 _ => {}
215 }
216 let mut result = evaluate_engine_command(cmd, arg)?;
217 match &mut result {
218 CommandResult::ModelChanged {
219 model,
220 reasoning_clamped,
221 } => *reasoning_clamped = runtime.set_model(model.clone()),
222 CommandResult::ThinkingChanged { spec } => {
223 match spec {
226 ThinkingSpec::Named(level) => {
227 if let Err(msg) = runtime.set_reasoning_level_checked(*level) {
228 return Some(CommandResult::Error(msg));
229 }
230 }
231 ThinkingSpec::Custom { budget, .. } => {
232 if let Err(msg) = runtime.set_thinking_budget_checked(*budget) {
236 return Some(CommandResult::Error(msg));
237 }
238 }
239 }
240 }
241 _ => {}
242 }
243 Some(result)
244}
245
246pub fn evaluate_engine_command(cmd: &str, arg: &str) -> Option<CommandResult> {
248 match cmd {
249 "model" | "models" if !arg.is_empty() => Some(CommandResult::ModelChanged {
250 model: arg.to_string(),
251 reasoning_clamped: None,
252 }),
253 "thinking" if !arg.is_empty() => match parse_thinking_arg(arg) {
254 Ok(spec) => Some(CommandResult::ThinkingChanged { spec }),
255 Err(e) => Some(CommandResult::Error(e)),
256 },
257 "quit" | "exit" => Some(CommandResult::Quit),
258 "compact" => Some(CommandResult::Compact {
259 custom_instructions: if arg.is_empty() {
260 None
261 } else {
262 Some(arg.to_string())
263 },
264 }),
265 _ => None,
266 }
267}
268
269pub fn parse_thinking_arg(arg: &str) -> Result<ThinkingSpec, String> {
271 match ReasoningLevel::parse(arg) {
272 Some(level) => Ok(ThinkingSpec::Named(level)),
273 None => {
274 if let Ok(n) = arg.trim().parse::<u32>() {
275 Ok(ThinkingSpec::Custom {
277 level: ReasoningLevel::from_legacy_budget(n),
278 budget: n,
279 })
280 } else {
281 Err(format!(
282 "unknown thinking level: {} \
283 (use off/adaptive/low/medium/high/xhigh/max/ultra/ultracode or a number)",
284 arg
285 ))
286 }
287 }
288 }
289}
290
291pub fn thinking_config_value(spec: ThinkingSpec) -> String {
294 spec.config_value()
295}
296
297pub fn context_command(
302 runtime: &crate::Runtime,
303 history: Option<&[crate::SharedMessage]>,
304) -> CommandResult {
305 CommandResult::Output(format!(
306 "{}\n{}",
307 runtime.context_report(history).render(),
308 runtime.context_management_status()
309 ))
310}
311
312pub fn confirms_command(arg: &str, runtime: &crate::Runtime) -> CommandResult {
316 const USAGE: &str = "usage: /confirms [status] | /confirms reset";
317 let latch = runtime.session_allow_all();
318 match arg.trim() {
319 "" | "status" => {
320 let ids = latch.extensions();
321 if ids.is_empty() {
322 CommandResult::Output(
323 "No extension has \"Allow all\" this session; every extension confirm prompts."
324 .to_string(),
325 )
326 } else {
327 CommandResult::Output(format!(
328 "\"Allow all\" is armed for: {}. Their tool-call confirms are auto-approved \
329 until /new. /confirms reset revokes.",
330 ids.join(", ")
331 ))
332 }
333 }
334 "reset" => {
335 let revoked = latch.clear();
336 if revoked.is_empty() {
337 CommandResult::Output("Nothing to revoke: no extension had \"Allow all\".".to_string())
338 } else {
339 CommandResult::Output(format!(
340 "Revoked \"Allow all\" for: {}. Their confirms prompt again.",
341 revoked.join(", ")
342 ))
343 }
344 }
345 _ => CommandResult::Error(USAGE.to_string()),
346 }
347}
348
349pub fn workers_command(arg: &str, runtime: &mut crate::Runtime) -> CommandResult {
359 use crate::runtime::subagent::{SubagentStatus, WorkerCancelScope};
360 const USAGE: &str =
361 "usage: /workers [status] | /workers cancel <sa_N|all> | /workers reconcile <sa_N|all>";
362 let words: Vec<_> = arg.split_whitespace().collect();
363 let unreconciled = runtime
365 .orchestration()
366 .map(|o| o.unreconciled_runtime_handles())
367 .unwrap_or_default();
368 match words.as_slice() {
369 [] | ["status"] => {
370 let registry = runtime
371 .subagent_registry()
372 .lock()
373 .unwrap_or_else(|p| p.into_inner());
374 let mut rows: Vec<(String, String)> = registry
375 .handles
376 .values()
377 .map(|h| {
378 let status = h.status();
379 let gate = match (status == SubagentStatus::Running, unreconciled.contains(&h.id)) {
380 (true, _) => "",
381 (false, true) => " — finished, not collected (holds rollover: collect or /workers reconcile)",
382 (false, false) => "",
383 };
384 let mins = (h.elapsed_secs() / 60.0).floor() as u64;
385 (
386 h.id.clone(),
387 format!(" {} {} {} {}m{}", h.id, h.agent_name, status.as_str(), mins, gate),
388 )
389 })
390 .collect();
391 for id in &unreconciled {
393 if !registry.handles.contains_key(id) {
394 rows.push((
395 id.clone(),
396 format!(" {id} (expired) — not collected (holds rollover: /workers reconcile {id})"),
397 ));
398 }
399 }
400 drop(registry);
401 if rows.is_empty() {
402 return CommandResult::Output("No workers tracked in this session.".into());
403 }
404 rows.sort_by(|a, b| natural_handle_order(&a.0, &b.0));
405 let lines: Vec<String> = rows.into_iter().map(|(_, line)| line).collect();
406 CommandResult::Output(format!("Workers ({}):\n{}", lines.len(), lines.join("\n")))
407 }
408 ["cancel", target] => {
409 let mut registry = runtime
410 .subagent_registry()
411 .lock()
412 .unwrap_or_else(|p| p.into_inner());
413 let cancelled = if *target == "all" {
414 registry.cancel_running(WorkerCancelScope::All)
415 } else {
416 let Some(h) = registry.get_mut(target) else {
417 return CommandResult::Error(format!("no worker {target} (see /workers)"));
418 };
419 if !h.cancel_if_running() {
423 let (stopping, status) = {
424 let s = h.state.read().unwrap_or_else(|p| p.into_inner());
425 (s.cancel_requested, s.public_status())
426 };
427 return CommandResult::Error(if stopping {
428 format!("{target} is already being cancelled")
429 } else if status == SubagentStatus::Running {
430 format!("{target} is finishing — nothing to cancel")
431 } else {
432 format!("{target} is already {} — nothing to cancel", status.as_str())
433 });
434 }
435 vec![target.to_string()]
436 };
437 if cancelled.is_empty() {
438 CommandResult::Output("No running workers to cancel.".into())
439 } else {
440 CommandResult::Output(format!(
441 "Cancelled {} (they stop at their next step; no completion wake is sent).",
442 cancelled.join(", ")
443 ))
444 }
445 }
446 ["reconcile", target] => {
447 let Some(orchestration) = runtime.orchestration().cloned() else {
448 return CommandResult::Output(
449 "Worker orchestration is not active; nothing to reconcile.".into(),
450 );
451 };
452 let candidates: Vec<String> = if *target == "all" {
453 let mut ids: Vec<String> = unreconciled.iter().cloned().collect();
454 ids.sort_by(|a, b| natural_handle_order(a, b));
455 ids
456 } else if unreconciled.contains(*target) {
457 vec![target.to_string()]
458 } else {
459 return CommandResult::Error(format!(
460 "{target} is not awaiting reconciliation (see /workers)"
461 ));
462 };
463 let statuses: Vec<(String, Option<SubagentStatus>)> = {
464 let registry = runtime
465 .subagent_registry()
466 .lock()
467 .unwrap_or_else(|p| p.into_inner());
468 candidates
469 .into_iter()
470 .map(|id| {
471 let status = registry.get(&id).map(|h| h.status());
472 (id, status)
473 })
474 .collect()
475 };
476 let (mut done, mut running, mut failed) = (Vec::new(), Vec::new(), Vec::new());
477 for (id, status) in statuses {
478 let terminal = match status {
479 Some(SubagentStatus::Running) => {
480 running.push(id);
481 continue;
482 }
483 Some(SubagentStatus::Completed) => {
484 agent_core::orchestration::WorkerTerminal::Completed
485 }
486 Some(SubagentStatus::TimedOut) => {
487 agent_core::orchestration::WorkerTerminal::TimedOut
488 }
489 _ => agent_core::orchestration::WorkerTerminal::Failed,
490 };
491 match orchestration
492 .terminal_and_collect(&id, terminal)
493 .and_then(|()| orchestration.reconcile(&id))
494 {
495 Ok(()) => done.push(id),
496 Err(e) => failed.push(format!("{id} ({e})")),
497 }
498 }
499 let mut out = Vec::new();
500 if !done.is_empty() {
501 out.push(format!(
502 "Reconciled {} on your authority; their results stay readable via subagent_collect until reaped.",
503 done.join(", ")
504 ));
505 }
506 if !running.is_empty() {
507 out.push(format!(
508 "Still running, left alone: {} (/workers cancel <id> first).",
509 running.join(", ")
510 ));
511 }
512 if !failed.is_empty() {
513 out.push(format!("Could not reconcile: {}.", failed.join(", ")));
514 }
515 if out.is_empty() {
516 out.push("Nothing awaiting reconciliation.".into());
517 }
518 CommandResult::Output(out.join("\n"))
519 }
520 _ => CommandResult::Error(USAGE.into()),
521 }
522}
523
524fn natural_handle_order(a: &str, b: &str) -> std::cmp::Ordering {
526 let num = |s: &str| s.strip_prefix("sa_").and_then(|n| n.parse::<u64>().ok());
527 match (num(a), num(b)) {
528 (Some(x), Some(y)) => x.cmp(&y),
529 _ => a.cmp(b),
530 }
531}
532
533pub fn budget_command(arg: &str, runtime: &mut crate::Runtime) -> CommandResult {
537 const USAGE: &str = "usage: /budget [status] | /budget time <duration> (positive whole seconds/minutes/hours, e.g. 30m or 4h; maximum 24h) | /budget time off";
538 let words: Vec<_> = arg.split_whitespace().collect();
539 match words.as_slice() {
540 [] | ["status"] => {
541 let budget = runtime.turn_budget();
542 CommandResult::Output(format!(
543 "Turn budget (configured for future turns, not live usage):\n\
544 wall-clock: {}; provider rounds: {} (+{} bounded renewals); tool calls: {}; tool-result bytes: {}\n\
545 context tokens: {}; cost USD: {}\n\
546 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.",
547 budget
548 .max_elapsed_secs()
549 .map_or_else(|| "off".into(), |s| format!("{s}s")),
550 budget.max_provider_rounds.map_or_else(|| "off".into(), |v| v.to_string()),
551 budget.max_round_renewals,
552 budget.max_tool_calls.map_or_else(|| "off".into(), |v| v.to_string()),
553 budget
554 .max_accumulated_tool_result_bytes
555 .map_or_else(|| "off".into(), |v| v.to_string()),
556 budget.max_context_tokens.map_or_else(|| "unset".into(), |v| v.to_string()),
557 budget.max_cost_usd.map_or_else(|| "unset".into(), |v| v.to_string()),
558 ))
559 }
560 ["time", "off"] => {
561 let mut budget = runtime.turn_budget().clone();
562 budget.max_elapsed = None;
563 runtime.set_turn_budget(budget);
564 CommandResult::Output(
565 "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(),
566 )
567 }
568 ["time", duration] => {
569 let Some(seconds) = parse_turn_duration(duration) else {
570 return CommandResult::Error(USAGE.into());
571 };
572 let mut budget = runtime.turn_budget().clone();
573 budget.max_elapsed = Some(std::time::Duration::from_secs(seconds));
574 runtime.set_turn_budget(budget);
575 CommandResult::Output(format!(
576 "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."
577 ))
578 }
579 _ => CommandResult::Error(USAGE.into()),
580 }
581}
582
583fn parse_turn_duration(value: &str) -> Option<u64> {
584 let (digits, multiplier) = if let Some(digits) = value.strip_suffix('s') {
585 (digits, 1)
586 } else if let Some(digits) = value.strip_suffix('m') {
587 (digits, 60)
588 } else if let Some(digits) = value.strip_suffix('h') {
589 (digits, 3600)
590 } else {
591 return None;
592 };
593 if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
594 return None;
595 }
596 let seconds = digits.parse::<u64>().ok()?.checked_mul(multiplier)?;
597 (1..=86_400).contains(&seconds).then_some(seconds)
598}
599
600pub fn trace_command(arg: &str, runtime: &crate::Runtime) -> CommandResult {
602 match arg.trim() {
603 "next" => {
604 runtime.trace_arm_next(false);
605 CommandResult::Output(
606 "trace armed for the next request (metadata only, then auto-disabled)".to_string(),
607 )
608 }
609 "next content" => {
610 runtime.trace_arm_next(true);
611 CommandResult::Output(
612 "trace armed for the next request WITH redacted content capture.\n\
613 The request body (never headers/credentials) is recursively \
614 redacted and kept in a private, short-lived capture bundle; \
615 export it with `synaps trace export <request-id> \
616 --include-content --allow-content-export --output PATH` \
617 before it expires."
618 .to_string(),
619 )
620 }
621 "" | "status" => CommandResult::Output(runtime.trace_status().render()),
622 other => CommandResult::Error(format!(
623 "unknown /trace subcommand: `{other}` (try: next, next content, status)"
624 )),
625 }
626}
627
628pub fn memory_removed_line() -> String {
632 format!(
633 "/memory was removed in {}: automatic memory is not built into Synaps any more; an extension provides it (Axel). Synaps itself captures nothing automatically.",
634 agent_core::AXEL_BACKEND_REMOVED_IN
635 )
636}
637
638pub fn persist_to_config(key: &str, value: &str) -> String {
640 match crate::config::write_config_value(key, value) {
641 Ok(()) => "(saved to config)".to_string(),
642 Err(e) => format!("(session only — failed to persist: {})", e),
643 }
644}
645
646#[cfg(test)]
647mod tests {
648 use super::*;
649
650 #[test]
651 fn model_command_carries_model_name() {
652 match evaluate_engine_command("model", "claude-sonnet-4-6") {
653 Some(CommandResult::ModelChanged { model, .. }) => {
654 assert_eq!(model, "claude-sonnet-4-6")
655 }
656 other => panic!("expected ModelChanged, got {:?}", other),
657 }
658 assert!(matches!(
659 evaluate_engine_command("models", "claude-opus-4-6"),
660 Some(CommandResult::ModelChanged { .. })
661 ));
662 assert!(evaluate_engine_command("model", "").is_none());
663 }
664
665 #[test]
666 fn thinking_command_normalizes_levels() {
667 match evaluate_engine_command("thinking", "high") {
668 Some(CommandResult::ThinkingChanged { spec }) => {
669 assert_eq!(spec.level(), ReasoningLevel::High);
670 assert_eq!(spec.budget(), Some(16384));
671 assert!(matches!(spec, ThinkingSpec::Named(ReasoningLevel::High)));
672 }
673 other => panic!("expected ThinkingChanged, got {:?}", other),
674 }
675 let med = parse_thinking_arg("med").unwrap();
676 assert_eq!(med.level(), ReasoningLevel::Medium);
677 assert_eq!(med.budget(), Some(4096));
678
679 let custom = parse_thinking_arg("8192").unwrap();
681 assert!(
682 matches!(
683 custom,
684 ThinkingSpec::Custom {
685 level: ReasoningLevel::High,
686 budget: 8192
687 }
688 ),
689 "expected Custom{{High, 8192}}, got {:?}",
690 custom
691 );
692 assert_eq!(custom.budget(), Some(8192));
693
694 assert!(parse_thinking_arg("bogus").is_err());
695 assert!(evaluate_engine_command("thinking", "").is_none());
696 }
697
698 #[test]
699 fn thinking_command_off_and_adaptive_are_distinct() {
700 let off = parse_thinking_arg("off").unwrap();
701 let adp = parse_thinking_arg("adaptive").unwrap();
702 assert_eq!(off.level(), ReasoningLevel::Off);
703 assert_eq!(off.budget(), Some(0));
704 assert_eq!(adp.level(), ReasoningLevel::Adaptive);
705 assert_eq!(adp.budget(), Some(0));
706 assert_ne!(off.level(), adp.level());
707 }
708
709 #[test]
710 fn max_and_ultra_have_no_budget() {
711 let max_spec = parse_thinking_arg("max").unwrap();
712 assert_eq!(max_spec.level(), ReasoningLevel::Max);
713 assert_eq!(max_spec.budget(), None, "max has no numeric budget");
714
715 let ultra_spec = parse_thinking_arg("ultra").unwrap();
716 assert_eq!(ultra_spec.level(), ReasoningLevel::Ultra);
717 assert_eq!(ultra_spec.budget(), None, "ultra has no numeric budget");
718
719 let xhigh_spec = parse_thinking_arg("xhigh").unwrap();
721 assert_eq!(xhigh_spec.level(), ReasoningLevel::XHigh);
722 assert_eq!(xhigh_spec.budget(), Some(32768));
723 }
724
725 #[test]
726 fn thinking_config_value_is_named_for_named_levels() {
727 assert_eq!(
728 thinking_config_value(ThinkingSpec::Named(ReasoningLevel::Off)),
729 "off"
730 );
731 assert_eq!(
732 thinking_config_value(ThinkingSpec::Named(ReasoningLevel::Adaptive)),
733 "adaptive"
734 );
735 assert_eq!(
736 thinking_config_value(ThinkingSpec::Named(ReasoningLevel::Low)),
737 "low"
738 );
739 assert_eq!(
740 thinking_config_value(ThinkingSpec::Named(ReasoningLevel::Medium)),
741 "medium"
742 );
743 assert_eq!(
744 thinking_config_value(ThinkingSpec::Named(ReasoningLevel::High)),
745 "high"
746 );
747 assert_eq!(
748 thinking_config_value(ThinkingSpec::Named(ReasoningLevel::XHigh)),
749 "xhigh"
750 );
751 assert_eq!(
752 thinking_config_value(ThinkingSpec::Named(ReasoningLevel::Max)),
753 "max"
754 );
755 assert_eq!(
756 thinking_config_value(ThinkingSpec::Named(ReasoningLevel::Ultra)),
757 "ultra"
758 );
759 assert_eq!(
760 thinking_config_value(ThinkingSpec::Named(ReasoningLevel::UltraCode)),
761 "ultracode"
762 );
763 }
764
765 #[test]
766 fn thinking_config_value_is_exact_digits_for_custom_budget() {
767 let spec = ThinkingSpec::Custom {
769 level: ReasoningLevel::High,
770 budget: 8192,
771 };
772 assert_eq!(thinking_config_value(spec), "8192");
773 let spec2 = ThinkingSpec::Custom {
774 level: ReasoningLevel::Medium,
775 budget: 3000,
776 };
777 assert_eq!(thinking_config_value(spec2), "3000");
778 }
779
780 #[test]
781 fn compact_carries_custom_instructions() {
782 match evaluate_engine_command("compact", "focus on auth") {
783 Some(CommandResult::Compact {
784 custom_instructions,
785 }) => {
786 assert_eq!(custom_instructions.as_deref(), Some("focus on auth"));
787 }
788 other => panic!("expected Compact, got {:?}", other),
789 }
790 assert!(matches!(
791 evaluate_engine_command("compact", ""),
792 Some(CommandResult::Compact {
793 custom_instructions: None
794 })
795 ));
796 }
797
798 #[test]
799 #[serial_test::serial(synaps_base_dir)]
800 fn persist_to_config_reports_write_result() {
801 let base = crate::test_env::BaseDirGuard::new();
804
805 let status = persist_to_config("model", "claude-sonnet-4-6");
806
807 assert_eq!(status, "(saved to config)");
808 let path = agent_core::config::resolve_read_path("config");
809 assert!(path.starts_with(base.path()), "written outside the base dir: {path:?}");
810 let contents = std::fs::read_to_string(&path).unwrap();
811 assert!(contents.contains("model = claude-sonnet-4-6"));
812 }
813
814 #[test]
815 fn validate_level_codex_sol_accepts_ultra() {
816 let mut rt = crate::Runtime::new_headless();
817 rt.set_model("openai-codex/gpt-5.6-sol".to_string());
818 assert!(rt
819 .set_reasoning_level_checked(ReasoningLevel::Ultra)
820 .is_ok());
821 }
822
823 #[test]
824 fn validate_level_codex_luna_rejects_ultra_leaves_state_unchanged() {
825 let mut rt = crate::Runtime::new_headless();
826 rt.set_model("openai-codex/gpt-5.6-luna".to_string());
827 rt.set_reasoning_level(ReasoningLevel::Low);
828 let err = rt
829 .set_reasoning_level_checked(ReasoningLevel::Ultra)
830 .unwrap_err();
831 assert!(err.contains("ultra"));
832 assert!(err.contains("gpt-5.6-luna"));
833 assert_eq!(rt.reasoning_level(), ReasoningLevel::Low);
835 }
836
837 #[test]
838 fn validate_level_non_codex_rejects_ultra() {
839 for model in [
840 "claude-sonnet-4-6",
841 "anthropic/claude-opus-4-7",
842 "groq/llama-3",
843 ] {
844 let mut rt = crate::Runtime::new_headless();
845 rt.set_model(model.to_string());
846 assert!(
847 rt.set_reasoning_level_checked(ReasoningLevel::Ultra)
848 .is_err(),
849 "non-Codex {model} must not gain ultra without exact metadata"
850 );
851 }
852 }
853
854 #[test]
857 fn set_reasoning_level_is_not_explicit() {
858 let mut rt = crate::Runtime::new_headless();
860 rt.set_reasoning_level(ReasoningLevel::High);
861 assert!(
862 !rt.is_reasoning_explicit(),
863 "set_reasoning_level must not mark explicit"
864 );
865 }
866
867 #[test]
868 fn set_reasoning_level_explicit_marks_flag() {
869 let mut rt = crate::Runtime::new_headless();
870 rt.set_reasoning_level_explicit(ReasoningLevel::High);
871 assert!(
872 rt.is_reasoning_explicit(),
873 "set_reasoning_level_explicit must mark explicit"
874 );
875 }
876
877 #[test]
878 fn set_model_overwrites_non_explicit_codex_default() {
879 let mut rt = crate::Runtime::new_headless();
881 rt.set_model("openai-codex/gpt-5.6-luna".to_string());
882 assert_eq!(rt.reasoning_level(), ReasoningLevel::Medium);
884 assert!(!rt.is_reasoning_explicit());
885 }
886
887 #[test]
888 fn set_model_preserves_explicit_user_choice() {
889 let mut rt = crate::Runtime::new_headless();
891 rt.set_reasoning_level_explicit(ReasoningLevel::Low);
892 rt.set_model("openai-codex/gpt-5.6-sol".to_string());
893 assert!(
895 rt.is_reasoning_explicit(),
896 "explicit flag must survive set_model"
897 );
898 }
899
900 #[test]
901 fn apply_config_thinking_sets_explicit() {
902 use crate::config::SynapsConfig;
904 let mut rt = crate::Runtime::new_headless();
905 let config = SynapsConfig {
906 thinking_level: Some(ReasoningLevel::XHigh),
907 ..Default::default()
908 };
909 rt.apply_config(&config);
910 assert!(
911 rt.is_reasoning_explicit(),
912 "config thinking must be explicit"
913 );
914 assert_eq!(rt.reasoning_level(), ReasoningLevel::XHigh);
915 rt.set_model("openai-codex/gpt-5.6-luna".to_string());
917 assert_eq!(
918 rt.reasoning_level(),
919 ReasoningLevel::XHigh,
920 "explicit config level must survive set_model"
921 );
922 }
923
924 #[test]
925 fn set_thinking_budget_explicit_retains_exact_budget_and_marks_flag() {
926 let mut rt = crate::Runtime::new_headless();
928 rt.set_thinking_budget_explicit(8192);
929 assert_eq!(
930 rt.thinking_budget_raw(),
931 8192,
932 "exact budget must be retained"
933 );
934 assert!(rt.is_reasoning_explicit());
935 assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
937 }
938
939 #[test]
942 fn custom_budget_rejected_on_xai_intrinsic_reasoning_leaves_state_unchanged() {
943 let mut rt = crate::Runtime::new_headless();
946 rt.set_model("xai-auth/grok-4.3".to_string());
947 let before_budget = rt.thinking_budget_raw();
948 let before_level = rt.reasoning_level();
949 let before_explicit = rt.is_reasoning_explicit();
950 match handle_engine_command("thinking", "8192", &mut rt) {
951 Some(CommandResult::Error(msg)) => {
952 assert!(
953 msg.contains("xai-auth/grok-4.3"),
954 "message names model: {msg}"
955 )
956 }
957 other => panic!("expected Error, got {:?}", other),
958 }
959 assert_eq!(
960 rt.thinking_budget_raw(),
961 before_budget,
962 "budget must be unchanged"
963 );
964 assert_eq!(
965 rt.reasoning_level(),
966 before_level,
967 "level must be unchanged"
968 );
969 assert_eq!(
970 rt.is_reasoning_explicit(),
971 before_explicit,
972 "provenance unchanged"
973 );
974 }
975
976 #[test]
977 fn custom_budget_rejected_on_xai_non_reasoning_model() {
978 let mut rt = crate::Runtime::new_headless();
979 rt.set_model("xai-auth/grok-4.20-0309-non-reasoning".to_string());
980 let before_budget = rt.thinking_budget_raw();
981 match handle_engine_command("thinking", "8192", &mut rt) {
982 Some(CommandResult::Error(msg)) => {
983 assert!(msg.contains("non-reasoning"), "{msg}")
984 }
985 other => panic!("expected Error, got {:?}", other),
986 }
987 assert_eq!(
988 rt.thinking_budget_raw(),
989 before_budget,
990 "state must be unchanged"
991 );
992 }
993
994 #[test]
995 fn custom_budget_maps_to_high_and_is_accepted_on_xai_45() {
996 let mut rt = crate::Runtime::new_headless();
998 rt.set_model("xai-auth/grok-4.5".to_string());
999 match handle_engine_command("thinking", "8192", &mut rt) {
1000 Some(CommandResult::ThinkingChanged { spec }) => {
1001 assert_eq!(spec.level(), ReasoningLevel::High);
1002 assert_eq!(spec.budget(), Some(8192));
1003 }
1004 other => panic!("expected ThinkingChanged, got {:?}", other),
1005 }
1006 assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
1007 assert!(rt.is_reasoning_explicit());
1008 }
1009
1010 #[test]
1013 fn model_command_reports_reasoning_clamp_for_unsupported_explicit_level() {
1014 let mut rt = crate::Runtime::new_headless();
1015 handle_engine_command("thinking", "xhigh", &mut rt).expect("thinking applies");
1016 assert_eq!(rt.reasoning_level(), ReasoningLevel::XHigh);
1017 match handle_engine_command("model", "xai-auth/grok-4.6", &mut rt) {
1018 Some(CommandResult::ModelChanged {
1019 model,
1020 reasoning_clamped,
1021 }) => {
1022 assert_eq!(model, "xai-auth/grok-4.6");
1023 assert_eq!(
1024 reasoning_clamped,
1025 Some(crate::runtime::ReasoningClamp {
1026 from: ReasoningLevel::XHigh,
1027 to: ReasoningLevel::High,
1028 })
1029 );
1030 }
1031 other => panic!("expected ModelChanged, got {:?}", other),
1032 }
1033 assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
1034 assert!(rt.is_reasoning_explicit());
1035 match handle_engine_command("model", "xai-auth/grok-4.5", &mut rt) {
1037 Some(CommandResult::ModelChanged {
1038 reasoning_clamped, ..
1039 }) => assert_eq!(reasoning_clamped, None),
1040 other => panic!("expected ModelChanged, got {:?}", other),
1041 }
1042 }
1043
1044 #[test]
1045 fn custom_budget_preserved_exactly_on_anthropic_fixed_budget_model() {
1046 let mut rt = crate::Runtime::new_headless();
1049 rt.set_model("claude-sonnet-4-6".to_string());
1050 match handle_engine_command("thinking", "8192", &mut rt) {
1051 Some(CommandResult::ThinkingChanged { spec }) => {
1052 assert_eq!(spec.config_value(), "8192", "persists exact digits");
1053 }
1054 other => panic!("expected ThinkingChanged, got {:?}", other),
1055 }
1056 assert_eq!(
1057 rt.thinking_budget_raw(),
1058 8192,
1059 "exact budget must be retained"
1060 );
1061 assert!(rt.is_reasoning_explicit());
1062 }
1063
1064 #[test]
1065 fn custom_budget_config_value_is_digits_not_named() {
1066 let spec = parse_thinking_arg("8192").unwrap();
1068 assert_eq!(
1069 spec.config_value(),
1070 "8192",
1071 "custom budget must persist as digits, not named level"
1072 );
1073 }
1074
1075 #[test]
1078 fn trace_next_enables_exactly_the_next_request_then_auto_disables() {
1079 let runtime = crate::Runtime::new_headless();
1080 assert!(!runtime.trace_context().enabled());
1082
1083 match trace_command("next", &runtime) {
1084 CommandResult::Output(text) => assert!(text.contains("next request")),
1085 other => panic!("expected Output, got {other:?}"),
1086 }
1087 let status = runtime.trace_status();
1088 assert!(!status.persistent_enabled);
1089 assert_eq!(status.arm, crate::runtime::trace::TraceArm::NextMetadata);
1090
1091 let armed = runtime.effective_trace_context();
1093 assert!(armed.enabled(), "armed context must trace the next request");
1094 let after = runtime.effective_trace_context();
1096 assert!(!after.enabled(), "arm must cover exactly one request");
1097 assert_eq!(
1098 runtime.trace_status().arm,
1099 crate::runtime::trace::TraceArm::Off
1100 );
1101 }
1102
1103 #[test]
1104 fn trace_status_reports_mode_path_and_counters_without_secrets() {
1105 let runtime = crate::Runtime::new_headless();
1106 match trace_command("status", &runtime) {
1107 CommandResult::Output(text) => {
1108 assert!(text.contains("trace: disabled"), "got: {text}");
1109 assert!(text.contains("degraded"));
1110 }
1111 other => panic!("expected Output, got {other:?}"),
1112 }
1113 runtime.trace_arm_next(true);
1114 match trace_command("", &runtime) {
1115 CommandResult::Output(text) => {
1116 assert!(text.contains("armed for next request"), "got: {text}");
1117 assert!(text.contains("content capture"));
1118 }
1119 other => panic!("expected Output, got {other:?}"),
1120 }
1121 assert!(matches!(
1122 trace_command("bogus", &runtime),
1123 CommandResult::Error(_)
1124 ));
1125 }
1126
1127 #[test]
1128 fn context_command_reports_metadata_never_content() {
1129 let mut runtime = crate::Runtime::new_headless();
1130 let secret = "CONTEXT-SENTINEL-5a5a";
1131 runtime.set_system_prompt(format!("you are {secret}"));
1132 let history = vec![std::sync::Arc::new(serde_json::json!({
1133 "role": "user", "content": secret
1134 }))];
1135 match context_command(&runtime, Some(&history)) {
1136 CommandResult::Output(text) => {
1137 assert!(!text.contains(secret), "sentinel leaked: {text}");
1138 assert!(text.contains("history: 1 messages"));
1139 assert!(text.contains("system prompt:"));
1140 assert!(text.contains("skills: unavailable"));
1141 }
1142 other => panic!("expected Output, got {other:?}"),
1143 }
1144 match handle_engine_command("context", "", &mut runtime) {
1146 Some(CommandResult::Output(text)) => {
1147 assert!(
1148 text.contains("history: unavailable messages"),
1149 "got: {text}"
1150 );
1151 }
1152 other => panic!("expected Output, got {other:?}"),
1153 }
1154 }
1155
1156 #[test]
1157 fn budget_duration_parser_is_strict_and_bounded() {
1158 for (input, seconds) in [
1159 ("1s", 1),
1160 ("30m", 1800),
1161 ("4h", 14400),
1162 ("24h", 86400),
1163 ("1440m", 86400),
1164 ("86400s", 86400),
1165 ] {
1166 assert_eq!(parse_turn_duration(input), Some(seconds), "{input}");
1167 }
1168 for input in [
1169 "",
1170 "s",
1171 "0s",
1172 "0h",
1173 "25h",
1174 "86401s",
1175 "1441m",
1176 "4",
1177 "4H",
1178 "-1h",
1179 "+1h",
1180 "1.5h",
1181 "1h30m",
1182 " 4h",
1183 "4 h",
1184 "4h ",
1185 "1e3s",
1186 "∞h",
1187 "12h",
1188 "18446744073709551615h",
1189 "18446744073709551616s",
1190 "off",
1191 "unlimited",
1192 ] {
1193 assert_eq!(parse_turn_duration(input), None, "{input}");
1194 }
1195 }
1196
1197 fn worker_handle(
1200 id: &str,
1201 status: crate::runtime::subagent::SubagentStatus,
1202 ) -> crate::runtime::subagent::SubagentHandle {
1203 let state = std::sync::Arc::new(std::sync::RwLock::new(
1204 crate::runtime::subagent::SubagentState::new(),
1205 ));
1206 state.write().unwrap().status = status;
1207 crate::runtime::subagent::SubagentHandle::new(
1208 id.into(),
1209 id.trim_start_matches("sa_").parse().unwrap_or(0),
1210 "spike".into(),
1211 "task".into(),
1212 "claude-sonnet-4-6".into(),
1213 "system".into(),
1214 60,
1215 state,
1216 None,
1217 None,
1218 None,
1219 )
1220 }
1221
1222 fn runtime_with_workers() -> (
1223 crate::Runtime,
1224 std::sync::Arc<crate::orchestration::OrchestrationRuntime>,
1225 ) {
1226 use crate::runtime::subagent::SubagentStatus;
1227 let mut runtime = crate::Runtime::new_headless();
1228 let model = agent_core::prompt::QualifiedModelId::parse("openrouter/z-ai/glm-5.2").unwrap();
1229 let orchestration = std::sync::Arc::new(
1230 crate::orchestration::OrchestrationRuntime::baseline(model, 3, 8).unwrap(),
1231 );
1232 for id in ["sa_9", "sa_10"] {
1233 orchestration.resolve_and_authorize(id, None).unwrap();
1234 orchestration.mark_starting(id).unwrap();
1235 orchestration.mark_running(id).unwrap();
1236 }
1237 runtime.install_orchestration(orchestration.clone());
1238 {
1239 let mut registry = runtime.subagent_registry().lock().unwrap();
1240 registry.register(worker_handle("sa_9", SubagentStatus::Running));
1241 registry.register(worker_handle("sa_10", SubagentStatus::Completed));
1242 }
1243 (runtime, orchestration)
1244 }
1245
1246 fn command_text(result: Option<CommandResult>) -> String {
1247 match result {
1248 Some(CommandResult::Output(text)) | Some(CommandResult::Error(text)) => text,
1249 other => panic!("unexpected {other:?}"),
1250 }
1251 }
1252
1253 #[test]
1256 fn confirms_lists_and_reset_revokes_the_allow_all_latch() {
1257 let mut runtime = crate::Runtime::new_headless();
1258 let worker = runtime.clone();
1259 let text = command_text(handle_engine_command("confirms", "", &mut runtime));
1260 assert!(text.contains("No extension"), "{text}");
1261
1262 worker
1263 .session_allow_all()
1264 .allow(&[("jev".to_string(), 0), ("audit".to_string(), 0)]);
1265 let text = command_text(handle_engine_command("confirms", "status", &mut runtime));
1266 assert!(text.contains("jev") && text.contains("audit"), "{text}");
1267
1268 let text = command_text(handle_engine_command("confirms", "reset", &mut runtime));
1269 assert!(text.contains("Revoked") && text.contains("jev"), "{text}");
1270 assert!(worker.session_allow_all().is_empty(), "shared with workers");
1271 let text = command_text(handle_engine_command("confirms", "reset", &mut runtime));
1272 assert!(text.contains("Nothing to revoke"), "{text}");
1273
1274 assert!(matches!(
1275 handle_engine_command("confirms", "explode", &mut runtime),
1276 Some(CommandResult::Error(e)) if e.contains("usage: /confirms")
1277 ));
1278 assert!(crate::skills::BUILTIN_COMMANDS.contains(&"confirms"));
1279 }
1280
1281 #[test]
1282 fn workers_status_lists_workers_and_flags_uncollected_ones() {
1283 let (mut runtime, _) = runtime_with_workers();
1284 let text = command_text(handle_engine_command("workers", "", &mut runtime));
1285 let sa9 = text.find("sa_9").expect(&text);
1286 let sa10 = text.find("sa_10").expect(&text);
1287 assert!(sa9 < sa10, "natural handle order: {text}");
1288 let line10 = text.lines().find(|l| l.contains("sa_10")).unwrap();
1289 assert!(
1290 line10.contains("completed") && line10.contains("holds rollover"),
1291 "{line10}"
1292 );
1293 let line9 = text.lines().find(|l| l.contains("sa_9")).unwrap();
1294 assert!(
1295 line9.contains("running") && !line9.contains("holds rollover"),
1296 "{line9}"
1297 );
1298 assert!(crate::skills::BUILTIN_COMMANDS.contains(&"workers"));
1299 }
1300
1301 #[test]
1302 fn workers_reconcile_releases_finished_workers_and_never_running_ones() {
1303 let (mut runtime, orchestration) = runtime_with_workers();
1304 let text = command_text(handle_engine_command(
1305 "workers",
1306 "reconcile all",
1307 &mut runtime,
1308 ));
1309 assert!(text.contains("Reconciled sa_10"), "{text}");
1310 assert!(text.contains("Still running, left alone: sa_9"), "{text}");
1311 assert!(!orchestration.is_unreconciled("sa_10"));
1312 assert!(orchestration.is_unreconciled("sa_9"));
1313 let Some(CommandResult::Error(e)) =
1315 handle_engine_command("workers", "reconcile sa_10", &mut runtime)
1316 else {
1317 panic!("already reconciled must be refused")
1318 };
1319 assert!(e.contains("not awaiting"), "{e}");
1320 }
1321
1322 #[test]
1327 fn workers_cancel_never_relabels_a_worker_that_completed_meanwhile() {
1328 use crate::runtime::subagent::SubagentStatus;
1329 let mut runtime = crate::Runtime::new_headless();
1330 for round in 0..20_000 {
1331 let h = worker_handle("sa_1", SubagentStatus::Running);
1332 let state = std::sync::Arc::clone(&h.state);
1333 {
1334 let mut registry = runtime.subagent_registry().lock().unwrap();
1335 registry.remove("sa_1");
1336 registry.register(h);
1337 }
1338 let go = std::sync::Arc::new(std::sync::Barrier::new(2));
1339 let worker = {
1340 let (go, state) = (go.clone(), state.clone());
1341 let spin = round % 4_000;
1343 std::thread::spawn(move || {
1344 go.wait();
1345 for _ in 0..spin {
1346 std::hint::spin_loop();
1347 }
1348 let mut s = state.write().unwrap();
1349 if matches!(s.status, SubagentStatus::Running) && !s.cancel_requested {
1350 s.status = SubagentStatus::Completed;
1351 }
1352 })
1353 };
1354 go.wait();
1355 let reply = handle_engine_command("workers", "cancel sa_1", &mut runtime);
1356 worker.join().unwrap();
1357 let s = state.read().unwrap();
1358 assert!(
1359 !(s.status == SubagentStatus::Completed && s.cancel_requested),
1360 "round {round}: completed AND flagged cancelled"
1361 );
1362 match reply {
1363 Some(CommandResult::Output(text)) => {
1364 assert!(s.cancel_requested && text.contains("Cancelled sa_1"), "{text}")
1365 }
1366 Some(CommandResult::Error(e)) => {
1367 assert!(!s.cancel_requested && e.contains("already completed"), "{e}")
1368 }
1369 _ => panic!("round {round}: unexpected reply"),
1370 }
1371 }
1372 }
1373
1374 #[test]
1375 fn workers_cancel_targets_running_workers_only() {
1376 let (mut runtime, _) = runtime_with_workers();
1377 let Some(CommandResult::Error(e)) =
1378 handle_engine_command("workers", "cancel sa_10", &mut runtime)
1379 else {
1380 panic!("finished worker cannot be cancelled")
1381 };
1382 assert!(e.contains("already completed"), "{e}");
1383 let text = command_text(handle_engine_command("workers", "cancel all", &mut runtime));
1384 assert!(text.contains("Cancelled sa_9"), "{text}");
1385 let registry = runtime.subagent_registry().lock().unwrap();
1386 assert!(
1387 registry
1388 .get("sa_9")
1389 .unwrap()
1390 .state
1391 .read()
1392 .unwrap()
1393 .cancel_requested
1394 );
1395 drop(registry);
1396 let Some(CommandResult::Error(usage)) =
1397 handle_engine_command("workers", "explode", &mut runtime)
1398 else {
1399 panic!("usage")
1400 };
1401 assert!(usage.starts_with("usage: /workers"));
1402 }
1403
1404 #[test]
1405 fn budget_command_status_and_invalid_arguments_do_not_mutate() {
1406 let mut runtime = crate::Runtime::new_headless();
1407 let original = runtime.turn_budget().clone();
1408 for arg in ["", "status", " status "] {
1409 let Some(CommandResult::Output(text)) =
1410 handle_engine_command("budget", arg, &mut runtime)
1411 else {
1412 panic!("{arg}");
1413 };
1414 assert!(text.contains("wall-clock: off;"));
1415 assert!(text.contains("not live usage"));
1416 assert!(text.contains("provider rounds: off (+8 bounded renewals)"));
1417 assert!(text.contains("tool calls: off;"));
1418 assert!(text.contains("tool-result bytes: off\n"));
1419 assert_eq!(runtime.turn_budget(), &original);
1420 }
1421 for arg in [
1422 "time",
1423 "time 0s",
1424 "time 25h",
1425 "time 1h trailing",
1426 "status 4h",
1427 "time off now",
1428 "off",
1429 "reset",
1430 ] {
1431 assert!(
1432 matches!(
1433 handle_engine_command("budget", arg, &mut runtime),
1434 Some(CommandResult::Error(_))
1435 ),
1436 "{arg}"
1437 );
1438 assert_eq!(runtime.turn_budget(), &original);
1439 }
1440 }
1441
1442 #[test]
1443 fn budget_command_changes_only_future_runtime_time_limit() {
1444 use crate::runtime::budget::{TurnBudget, TurnBudgetMeter, TurnRole};
1445 let mut runtime = crate::Runtime::new_headless();
1446 let original = TurnBudget {
1447 max_elapsed: Some(std::time::Duration::ZERO),
1448 max_tool_calls: Some(11),
1449 max_context_tokens: Some(456),
1450 max_cost_usd: Some(0.75),
1451 ..TurnBudget::for_role(TurnRole::Foreground)
1452 };
1453 runtime.set_turn_budget(original.clone());
1454 let mut in_flight = TurnBudgetMeter::new(runtime.turn_budget().clone());
1455 let other_runtime = runtime.clone();
1456 let Some(CommandResult::Output(text)) =
1457 handle_engine_command("budget", "time 4h", &mut runtime)
1458 else {
1459 panic!("expected output");
1460 };
1461 assert!(text.contains("14400s"));
1462 assert!(text.contains("not saved"));
1463 let mut expected = original.clone();
1464 expected.max_elapsed = Some(std::time::Duration::from_secs(14400));
1465 assert_eq!(runtime.turn_budget(), &expected);
1466 assert_eq!(other_runtime.turn_budget(), &original);
1467 assert_eq!(
1468 in_flight.begin_round(),
1469 Err(agent_core::BudgetDimension::WallClock)
1470 );
1471 assert!(TurnBudgetMeter::new(runtime.turn_budget().clone())
1472 .begin_round()
1473 .is_ok());
1474 let Some(CommandResult::Output(text)) =
1475 handle_engine_command("budget", "time off", &mut runtime)
1476 else {
1477 panic!("expected output");
1478 };
1479 assert!(text.contains("removed"));
1480 expected.max_elapsed = None;
1481 assert_eq!(runtime.turn_budget(), &expected);
1482 assert_eq!(
1484 crate::Runtime::new_headless().turn_budget().max_elapsed,
1485 None
1486 );
1487 for role in [TurnRole::Foreground, TurnRole::Worker, TurnRole::Event] {
1488 assert_eq!(TurnBudget::for_role(role).max_elapsed, None);
1489 }
1490 assert!(crate::skills::BUILTIN_COMMANDS.contains(&"budget"));
1491 }
1492
1493 #[test]
1496 fn memory_answers_the_removal_line_and_changes_nothing() {
1497 let mut runtime = crate::Runtime::new_headless();
1498 for arg in ["", "on", "status", "off", "index-history confirm", "bogus"] {
1499 match handle_engine_command("memory", arg, &mut runtime) {
1500 Some(CommandResult::Output(text)) => assert_eq!(text, memory_removed_line()),
1501 other => panic!("/memory {arg}: expected the removal line, got {other:?}"),
1502 }
1503 }
1504 }
1505}