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    /// Sidecar toggle/status.
136    SidecarToggle {
137        plugin_id: Option<String>,
138    },
139    SidecarStatus {
140        plugin_id: Option<String>,
141    },
142}
143
144/// TUI-specific modals the engine can request.
145#[derive(Debug, Clone)]
146pub enum ModalRequest {
147    Models,
148    Settings,
149    Plugins,
150    HelpFind { query: String },
151    Extensions { sub: String },
152}
153
154/// Summary of a session for listing.
155#[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
165/// Parse a slash command into (command, arg).
166pub 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
179/// Async pre-step for [`handle_engine_command`]: load the capability
180/// evidence `/model` and `/thinking` are validated against into THIS
181/// process (see [`crate::Runtime::prepare_model_capabilities`]) so the
182/// reasoning clamp / level check is decided on live data, not on an empty
183/// process-local cache. Every async frontend calls this right before the
184/// synchronous handler; it never fails and is a no-op for other commands.
185pub 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
198/// Process commands that are pure engine logic — no TUI state needed.
199/// Returns None if the command needs TUI-level handling.
200pub fn handle_engine_command(
201    cmd: &str,
202    arg: &str,
203    runtime: &mut crate::Runtime,
204) -> Option<CommandResult> {
205    // Runtime-backed commands (Task 12): shared across TUI, headless chat,
206    // and server. `/context` here has no conversation history (the surface
207    // owns it) — the TUI intercepts `/context` earlier and passes its own
208    // history through `context_command`.
209    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            // Validate and apply the spec against model capabilities BEFORE
232            // mutating runtime. State is unchanged on Err.
233            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                    // Custom budget: validate the derived level against the
241                    // exact-model capability tables BEFORE mutating; on Ok the
242                    // exact budget is retained (Anthropic wire uses it as-is).
243                    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
254/// Pure command → result mapping (no runtime mutation).
255pub 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
277/// Parse a `/thinking` argument into a `ThinkingSpec`.
278pub 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                // Custom numeric budget: bucketize to nearest named level for display.
284                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
299/// Canonical config-file string for a thinking change.
300/// Returns the exact budget digits for Custom, named string for Named.
301pub fn thinking_config_value(spec: ThinkingSpec) -> String {
302    spec.config_value()
303}
304
305/// `/context` (Task 12, spec §6.6 acceptance): structured metadata-only
306/// report — system/tool/history counts and bytes, latest cache component
307/// change and reuse estimates, writer counters. Surfaces that own the
308/// conversation pass it as `history`; others get honest `unavailable`.
309pub 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
320/// `/confirms [status] | reset` — inspect or revoke the session's extension
321/// "Allow all" latch (#148 I2b). Reset clears the shared set, so running
322/// subagents stop auto-approving immediately too.
323pub 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
357/// `/workers [status] | cancel <sa_N|all> | reconcile <sa_N|all>` — the
358/// user's direct handle on reactive workers, independent of the model:
359/// - `status` lists every tracked worker and whether it still holds the
360///   orchestration gate (rollover, model change, completion);
361/// - `cancel` stops running workers (Esc only stops the interrupted turn's
362///   own workers, so this is how background workers are stopped);
363/// - `reconcile` is the user's attestation for FINISHED workers the model
364///   never collected — the escape hatch when they pin the session. It never
365///   touches a running worker (cancel it first). No inference, no history.
366pub 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    // Lock order: orchestration snapshot BEFORE the subagent registry.
372    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            // Reaped from the registry but never reconciled (output expired).
400            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                // One decision with the worker's own completion (see
428                // `cancel_if_running`): a worker that just finished keeps
429                // its result.
430                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
532/// `sa_9` before `sa_10`; anything unparseable falls back to string order.
533fn 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
541/// Explicit operator control of future turns. No config writes, inference,
542/// history mutation, or changes to an already-running stream's budget snapshot.
543/// This is a frontend slash command, never a model-callable budget renewal.
544pub 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
608/// `/trace next|next content|status` (Task 12): explicit trace controls.
609pub 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
636/// `/memory on|recall|capture|once|status|off|index-history|why` (task A5,
637/// spec §7.3): the ONE deterministic engine entry point every frontend —
638/// TUI, headless chat, RPC, server, watcher, agent — shares via
639/// [`handle_engine_command`]. Frontends must not duplicate lease or budget
640/// logic.
641///
642/// Every enabling arm mints host-owned
643/// [`crate::runtime::memory_context::UserIntentProof::ExplicitCommand`]
644/// proof right here: a deterministic slash command is authoritative (spec
645/// assumption 5), and proof never derives from model text.
646pub 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        // Disable is applied to session state BEFORE this returns: `render`
664        // runs on the post-revocation status snapshot.
665        "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
700/// Persist a config key and return a user-visible status suffix.
701pub 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        // Custom numeric: produces ThinkingSpec::Custom, not Named.
742        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        // xhigh still has a numeric budget
782        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        // B3: /thinking 8192 must persist "8192", not the named level "high".
830        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        // The config lives under the base directory, which tests pin with
864        // the guard (an ambient SYNAPS_BASE_DIR wins over HOME).
865        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        // State must be unchanged after rejection.
896        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    // B3: provenance tests -------------------------------------------------------
917
918    #[test]
919    fn set_reasoning_level_is_not_explicit() {
920        // set_reasoning_level (config/restore path) must NOT set explicit flag.
921        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        // No explicit choice → set_model applies model's default.
942        let mut rt = crate::Runtime::new_headless();
943        rt.set_model("openai-codex/gpt-5.6-luna".to_string());
944        // luna default is Medium per static table
945        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        // Explicit user choice survives model switch.
952        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        // sol default is Low anyway, but let's use a level that differs from default
956        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        // Config-specified thinking level is explicit — preserved across model switch.
965        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        // Model switch must not overwrite the explicit config level.
978        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        // B3: /thinking 8192 → set_thinking_budget_explicit(8192) must retain 8192.
989        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        // Named level for display is High (4097..=16384 range).
998        assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
999    }
1000
1001    // ── Numeric `/thinking <N>` mutation-time validation (review corrective) ──
1002
1003    #[test]
1004    fn custom_budget_rejected_on_xai_intrinsic_reasoning_leaves_state_unchanged() {
1005        // /thinking 8192 derives High; grok-4.3 has no effort control → reject
1006        // immediately, no mutation, no silent downgrade.
1007        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        // 8192 → High, which grok-4.5 supports exactly → accepted.
1059        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    /// `/model` onto a model that rejects the explicit level reports the clamp
1073    /// so surfaces can tell the user, and leaves the runtime in a usable state.
1074    #[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        // Supported explicit level: no clamp reported.
1098        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        // Anthropic compatibility: numeric budgets stay exact (never bucketized
1109        // away) and are accepted on fixed-budget thinking models.
1110        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        // B3: ThinkingSpec::Custom config_value() must be the digit string.
1129        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    // ── Task 12: /trace and /context surfaces ──
1138
1139    #[test]
1140    fn trace_next_enables_exactly_the_next_request_then_auto_disables() {
1141        let runtime = crate::Runtime::new_headless();
1142        // Telemetry off: the base session context is disabled.
1143        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        // Exactly one effective context is enabled, even with telemetry Off…
1154        let armed = runtime.effective_trace_context();
1155        assert!(armed.enabled(), "armed context must trace the next request");
1156        // …then the arm auto-disables: the following request is untraced.
1157        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        // The engine-shared path (no history) reports honestly.
1207        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    // ── /workers ────────────────────────────────────────────────────────────
1260
1261    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    /// #148 I2b: `/confirms` shows which extensions are latched and
1316    /// `/confirms reset` revokes them — for workers sharing the set too.
1317    #[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        // A handle that is not awaiting reconciliation is refused.
1376        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    /// `/workers cancel sa_N` racing the worker's own completion: the
1385    /// status check and the cancel are one decision, so a worker that
1386    /// completed in between keeps its result (and the user is told it
1387    /// already finished) instead of being relabelled Cancelled.
1388    #[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                // Sweep where the completion lands relative to the command.
1404                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        // No role has a time limit by default.
1545        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    // ── /memory (task A5, spec §7.3) ────────────────────────────────────────
1556
1557    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    /// Spec §7.3: every enabling arg form transitions the session state to
1569    /// its exact mode — `on` → CaptureAndRecall, `recall` →
1570    /// RecallEachPrompt, `capture` → CaptureOnly.
1571    #[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    /// `/memory once` installs a pending one-shot recall and leaves the
1594    /// durable slot untouched.
1595    #[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        // A second grant while one is pending fails typed, not silently.
1604        assert!(matches!(
1605            memory_command("once", &runtime),
1606            CommandResult::Error(e) if e.contains("already pending")
1607        ));
1608    }
1609
1610    /// Rendering: `/memory status` (and the empty default) mentions the
1611    /// mode and the lease expiry.
1612    #[test]
1613    fn memory_status_render_mentions_mode_and_lease_expiry() {
1614        let runtime = crate::Runtime::new_headless();
1615        // Off default (spec §21): status names the off mode.
1616        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    /// `/memory off` revokes session state BEFORE returning: the runtime
1634    /// state check and the returned render both show Off.
1635    #[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        // State check: revocation already applied when the command returned.
1645        assert_eq!(runtime.memory_context_status().durable, DurableStatus::Off);
1646        // The returned render is the post-revocation snapshot.
1647        assert!(text.contains("mode off"), "got: {text}");
1648    }
1649
1650    /// Task B5 (spec §10.4): `/memory why` with no accepted recall this
1651    /// session is clear OUTPUT (never `CommandResult::Error`) explaining the
1652    /// three no-metadata causes — both while memory is off and after enable
1653    /// but before any eligible prompt has run.
1654    #[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        // Enabled but no eligible prompt yet: still the clear non-error text.
1664        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    /// Spec §6.3 / assumption 5: every enable path grants under host-minted
1670    /// `ExplicitCommand` proof — never model-supplied text.
1671    #[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    /// Unknown args are a typed error naming the valid forms.
1696    #[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    /// `index-history` (task D1) returns the host-computed disclosure preview
1710    /// and does not begin import.
1711    #[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    /// All frontends route `/memory` through the single
1766    /// `handle_engine_command` dispatch point (spec §7.3: one engine API).
1767    #[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}