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 endpoint: crate::events::registry::SessionEndpoint,
28 session_id: String,
29 hook_bus: Arc<crate::extensions::hooks::HookBus>,
32 #[allow(dead_code)]
38 log_guard: Option<tracing_appender::non_blocking::WorkerGuard>,
39}
40
41impl BackgroundTasks {
42 pub(crate) fn update_session_name(
43 &self,
44 name: Option<&str>,
45 ) -> std::result::Result<(), String> {
46 self.endpoint.update_name(name)
47 }
48
49 pub fn shutdown(&self) {
51 self.watcher_shutdown
52 .store(true, std::sync::atomic::Ordering::Release);
53 self.endpoint.shutdown();
54 if let Ok(handle) = tokio::runtime::Handle::try_current() {
56 let hook_bus = Arc::clone(&self.hook_bus);
57 let session_id = self.session_id.clone();
58 handle.spawn(async move {
59 hook_bus.clear_session_injection(&session_id).await;
60 });
61 }
62 }
63}
64
65impl Drop for BackgroundTasks {
66 fn drop(&mut self) {
67 self.watcher_shutdown
68 .store(true, std::sync::atomic::Ordering::Relaxed);
69 self.watcher_task.abort();
70 self.endpoint.shutdown();
71 }
72}
73
74pub struct EngineBoot {
76 pub runtime: Runtime,
77 pub config: crate::SynapsConfig,
78 pub no_extensions: bool,
81 pub session: Session,
82 pub api_messages: Vec<crate::SharedMessage>,
83 pub total_input_tokens: u64,
84 pub total_output_tokens: u64,
85 pub session_cost: f64,
86 pub continued: bool,
87 pub continue_info: Option<ContinueInfo>,
88 pub registry: Arc<CommandRegistry>,
89 pub keybind_registry: Arc<std::sync::RwLock<KeybindRegistry>>,
94 pub mcp_server_count: usize,
95 pub system_prompt_path: std::path::PathBuf,
96 pub ext_manager: Arc<RwLock<crate::extensions::manager::ExtensionManager>>,
97 pub background: BackgroundTasks,
99}
100
101pub struct ContinueInfo {
103 pub session_id: String,
104 pub resolved_via: Option<String>, pub query: String,
106 pub compaction_notice: Option<String>,
108}
109
110pub async fn boot(opts: EngineOpts) -> Result<EngineBoot> {
113 let host = EngineHost::boot_and_install(HostOpts {
119 profile: opts.profile.clone(),
120 no_extensions: opts.no_extensions,
121 mcp_discovery: crate::McpDiscovery::Inline,
122 })
123 .await?;
124 let mut runtime = host.foreground_runtime().await?;
127 let config: crate::SynapsConfig = (**host.config()).clone();
128
129 let sb = resolve_session_and_prompt(
130 &mut runtime,
131 &opts.continue_session,
132 opts.system.as_deref(),
133 opts.prompt_manifest.as_deref(),
134 )?;
135
136 let registry = Arc::clone(host.command_registry());
139 let keybind_registry = Arc::clone(host.keybind_registry());
140 let mcp_server_count = host.mcp_server_count();
141
142 let system_prompt_path = crate::config::resolve_read_path("system.md");
143
144 let background = spawn_session_background(&runtime, &sb.session)?;
148
149 finish_session_setup(&mut runtime, &config, &sb.session, None, IndexRecord::Start);
150
151 let ext_manager = Arc::clone(host.ext_manager());
153
154 if mcp_server_count > 0 {
155 tracing::info!(
156 "{} MCP tools available from configured servers",
157 mcp_server_count
158 );
159 }
160
161 Ok(EngineBoot {
162 runtime,
163 config,
164 no_extensions: opts.no_extensions,
165 session: sb.session,
166 api_messages: sb.api_messages,
167 total_input_tokens: sb.total_input_tokens,
168 total_output_tokens: sb.total_output_tokens,
169 session_cost: sb.session_cost,
170 continued: sb.continued,
171 continue_info: sb.continue_info,
172 registry,
173 keybind_registry,
174 mcp_server_count,
175 system_prompt_path,
176 ext_manager,
177 background,
178 })
179}
180
181pub(crate) fn resolve_session_and_prompt(
184 runtime: &mut Runtime,
185 continue_session: &Option<Option<String>>,
186 system: Option<&str>,
187 prompt_manifest: Option<&std::path::Path>,
188) -> Result<SessionBootResult> {
189 let sb = resolve_or_create_session(runtime, continue_session)?;
192 runtime.set_session_id(Some(sb.session.id.clone()));
193 runtime.reset_context_continuation(&sb.session.id, &sb.api_messages);
196
197 let legacy_prompt = crate::config::resolve_system_prompt(system);
199 if let Some(path) = prompt_manifest {
200 let raw = std::fs::read_to_string(path)
201 .map_err(|_| crate::RuntimeError::Config("prompt manifest is unavailable".into()))?;
202 let manifest = agent_core::prompt::PromptManifest::parse(&raw)
203 .map_err(|e| crate::RuntimeError::Config(format!("invalid prompt manifest: {e}")))?;
204 let registry = manifest
205 .registry(path.parent())
206 .map_err(|e| crate::RuntimeError::Config(format!("invalid prompt manifest: {e}")))?;
207 let model = crate::orchestration::canonical_foreground_identity(runtime.model())
208 .map_err(|e| crate::RuntimeError::Config(format!("invalid foreground model: {e}")))?;
209 let context = agent_core::prompt::SelectionContext::new(model.clone(), None)
210 .map_err(|e| crate::RuntimeError::Config(e.to_string()))?;
211 let catalog = crate::orchestration::OrchestrationRuntime::trusted_catalog(
212 &model,
213 manifest.delegation_catalog_candidates(),
214 )
215 .map_err(|error| crate::RuntimeError::Config(error.into()))?;
216 let delegation_policy = manifest
217 .delegation_policy(model.clone(), &catalog)
218 .map_err(|e| crate::RuntimeError::Config(format!("invalid prompt manifest: {e}")))?;
219 let delegation_policy_digest = delegation_policy.as_ref().map(|policy| policy.digest());
220 if let Some(policy) = delegation_policy {
221 runtime.install_orchestration(Arc::new(
224 crate::orchestration::OrchestrationRuntime::new(policy)
225 .with_max_depth(runtime.subagent_limits().max_depth)
226 .map_err(|error| crate::RuntimeError::Config(error.into()))?,
227 ));
228 } else {
229 runtime.install_orchestration(Arc::new(
230 crate::orchestration::OrchestrationRuntime::from_limits(
231 model.clone(),
232 runtime.subagent_limits(),
233 )
234 .map_err(|error| crate::RuntimeError::Config(error.into()))?,
235 ));
236 }
237 let user = system
238 .map(|_| {
239 agent_core::prompt::resolved_system_prompt_as_user_module(legacy_prompt.clone())
240 })
241 .transpose()
242 .map_err(|e| crate::RuntimeError::Config(e.to_string()))?;
243 let stack =
244 agent_core::prompt::compile_prompt_stack(&manifest, ®istry, &context, user.clone())
245 .map_err(|e| {
246 crate::RuntimeError::Config(format!("invalid prompt manifest: {e}"))
247 })?;
248 runtime
249 .apply_prompt_stack(stack)
250 .map_err(|e| crate::RuntimeError::Config(format!("invalid prompt manifest: {e}")))?;
251 runtime.retain_prompt_reload_source(
252 path.to_path_buf(),
253 context,
254 user,
255 delegation_policy_digest,
256 );
257 } else {
258 let foreground = crate::orchestration::canonical_foreground_identity(runtime.model())
259 .map_err(|e| crate::RuntimeError::Config(format!("invalid foreground model: {e}")))?;
260 runtime.install_orchestration(Arc::new(
261 crate::orchestration::OrchestrationRuntime::from_limits(
262 foreground,
263 runtime.subagent_limits(),
264 )
265 .map_err(|error| crate::RuntimeError::Config(error.into()))?,
266 ));
267 runtime.set_system_prompt(legacy_prompt);
268 }
269
270 Ok(sb)
271}
272
273pub(crate) fn spawn_session_background(
276 runtime: &Runtime,
277 session: &Session,
278) -> Result<BackgroundTasks> {
279 let watcher_shutdown = Arc::new(std::sync::atomic::AtomicBool::new(false));
281 let watcher_task = {
282 let inbox_dir = crate::config::base_dir().join("inbox");
283 let event_queue = runtime.event_queue().clone();
284 let shutdown = watcher_shutdown.clone();
285 tokio::spawn(async move {
286 crate::events::watch_inbox(inbox_dir, event_queue, shutdown).await;
287 })
288 };
289
290 let abort_tasks = |ws: &Arc<std::sync::atomic::AtomicBool>,
292 wt: &tokio::task::JoinHandle<()>| {
293 ws.store(true, std::sync::atomic::Ordering::Relaxed);
294 wt.abort();
295 };
296
297 let endpoint = match crate::events::registry::SessionEndpoint::start(
298 &session.id,
299 session.name.clone(),
300 runtime.event_queue().clone(),
301 ) {
302 Ok(endpoint) => endpoint,
303 Err(e) => {
304 abort_tasks(&watcher_shutdown, &watcher_task);
305 return Err(crate::core::error::RuntimeError::Session(e));
306 }
307 };
308
309 Ok(BackgroundTasks {
310 watcher_shutdown,
311 watcher_task,
312 endpoint,
313 session_id: session.id.clone(),
314 hook_bus: Arc::clone(runtime.hook_bus()),
315 log_guard: None,
317 })
318}
319
320#[derive(Clone, Copy, Debug, PartialEq, Eq)]
323pub(crate) enum IndexRecord {
324 Start,
325 Skip,
326}
327
328pub(crate) fn finish_session_setup(
331 runtime: &mut Runtime,
332 config: &crate::SynapsConfig,
333 session: &Session,
334 cwd: Option<std::path::PathBuf>,
335 index_record: IndexRecord,
336) {
337 runtime.set_turn_budget(crate::runtime::budget::TurnBudget::from_config(
340 crate::runtime::budget::TurnRole::Foreground,
341 &config.turn_budgets,
342 ));
343
344 if index_record == IndexRecord::Start {
353 let mut index_record =
354 crate::core::session_index::SessionIndexRecord::start(&session.id);
355 index_record.model = Some(session.model.clone());
356 index_record.profile = crate::core::config::get_profile();
357 index_record.cwd = cwd.or_else(|| std::env::current_dir().ok());
358 if let Err(err) = crate::core::session_index::append_record(&index_record) {
359 tracing::warn!("failed to append session start index record: {}", err);
360 }
361 }
362
363}
364
365pub(crate) struct SessionBootResult {
367 pub(crate) session: Session,
368 pub(crate) api_messages: Vec<crate::SharedMessage>,
369 pub(crate) total_input_tokens: u64,
370 pub(crate) total_output_tokens: u64,
371 pub(crate) session_cost: f64,
372 pub(crate) continued: bool,
373 pub(crate) continue_info: Option<ContinueInfo>,
374}
375
376pub fn lock_session(
386 id: &str,
387 continued: bool,
388 kind: &str,
389) -> Result<Option<agent_core::session_lock::SessionLock>> {
390 let dir = agent_core::session_lock::sessions_dir();
391 let holder = agent_core::session_lock::LockHolder {
392 pid: std::process::id(),
393 kind: kind.to_string(),
394 };
395 match agent_core::session_lock::SessionLock::try_acquire(&dir, id, holder) {
396 Ok(lock) => Ok(Some(lock)),
397 Err(
398 e @ (agent_core::session_lock::SessionLockError::Held { .. }
399 | agent_core::session_lock::SessionLockError::CompactedInto { .. }),
400 ) if continued => Err(crate::RuntimeError::Session(e.to_string())),
401 Err(e) => {
402 tracing::warn!(session = %id, "session lock: {e}");
403 Ok(None)
404 }
405 }
406}
407
408pub async fn recover_turn_draft(conv: &mut crate::engine::session::ConversationState) {
423 let dir = agent_core::session_lock::sessions_dir();
424 let id = conv.session.id.clone();
425 let read = {
426 let (dir, id) = (dir.clone(), id.clone());
427 tokio::task::spawn_blocking(move || {
428 agent_core::core::session_draft::read_turn_draft(&dir, &id)
429 })
430 .await
431 .map_err(std::io::Error::other)
432 .and_then(|r| r)
433 };
434 let recovered = match read {
435 Ok(None) => return,
436 Ok(Some(draft)) => {
437 crate::engine::interrupt::recover_crashed_turn(&mut conv.api_messages, &draft)
438 }
439 Err(e) => {
440 tracing::warn!(session = %id, "unreadable turn draft, removing it: {e}");
443 false
444 }
445 };
446 if recovered {
447 tracing::warn!(
448 session = %id,
449 "session was interrupted mid-turn by an unexpected stop; recovered"
450 );
451 if !conv.save().await {
452 tracing::warn!(
453 session = %id,
454 "could not save the recovered history; keeping the turn draft"
455 );
456 return;
457 }
458 } else {
459 tracing::info!(session = %id, "removing a stale turn draft");
462 }
463 let removed = tokio::task::spawn_blocking(move || {
464 agent_core::core::session_draft::remove_turn_draft(&dir, &id)
465 })
466 .await;
467 if let Ok(Err(e)) | Err(e) = removed.map_err(std::io::Error::other) {
468 tracing::warn!(session = %conv.session.id, "failed to remove turn draft: {e}");
469 }
470}
471
472fn resolve_or_create_session(
473 runtime: &mut Runtime,
474 continue_session: &Option<Option<String>>,
475) -> Result<SessionBootResult> {
476 match continue_session {
477 Some(ref maybe_id) => {
478 let raw_session = match maybe_id {
479 Some(ref id) => resolve_session(id).map_err(|e| {
480 crate::error::RuntimeError::Tool(format!(
481 "Failed to load session '{}': {}",
482 id, e
483 ))
484 })?,
485 None => latest_session().map_err(|e| {
486 crate::error::RuntimeError::Tool(format!("No sessions to continue: {}", e))
487 })?,
488 };
489 let resolved = agent_core::session::follow_compaction_chain(raw_session)
492 .map_err(|e| crate::error::RuntimeError::Session(e.to_string()))?;
493 let compaction_notice = resolved.compaction_notice;
494 let mut session = resolved.session;
495 runtime.set_model(session.model.clone());
496 if let Some(clamp) = runtime.restore_session_reasoning(&session.thinking_level) {
501 tracing::warn!(
502 from = %clamp.from,
503 to = %clamp.to,
504 model = %session.model,
505 "saved session thinking level not supported by model; clamped"
506 );
507 session.thinking_level = runtime.thinking_level().to_string();
508 }
509 if let Some(ref sp) = session.system_prompt {
510 runtime.set_system_prompt(sp.clone());
511 }
512
513 let continue_info = {
514 let (resolved_via, query) = match maybe_id {
515 Some(ref q) => {
516 let via = if *q != session.id {
517 if crate::chain::load_chain(q).is_ok() {
518 Some("chain".to_string())
519 } else if agent_core::session::find_session_by_name(q).is_ok() {
520 Some("name".to_string())
521 } else if compaction_notice.is_some() {
522 Some("compacted".to_string())
523 } else {
524 None
525 }
526 } else {
527 None
528 };
529 (via, q.clone())
530 }
531 None => {
532 let via = compaction_notice.as_ref().map(|_| "compacted".to_string());
533 (via, session.id.clone())
534 }
535 };
536 if maybe_id.is_some() || compaction_notice.is_some() {
539 Some(ContinueInfo {
540 session_id: session.id.clone(),
541 resolved_via,
542 query,
543 compaction_notice: compaction_notice.clone(),
544 })
545 } else {
546 None
547 }
548 };
549
550 if crate::engine::interrupt::migrate_legacy_abort_context(
558 &mut session.api_messages,
559 &mut session.abort_context,
560 ) {
561 tracing::info!(
562 session = %session.id,
563 "migrated a legacy abort-context recap to an interruption marker"
564 );
565 }
566 Ok(SessionBootResult {
571 api_messages: session.api_messages.clone(),
572 total_input_tokens: session.total_input_tokens,
573 total_output_tokens: session.total_output_tokens,
574 session_cost: session.session_cost,
575 continued: true,
576 continue_info,
577 session,
578 })
579 }
580 None => {
581 let session = Session::new(
582 runtime.model(),
583 runtime.thinking_level(),
584 runtime.system_prompt(),
585 );
586 Ok(SessionBootResult {
587 session,
588 api_messages: Vec::new(),
589 total_input_tokens: 0,
590 total_output_tokens: 0,
591 session_cost: 0.0,
592 continued: false,
593 continue_info: None,
594 })
595 }
596 }
597}
598
599#[cfg(test)]
600mod tests {
601 use super::*;
602 use agent_core::reasoning::ReasoningLevel;
603
604 #[cfg(unix)]
608 #[tokio::test]
609 #[serial_test::serial(synaps_base_dir)]
610 async fn session_registration_is_removed_on_drop_without_shutdown() {
611 let _base = crate::test_env::BaseDirGuard::new();
612 let runtime = Runtime::new_headless();
613 let dir = crate::events::registry::registry_dir();
614 for explicit_shutdown in [false, true] {
615 let session = Session::new("claude-sonnet-4-5", "low", None);
616 let card = dir.join(format!("{}.json", session.id));
617 let background = spawn_session_background(&runtime, &session).unwrap();
618 let sock = std::path::PathBuf::from(&background.endpoint.registration.socket_path);
619 for _ in 0..100 {
620 if sock.exists() {
621 break;
622 }
623 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
624 }
625 assert!(card.exists() && sock.exists(), "registered and listening");
626 if explicit_shutdown {
627 background.shutdown();
628 }
629 drop(background);
630 assert!(
631 !card.exists(),
632 "card removed (shutdown: {explicit_shutdown})"
633 );
634 assert!(
635 !sock.exists(),
636 "socket removed (shutdown: {explicit_shutdown})"
637 );
638 }
639 }
640
641 #[cfg(unix)]
643 #[tokio::test]
644 #[serial_test::serial(synaps_base_dir)]
645 async fn starting_a_session_prunes_registrations_of_dead_processes() {
646 let _base = crate::test_env::BaseDirGuard::new();
647 let dir = crate::events::registry::registry_dir();
648 let dead = crate::events::registry::SessionRegistration {
649 kind: crate::events::registry::REGISTRATION_KIND.to_string(),
650 session_id: "20200101-000000-dead".into(),
651 name: None,
652 socket_path: crate::events::registry::socket_path_for_session("20200101-000000-dead"),
653 pid: 999_999,
654 started_at: chrono::Utc::now(),
655 };
656 crate::events::registry::register_session(&dead).unwrap();
657 let _stale = std::os::unix::net::UnixListener::bind(&dead.socket_path).unwrap();
658
659 let runtime = Runtime::new_headless();
660 let session = Session::new("claude-sonnet-4-5", "low", None);
661 let background = spawn_session_background(&runtime, &session).unwrap();
662 assert!(!dir.join("20200101-000000-dead.json").exists());
663 assert!(!std::path::Path::new(&dead.socket_path).exists());
664 background.shutdown();
665 }
666
667 async fn saved_session(first: &str) -> Session {
668 let mut session = Session::new("claude-sonnet-4-5", "low", None);
669 session.api_messages = vec![std::sync::Arc::new(
670 serde_json::json!({"role": "user", "content": first}),
671 )];
672 session.save().await.unwrap();
673 session
674 }
675
676 #[tokio::test]
681 #[serial_test::serial(synaps_base_dir)]
682 async fn loading_never_folds_or_touches_the_draft() {
683 use agent_core::core::session_draft::{read_turn_draft, write_turn_draft, TurnDraft};
684 let _base = crate::test_env::BaseDirGuard::new();
685 let dir = agent_core::session_lock::sessions_dir();
686 let session = saved_session("do X").await;
687 let draft = TurnDraft {
688 base_len: 1,
689 partial_text: "partial".into(),
690 };
691 write_turn_draft(&dir, &session.id, &draft).unwrap();
692
693 let mut runtime = Runtime::new_headless();
694 let sb = resolve_or_create_session(&mut runtime, &Some(Some(session.id.clone()))).unwrap();
695 assert_eq!(sb.api_messages.len(), 1, "history exactly as saved");
696 assert_eq!(
697 read_turn_draft(&dir, &session.id).unwrap(),
698 Some(draft),
699 "the draft is left for the lock holder"
700 );
701 }
702
703 #[tokio::test]
705 #[serial_test::serial(synaps_base_dir)]
706 async fn the_lock_holder_recovers_saves_then_removes_the_draft() {
707 use agent_core::core::session_draft::{read_turn_draft, write_turn_draft, TurnDraft};
708 let _base = crate::test_env::BaseDirGuard::new();
709 let dir = agent_core::session_lock::sessions_dir();
710 let session = saved_session("do X").await;
711 let id = session.id.clone();
712 write_turn_draft(
713 &dir,
714 &id,
715 &TurnDraft {
716 base_len: 1,
717 partial_text: "partial".into(),
718 },
719 )
720 .unwrap();
721
722 let mut conv = crate::engine::session::ConversationState::from_resumed(session);
723 recover_turn_draft(&mut conv).await;
724 assert_eq!(conv.api_messages.len(), 3, "partial reply + marker");
725 let on_disk = Session::load(&id).unwrap();
726 assert_eq!(on_disk.api_messages, conv.api_messages, "saved before removal");
727 assert_eq!(read_turn_draft(&dir, &id).unwrap(), None, "draft removed");
728
729 recover_turn_draft(&mut conv).await;
731 assert_eq!(conv.api_messages.len(), 3);
732 }
733
734 #[tokio::test]
737 #[serial_test::serial(synaps_base_dir)]
738 async fn an_unreadable_draft_is_removed_and_history_kept() {
739 let _base = crate::test_env::BaseDirGuard::new();
740 let dir = agent_core::session_lock::sessions_dir();
741 let session = saved_session("do X").await;
742 let path = dir.join(format!("{}.turn", session.id));
743 std::fs::write(&path, b"{not json").unwrap();
744 let mut conv = crate::engine::session::ConversationState::from_resumed(session);
745 recover_turn_draft(&mut conv).await;
746 assert_eq!(conv.api_messages.len(), 1);
747 assert!(!path.exists());
748 }
749
750 #[cfg(unix)]
753 #[tokio::test]
754 #[serial_test::serial(synaps_base_dir)]
755 async fn a_failed_recovery_save_keeps_the_draft() {
756 use agent_core::core::session_draft::{read_turn_draft, write_turn_draft, TurnDraft};
757 let _base = crate::test_env::BaseDirGuard::new();
758 let dir = agent_core::session_lock::sessions_dir();
759 let session = saved_session("do X").await;
760 let id = session.id.clone();
761 let draft = TurnDraft {
762 base_len: 1,
763 partial_text: "partial".into(),
764 };
765 write_turn_draft(&dir, &id, &draft).unwrap();
766 let snapshot = dir.join(format!("{id}.json"));
769 std::fs::remove_file(&snapshot).unwrap();
770 std::fs::create_dir(&snapshot).unwrap();
771 std::fs::write(snapshot.join("occupied"), b"x").unwrap();
772
773 let mut conv = crate::engine::session::ConversationState::from_resumed(session);
774 recover_turn_draft(&mut conv).await;
775 assert_eq!(read_turn_draft(&dir, &id).unwrap(), Some(draft), "draft kept");
776 }
777
778 #[tokio::test]
781 #[serial_test::serial(synaps_base_dir)]
782 async fn boot_hosts_refuse_to_continue_a_session_held_elsewhere() {
783 let _base = crate::test_env::BaseDirGuard::new();
784 let held = lock_session("20260930-000000-held", true, "tui").unwrap();
785 assert!(held.is_some());
786 let err = lock_session("20260930-000000-held", true, "rpc").unwrap_err();
787 assert!(err.to_string().contains("20260930-000000-held"), "{err}");
788 assert!(lock_session("20260930-000000-held", false, "rpc").unwrap().is_none());
790 drop(held);
791 assert!(lock_session("20260930-000000-held", true, "rpc").unwrap().is_some());
792
793 let mut old = Session::new("claude-sonnet-4-5", "low", None);
795 old.api_messages = vec![std::sync::Arc::new(
796 serde_json::json!({"role": "user", "content": "before compaction"}),
797 )];
798 old.compacted_into = Some("20260930-000001-next".into());
799 old.save().await.unwrap();
800 let err = lock_session(&old.id, true, "rpc").unwrap_err();
801 assert!(err.to_string().contains("20260930-000001-next"), "{err}");
802 }
803
804 #[test]
807 fn continue_path_restores_thinking_level_from_session() {
808 let mut runtime = Runtime::new_headless();
809
810 let thinking_level_str = "ultra";
812 if let Some(level) = ReasoningLevel::parse(thinking_level_str) {
813 runtime.set_reasoning_level_explicit(level);
814 }
815
816 assert_eq!(
817 runtime.reasoning_level(),
818 ReasoningLevel::Ultra,
819 "thinking level must be restored from session on --continue"
820 );
821 assert!(
822 runtime.is_reasoning_explicit(),
823 "restored thinking level must be marked explicit so set_model won't overwrite it"
824 );
825 }
826
827 #[test]
828 fn continue_path_restores_max_level() {
829 let mut runtime = Runtime::new_headless();
830 let thinking_level_str = "max";
831 if let Some(level) = ReasoningLevel::parse(thinking_level_str) {
832 runtime.set_reasoning_level_explicit(level);
833 }
834 assert_eq!(runtime.reasoning_level(), ReasoningLevel::Max);
835 }
836
837 #[test]
838 fn continue_path_restores_off_level() {
839 let mut runtime = Runtime::new_headless();
840 let thinking_level_str = "off";
841 if let Some(level) = ReasoningLevel::parse(thinking_level_str) {
842 runtime.set_reasoning_level_explicit(level);
843 }
844 assert_eq!(runtime.reasoning_level(), ReasoningLevel::Off);
845 }
846
847 #[test]
850 fn default_config_is_dark_no_checkpoint_tool_legacy_binding() {
851 let runtime = Runtime::new_headless();
852 assert!(
854 !runtime.memory_backend_exclusive(),
855 "default config must NOT be exclusive (Axel); it must be legacy"
856 );
857 let tools = runtime.tools_shared();
859 let registry = tools.blocking_read();
860 let has_checkpoint = registry
861 .iter_tools_sorted()
862 .iter()
863 .any(|t| t.name() == "context_checkpoint");
864 assert!(
865 !has_checkpoint,
866 "context_checkpoint must not appear in the default tool set"
867 );
868 }
869}