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    socket_shutdown: Arc<std::sync::atomic::AtomicBool>,
28    socket_task: tokio::task::JoinHandle<()>,
29    #[allow(dead_code)] // stored for potential future use (e.g. reconnect)
30    session_socket_path: String,
31    session_id: String,
32    /// Hook bus the session's `on_session_start` injection lives on; cleared
33    /// at shutdown so a long-lived process does not accumulate stale keys.
34    hook_bus: Arc<crate::extensions::hooks::HookBus>,
35    /// File-appender flush guard. Holding this for the lifetime of the
36    /// renderer keeps the non-blocking log writer's background thread
37    /// alive — without it, log lines emitted after `boot()` returns can
38    /// be silently dropped before they reach disk. Dropped last when
39    /// BackgroundTasks drops.
40    #[allow(dead_code)]
41    log_guard: Option<tracing_appender::non_blocking::WorkerGuard>,
42}
43
44impl BackgroundTasks {
45    /// Signal all tasks to stop and unregister the session.
46    pub fn shutdown(&self) {
47        self.watcher_shutdown
48            .store(true, std::sync::atomic::Ordering::Release);
49        self.socket_shutdown
50            .store(true, std::sync::atomic::Ordering::Release);
51        crate::events::registry::unregister_session(&self.session_id);
52        // Cleanup only — fail-soft when no tokio runtime is current.
53        if let Ok(handle) = tokio::runtime::Handle::try_current() {
54            let hook_bus = Arc::clone(&self.hook_bus);
55            let session_id = self.session_id.clone();
56            handle.spawn(async move {
57                hook_bus.clear_session_injection(&session_id).await;
58            });
59        }
60    }
61}
62
63impl Drop for BackgroundTasks {
64    fn drop(&mut self) {
65        self.watcher_shutdown
66            .store(true, std::sync::atomic::Ordering::Relaxed);
67        self.socket_shutdown
68            .store(true, std::sync::atomic::Ordering::Relaxed);
69        self.watcher_task.abort();
70        self.socket_task.abort();
71    }
72}
73
74/// Result of the boot sequence — everything a renderer needs to start.
75pub struct EngineBoot {
76    pub runtime: Runtime,
77    pub config: crate::SynapsConfig,
78    /// Echo of EngineOpts.no_extensions — callers gate extension discovery
79    /// on this so the flag has one source of truth.
80    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    /// Keybind registry. Uses std::sync::RwLock (not tokio) because keybind
90    /// lookups are synchronous, fast, and called from input handling code
91    /// that cannot await. This is safe as long as the lock is never held
92    /// across an await point.
93    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    /// Background tasks — inbox watcher, socket listener. Aborts on drop.
98    pub background: BackgroundTasks,
99}
100
101/// Info about how a continued session was resolved.
102pub struct ContinueInfo {
103    pub session_id: String,
104    pub resolved_via: Option<String>, // "chain", "name", "compacted", or None
105    pub query: String,
106    /// F24: set when the requested session was compacted into a successor.
107    pub compaction_notice: Option<String>,
108}
109
110/// Run the full engine boot sequence:
111/// config → system prompt → skills → MCP → session → sockets → extensions
112pub async fn boot(opts: EngineOpts) -> Result<EngineBoot> {
113    // Process-global parts (profile, logging, HTTP client, registry, skills,
114    // MCP, extension manager) are built ONCE per process by `EngineHost`
115    // and reused by every later boot in the same process. The log-appender
116    // guard lives on the host — process lifetime ≥ renderer lifetime — so
117    // log lines emitted after boot() returns are never silently dropped.
118    let host = EngineHost::boot_and_install(HostOpts {
119        profile: opts.profile.clone(),
120        no_extensions: opts.no_extensions,
121    })
122    .await?;
123    // `apply_config` is applied inside `foreground_runtime()` — before
124    // session resolution, same relative order as before.
125    let mut runtime = host.foreground_runtime().await?;
126    let config: crate::SynapsConfig = (**host.config()).clone();
127
128    let sb = resolve_session_and_prompt(
129        &mut runtime,
130        &opts.continue_session,
131        opts.system.as_deref(),
132        opts.prompt_manifest.as_deref(),
133    )?;
134
135    // Skills, command registry, MCP setup and the MCP lease manager are
136    // host-owned (see `EngineHost::boot`); the runtime already holds them.
137    let registry = Arc::clone(host.command_registry());
138    let keybind_registry = Arc::clone(host.keybind_registry());
139    let mcp_server_count = host.mcp_server_count();
140
141    let system_prompt_path = crate::config::resolve_read_path("system.md");
142
143    // Session was resolved before policy compilation so its model is the immutable
144    // foreground identity used by worker inheritance and authorization.
145
146    let background = spawn_session_background(&runtime, &sb.session)?;
147
148    finish_session_setup(&mut runtime, &config, &sb.session, None, IndexRecord::Start);
149
150    // Extension manager: host-owned.
151    let ext_manager = Arc::clone(host.ext_manager());
152
153    if mcp_server_count > 0 {
154        tracing::info!(
155            "{} MCP servers available (use connect_mcp_server to activate)",
156            mcp_server_count
157        );
158    }
159
160    Ok(EngineBoot {
161        runtime,
162        config,
163        no_extensions: opts.no_extensions,
164        session: sb.session,
165        api_messages: sb.api_messages,
166        total_input_tokens: sb.total_input_tokens,
167        total_output_tokens: sb.total_output_tokens,
168        session_cost: sb.session_cost,
169        continued: sb.continued,
170        continue_info: sb.continue_info,
171        registry,
172        keybind_registry,
173        mcp_server_count,
174        system_prompt_path,
175        ext_manager,
176        background,
177    })
178}
179
180/// Session resolution + prompt/orchestration install (code motion from
181/// `boot()`; called per session by `SessionActor::create` too).
182pub(crate) fn resolve_session_and_prompt(
183    runtime: &mut Runtime,
184    continue_session: &Option<Option<String>>,
185    system: Option<&str>,
186    prompt_manifest: Option<&std::path::Path>,
187) -> Result<SessionBootResult> {
188    // Resolve the final foreground route before compiling immutable delegation
189    // policy. Continuing a session may replace the configured model.
190    let sb = resolve_or_create_session(runtime, continue_session)?;
191    runtime.set_session_id(Some(sb.session.id.clone()));
192    // merge(112): seed continuation state from the resolved session so a
193    // --continue resume picks up the context-continuation high-water mark.
194    runtime.reset_context_continuation(&sb.session.id, &sb.api_messages);
195
196    // Validate and compile an opted-in manifest before any session/network work.
197    let legacy_prompt = crate::config::resolve_system_prompt(system);
198    if let Some(path) = prompt_manifest {
199        let raw = std::fs::read_to_string(path)
200            .map_err(|_| crate::RuntimeError::Config("prompt manifest is unavailable".into()))?;
201        let manifest = agent_core::prompt::PromptManifest::parse(&raw)
202            .map_err(|e| crate::RuntimeError::Config(format!("invalid prompt manifest: {e}")))?;
203        let registry = manifest
204            .registry(path.parent())
205            .map_err(|e| crate::RuntimeError::Config(format!("invalid prompt manifest: {e}")))?;
206        let model = crate::orchestration::canonical_foreground_identity(runtime.model())
207            .map_err(|e| crate::RuntimeError::Config(format!("invalid foreground model: {e}")))?;
208        let context = agent_core::prompt::SelectionContext::new(model.clone(), None)
209            .map_err(|e| crate::RuntimeError::Config(e.to_string()))?;
210        let catalog = crate::orchestration::OrchestrationRuntime::trusted_catalog(
211            &model,
212            manifest.delegation_catalog_candidates(),
213        )
214        .map_err(|error| crate::RuntimeError::Config(error.into()))?;
215        let delegation_policy = manifest
216            .delegation_policy(model.clone(), &catalog)
217            .map_err(|e| crate::RuntimeError::Config(format!("invalid prompt manifest: {e}")))?;
218        let delegation_policy_digest = delegation_policy.as_ref().map(|policy| policy.digest());
219        if let Some(policy) = delegation_policy {
220            runtime.install_orchestration(Arc::new(
221                crate::orchestration::OrchestrationRuntime::new(policy),
222            ));
223        } else {
224            runtime.install_orchestration(Arc::new(
225                crate::orchestration::OrchestrationRuntime::baseline(model.clone(), 8, 64)
226                    .map_err(|error| crate::RuntimeError::Config(error.into()))?,
227            ));
228        }
229        let user = system
230            .map(|_| {
231                agent_core::prompt::resolved_system_prompt_as_user_module(legacy_prompt.clone())
232            })
233            .transpose()
234            .map_err(|e| crate::RuntimeError::Config(e.to_string()))?;
235        let stack =
236            agent_core::prompt::compile_prompt_stack(&manifest, &registry, &context, user.clone())
237                .map_err(|e| {
238                    crate::RuntimeError::Config(format!("invalid prompt manifest: {e}"))
239                })?;
240        runtime
241            .apply_prompt_stack(stack)
242            .map_err(|e| crate::RuntimeError::Config(format!("invalid prompt manifest: {e}")))?;
243        runtime.retain_prompt_reload_source(
244            path.to_path_buf(),
245            context,
246            user,
247            delegation_policy_digest,
248        );
249    } else {
250        let foreground = crate::orchestration::canonical_foreground_identity(runtime.model())
251            .map_err(|e| crate::RuntimeError::Config(format!("invalid foreground model: {e}")))?;
252        runtime.install_orchestration(Arc::new(
253            crate::orchestration::OrchestrationRuntime::baseline(foreground, 8, 64)
254                .map_err(|error| crate::RuntimeError::Config(error.into()))?,
255        ));
256        runtime.set_system_prompt(legacy_prompt);
257    }
258
259    Ok(sb)
260}
261
262/// Inbox watcher + per-session UDS listener + registry registration (code
263/// motion from `boot()`). Fails loudly when registration fails.
264pub(crate) fn spawn_session_background(
265    runtime: &Runtime,
266    session: &Session,
267) -> Result<BackgroundTasks> {
268    // Start inbox watcher
269    let watcher_shutdown = Arc::new(std::sync::atomic::AtomicBool::new(false));
270    let watcher_task = {
271        let inbox_dir = crate::config::base_dir().join("inbox");
272        let event_queue = runtime.event_queue().clone();
273        let shutdown = watcher_shutdown.clone();
274        tokio::spawn(async move {
275            crate::events::watch_inbox(inbox_dir, event_queue, shutdown).await;
276        })
277    };
278
279    // Helper: abort background tasks on error
280    let abort_tasks = |ws: &Arc<std::sync::atomic::AtomicBool>,
281                       wt: &tokio::task::JoinHandle<()>| {
282        ws.store(true, std::sync::atomic::Ordering::Relaxed);
283        wt.abort();
284    };
285
286    // Start per-session Unix socket listener + register in session registry
287    let socket_shutdown = Arc::new(std::sync::atomic::AtomicBool::new(false));
288    let session_socket_path = crate::events::registry::socket_path_for_session(&session.id);
289    let socket_task = crate::events::socket::listen_session_socket(
290        session_socket_path.clone(),
291        runtime.event_queue().clone(),
292        socket_shutdown.clone(),
293    );
294    let session_registration = crate::events::registry::SessionRegistration {
295        kind: crate::events::registry::REGISTRATION_KIND.to_string(),
296        session_id: session.id.clone(),
297        name: session.name.clone(),
298        socket_path: session_socket_path.clone(),
299        pid: std::process::id(),
300        started_at: chrono::Utc::now(),
301    };
302    if let Err(e) = crate::events::registry::register_session(&session_registration) {
303        abort_tasks(&watcher_shutdown, &watcher_task);
304        socket_shutdown.store(true, std::sync::atomic::Ordering::Relaxed);
305        socket_task.abort();
306        // Fail loudly: returning Ok with already-aborted handles silently
307        // poisoned downstream — server inherited dead watcher/socket tasks
308        // and a session that wasn't in the registry, so other tools couldn't
309        // see it. Better to fail boot than start in a broken state.
310        return Err(crate::core::error::RuntimeError::Session(format!(
311            "failed to register session {}: {}",
312            session_registration.session_id, e
313        )));
314    }
315
316    Ok(BackgroundTasks {
317        watcher_shutdown,
318        watcher_task,
319        socket_shutdown,
320        socket_task,
321        session_socket_path,
322        session_id: session.id.clone(),
323        hook_bus: Arc::clone(runtime.hook_bus()),
324        // The appender guard lives on the `EngineHost` now.
325        log_guard: None,
326    })
327}
328
329/// Whether `finish_session_setup` appends the session START index record.
330/// `Skip` on unpark (B3) / reload rehydrate (C3): the session already has one.
331#[derive(Clone, Copy, Debug, PartialEq, Eq)]
332pub(crate) enum IndexRecord {
333    Start,
334    Skip,
335}
336
337/// Foreground turn budget + session start index record (code motion from
338/// `boot()`). `cwd` = the session's configured cwd (`None` → process cwd).
339pub(crate) fn finish_session_setup(
340    runtime: &mut Runtime,
341    config: &crate::SynapsConfig,
342    session: &Session,
343    cwd: Option<std::path::PathBuf>,
344    index_record: IndexRecord,
345) {
346    // Task 23: the engine's interactive session runs under the FOREGROUND
347    // turn budget with typed per-role config overrides applied.
348    runtime.set_turn_budget(crate::runtime::budget::TurnBudget::from_config(
349        crate::runtime::budget::TurnRole::Foreground,
350        &config.turn_budgets,
351    ));
352
353    // Session start index record.
354    //
355    // The `on_session_start` HOOK is deliberately NOT emitted here. Extensions
356    // are loaded by the host after boot returns (see
357    // `extensions::loader::spawn_discover_and_load`), so emitting at this
358    // point delivered the event to an empty bus in every host — the hook had
359    // never once reached an extension. It is now emitted by the loader, after
360    // subscribers exist.
361    if index_record == IndexRecord::Start {
362        let mut index_record =
363            crate::core::session_index::SessionIndexRecord::start(&session.id);
364        index_record.model = Some(session.model.clone());
365        index_record.profile = crate::core::config::get_profile();
366        index_record.cwd = cwd.or_else(|| std::env::current_dir().ok());
367        if let Err(err) = crate::core::session_index::append_record(&index_record) {
368            tracing::warn!("failed to append session start index record: {}", err);
369        }
370    }
371
372}
373
374/// Result of session resolution.
375pub(crate) struct SessionBootResult {
376    pub(crate) session: Session,
377    pub(crate) api_messages: Vec<crate::SharedMessage>,
378    pub(crate) total_input_tokens: u64,
379    pub(crate) total_output_tokens: u64,
380    pub(crate) session_cost: f64,
381    pub(crate) continued: bool,
382    pub(crate) continue_info: Option<ContinueInfo>,
383}
384
385/// The session lock for a `boot()` host that drives `Runtime` itself (rpc,
386/// `synaps server`). Continuing a session that another process (a TUI, the
387/// daemon, another rpc) holds is REFUSED: two writers would fork its history
388/// and the older one's saves would overwrite the newer's. So is continuing
389/// a session already compacted into a successor (its history is closed). A
390/// fresh session, or a lock that cannot be taken for another reason (e.g. a
391/// read-only sessions dir), proceeds best-effort, as the session actor does.
392/// The lock is held for the returned value's lifetime; a holder that
393/// continued a session runs crash recovery (`recover_turn_draft`).
394pub fn lock_session(
395    id: &str,
396    continued: bool,
397    kind: &str,
398) -> Result<Option<agent_core::session_lock::SessionLock>> {
399    let dir = agent_core::session_lock::sessions_dir();
400    let holder = agent_core::session_lock::LockHolder {
401        pid: std::process::id(),
402        kind: kind.to_string(),
403    };
404    match agent_core::session_lock::SessionLock::try_acquire(&dir, id, holder) {
405        Ok(lock) => Ok(Some(lock)),
406        Err(
407            e @ (agent_core::session_lock::SessionLockError::Held { .. }
408            | agent_core::session_lock::SessionLockError::CompactedInto { .. }),
409        ) if continued => Err(crate::RuntimeError::Session(e.to_string())),
410        Err(e) => {
411            tracing::warn!(session = %id, "session lock: {e}");
412            Ok(None)
413        }
414    }
415}
416
417/// Crash recovery: if `sessions/<id>.turn` exists, the process that last ran
418/// this session died with a turn open. Fold the draft into the history
419/// (`engine::interrupt::recover_crashed_turn`), persist that, THEN remove
420/// the draft.
421///
422/// Run ONLY by the holder of the session lock, after taking it (actor
423/// create, unpark and `/resume`; rpc and `synaps server` after
424/// `lock_session`): a draft under a lock held elsewhere belongs to a turn
425/// that is running right now. Legacy chat takes no lock and never recovers.
426/// Loading itself (`resolve_or_create_session`) leaves the draft alone.
427///
428/// The draft is removed only once the recovered history is on disk; if that
429/// save fails the draft stays, and the next holder retries (recovery is
430/// idempotent through its own marker).
431pub async fn recover_turn_draft(conv: &mut crate::engine::session::ConversationState) {
432    let dir = agent_core::session_lock::sessions_dir();
433    let id = conv.session.id.clone();
434    let read = {
435        let (dir, id) = (dir.clone(), id.clone());
436        tokio::task::spawn_blocking(move || {
437            agent_core::core::session_draft::read_turn_draft(&dir, &id)
438        })
439        .await
440        .map_err(std::io::Error::other)
441        .and_then(|r| r)
442    };
443    let recovered = match read {
444        Ok(None) => return,
445        Ok(Some(draft)) => {
446            crate::engine::interrupt::recover_crashed_turn(&mut conv.api_messages, &draft)
447        }
448        Err(e) => {
449            // Unreadable draft: never block the load. The history stays
450            // exactly as saved; the draft is removed below.
451            tracing::warn!(session = %id, "unreadable turn draft, removing it: {e}");
452            false
453        }
454    };
455    if recovered {
456        tracing::warn!(
457            session = %id,
458            "session was interrupted mid-turn by an unexpected stop; recovered"
459        );
460        if !conv.save().await {
461            tracing::warn!(
462                session = %id,
463                "could not save the recovered history; keeping the turn draft"
464            );
465            return;
466        }
467    } else {
468        // The turn had concluded (or the draft was unreadable): the saved
469        // history is already right, only the draft's removal was lost.
470        tracing::info!(session = %id, "removing a stale turn draft");
471    }
472    let removed = tokio::task::spawn_blocking(move || {
473        agent_core::core::session_draft::remove_turn_draft(&dir, &id)
474    })
475    .await;
476    if let Ok(Err(e)) | Err(e) = removed.map_err(std::io::Error::other) {
477        tracing::warn!(session = %conv.session.id, "failed to remove turn draft: {e}");
478    }
479}
480
481fn resolve_or_create_session(
482    runtime: &mut Runtime,
483    continue_session: &Option<Option<String>>,
484) -> Result<SessionBootResult> {
485    match continue_session {
486        Some(ref maybe_id) => {
487            let raw_session = match maybe_id {
488                Some(ref id) => resolve_session(id).map_err(|e| {
489                    crate::error::RuntimeError::Tool(format!(
490                        "Failed to load session '{}': {}",
491                        id, e
492                    ))
493                })?,
494                None => latest_session().map_err(|e| {
495                    crate::error::RuntimeError::Tool(format!("No sessions to continue: {}", e))
496                })?,
497            };
498            // F24: follow compacted_into forward so --continue <old> lands
499            // on the final successor, never on a pre-compaction fork point.
500            let resolved = agent_core::session::follow_compaction_chain(raw_session)
501                .map_err(|e| crate::error::RuntimeError::Session(e.to_string()))?;
502            let compaction_notice = resolved.compaction_notice;
503            let mut session = resolved.session;
504            runtime.set_model(session.model.clone());
505            // Restore the session's named reasoning level so max/ultra/off
506            // and custom budgets survive --continue — then clamp against the
507            // model so old grok+xhigh session files don't resume into a
508            // permanently failing state.
509            if let Some(clamp) = runtime.restore_session_reasoning(&session.thinking_level) {
510                tracing::warn!(
511                    from = %clamp.from,
512                    to = %clamp.to,
513                    model = %session.model,
514                    "saved session thinking level not supported by model; clamped"
515                );
516                session.thinking_level = runtime.thinking_level().to_string();
517            }
518            if let Some(ref sp) = session.system_prompt {
519                runtime.set_system_prompt(sp.clone());
520            }
521
522            let continue_info = {
523                let (resolved_via, query) = match maybe_id {
524                    Some(ref q) => {
525                        let via = if *q != session.id {
526                            if crate::chain::load_chain(q).is_ok() {
527                                Some("chain".to_string())
528                            } else if agent_core::session::find_session_by_name(q).is_ok() {
529                                Some("name".to_string())
530                            } else if compaction_notice.is_some() {
531                                Some("compacted".to_string())
532                            } else {
533                                None
534                            }
535                        } else {
536                            None
537                        };
538                        (via, q.clone())
539                    }
540                    None => {
541                        let via = compaction_notice.as_ref().map(|_| "compacted".to_string());
542                        (via, session.id.clone())
543                    }
544                };
545                // Always produce ContinueInfo when we have a compaction notice,
546                // even for bare --continue (no explicit id).
547                if maybe_id.is_some() || compaction_notice.is_some() {
548                    Some(ContinueInfo {
549                        session_id: session.id.clone(),
550                        resolved_via,
551                        query,
552                        compaction_notice: compaction_notice.clone(),
553                    })
554                } else {
555                    None
556                }
557            };
558
559            // Sessions saved before the interruption marker existed carry a
560            // recap in `abort_context`, meant to be prepended to the next user
561            // message. Migrate here, for actor create, unpark and `boot()`
562            // (`/resume` migrates in `ConversationState::from_resumed`), and
563            // before the continuation seed, so everything downstream sees the
564            // migrated history. The recap is dropped; the marker is appended
565            // (append-only: the cached prefix is untouched).
566            if crate::engine::interrupt::migrate_legacy_abort_context(
567                &mut session.api_messages,
568                &mut session.abort_context,
569            ) {
570                tracing::info!(
571                    session = %session.id,
572                    "migrated a legacy abort-context recap to an interruption marker"
573                );
574            }
575            // A turn draft (`sessions/<id>.turn`) is NOT folded in here:
576            // loading runs before (or without) the session lock. The lock
577            // holder does it (`recover_turn_draft`).
578
579            Ok(SessionBootResult {
580                api_messages: session.api_messages.clone(),
581                total_input_tokens: session.total_input_tokens,
582                total_output_tokens: session.total_output_tokens,
583                session_cost: session.session_cost,
584                continued: true,
585                continue_info,
586                session,
587            })
588        }
589        None => {
590            let session = Session::new(
591                runtime.model(),
592                runtime.thinking_level(),
593                runtime.system_prompt(),
594            );
595            Ok(SessionBootResult {
596                session,
597                api_messages: Vec::new(),
598                total_input_tokens: 0,
599                total_output_tokens: 0,
600                session_cost: 0.0,
601                continued: false,
602                continue_info: None,
603            })
604        }
605    }
606}
607
608#[cfg(test)]
609mod tests {
610    use super::*;
611    use agent_core::reasoning::ReasoningLevel;
612
613    async fn saved_session(first: &str) -> Session {
614        let mut session = Session::new("claude-sonnet-4-5", "low", None);
615        session.api_messages = vec![std::sync::Arc::new(
616            serde_json::json!({"role": "user", "content": first}),
617        )];
618        session.save().await.unwrap();
619        session
620    }
621
622    /// Loading runs before (or without) the session lock, so it must never
623    /// fold a turn draft in: `rpc --continue` on a session whose turn is
624    /// running in another process would otherwise record a crash that never
625    /// happened. (Review finding: `boot()` callers never lock.)
626    #[tokio::test]
627    #[serial_test::serial(synaps_base_dir)]
628    async fn loading_never_folds_or_touches_the_draft() {
629        use agent_core::core::session_draft::{read_turn_draft, write_turn_draft, TurnDraft};
630        let _base = crate::test_env::BaseDirGuard::new();
631        let dir = agent_core::session_lock::sessions_dir();
632        let session = saved_session("do X").await;
633        let draft = TurnDraft {
634            base_len: 1,
635            partial_text: "partial".into(),
636        };
637        write_turn_draft(&dir, &session.id, &draft).unwrap();
638
639        let mut runtime = Runtime::new_headless();
640        let sb = resolve_or_create_session(&mut runtime, &Some(Some(session.id.clone()))).unwrap();
641        assert_eq!(sb.api_messages.len(), 1, "history exactly as saved");
642        assert_eq!(
643            read_turn_draft(&dir, &session.id).unwrap(),
644            Some(draft),
645            "the draft is left for the lock holder"
646        );
647    }
648
649    /// The lock holder folds the draft in, saves, THEN removes the draft.
650    #[tokio::test]
651    #[serial_test::serial(synaps_base_dir)]
652    async fn the_lock_holder_recovers_saves_then_removes_the_draft() {
653        use agent_core::core::session_draft::{read_turn_draft, write_turn_draft, TurnDraft};
654        let _base = crate::test_env::BaseDirGuard::new();
655        let dir = agent_core::session_lock::sessions_dir();
656        let session = saved_session("do X").await;
657        let id = session.id.clone();
658        write_turn_draft(
659            &dir,
660            &id,
661            &TurnDraft {
662                base_len: 1,
663                partial_text: "partial".into(),
664            },
665        )
666        .unwrap();
667
668        let mut conv = crate::engine::session::ConversationState::from_resumed(session);
669        recover_turn_draft(&mut conv).await;
670        assert_eq!(conv.api_messages.len(), 3, "partial reply + marker");
671        let on_disk = Session::load(&id).unwrap();
672        assert_eq!(on_disk.api_messages, conv.api_messages, "saved before removal");
673        assert_eq!(read_turn_draft(&dir, &id).unwrap(), None, "draft removed");
674
675        // No draft: nothing to do.
676        recover_turn_draft(&mut conv).await;
677        assert_eq!(conv.api_messages.len(), 3);
678    }
679
680    /// An unreadable draft never blocks the load: history untouched, draft
681    /// removed.
682    #[tokio::test]
683    #[serial_test::serial(synaps_base_dir)]
684    async fn an_unreadable_draft_is_removed_and_history_kept() {
685        let _base = crate::test_env::BaseDirGuard::new();
686        let dir = agent_core::session_lock::sessions_dir();
687        let session = saved_session("do X").await;
688        let path = dir.join(format!("{}.turn", session.id));
689        std::fs::write(&path, b"{not json").unwrap();
690        let mut conv = crate::engine::session::ConversationState::from_resumed(session);
691        recover_turn_draft(&mut conv).await;
692        assert_eq!(conv.api_messages.len(), 1);
693        assert!(!path.exists());
694    }
695
696    /// If the recovered history cannot be saved, the draft stays: removing
697    /// it would lose the only record that the turn was cut off.
698    #[cfg(unix)]
699    #[tokio::test]
700    #[serial_test::serial(synaps_base_dir)]
701    async fn a_failed_recovery_save_keeps_the_draft() {
702        use agent_core::core::session_draft::{read_turn_draft, write_turn_draft, TurnDraft};
703        let _base = crate::test_env::BaseDirGuard::new();
704        let dir = agent_core::session_lock::sessions_dir();
705        let session = saved_session("do X").await;
706        let id = session.id.clone();
707        let draft = TurnDraft {
708            base_len: 1,
709            partial_text: "partial".into(),
710        };
711        write_turn_draft(&dir, &id, &draft).unwrap();
712        // Make the snapshot unwritable: a non-empty directory where the
713        // `<id>.json` file goes (the atomic rename onto it fails).
714        let snapshot = dir.join(format!("{id}.json"));
715        std::fs::remove_file(&snapshot).unwrap();
716        std::fs::create_dir(&snapshot).unwrap();
717        std::fs::write(snapshot.join("occupied"), b"x").unwrap();
718
719        let mut conv = crate::engine::session::ConversationState::from_resumed(session);
720        recover_turn_draft(&mut conv).await;
721        assert_eq!(read_turn_draft(&dir, &id).unwrap(), Some(draft), "draft kept");
722    }
723
724    /// A `boot()` host may not continue a session another holder has; a
725    /// fresh one is locked (and the lock is released on drop).
726    #[tokio::test]
727    #[serial_test::serial(synaps_base_dir)]
728    async fn boot_hosts_refuse_to_continue_a_session_held_elsewhere() {
729        let _base = crate::test_env::BaseDirGuard::new();
730        let held = lock_session("20260930-000000-held", true, "tui").unwrap();
731        assert!(held.is_some());
732        let err = lock_session("20260930-000000-held", true, "rpc").unwrap_err();
733        assert!(err.to_string().contains("20260930-000000-held"), "{err}");
734        // Not continuing (a fresh id) never refuses.
735        assert!(lock_session("20260930-000000-held", false, "rpc").unwrap().is_none());
736        drop(held);
737        assert!(lock_session("20260930-000000-held", true, "rpc").unwrap().is_some());
738
739        // A session compacted into a successor is closed: never continued.
740        let mut old = Session::new("claude-sonnet-4-5", "low", None);
741        old.api_messages = vec![std::sync::Arc::new(
742            serde_json::json!({"role": "user", "content": "before compaction"}),
743        )];
744        old.compacted_into = Some("20260930-000001-next".into());
745        old.save().await.unwrap();
746        let err = lock_session(&old.id, true, "rpc").unwrap_err();
747        assert!(err.to_string().contains("20260930-000001-next"), "{err}");
748    }
749
750    /// B1: --continue path must restore thinking_level from the saved session.
751    /// Simulates what resolve_or_create_session does when a session is continued.
752    #[test]
753    fn continue_path_restores_thinking_level_from_session() {
754        let mut runtime = Runtime::new_headless();
755
756        // Simulate what resolve_or_create_session does on --continue.
757        let thinking_level_str = "ultra";
758        if let Some(level) = ReasoningLevel::parse(thinking_level_str) {
759            runtime.set_reasoning_level_explicit(level);
760        }
761
762        assert_eq!(
763            runtime.reasoning_level(),
764            ReasoningLevel::Ultra,
765            "thinking level must be restored from session on --continue"
766        );
767        assert!(
768            runtime.is_reasoning_explicit(),
769            "restored thinking level must be marked explicit so set_model won't overwrite it"
770        );
771    }
772
773    #[test]
774    fn continue_path_restores_max_level() {
775        let mut runtime = Runtime::new_headless();
776        let thinking_level_str = "max";
777        if let Some(level) = ReasoningLevel::parse(thinking_level_str) {
778            runtime.set_reasoning_level_explicit(level);
779        }
780        assert_eq!(runtime.reasoning_level(), ReasoningLevel::Max);
781    }
782
783    #[test]
784    fn continue_path_restores_off_level() {
785        let mut runtime = Runtime::new_headless();
786        let thinking_level_str = "off";
787        if let Some(level) = ReasoningLevel::parse(thinking_level_str) {
788            runtime.set_reasoning_level_explicit(level);
789        }
790        assert_eq!(runtime.reasoning_level(), ReasoningLevel::Off);
791    }
792
793    /// merge(112) DARK test: default config boots with legacy memory and no
794    /// context_checkpoint tool — Axel features are invisible until opted in.
795    #[test]
796    fn default_config_is_dark_no_checkpoint_tool_legacy_binding() {
797        let runtime = Runtime::new_headless();
798        // Default memory backend must be legacy (no Axel sidecar spawn).
799        assert!(
800            !runtime.memory_backend_exclusive(),
801            "default config must NOT be exclusive (Axel); it must be legacy"
802        );
803        // The tool registry must not contain context_checkpoint.
804        let tools = runtime.tools_shared();
805        let registry = tools.blocking_read();
806        let has_checkpoint = registry
807            .iter_tools_sorted()
808            .iter()
809            .any(|t| t.name() == "context_checkpoint");
810        assert!(
811            !has_checkpoint,
812            "context_checkpoint must not appear in the default tool set"
813        );
814    }
815}