Skip to main content

agent_engine/session/
actor_cmds.rs

1//! `SessionActor` — command bodies that belong to the TUI port (A3):
2//! `engine_command` (structured reply), `submit_prepared`, `plugin_command`,
3//! `resume`, `session_name`, `emit_subagent_rows`. Kept apart from
4//! `actor.rs` so the turn machine (B) and the command surface (A) never
5//! touch the same file.
6
7use std::sync::Arc;
8
9use super::actor::{Live, SessionActor};
10use super::types::*;
11
12/// `/compact [instructions]` as the engine parses it (no runtime mutation).
13fn compact_request(cmd: &str, arg: &str) -> Option<crate::engine::commands::CommandResult> {
14    match crate::engine::commands::evaluate_engine_command(cmd, arg) {
15        Some(r @ crate::engine::commands::CommandResult::Compact { .. }) => Some(r),
16        _ => None,
17    }
18}
19
20/// `handle_engine_command` on `runtime` + the `QueryResult` value clients
21/// render. Returns `(value, view_changed)`. Shared by the actor and the
22/// TUI's `ScriptedTransport` so both answer identically.
23pub fn engine_command_reply(
24    cmd: &str,
25    arg: &str,
26    runtime: &mut crate::Runtime,
27    session: &mut crate::Session,
28) -> (serde_json::Value, bool) {
29    use crate::engine::commands::{handle_engine_command, CommandResult};
30    match handle_engine_command(cmd, arg, runtime) {
31        None => (serde_json::json!({ "kind": "unhandled" }), false),
32        Some(CommandResult::Quit) => (serde_json::json!({ "kind": "quit" }), false),
33        Some(CommandResult::ModelChanged {
34            model,
35            reasoning_clamped,
36        }) => {
37            session.model = runtime.model().to_string();
38            let mut text = format!("model → {}", model);
39            let mut clamp = serde_json::Value::Null;
40            if let Some(c) = reasoning_clamped {
41                session.thinking_level = runtime.thinking_level().to_string();
42                text.push_str(&format!(
43                    "\nthinking → {} (clamped from {}: not supported by {})",
44                    c.to.as_str(),
45                    c.from.as_str(),
46                    runtime.model()
47                ));
48                clamp = serde_json::json!({ "from": c.from.as_str(), "to": c.to.as_str() });
49            }
50            (
51                serde_json::json!({
52                    "kind": "notice",
53                    "event": "model_changed",
54                    "text": text,
55                    "model": runtime.model(),
56                    "clamp": clamp,
57                }),
58                true,
59            )
60        }
61        Some(CommandResult::ThinkingChanged { spec }) => {
62            session.thinking_level = spec.config_value();
63            (
64                serde_json::json!({
65                    "kind": "notice",
66                    "event": "thinking_changed",
67                    "text": format!("thinking → {}", spec.level()),
68                    "level": spec.level().to_string(),
69                    "config_value": spec.config_value(),
70                }),
71                true,
72            )
73        }
74        Some(CommandResult::Compact { .. }) => (serde_json::json!({ "kind": "none" }), false),
75        Some(CommandResult::Error(e)) => (serde_json::json!({ "kind": "error", "text": e }), false),
76        Some(CommandResult::Output(text)) => {
77            (serde_json::json!({ "kind": "output", "text": text }), false)
78        }
79        Some(_) => (serde_json::json!({ "kind": "none" }), false),
80    }
81}
82
83/// `/auth reload` → `{kind: output|error, text}`. Token values never appear:
84/// success text is built by `Runtime::reload_auth`, failures carry the broker
85/// error (which is token-free by construction).
86pub async fn auth_command_reply(arg: &str, runtime: &crate::Runtime) -> serde_json::Value {
87    match arg.trim() {
88        "reload" => match runtime.reload_auth().await {
89            Ok(text) => serde_json::json!({ "kind": "output", "text": text }),
90            Err(e) => serde_json::json!({
91                "kind": "error",
92                "text": format!("auth reload failed: {e}"),
93            }),
94        },
95        _ => serde_json::json!({ "kind": "error", "text": "usage: /auth reload" }),
96    }
97}
98
99impl SessionActor {
100    /// `engine::commands::handle_engine_command` on THE runtime; reply as a
101    /// `QueryResult` the client renders. `kind`/`text` are what chat.rs
102    /// reads (`notice`/`error`/`output`/`quit`/`unhandled`); the TUI reads
103    /// the structured extras (`event`, `model`, `clamp`, `level`,
104    /// `config_value`) to render its own lines byte-for-byte.
105    pub(crate) async fn engine_command(&mut self, id: u64, cmd: String, arg: String) {
106        use crate::engine::commands::CommandResult;
107        // TUI-only commands that need the actor's session (it owns the
108        // journal): `saveas` names/unnames + force-saves.
109        if cmd == "saveas" {
110            let value = self.session_name(&arg).await;
111            self.emit(SessionEventWire::QueryResult { id, value });
112            return;
113        }
114        // `/keepwarm [status|off|auto|<N>h]`: actor-owned prompt-cache
115        // keep-warm state (task #444).
116        if cmd == "keepwarm" {
117            let value = self.cache_keepwarm_command(&arg).await;
118            self.emit(SessionEventWire::QueryResult { id, value });
119            return;
120        }
121        // `/auth reload` is async (broker round-trip), so it cannot live in
122        // the sync `handle_engine_command`. Never echoes a token.
123        if cmd == "auth" {
124            let value = auth_command_reply(&arg, &self.runtime).await;
125            self.emit(SessionEventWire::QueryResult { id, value });
126            return;
127        }
128        crate::engine::commands::prepare_engine_command(&cmd, &arg, &self.runtime).await;
129        // `/workers cancel`: which workers this command stops (a cancel
130        // already in flight is not this command's).
131        let cancelling = cmd == "workers" && arg.trim_start().starts_with("cancel");
132        let uncancelled = if cancelling { self.uncancelled_workers() } else { Vec::new() };
133        let (value, view_changed) =
134            engine_command_reply(&cmd, &arg, &mut self.runtime, &mut self.conv.session);
135        if cancelling {
136            let after = self
137                .runtime
138                .subagent_registry()
139                .lock()
140                .unwrap_or_else(|p| p.into_inner())
141                .display_rows();
142            for (id, agent) in &cancelled_by_command(uncancelled, &after) {
143                self.note_in_main_conversation(super::actor::subagent_user_cancel_event(id, agent));
144            }
145        }
146        if cmd == "memory" {
147            self.note_memory_choice(&arg);
148            self.observe_memory();
149        }
150        if view_changed {
151            self.publish_view().await;
152            // Clients mirror the view from `SettingChanged` (and `Attached`)
153            // only: without this, `/model` in one tab left every other
154            // tab's footer, and its next request's model chip, on the old one.
155            let view = (**self.view.load()).clone();
156            self.emit(SessionEventWire::SettingChanged(SettingApplied {
157                id: super::wire::VIEW_CHANGED_SETTING_ID,
158                setting: cmd.clone(),
159                ok: true,
160                message: None,
161                view,
162                clamp: None,
163            }));
164        }
165        if let Some(CommandResult::Compact {
166            custom_instructions,
167        }) = compact_request(&cmd, &arg)
168        {
169            self.compact(custom_instructions, "manual").await;
170        }
171        self.emit(SessionEventWire::QueryResult { id, value });
172    }
173
174    /// The memory choice a rebuilt runtime must honour: the recorded one,
175    /// or `Off` once memory is effectively off while a recorded on-mode or
176    /// the operator's durable default would grant it again. Consent can be
177    /// revoked without `/memory` — the model's `memory_context` tool
178    /// (`action: "disable"`), a lease expiry, a host-side revoke — and
179    /// recording `Off` is always the safe direction. On-modes are recorded
180    /// only from `/memory` ([`Self::note_memory_choice`]): the tool cannot
181    /// enable (that needs the command's ExplicitCommand proof), so no other
182    /// path may make one sticky.
183    ///
184    /// Off counts as a revocation only once memory was seen active in this
185    /// runtime (`memory_seen_active`): a grant that never took effect — an
186    /// extension-backed provider not registered yet after a reload or
187    /// unpark — leaves the choice alone, so the next rebuild retries it.
188    pub(crate) fn effective_memory_mode(&self) -> Option<super::SessionMemoryMode> {
189        use crate::runtime::memory_context::DurableStatus;
190        let recorded = self.config.memory_mode;
191        if !self.runtime.is_live() {
192            return recorded;
193        }
194        let off = matches!(self.runtime.memory_context_status().durable, DurableStatus::Off);
195        let would_grant = match recorded {
196            Some(mode) => mode.lease_mode().is_some(),
197            None => self.memory_default_is_durable(),
198        };
199        if off && would_grant && self.memory_seen_active {
200            Some(super::SessionMemoryMode::Off)
201        } else {
202            recorded
203        }
204    }
205
206    /// The operator's confirmed `memory.default_mode` grants a durable
207    /// session lease (`recall_once` is a one-shot, not a mode).
208    fn memory_default_is_durable(&self) -> bool {
209        let memory = &self.host.config().memory;
210        memory.default_mode_confirmed
211            && matches!(
212                memory.default_mode.as_str(),
213                "recall_each_prompt" | "capture_only" | "capture_and_recall"
214            )
215    }
216
217    /// Latch `memory_seen_active` if this runtime's memory is active now.
218    /// Called where a grant can take effect: the runtime's build, `/memory`,
219    /// each turn's start and end.
220    pub(crate) fn observe_memory(&mut self) {
221        use crate::runtime::memory_context::DurableStatus;
222        if self.runtime.is_live()
223            && matches!(
224                self.runtime.memory_context_status().durable,
225                DurableStatus::Active { .. }
226            )
227        {
228            self.memory_seen_active = true;
229        }
230    }
231
232    /// Record [`Self::effective_memory_mode`] (config + the handle's slot).
233    /// A revocation recorded here (not by `/memory`) is told to the user.
234    pub(crate) fn sync_memory_choice(&mut self) {
235        self.observe_memory();
236        let mode = self.effective_memory_mode();
237        if mode != self.config.memory_mode {
238            self.config.memory_mode = mode;
239            *self
240                .memory_mode_slot
241                .lock()
242                .unwrap_or_else(std::sync::PoisonError::into_inner) = mode;
243            if mode == Some(super::SessionMemoryMode::Off) {
244                self.emit(SessionEventWire::SystemNotice(
245                    "memory stays off for this session: its consent was revoked, and a park or daemon reload keeps it off (/memory on turns it back on)"
246                        .into(),
247                ));
248            }
249        }
250    }
251
252    /// `/close [force]`: end this session in the daemon. It is saved and
253    /// ended exactly like the in-process quit (`End{ClientQuit}`), leaves the
254    /// daemon's session list, and every attached client is told (`Ended`);
255    /// `synaps --attach --continue <id>` brings it back from its journal.
256    /// Over a socket `/quit` only detaches, which leaves the session loaded.
257    ///
258    /// Refused while closing would destroy work in flight: a running turn or
259    /// compaction, a running worker (a session's workers die with its
260    /// runtime), an armed `/auto` run. `force` closes anyway: it cancels those
261    /// workers first and notes each one in the conversation, for whoever
262    /// continues it. A parked session has none of these and closes without
263    /// being rebuilt.
264    ///
265    /// Owner-only, like every engine command. Replies on the `QueryResult`
266    /// (`output` / `error`) before the `Ended`. `Some(reason)` = end now.
267    pub(crate) fn close_command(&mut self, id: u64, arg: &str) -> Option<EndReason> {
268        let reply = |text: String, kind: &str| SessionEventWire::QueryResult {
269            id,
270            value: serde_json::json!({ "kind": kind, "text": text }),
271        };
272        let force = match arg.trim() {
273            "" => false,
274            "force" => true,
275            _ => {
276                self.emit(reply("usage: /close [force]".into(), "error"));
277                return None;
278            }
279        };
280        let live = self.runtime.is_live();
281        let workers: Vec<(String, String)> = if live {
282            let mut running: Vec<(String, String)> = self
283                .runtime
284                .subagent_registry()
285                .lock()
286                .unwrap_or_else(|p| p.into_inner())
287                .display_rows()
288                .into_iter()
289                .filter(|r| {
290                    r.status == crate::runtime::subagent::SubagentStatus::Running
291                        && !r.cancel_requested
292                })
293                .map(|r| (r.handle_id, r.agent_name))
294                .collect();
295            running.sort();
296            running
297        } else {
298            Vec::new()
299        };
300        let mut blockers = Vec::new();
301        if self.streaming {
302            blockers.push("a turn is running".to_string());
303        }
304        if self.compact.is_some() {
305            blockers.push("a compaction is running".to_string());
306        }
307        if !workers.is_empty() {
308            let names: Vec<String> = workers.iter().map(|(id, agent)| format!("{id} {agent}")).collect();
309            blockers.push(format!(
310                "{} worker{} running ({})",
311                workers.len(),
312                if workers.len() == 1 { " is" } else { "s are" },
313                names.join(", ")
314            ));
315        }
316        if self.driver.is_some() {
317            blockers.push("an /auto run is armed".to_string());
318        }
319        if !blockers.is_empty() && !force {
320            self.emit(reply(
321                format!(
322                    "not closed: {}. Closing the session would stop {}. Wait, or use /close force.",
323                    blockers.join("; "),
324                    if blockers.len() == 1 { "it" } else { "them" },
325                ),
326                "error",
327            ));
328            return None;
329        }
330        if !workers.is_empty() {
331            let cancelled = self
332                .runtime
333                .subagent_registry()
334                .lock()
335                .unwrap_or_else(|p| p.into_inner())
336                .cancel_running(crate::runtime::subagent::WorkerCancelScope::All);
337            for (handle_id, agent) in workers.iter().filter(|(h, _)| cancelled.contains(h)) {
338                self.note_in_main_conversation(super::actor::subagent_close_cancel_event(handle_id, agent));
339            }
340        }
341        let session = (**self.journal_id.load()).clone();
342        // No history, no journal: there is nothing to continue (a parked
343        // session always has one).
344        let text = if self.conv.is_live() && self.conv.api_messages.is_empty() {
345            format!("closing session {session} (it never ran a turn, so there is nothing to resume)")
346        } else {
347            format!("closing session {session}; resume it with: synaps --attach --continue {session}")
348        };
349        self.emit(reply(text, "output"));
350        Some(EndReason::ClientQuit)
351    }
352
353    /// `(id, agent)` of every worker with no cancel requested, sorted.
354    fn uncancelled_workers(&self) -> Vec<(String, String)> {
355        let mut workers: Vec<(String, String)> = self
356            .runtime
357            .subagent_registry()
358            .lock()
359            .unwrap_or_else(|p| p.into_inner())
360            .display_rows()
361            .into_iter()
362            .filter(|r| !r.cancel_requested)
363            .map(|r| (r.handle_id, r.agent_name))
364            .collect();
365        workers.sort();
366        workers
367    }
368
369    /// `/memory on|recall|capture|off` that took effect becomes the
370    /// session's own choice (`SessionConfig::memory_mode`): a rebuilt
371    /// runtime restores it rather than the operator default. A failed grant
372    /// leaves the previous choice in place.
373    fn note_memory_choice(&mut self, arg: &str) {
374        use crate::runtime::memory_context::DurableStatus;
375        let Some(choice) = super::SessionMemoryMode::from_command_arg(arg) else {
376            return;
377        };
378        let took = match (self.runtime.memory_context_status().durable, choice.lease_mode()) {
379            (DurableStatus::Off, None) => true,
380            (DurableStatus::Active { mode, .. }, Some(want)) => mode == want,
381            _ => false,
382        };
383        if took {
384            self.config.memory_mode = Some(choice);
385            *self
386                .memory_mode_slot
387                .lock()
388                .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(choice);
389        }
390    }
391
392    /// `/saveas <name>` | `/saveas` (clear). Force-saves even with no
393    /// messages — persist the name change (commands.rs `saveas` arm).
394    async fn session_name(&mut self, arg: &str) -> serde_json::Value {
395        let trimmed = arg.trim();
396        // This saves `conv.session` directly (even with no messages): a
397        // queued save of it, without the new name, must not land after.
398        self.persister.flush().await;
399        if trimmed.is_empty() {
400            self.conv.session.clear_name();
401            let _ = self.conv.session.save().await;
402            self.sync_name();
403            self.emit_conversation();
404            return serde_json::json!({
405                "kind": "output", "text": "session name cleared", "name": serde_json::Value::Null,
406            });
407        }
408        match self.conv.session.set_name(trimmed) {
409            Ok(()) => {
410                let _ = self.conv.session.save().await;
411                self.sync_name();
412                self.emit_conversation();
413                serde_json::json!({
414                    "kind": "output",
415                    "text": format!("session named '{}'", trimmed),
416                    "name": trimmed,
417                })
418            }
419            Err(e) => serde_json::json!({ "kind": "error", "text": e.to_string() }),
420        }
421    }
422
423    /// dispatch.rs LoadSkill (:330-360): pre-built tool_use/tool_result pair
424    /// (+ optional user text) then a turn. Does NOT reset
425    /// `consecutive_auto_turns`.
426    pub(crate) async fn submit_prepared(
427        &mut self,
428        messages: Vec<crate::SharedMessage>,
429        user_text: Option<String>,
430    ) {
431        if self.streaming {
432            self.emit(SessionEventWire::SystemNotice(
433                "cannot load a skill while streaming".into(),
434            ));
435            return;
436        }
437        self.conv.api_messages.extend(messages);
438        if let Some(text) = user_text {
439            self.conv
440                .api_messages
441                .push(Arc::new(serde_json::json!({"role": "user", "content": text})));
442        }
443        self.start_turn(TurnTrigger::PluginCommand, None).await;
444    }
445
446    /// commands.rs:123-158: tools-backed (non-interactive) plugin command on
447    /// THE runtime's tool set. Reply `{kind:"plugin_output", status, stdout,
448    /// stderr}` or `{kind:"error", text}`.
449    pub(crate) async fn plugin_command(&mut self, id: u64, plugin: String, name: String, arg: String) {
450        let value = match Self::find_plugin_command(&plugin, &name) {
451            None => serde_json::json!({
452                "kind": "error",
453                "text": format!("unknown plugin command /{plugin}:{name}"),
454            }),
455            Some(cmd) => match crate::skills::commands::execute_plugin_command_with_tools(
456                &cmd,
457                &arg,
458                self.runtime.tools_shared(),
459            )
460            .await
461            {
462                Ok(output) => serde_json::json!({
463                    "kind": "plugin_output",
464                    "status": output.status,
465                    "stdout": output.stdout,
466                    "stderr": output.stderr,
467                }),
468                Err(e) => serde_json::json!({ "kind": "error", "text": e.to_string() }),
469            },
470        };
471        self.emit(SessionEventWire::QueryResult { id, value });
472    }
473
474    fn find_plugin_command(
475        plugin: &str,
476        name: &str,
477    ) -> Option<Arc<crate::skills::registry::RegisteredPluginCommand>> {
478        let host = crate::host::EngineHost::current()?;
479        match host.command_registry().resolve(&format!("{plugin}:{name}")) {
480            crate::skills::registry::Resolution::PluginCommand(c) => Some(c),
481            _ => None,
482        }
483    }
484
485    /// commands.rs:647-693 `/resume`: save current, load `query`, restore
486    /// model/reasoning/system prompt, swap conversation. Reply `Resumed{id}`
487    /// then `Conversation`; error → `QueryResult{id, {kind:"error", text}}`.
488    pub(crate) async fn resume(&mut self, id: u64, query: String) {
489        if self.streaming {
490            self.emit(SessionEventWire::QueryResult {
491                id,
492                value: serde_json::json!({ "kind": "error", "text": "cannot resume while streaming" }),
493            });
494            return;
495        }
496        // Queued saves first: resuming the CURRENT session reloads it from
497        // disk, which must hold its latest state.
498        self.persister.flush().await;
499        let session = match crate::resolve_session(&query) {
500            Ok(s) => s,
501            Err(e) => {
502                self.emit(SessionEventWire::QueryResult {
503                    id,
504                    value: serde_json::json!({ "kind": "error", "text": e.to_string() }),
505                });
506                return;
507            }
508        };
509        let old_id = self.conv.session.id.clone();
510        let new_id = session.id.clone();
511        // F10: the journal lock follows the conversation. Take the new lock
512        // FIRST, before anything changes: a session locked by another
513        // process (or another session of this daemon) is live there right
514        // now, and resuming it here would fork its history. The old lock is
515        // only released once the new one is held.
516        let new_lock = if new_id == old_id {
517            None // resuming the current session: keep its lock
518        } else {
519            match Self::try_lock_session(&new_id) {
520                Ok(lock) => Some(lock),
521                Err(e @ agent_core::session_lock::SessionLockError::Held { .. })
522                | Err(e @ agent_core::session_lock::SessionLockError::CompactedInto { .. }) => {
523                    self.emit(SessionEventWire::QueryResult {
524                        id,
525                        value: serde_json::json!({
526                            "kind": "error",
527                            "text": format!("cannot resume: {e}"),
528                        }),
529                    });
530                    return;
531                }
532                Err(e) => {
533                    // I/O trouble (e.g. a read-only sessions dir): proceed
534                    // unlocked, as create/unpark do.
535                    tracing::warn!(session = %new_id, "resume: session lock: {e}");
536                    None
537                }
538            }
539        };
540        // Committed to the swap: end keep-warm now, booking any ping in
541        // flight to the conversation being left (saved just below).
542        self.cache_keepwarm_conversation_replaced();
543        self.runtime.set_model(session.model.clone());
544        // A resumed session owns its saved choice. Preserve that explicit
545        // provenance across later model switches.
546        let clamp_notice = self
547            .runtime
548            .restore_session_reasoning(&session.thinking_level)
549            .map(|clamp| {
550                format!(
551                    "thinking → {} (clamped from {}: not supported by {})",
552                    clamp.to.as_str(),
553                    clamp.from.as_str(),
554                    self.runtime.model()
555                )
556            });
557        if let Some(ref sp) = session.system_prompt {
558            self.runtime.set_system_prompt(sp.clone());
559        }
560        self.save().await;
561        let via = if crate::chain::load_chain(&query).is_ok() {
562            Some(format!("chain '{}'", query))
563        } else if crate::find_session_by_name(&query).is_ok() {
564            Some(format!("name '{}'", query))
565        } else {
566            None
567        };
568        if new_id != old_id {
569            // Drops (releases) the old lock; `None` = proceeding unlocked.
570            self.session_lock = new_lock;
571        }
572        self.conv = Live::new(crate::engine::session::ConversationState::from_resumed(session));
573        // Crash recovery is the lock holder's alone (`recover_turn_draft`).
574        if self.config.persist && self.session_lock.is_some() {
575            crate::engine::setup::recover_turn_draft(&mut self.conv).await;
576        }
577        if clamp_notice.is_some() {
578            // Keep the session file in sync with the clamped runtime.
579            self.conv.session.thinking_level = self.runtime.thinking_level().to_string();
580        }
581        self.consecutive_auto_turns = 0;
582        self.runtime.begin_conversation(Some(new_id.clone()));
583        self.set_journal_id(new_id.clone());
584        self.publish_view().await;
585        self.emit(SessionEventWire::Resumed {
586            id,
587            old_id,
588            new_id,
589            via,
590            clamp_notice,
591        });
592        self.emit_conversation();
593    }
594
595    /// `runtime.subagent_registry().display_rows()` → `SubagentRows`
596    /// (called at Done/Error and from the 1 Hz arm B adds).
597    #[allow(dead_code)]
598    pub(crate) fn emit_subagent_rows(&mut self) {
599        let rows = self
600            .runtime
601            .subagent_registry()
602            .lock()
603            .unwrap_or_else(|p| p.into_inner())
604            .display_rows();
605        self.emit(SessionEventWire::SubagentRows(rows));
606    }
607
608    /// Any non-terminal subagent row (drives the 1 Hz arm).
609    #[allow(dead_code)]
610    pub(crate) fn has_live_subagents(&self) -> bool {
611        self.runtime
612            .subagent_registry()
613            .lock()
614            .unwrap_or_else(|p| p.into_inner())
615            .display_rows()
616            .iter()
617            .any(|r| matches!(r.status, crate::runtime::subagent::SubagentStatus::Running))
618    }
619}
620
621/// Of the workers that had no cancel requested before a `/workers cancel`,
622/// those that have one now. A worker that left the registry meanwhile
623/// (reaped at a turn end that raced the command) was not cancelled by it.
624fn cancelled_by_command(
625    before: Vec<(String, String)>,
626    after: &[crate::runtime::subagent::SubagentDisplayRow],
627) -> Vec<(String, String)> {
628    before
629        .into_iter()
630        .filter(|(id, _)| after.iter().any(|r| &r.handle_id == id && r.cancel_requested))
631        .collect()
632}
633
634#[cfg(test)]
635mod tests {
636    //! `/resume`'s reasoning restore is the runtime's `restore_session_reasoning`
637    //! + the clamp line above; pin the two behaviours the TUI tests pinned.
638    use crate::Runtime;
639
640    fn row(id: &str, cancel_requested: bool) -> crate::runtime::subagent::SubagentDisplayRow {
641        serde_json::from_value(serde_json::json!({
642            "subagent_id": 0, "agent_name": "a", "status": "running",
643            "cancel_requested": cancel_requested, "elapsed_secs": 0.0,
644            "finished_elapsed": null, "handle_id": id,
645        }))
646        .unwrap()
647    }
648
649    /// `/workers cancel` tells the main agent only about workers the command
650    /// flagged: one reaped from the registry while it ran (it completed; its
651    /// wake is the truth) is not "cancelled by the user".
652    #[test]
653    fn the_cancel_note_names_only_workers_the_command_flagged() {
654        let before: Vec<(String, String)> = ["sa_1", "sa_2", "sa_3"]
655            .iter()
656            .map(|id| (id.to_string(), "a".to_string()))
657            .collect();
658        // sa_1 flagged; sa_2 reaped meanwhile; sa_3 untouched.
659        let after = [row("sa_1", true), row("sa_3", false)];
660        let named: Vec<String> =
661            super::cancelled_by_command(before, &after).into_iter().map(|(id, _)| id).collect();
662        assert_eq!(named, ["sa_1"]);
663    }
664
665    #[tokio::test]
666    async fn restore_session_reasoning_keeps_explicit_provenance() {
667        let mut runtime = Runtime::new().await.unwrap();
668        runtime.set_model("openai-codex/gpt-5.6-sol".to_string());
669        let clamp = runtime.restore_session_reasoning("ultra");
670        assert!(clamp.is_none());
671        assert_eq!(
672            runtime.reasoning_level(),
673            agent_core::reasoning::ReasoningLevel::Ultra
674        );
675        assert!(runtime.is_reasoning_explicit());
676        runtime.set_model("openai-codex/gpt-5.6-terra".to_string());
677        assert_eq!(
678            runtime.reasoning_level(),
679            agent_core::reasoning::ReasoningLevel::Ultra
680        );
681    }
682
683    #[tokio::test]
684    async fn restore_session_reasoning_clamps_unsupported_level() {
685        let mut runtime = Runtime::new().await.unwrap();
686        runtime.set_model("xai-auth/grok-4.6".to_string());
687        let clamp = runtime.restore_session_reasoning("xhigh");
688        assert!(clamp.is_some());
689        assert_eq!(
690            runtime.reasoning_level(),
691            agent_core::reasoning::ReasoningLevel::High
692        );
693        assert!(runtime.is_reasoning_explicit());
694    }
695}
696
697#[cfg(test)]
698mod auth_command_tests {
699    use super::auth_command_reply;
700
701    #[tokio::test]
702    async fn auth_command_usage_and_token_free_error() {
703        let rt = crate::Runtime::new_headless(); // api_key "test-token"
704        let v = auth_command_reply("bogus", &rt).await;
705        assert_eq!(v["kind"], "error");
706        assert!(v["text"].as_str().unwrap().contains("usage: /auth reload"));
707        let v = auth_command_reply("  reload ", &rt).await;
708        let t = v["text"].as_str().unwrap();
709        assert_eq!(v["kind"], "error");
710        assert!(t.contains("nothing to reload"), "{t}");
711        assert!(!t.contains("test-token"), "{t}");
712    }
713
714    #[test]
715    fn auth_is_a_builtin_command() {
716        assert!(crate::skills::BUILTIN_COMMANDS.contains(&"auth"));
717    }
718}