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
83impl SessionActor {
84    /// `engine::commands::handle_engine_command` on THE runtime; reply as a
85    /// `QueryResult` the client renders. `kind`/`text` are what chat.rs
86    /// reads (`notice`/`error`/`output`/`quit`/`unhandled`); the TUI reads
87    /// the structured extras (`event`, `model`, `clamp`, `level`,
88    /// `config_value`) to render its own lines byte-for-byte.
89    pub(crate) async fn engine_command(&mut self, id: u64, cmd: String, arg: String) {
90        use crate::engine::commands::CommandResult;
91        // TUI-only commands that need the actor's session (it owns the
92        // journal): `saveas` names/unnames + force-saves.
93        if cmd == "saveas" {
94            let value = self.session_name(&arg).await;
95            self.emit(SessionEventWire::QueryResult { id, value });
96            return;
97        }
98        // `/keepwarm [status|off|auto|<N>h]`: actor-owned prompt-cache
99        // keep-warm state (task #444).
100        if cmd == "keepwarm" {
101            let value = self.cache_keepwarm_command(&arg).await;
102            self.emit(SessionEventWire::QueryResult { id, value });
103            return;
104        }
105        let (value, view_changed) =
106            engine_command_reply(&cmd, &arg, &mut self.runtime, &mut self.conv.session);
107        if view_changed {
108            self.publish_view().await;
109        }
110        if let Some(CommandResult::Compact {
111            custom_instructions,
112        }) = compact_request(&cmd, &arg)
113        {
114            self.compact(custom_instructions, "manual").await;
115        }
116        self.emit(SessionEventWire::QueryResult { id, value });
117    }
118
119    /// `/saveas <name>` | `/saveas` (clear). Force-saves even with no
120    /// messages — persist the name change (commands.rs `saveas` arm).
121    async fn session_name(&mut self, arg: &str) -> serde_json::Value {
122        let trimmed = arg.trim();
123        // This saves `conv.session` directly (even with no messages): a
124        // queued save of it, without the new name, must not land after.
125        self.persister.flush().await;
126        if trimmed.is_empty() {
127            self.conv.session.clear_name();
128            let _ = self.conv.session.save().await;
129            self.sync_name();
130            self.emit_conversation();
131            return serde_json::json!({
132                "kind": "output", "text": "session name cleared", "name": serde_json::Value::Null,
133            });
134        }
135        match self.conv.session.set_name(trimmed) {
136            Ok(()) => {
137                let _ = self.conv.session.save().await;
138                self.sync_name();
139                self.emit_conversation();
140                serde_json::json!({
141                    "kind": "output",
142                    "text": format!("session named '{}'", trimmed),
143                    "name": trimmed,
144                })
145            }
146            Err(e) => serde_json::json!({ "kind": "error", "text": e.to_string() }),
147        }
148    }
149
150    /// dispatch.rs LoadSkill (:330-360): pre-built tool_use/tool_result pair
151    /// (+ optional user text) then a turn. Does NOT reset
152    /// `consecutive_auto_turns`.
153    pub(crate) async fn submit_prepared(
154        &mut self,
155        messages: Vec<crate::SharedMessage>,
156        user_text: Option<String>,
157    ) {
158        if self.streaming {
159            self.emit(SessionEventWire::SystemNotice(
160                "cannot load a skill while streaming".into(),
161            ));
162            return;
163        }
164        self.conv.api_messages.extend(messages);
165        if let Some(text) = user_text {
166            self.conv
167                .api_messages
168                .push(Arc::new(serde_json::json!({"role": "user", "content": text})));
169        }
170        self.start_turn(TurnTrigger::PluginCommand, None).await;
171    }
172
173    /// commands.rs:123-158: tools-backed (non-interactive) plugin command on
174    /// THE runtime's tool set. Reply `{kind:"plugin_output", status, stdout,
175    /// stderr}` or `{kind:"error", text}`.
176    pub(crate) async fn plugin_command(&mut self, id: u64, plugin: String, name: String, arg: String) {
177        let value = match Self::find_plugin_command(&plugin, &name) {
178            None => serde_json::json!({
179                "kind": "error",
180                "text": format!("unknown plugin command /{plugin}:{name}"),
181            }),
182            Some(cmd) => match crate::skills::commands::execute_plugin_command_with_tools(
183                &cmd,
184                &arg,
185                self.runtime.tools_shared(),
186            )
187            .await
188            {
189                Ok(output) => serde_json::json!({
190                    "kind": "plugin_output",
191                    "status": output.status,
192                    "stdout": output.stdout,
193                    "stderr": output.stderr,
194                }),
195                Err(e) => serde_json::json!({ "kind": "error", "text": e.to_string() }),
196            },
197        };
198        self.emit(SessionEventWire::QueryResult { id, value });
199    }
200
201    fn find_plugin_command(
202        plugin: &str,
203        name: &str,
204    ) -> Option<Arc<crate::skills::registry::RegisteredPluginCommand>> {
205        let host = crate::host::EngineHost::current()?;
206        match host.command_registry().resolve(&format!("{plugin}:{name}")) {
207            crate::skills::registry::Resolution::PluginCommand(c) => Some(c),
208            _ => None,
209        }
210    }
211
212    /// commands.rs:647-693 `/resume`: save current, load `query`, restore
213    /// model/reasoning/system prompt, swap conversation. Reply `Resumed{id}`
214    /// then `Conversation`; error → `QueryResult{id, {kind:"error", text}}`.
215    pub(crate) async fn resume(&mut self, id: u64, query: String) {
216        if self.streaming {
217            self.emit(SessionEventWire::QueryResult {
218                id,
219                value: serde_json::json!({ "kind": "error", "text": "cannot resume while streaming" }),
220            });
221            return;
222        }
223        // Queued saves first: resuming the CURRENT session reloads it from
224        // disk, which must hold its latest state.
225        self.persister.flush().await;
226        let session = match crate::resolve_session(&query) {
227            Ok(s) => s,
228            Err(e) => {
229                self.emit(SessionEventWire::QueryResult {
230                    id,
231                    value: serde_json::json!({ "kind": "error", "text": e.to_string() }),
232                });
233                return;
234            }
235        };
236        let old_id = self.conv.session.id.clone();
237        let new_id = session.id.clone();
238        // F10: the journal lock follows the conversation. Take the new lock
239        // FIRST, before anything changes: a session locked by another
240        // process (or another session of this daemon) is live there right
241        // now, and resuming it here would fork its history. The old lock is
242        // only released once the new one is held.
243        let new_lock = if new_id == old_id {
244            None // resuming the current session: keep its lock
245        } else {
246            match Self::try_lock_session(&new_id) {
247                Ok(lock) => Some(lock),
248                Err(e @ agent_core::session_lock::SessionLockError::Held { .. })
249                | Err(e @ agent_core::session_lock::SessionLockError::CompactedInto { .. }) => {
250                    self.emit(SessionEventWire::QueryResult {
251                        id,
252                        value: serde_json::json!({
253                            "kind": "error",
254                            "text": format!("cannot resume: {e}"),
255                        }),
256                    });
257                    return;
258                }
259                Err(e) => {
260                    // I/O trouble (e.g. a read-only sessions dir): proceed
261                    // unlocked, as create/unpark do.
262                    tracing::warn!(session = %new_id, "resume: session lock: {e}");
263                    None
264                }
265            }
266        };
267        // Committed to the swap: end keep-warm now, booking any ping in
268        // flight to the conversation being left (saved just below).
269        self.cache_keepwarm_conversation_replaced();
270        self.runtime.set_model(session.model.clone());
271        // A resumed session owns its saved choice. Preserve that explicit
272        // provenance across later model switches.
273        let clamp_notice = self
274            .runtime
275            .restore_session_reasoning(&session.thinking_level)
276            .map(|clamp| {
277                format!(
278                    "thinking → {} (clamped from {}: not supported by {})",
279                    clamp.to.as_str(),
280                    clamp.from.as_str(),
281                    self.runtime.model()
282                )
283            });
284        if let Some(ref sp) = session.system_prompt {
285            self.runtime.set_system_prompt(sp.clone());
286        }
287        self.save().await;
288        let via = if crate::chain::load_chain(&query).is_ok() {
289            Some(format!("chain '{}'", query))
290        } else if crate::find_session_by_name(&query).is_ok() {
291            Some(format!("name '{}'", query))
292        } else {
293            None
294        };
295        if new_id != old_id {
296            // Drops (releases) the old lock; `None` = proceeding unlocked.
297            self.session_lock = new_lock;
298        }
299        self.conv = Live::new(crate::engine::session::ConversationState::from_resumed(session));
300        // Crash recovery is the lock holder's alone (`recover_turn_draft`).
301        if self.config.persist && self.session_lock.is_some() {
302            crate::engine::setup::recover_turn_draft(&mut self.conv).await;
303        }
304        if clamp_notice.is_some() {
305            // Keep the session file in sync with the clamped runtime.
306            self.conv.session.thinking_level = self.runtime.thinking_level().to_string();
307        }
308        self.consecutive_auto_turns = 0;
309        self.runtime.set_session_id(Some(new_id.clone()));
310        self.journal_id.store(Arc::new(new_id.clone()));
311        self.publish_view().await;
312        self.emit(SessionEventWire::Resumed {
313            id,
314            old_id,
315            new_id,
316            via,
317            clamp_notice,
318        });
319        self.emit_conversation();
320    }
321
322    /// `runtime.subagent_registry().display_rows()` → `SubagentRows`
323    /// (called at Done/Error and from the 1 Hz arm B adds).
324    #[allow(dead_code)]
325    pub(crate) fn emit_subagent_rows(&mut self) {
326        let rows = self
327            .runtime
328            .subagent_registry()
329            .lock()
330            .unwrap_or_else(|p| p.into_inner())
331            .display_rows();
332        self.emit(SessionEventWire::SubagentRows(rows));
333    }
334
335    /// Any non-terminal subagent row (drives the 1 Hz arm).
336    #[allow(dead_code)]
337    pub(crate) fn has_live_subagents(&self) -> bool {
338        self.runtime
339            .subagent_registry()
340            .lock()
341            .unwrap_or_else(|p| p.into_inner())
342            .display_rows()
343            .iter()
344            .any(|r| matches!(r.status, crate::runtime::subagent::SubagentStatus::Running))
345    }
346}
347
348#[cfg(test)]
349mod tests {
350    //! `/resume`'s reasoning restore is the runtime's `restore_session_reasoning`
351    //! + the clamp line above; pin the two behaviours the TUI tests pinned.
352    use crate::Runtime;
353
354    #[tokio::test]
355    async fn restore_session_reasoning_keeps_explicit_provenance() {
356        let mut runtime = Runtime::new().await.unwrap();
357        runtime.set_model("openai-codex/gpt-5.6-sol".to_string());
358        let clamp = runtime.restore_session_reasoning("ultra");
359        assert!(clamp.is_none());
360        assert_eq!(
361            runtime.reasoning_level(),
362            agent_core::reasoning::ReasoningLevel::Ultra
363        );
364        assert!(runtime.is_reasoning_explicit());
365        runtime.set_model("openai-codex/gpt-5.6-terra".to_string());
366        assert_eq!(
367            runtime.reasoning_level(),
368            agent_core::reasoning::ReasoningLevel::Ultra
369        );
370    }
371
372    #[tokio::test]
373    async fn restore_session_reasoning_clamps_unsupported_level() {
374        let mut runtime = Runtime::new().await.unwrap();
375        runtime.set_model("xai-auth/grok-4.6".to_string());
376        let clamp = runtime.restore_session_reasoning("xhigh");
377        assert!(clamp.is_some());
378        assert_eq!(
379            runtime.reasoning_level(),
380            agent_core::reasoning::ReasoningLevel::High
381        );
382        assert!(runtime.is_reasoning_explicit());
383    }
384}