1use crate::skills::keybinds::KeybindRegistry;
7use crate::skills::registry::CommandRegistry;
8use crate::{latest_session, resolve_session, EngineHost, HostOpts, Result, Runtime, Session};
9use std::sync::Arc;
10use tokio::sync::RwLock;
11
12pub struct EngineOpts {
14 pub continue_session: Option<Option<String>>,
15 pub system: Option<String>,
16 pub prompt_manifest: Option<std::path::PathBuf>,
17 pub profile: Option<String>,
20 pub no_extensions: bool,
21}
22
23pub struct BackgroundTasks {
25 watcher_shutdown: Arc<std::sync::atomic::AtomicBool>,
26 watcher_task: tokio::task::JoinHandle<()>,
27 inbox_gate: Arc<std::sync::atomic::AtomicBool>,
31 endpoint: crate::events::registry::SessionEndpoint,
32 session_id: String,
33 hook_bus: Arc<crate::extensions::hooks::HookBus>,
36 #[allow(dead_code)]
42 log_guard: Option<tracing_appender::non_blocking::WorkerGuard>,
43}
44
45impl BackgroundTasks {
46 pub(crate) fn update_session_name(
47 &self,
48 name: Option<&str>,
49 ) -> std::result::Result<(), String> {
50 self.endpoint.update_name(name)
51 }
52
53 pub(crate) fn set_inbox_accepting(&self, accepting: bool) {
55 self.inbox_gate
56 .store(accepting, std::sync::atomic::Ordering::Release);
57 }
58
59 pub fn shutdown(&self) {
61 self.watcher_shutdown
62 .store(true, std::sync::atomic::Ordering::Release);
63 self.endpoint.shutdown();
64 if let Ok(handle) = tokio::runtime::Handle::try_current() {
66 let hook_bus = Arc::clone(&self.hook_bus);
67 let session_id = self.session_id.clone();
68 handle.spawn(async move {
69 hook_bus.clear_session_injection(&session_id).await;
70 });
71 }
72 }
73}
74
75impl Drop for BackgroundTasks {
76 fn drop(&mut self) {
77 self.watcher_shutdown
78 .store(true, std::sync::atomic::Ordering::Relaxed);
79 self.watcher_task.abort();
80 self.endpoint.shutdown();
81 }
82}
83
84pub struct EngineBoot {
86 pub runtime: Runtime,
87 pub config: crate::SynapsConfig,
88 pub no_extensions: bool,
91 pub session: Session,
92 pub api_messages: Vec<crate::SharedMessage>,
93 pub total_input_tokens: u64,
94 pub total_output_tokens: u64,
95 pub session_cost: f64,
96 pub continued: bool,
97 pub continue_info: Option<ContinueInfo>,
98 pub registry: Arc<CommandRegistry>,
99 pub keybind_registry: Arc<std::sync::RwLock<KeybindRegistry>>,
104 pub mcp_server_count: usize,
105 pub system_prompt_path: std::path::PathBuf,
106 pub ext_manager: Arc<RwLock<crate::extensions::manager::ExtensionManager>>,
107 pub background: BackgroundTasks,
109}
110
111pub struct ContinueInfo {
113 pub session_id: String,
114 pub resolved_via: Option<String>, pub query: String,
116 pub compaction_notice: Option<String>,
118}
119
120pub async fn boot(opts: EngineOpts) -> Result<EngineBoot> {
123 let host = EngineHost::boot_and_install(HostOpts {
129 profile: opts.profile.clone(),
130 no_extensions: opts.no_extensions,
131 mcp_discovery: crate::McpDiscovery::Inline,
132 })
133 .await?;
134 let mut runtime = host.foreground_runtime().await?;
137 let config: crate::SynapsConfig = (**host.config()).clone();
138
139 let sb = resolve_session_and_prompt(
140 &mut runtime,
141 &opts.continue_session,
142 opts.system.as_deref(),
143 opts.prompt_manifest.as_deref(),
144 )?;
145
146 let registry = Arc::clone(host.command_registry());
149 let keybind_registry = Arc::clone(host.keybind_registry());
150 let mcp_server_count = host.mcp_server_count();
151
152 let system_prompt_path = crate::config::resolve_read_path("system.md");
153
154 let background = spawn_session_background(&runtime, &sb.session)?;
158
159 finish_session_setup(&mut runtime, &config, &sb.session, None, IndexRecord::Start);
160
161 let ext_manager = Arc::clone(host.ext_manager());
163
164 if mcp_server_count > 0 {
165 tracing::info!(
166 "{} MCP tools available from configured servers",
167 mcp_server_count
168 );
169 }
170
171 Ok(EngineBoot {
172 runtime,
173 config,
174 no_extensions: opts.no_extensions,
175 session: sb.session,
176 api_messages: sb.api_messages,
177 total_input_tokens: sb.total_input_tokens,
178 total_output_tokens: sb.total_output_tokens,
179 session_cost: sb.session_cost,
180 continued: sb.continued,
181 continue_info: sb.continue_info,
182 registry,
183 keybind_registry,
184 mcp_server_count,
185 system_prompt_path,
186 ext_manager,
187 background,
188 })
189}
190
191pub(crate) fn resolve_session_and_prompt(
194 runtime: &mut Runtime,
195 continue_session: &Option<Option<String>>,
196 system: Option<&str>,
197 prompt_manifest: Option<&std::path::Path>,
198) -> Result<SessionBootResult> {
199 let sb = resolve_or_create_session(runtime, continue_session)?;
202 runtime.set_session_id(Some(sb.session.id.clone()));
203 runtime.reset_context_continuation(&sb.session.id, &sb.api_messages);
206
207 let legacy_prompt = crate::config::resolve_system_prompt(system);
209 if let Some(path) = prompt_manifest {
210 let raw = std::fs::read_to_string(path)
211 .map_err(|_| crate::RuntimeError::Config("prompt manifest is unavailable".into()))?;
212 let manifest = agent_core::prompt::PromptManifest::parse(&raw)
213 .map_err(|e| crate::RuntimeError::Config(format!("invalid prompt manifest: {e}")))?;
214 let registry = manifest
215 .registry(path.parent())
216 .map_err(|e| crate::RuntimeError::Config(format!("invalid prompt manifest: {e}")))?;
217 let model = crate::orchestration::canonical_foreground_identity(runtime.model())
218 .map_err(|e| crate::RuntimeError::Config(format!("invalid foreground model: {e}")))?;
219 let context = agent_core::prompt::SelectionContext::new(model.clone(), None)
220 .map_err(|e| crate::RuntimeError::Config(e.to_string()))?;
221 let catalog = crate::orchestration::OrchestrationRuntime::trusted_catalog(
222 &model,
223 manifest.delegation_catalog_candidates(),
224 )
225 .map_err(|error| crate::RuntimeError::Config(error.into()))?;
226 let delegation_policy = manifest
227 .delegation_policy(model.clone(), &catalog)
228 .map_err(|e| crate::RuntimeError::Config(format!("invalid prompt manifest: {e}")))?;
229 let delegation_policy_digest = delegation_policy.as_ref().map(|policy| policy.digest());
230 if let Some(policy) = delegation_policy {
231 runtime.install_orchestration(Arc::new(
234 crate::orchestration::OrchestrationRuntime::new(policy)
235 .with_max_depth(runtime.subagent_limits().max_depth)
236 .map_err(|error| crate::RuntimeError::Config(error.into()))?,
237 ));
238 } else {
239 runtime.install_orchestration(Arc::new(
240 crate::orchestration::OrchestrationRuntime::from_limits(
241 model.clone(),
242 runtime.subagent_limits(),
243 )
244 .map_err(|error| crate::RuntimeError::Config(error.into()))?,
245 ));
246 }
247 let user = system
248 .map(|_| {
249 agent_core::prompt::resolved_system_prompt_as_user_module(legacy_prompt.clone())
250 })
251 .transpose()
252 .map_err(|e| crate::RuntimeError::Config(e.to_string()))?;
253 let stack =
254 agent_core::prompt::compile_prompt_stack(&manifest, ®istry, &context, user.clone())
255 .map_err(|e| {
256 crate::RuntimeError::Config(format!("invalid prompt manifest: {e}"))
257 })?;
258 runtime
259 .apply_prompt_stack(stack)
260 .map_err(|e| crate::RuntimeError::Config(format!("invalid prompt manifest: {e}")))?;
261 runtime.retain_prompt_reload_source(
262 path.to_path_buf(),
263 context,
264 user,
265 delegation_policy_digest,
266 );
267 } else {
268 let foreground = crate::orchestration::canonical_foreground_identity(runtime.model())
269 .map_err(|e| crate::RuntimeError::Config(format!("invalid foreground model: {e}")))?;
270 runtime.install_orchestration(Arc::new(
271 crate::orchestration::OrchestrationRuntime::from_limits(
272 foreground,
273 runtime.subagent_limits(),
274 )
275 .map_err(|error| crate::RuntimeError::Config(error.into()))?,
276 ));
277 runtime.set_system_prompt(legacy_prompt);
278 }
279
280 Ok(sb)
281}
282
283pub(crate) fn spawn_session_background(
287 runtime: &Runtime,
288 session: &Session,
289) -> Result<BackgroundTasks> {
290 spawn_session_background_gated(
291 runtime,
292 session,
293 Arc::new(std::sync::atomic::AtomicBool::new(true)),
294 )
295}
296
297pub(crate) fn spawn_session_background_gated(
301 runtime: &Runtime,
302 session: &Session,
303 inbox_gate: Arc<std::sync::atomic::AtomicBool>,
304) -> Result<BackgroundTasks> {
305 let watcher_shutdown = Arc::new(std::sync::atomic::AtomicBool::new(false));
307 let watcher_task = {
308 let inbox_dir = crate::config::base_dir().join("inbox");
309 let event_queue = runtime.event_queue().clone();
310 let shutdown = watcher_shutdown.clone();
311 let gate = Arc::clone(&inbox_gate);
312 tokio::spawn(async move {
313 crate::events::watch_inbox_gated(inbox_dir, event_queue, shutdown, gate).await;
314 })
315 };
316
317 let abort_tasks = |ws: &Arc<std::sync::atomic::AtomicBool>,
319 wt: &tokio::task::JoinHandle<()>| {
320 ws.store(true, std::sync::atomic::Ordering::Relaxed);
321 wt.abort();
322 };
323
324 let endpoint = match crate::events::registry::SessionEndpoint::start(
325 &session.id,
326 session.name.clone(),
327 runtime.event_queue().clone(),
328 ) {
329 Ok(endpoint) => endpoint,
330 Err(e) => {
331 abort_tasks(&watcher_shutdown, &watcher_task);
332 return Err(crate::core::error::RuntimeError::Session(e));
333 }
334 };
335
336 Ok(BackgroundTasks {
337 watcher_shutdown,
338 watcher_task,
339 inbox_gate,
340 endpoint,
341 session_id: session.id.clone(),
342 hook_bus: Arc::clone(runtime.hook_bus()),
343 log_guard: None,
345 })
346}
347
348#[derive(Clone, Copy, Debug, PartialEq, Eq)]
351pub(crate) enum IndexRecord {
352 Start,
353 Skip,
354}
355
356pub(crate) fn finish_session_setup(
359 runtime: &mut Runtime,
360 config: &crate::SynapsConfig,
361 session: &Session,
362 cwd: Option<std::path::PathBuf>,
363 index_record: IndexRecord,
364) {
365 runtime.set_turn_budget(crate::runtime::budget::TurnBudget::from_config(
368 crate::runtime::budget::TurnRole::Foreground,
369 &config.turn_budgets,
370 ));
371
372 if index_record == IndexRecord::Start {
381 let mut index_record =
382 crate::core::session_index::SessionIndexRecord::start(&session.id);
383 index_record.model = Some(session.model.clone());
384 index_record.profile = crate::core::config::get_profile();
385 index_record.cwd = cwd.or_else(|| std::env::current_dir().ok());
386 if let Err(err) = crate::core::session_index::append_record(&index_record) {
387 tracing::warn!("failed to append session start index record: {}", err);
388 }
389 }
390
391}
392
393pub(crate) struct SessionBootResult {
395 pub(crate) session: Session,
396 pub(crate) api_messages: Vec<crate::SharedMessage>,
397 pub(crate) total_input_tokens: u64,
398 pub(crate) total_output_tokens: u64,
399 pub(crate) session_cost: f64,
400 pub(crate) continued: bool,
401 pub(crate) continue_info: Option<ContinueInfo>,
402}
403
404pub fn lock_session(
414 id: &str,
415 continued: bool,
416 kind: &str,
417) -> Result<Option<agent_core::session_lock::SessionLock>> {
418 let dir = agent_core::session_lock::sessions_dir();
419 let holder = agent_core::session_lock::LockHolder {
420 pid: std::process::id(),
421 kind: kind.to_string(),
422 };
423 match agent_core::session_lock::SessionLock::try_acquire(&dir, id, holder) {
424 Ok(lock) => Ok(Some(lock)),
425 Err(
426 e @ (agent_core::session_lock::SessionLockError::Held { .. }
427 | agent_core::session_lock::SessionLockError::CompactedInto { .. }),
428 ) if continued => Err(crate::RuntimeError::Session(e.to_string())),
429 Err(e) => {
430 tracing::warn!(session = %id, "session lock: {e}");
431 Ok(None)
432 }
433 }
434}
435
436pub async fn recover_turn_draft(conv: &mut crate::engine::session::ConversationState) {
450 let dir = agent_core::session_lock::sessions_dir();
451 let id = conv.session.id.clone();
452 let read = {
453 let (dir, id) = (dir.clone(), id.clone());
454 tokio::task::spawn_blocking(move || {
455 agent_core::core::session_draft::read_turn_draft(&dir, &id)
456 })
457 .await
458 .map_err(std::io::Error::other)
459 .and_then(|r| r)
460 };
461 let recovered = match read {
462 Ok(None) => return,
463 Ok(Some(draft)) => {
464 crate::engine::interrupt::recover_crashed_turn(&mut conv.api_messages, &draft)
465 }
466 Err(e) => {
467 tracing::warn!(session = %id, "unreadable turn draft, removing it: {e}");
470 false
471 }
472 };
473 if recovered {
474 tracing::warn!(
475 session = %id,
476 "session was interrupted mid-turn by an unexpected stop; recovered"
477 );
478 if !conv.save().await {
479 tracing::warn!(
480 session = %id,
481 "could not save the recovered history; keeping the turn draft"
482 );
483 return;
484 }
485 } else {
486 tracing::info!(session = %id, "removing a stale turn draft");
489 }
490 let removed = tokio::task::spawn_blocking(move || {
491 agent_core::core::session_draft::remove_turn_draft(&dir, &id)
492 })
493 .await;
494 if let Ok(Err(e)) | Err(e) = removed.map_err(std::io::Error::other) {
495 tracing::warn!(session = %conv.session.id, "failed to remove turn draft: {e}");
496 }
497}
498
499fn resolve_or_create_session(
500 runtime: &mut Runtime,
501 continue_session: &Option<Option<String>>,
502) -> Result<SessionBootResult> {
503 match continue_session {
504 Some(ref maybe_id) => {
505 let raw_session = match maybe_id {
506 Some(ref id) => resolve_session(id).map_err(|e| {
507 crate::error::RuntimeError::Tool(format!(
508 "Failed to load session '{}': {}",
509 id, e
510 ))
511 })?,
512 None => latest_session().map_err(|e| {
513 crate::error::RuntimeError::Tool(format!("No sessions to continue: {}", e))
514 })?,
515 };
516 let resolved = agent_core::session::follow_compaction_chain(raw_session)
519 .map_err(|e| crate::error::RuntimeError::Session(e.to_string()))?;
520 let compaction_notice = resolved.compaction_notice;
521 let mut session = resolved.session;
522 runtime.set_model(session.model.clone());
523 if let Some(clamp) = runtime.restore_session_reasoning(&session.thinking_level) {
528 tracing::warn!(
529 from = %clamp.from,
530 to = %clamp.to,
531 model = %session.model,
532 "saved session thinking level not supported by model; clamped"
533 );
534 session.thinking_level = runtime.thinking_level().to_string();
535 }
536 if let Some(ref sp) = session.system_prompt {
537 runtime.set_system_prompt(sp.clone());
538 }
539
540 let continue_info = {
541 let (resolved_via, query) = match maybe_id {
542 Some(ref q) => {
543 let via = if *q != session.id {
544 if crate::chain::load_chain(q).is_ok() {
545 Some("chain".to_string())
546 } else if agent_core::session::find_session_by_name(q).is_ok() {
547 Some("name".to_string())
548 } else if compaction_notice.is_some() {
549 Some("compacted".to_string())
550 } else {
551 None
552 }
553 } else {
554 None
555 };
556 (via, q.clone())
557 }
558 None => {
559 let via = compaction_notice.as_ref().map(|_| "compacted".to_string());
560 (via, session.id.clone())
561 }
562 };
563 if maybe_id.is_some() || compaction_notice.is_some() {
566 Some(ContinueInfo {
567 session_id: session.id.clone(),
568 resolved_via,
569 query,
570 compaction_notice: compaction_notice.clone(),
571 })
572 } else {
573 None
574 }
575 };
576
577 if crate::engine::interrupt::migrate_legacy_abort_context(
585 &mut session.api_messages,
586 &mut session.abort_context,
587 ) {
588 tracing::info!(
589 session = %session.id,
590 "migrated a legacy abort-context recap to an interruption marker"
591 );
592 }
593 Ok(SessionBootResult {
598 api_messages: session.api_messages.clone(),
599 total_input_tokens: session.total_input_tokens,
600 total_output_tokens: session.total_output_tokens,
601 session_cost: session.session_cost,
602 continued: true,
603 continue_info,
604 session,
605 })
606 }
607 None => {
608 let session = Session::new(
609 runtime.model(),
610 runtime.thinking_level(),
611 runtime.system_prompt(),
612 );
613 Ok(SessionBootResult {
614 session,
615 api_messages: Vec::new(),
616 total_input_tokens: 0,
617 total_output_tokens: 0,
618 session_cost: 0.0,
619 continued: false,
620 continue_info: None,
621 })
622 }
623 }
624}
625
626#[cfg(test)]
627mod tests {
628 use super::*;
629 use agent_core::reasoning::ReasoningLevel;
630
631 #[cfg(unix)]
635 #[tokio::test]
636 #[serial_test::serial(synaps_base_dir)]
637 async fn session_registration_is_removed_on_drop_without_shutdown() {
638 let _base = crate::test_env::BaseDirGuard::new();
639 let runtime = Runtime::new_headless();
640 let dir = crate::events::registry::registry_dir();
641 for explicit_shutdown in [false, true] {
642 let session = Session::new("claude-sonnet-4-5", "low", None);
643 let card = dir.join(format!("{}.json", session.id));
644 let background = spawn_session_background(&runtime, &session).unwrap();
645 let sock = std::path::PathBuf::from(&background.endpoint.registration.socket_path);
646 for _ in 0..100 {
647 if sock.exists() {
648 break;
649 }
650 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
651 }
652 assert!(card.exists() && sock.exists(), "registered and listening");
653 if explicit_shutdown {
654 background.shutdown();
655 }
656 drop(background);
657 assert!(
658 !card.exists(),
659 "card removed (shutdown: {explicit_shutdown})"
660 );
661 assert!(
662 !sock.exists(),
663 "socket removed (shutdown: {explicit_shutdown})"
664 );
665 }
666 }
667
668 #[cfg(unix)]
670 #[tokio::test]
671 #[serial_test::serial(synaps_base_dir)]
672 async fn starting_a_session_prunes_registrations_of_dead_processes() {
673 let _base = crate::test_env::BaseDirGuard::new();
674 let dir = crate::events::registry::registry_dir();
675 let dead = crate::events::registry::SessionRegistration {
676 kind: crate::events::registry::REGISTRATION_KIND.to_string(),
677 session_id: "20200101-000000-dead".into(),
678 name: None,
679 socket_path: crate::events::registry::socket_path_for_session("20200101-000000-dead"),
680 pid: 999_999,
681 started_at: chrono::Utc::now(),
682 };
683 crate::events::registry::register_session(&dead).unwrap();
684 let _stale = std::os::unix::net::UnixListener::bind(&dead.socket_path).unwrap();
685
686 let runtime = Runtime::new_headless();
687 let session = Session::new("claude-sonnet-4-5", "low", None);
688 let background = spawn_session_background(&runtime, &session).unwrap();
689 assert!(!dir.join("20200101-000000-dead.json").exists());
690 assert!(!std::path::Path::new(&dead.socket_path).exists());
691 background.shutdown();
692 }
693
694 async fn saved_session(first: &str) -> Session {
695 let mut session = Session::new("claude-sonnet-4-5", "low", None);
696 session.api_messages = vec![std::sync::Arc::new(
697 serde_json::json!({"role": "user", "content": first}),
698 )];
699 session.save().await.unwrap();
700 session
701 }
702
703 #[tokio::test]
708 #[serial_test::serial(synaps_base_dir)]
709 async fn loading_never_folds_or_touches_the_draft() {
710 use agent_core::core::session_draft::{read_turn_draft, write_turn_draft, TurnDraft};
711 let _base = crate::test_env::BaseDirGuard::new();
712 let dir = agent_core::session_lock::sessions_dir();
713 let session = saved_session("do X").await;
714 let draft = TurnDraft {
715 base_len: 1,
716 partial_text: "partial".into(),
717 };
718 write_turn_draft(&dir, &session.id, &draft).unwrap();
719
720 let mut runtime = Runtime::new_headless();
721 let sb = resolve_or_create_session(&mut runtime, &Some(Some(session.id.clone()))).unwrap();
722 assert_eq!(sb.api_messages.len(), 1, "history exactly as saved");
723 assert_eq!(
724 read_turn_draft(&dir, &session.id).unwrap(),
725 Some(draft),
726 "the draft is left for the lock holder"
727 );
728 }
729
730 #[tokio::test]
732 #[serial_test::serial(synaps_base_dir)]
733 async fn the_lock_holder_recovers_saves_then_removes_the_draft() {
734 use agent_core::core::session_draft::{read_turn_draft, write_turn_draft, TurnDraft};
735 let _base = crate::test_env::BaseDirGuard::new();
736 let dir = agent_core::session_lock::sessions_dir();
737 let session = saved_session("do X").await;
738 let id = session.id.clone();
739 write_turn_draft(
740 &dir,
741 &id,
742 &TurnDraft {
743 base_len: 1,
744 partial_text: "partial".into(),
745 },
746 )
747 .unwrap();
748
749 let mut conv = crate::engine::session::ConversationState::from_resumed(session);
750 recover_turn_draft(&mut conv).await;
751 assert_eq!(conv.api_messages.len(), 3, "partial reply + marker");
752 let on_disk = Session::load(&id).unwrap();
753 assert_eq!(on_disk.api_messages, conv.api_messages, "saved before removal");
754 assert_eq!(read_turn_draft(&dir, &id).unwrap(), None, "draft removed");
755
756 recover_turn_draft(&mut conv).await;
758 assert_eq!(conv.api_messages.len(), 3);
759 }
760
761 #[tokio::test]
764 #[serial_test::serial(synaps_base_dir)]
765 async fn an_unreadable_draft_is_removed_and_history_kept() {
766 let _base = crate::test_env::BaseDirGuard::new();
767 let dir = agent_core::session_lock::sessions_dir();
768 let session = saved_session("do X").await;
769 let path = dir.join(format!("{}.turn", session.id));
770 std::fs::write(&path, b"{not json").unwrap();
771 let mut conv = crate::engine::session::ConversationState::from_resumed(session);
772 recover_turn_draft(&mut conv).await;
773 assert_eq!(conv.api_messages.len(), 1);
774 assert!(!path.exists());
775 }
776
777 #[cfg(unix)]
780 #[tokio::test]
781 #[serial_test::serial(synaps_base_dir)]
782 async fn a_failed_recovery_save_keeps_the_draft() {
783 use agent_core::core::session_draft::{read_turn_draft, write_turn_draft, TurnDraft};
784 let _base = crate::test_env::BaseDirGuard::new();
785 let dir = agent_core::session_lock::sessions_dir();
786 let session = saved_session("do X").await;
787 let id = session.id.clone();
788 let draft = TurnDraft {
789 base_len: 1,
790 partial_text: "partial".into(),
791 };
792 write_turn_draft(&dir, &id, &draft).unwrap();
793 let snapshot = dir.join(format!("{id}.json"));
796 std::fs::remove_file(&snapshot).unwrap();
797 std::fs::create_dir(&snapshot).unwrap();
798 std::fs::write(snapshot.join("occupied"), b"x").unwrap();
799
800 let mut conv = crate::engine::session::ConversationState::from_resumed(session);
801 recover_turn_draft(&mut conv).await;
802 assert_eq!(read_turn_draft(&dir, &id).unwrap(), Some(draft), "draft kept");
803 }
804
805 #[tokio::test]
808 #[serial_test::serial(synaps_base_dir)]
809 async fn boot_hosts_refuse_to_continue_a_session_held_elsewhere() {
810 let _base = crate::test_env::BaseDirGuard::new();
811 let held = lock_session("20260930-000000-held", true, "tui").unwrap();
812 assert!(held.is_some());
813 let err = lock_session("20260930-000000-held", true, "rpc").unwrap_err();
814 assert!(err.to_string().contains("20260930-000000-held"), "{err}");
815 assert!(lock_session("20260930-000000-held", false, "rpc").unwrap().is_none());
817 drop(held);
818 assert!(lock_session("20260930-000000-held", true, "rpc").unwrap().is_some());
819
820 let mut old = Session::new("claude-sonnet-4-5", "low", None);
822 old.api_messages = vec![std::sync::Arc::new(
823 serde_json::json!({"role": "user", "content": "before compaction"}),
824 )];
825 old.compacted_into = Some("20260930-000001-next".into());
826 old.save().await.unwrap();
827 let err = lock_session(&old.id, true, "rpc").unwrap_err();
828 assert!(err.to_string().contains("20260930-000001-next"), "{err}");
829 }
830
831 #[test]
834 fn continue_path_restores_thinking_level_from_session() {
835 let mut runtime = Runtime::new_headless();
836
837 let thinking_level_str = "ultra";
839 if let Some(level) = ReasoningLevel::parse(thinking_level_str) {
840 runtime.set_reasoning_level_explicit(level);
841 }
842
843 assert_eq!(
844 runtime.reasoning_level(),
845 ReasoningLevel::Ultra,
846 "thinking level must be restored from session on --continue"
847 );
848 assert!(
849 runtime.is_reasoning_explicit(),
850 "restored thinking level must be marked explicit so set_model won't overwrite it"
851 );
852 }
853
854 #[test]
855 fn continue_path_restores_max_level() {
856 let mut runtime = Runtime::new_headless();
857 let thinking_level_str = "max";
858 if let Some(level) = ReasoningLevel::parse(thinking_level_str) {
859 runtime.set_reasoning_level_explicit(level);
860 }
861 assert_eq!(runtime.reasoning_level(), ReasoningLevel::Max);
862 }
863
864 #[test]
865 fn continue_path_restores_off_level() {
866 let mut runtime = Runtime::new_headless();
867 let thinking_level_str = "off";
868 if let Some(level) = ReasoningLevel::parse(thinking_level_str) {
869 runtime.set_reasoning_level_explicit(level);
870 }
871 assert_eq!(runtime.reasoning_level(), ReasoningLevel::Off);
872 }
873
874 #[tokio::test]
879 async fn default_config_is_dark_no_checkpoint_tool() {
880 let runtime = Runtime::new_headless();
881 let offered = runtime.offered_tools_schema().await;
882 let names: Vec<&str> = offered.iter().filter_map(|t| t["name"].as_str()).collect();
883 for dark in ["context_checkpoint", "forum_post", "forum_read", "forum_forget"] {
884 assert!(
885 !names.contains(&dark),
886 "{dark} must not appear in the default tool set: {names:?}"
887 );
888 }
889 assert!(names.contains(&"read"), "{names:?}");
890 }
891}