1use std::sync::Arc;
8
9use super::actor::{Live, SessionActor};
10use super::types::*;
11
12fn 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
20pub 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
83pub 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 pub(crate) async fn engine_command(&mut self, id: u64, cmd: String, arg: String) {
106 use crate::engine::commands::CommandResult;
107 if cmd == "saveas" {
110 let value = self.session_name(&arg).await;
111 self.emit(SessionEventWire::QueryResult { id, value });
112 return;
113 }
114 if cmd == "keepwarm" {
117 let value = self.cache_keepwarm_command(&arg).await;
118 self.emit(SessionEventWire::QueryResult { id, value });
119 return;
120 }
121 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 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 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 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 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 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 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 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 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 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 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 async fn session_name(&mut self, arg: &str) -> serde_json::Value {
395 let trimmed = arg.trim();
396 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 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 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 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 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 let new_lock = if new_id == old_id {
517 None } 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 tracing::warn!(session = %new_id, "resume: session lock: {e}");
536 None
537 }
538 }
539 };
540 self.cache_keepwarm_conversation_replaced();
543 self.runtime.set_model(session.model.clone());
544 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 self.session_lock = new_lock;
571 }
572 self.conv = Live::new(crate::engine::session::ConversationState::from_resumed(session));
573 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 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 #[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 #[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
621fn 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 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 #[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 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(); 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}