Skip to main content

agent_engine/engine/
commands.rs

1//! Engine-level command results — TUI-agnostic outcomes of slash commands.
2//!
3//! The engine processes a command and returns a `CommandResult`.
4//! Renderers (TUI, headless) decide how to display the result.
5
6use agent_core::reasoning::ReasoningLevel;
7
8/// A thinking specification — either a named level or a custom numeric budget.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum ThinkingSpec {
11    /// A named canonical level (off/adaptive/low/medium/high/xhigh/max/ultra).
12    Named(ReasoningLevel),
13    /// A custom numeric budget (e.g. `/thinking 8192`).
14    /// `level` is the nearest named level for display; `budget` is the exact value.
15    Custom { level: ReasoningLevel, budget: u32 },
16}
17
18impl ThinkingSpec {
19    /// Named level (used for validation and display).
20    pub fn level(self) -> ReasoningLevel {
21        match self {
22            ThinkingSpec::Named(l) => l,
23            ThinkingSpec::Custom { level, .. } => level,
24        }
25    }
26
27    /// Exact budget, if applicable. `None` for Max/Ultra which have no numeric budget.
28    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    /// Config-file string: exact budget digits for Custom, named string otherwise.
36    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/// Result of processing a slash command in the engine.
45#[derive(Debug, Clone)]
46pub enum CommandResult {
47    /// No output, continue.
48    None,
49
50    /// Text output to display to the user.
51    Output(String),
52
53    /// Error message.
54    Error(String),
55
56    /// Model was changed. `reasoning_clamped` is populated when the runtime
57    /// had to substitute the active reasoning level because the new model does
58    /// not support it (see `Runtime::try_set_model`).
59    ModelChanged {
60        model: String,
61        reasoning_clamped: Option<crate::runtime::ReasoningClamp>,
62    },
63
64    /// Thinking level was changed.
65    ThinkingChanged {
66        spec: ThinkingSpec,
67    },
68
69    /// System prompt was updated.
70    SystemPromptSet {
71        source: String, // "inline", "file", "saved"
72    },
73
74    /// System prompt displayed.
75    SystemPromptShow {
76        prompt: String,
77    },
78
79    /// Session list.
80    SessionList {
81        sessions: Vec<SessionSummary>,
82    },
83
84    /// Session cleared. New session returned.
85    Cleared,
86
87    /// Quit requested.
88    Quit,
89
90    /// Compaction requested (engine should trigger it).
91    Compact {
92        custom_instructions: Option<String>,
93    },
94
95    /// Session resumed.
96    Resumed {
97        session_id: String,
98        model: String,
99    },
100
101    /// Session named/saved.
102    Named {
103        name: String,
104    },
105
106    /// Chain info.
107    ChainInfo(String),
108
109    /// Request to open a TUI-specific modal (TUI handles, headless ignores).
110    OpenModal(ModalRequest),
111
112    /// Status/usage info.
113    Status {
114        text: String,
115    },
116
117    /// Ping results.
118    PingStarted,
119
120    /// Keybind list.
121    KeybindList(String),
122
123    /// Skill loaded — needs to be injected into the conversation.
124    SkillLoaded {
125        skill: std::sync::Arc<crate::skills::LoadedSkill>,
126        arg: String,
127    },
128
129    /// Plugin command to execute.
130    PluginCommand {
131        command: std::sync::Arc<crate::skills::registry::RegisteredPluginCommand>,
132        arg: String,
133    },
134}
135
136/// TUI-specific modals the engine can request.
137#[derive(Debug, Clone)]
138pub enum ModalRequest {
139    Models,
140    Settings,
141    Plugins,
142    HelpFind { query: String },
143    Extensions { sub: String },
144}
145
146/// Summary of a session for listing.
147#[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
157/// Parse a slash command into (command, arg).
158pub 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
171/// Async pre-step for [`handle_engine_command`]: load the capability
172/// evidence `/model` and `/thinking` are validated against into THIS
173/// process (see [`crate::Runtime::prepare_model_capabilities`]) so the
174/// reasoning clamp / level check is decided on live data, not on an empty
175/// process-local cache. Every async frontend calls this right before the
176/// synchronous handler; it never fails and is a no-op for other commands.
177pub 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
190/// Process commands that are pure engine logic — no TUI state needed.
191/// Returns None if the command needs TUI-level handling.
192pub fn handle_engine_command(
193    cmd: &str,
194    arg: &str,
195    runtime: &mut crate::Runtime,
196) -> Option<CommandResult> {
197    // Runtime-backed commands (Task 12): shared across TUI, headless chat,
198    // and server. `/context` here has no conversation history (the surface
199    // owns it) — the TUI intercepts `/context` earlier and passes its own
200    // history through `context_command`.
201    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            // Validate and apply the spec against model capabilities BEFORE
224            // mutating runtime. State is unchanged on Err.
225            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                    // Custom budget: validate the derived level against the
233                    // exact-model capability tables BEFORE mutating; on Ok the
234                    // exact budget is retained (Anthropic wire uses it as-is).
235                    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
246/// Pure command → result mapping (no runtime mutation).
247pub 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
269/// Parse a `/thinking` argument into a `ThinkingSpec`.
270pub 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                // Custom numeric budget: bucketize to nearest named level for display.
276                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
291/// Canonical config-file string for a thinking change.
292/// Returns the exact budget digits for Custom, named string for Named.
293pub fn thinking_config_value(spec: ThinkingSpec) -> String {
294    spec.config_value()
295}
296
297/// `/context` (Task 12, spec §6.6 acceptance): structured metadata-only
298/// report — system/tool/history counts and bytes, latest cache component
299/// change and reuse estimates, writer counters. Surfaces that own the
300/// conversation pass it as `history`; others get honest `unavailable`.
301pub 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
312/// `/confirms [status] | reset` — inspect or revoke the session's extension
313/// "Allow all" latch (#148 I2b). Reset clears the shared set, so running
314/// subagents stop auto-approving immediately too.
315pub 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
349/// `/workers [status] | cancel <sa_N|all> | reconcile <sa_N|all>` — the
350/// user's direct handle on reactive workers, independent of the model:
351/// - `status` lists every tracked worker and whether it still holds the
352///   orchestration gate (rollover, model change, completion);
353/// - `cancel` stops running workers (Esc only stops the interrupted turn's
354///   own workers, so this is how background workers are stopped);
355/// - `reconcile` is the user's attestation for FINISHED workers the model
356///   never collected — the escape hatch when they pin the session. It never
357///   touches a running worker (cancel it first). No inference, no history.
358pub 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    // Lock order: orchestration snapshot BEFORE the subagent registry.
364    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            // Reaped from the registry but never reconciled (output expired).
392            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                // One decision with the worker's own completion (see
420                // `cancel_if_running`): a worker that just finished keeps
421                // its result.
422                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
524/// `sa_9` before `sa_10`; anything unparseable falls back to string order.
525fn 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
533/// Explicit operator control of future turns. No config writes, inference,
534/// history mutation, or changes to an already-running stream's budget snapshot.
535/// This is a frontend slash command, never a model-callable budget renewal.
536pub 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
600/// `/trace next|next content|status` (Task 12): explicit trace controls.
601pub 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
628/// What `/memory` answers, whatever its argument, since built-in automatic
629/// memory left core. Kept so a user who types it (or an older TUI that
630/// sends it) gets this line instead of "unknown command".
631pub 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
638/// Persist a config key and return a user-visible status suffix.
639pub 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        // Custom numeric: produces ThinkingSpec::Custom, not Named.
680        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        // xhigh still has a numeric budget
720        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        // B3: /thinking 8192 must persist "8192", not the named level "high".
768        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        // The config lives under the base directory, which tests pin with
802        // the guard (an ambient SYNAPS_BASE_DIR wins over HOME).
803        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        // State must be unchanged after rejection.
834        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    // B3: provenance tests -------------------------------------------------------
855
856    #[test]
857    fn set_reasoning_level_is_not_explicit() {
858        // set_reasoning_level (config/restore path) must NOT set explicit flag.
859        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        // No explicit choice → set_model applies model's default.
880        let mut rt = crate::Runtime::new_headless();
881        rt.set_model("openai-codex/gpt-5.6-luna".to_string());
882        // luna default is Medium per static table
883        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        // Explicit user choice survives model switch.
890        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        // sol default is Low anyway, but let's use a level that differs from default
894        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        // Config-specified thinking level is explicit — preserved across model switch.
903        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        // Model switch must not overwrite the explicit config level.
916        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        // B3: /thinking 8192 → set_thinking_budget_explicit(8192) must retain 8192.
927        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        // Named level for display is High (4097..=16384 range).
936        assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
937    }
938
939    // ── Numeric `/thinking <N>` mutation-time validation (review corrective) ──
940
941    #[test]
942    fn custom_budget_rejected_on_xai_intrinsic_reasoning_leaves_state_unchanged() {
943        // /thinking 8192 derives High; grok-4.3 has no effort control → reject
944        // immediately, no mutation, no silent downgrade.
945        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        // 8192 → High, which grok-4.5 supports exactly → accepted.
997        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    /// `/model` onto a model that rejects the explicit level reports the clamp
1011    /// so surfaces can tell the user, and leaves the runtime in a usable state.
1012    #[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        // Supported explicit level: no clamp reported.
1036        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        // Anthropic compatibility: numeric budgets stay exact (never bucketized
1047        // away) and are accepted on fixed-budget thinking models.
1048        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        // B3: ThinkingSpec::Custom config_value() must be the digit string.
1067        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    // ── Task 12: /trace and /context surfaces ──
1076
1077    #[test]
1078    fn trace_next_enables_exactly_the_next_request_then_auto_disables() {
1079        let runtime = crate::Runtime::new_headless();
1080        // Telemetry off: the base session context is disabled.
1081        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        // Exactly one effective context is enabled, even with telemetry Off…
1092        let armed = runtime.effective_trace_context();
1093        assert!(armed.enabled(), "armed context must trace the next request");
1094        // …then the arm auto-disables: the following request is untraced.
1095        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        // The engine-shared path (no history) reports honestly.
1145        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    // ── /workers ────────────────────────────────────────────────────────────
1198
1199    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    /// #148 I2b: `/confirms` shows which extensions are latched and
1254    /// `/confirms reset` revokes them — for workers sharing the set too.
1255    #[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        // A handle that is not awaiting reconciliation is refused.
1314        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    /// `/workers cancel sa_N` racing the worker's own completion: the
1323    /// status check and the cancel are one decision, so a worker that
1324    /// completed in between keeps its result (and the user is told it
1325    /// already finished) instead of being relabelled Cancelled.
1326    #[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                // Sweep where the completion lands relative to the command.
1342                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        // No role has a time limit by default.
1483        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    /// `/memory`, with any argument, answers the one removal line and turns
1494    /// nothing on.
1495    #[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}