Skip to main content

agent_engine/engine/
setup.rs

1//! Engine setup — boot sequence shared by TUI and headless modes.
2//!
3//! Extracts the initialization logic that was previously inlined in
4//! chatui/mod.rs so both renderers can use the same boot path.
5
6use 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
12/// Options for engine boot.
13pub struct EngineOpts {
14    pub continue_session: Option<Option<String>>,
15    pub system: Option<String>,
16    pub prompt_manifest: Option<std::path::PathBuf>,
17    /// Honoured by the first `boot` in a process only: the `EngineHost` is
18    /// built once and later boots reuse it, profile included.
19    pub profile: Option<String>,
20    pub no_extensions: bool,
21}
22
23/// Background tasks spawned during boot. Aborts on drop.
24pub struct BackgroundTasks {
25    watcher_shutdown: Arc<std::sync::atomic::AtomicBool>,
26    watcher_task: tokio::task::JoinHandle<()>,
27    /// Whether the inbox watcher may take shared-inbox files now. A daemon
28    /// session holds it open only while a client owns its input; living here,
29    /// it survives park/unpark with the watcher it gates.
30    inbox_gate: Arc<std::sync::atomic::AtomicBool>,
31    endpoint: crate::events::registry::SessionEndpoint,
32    session_id: String,
33    /// Hook bus the session's `on_session_start` injection lives on; cleared
34    /// at shutdown so a long-lived process does not accumulate stale keys.
35    hook_bus: Arc<crate::extensions::hooks::HookBus>,
36    /// File-appender flush guard. Holding this for the lifetime of the
37    /// renderer keeps the non-blocking log writer's background thread
38    /// alive — without it, log lines emitted after `boot()` returns can
39    /// be silently dropped before they reach disk. Dropped last when
40    /// BackgroundTasks drops.
41    #[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    /// Open or close the inbox watcher's gate (see [`spawn_session_background_gated`]).
54    pub(crate) fn set_inbox_accepting(&self, accepting: bool) {
55        self.inbox_gate
56            .store(accepting, std::sync::atomic::Ordering::Release);
57    }
58
59    /// Signal all tasks to stop and unregister the session.
60    pub fn shutdown(&self) {
61        self.watcher_shutdown
62            .store(true, std::sync::atomic::Ordering::Release);
63        self.endpoint.shutdown();
64        // Cleanup only — fail-soft when no tokio runtime is current.
65        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
84/// Result of the boot sequence — everything a renderer needs to start.
85pub struct EngineBoot {
86    pub runtime: Runtime,
87    pub config: crate::SynapsConfig,
88    /// Echo of EngineOpts.no_extensions — callers gate extension discovery
89    /// on this so the flag has one source of truth.
90    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    /// Keybind registry. Uses std::sync::RwLock (not tokio) because keybind
100    /// lookups are synchronous, fast, and called from input handling code
101    /// that cannot await. This is safe as long as the lock is never held
102    /// across an await point.
103    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    /// Background tasks — inbox watcher, socket listener. Aborts on drop.
108    pub background: BackgroundTasks,
109}
110
111/// Info about how a continued session was resolved.
112pub struct ContinueInfo {
113    pub session_id: String,
114    pub resolved_via: Option<String>, // "chain", "name", "compacted", or None
115    pub query: String,
116    /// F24: set when the requested session was compacted into a successor.
117    pub compaction_notice: Option<String>,
118}
119
120/// Run the full engine boot sequence:
121/// config → system prompt → skills → MCP → session → sockets → extensions
122pub async fn boot(opts: EngineOpts) -> Result<EngineBoot> {
123    // Process-global parts (profile, logging, HTTP client, registry, skills,
124    // MCP, extension manager) are built ONCE per process by `EngineHost`
125    // and reused by every later boot in the same process. The log-appender
126    // guard lives on the host — process lifetime ≥ renderer lifetime — so
127    // log lines emitted after boot() returns are never silently dropped.
128    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    // `apply_config` is applied inside `foreground_runtime()` — before
135    // session resolution, same relative order as before.
136    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    // Skills, command registry, MCP setup and the MCP lease manager are
147    // host-owned (see `EngineHost::boot`); the runtime already holds them.
148    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    // Session was resolved before policy compilation so its model is the immutable
155    // foreground identity used by worker inheritance and authorization.
156
157    let background = spawn_session_background(&runtime, &sb.session)?;
158
159    finish_session_setup(&mut runtime, &config, &sb.session, None, IndexRecord::Start);
160
161    // Extension manager: host-owned.
162    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
191/// Session resolution + prompt/orchestration install (code motion from
192/// `boot()`; called per session by `SessionActor::create` too).
193pub(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    // Resolve the final foreground route before compiling immutable delegation
200    // policy. Continuing a session may replace the configured model.
201    let sb = resolve_or_create_session(runtime, continue_session)?;
202    runtime.set_session_id(Some(sb.session.id.clone()));
203    // merge(112): seed continuation state from the resolved session so a
204    // --continue resume picks up the context-continuation high-water mark.
205    runtime.reset_context_continuation(&sb.session.id, &sb.api_messages);
206
207    // Validate and compile an opted-in manifest before any session/network work.
208    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            // The manifest's policy owns concurrency and totals; nesting
232            // depth is not part of a manifest, so the user's limit applies.
233            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, &registry, &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
283/// Inbox watcher + per-session UDS listener + registry registration (code
284/// motion from `boot()`). Fails loudly when registration fails. The inbox
285/// gate starts open: an in-process session always has its user at hand.
286pub(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
297/// [`spawn_session_background`] whose inbox watcher takes shared-inbox files
298/// only while `inbox_gate` is true. The per-session socket is not gated:
299/// events addressed to this session always arrive.
300pub(crate) fn spawn_session_background_gated(
301    runtime: &Runtime,
302    session: &Session,
303    inbox_gate: Arc<std::sync::atomic::AtomicBool>,
304) -> Result<BackgroundTasks> {
305    // Start inbox watcher
306    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    // Helper: abort background tasks on error
318    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        // The appender guard lives on the `EngineHost` now.
344        log_guard: None,
345    })
346}
347
348/// Whether `finish_session_setup` appends the session START index record.
349/// `Skip` on unpark (B3) / reload rehydrate (C3): the session already has one.
350#[derive(Clone, Copy, Debug, PartialEq, Eq)]
351pub(crate) enum IndexRecord {
352    Start,
353    Skip,
354}
355
356/// Foreground turn budget + session start index record (code motion from
357/// `boot()`). `cwd` = the session's configured cwd (`None` → process cwd).
358pub(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    // Task 23: the engine's interactive session runs under the FOREGROUND
366    // turn budget with typed per-role config overrides applied.
367    runtime.set_turn_budget(crate::runtime::budget::TurnBudget::from_config(
368        crate::runtime::budget::TurnRole::Foreground,
369        &config.turn_budgets,
370    ));
371
372    // Session start index record.
373    //
374    // The `on_session_start` HOOK is deliberately NOT emitted here. Extensions
375    // are loaded by the host after boot returns (see
376    // `extensions::loader::spawn_discover_and_load`), so emitting at this
377    // point delivered the event to an empty bus in every host — the hook had
378    // never once reached an extension. It is now emitted by the loader, after
379    // subscribers exist.
380    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
393/// Result of session resolution.
394pub(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
404/// The session lock for a `boot()` host that drives `Runtime` itself
405/// (`synaps rpc`). Continuing a session that another process (a TUI, the
406/// daemon, another rpc) holds is REFUSED: two writers would fork its history
407/// and the older one's saves would overwrite the newer's. So is continuing
408/// a session already compacted into a successor (its history is closed). A
409/// fresh session, or a lock that cannot be taken for another reason (e.g. a
410/// read-only sessions dir), proceeds best-effort, as the session actor does.
411/// The lock is held for the returned value's lifetime; a holder that
412/// continued a session runs crash recovery (`recover_turn_draft`).
413pub 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
436/// Crash recovery: if `sessions/<id>.turn` exists, the process that last ran
437/// this session died with a turn open. Fold the draft into the history
438/// (`engine::interrupt::recover_crashed_turn`), persist that, THEN remove
439/// the draft.
440///
441/// Run ONLY by the holder of the session lock, after taking it (actor
442/// create, unpark and `/resume`; rpc after `lock_session`): a draft under a lock held elsewhere belongs to a turn
443/// that is running right now. Legacy chat takes no lock and never recovers.
444/// Loading itself (`resolve_or_create_session`) leaves the draft alone.
445///
446/// The draft is removed only once the recovered history is on disk; if that
447/// save fails the draft stays, and the next holder retries (recovery is
448/// idempotent through its own marker).
449pub 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            // Unreadable draft: never block the load. The history stays
468            // exactly as saved; the draft is removed below.
469            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        // The turn had concluded (or the draft was unreadable): the saved
487        // history is already right, only the draft's removal was lost.
488        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            // F24: follow compacted_into forward so --continue <old> lands
517            // on the final successor, never on a pre-compaction fork point.
518            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            // Restore the session's named reasoning level so max/ultra/off
524            // and custom budgets survive --continue — then clamp against the
525            // model so old grok+xhigh session files don't resume into a
526            // permanently failing state.
527            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                // Always produce ContinueInfo when we have a compaction notice,
564                // even for bare --continue (no explicit id).
565                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            // Sessions saved before the interruption marker existed carry a
578            // recap in `abort_context`, meant to be prepended to the next user
579            // message. Migrate here, for actor create, unpark and `boot()`
580            // (`/resume` migrates in `ConversationState::from_resumed`), and
581            // before the continuation seed, so everything downstream sees the
582            // migrated history. The recap is dropped; the marker is appended
583            // (append-only: the cached prefix is untouched).
584            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            // A turn draft (`sessions/<id>.turn`) is NOT folded in here:
594            // loading runs before (or without) the session lock. The lock
595            // holder does it (`recover_turn_draft`).
596
597            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    /// A session's registry card and socket must not outlive it, even when
632    /// it ends without `shutdown()` (S348: 470 leftovers in `run/`), and
633    /// `shutdown()` followed by drop stays clean.
634    #[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    /// Starting a session drops registrations whose owner process is gone.
669    #[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    /// Loading runs before (or without) the session lock, so it must never
704    /// fold a turn draft in: `rpc --continue` on a session whose turn is
705    /// running in another process would otherwise record a crash that never
706    /// happened. (Review finding: `boot()` callers never lock.)
707    #[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    /// The lock holder folds the draft in, saves, THEN removes the draft.
731    #[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        // No draft: nothing to do.
757        recover_turn_draft(&mut conv).await;
758        assert_eq!(conv.api_messages.len(), 3);
759    }
760
761    /// An unreadable draft never blocks the load: history untouched, draft
762    /// removed.
763    #[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    /// If the recovered history cannot be saved, the draft stays: removing
778    /// it would lose the only record that the turn was cut off.
779    #[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        // Make the snapshot unwritable: a non-empty directory where the
794        // `<id>.json` file goes (the atomic rename onto it fails).
795        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    /// A `boot()` host may not continue a session another holder has; a
806    /// fresh one is locked (and the lock is released on drop).
807    #[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        // Not continuing (a fresh id) never refuses.
816        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        // A session compacted into a successor is closed: never continued.
821        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    /// B1: --continue path must restore thinking_level from the saved session.
832    /// Simulates what resolve_or_create_session does when a session is continued.
833    #[test]
834    fn continue_path_restores_thinking_level_from_session() {
835        let mut runtime = Runtime::new_headless();
836
837        // Simulate what resolve_or_create_session does on --continue.
838        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    /// DARK test: default config is offered no context_checkpoint, and no
875    /// built-in forum tool exists.
876    /// `context_checkpoint` is in the registry, which every chat on a host
877    /// shares; what a chat is offered is its own.
878    #[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}