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 async fn prepare_engine_command(cmd: &str, arg: &str, runtime: &crate::Runtime) {
186 match cmd {
187 "model" | "models" if !arg.trim().is_empty() => {
188 runtime.prepare_model_capabilities(arg.trim()).await
189 }
190 "thinking" if !arg.trim().is_empty() => {
191 let model = runtime.model().to_string();
192 runtime.prepare_model_capabilities(&model).await;
193 }
194 _ => {}
195 }
196}
197
198pub fn handle_engine_command(
201 cmd: &str,
202 arg: &str,
203 runtime: &mut crate::Runtime,
204) -> Option<CommandResult> {
205 match cmd {
210 "context" if !arg.is_empty() => {
211 return Some(match runtime.context_management_command(arg) {
212 Ok(s) => CommandResult::Output(s),
213 Err(e) => CommandResult::Error(e),
214 })
215 }
216 "context" => return Some(context_command(runtime, None)),
217 "trace" => return Some(trace_command(arg, runtime)),
218 "budget" => return Some(budget_command(arg, runtime)),
219 "workers" => return Some(workers_command(arg, runtime)),
220 "confirms" => return Some(confirms_command(arg, runtime)),
221 "memory" => return Some(memory_command(arg, runtime)),
222 _ => {}
223 }
224 let mut result = evaluate_engine_command(cmd, arg)?;
225 match &mut result {
226 CommandResult::ModelChanged {
227 model,
228 reasoning_clamped,
229 } => *reasoning_clamped = runtime.set_model(model.clone()),
230 CommandResult::ThinkingChanged { spec } => {
231 match spec {
234 ThinkingSpec::Named(level) => {
235 if let Err(msg) = runtime.set_reasoning_level_checked(*level) {
236 return Some(CommandResult::Error(msg));
237 }
238 }
239 ThinkingSpec::Custom { budget, .. } => {
240 if let Err(msg) = runtime.set_thinking_budget_checked(*budget) {
244 return Some(CommandResult::Error(msg));
245 }
246 }
247 }
248 }
249 _ => {}
250 }
251 Some(result)
252}
253
254pub fn evaluate_engine_command(cmd: &str, arg: &str) -> Option<CommandResult> {
256 match cmd {
257 "model" | "models" if !arg.is_empty() => Some(CommandResult::ModelChanged {
258 model: arg.to_string(),
259 reasoning_clamped: None,
260 }),
261 "thinking" if !arg.is_empty() => match parse_thinking_arg(arg) {
262 Ok(spec) => Some(CommandResult::ThinkingChanged { spec }),
263 Err(e) => Some(CommandResult::Error(e)),
264 },
265 "quit" | "exit" => Some(CommandResult::Quit),
266 "compact" => Some(CommandResult::Compact {
267 custom_instructions: if arg.is_empty() {
268 None
269 } else {
270 Some(arg.to_string())
271 },
272 }),
273 _ => None,
274 }
275}
276
277pub fn parse_thinking_arg(arg: &str) -> Result<ThinkingSpec, String> {
279 match ReasoningLevel::parse(arg) {
280 Some(level) => Ok(ThinkingSpec::Named(level)),
281 None => {
282 if let Ok(n) = arg.trim().parse::<u32>() {
283 Ok(ThinkingSpec::Custom {
285 level: ReasoningLevel::from_legacy_budget(n),
286 budget: n,
287 })
288 } else {
289 Err(format!(
290 "unknown thinking level: {} \
291 (use off/adaptive/low/medium/high/xhigh/max/ultra/ultracode or a number)",
292 arg
293 ))
294 }
295 }
296 }
297}
298
299pub fn thinking_config_value(spec: ThinkingSpec) -> String {
302 spec.config_value()
303}
304
305pub fn context_command(
310 runtime: &crate::Runtime,
311 history: Option<&[crate::SharedMessage]>,
312) -> CommandResult {
313 CommandResult::Output(format!(
314 "{}\n{}",
315 runtime.context_report(history).render(),
316 runtime.context_management_status()
317 ))
318}
319
320pub fn confirms_command(arg: &str, runtime: &crate::Runtime) -> CommandResult {
324 const USAGE: &str = "usage: /confirms [status] | /confirms reset";
325 let latch = runtime.session_allow_all();
326 match arg.trim() {
327 "" | "status" => {
328 let ids = latch.extensions();
329 if ids.is_empty() {
330 CommandResult::Output(
331 "No extension has \"Allow all\" this session; every extension confirm prompts."
332 .to_string(),
333 )
334 } else {
335 CommandResult::Output(format!(
336 "\"Allow all\" is armed for: {}. Their tool-call confirms are auto-approved \
337 until /new. /confirms reset revokes.",
338 ids.join(", ")
339 ))
340 }
341 }
342 "reset" => {
343 let revoked = latch.clear();
344 if revoked.is_empty() {
345 CommandResult::Output("Nothing to revoke: no extension had \"Allow all\".".to_string())
346 } else {
347 CommandResult::Output(format!(
348 "Revoked \"Allow all\" for: {}. Their confirms prompt again.",
349 revoked.join(", ")
350 ))
351 }
352 }
353 _ => CommandResult::Error(USAGE.to_string()),
354 }
355}
356
357pub fn workers_command(arg: &str, runtime: &mut crate::Runtime) -> CommandResult {
367 use crate::runtime::subagent::{SubagentStatus, WorkerCancelScope};
368 const USAGE: &str =
369 "usage: /workers [status] | /workers cancel <sa_N|all> | /workers reconcile <sa_N|all>";
370 let words: Vec<_> = arg.split_whitespace().collect();
371 let unreconciled = runtime
373 .orchestration()
374 .map(|o| o.unreconciled_runtime_handles())
375 .unwrap_or_default();
376 match words.as_slice() {
377 [] | ["status"] => {
378 let registry = runtime
379 .subagent_registry()
380 .lock()
381 .unwrap_or_else(|p| p.into_inner());
382 let mut rows: Vec<(String, String)> = registry
383 .handles
384 .values()
385 .map(|h| {
386 let status = h.status();
387 let gate = match (status == SubagentStatus::Running, unreconciled.contains(&h.id)) {
388 (true, _) => "",
389 (false, true) => " — finished, not collected (holds rollover: collect or /workers reconcile)",
390 (false, false) => "",
391 };
392 let mins = (h.elapsed_secs() / 60.0).floor() as u64;
393 (
394 h.id.clone(),
395 format!(" {} {} {} {}m{}", h.id, h.agent_name, status.as_str(), mins, gate),
396 )
397 })
398 .collect();
399 for id in &unreconciled {
401 if !registry.handles.contains_key(id) {
402 rows.push((
403 id.clone(),
404 format!(" {id} (expired) — not collected (holds rollover: /workers reconcile {id})"),
405 ));
406 }
407 }
408 drop(registry);
409 if rows.is_empty() {
410 return CommandResult::Output("No workers tracked in this session.".into());
411 }
412 rows.sort_by(|a, b| natural_handle_order(&a.0, &b.0));
413 let lines: Vec<String> = rows.into_iter().map(|(_, line)| line).collect();
414 CommandResult::Output(format!("Workers ({}):\n{}", lines.len(), lines.join("\n")))
415 }
416 ["cancel", target] => {
417 let mut registry = runtime
418 .subagent_registry()
419 .lock()
420 .unwrap_or_else(|p| p.into_inner());
421 let cancelled = if *target == "all" {
422 registry.cancel_running(WorkerCancelScope::All)
423 } else {
424 let Some(h) = registry.get_mut(target) else {
425 return CommandResult::Error(format!("no worker {target} (see /workers)"));
426 };
427 if !h.cancel_if_running() {
431 let (stopping, status) = {
432 let s = h.state.read().unwrap_or_else(|p| p.into_inner());
433 (s.cancel_requested, s.public_status())
434 };
435 return CommandResult::Error(if stopping {
436 format!("{target} is already being cancelled")
437 } else if status == SubagentStatus::Running {
438 format!("{target} is finishing — nothing to cancel")
439 } else {
440 format!("{target} is already {} — nothing to cancel", status.as_str())
441 });
442 }
443 vec![target.to_string()]
444 };
445 if cancelled.is_empty() {
446 CommandResult::Output("No running workers to cancel.".into())
447 } else {
448 CommandResult::Output(format!(
449 "Cancelled {} (they stop at their next step; no completion wake is sent).",
450 cancelled.join(", ")
451 ))
452 }
453 }
454 ["reconcile", target] => {
455 let Some(orchestration) = runtime.orchestration().cloned() else {
456 return CommandResult::Output(
457 "Worker orchestration is not active; nothing to reconcile.".into(),
458 );
459 };
460 let candidates: Vec<String> = if *target == "all" {
461 let mut ids: Vec<String> = unreconciled.iter().cloned().collect();
462 ids.sort_by(|a, b| natural_handle_order(a, b));
463 ids
464 } else if unreconciled.contains(*target) {
465 vec![target.to_string()]
466 } else {
467 return CommandResult::Error(format!(
468 "{target} is not awaiting reconciliation (see /workers)"
469 ));
470 };
471 let statuses: Vec<(String, Option<SubagentStatus>)> = {
472 let registry = runtime
473 .subagent_registry()
474 .lock()
475 .unwrap_or_else(|p| p.into_inner());
476 candidates
477 .into_iter()
478 .map(|id| {
479 let status = registry.get(&id).map(|h| h.status());
480 (id, status)
481 })
482 .collect()
483 };
484 let (mut done, mut running, mut failed) = (Vec::new(), Vec::new(), Vec::new());
485 for (id, status) in statuses {
486 let terminal = match status {
487 Some(SubagentStatus::Running) => {
488 running.push(id);
489 continue;
490 }
491 Some(SubagentStatus::Completed) => {
492 agent_core::orchestration::WorkerTerminal::Completed
493 }
494 Some(SubagentStatus::TimedOut) => {
495 agent_core::orchestration::WorkerTerminal::TimedOut
496 }
497 _ => agent_core::orchestration::WorkerTerminal::Failed,
498 };
499 match orchestration
500 .terminal_and_collect(&id, terminal)
501 .and_then(|()| orchestration.reconcile(&id))
502 {
503 Ok(()) => done.push(id),
504 Err(e) => failed.push(format!("{id} ({e})")),
505 }
506 }
507 let mut out = Vec::new();
508 if !done.is_empty() {
509 out.push(format!(
510 "Reconciled {} on your authority; their results stay readable via subagent_collect until reaped.",
511 done.join(", ")
512 ));
513 }
514 if !running.is_empty() {
515 out.push(format!(
516 "Still running, left alone: {} (/workers cancel <id> first).",
517 running.join(", ")
518 ));
519 }
520 if !failed.is_empty() {
521 out.push(format!("Could not reconcile: {}.", failed.join(", ")));
522 }
523 if out.is_empty() {
524 out.push("Nothing awaiting reconciliation.".into());
525 }
526 CommandResult::Output(out.join("\n"))
527 }
528 _ => CommandResult::Error(USAGE.into()),
529 }
530}
531
532fn natural_handle_order(a: &str, b: &str) -> std::cmp::Ordering {
534 let num = |s: &str| s.strip_prefix("sa_").and_then(|n| n.parse::<u64>().ok());
535 match (num(a), num(b)) {
536 (Some(x), Some(y)) => x.cmp(&y),
537 _ => a.cmp(b),
538 }
539}
540
541pub fn budget_command(arg: &str, runtime: &mut crate::Runtime) -> CommandResult {
545 const USAGE: &str = "usage: /budget [status] | /budget time <duration> (positive whole seconds/minutes/hours, e.g. 30m or 4h; maximum 24h) | /budget time off";
546 let words: Vec<_> = arg.split_whitespace().collect();
547 match words.as_slice() {
548 [] | ["status"] => {
549 let budget = runtime.turn_budget();
550 CommandResult::Output(format!(
551 "Turn budget (configured for future turns, not live usage):\n\
552 wall-clock: {}; provider rounds: {} (+{} bounded renewals); tool calls: {}; tool-result bytes: {}\n\
553 context tokens: {}; cost USD: {}\n\
554 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.",
555 budget
556 .max_elapsed_secs()
557 .map_or_else(|| "off".into(), |s| format!("{s}s")),
558 budget.max_provider_rounds.map_or_else(|| "off".into(), |v| v.to_string()),
559 budget.max_round_renewals,
560 budget.max_tool_calls.map_or_else(|| "off".into(), |v| v.to_string()),
561 budget
562 .max_accumulated_tool_result_bytes
563 .map_or_else(|| "off".into(), |v| v.to_string()),
564 budget.max_context_tokens.map_or_else(|| "unset".into(), |v| v.to_string()),
565 budget.max_cost_usd.map_or_else(|| "unset".into(), |v| v.to_string()),
566 ))
567 }
568 ["time", "off"] => {
569 let mut budget = runtime.turn_budget().clone();
570 budget.max_elapsed = None;
571 runtime.set_turn_budget(budget);
572 CommandResult::Output(
573 "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(),
574 )
575 }
576 ["time", duration] => {
577 let Some(seconds) = parse_turn_duration(duration) else {
578 return CommandResult::Error(USAGE.into());
579 };
580 let mut budget = runtime.turn_budget().clone();
581 budget.max_elapsed = Some(std::time::Duration::from_secs(seconds));
582 runtime.set_turn_budget(budget);
583 CommandResult::Output(format!(
584 "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."
585 ))
586 }
587 _ => CommandResult::Error(USAGE.into()),
588 }
589}
590
591fn parse_turn_duration(value: &str) -> Option<u64> {
592 let (digits, multiplier) = if let Some(digits) = value.strip_suffix('s') {
593 (digits, 1)
594 } else if let Some(digits) = value.strip_suffix('m') {
595 (digits, 60)
596 } else if let Some(digits) = value.strip_suffix('h') {
597 (digits, 3600)
598 } else {
599 return None;
600 };
601 if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
602 return None;
603 }
604 let seconds = digits.parse::<u64>().ok()?.checked_mul(multiplier)?;
605 (1..=86_400).contains(&seconds).then_some(seconds)
606}
607
608pub fn trace_command(arg: &str, runtime: &crate::Runtime) -> CommandResult {
610 match arg.trim() {
611 "next" => {
612 runtime.trace_arm_next(false);
613 CommandResult::Output(
614 "trace armed for the next request (metadata only, then auto-disabled)".to_string(),
615 )
616 }
617 "next content" => {
618 runtime.trace_arm_next(true);
619 CommandResult::Output(
620 "trace armed for the next request WITH redacted content capture.\n\
621 The request body (never headers/credentials) is recursively \
622 redacted and kept in a private, short-lived capture bundle; \
623 export it with `synaps trace export <request-id> \
624 --include-content --allow-content-export --output PATH` \
625 before it expires."
626 .to_string(),
627 )
628 }
629 "" | "status" => CommandResult::Output(runtime.trace_status().render()),
630 other => CommandResult::Error(format!(
631 "unknown /trace subcommand: `{other}` (try: next, next content, status)"
632 )),
633 }
634}
635
636pub fn memory_command(arg: &str, runtime: &crate::Runtime) -> CommandResult {
647 use crate::runtime::memory_context::{mint_explicit_command_proof, MemoryContextMode};
648 let enable = |mode: MemoryContextMode| match runtime
649 .memory_context_enable(mode, mint_explicit_command_proof())
650 {
651 Ok(status) => CommandResult::Output(status.render()),
652 Err(e) => CommandResult::Error(e.to_string()),
653 };
654 match arg.trim() {
655 "on" => enable(MemoryContextMode::CaptureAndRecall),
656 "recall" => enable(MemoryContextMode::RecallEachPrompt),
657 "capture" => enable(MemoryContextMode::CaptureOnly),
658 "once" => match runtime.memory_context_recall_once(mint_explicit_command_proof()) {
659 Ok(status) => CommandResult::Output(status.render()),
660 Err(e) => CommandResult::Error(e.to_string()),
661 },
662 "" | "status" => CommandResult::Output(runtime.memory_context_status().render()),
663 "off" => CommandResult::Output(runtime.memory_context_disable().render()),
666 "index-history" => match runtime.memory_history_preview() {
667 Ok(preview) => CommandResult::Output(preview.render()),
668 Err(error) => CommandResult::Error(error.to_string()),
669 },
670 "index-history confirm" => match runtime.memory_history_confirm() {
671 Ok(report) => CommandResult::Output(format!(
672 "History import complete: {} session(s) loaded, {} bounded capture record(s) submitted in {} batch(es).",
673 report.sessions_loaded, report.captures_built, report.batches_submitted,
674 )),
675 Err(error) => CommandResult::Error(error.to_string()),
676 },
677 "index-history decline" => {
678 if runtime.memory_history_decline() {
679 CommandResult::Output(
680 "History import declined; no session content was read and no Axel data was written."
681 .to_string(),
682 )
683 } else {
684 CommandResult::Error(
685 "history import requires a pending disclosure preview before decline"
686 .to_string(),
687 )
688 }
689 }
690 "why" => CommandResult::Output(crate::runtime::memory_context::render_recall_why(
691 runtime.memory_recall_why().as_ref(),
692 )),
693 other => CommandResult::Error(format!(
694 "unknown /memory subcommand: `{other}` \
695 (try: on, recall, capture, once, status, off, index-history, why)"
696 )),
697 }
698}
699
700pub fn persist_to_config(key: &str, value: &str) -> String {
702 match crate::config::write_config_value(key, value) {
703 Ok(()) => "(saved to config)".to_string(),
704 Err(e) => format!("(session only — failed to persist: {})", e),
705 }
706}
707
708#[cfg(test)]
709mod tests {
710 use super::*;
711
712 #[test]
713 fn model_command_carries_model_name() {
714 match evaluate_engine_command("model", "claude-sonnet-4-6") {
715 Some(CommandResult::ModelChanged { model, .. }) => {
716 assert_eq!(model, "claude-sonnet-4-6")
717 }
718 other => panic!("expected ModelChanged, got {:?}", other),
719 }
720 assert!(matches!(
721 evaluate_engine_command("models", "claude-opus-4-6"),
722 Some(CommandResult::ModelChanged { .. })
723 ));
724 assert!(evaluate_engine_command("model", "").is_none());
725 }
726
727 #[test]
728 fn thinking_command_normalizes_levels() {
729 match evaluate_engine_command("thinking", "high") {
730 Some(CommandResult::ThinkingChanged { spec }) => {
731 assert_eq!(spec.level(), ReasoningLevel::High);
732 assert_eq!(spec.budget(), Some(16384));
733 assert!(matches!(spec, ThinkingSpec::Named(ReasoningLevel::High)));
734 }
735 other => panic!("expected ThinkingChanged, got {:?}", other),
736 }
737 let med = parse_thinking_arg("med").unwrap();
738 assert_eq!(med.level(), ReasoningLevel::Medium);
739 assert_eq!(med.budget(), Some(4096));
740
741 let custom = parse_thinking_arg("8192").unwrap();
743 assert!(
744 matches!(
745 custom,
746 ThinkingSpec::Custom {
747 level: ReasoningLevel::High,
748 budget: 8192
749 }
750 ),
751 "expected Custom{{High, 8192}}, got {:?}",
752 custom
753 );
754 assert_eq!(custom.budget(), Some(8192));
755
756 assert!(parse_thinking_arg("bogus").is_err());
757 assert!(evaluate_engine_command("thinking", "").is_none());
758 }
759
760 #[test]
761 fn thinking_command_off_and_adaptive_are_distinct() {
762 let off = parse_thinking_arg("off").unwrap();
763 let adp = parse_thinking_arg("adaptive").unwrap();
764 assert_eq!(off.level(), ReasoningLevel::Off);
765 assert_eq!(off.budget(), Some(0));
766 assert_eq!(adp.level(), ReasoningLevel::Adaptive);
767 assert_eq!(adp.budget(), Some(0));
768 assert_ne!(off.level(), adp.level());
769 }
770
771 #[test]
772 fn max_and_ultra_have_no_budget() {
773 let max_spec = parse_thinking_arg("max").unwrap();
774 assert_eq!(max_spec.level(), ReasoningLevel::Max);
775 assert_eq!(max_spec.budget(), None, "max has no numeric budget");
776
777 let ultra_spec = parse_thinking_arg("ultra").unwrap();
778 assert_eq!(ultra_spec.level(), ReasoningLevel::Ultra);
779 assert_eq!(ultra_spec.budget(), None, "ultra has no numeric budget");
780
781 let xhigh_spec = parse_thinking_arg("xhigh").unwrap();
783 assert_eq!(xhigh_spec.level(), ReasoningLevel::XHigh);
784 assert_eq!(xhigh_spec.budget(), Some(32768));
785 }
786
787 #[test]
788 fn thinking_config_value_is_named_for_named_levels() {
789 assert_eq!(
790 thinking_config_value(ThinkingSpec::Named(ReasoningLevel::Off)),
791 "off"
792 );
793 assert_eq!(
794 thinking_config_value(ThinkingSpec::Named(ReasoningLevel::Adaptive)),
795 "adaptive"
796 );
797 assert_eq!(
798 thinking_config_value(ThinkingSpec::Named(ReasoningLevel::Low)),
799 "low"
800 );
801 assert_eq!(
802 thinking_config_value(ThinkingSpec::Named(ReasoningLevel::Medium)),
803 "medium"
804 );
805 assert_eq!(
806 thinking_config_value(ThinkingSpec::Named(ReasoningLevel::High)),
807 "high"
808 );
809 assert_eq!(
810 thinking_config_value(ThinkingSpec::Named(ReasoningLevel::XHigh)),
811 "xhigh"
812 );
813 assert_eq!(
814 thinking_config_value(ThinkingSpec::Named(ReasoningLevel::Max)),
815 "max"
816 );
817 assert_eq!(
818 thinking_config_value(ThinkingSpec::Named(ReasoningLevel::Ultra)),
819 "ultra"
820 );
821 assert_eq!(
822 thinking_config_value(ThinkingSpec::Named(ReasoningLevel::UltraCode)),
823 "ultracode"
824 );
825 }
826
827 #[test]
828 fn thinking_config_value_is_exact_digits_for_custom_budget() {
829 let spec = ThinkingSpec::Custom {
831 level: ReasoningLevel::High,
832 budget: 8192,
833 };
834 assert_eq!(thinking_config_value(spec), "8192");
835 let spec2 = ThinkingSpec::Custom {
836 level: ReasoningLevel::Medium,
837 budget: 3000,
838 };
839 assert_eq!(thinking_config_value(spec2), "3000");
840 }
841
842 #[test]
843 fn compact_carries_custom_instructions() {
844 match evaluate_engine_command("compact", "focus on auth") {
845 Some(CommandResult::Compact {
846 custom_instructions,
847 }) => {
848 assert_eq!(custom_instructions.as_deref(), Some("focus on auth"));
849 }
850 other => panic!("expected Compact, got {:?}", other),
851 }
852 assert!(matches!(
853 evaluate_engine_command("compact", ""),
854 Some(CommandResult::Compact {
855 custom_instructions: None
856 })
857 ));
858 }
859
860 #[test]
861 #[serial_test::serial(synaps_base_dir)]
862 fn persist_to_config_reports_write_result() {
863 let base = crate::test_env::BaseDirGuard::new();
866
867 let status = persist_to_config("model", "claude-sonnet-4-6");
868
869 assert_eq!(status, "(saved to config)");
870 let path = agent_core::config::resolve_read_path("config");
871 assert!(path.starts_with(base.path()), "written outside the base dir: {path:?}");
872 let contents = std::fs::read_to_string(&path).unwrap();
873 assert!(contents.contains("model = claude-sonnet-4-6"));
874 }
875
876 #[test]
877 fn validate_level_codex_sol_accepts_ultra() {
878 let mut rt = crate::Runtime::new_headless();
879 rt.set_model("openai-codex/gpt-5.6-sol".to_string());
880 assert!(rt
881 .set_reasoning_level_checked(ReasoningLevel::Ultra)
882 .is_ok());
883 }
884
885 #[test]
886 fn validate_level_codex_luna_rejects_ultra_leaves_state_unchanged() {
887 let mut rt = crate::Runtime::new_headless();
888 rt.set_model("openai-codex/gpt-5.6-luna".to_string());
889 rt.set_reasoning_level(ReasoningLevel::Low);
890 let err = rt
891 .set_reasoning_level_checked(ReasoningLevel::Ultra)
892 .unwrap_err();
893 assert!(err.contains("ultra"));
894 assert!(err.contains("gpt-5.6-luna"));
895 assert_eq!(rt.reasoning_level(), ReasoningLevel::Low);
897 }
898
899 #[test]
900 fn validate_level_non_codex_rejects_ultra() {
901 for model in [
902 "claude-sonnet-4-6",
903 "anthropic/claude-opus-4-7",
904 "groq/llama-3",
905 ] {
906 let mut rt = crate::Runtime::new_headless();
907 rt.set_model(model.to_string());
908 assert!(
909 rt.set_reasoning_level_checked(ReasoningLevel::Ultra)
910 .is_err(),
911 "non-Codex {model} must not gain ultra without exact metadata"
912 );
913 }
914 }
915
916 #[test]
919 fn set_reasoning_level_is_not_explicit() {
920 let mut rt = crate::Runtime::new_headless();
922 rt.set_reasoning_level(ReasoningLevel::High);
923 assert!(
924 !rt.is_reasoning_explicit(),
925 "set_reasoning_level must not mark explicit"
926 );
927 }
928
929 #[test]
930 fn set_reasoning_level_explicit_marks_flag() {
931 let mut rt = crate::Runtime::new_headless();
932 rt.set_reasoning_level_explicit(ReasoningLevel::High);
933 assert!(
934 rt.is_reasoning_explicit(),
935 "set_reasoning_level_explicit must mark explicit"
936 );
937 }
938
939 #[test]
940 fn set_model_overwrites_non_explicit_codex_default() {
941 let mut rt = crate::Runtime::new_headless();
943 rt.set_model("openai-codex/gpt-5.6-luna".to_string());
944 assert_eq!(rt.reasoning_level(), ReasoningLevel::Medium);
946 assert!(!rt.is_reasoning_explicit());
947 }
948
949 #[test]
950 fn set_model_preserves_explicit_user_choice() {
951 let mut rt = crate::Runtime::new_headless();
953 rt.set_reasoning_level_explicit(ReasoningLevel::Low);
954 rt.set_model("openai-codex/gpt-5.6-sol".to_string());
955 assert!(
957 rt.is_reasoning_explicit(),
958 "explicit flag must survive set_model"
959 );
960 }
961
962 #[test]
963 fn apply_config_thinking_sets_explicit() {
964 use crate::config::SynapsConfig;
966 let mut rt = crate::Runtime::new_headless();
967 let config = SynapsConfig {
968 thinking_level: Some(ReasoningLevel::XHigh),
969 ..Default::default()
970 };
971 rt.apply_config(&config);
972 assert!(
973 rt.is_reasoning_explicit(),
974 "config thinking must be explicit"
975 );
976 assert_eq!(rt.reasoning_level(), ReasoningLevel::XHigh);
977 rt.set_model("openai-codex/gpt-5.6-luna".to_string());
979 assert_eq!(
980 rt.reasoning_level(),
981 ReasoningLevel::XHigh,
982 "explicit config level must survive set_model"
983 );
984 }
985
986 #[test]
987 fn set_thinking_budget_explicit_retains_exact_budget_and_marks_flag() {
988 let mut rt = crate::Runtime::new_headless();
990 rt.set_thinking_budget_explicit(8192);
991 assert_eq!(
992 rt.thinking_budget_raw(),
993 8192,
994 "exact budget must be retained"
995 );
996 assert!(rt.is_reasoning_explicit());
997 assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
999 }
1000
1001 #[test]
1004 fn custom_budget_rejected_on_xai_intrinsic_reasoning_leaves_state_unchanged() {
1005 let mut rt = crate::Runtime::new_headless();
1008 rt.set_model("xai-auth/grok-4.3".to_string());
1009 let before_budget = rt.thinking_budget_raw();
1010 let before_level = rt.reasoning_level();
1011 let before_explicit = rt.is_reasoning_explicit();
1012 match handle_engine_command("thinking", "8192", &mut rt) {
1013 Some(CommandResult::Error(msg)) => {
1014 assert!(
1015 msg.contains("xai-auth/grok-4.3"),
1016 "message names model: {msg}"
1017 )
1018 }
1019 other => panic!("expected Error, got {:?}", other),
1020 }
1021 assert_eq!(
1022 rt.thinking_budget_raw(),
1023 before_budget,
1024 "budget must be unchanged"
1025 );
1026 assert_eq!(
1027 rt.reasoning_level(),
1028 before_level,
1029 "level must be unchanged"
1030 );
1031 assert_eq!(
1032 rt.is_reasoning_explicit(),
1033 before_explicit,
1034 "provenance unchanged"
1035 );
1036 }
1037
1038 #[test]
1039 fn custom_budget_rejected_on_xai_non_reasoning_model() {
1040 let mut rt = crate::Runtime::new_headless();
1041 rt.set_model("xai-auth/grok-4.20-0309-non-reasoning".to_string());
1042 let before_budget = rt.thinking_budget_raw();
1043 match handle_engine_command("thinking", "8192", &mut rt) {
1044 Some(CommandResult::Error(msg)) => {
1045 assert!(msg.contains("non-reasoning"), "{msg}")
1046 }
1047 other => panic!("expected Error, got {:?}", other),
1048 }
1049 assert_eq!(
1050 rt.thinking_budget_raw(),
1051 before_budget,
1052 "state must be unchanged"
1053 );
1054 }
1055
1056 #[test]
1057 fn custom_budget_maps_to_high_and_is_accepted_on_xai_45() {
1058 let mut rt = crate::Runtime::new_headless();
1060 rt.set_model("xai-auth/grok-4.5".to_string());
1061 match handle_engine_command("thinking", "8192", &mut rt) {
1062 Some(CommandResult::ThinkingChanged { spec }) => {
1063 assert_eq!(spec.level(), ReasoningLevel::High);
1064 assert_eq!(spec.budget(), Some(8192));
1065 }
1066 other => panic!("expected ThinkingChanged, got {:?}", other),
1067 }
1068 assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
1069 assert!(rt.is_reasoning_explicit());
1070 }
1071
1072 #[test]
1075 fn model_command_reports_reasoning_clamp_for_unsupported_explicit_level() {
1076 let mut rt = crate::Runtime::new_headless();
1077 handle_engine_command("thinking", "xhigh", &mut rt).expect("thinking applies");
1078 assert_eq!(rt.reasoning_level(), ReasoningLevel::XHigh);
1079 match handle_engine_command("model", "xai-auth/grok-4.6", &mut rt) {
1080 Some(CommandResult::ModelChanged {
1081 model,
1082 reasoning_clamped,
1083 }) => {
1084 assert_eq!(model, "xai-auth/grok-4.6");
1085 assert_eq!(
1086 reasoning_clamped,
1087 Some(crate::runtime::ReasoningClamp {
1088 from: ReasoningLevel::XHigh,
1089 to: ReasoningLevel::High,
1090 })
1091 );
1092 }
1093 other => panic!("expected ModelChanged, got {:?}", other),
1094 }
1095 assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
1096 assert!(rt.is_reasoning_explicit());
1097 match handle_engine_command("model", "xai-auth/grok-4.5", &mut rt) {
1099 Some(CommandResult::ModelChanged {
1100 reasoning_clamped, ..
1101 }) => assert_eq!(reasoning_clamped, None),
1102 other => panic!("expected ModelChanged, got {:?}", other),
1103 }
1104 }
1105
1106 #[test]
1107 fn custom_budget_preserved_exactly_on_anthropic_fixed_budget_model() {
1108 let mut rt = crate::Runtime::new_headless();
1111 rt.set_model("claude-sonnet-4-6".to_string());
1112 match handle_engine_command("thinking", "8192", &mut rt) {
1113 Some(CommandResult::ThinkingChanged { spec }) => {
1114 assert_eq!(spec.config_value(), "8192", "persists exact digits");
1115 }
1116 other => panic!("expected ThinkingChanged, got {:?}", other),
1117 }
1118 assert_eq!(
1119 rt.thinking_budget_raw(),
1120 8192,
1121 "exact budget must be retained"
1122 );
1123 assert!(rt.is_reasoning_explicit());
1124 }
1125
1126 #[test]
1127 fn custom_budget_config_value_is_digits_not_named() {
1128 let spec = parse_thinking_arg("8192").unwrap();
1130 assert_eq!(
1131 spec.config_value(),
1132 "8192",
1133 "custom budget must persist as digits, not named level"
1134 );
1135 }
1136
1137 #[test]
1140 fn trace_next_enables_exactly_the_next_request_then_auto_disables() {
1141 let runtime = crate::Runtime::new_headless();
1142 assert!(!runtime.trace_context().enabled());
1144
1145 match trace_command("next", &runtime) {
1146 CommandResult::Output(text) => assert!(text.contains("next request")),
1147 other => panic!("expected Output, got {other:?}"),
1148 }
1149 let status = runtime.trace_status();
1150 assert!(!status.persistent_enabled);
1151 assert_eq!(status.arm, crate::runtime::trace::TraceArm::NextMetadata);
1152
1153 let armed = runtime.effective_trace_context();
1155 assert!(armed.enabled(), "armed context must trace the next request");
1156 let after = runtime.effective_trace_context();
1158 assert!(!after.enabled(), "arm must cover exactly one request");
1159 assert_eq!(
1160 runtime.trace_status().arm,
1161 crate::runtime::trace::TraceArm::Off
1162 );
1163 }
1164
1165 #[test]
1166 fn trace_status_reports_mode_path_and_counters_without_secrets() {
1167 let runtime = crate::Runtime::new_headless();
1168 match trace_command("status", &runtime) {
1169 CommandResult::Output(text) => {
1170 assert!(text.contains("trace: disabled"), "got: {text}");
1171 assert!(text.contains("degraded"));
1172 }
1173 other => panic!("expected Output, got {other:?}"),
1174 }
1175 runtime.trace_arm_next(true);
1176 match trace_command("", &runtime) {
1177 CommandResult::Output(text) => {
1178 assert!(text.contains("armed for next request"), "got: {text}");
1179 assert!(text.contains("content capture"));
1180 }
1181 other => panic!("expected Output, got {other:?}"),
1182 }
1183 assert!(matches!(
1184 trace_command("bogus", &runtime),
1185 CommandResult::Error(_)
1186 ));
1187 }
1188
1189 #[test]
1190 fn context_command_reports_metadata_never_content() {
1191 let mut runtime = crate::Runtime::new_headless();
1192 let secret = "CONTEXT-SENTINEL-5a5a";
1193 runtime.set_system_prompt(format!("you are {secret}"));
1194 let history = vec![std::sync::Arc::new(serde_json::json!({
1195 "role": "user", "content": secret
1196 }))];
1197 match context_command(&runtime, Some(&history)) {
1198 CommandResult::Output(text) => {
1199 assert!(!text.contains(secret), "sentinel leaked: {text}");
1200 assert!(text.contains("history: 1 messages"));
1201 assert!(text.contains("system prompt:"));
1202 assert!(text.contains("skills: unavailable"));
1203 }
1204 other => panic!("expected Output, got {other:?}"),
1205 }
1206 match handle_engine_command("context", "", &mut runtime) {
1208 Some(CommandResult::Output(text)) => {
1209 assert!(
1210 text.contains("history: unavailable messages"),
1211 "got: {text}"
1212 );
1213 }
1214 other => panic!("expected Output, got {other:?}"),
1215 }
1216 }
1217
1218 #[test]
1219 fn budget_duration_parser_is_strict_and_bounded() {
1220 for (input, seconds) in [
1221 ("1s", 1),
1222 ("30m", 1800),
1223 ("4h", 14400),
1224 ("24h", 86400),
1225 ("1440m", 86400),
1226 ("86400s", 86400),
1227 ] {
1228 assert_eq!(parse_turn_duration(input), Some(seconds), "{input}");
1229 }
1230 for input in [
1231 "",
1232 "s",
1233 "0s",
1234 "0h",
1235 "25h",
1236 "86401s",
1237 "1441m",
1238 "4",
1239 "4H",
1240 "-1h",
1241 "+1h",
1242 "1.5h",
1243 "1h30m",
1244 " 4h",
1245 "4 h",
1246 "4h ",
1247 "1e3s",
1248 "∞h",
1249 "12h",
1250 "18446744073709551615h",
1251 "18446744073709551616s",
1252 "off",
1253 "unlimited",
1254 ] {
1255 assert_eq!(parse_turn_duration(input), None, "{input}");
1256 }
1257 }
1258
1259 fn worker_handle(
1262 id: &str,
1263 status: crate::runtime::subagent::SubagentStatus,
1264 ) -> crate::runtime::subagent::SubagentHandle {
1265 let state = std::sync::Arc::new(std::sync::RwLock::new(
1266 crate::runtime::subagent::SubagentState::new(),
1267 ));
1268 state.write().unwrap().status = status;
1269 crate::runtime::subagent::SubagentHandle::new(
1270 id.into(),
1271 id.trim_start_matches("sa_").parse().unwrap_or(0),
1272 "spike".into(),
1273 "task".into(),
1274 "claude-sonnet-4-6".into(),
1275 "system".into(),
1276 60,
1277 state,
1278 None,
1279 None,
1280 None,
1281 )
1282 }
1283
1284 fn runtime_with_workers() -> (
1285 crate::Runtime,
1286 std::sync::Arc<crate::orchestration::OrchestrationRuntime>,
1287 ) {
1288 use crate::runtime::subagent::SubagentStatus;
1289 let mut runtime = crate::Runtime::new_headless();
1290 let model = agent_core::prompt::QualifiedModelId::parse("openrouter/z-ai/glm-5.2").unwrap();
1291 let orchestration = std::sync::Arc::new(
1292 crate::orchestration::OrchestrationRuntime::baseline(model, 3, 8).unwrap(),
1293 );
1294 for id in ["sa_9", "sa_10"] {
1295 orchestration.resolve_and_authorize(id, None).unwrap();
1296 orchestration.mark_starting(id).unwrap();
1297 orchestration.mark_running(id).unwrap();
1298 }
1299 runtime.install_orchestration(orchestration.clone());
1300 {
1301 let mut registry = runtime.subagent_registry().lock().unwrap();
1302 registry.register(worker_handle("sa_9", SubagentStatus::Running));
1303 registry.register(worker_handle("sa_10", SubagentStatus::Completed));
1304 }
1305 (runtime, orchestration)
1306 }
1307
1308 fn command_text(result: Option<CommandResult>) -> String {
1309 match result {
1310 Some(CommandResult::Output(text)) | Some(CommandResult::Error(text)) => text,
1311 other => panic!("unexpected {other:?}"),
1312 }
1313 }
1314
1315 #[test]
1318 fn confirms_lists_and_reset_revokes_the_allow_all_latch() {
1319 let mut runtime = crate::Runtime::new_headless();
1320 let worker = runtime.clone();
1321 let text = command_text(handle_engine_command("confirms", "", &mut runtime));
1322 assert!(text.contains("No extension"), "{text}");
1323
1324 worker
1325 .session_allow_all()
1326 .allow(&[("jev".to_string(), 0), ("audit".to_string(), 0)]);
1327 let text = command_text(handle_engine_command("confirms", "status", &mut runtime));
1328 assert!(text.contains("jev") && text.contains("audit"), "{text}");
1329
1330 let text = command_text(handle_engine_command("confirms", "reset", &mut runtime));
1331 assert!(text.contains("Revoked") && text.contains("jev"), "{text}");
1332 assert!(worker.session_allow_all().is_empty(), "shared with workers");
1333 let text = command_text(handle_engine_command("confirms", "reset", &mut runtime));
1334 assert!(text.contains("Nothing to revoke"), "{text}");
1335
1336 assert!(matches!(
1337 handle_engine_command("confirms", "explode", &mut runtime),
1338 Some(CommandResult::Error(e)) if e.contains("usage: /confirms")
1339 ));
1340 assert!(crate::skills::BUILTIN_COMMANDS.contains(&"confirms"));
1341 }
1342
1343 #[test]
1344 fn workers_status_lists_workers_and_flags_uncollected_ones() {
1345 let (mut runtime, _) = runtime_with_workers();
1346 let text = command_text(handle_engine_command("workers", "", &mut runtime));
1347 let sa9 = text.find("sa_9").expect(&text);
1348 let sa10 = text.find("sa_10").expect(&text);
1349 assert!(sa9 < sa10, "natural handle order: {text}");
1350 let line10 = text.lines().find(|l| l.contains("sa_10")).unwrap();
1351 assert!(
1352 line10.contains("completed") && line10.contains("holds rollover"),
1353 "{line10}"
1354 );
1355 let line9 = text.lines().find(|l| l.contains("sa_9")).unwrap();
1356 assert!(
1357 line9.contains("running") && !line9.contains("holds rollover"),
1358 "{line9}"
1359 );
1360 assert!(crate::skills::BUILTIN_COMMANDS.contains(&"workers"));
1361 }
1362
1363 #[test]
1364 fn workers_reconcile_releases_finished_workers_and_never_running_ones() {
1365 let (mut runtime, orchestration) = runtime_with_workers();
1366 let text = command_text(handle_engine_command(
1367 "workers",
1368 "reconcile all",
1369 &mut runtime,
1370 ));
1371 assert!(text.contains("Reconciled sa_10"), "{text}");
1372 assert!(text.contains("Still running, left alone: sa_9"), "{text}");
1373 assert!(!orchestration.is_unreconciled("sa_10"));
1374 assert!(orchestration.is_unreconciled("sa_9"));
1375 let Some(CommandResult::Error(e)) =
1377 handle_engine_command("workers", "reconcile sa_10", &mut runtime)
1378 else {
1379 panic!("already reconciled must be refused")
1380 };
1381 assert!(e.contains("not awaiting"), "{e}");
1382 }
1383
1384 #[test]
1389 fn workers_cancel_never_relabels_a_worker_that_completed_meanwhile() {
1390 use crate::runtime::subagent::SubagentStatus;
1391 let mut runtime = crate::Runtime::new_headless();
1392 for round in 0..20_000 {
1393 let h = worker_handle("sa_1", SubagentStatus::Running);
1394 let state = std::sync::Arc::clone(&h.state);
1395 {
1396 let mut registry = runtime.subagent_registry().lock().unwrap();
1397 registry.remove("sa_1");
1398 registry.register(h);
1399 }
1400 let go = std::sync::Arc::new(std::sync::Barrier::new(2));
1401 let worker = {
1402 let (go, state) = (go.clone(), state.clone());
1403 let spin = round % 4_000;
1405 std::thread::spawn(move || {
1406 go.wait();
1407 for _ in 0..spin {
1408 std::hint::spin_loop();
1409 }
1410 let mut s = state.write().unwrap();
1411 if matches!(s.status, SubagentStatus::Running) && !s.cancel_requested {
1412 s.status = SubagentStatus::Completed;
1413 }
1414 })
1415 };
1416 go.wait();
1417 let reply = handle_engine_command("workers", "cancel sa_1", &mut runtime);
1418 worker.join().unwrap();
1419 let s = state.read().unwrap();
1420 assert!(
1421 !(s.status == SubagentStatus::Completed && s.cancel_requested),
1422 "round {round}: completed AND flagged cancelled"
1423 );
1424 match reply {
1425 Some(CommandResult::Output(text)) => {
1426 assert!(s.cancel_requested && text.contains("Cancelled sa_1"), "{text}")
1427 }
1428 Some(CommandResult::Error(e)) => {
1429 assert!(!s.cancel_requested && e.contains("already completed"), "{e}")
1430 }
1431 _ => panic!("round {round}: unexpected reply"),
1432 }
1433 }
1434 }
1435
1436 #[test]
1437 fn workers_cancel_targets_running_workers_only() {
1438 let (mut runtime, _) = runtime_with_workers();
1439 let Some(CommandResult::Error(e)) =
1440 handle_engine_command("workers", "cancel sa_10", &mut runtime)
1441 else {
1442 panic!("finished worker cannot be cancelled")
1443 };
1444 assert!(e.contains("already completed"), "{e}");
1445 let text = command_text(handle_engine_command("workers", "cancel all", &mut runtime));
1446 assert!(text.contains("Cancelled sa_9"), "{text}");
1447 let registry = runtime.subagent_registry().lock().unwrap();
1448 assert!(
1449 registry
1450 .get("sa_9")
1451 .unwrap()
1452 .state
1453 .read()
1454 .unwrap()
1455 .cancel_requested
1456 );
1457 drop(registry);
1458 let Some(CommandResult::Error(usage)) =
1459 handle_engine_command("workers", "explode", &mut runtime)
1460 else {
1461 panic!("usage")
1462 };
1463 assert!(usage.starts_with("usage: /workers"));
1464 }
1465
1466 #[test]
1467 fn budget_command_status_and_invalid_arguments_do_not_mutate() {
1468 let mut runtime = crate::Runtime::new_headless();
1469 let original = runtime.turn_budget().clone();
1470 for arg in ["", "status", " status "] {
1471 let Some(CommandResult::Output(text)) =
1472 handle_engine_command("budget", arg, &mut runtime)
1473 else {
1474 panic!("{arg}");
1475 };
1476 assert!(text.contains("wall-clock: off;"));
1477 assert!(text.contains("not live usage"));
1478 assert!(text.contains("provider rounds: off (+8 bounded renewals)"));
1479 assert!(text.contains("tool calls: off;"));
1480 assert!(text.contains("tool-result bytes: off\n"));
1481 assert_eq!(runtime.turn_budget(), &original);
1482 }
1483 for arg in [
1484 "time",
1485 "time 0s",
1486 "time 25h",
1487 "time 1h trailing",
1488 "status 4h",
1489 "time off now",
1490 "off",
1491 "reset",
1492 ] {
1493 assert!(
1494 matches!(
1495 handle_engine_command("budget", arg, &mut runtime),
1496 Some(CommandResult::Error(_))
1497 ),
1498 "{arg}"
1499 );
1500 assert_eq!(runtime.turn_budget(), &original);
1501 }
1502 }
1503
1504 #[test]
1505 fn budget_command_changes_only_future_runtime_time_limit() {
1506 use crate::runtime::budget::{TurnBudget, TurnBudgetMeter, TurnRole};
1507 let mut runtime = crate::Runtime::new_headless();
1508 let original = TurnBudget {
1509 max_elapsed: Some(std::time::Duration::ZERO),
1510 max_tool_calls: Some(11),
1511 max_context_tokens: Some(456),
1512 max_cost_usd: Some(0.75),
1513 ..TurnBudget::for_role(TurnRole::Foreground)
1514 };
1515 runtime.set_turn_budget(original.clone());
1516 let mut in_flight = TurnBudgetMeter::new(runtime.turn_budget().clone());
1517 let other_runtime = runtime.clone();
1518 let Some(CommandResult::Output(text)) =
1519 handle_engine_command("budget", "time 4h", &mut runtime)
1520 else {
1521 panic!("expected output");
1522 };
1523 assert!(text.contains("14400s"));
1524 assert!(text.contains("not saved"));
1525 let mut expected = original.clone();
1526 expected.max_elapsed = Some(std::time::Duration::from_secs(14400));
1527 assert_eq!(runtime.turn_budget(), &expected);
1528 assert_eq!(other_runtime.turn_budget(), &original);
1529 assert_eq!(
1530 in_flight.begin_round(),
1531 Err(agent_core::BudgetDimension::WallClock)
1532 );
1533 assert!(TurnBudgetMeter::new(runtime.turn_budget().clone())
1534 .begin_round()
1535 .is_ok());
1536 let Some(CommandResult::Output(text)) =
1537 handle_engine_command("budget", "time off", &mut runtime)
1538 else {
1539 panic!("expected output");
1540 };
1541 assert!(text.contains("removed"));
1542 expected.max_elapsed = None;
1543 assert_eq!(runtime.turn_budget(), &expected);
1544 assert_eq!(
1546 crate::Runtime::new_headless().turn_budget().max_elapsed,
1547 None
1548 );
1549 for role in [TurnRole::Foreground, TurnRole::Worker, TurnRole::Autonomous] {
1550 assert_eq!(TurnBudget::for_role(role).max_elapsed, None);
1551 }
1552 assert!(crate::skills::BUILTIN_COMMANDS.contains(&"budget"));
1553 }
1554
1555 use crate::runtime::memory_context::{
1558 DurableStatus, MemoryContextMode, OneShotStatus, UserIntentProof,
1559 };
1560
1561 fn output_text(result: CommandResult) -> String {
1562 match result {
1563 CommandResult::Output(text) => text,
1564 other => panic!("expected Output, got {other:?}"),
1565 }
1566 }
1567
1568 #[test]
1572 fn memory_enable_args_transition_to_exact_modes() {
1573 for (arg, mode) in [
1574 ("on", MemoryContextMode::CaptureAndRecall),
1575 ("recall", MemoryContextMode::RecallEachPrompt),
1576 ("capture", MemoryContextMode::CaptureOnly),
1577 ] {
1578 let runtime = crate::Runtime::new_headless();
1579 let text = output_text(memory_command(arg, &runtime));
1580 assert!(
1581 text.contains(&format!("mode {}", mode.as_str())),
1582 "/memory {arg} output must name the mode; got: {text}"
1583 );
1584 match runtime.memory_context_status().durable {
1585 DurableStatus::Active { mode: active, .. } => {
1586 assert_eq!(active, mode, "/memory {arg} must install exactly {mode:?}")
1587 }
1588 DurableStatus::Off => panic!("/memory {arg} must install a session lease"),
1589 }
1590 }
1591 }
1592
1593 #[test]
1596 fn memory_once_installs_pending_one_shot_only() {
1597 let runtime = crate::Runtime::new_headless();
1598 let text = output_text(memory_command("once", &runtime));
1599 assert!(text.contains("one-shot recall: pending"), "got: {text}");
1600 let status = runtime.memory_context_status();
1601 assert_eq!(status.durable, DurableStatus::Off, "durable slot untouched");
1602 assert!(matches!(status.one_shot, OneShotStatus::Pending { .. }));
1603 assert!(matches!(
1605 memory_command("once", &runtime),
1606 CommandResult::Error(e) if e.contains("already pending")
1607 ));
1608 }
1609
1610 #[test]
1613 fn memory_status_render_mentions_mode_and_lease_expiry() {
1614 let runtime = crate::Runtime::new_headless();
1615 let text = output_text(memory_command("status", &runtime));
1617 assert!(text.contains("mode off"), "got: {text}");
1618
1619 output_text(memory_command("on", &runtime));
1620 for arg in ["status", ""] {
1621 let text = output_text(memory_command(arg, &runtime));
1622 assert!(
1623 text.contains("mode capture_and_recall"),
1624 "status must mention the mode; got: {text}"
1625 );
1626 assert!(
1627 text.contains("expiry"),
1628 "status must mention the lease expiry; got: {text}"
1629 );
1630 }
1631 }
1632
1633 #[test]
1636 fn memory_off_takes_effect_before_returning() {
1637 let runtime = crate::Runtime::new_headless();
1638 output_text(memory_command("on", &runtime));
1639 assert!(matches!(
1640 runtime.memory_context_status().durable,
1641 DurableStatus::Active { .. }
1642 ));
1643 let text = output_text(memory_command("off", &runtime));
1644 assert_eq!(runtime.memory_context_status().durable, DurableStatus::Off);
1646 assert!(text.contains("mode off"), "got: {text}");
1648 }
1649
1650 #[test]
1655 fn memory_why_without_recall_metadata_is_clear_output_not_error() {
1656 let runtime = crate::Runtime::new_headless();
1657 let text = output_text(memory_command("why", &runtime));
1658 assert!(text.contains("no recall metadata available"), "got: {text}");
1659 assert!(text.contains("memory is off"), "got: {text}");
1660 assert!(text.contains("no eligible prompt"), "got: {text}");
1661 assert!(text.contains("skipped"), "got: {text}");
1662
1663 output_text(memory_command("on", &runtime));
1665 let text = output_text(memory_command("why", &runtime));
1666 assert!(text.contains("no recall metadata available"), "got: {text}");
1667 }
1668
1669 #[test]
1672 fn memory_enable_always_uses_explicit_command_proof() {
1673 for arg in ["on", "recall", "capture"] {
1674 let runtime = crate::Runtime::new_headless();
1675 output_text(memory_command(arg, &runtime));
1676 assert!(
1677 matches!(
1678 runtime.memory_durable_proof_for_test(),
1679 Some(UserIntentProof::ExplicitCommand { .. })
1680 ),
1681 "/memory {arg} must grant under ExplicitCommand proof"
1682 );
1683 }
1684 let runtime = crate::Runtime::new_headless();
1685 output_text(memory_command("once", &runtime));
1686 assert!(
1687 matches!(
1688 runtime.memory_one_shot_proof_for_test(),
1689 Some(UserIntentProof::ExplicitCommand { .. })
1690 ),
1691 "/memory once must grant under ExplicitCommand proof"
1692 );
1693 }
1694
1695 #[test]
1697 fn memory_unknown_arg_is_typed_error() {
1698 let runtime = crate::Runtime::new_headless();
1699 match memory_command("bogus", &runtime) {
1700 CommandResult::Error(e) => {
1701 assert!(e.contains("unknown /memory subcommand"), "got: {e}");
1702 assert!(e.contains("index-history"), "hint lists forms; got: {e}");
1703 }
1704 other => panic!("expected Error, got {other:?}"),
1705 }
1706 assert_eq!(runtime.memory_context_status().durable, DurableStatus::Off);
1707 }
1708
1709 #[test]
1712 fn memory_index_history_surfaces_disclosure_preview() {
1713 let runtime = crate::Runtime::new_headless();
1714 match memory_command("index-history", &runtime) {
1715 CommandResult::Output(text) => {
1716 assert!(text.contains("History import preview"), "got: {text}");
1717 assert!(text.contains("project:"), "got: {text}");
1718 assert!(text.contains("sessions:"), "got: {text}");
1719 assert!(text.contains("approximate bytes:"), "got: {text}");
1720 assert!(text.contains("included date range:"), "got: {text}");
1721 assert!(text.contains("included classes:"), "got: {text}");
1722 assert!(text.contains("excluded classes:"), "got: {text}");
1723 assert!(text.contains("retention policy:"), "got: {text}");
1724 assert!(text.contains("redaction policy:"), "got: {text}");
1725 assert!(text.contains("destination .r8:"), "got: {text}");
1726 assert!(
1727 text.contains("explicit confirmation required: true"),
1728 "got: {text}"
1729 );
1730 assert!(text.contains("no import has started"), "got: {text}");
1731 }
1732 other => panic!("expected Output, got {other:?}"),
1733 }
1734 let status = runtime.memory_context_status();
1735 assert_eq!(status.durable, DurableStatus::Off);
1736 assert_eq!(status.one_shot, OneShotStatus::Idle);
1737 }
1738
1739 #[test]
1740 fn memory_index_history_decline_clears_pending_preview() {
1741 let runtime = crate::Runtime::new_headless();
1742 assert!(matches!(
1743 memory_command("index-history", &runtime),
1744 CommandResult::Output(_)
1745 ));
1746 assert!(matches!(
1747 memory_command("index-history decline", &runtime),
1748 CommandResult::Output(text) if text.contains("no session content was read")
1749 ));
1750 assert!(matches!(
1751 memory_command("index-history confirm", &runtime),
1752 CommandResult::Error(error) if error.contains("explicit user confirmation")
1753 ));
1754 }
1755
1756 #[test]
1757 fn memory_index_history_confirmation_requires_prior_preview() {
1758 let runtime = crate::Runtime::new_headless();
1759 assert!(matches!(
1760 memory_command("index-history confirm", &runtime),
1761 CommandResult::Error(error) if error.contains("explicit user confirmation")
1762 ));
1763 }
1764
1765 #[test]
1768 fn memory_dispatches_through_handle_engine_command() {
1769 let mut runtime = crate::Runtime::new_headless();
1770 match handle_engine_command("memory", "on", &mut runtime) {
1771 Some(CommandResult::Output(text)) => {
1772 assert!(text.contains("mode capture_and_recall"), "got: {text}");
1773 }
1774 other => panic!("expected Some(Output), got {other:?}"),
1775 }
1776 match handle_engine_command("memory", "", &mut runtime) {
1777 Some(CommandResult::Output(text)) => {
1778 assert!(text.contains("mode capture_and_recall"), "got: {text}");
1779 assert!(text.contains("expiry"), "got: {text}");
1780 }
1781 other => panic!("expected Some(Output), got {other:?}"),
1782 }
1783 }
1784}