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(
314 inbox_dir,
315 event_queue,
316 shutdown,
317 crate::events::ingest::accept_all,
318 gate,
319 )
320 .await;
321 })
322 };
323
324 let abort_tasks = |ws: &Arc<std::sync::atomic::AtomicBool>,
326 wt: &tokio::task::JoinHandle<()>| {
327 ws.store(true, std::sync::atomic::Ordering::Relaxed);
328 wt.abort();
329 };
330
331 let endpoint = match crate::events::registry::SessionEndpoint::start(
332 &session.id,
333 session.name.clone(),
334 runtime.event_queue().clone(),
335 ) {
336 Ok(endpoint) => endpoint,
337 Err(e) => {
338 abort_tasks(&watcher_shutdown, &watcher_task);
339 return Err(crate::core::error::RuntimeError::Session(e));
340 }
341 };
342
343 Ok(BackgroundTasks {
344 watcher_shutdown,
345 watcher_task,
346 inbox_gate,
347 endpoint,
348 session_id: session.id.clone(),
349 hook_bus: Arc::clone(runtime.hook_bus()),
350 log_guard: None,
352 })
353}
354
355#[derive(Clone, Copy, Debug, PartialEq, Eq)]
358pub(crate) enum IndexRecord {
359 Start,
360 Skip,
361}
362
363pub(crate) fn finish_session_setup(
366 runtime: &mut Runtime,
367 config: &crate::SynapsConfig,
368 session: &Session,
369 cwd: Option<std::path::PathBuf>,
370 index_record: IndexRecord,
371) {
372 runtime.set_turn_budget(crate::runtime::budget::TurnBudget::from_config(
375 crate::runtime::budget::TurnRole::Foreground,
376 &config.turn_budgets,
377 ));
378
379 if index_record == IndexRecord::Start {
388 let mut index_record =
389 crate::core::session_index::SessionIndexRecord::start(&session.id);
390 index_record.model = Some(session.model.clone());
391 index_record.profile = crate::core::config::get_profile();
392 index_record.cwd = cwd.or_else(|| std::env::current_dir().ok());
393 if let Err(err) = crate::core::session_index::append_record(&index_record) {
394 tracing::warn!("failed to append session start index record: {}", err);
395 }
396 }
397
398}
399
400pub(crate) struct SessionBootResult {
402 pub(crate) session: Session,
403 pub(crate) api_messages: Vec<crate::SharedMessage>,
404 pub(crate) total_input_tokens: u64,
405 pub(crate) total_output_tokens: u64,
406 pub(crate) session_cost: f64,
407 pub(crate) continued: bool,
408 pub(crate) continue_info: Option<ContinueInfo>,
409}
410
411pub fn lock_session(
421 id: &str,
422 continued: bool,
423 kind: &str,
424) -> Result<Option<agent_core::session_lock::SessionLock>> {
425 let dir = agent_core::session_lock::sessions_dir();
426 let holder = agent_core::session_lock::LockHolder {
427 pid: std::process::id(),
428 kind: kind.to_string(),
429 };
430 match agent_core::session_lock::SessionLock::try_acquire(&dir, id, holder) {
431 Ok(lock) => Ok(Some(lock)),
432 Err(
433 e @ (agent_core::session_lock::SessionLockError::Held { .. }
434 | agent_core::session_lock::SessionLockError::CompactedInto { .. }),
435 ) if continued => Err(crate::RuntimeError::Session(e.to_string())),
436 Err(e) => {
437 tracing::warn!(session = %id, "session lock: {e}");
438 Ok(None)
439 }
440 }
441}
442
443pub async fn recover_turn_draft(conv: &mut crate::engine::session::ConversationState) {
458 let dir = agent_core::session_lock::sessions_dir();
459 let id = conv.session.id.clone();
460 let read = {
461 let (dir, id) = (dir.clone(), id.clone());
462 tokio::task::spawn_blocking(move || {
463 agent_core::core::session_draft::read_turn_draft(&dir, &id)
464 })
465 .await
466 .map_err(std::io::Error::other)
467 .and_then(|r| r)
468 };
469 let recovered = match read {
470 Ok(None) => return,
471 Ok(Some(draft)) => {
472 crate::engine::interrupt::recover_crashed_turn(&mut conv.api_messages, &draft)
473 }
474 Err(e) => {
475 tracing::warn!(session = %id, "unreadable turn draft, removing it: {e}");
478 false
479 }
480 };
481 if recovered {
482 tracing::warn!(
483 session = %id,
484 "session was interrupted mid-turn by an unexpected stop; recovered"
485 );
486 if !conv.save().await {
487 tracing::warn!(
488 session = %id,
489 "could not save the recovered history; keeping the turn draft"
490 );
491 return;
492 }
493 } else {
494 tracing::info!(session = %id, "removing a stale turn draft");
497 }
498 let removed = tokio::task::spawn_blocking(move || {
499 agent_core::core::session_draft::remove_turn_draft(&dir, &id)
500 })
501 .await;
502 if let Ok(Err(e)) | Err(e) = removed.map_err(std::io::Error::other) {
503 tracing::warn!(session = %conv.session.id, "failed to remove turn draft: {e}");
504 }
505}
506
507fn resolve_or_create_session(
508 runtime: &mut Runtime,
509 continue_session: &Option<Option<String>>,
510) -> Result<SessionBootResult> {
511 match continue_session {
512 Some(ref maybe_id) => {
513 let raw_session = match maybe_id {
514 Some(ref id) => resolve_session(id).map_err(|e| {
515 crate::error::RuntimeError::Tool(format!(
516 "Failed to load session '{}': {}",
517 id, e
518 ))
519 })?,
520 None => latest_session().map_err(|e| {
521 crate::error::RuntimeError::Tool(format!("No sessions to continue: {}", e))
522 })?,
523 };
524 let resolved = agent_core::session::follow_compaction_chain(raw_session)
527 .map_err(|e| crate::error::RuntimeError::Session(e.to_string()))?;
528 let compaction_notice = resolved.compaction_notice;
529 let mut session = resolved.session;
530 runtime.set_model(session.model.clone());
531 if let Some(clamp) = runtime.restore_session_reasoning(&session.thinking_level) {
536 tracing::warn!(
537 from = %clamp.from,
538 to = %clamp.to,
539 model = %session.model,
540 "saved session thinking level not supported by model; clamped"
541 );
542 session.thinking_level = runtime.thinking_level().to_string();
543 }
544 if let Some(ref sp) = session.system_prompt {
545 runtime.set_system_prompt(sp.clone());
546 }
547
548 let continue_info = {
549 let (resolved_via, query) = match maybe_id {
550 Some(ref q) => {
551 let via = if *q != session.id {
552 if crate::chain::load_chain(q).is_ok() {
553 Some("chain".to_string())
554 } else if agent_core::session::find_session_by_name(q).is_ok() {
555 Some("name".to_string())
556 } else if compaction_notice.is_some() {
557 Some("compacted".to_string())
558 } else {
559 None
560 }
561 } else {
562 None
563 };
564 (via, q.clone())
565 }
566 None => {
567 let via = compaction_notice.as_ref().map(|_| "compacted".to_string());
568 (via, session.id.clone())
569 }
570 };
571 if maybe_id.is_some() || compaction_notice.is_some() {
574 Some(ContinueInfo {
575 session_id: session.id.clone(),
576 resolved_via,
577 query,
578 compaction_notice: compaction_notice.clone(),
579 })
580 } else {
581 None
582 }
583 };
584
585 if crate::engine::interrupt::migrate_legacy_abort_context(
593 &mut session.api_messages,
594 &mut session.abort_context,
595 ) {
596 tracing::info!(
597 session = %session.id,
598 "migrated a legacy abort-context recap to an interruption marker"
599 );
600 }
601 Ok(SessionBootResult {
606 api_messages: session.api_messages.clone(),
607 total_input_tokens: session.total_input_tokens,
608 total_output_tokens: session.total_output_tokens,
609 session_cost: session.session_cost,
610 continued: true,
611 continue_info,
612 session,
613 })
614 }
615 None => {
616 let session = Session::new(
617 runtime.model(),
618 runtime.thinking_level(),
619 runtime.system_prompt(),
620 );
621 Ok(SessionBootResult {
622 session,
623 api_messages: Vec::new(),
624 total_input_tokens: 0,
625 total_output_tokens: 0,
626 session_cost: 0.0,
627 continued: false,
628 continue_info: None,
629 })
630 }
631 }
632}
633
634#[cfg(test)]
635mod tests {
636 use super::*;
637 use agent_core::reasoning::ReasoningLevel;
638
639 #[cfg(unix)]
643 #[tokio::test]
644 #[serial_test::serial(synaps_base_dir)]
645 async fn session_registration_is_removed_on_drop_without_shutdown() {
646 let _base = crate::test_env::BaseDirGuard::new();
647 let runtime = Runtime::new_headless();
648 let dir = crate::events::registry::registry_dir();
649 for explicit_shutdown in [false, true] {
650 let session = Session::new("claude-sonnet-4-5", "low", None);
651 let card = dir.join(format!("{}.json", session.id));
652 let background = spawn_session_background(&runtime, &session).unwrap();
653 let sock = std::path::PathBuf::from(&background.endpoint.registration.socket_path);
654 for _ in 0..100 {
655 if sock.exists() {
656 break;
657 }
658 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
659 }
660 assert!(card.exists() && sock.exists(), "registered and listening");
661 if explicit_shutdown {
662 background.shutdown();
663 }
664 drop(background);
665 assert!(
666 !card.exists(),
667 "card removed (shutdown: {explicit_shutdown})"
668 );
669 assert!(
670 !sock.exists(),
671 "socket removed (shutdown: {explicit_shutdown})"
672 );
673 }
674 }
675
676 #[cfg(unix)]
678 #[tokio::test]
679 #[serial_test::serial(synaps_base_dir)]
680 async fn starting_a_session_prunes_registrations_of_dead_processes() {
681 let _base = crate::test_env::BaseDirGuard::new();
682 let dir = crate::events::registry::registry_dir();
683 let dead = crate::events::registry::SessionRegistration {
684 kind: crate::events::registry::REGISTRATION_KIND.to_string(),
685 session_id: "20200101-000000-dead".into(),
686 name: None,
687 socket_path: crate::events::registry::socket_path_for_session("20200101-000000-dead"),
688 pid: 999_999,
689 started_at: chrono::Utc::now(),
690 };
691 crate::events::registry::register_session(&dead).unwrap();
692 let _stale = std::os::unix::net::UnixListener::bind(&dead.socket_path).unwrap();
693
694 let runtime = Runtime::new_headless();
695 let session = Session::new("claude-sonnet-4-5", "low", None);
696 let background = spawn_session_background(&runtime, &session).unwrap();
697 assert!(!dir.join("20200101-000000-dead.json").exists());
698 assert!(!std::path::Path::new(&dead.socket_path).exists());
699 background.shutdown();
700 }
701
702 async fn saved_session(first: &str) -> Session {
703 let mut session = Session::new("claude-sonnet-4-5", "low", None);
704 session.api_messages = vec![std::sync::Arc::new(
705 serde_json::json!({"role": "user", "content": first}),
706 )];
707 session.save().await.unwrap();
708 session
709 }
710
711 #[tokio::test]
716 #[serial_test::serial(synaps_base_dir)]
717 async fn loading_never_folds_or_touches_the_draft() {
718 use agent_core::core::session_draft::{read_turn_draft, write_turn_draft, TurnDraft};
719 let _base = crate::test_env::BaseDirGuard::new();
720 let dir = agent_core::session_lock::sessions_dir();
721 let session = saved_session("do X").await;
722 let draft = TurnDraft {
723 base_len: 1,
724 partial_text: "partial".into(),
725 };
726 write_turn_draft(&dir, &session.id, &draft).unwrap();
727
728 let mut runtime = Runtime::new_headless();
729 let sb = resolve_or_create_session(&mut runtime, &Some(Some(session.id.clone()))).unwrap();
730 assert_eq!(sb.api_messages.len(), 1, "history exactly as saved");
731 assert_eq!(
732 read_turn_draft(&dir, &session.id).unwrap(),
733 Some(draft),
734 "the draft is left for the lock holder"
735 );
736 }
737
738 #[tokio::test]
740 #[serial_test::serial(synaps_base_dir)]
741 async fn the_lock_holder_recovers_saves_then_removes_the_draft() {
742 use agent_core::core::session_draft::{read_turn_draft, write_turn_draft, TurnDraft};
743 let _base = crate::test_env::BaseDirGuard::new();
744 let dir = agent_core::session_lock::sessions_dir();
745 let session = saved_session("do X").await;
746 let id = session.id.clone();
747 write_turn_draft(
748 &dir,
749 &id,
750 &TurnDraft {
751 base_len: 1,
752 partial_text: "partial".into(),
753 },
754 )
755 .unwrap();
756
757 let mut conv = crate::engine::session::ConversationState::from_resumed(session);
758 recover_turn_draft(&mut conv).await;
759 assert_eq!(conv.api_messages.len(), 3, "partial reply + marker");
760 let on_disk = Session::load(&id).unwrap();
761 assert_eq!(on_disk.api_messages, conv.api_messages, "saved before removal");
762 assert_eq!(read_turn_draft(&dir, &id).unwrap(), None, "draft removed");
763
764 recover_turn_draft(&mut conv).await;
766 assert_eq!(conv.api_messages.len(), 3);
767 }
768
769 #[tokio::test]
772 #[serial_test::serial(synaps_base_dir)]
773 async fn an_unreadable_draft_is_removed_and_history_kept() {
774 let _base = crate::test_env::BaseDirGuard::new();
775 let dir = agent_core::session_lock::sessions_dir();
776 let session = saved_session("do X").await;
777 let path = dir.join(format!("{}.turn", session.id));
778 std::fs::write(&path, b"{not json").unwrap();
779 let mut conv = crate::engine::session::ConversationState::from_resumed(session);
780 recover_turn_draft(&mut conv).await;
781 assert_eq!(conv.api_messages.len(), 1);
782 assert!(!path.exists());
783 }
784
785 #[cfg(unix)]
788 #[tokio::test]
789 #[serial_test::serial(synaps_base_dir)]
790 async fn a_failed_recovery_save_keeps_the_draft() {
791 use agent_core::core::session_draft::{read_turn_draft, write_turn_draft, TurnDraft};
792 let _base = crate::test_env::BaseDirGuard::new();
793 let dir = agent_core::session_lock::sessions_dir();
794 let session = saved_session("do X").await;
795 let id = session.id.clone();
796 let draft = TurnDraft {
797 base_len: 1,
798 partial_text: "partial".into(),
799 };
800 write_turn_draft(&dir, &id, &draft).unwrap();
801 let snapshot = dir.join(format!("{id}.json"));
804 std::fs::remove_file(&snapshot).unwrap();
805 std::fs::create_dir(&snapshot).unwrap();
806 std::fs::write(snapshot.join("occupied"), b"x").unwrap();
807
808 let mut conv = crate::engine::session::ConversationState::from_resumed(session);
809 recover_turn_draft(&mut conv).await;
810 assert_eq!(read_turn_draft(&dir, &id).unwrap(), Some(draft), "draft kept");
811 }
812
813 #[tokio::test]
816 #[serial_test::serial(synaps_base_dir)]
817 async fn boot_hosts_refuse_to_continue_a_session_held_elsewhere() {
818 let _base = crate::test_env::BaseDirGuard::new();
819 let held = lock_session("20260930-000000-held", true, "tui").unwrap();
820 assert!(held.is_some());
821 let err = lock_session("20260930-000000-held", true, "rpc").unwrap_err();
822 assert!(err.to_string().contains("20260930-000000-held"), "{err}");
823 assert!(lock_session("20260930-000000-held", false, "rpc").unwrap().is_none());
825 drop(held);
826 assert!(lock_session("20260930-000000-held", true, "rpc").unwrap().is_some());
827
828 let mut old = Session::new("claude-sonnet-4-5", "low", None);
830 old.api_messages = vec![std::sync::Arc::new(
831 serde_json::json!({"role": "user", "content": "before compaction"}),
832 )];
833 old.compacted_into = Some("20260930-000001-next".into());
834 old.save().await.unwrap();
835 let err = lock_session(&old.id, true, "rpc").unwrap_err();
836 assert!(err.to_string().contains("20260930-000001-next"), "{err}");
837 }
838
839 #[test]
842 fn continue_path_restores_thinking_level_from_session() {
843 let mut runtime = Runtime::new_headless();
844
845 let thinking_level_str = "ultra";
847 if let Some(level) = ReasoningLevel::parse(thinking_level_str) {
848 runtime.set_reasoning_level_explicit(level);
849 }
850
851 assert_eq!(
852 runtime.reasoning_level(),
853 ReasoningLevel::Ultra,
854 "thinking level must be restored from session on --continue"
855 );
856 assert!(
857 runtime.is_reasoning_explicit(),
858 "restored thinking level must be marked explicit so set_model won't overwrite it"
859 );
860 }
861
862 #[test]
863 fn continue_path_restores_max_level() {
864 let mut runtime = Runtime::new_headless();
865 let thinking_level_str = "max";
866 if let Some(level) = ReasoningLevel::parse(thinking_level_str) {
867 runtime.set_reasoning_level_explicit(level);
868 }
869 assert_eq!(runtime.reasoning_level(), ReasoningLevel::Max);
870 }
871
872 #[test]
873 fn continue_path_restores_off_level() {
874 let mut runtime = Runtime::new_headless();
875 let thinking_level_str = "off";
876 if let Some(level) = ReasoningLevel::parse(thinking_level_str) {
877 runtime.set_reasoning_level_explicit(level);
878 }
879 assert_eq!(runtime.reasoning_level(), ReasoningLevel::Off);
880 }
881
882 #[test]
885 fn default_config_is_dark_no_checkpoint_tool_legacy_binding() {
886 let runtime = Runtime::new_headless();
887 assert!(
889 !runtime.memory_backend_exclusive(),
890 "default config must NOT be exclusive (Axel); it must be legacy"
891 );
892 let tools = runtime.tools_shared();
894 let registry = tools.blocking_read();
895 let has_checkpoint = registry
896 .iter_tools_sorted()
897 .iter()
898 .any(|t| t.name() == "context_checkpoint");
899 assert!(
900 !has_checkpoint,
901 "context_checkpoint must not appear in the default tool set"
902 );
903 }
904}