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 view_changed {
147            self.publish_view().await;
148            // Clients mirror the view from `SettingChanged` (and `Attached`)
149            // only: without this, `/model` in one tab left every other
150            // tab's footer, and its next request's model chip, on the old one.
151            let view = (**self.view.load()).clone();
152            self.emit(SessionEventWire::SettingChanged(SettingApplied {
153                id: super::wire::VIEW_CHANGED_SETTING_ID,
154                setting: cmd.clone(),
155                ok: true,
156                message: None,
157                view,
158                clamp: None,
159            }));
160        }
161        if let Some(CommandResult::Compact {
162            custom_instructions,
163        }) = compact_request(&cmd, &arg)
164        {
165            self.compact(custom_instructions, "manual").await;
166        }
167        self.emit(SessionEventWire::QueryResult { id, value });
168    }
169
170    /// `/close [force]`: end this session in the daemon. It is saved and
171    /// ended exactly like the in-process quit (`End{ClientQuit}`), leaves the
172    /// daemon's session list, and every attached client is told (`Ended`);
173    /// `synaps --attach --continue <id>` brings it back from its journal.
174    /// Over a socket `/quit` only detaches, which leaves the session loaded.
175    ///
176    /// Refused while closing would destroy work in flight: a running turn or
177    /// compaction, a running worker (a session's workers die with its
178    /// runtime), an armed `/auto` run. `force` closes anyway: it cancels those
179    /// workers first and notes each one in the conversation, for whoever
180    /// continues it. A parked session has none of these and closes without
181    /// being rebuilt.
182    ///
183    /// Owner-only, like every engine command. Replies on the `QueryResult`
184    /// (`output` / `error`) before the `Ended`. `Some(reason)` = end now.
185    /// The host (`host`) may add [`super::actor::CLOSE_UNATTACHED_ARG`]:
186    /// refused while any client is attached.
187    pub(crate) fn close_command(&mut self, id: u64, arg: &str, host: bool) -> Option<EndReason> {
188        let reply = |text: String, kind: &str| SessionEventWire::QueryResult {
189            id,
190            value: serde_json::json!({ "kind": kind, "text": text }),
191        };
192        let (mut force, mut unattached) = (false, false);
193        for word in arg.split_whitespace() {
194            match word {
195                "force" if !force => force = true,
196                w if host && w == super::actor::CLOSE_UNATTACHED_ARG && !unattached => unattached = true,
197                _ => {
198                    self.emit(reply("usage: /close [force]".into(), "error"));
199                    return None;
200                }
201            }
202        }
203        if unattached && !self.attached.is_empty() {
204            let n = self.attached.len();
205            self.emit(reply(format!("not closed: attached by {n} client(s)"), "error"));
206            return None;
207        }
208        let live = self.runtime.is_live();
209        let workers: Vec<(String, String)> = if live {
210            let mut running: Vec<(String, String)> = self
211                .runtime
212                .subagent_registry()
213                .lock()
214                .unwrap_or_else(|p| p.into_inner())
215                .display_rows()
216                .into_iter()
217                .filter(|r| {
218                    r.status == crate::runtime::subagent::SubagentStatus::Running
219                        && !r.cancel_requested
220                })
221                .map(|r| (r.handle_id, r.agent_name))
222                .collect();
223            running.sort();
224            running
225        } else {
226            Vec::new()
227        };
228        let mut blockers = Vec::new();
229        if self.streaming {
230            blockers.push("a turn is running".to_string());
231        }
232        if self.compact.is_some() {
233            blockers.push("a compaction is running".to_string());
234        }
235        if !workers.is_empty() {
236            let names: Vec<String> = workers.iter().map(|(id, agent)| format!("{id} {agent}")).collect();
237            blockers.push(format!(
238                "{} worker{} running ({})",
239                workers.len(),
240                if workers.len() == 1 { " is" } else { "s are" },
241                names.join(", ")
242            ));
243        }
244        if self.driver.is_some() {
245            blockers.push("an /auto run is armed".to_string());
246        }
247        if !blockers.is_empty() && !force {
248            self.emit(reply(
249                format!(
250                    "not closed: {}. Closing the session would stop {}. Wait, or use /close force.",
251                    blockers.join("; "),
252                    if blockers.len() == 1 { "it" } else { "them" },
253                ),
254                "error",
255            ));
256            return None;
257        }
258        if !workers.is_empty() {
259            let cancelled = self
260                .runtime
261                .subagent_registry()
262                .lock()
263                .unwrap_or_else(|p| p.into_inner())
264                .cancel_running(crate::runtime::subagent::WorkerCancelScope::All);
265            for (handle_id, agent) in workers.iter().filter(|(h, _)| cancelled.contains(h)) {
266                self.note_in_main_conversation(super::actor::subagent_close_cancel_event(handle_id, agent));
267            }
268        }
269        let session = (**self.journal_id.load()).clone();
270        // No history, no journal: there is nothing to continue (a parked
271        // session always has one).
272        let text = if self.conv.is_live() && self.conv.api_messages.is_empty() {
273            format!("closing session {session} (it never ran a turn, so there is nothing to resume)")
274        } else {
275            format!("closing session {session}; resume it with: synaps --attach --continue {session}")
276        };
277        self.emit(reply(text, "output"));
278        Some(EndReason::ClientQuit)
279    }
280
281    /// `(id, agent)` of every worker with no cancel requested, sorted.
282    fn uncancelled_workers(&self) -> Vec<(String, String)> {
283        let mut workers: Vec<(String, String)> = self
284            .runtime
285            .subagent_registry()
286            .lock()
287            .unwrap_or_else(|p| p.into_inner())
288            .display_rows()
289            .into_iter()
290            .filter(|r| !r.cancel_requested)
291            .map(|r| (r.handle_id, r.agent_name))
292            .collect();
293        workers.sort();
294        workers
295    }
296
297    /// `/saveas <name>` | `/saveas` (clear). Force-saves even with no
298    /// messages — persist the name change (commands.rs `saveas` arm).
299    async fn session_name(&mut self, arg: &str) -> serde_json::Value {
300        let trimmed = arg.trim();
301        // The same process-wide lock as `synaps daemon rename`: a `/saveas`
302        // here and a rename of another session can't both take one name.
303        let Some(_guard) = name_lock().await else {
304            return serde_json::json!({ "kind": "error", "text": NAME_LOCK_BUSY });
305        };
306        // This saves `conv.session` directly (even with no messages): a
307        // queued save of it, without the new name, must not land after.
308        self.persister.flush().await;
309        if trimmed.is_empty() {
310            self.conv.session.clear_name();
311            let _ = self.conv.session.save().await;
312            self.sync_name();
313            self.emit_conversation();
314            return serde_json::json!({
315                "kind": "output", "text": "session name cleared", "name": serde_json::Value::Null,
316            });
317        }
318        match self.conv.session.set_name(trimmed) {
319            Ok(()) => {
320                let _ = self.conv.session.save().await;
321                self.sync_name();
322                self.emit_conversation();
323                serde_json::json!({
324                    "kind": "output",
325                    "text": format!("session named '{}'", trimmed),
326                    "name": trimmed,
327                })
328            }
329            Err(e) => serde_json::json!({ "kind": "error", "text": e.to_string() }),
330        }
331    }
332
333    /// dispatch.rs LoadSkill (:330-360): pre-built tool_use/tool_result pair
334    /// (+ optional user text) then a turn. Does NOT reset
335    /// `consecutive_auto_turns`.
336    pub(crate) async fn submit_prepared(
337        &mut self,
338        messages: Vec<crate::SharedMessage>,
339        user_text: Option<String>,
340    ) {
341        if self.streaming {
342            self.emit(SessionEventWire::SystemNotice(
343                "cannot load a skill while streaming".into(),
344            ));
345            return;
346        }
347        self.conv.api_messages.extend(messages);
348        if let Some(text) = user_text {
349            self.conv
350                .api_messages
351                .push(Arc::new(serde_json::json!({"role": "user", "content": text})));
352        }
353        self.start_turn(TurnTrigger::PluginCommand, None).await;
354    }
355
356    /// commands.rs:123-158: tools-backed (non-interactive) plugin command on
357    /// THE runtime's tool set. Reply `{kind:"plugin_output", status, stdout,
358    /// stderr}` or `{kind:"error", text}`.
359    pub(crate) async fn plugin_command(&mut self, id: u64, plugin: String, name: String, arg: String) {
360        let value = match Self::find_plugin_command(&plugin, &name) {
361            None => serde_json::json!({
362                "kind": "error",
363                "text": format!("unknown plugin command /{plugin}:{name}"),
364            }),
365            Some(cmd) => match crate::skills::commands::execute_plugin_command_with_tools(
366                &cmd,
367                &arg,
368                self.runtime.tools_shared(),
369                // The command is this chat's: an extension tool it runs is
370                // told the chat and its folder and given its memory
371                // binding, as in a turn; a shell command gets the
372                // session's environment.
373                self.runtime.command_chat(),
374            )
375            .await
376            {
377                Ok(output) => serde_json::json!({
378                    "kind": "plugin_output",
379                    "status": output.status,
380                    "stdout": output.stdout,
381                    "stderr": output.stderr,
382                }),
383                Err(e) => serde_json::json!({ "kind": "error", "text": e.to_string() }),
384            },
385        };
386        self.emit(SessionEventWire::QueryResult { id, value });
387    }
388
389    fn find_plugin_command(
390        plugin: &str,
391        name: &str,
392    ) -> Option<Arc<crate::skills::registry::RegisteredPluginCommand>> {
393        let host = crate::host::EngineHost::current()?;
394        match host.command_registry().resolve(&format!("{plugin}:{name}")) {
395            crate::skills::registry::Resolution::PluginCommand(c) => Some(c),
396            _ => None,
397        }
398    }
399
400    /// commands.rs:647-693 `/resume`: save current, load `query`, restore
401    /// model/reasoning/system prompt, swap conversation. Reply `Resumed{id}`
402    /// then `Conversation`; error → `QueryResult{id, {kind:"error", text}}`.
403    pub(crate) async fn resume(&mut self, id: u64, query: String) {
404        if self.streaming {
405            self.emit(SessionEventWire::QueryResult {
406                id,
407                value: serde_json::json!({ "kind": "error", "text": "cannot resume while streaming" }),
408            });
409            return;
410        }
411        // Queued saves first: resuming the CURRENT session reloads it from
412        // disk, which must hold its latest state.
413        self.persister.flush().await;
414        let session = match crate::resolve_session(&query) {
415            Ok(s) => s,
416            Err(e) => {
417                self.emit(SessionEventWire::QueryResult {
418                    id,
419                    value: serde_json::json!({ "kind": "error", "text": e.to_string() }),
420                });
421                return;
422            }
423        };
424        let old_id = self.conv.session.id.clone();
425        let new_id = session.id.clone();
426        // F10: the journal lock follows the conversation. Take the new lock
427        // FIRST, before anything changes: a session locked by another
428        // process (or another session of this daemon) is live there right
429        // now, and resuming it here would fork its history. The old lock is
430        // only released once the new one is held.
431        let new_lock = if new_id == old_id {
432            None // resuming the current session: keep its lock
433        } else {
434            match Self::try_lock_session(&new_id) {
435                Ok(lock) => Some(lock),
436                Err(e @ agent_core::session_lock::SessionLockError::Held { .. })
437                | Err(e @ agent_core::session_lock::SessionLockError::CompactedInto { .. }) => {
438                    self.emit(SessionEventWire::QueryResult {
439                        id,
440                        value: serde_json::json!({
441                            "kind": "error",
442                            "text": format!("cannot resume: {e}"),
443                        }),
444                    });
445                    return;
446                }
447                Err(e) => {
448                    // I/O trouble (e.g. a read-only sessions dir): proceed
449                    // unlocked, as create/unpark do.
450                    tracing::warn!(session = %new_id, "resume: session lock: {e}");
451                    None
452                }
453            }
454        };
455        // Committed to the swap: end keep-warm now, booking any ping in
456        // flight to the conversation being left (saved just below).
457        self.cache_keepwarm_conversation_replaced();
458        self.runtime.set_model(session.model.clone());
459        // A resumed session owns its saved choice. Preserve that explicit
460        // provenance across later model switches.
461        let clamp_notice = self
462            .runtime
463            .restore_session_reasoning(&session.thinking_level)
464            .map(|clamp| {
465                format!(
466                    "thinking → {} (clamped from {}: not supported by {})",
467                    clamp.to.as_str(),
468                    clamp.from.as_str(),
469                    self.runtime.model()
470                )
471            });
472        if let Some(ref sp) = session.system_prompt {
473            self.runtime.set_system_prompt(sp.clone());
474        }
475        self.save().await;
476        let via = if crate::chain::load_chain(&query).is_ok() {
477            Some(format!("chain '{}'", query))
478        } else if crate::find_session_by_name(&query).is_ok() {
479            Some(format!("name '{}'", query))
480        } else {
481            None
482        };
483        if new_id != old_id {
484            // Drops (releases) the old lock; `None` = proceeding unlocked.
485            self.session_lock = new_lock;
486        }
487        self.conv = Live::new(crate::engine::session::ConversationState::from_resumed(session));
488        // Crash recovery is the lock holder's alone (`recover_turn_draft`).
489        if self.config.persist && self.session_lock.is_some() {
490            crate::engine::setup::recover_turn_draft(&mut self.conv).await;
491        }
492        if clamp_notice.is_some() {
493            // Keep the session file in sync with the clamped runtime.
494            self.conv.session.thinking_level = self.runtime.thinking_level().to_string();
495        }
496        self.consecutive_auto_turns = 0;
497        self.runtime.begin_conversation(Some(new_id.clone()));
498        self.set_journal_id(new_id.clone());
499        self.publish_view().await;
500        self.emit(SessionEventWire::Resumed {
501            id,
502            old_id,
503            new_id,
504            via,
505            clamp_notice,
506        });
507        self.emit_conversation();
508    }
509
510    /// `runtime.subagent_registry().display_rows()` → `SubagentRows`
511    /// (called at Done/Error and from the 1 Hz arm B adds).
512    #[allow(dead_code)]
513    pub(crate) fn emit_subagent_rows(&mut self) {
514        let rows = self
515            .runtime
516            .subagent_registry()
517            .lock()
518            .unwrap_or_else(|p| p.into_inner())
519            .display_rows();
520        self.emit(SessionEventWire::SubagentRows(rows));
521    }
522
523    /// Any non-terminal subagent row (drives the 1 Hz arm).
524    #[allow(dead_code)]
525    pub(crate) fn has_live_subagents(&self) -> bool {
526        self.runtime
527            .subagent_registry()
528            .lock()
529            .unwrap_or_else(|p| p.into_inner())
530            .display_rows()
531            .iter()
532            .any(|r| matches!(r.status, crate::runtime::subagent::SubagentStatus::Running))
533    }
534}
535
536/// Of the workers that had no cancel requested before a `/workers cancel`,
537/// those that have one now. A worker that left the registry meanwhile
538/// (reaped at a turn end that raced the command) was not cancelled by it.
539fn cancelled_by_command(
540    before: Vec<(String, String)>,
541    after: &[crate::runtime::subagent::SubagentDisplayRow],
542) -> Vec<(String, String)> {
543    before
544        .into_iter()
545        .filter(|(id, _)| after.iter().any(|r| &r.handle_id == id && r.cancel_requested))
546        .collect()
547}
548
549#[cfg(test)]
550mod tests {
551    //! `/resume`'s reasoning restore is the runtime's `restore_session_reasoning`
552    //! + the clamp line above; pin the two behaviours the TUI tests pinned.
553    use crate::Runtime;
554
555    fn row(id: &str, cancel_requested: bool) -> crate::runtime::subagent::SubagentDisplayRow {
556        serde_json::from_value(serde_json::json!({
557            "subagent_id": 0, "agent_name": "a", "status": "running",
558            "cancel_requested": cancel_requested, "elapsed_secs": 0.0,
559            "finished_elapsed": null, "handle_id": id,
560        }))
561        .unwrap()
562    }
563
564    /// `/workers cancel` tells the main agent only about workers the command
565    /// flagged: one reaped from the registry while it ran (it completed; its
566    /// wake is the truth) is not "cancelled by the user".
567    #[test]
568    fn the_cancel_note_names_only_workers_the_command_flagged() {
569        let before: Vec<(String, String)> = ["sa_1", "sa_2", "sa_3"]
570            .iter()
571            .map(|id| (id.to_string(), "a".to_string()))
572            .collect();
573        // sa_1 flagged; sa_2 reaped meanwhile; sa_3 untouched.
574        let after = [row("sa_1", true), row("sa_3", false)];
575        let named: Vec<String> =
576            super::cancelled_by_command(before, &after).into_iter().map(|(id, _)| id).collect();
577        assert_eq!(named, ["sa_1"]);
578    }
579
580    #[tokio::test]
581    async fn restore_session_reasoning_keeps_explicit_provenance() {
582        let mut runtime = Runtime::new().await.unwrap();
583        runtime.set_model("openai-codex/gpt-5.6-sol".to_string());
584        let clamp = runtime.restore_session_reasoning("ultra");
585        assert!(clamp.is_none());
586        assert_eq!(
587            runtime.reasoning_level(),
588            agent_core::reasoning::ReasoningLevel::Ultra
589        );
590        assert!(runtime.is_reasoning_explicit());
591        runtime.set_model("openai-codex/gpt-5.6-terra".to_string());
592        assert_eq!(
593            runtime.reasoning_level(),
594            agent_core::reasoning::ReasoningLevel::Ultra
595        );
596    }
597
598    #[tokio::test]
599    async fn restore_session_reasoning_clamps_unsupported_level() {
600        let mut runtime = Runtime::new().await.unwrap();
601        runtime.set_model("xai-auth/grok-4.6".to_string());
602        let clamp = runtime.restore_session_reasoning("xhigh");
603        assert!(clamp.is_some());
604        assert_eq!(
605            runtime.reasoning_level(),
606            agent_core::reasoning::ReasoningLevel::High
607        );
608        assert!(runtime.is_reasoning_explicit());
609    }
610}
611
612#[cfg(test)]
613mod auth_command_tests {
614    use super::auth_command_reply;
615
616    #[tokio::test]
617    async fn auth_command_usage_and_token_free_error() {
618        let rt = crate::Runtime::new_headless(); // api_key "test-token"
619        let v = auth_command_reply("bogus", &rt).await;
620        assert_eq!(v["kind"], "error");
621        assert!(v["text"].as_str().unwrap().contains("usage: /auth reload"));
622        let v = auth_command_reply("  reload ", &rt).await;
623        let t = v["text"].as_str().unwrap();
624        assert_eq!(v["kind"], "error");
625        assert!(t.contains("nothing to reload"), "{t}");
626        assert!(!t.contains("test-token"), "{t}");
627    }
628
629    #[test]
630    fn auth_is_a_builtin_command() {
631        assert!(crate::skills::BUILTIN_COMMANDS.contains(&"auth"));
632    }
633}
634
635// ── `synaps daemon rename` (host-only engine command `rename`) ──────────────
636
637/// Held from the uniqueness check through the save and the published name,
638/// by `synaps daemon rename` AND `/saveas`, so two sessions of this process
639/// can never take the same name at once (the actors are separate tasks).
640/// Only this process: a journal lock is per session, so another process
641/// (an in-process TUI's `/saveas`, the session picker) can still race.
642static NAME_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
643
644/// Longest a session waits for [`NAME_LOCK`] (another session's rename is
645/// saving). The wait blocks this actor's loop, so it is bounded: on expiry
646/// the rename answers `busy` instead.
647pub const NAME_LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(3);
648
649async fn name_lock() -> Option<tokio::sync::MutexGuard<'static, ()>> {
650    tokio::time::timeout(NAME_LOCK_WAIT, NAME_LOCK.lock()).await.ok()
651}
652
653const NAME_LOCK_BUSY: &str = "another session is saving a rename; retry in a moment";
654
655/// Test seam: hold the process-wide name lock (as a slow rename would).
656#[doc(hidden)]
657pub async fn hold_name_lock_for_tests() -> tokio::sync::MutexGuard<'static, ()> {
658    NAME_LOCK.lock().await
659}
660
661/// The `rename` command's argument: JSON, written only by the daemon
662/// (`daemon/rename.rs`). Plain text (an in-process `/rename foo`) is refused.
663#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
664pub(crate) struct RenameArgs {
665    pub name: Option<String>,
666    /// Compare-and-set: `Some(None)` = only if unnamed, `Some(Some(x))` =
667    /// only if named `x`, `None` = no precondition.
668    #[serde(
669        default,
670        deserialize_with = "crate::session::wire::present_option",
671        skip_serializing_if = "Option::is_none"
672    )]
673    pub expect: Option<Option<String>>,
674}
675
676fn rename_error(code: &str, text: impl Into<String>) -> serde_json::Value {
677    serde_json::json!({ "kind": "error", "code": code, "text": text.into() })
678}
679
680fn rename_ok(name: &Option<String>, in_memory_only: bool) -> serde_json::Value {
681    let text = match (name, in_memory_only) {
682        (Some(n), false) => format!("session named '{n}'"),
683        (None, false) => "session name cleared".to_string(),
684        (Some(n), true) => format!("session named '{n}' (in memory only: this session is not saved)"),
685        (None, true) => "session name cleared (in memory only: this session is not saved)".to_string(),
686    };
687    serde_json::json!({ "kind": "output", "text": text, "name": name, "in_memory_only": in_memory_only })
688}
689
690fn name_error_reply(e: agent_core::session::NameError) -> serde_json::Value {
691    let mut v = rename_error(e.code(), e.to_string());
692    if let agent_core::session::NameError::Taken { by, .. } = &e {
693        v["taken_by"] = serde_json::json!(by);
694    }
695    if matches!(e, agent_core::session::NameError::Io(_)) {
696        v["code"] = serde_json::json!("failed");
697    }
698    v
699}
700
701impl SessionActor {
702    /// `synaps daemon rename`: `/saveas`'s rules (valid, unique across the
703    /// sessions dir, no chain clash) under [`NAME_LOCK`], with the
704    /// compare-and-set `expect`. A live session saves durably and keeps its
705    /// file mtimes (a rename is not activity: `--continue` and `/sessions`
706    /// go by mtime); a failed save rolls the name back. A parked session is
707    /// renamed on disk under its journal lock and is not woken.
708    pub(crate) async fn host_rename(&mut self, arg: &str) -> serde_json::Value {
709        let Ok(args) = serde_json::from_str::<RenameArgs>(arg) else {
710            return rename_error(
711                "failed",
712                "rename is the daemon's control command (`synaps daemon rename`); use /saveas",
713            );
714        };
715        let want = args.name.as_deref().map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
716        if let Some(n) = &want {
717            if let Err(e) = agent_core::session::validate_name(n) {
718                return rename_error("invalid", e);
719            }
720        }
721        let Some(_guard) = name_lock().await else {
722            return rename_error("busy", NAME_LOCK_BUSY);
723        };
724        let current: Option<String> =
725            if self.is_parked() { (**self.name.load()).clone() } else { self.conv.session.name.clone() };
726        if let Some(expected) = &args.expect {
727            if *expected != current {
728                let text = match (&current, expected) {
729                    (Some(c), None) => format!("session is named '{c}'; rename never replaces a name without --force"),
730                    (Some(c), Some(e)) => format!("session is named '{c}', not '{e}'; not renamed"),
731                    (None, _) => "session has no name any more; not renamed".to_string(),
732                };
733                let mut v = rename_error("named", text);
734                v["current_name"] = serde_json::json!(current);
735                return v;
736            }
737        }
738        if self.is_parked() {
739            // Checked again against the file, under its journal lock.
740            self.rename_parked(want, args.expect).await
741        } else if want == current {
742            rename_ok(&want, !self.config.persist)
743        } else {
744            self.rename_live(want).await
745        }
746    }
747
748    async fn rename_live(&mut self, want: Option<String>) -> serde_json::Value {
749        if let Some(n) = &want {
750            // One sessions-dir scan; the name is then assigned directly.
751            if let Err(e) = self.conv.session.check_name(n) {
752                return name_error_reply(e);
753            }
754        }
755        if !self.config.persist {
756            self.conv.session.name = want.clone();
757            self.sync_name();
758            self.emit_conversation();
759            return rename_ok(&want, true);
760        }
761        if self.session_lock.is_none() {
762            return rename_error(
763                "locked",
764                "this daemon does not hold the session's journal lock (it may be open in another process); not renamed",
765            );
766        }
767        // A queued save of the old name must not land after ours.
768        self.persister.flush().await;
769        let dir = agent_core::session_lock::sessions_dir();
770        let id = self.conv.session.id.clone();
771        let mtimes = agent_core::session_catalog::SessionMtimes::capture(&dir, &id);
772        let old = std::mem::replace(&mut self.conv.session.name, want.clone());
773        if let Err(e) = self.conv.session.save_durable().await {
774            self.conv.session.name = old;
775            return rename_error("failed", format!("rename not saved (rolled back): {e}"));
776        }
777        mtimes.restore(&dir, &id);
778        self.sync_name();
779        self.emit_conversation();
780        rename_ok(&want, false)
781    }
782
783    /// A parked session has released its journal lock, so another process
784    /// may have renamed its file: `expect` is checked against the FILE, under
785    /// the journal lock, and a mismatch republishes the name found there.
786    async fn rename_parked(&mut self, want: Option<String>, expect: Option<Option<String>>) -> serde_json::Value {
787        use agent_core::session_catalog::RenameError;
788        let dir = agent_core::session_lock::sessions_dir();
789        let id = (**self.journal_id.load()).clone();
790        let name = want.clone();
791        let res = tokio::task::spawn_blocking(move || {
792            let expect = expect.as_ref().map(|e| e.as_deref());
793            agent_core::session_catalog::rename_past_session_if(&dir, &id, name.as_deref(), expect)
794        })
795        .await;
796        match res {
797            Ok(Ok(())) => {}
798            Ok(Err(e)) => {
799                let code = match &e {
800                    RenameError::InvalidName(_) => "invalid",
801                    RenameError::NameTaken { .. } => "taken",
802                    RenameError::ChainConflict => "chain_conflict",
803                    RenameError::Live { .. } => "locked",
804                    RenameError::NameMismatch { .. } => "named",
805                    _ => "failed",
806                };
807                let mut v = rename_error(code, e.to_string());
808                if let RenameError::NameTaken { other_id } = &e {
809                    v["taken_by"] = serde_json::json!(other_id);
810                }
811                if let RenameError::NameMismatch { current } = e {
812                    v["current_name"] = serde_json::json!(current);
813                    self.mirror_name(current);
814                }
815                return v;
816            }
817            Err(e) => return rename_error("failed", format!("rename failed: {e}")),
818        }
819        self.mirror_name(want.clone());
820        rename_ok(&want, false)
821    }
822}