Skip to main content

imp_core/
imp_session.rs

1//! High-level session API for driving imp programmatically.
2//!
3//! `ImpSession` is the primary public interface for embedding imp in other
4//! Rust programs, building custom UIs, or driving agents from orchestrators.
5//! It wires together config, auth, model resolution, agent construction,
6//! session persistence, and the event stream — eliminating the boilerplate
7//! that each run mode (interactive, print, headless, RPC) otherwise
8//! duplicates.
9//!
10//! # Example
11//!
12//! ```no_run
13//! use imp_core::imp_session::{ImpSession, SessionOptions, SessionChoice};
14//!
15//! # async fn example() -> imp_core::Result<()> {
16//! let mut session = ImpSession::create(SessionOptions {
17//!     cwd: std::env::current_dir()?,
18//!     ..Default::default()
19//! }).await?;
20//!
21//! session.prompt("What files are in the current directory?").await?;
22//!
23//! while let Some(event) = session.recv_event().await {
24//!     println!("{event:?}");
25//! }
26//! # Ok(())
27//! # }
28//! ```
29
30use std::collections::VecDeque;
31use std::path::PathBuf;
32use std::sync::Arc;
33
34use tokio::sync::mpsc;
35use tokio::task::JoinHandle;
36
37use imp_llm::auth::{ApiKey, AuthStore};
38use imp_llm::model::{ModelMeta, ModelRegistry};
39use imp_llm::providers::create_provider;
40use imp_llm::{Model, ThinkingLevel};
41
42use crate::agent::{Agent, AgentCommand, AgentEvent, AgentHandle};
43use crate::builder::AgentBuilder;
44use crate::config::{AgentMode, Config};
45use crate::error::{Error, Result};
46use crate::policy::RunPolicy;
47use crate::session::{SessionCheckpointRecord, SessionEntry, SessionManager};
48use crate::storage;
49use crate::system_prompt::{Fact, TaskContext};
50use crate::ui::UserInterface;
51
52// ── Options ─────────────────────────────────────────────────────
53
54/// How to initialize the session file.
55#[derive(Debug, Clone, Default)]
56pub enum SessionChoice {
57    /// Fresh session, persisted to disk.
58    #[default]
59    New,
60    /// No persistence.
61    InMemory,
62    /// Continue the most recent session for the working directory.
63    Continue,
64    /// Open a specific session file.
65    Open(PathBuf),
66}
67
68use crate::tools::LuaToolLoader;
69use crate::workflow::{AutonomyMode, VerificationGate};
70
71/// Configuration for creating an `ImpSession`.
72///
73/// All fields have sensible defaults — only `cwd` is typically required.
74pub struct SessionOptions {
75    /// Working directory. Tools resolve paths relative to this.
76    pub cwd: PathBuf,
77
78    /// Prebuilt model override for deterministic tests or embedded callers.
79    /// When set, ImpSession skips runtime model/provider/auth resolution.
80    pub model_override: Option<Model>,
81
82    /// Model hint — alias ("sonnet") or full ID. Resolved against the
83    /// model registry. Falls back to config, then "sonnet".
84    pub model: Option<String>,
85
86    /// Provider override. Usually auto-detected from the model.
87    pub provider: Option<String>,
88
89    /// Runtime API key override (not persisted).
90    pub api_key: Option<String>,
91
92    /// Thinking level override.
93    pub thinking: Option<ThinkingLevel>,
94
95    /// Agent mode (full, worker, orchestrator, …).
96    pub mode: Option<AgentMode>,
97
98    /// Autonomy mode for workflow/runtime policy. Defaults to safe.
99    pub autonomy_mode: Option<AutonomyMode>,
100
101    /// Verification gates declared by CLI/config/user input.
102    pub verification_gates: Vec<VerificationGate>,
103
104    /// Maximum turns before the agent stops.
105    pub max_turns: Option<u32>,
106
107    /// Max output tokens per response.
108    pub max_tokens: Option<u32>,
109
110    /// Replace the assembled system prompt entirely.
111    pub system_prompt: Option<String>,
112
113    /// Skip native tool registration.
114    pub no_tools: bool,
115
116    /// Session persistence strategy.
117    pub session: SessionChoice,
118
119    /// Task context for headless / unit mode.
120    pub task: Option<TaskContext>,
121
122    /// Task-specific facts to inject into the system prompt.
123    pub facts: Vec<Fact>,
124
125    /// Lua extension loader. Called after native tools are registered.
126    /// The binary crate typically provides this; library callers can
127    /// pass `None` to skip Lua extensions.
128    pub lua_loader: Option<LuaToolLoader>,
129
130    /// Per-run tool/write policy layered on top of AgentMode.
131    pub run_policy: RunPolicy,
132
133    /// Custom UI implementation. Defaults to `NullInterface`.
134    pub ui: Option<Arc<dyn UserInterface>>,
135
136    /// Path to auth.json. Defaults to `~/.config/imp/auth.json`.
137    pub auth_path: Option<PathBuf>,
138
139    /// Pre-assembled context messages injected before the first prompt.
140    /// Built by `context_prefill::assemble_context()` at dispatch time.
141    /// The agent starts with these files already in its cached prefix.
142    pub context_prefill: Vec<imp_llm::Message>,
143}
144
145impl Default for SessionOptions {
146    fn default() -> Self {
147        Self {
148            cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
149            model_override: None,
150            model: None,
151            provider: None,
152            api_key: None,
153            thinking: None,
154            mode: None,
155            autonomy_mode: None,
156            verification_gates: Vec::new(),
157            max_turns: None,
158            max_tokens: None,
159            system_prompt: None,
160            no_tools: false,
161            session: SessionChoice::default(),
162            task: None,
163            facts: Vec::new(),
164            lua_loader: None,
165            run_policy: RunPolicy::default(),
166            ui: None,
167            auth_path: None,
168            context_prefill: Vec::new(),
169        }
170    }
171}
172
173#[derive(Debug, Clone)]
174pub struct RuntimeConnectionIntent<'a> {
175    pub model_hint: Option<&'a str>,
176    pub config_model: Option<&'a str>,
177    pub provider_override: Option<&'a str>,
178    pub api_key_override_present: bool,
179}
180
181#[derive(Debug, Clone, PartialEq, Eq)]
182pub struct ResolvedRuntimeConnection {
183    pub model_id: String,
184    pub provider_name: String,
185}
186
187/// Resolve the model-first runtime connection (model id + provider route/surface)
188/// shared by CLI and session startup.
189pub fn resolve_runtime_connection(
190    intent: RuntimeConnectionIntent<'_>,
191    auth_store: &AuthStore,
192    registry: &ModelRegistry,
193) -> std::result::Result<ResolvedRuntimeConnection, String> {
194    let model_hint = intent
195        .model_hint
196        .or(intent.config_model)
197        .unwrap_or("sonnet");
198
199    let meta = registry
200        .resolve_meta(model_hint, intent.provider_override)
201        .ok_or_else(|| format!("Unknown model: {model_hint}"))?;
202
203    let provider_name = intent
204        .provider_override
205        .unwrap_or(&meta.provider)
206        .to_string();
207
208    if let Some(oauth_route) = auth_preferred_oauth_route(
209        intent.provider_override,
210        intent.api_key_override_present,
211        auth_store,
212        registry,
213        &meta,
214        &provider_name,
215    ) {
216        return Ok(oauth_route);
217    }
218
219    Ok(ResolvedRuntimeConnection {
220        model_id: meta.id.clone(),
221        provider_name,
222    })
223}
224
225// ── ImpSession ──────────────────────────────────────────────────
226
227/// A fully wired agent session.
228///
229/// Manages the lifecycle of a single agent: config resolution, model
230/// selection, session persistence, and the event/command channels.
231pub struct ImpSession {
232    agent: Option<Agent>,
233    handle: AgentHandle,
234    session_mgr: SessionManager,
235    config: Config,
236    model: Model,
237    auth_store: AuthStore,
238    model_registry: ModelRegistry,
239    cwd: PathBuf,
240    /// Task handle for the currently running agent loop, if any.
241    agent_task: Option<JoinHandle<(Agent, Result<()>)>>,
242    completed_run_result: Option<Result<()>>,
243    pending_persistence_errors: VecDeque<String>,
244    /// Context prefill messages, injected once before the first prompt.
245    context_prefill: Vec<imp_llm::Message>,
246    context_prefill_injected: bool,
247}
248
249impl ImpSession {
250    /// Create a new session by resolving config, auth, model, and tools.
251    ///
252    /// This is the main factory — mirrors pi's `createAgentSession()`.
253    pub async fn create(options: SessionOptions) -> Result<Self> {
254        let cwd = options.cwd.clone();
255
256        let _ = storage::reconcile_legacy_into_global_root();
257
258        // 1. Load config (user + project, merged)
259        let mut config = Config::resolve(&Config::user_config_dir(), Some(&cwd))?;
260
261        // Apply option overrides
262        if let Some(thinking) = options.thinking {
263            config.thinking = Some(thinking);
264        }
265        if let Some(mode) = options.mode {
266            config.mode = mode;
267        }
268
269        // 2. Resolve auth
270        let auth_path = options
271            .auth_path
272            .clone()
273            .or_else(storage::existing_global_auth_path)
274            .unwrap_or_else(storage::global_auth_path);
275        let mut auth_store =
276            AuthStore::load(&auth_path).unwrap_or_else(|_| AuthStore::new(auth_path));
277
278        if let Some(ref key) = options.api_key {
279            // We'll set this after we know the provider name
280            // Store it temporarily
281            let _ = key; // handled below
282        }
283
284        // 3. Resolve model + provider route
285        let model_registry = ModelRegistry::with_builtins();
286        let (model, _provider_name, api_key) = if let Some(model) = options.model_override.as_ref()
287        {
288            (
289                clone_model(model),
290                model.meta.provider.clone(),
291                String::new(),
292            )
293        } else {
294            let runtime_connection = resolve_runtime_connection(
295                RuntimeConnectionIntent {
296                    model_hint: options.model.as_deref(),
297                    config_model: config.model.as_deref(),
298                    provider_override: options.provider.as_deref(),
299                    api_key_override_present: options.api_key.is_some(),
300                },
301                &auth_store,
302                &model_registry,
303            )
304            .map_err(Error::Config)?;
305
306            let meta = model_registry
307                .resolve_meta(
308                    &runtime_connection.model_id,
309                    Some(&runtime_connection.provider_name),
310                )
311                .ok_or_else(|| {
312                    Error::Config(format!(
313                        "Unknown model/provider route: {} via {}",
314                        runtime_connection.model_id, runtime_connection.provider_name
315                    ))
316                })?;
317
318            let provider_name = runtime_connection.provider_name.clone();
319
320            if let Some(ref key) = options.api_key {
321                auth_store.set_runtime_key(&provider_name, key.clone());
322            }
323
324            let provider = create_provider(&provider_name)
325                .ok_or_else(|| Error::Config(format!("Unknown provider: {provider_name}")))?;
326
327            let api_key = resolve_api_key(&mut auth_store, &provider_name).await?;
328            (
329                Model {
330                    meta,
331                    provider: Arc::from(provider),
332                },
333                provider_name,
334                api_key,
335            )
336        };
337
338        // 5. Build agent
339        let mut builder =
340            AgentBuilder::new(config.clone(), cwd.clone(), clone_model(&model), api_key);
341
342        if let Some(task) = &options.task {
343            builder = builder.task(task.clone());
344        }
345        if !options.facts.is_empty() {
346            builder = builder.facts(options.facts.clone());
347        }
348        if let Some(prompt) = &options.system_prompt {
349            builder = builder.system_prompt(prompt.clone());
350        }
351        if let Some(lua_loader) = options.lua_loader {
352            builder = builder.lua_tool_loader(move |policy, tools| lua_loader(policy, tools));
353        }
354        if let Some(autonomy_mode) = options.autonomy_mode {
355            builder = builder.autonomy_mode(autonomy_mode);
356        }
357        builder = builder.verification_gates(options.verification_gates.clone());
358        builder = builder.run_policy(options.run_policy.clone());
359
360        let (mut agent, handle) = builder.build()?;
361
362        if options.no_tools {
363            agent.tools.retain(|_| false);
364        }
365
366        if options.no_tools {
367            agent.thinking_level = config.thinking.unwrap_or(ThinkingLevel::Off);
368            if let Some(max_tokens) = options.max_tokens.or(config.max_tokens) {
369                agent.max_tokens = Some(max_tokens);
370            }
371        } else if let Some(max_tokens) = options.max_tokens {
372            agent.max_tokens = Some(max_tokens);
373        }
374        if let Some(ui) = &options.ui {
375            agent.ui = Arc::clone(ui);
376        }
377
378        // 6. Set up session persistence
379        let session_dir = storage::global_sessions_dir();
380        let session_mgr = match options.session {
381            SessionChoice::New => SessionManager::new(&cwd, &session_dir)?,
382            SessionChoice::InMemory => SessionManager::in_memory(),
383            SessionChoice::Continue => SessionManager::continue_recent(&cwd, &session_dir)?
384                .unwrap_or_else(|| SessionManager::new(&cwd, &session_dir).unwrap()),
385            SessionChoice::Open(ref path) => SessionManager::open(path)?,
386        };
387
388        Ok(Self {
389            agent: Some(agent),
390            handle,
391            session_mgr,
392            config,
393            model,
394            auth_store,
395            model_registry,
396            cwd,
397            context_prefill: options.context_prefill,
398            context_prefill_injected: false,
399            agent_task: None,
400            completed_run_result: None,
401            pending_persistence_errors: VecDeque::new(),
402        })
403    }
404
405    // ── Prompting ───────────────────────────────────────────────
406
407    /// Send a prompt and run the agent loop.
408    ///
409    /// The agent runs on a background task. Use [`recv_event`] to consume
410    /// events, and [`steer`] / [`follow_up`] / [`cancel`] to control it.
411    ///
412    /// Returns an error if the agent is already running.
413    pub async fn prompt(&mut self, text: &str) -> Result<()> {
414        if self.agent_task.is_some() {
415            return Err(Error::Config(
416                "Agent is already running. Cancel or wait for it to finish.".into(),
417            ));
418        }
419
420        self.completed_run_result = None;
421        self.pending_persistence_errors.clear();
422
423        // Persist user message to session
424        let msg_id = uuid::Uuid::new_v4().to_string();
425        let _ = self.session_mgr.append(SessionEntry::Message {
426            id: msg_id,
427            parent_id: None,
428            message: imp_llm::Message::user(text),
429        });
430
431        // Load prior messages from session history into agent
432        let mut agent = self
433            .agent
434            .take()
435            .ok_or_else(|| Error::Config("Agent already consumed".into()))?;
436
437        let mut history: Vec<imp_llm::Message> = self.session_mgr.get_active_messages();
438
439        // The prompt was already appended to session history so resume/tree state
440        // is correct, but Agent::run() will push the active prompt itself. Remove
441        // the just-appended trailing user message to avoid duplicating it in the
442        // model context for this run.
443        if matches!(
444            history.last(),
445            Some(imp_llm::Message::User(user))
446                if matches!(
447                    user.content.as_slice(),
448                    [imp_llm::ContentBlock::Text { text: last_text }] if last_text == text
449                )
450        ) {
451            history.pop();
452        }
453
454        // Inject context prefill (once, before the first prompt). These messages
455        // form the cached prefix: file contents the agent needs, assembled at
456        // dispatch time by context_prefill::assemble_context(). Subsequent turns
457        // get cache_read on this prefix instead of re-reading files.
458        if !self.context_prefill_injected && !self.context_prefill.is_empty() {
459            for msg in &self.context_prefill {
460                history.push(msg.clone());
461            }
462            // Assistant acknowledgment to maintain user/assistant alternation
463            history.push(imp_llm::Message::Assistant(imp_llm::AssistantMessage {
464                content: vec![imp_llm::ContentBlock::Text {
465                    text: "Context loaded. Ready to work.".into(),
466                }],
467                usage: None,
468                stop_reason: imp_llm::StopReason::EndTurn,
469                timestamp: imp_llm::now(),
470            }));
471            self.context_prefill_injected = true;
472        }
473
474        // Replace agent messages with session history. Agent::run() will append
475        // the active prompt as the next user message.
476        agent.messages = history;
477
478        let prompt = text.to_string();
479        let task = tokio::spawn(async move {
480            let result = agent.run(prompt).await;
481            (agent, result)
482        });
483        self.agent_task = Some(task);
484
485        Ok(())
486    }
487
488    /// Send a prompt and block until the agent finishes.
489    ///
490    /// Events are still emitted via [`recv_event`], but this method
491    /// does not return until the agent loop completes.
492    pub async fn prompt_and_wait(&mut self, text: &str) -> Result<()> {
493        self.prompt(text).await?;
494        self.wait().await
495    }
496
497    /// Wait for the running agent to finish.
498    pub async fn wait(&mut self) -> Result<()> {
499        if let Some(task) = self.agent_task.take() {
500            let (agent, result) = task
501                .await
502                .map_err(|e| Error::Config(format!("Agent task panicked: {e}")))?;
503            self.agent = Some(agent);
504            self.completed_run_result = Some(result);
505            self.drain_pending_events_for_persistence();
506        }
507
508        if let Some(result) = self.completed_run_result.take() {
509            return result;
510        }
511
512        Ok(())
513    }
514
515    /// Interrupt the agent: delivered after the current tool finishes,
516    /// remaining queued tools are skipped.
517    pub async fn steer(&self, text: &str) -> Result<()> {
518        self.handle
519            .command_tx
520            .send(AgentCommand::Steer(text.into()))
521            .await
522            .map_err(|_| Error::Config("Agent not running".into()))
523    }
524
525    /// Follow-up: delivered only after the agent finishes all current work.
526    pub async fn follow_up(&self, text: &str) -> Result<()> {
527        self.handle
528            .command_tx
529            .send(AgentCommand::FollowUp(text.into()))
530            .await
531            .map_err(|_| Error::Config("Agent not running".into()))
532    }
533
534    /// Cancel the current agent run.
535    pub async fn cancel(&self) -> Result<()> {
536        self.handle
537            .command_tx
538            .send(AgentCommand::Cancel)
539            .await
540            .map_err(|_| Error::Config("Agent not running".into()))
541    }
542
543    /// Force-abort the current agent task when graceful cancellation does not finish.
544    pub fn abort(&mut self) {
545        if let Some(task) = self.agent_task.take() {
546            task.abort();
547            self.completed_run_result = Some(Err(Error::Cancelled));
548        }
549    }
550
551    // ── Events ──────────────────────────────────────────────────
552
553    /// Receive the next event from the agent.
554    ///
555    /// Returns `None` when the agent has finished and all events have
556    /// been consumed.
557    pub async fn recv_event(&mut self) -> Option<AgentEvent> {
558        if let Some(error) = self.take_persistence_error() {
559            return Some(AgentEvent::Error { error });
560        }
561
562        if self.agent_task.is_none() && self.completed_run_result.is_some() {
563            return None;
564        }
565
566        let event = self.handle.event_rx.recv().await?;
567        let events = self.persist_event_entries(&event);
568
569        if matches!(event, AgentEvent::AgentEnd { .. }) {
570            if let Some(task) = self.agent_task.take() {
571                match task.await {
572                    Ok((agent, result)) => {
573                        self.agent = Some(agent);
574                        self.completed_run_result = Some(result);
575                    }
576                    Err(join_error) => {
577                        self.push_persistence_error(
578                            events,
579                            format!("agent task panicked: {join_error}"),
580                        );
581                    }
582                }
583            }
584        }
585
586        Some(event)
587    }
588
589    /// Get mutable access to the raw event receiver.
590    ///
591    /// Use this when you need `select!` or other channel combinators.
592    pub fn event_rx(&mut self) -> &mut mpsc::Receiver<AgentEvent> {
593        &mut self.handle.event_rx
594    }
595
596    // ── Model ───────────────────────────────────────────────────
597
598    /// Switch the model for subsequent prompts.
599    ///
600    /// The change takes effect on the next `prompt()` call.
601    pub async fn set_model(&mut self, hint: &str) -> Result<()> {
602        let meta = self
603            .model_registry
604            .resolve_meta(hint, None)
605            .ok_or_else(|| Error::Config(format!("Unknown model: {hint}")))?;
606
607        let provider_name = meta.provider.clone();
608        let provider = create_provider(&provider_name)
609            .ok_or_else(|| Error::Config(format!("Unknown provider: {provider_name}")))?;
610        let api_key = resolve_api_key(&mut self.auth_store, &provider_name).await?;
611
612        self.model = Model {
613            meta,
614            provider: Arc::from(provider),
615        };
616
617        // If we still have the agent (not currently running), update it
618        if let Some(ref mut agent) = self.agent {
619            agent.model = clone_model(&self.model);
620            agent.api_key = api_key;
621        }
622
623        Ok(())
624    }
625
626    /// Set the thinking level for subsequent prompts.
627    pub fn set_thinking(&mut self, level: ThinkingLevel) {
628        self.config.thinking = Some(level);
629        if let Some(ref mut agent) = self.agent {
630            agent.thinking_level = level;
631        }
632    }
633
634    // ── Accessors ───────────────────────────────────────────────
635
636    /// The current model.
637    pub fn model(&self) -> &Model {
638        &self.model
639    }
640
641    /// The resolved config.
642    pub fn config(&self) -> &Config {
643        &self.config
644    }
645
646    /// The session manager (tree, entries, persistence).
647    pub fn session_manager(&self) -> &SessionManager {
648        &self.session_mgr
649    }
650
651    /// Mutable access to the session manager.
652    pub fn session_manager_mut(&mut self) -> &mut SessionManager {
653        &mut self.session_mgr
654    }
655
656    /// The working directory.
657    pub fn cwd(&self) -> &PathBuf {
658        &self.cwd
659    }
660
661    /// The auth store (for checking credentials, OAuth status, etc).
662    pub fn auth_store(&self) -> &AuthStore {
663        &self.auth_store
664    }
665
666    /// Mutable access to the auth store.
667    pub fn auth_store_mut(&mut self) -> &mut AuthStore {
668        &mut self.auth_store
669    }
670
671    /// The model registry.
672    pub fn model_registry(&self) -> &ModelRegistry {
673        &self.model_registry
674    }
675
676    /// Whether the agent is currently running a prompt.
677    pub fn is_running(&self) -> bool {
678        self.agent_task.is_some()
679    }
680
681    /// Get the raw command sender for advanced use cases.
682    pub fn command_tx(&self) -> &mpsc::Sender<AgentCommand> {
683        &self.handle.command_tx
684    }
685
686    fn persist_event_entries(&mut self, event: &AgentEvent) -> Vec<&'static str> {
687        let persisted = match self
688            .session_mgr
689            .persist_agent_event_entries(&self.model, event)
690        {
691            Ok(persisted) => persisted,
692            Err(error) => {
693                self.push_persistence_error(
694                    Vec::new(),
695                    format!("failed to persist agent event entries: {error}"),
696                );
697                Vec::new()
698            }
699        };
700
701        if let Some(agent) = self.agent.as_ref() {
702            if let Err(error) =
703                persist_checkpoint_records(&mut self.session_mgr, &agent.checkpoint_state)
704            {
705                self.push_persistence_error(
706                    persisted.clone(),
707                    format!("failed to persist checkpoint records: {error}"),
708                );
709            }
710        }
711
712        persisted
713    }
714
715    fn drain_pending_events_for_persistence(&mut self) {
716        while let Ok(event) = self.handle.event_rx.try_recv() {
717            self.persist_event_entries(&event);
718        }
719    }
720
721    fn push_persistence_error(&mut self, persisted: Vec<&'static str>, error: String) {
722        let prefix = if persisted.is_empty() {
723            "session persistence warning".to_string()
724        } else {
725            format!("session persistence warning after {}", persisted.join(", "))
726        };
727        self.pending_persistence_errors
728            .push_back(format!("{prefix}: {error}"));
729    }
730
731    fn take_persistence_error(&mut self) -> Option<String> {
732        self.pending_persistence_errors.pop_front()
733    }
734}
735// ── Helpers ─────────────────────────────────────────────────────
736
737/// Resolve the API key for a provider, handling OAuth refresh.
738async fn resolve_api_key(auth_store: &mut AuthStore, provider: &str) -> Result<ApiKey> {
739    let result = match provider {
740        "openai-codex" => auth_store.resolve_chatgpt_oauth().await,
741        "anthropic" | "kimi-code" => auth_store.resolve_with_refresh(provider).await,
742        _ => auth_store.resolve(provider),
743    };
744    result.map_err(|e| Error::Config(format!("Auth failed for {provider}: {e}")))
745}
746
747fn auth_preferred_oauth_route(
748    provider_override: Option<&str>,
749    api_key_override_present: bool,
750    auth_store: &AuthStore,
751    registry: &ModelRegistry,
752    meta: &ModelMeta,
753    provider_name: &str,
754) -> Option<ResolvedRuntimeConnection> {
755    if should_use_openai_chatgpt_route(
756        provider_override,
757        api_key_override_present,
758        auth_store,
759        registry,
760        &meta.id,
761        provider_name,
762    ) {
763        return Some(ResolvedRuntimeConnection {
764            model_id: meta.id.clone(),
765            provider_name: "openai-codex".to_string(),
766        });
767    }
768
769    if should_use_kimi_code_route(
770        provider_override,
771        api_key_override_present,
772        auth_store,
773        registry,
774        meta,
775        provider_name,
776    ) {
777        return Some(ResolvedRuntimeConnection {
778            model_id: "kimi2.6".to_string(),
779            provider_name: "kimi-code".to_string(),
780        });
781    }
782
783    None
784}
785fn should_use_openai_chatgpt_route(
786    provider_override: Option<&str>,
787    api_key_override_present: bool,
788    auth_store: &AuthStore,
789    registry: &ModelRegistry,
790    model_id: &str,
791    provider_name: &str,
792) -> bool {
793    let provider_allows_fallback = match provider_override {
794        None => true,
795        Some("openai") => true,
796        Some(_) => false,
797    };
798
799    provider_allows_fallback
800        && !api_key_override_present
801        && provider_name == "openai"
802        && auth_store.resolve_api_key_only("openai").is_err()
803        && (auth_store.get_oauth("openai").is_some()
804            || auth_store.get_oauth("openai-codex").is_some())
805        && codex_supports_model(registry, model_id)
806}
807
808fn should_use_kimi_code_route(
809    provider_override: Option<&str>,
810    api_key_override_present: bool,
811    auth_store: &AuthStore,
812    registry: &ModelRegistry,
813    meta: &ModelMeta,
814    provider_name: &str,
815) -> bool {
816    let provider_allows_fallback = match provider_override {
817        None => true,
818        Some("moonshot") => true,
819        Some("kimi-code") => true,
820        Some(_) => false,
821    };
822
823    provider_allows_fallback
824        && !api_key_override_present
825        && provider_name == "moonshot"
826        && auth_store.resolve_api_key_only("moonshot").is_err()
827        && auth_store.get_oauth("kimi-code").is_some()
828        && registry.find("kimi2.6").is_some()
829        && is_kimi_moonshot_model(&meta.id)
830}
831
832fn is_kimi_moonshot_model(model_id: &str) -> bool {
833    matches!(
834        model_id,
835        "kimi-k2.6"
836            | "kimi-k2.5"
837            | "kimi-k2-0905-preview"
838            | "kimi-k2-turbo-preview"
839            | "kimi-k2-thinking"
840            | "kimi-k2-thinking-turbo"
841    )
842}
843fn clone_model(model: &Model) -> Model {
844    Model {
845        meta: model.meta.clone(),
846        provider: Arc::clone(&model.provider),
847    }
848}
849
850fn persist_checkpoint_records(
851    session_mgr: &mut SessionManager,
852    checkpoint_state: &crate::tools::CheckpointState,
853) -> Result<Vec<String>> {
854    let existing: std::collections::HashSet<String> = session_mgr
855        .checkpoint_records()
856        .into_iter()
857        .map(|record| record.checkpoint_id)
858        .collect();
859
860    let mut persisted = Vec::new();
861    for record in checkpoint_state.checkpoints() {
862        if existing.contains(&record.id) {
863            continue;
864        }
865        session_mgr.append_checkpoint_record(SessionCheckpointRecord {
866            version: crate::session::CHECKPOINT_RECORD_VERSION,
867            checkpoint_id: record.id.clone(),
868            created_at: record.created_at,
869            label: record.label.clone(),
870            files: record
871                .files
872                .iter()
873                .map(|path| path.to_string_lossy().to_string())
874                .collect(),
875        })?;
876        persisted.push(record.id);
877    }
878
879    Ok(persisted)
880}
881
882fn codex_supports_model(_registry: &ModelRegistry, model_id: &str) -> bool {
883    imp_llm::model::builtin_openai_codex_models()
884        .iter()
885        .any(|m| m.id == model_id)
886}
887
888#[cfg(test)]
889mod tests {
890    use super::*;
891    use imp_llm::{
892        auth::{ApiKey, AuthStore},
893        model::{Capabilities, ModelPricing},
894        provider::{Context, Provider, RequestOptions},
895        AssistantMessage, ContentBlock, ModelMeta, StopReason, StreamEvent, Usage,
896    };
897    use serde_json::json;
898    use tempfile::TempDir;
899
900    struct NoopProvider {
901        models: Vec<ModelMeta>,
902    }
903
904    struct SingleResponseProvider {
905        models: Vec<ModelMeta>,
906        events: std::sync::Mutex<Option<Vec<imp_llm::Result<StreamEvent>>>>,
907    }
908
909    #[async_trait::async_trait]
910    impl Provider for NoopProvider {
911        fn stream(
912            &self,
913            _model: &Model,
914            _context: Context,
915            _options: RequestOptions,
916            _api_key: &str,
917        ) -> std::pin::Pin<Box<dyn futures_core::Stream<Item = imp_llm::Result<StreamEvent>> + Send>>
918        {
919            Box::pin(futures::stream::empty())
920        }
921
922        async fn resolve_auth(&self, _auth: &AuthStore) -> imp_llm::Result<ApiKey> {
923            Ok(String::new())
924        }
925
926        fn id(&self) -> &str {
927            "noop"
928        }
929
930        fn models(&self) -> &[ModelMeta] {
931            &self.models
932        }
933    }
934
935    #[async_trait::async_trait]
936    impl Provider for SingleResponseProvider {
937        fn stream(
938            &self,
939            _model: &Model,
940            _context: Context,
941            _options: RequestOptions,
942            _api_key: &str,
943        ) -> std::pin::Pin<Box<dyn futures_core::Stream<Item = imp_llm::Result<StreamEvent>> + Send>>
944        {
945            let events = self
946                .events
947                .lock()
948                .expect("single response provider lock")
949                .take()
950                .unwrap_or_default();
951            Box::pin(futures::stream::iter(events))
952        }
953
954        async fn resolve_auth(&self, _auth: &AuthStore) -> imp_llm::Result<ApiKey> {
955            Ok(String::new())
956        }
957
958        fn id(&self) -> &str {
959            "single-response"
960        }
961
962        fn models(&self) -> &[ModelMeta] {
963            &self.models
964        }
965    }
966
967    fn test_model() -> Model {
968        let meta = ModelMeta {
969            id: "test-model".into(),
970            provider: "test-provider".into(),
971            name: "Test Model".into(),
972            context_window: 8192,
973            max_output_tokens: 2048,
974            pricing: ModelPricing {
975                input_per_mtok: 2.0,
976                output_per_mtok: 4.0,
977                cache_read_per_mtok: 0.5,
978                cache_write_per_mtok: 1.0,
979            },
980            capabilities: Capabilities {
981                reasoning: false,
982                images: false,
983                tool_use: true,
984            },
985        };
986        Model {
987            meta: meta.clone(),
988            provider: Arc::new(NoopProvider { models: vec![meta] }),
989        }
990    }
991
992    fn test_model_with_events(events: Vec<imp_llm::Result<StreamEvent>>) -> Model {
993        let meta = ModelMeta {
994            id: "test-model".into(),
995            provider: "test-provider".into(),
996            name: "Test Model".into(),
997            context_window: 8192,
998            max_output_tokens: 2048,
999            pricing: ModelPricing {
1000                input_per_mtok: 2.0,
1001                output_per_mtok: 4.0,
1002                cache_read_per_mtok: 0.5,
1003                cache_write_per_mtok: 1.0,
1004            },
1005            capabilities: Capabilities {
1006                reasoning: false,
1007                images: false,
1008                tool_use: true,
1009            },
1010        };
1011        Model {
1012            meta: meta.clone(),
1013            provider: Arc::new(SingleResponseProvider {
1014                models: vec![meta],
1015                events: std::sync::Mutex::new(Some(events)),
1016            }),
1017        }
1018    }
1019
1020    fn test_assistant_message(timestamp: u64, usage: Option<Usage>) -> AssistantMessage {
1021        AssistantMessage {
1022            content: vec![ContentBlock::Text {
1023                text: "done".into(),
1024            }],
1025            usage,
1026            stop_reason: StopReason::EndTurn,
1027            timestamp,
1028        }
1029    }
1030
1031    #[test]
1032    fn session_options_default_is_sensible() {
1033        let opts = SessionOptions::default();
1034        assert!(opts.model.is_none());
1035        assert!(opts.max_tokens.is_none());
1036        assert!(!opts.no_tools);
1037        assert!(matches!(opts.session, SessionChoice::New));
1038    }
1039
1040    #[test]
1041    fn resolve_runtime_connection_prefers_openai_chatgpt_route_when_oauth_exists() {
1042        let dir = tempfile::tempdir().unwrap();
1043        let auth_path = dir.path().join("auth.json");
1044        let mut auth_store = AuthStore::new(auth_path);
1045        auth_store
1046            .store(
1047                "openai",
1048                imp_llm::auth::StoredCredential::OAuth(imp_llm::auth::OAuthCredential {
1049                    access_token: "oauth-token".into(),
1050                    refresh_token: "refresh-token".into(),
1051                    expires_at: imp_llm::now() + 3600,
1052                }),
1053            )
1054            .unwrap();
1055        let registry = ModelRegistry::with_builtins();
1056
1057        let resolved = resolve_runtime_connection(
1058            RuntimeConnectionIntent {
1059                model_hint: Some("gpt-5.4"),
1060                config_model: None,
1061                provider_override: Some("openai"),
1062                api_key_override_present: false,
1063            },
1064            &auth_store,
1065            &registry,
1066        )
1067        .unwrap();
1068
1069        assert_eq!(resolved.model_id, "gpt-5.4");
1070        assert_eq!(resolved.provider_name, "openai-codex");
1071    }
1072
1073    #[test]
1074    fn resolve_runtime_connection_respects_forced_non_openai_provider() {
1075        let auth_path = PathBuf::from("/tmp/nonexistent-auth.json");
1076        let auth_store = AuthStore::new(auth_path);
1077        let registry = ModelRegistry::with_builtins();
1078
1079        let resolved = resolve_runtime_connection(
1080            RuntimeConnectionIntent {
1081                model_hint: Some("gpt-5.4"),
1082                config_model: None,
1083                provider_override: Some("anthropic"),
1084                api_key_override_present: false,
1085            },
1086            &auth_store,
1087            &registry,
1088        )
1089        .unwrap();
1090
1091        assert_eq!(resolved.provider_name, "anthropic");
1092    }
1093
1094    #[test]
1095    fn resolve_runtime_connection_does_not_switch_when_model_is_not_codex_supported() {
1096        let dir = tempfile::tempdir().unwrap();
1097        let auth_path = dir.path().join("auth.json");
1098        let mut auth_store = AuthStore::new(auth_path);
1099        auth_store
1100            .store(
1101                "openai",
1102                imp_llm::auth::StoredCredential::OAuth(imp_llm::auth::OAuthCredential {
1103                    access_token: "oauth-token".into(),
1104                    refresh_token: "refresh-token".into(),
1105                    expires_at: imp_llm::now() + 3600,
1106                }),
1107            )
1108            .unwrap();
1109        let registry = ModelRegistry::with_builtins();
1110
1111        let resolved = resolve_runtime_connection(
1112            RuntimeConnectionIntent {
1113                model_hint: Some("gpt-4o"),
1114                config_model: None,
1115                provider_override: Some("openai"),
1116                api_key_override_present: false,
1117            },
1118            &auth_store,
1119            &registry,
1120        )
1121        .unwrap();
1122
1123        assert_eq!(resolved.model_id, "gpt-4o");
1124        assert_eq!(resolved.provider_name, "openai");
1125    }
1126
1127    #[test]
1128    fn resolve_runtime_connection_does_not_switch_when_api_key_override_is_present() {
1129        let dir = tempfile::tempdir().unwrap();
1130        let auth_path = dir.path().join("auth.json");
1131        let mut auth_store = AuthStore::new(auth_path);
1132        auth_store
1133            .store(
1134                "openai",
1135                imp_llm::auth::StoredCredential::OAuth(imp_llm::auth::OAuthCredential {
1136                    access_token: "oauth-token".into(),
1137                    refresh_token: "refresh-token".into(),
1138                    expires_at: imp_llm::now() + 3600,
1139                }),
1140            )
1141            .unwrap();
1142        let registry = ModelRegistry::with_builtins();
1143
1144        let resolved = resolve_runtime_connection(
1145            RuntimeConnectionIntent {
1146                model_hint: Some("gpt-5.4"),
1147                config_model: None,
1148                provider_override: None,
1149                api_key_override_present: true,
1150            },
1151            &auth_store,
1152            &registry,
1153        )
1154        .unwrap();
1155
1156        assert_eq!(resolved.model_id, "gpt-5.4");
1157        assert_eq!(resolved.provider_name, "openai");
1158    }
1159
1160    #[test]
1161    fn resolve_runtime_connection_prefers_kimi_code_route_when_oauth_exists_without_api_key() {
1162        let dir = tempfile::tempdir().unwrap();
1163        let auth_path = dir.path().join("auth.json");
1164        let mut auth_store = AuthStore::new(auth_path);
1165        auth_store
1166            .store(
1167                "kimi-code",
1168                imp_llm::auth::StoredCredential::OAuth(imp_llm::auth::OAuthCredential {
1169                    access_token: "oauth-token".into(),
1170                    refresh_token: "refresh-token".into(),
1171                    expires_at: imp_llm::now() + 3600,
1172                }),
1173            )
1174            .unwrap();
1175        let registry = ModelRegistry::with_builtins();
1176
1177        let resolved = resolve_runtime_connection(
1178            RuntimeConnectionIntent {
1179                model_hint: Some("kimi"),
1180                config_model: None,
1181                provider_override: None,
1182                api_key_override_present: false,
1183            },
1184            &auth_store,
1185            &registry,
1186        )
1187        .unwrap();
1188
1189        assert_eq!(resolved.model_id, "kimi2.6");
1190        assert_eq!(resolved.provider_name, "kimi-code");
1191    }
1192
1193    #[test]
1194    fn resolve_runtime_connection_keeps_moonshot_kimi_when_api_key_exists() {
1195        let dir = tempfile::tempdir().unwrap();
1196        let auth_path = dir.path().join("auth.json");
1197        let mut auth_store = AuthStore::new(auth_path);
1198        auth_store
1199            .store(
1200                "moonshot",
1201                imp_llm::auth::StoredCredential::ApiKey {
1202                    key: "sk-moonshot".into(),
1203                },
1204            )
1205            .unwrap();
1206        auth_store
1207            .store(
1208                "kimi-code",
1209                imp_llm::auth::StoredCredential::OAuth(imp_llm::auth::OAuthCredential {
1210                    access_token: "oauth-token".into(),
1211                    refresh_token: "refresh-token".into(),
1212                    expires_at: imp_llm::now() + 3600,
1213                }),
1214            )
1215            .unwrap();
1216        let registry = ModelRegistry::with_builtins();
1217
1218        let resolved = resolve_runtime_connection(
1219            RuntimeConnectionIntent {
1220                model_hint: Some("kimi"),
1221                config_model: None,
1222                provider_override: None,
1223                api_key_override_present: false,
1224            },
1225            &auth_store,
1226            &registry,
1227        )
1228        .unwrap();
1229
1230        assert_eq!(resolved.model_id, "kimi-k2.6");
1231        assert_eq!(resolved.provider_name, "moonshot");
1232    }
1233
1234    #[tokio::test]
1235    async fn no_tools_session_surfaces_auth_failure_instead_of_empty_api_key() {
1236        let tmp = TempDir::new().unwrap();
1237        let cwd = tmp.path().join("project");
1238        let auth_path = tmp.path().join("auth.json");
1239        std::fs::create_dir_all(&cwd).unwrap();
1240
1241        let result = ImpSession::create(SessionOptions {
1242            cwd: cwd.clone(),
1243            auth_path: Some(auth_path),
1244            provider: Some("openai-codex".into()),
1245            model: Some("gpt-5.4".into()),
1246            no_tools: true,
1247            session: SessionChoice::InMemory,
1248            ..Default::default()
1249        })
1250        .await;
1251
1252        match result {
1253            Ok(_) => panic!("missing auth should fail clearly"),
1254            Err(Error::Config(message)) => {
1255                assert!(message.contains("Auth failed for openai-codex"));
1256                assert!(!message.contains("Incorrect API key provided: ''"));
1257            }
1258            Err(other) => panic!("expected config error, got {other:?}"),
1259        }
1260    }
1261
1262    #[tokio::test]
1263    async fn no_tools_session_builds_assembled_system_prompt_when_task_present() {
1264        let tmp = TempDir::new().unwrap();
1265        let cwd = tmp.path().join("project");
1266        let auth_path = tmp.path().join("auth.json");
1267        std::fs::create_dir_all(&cwd).unwrap();
1268
1269        let mut auth_store = AuthStore::new(auth_path.clone());
1270        auth_store
1271            .store(
1272                "openai",
1273                imp_llm::auth::StoredCredential::OAuth(imp_llm::auth::OAuthCredential {
1274                    access_token: "oauth-token".into(),
1275                    refresh_token: "refresh-token".into(),
1276                    expires_at: imp_llm::now() + 3600,
1277                }),
1278            )
1279            .unwrap();
1280
1281        let session = ImpSession::create(SessionOptions {
1282            cwd: cwd.clone(),
1283            auth_path: Some(auth_path),
1284            provider: Some("openai".into()),
1285            model: Some("gpt-5.4".into()),
1286            no_tools: true,
1287            session: SessionChoice::InMemory,
1288            task: Some(TaskContext {
1289                title: "Test task".into(),
1290                description: "Verify headless prompt assembly".into(),
1291                design: None,
1292                acceptance: Some("Prompt includes task guidance".into()),
1293                verify: None,
1294                verify_timeout_secs: None,
1295                fail_first: false,
1296                notes: None,
1297                attempts: vec![],
1298                dependencies: vec![],
1299                decisions: vec![],
1300                context_paths: vec![],
1301                constraints: vec![],
1302            }),
1303            ..Default::default()
1304        })
1305        .await
1306        .expect("no-tools session should build with saved auth");
1307
1308        let prompt = session
1309            .agent
1310            .as_ref()
1311            .expect("agent present")
1312            .system_prompt
1313            .clone();
1314        assert!(!prompt.trim().is_empty());
1315        assert!(prompt.contains("Test task"));
1316        assert!(prompt.contains("Verify headless prompt assembly"));
1317    }
1318
1319    #[tokio::test]
1320    async fn recv_event_returns_none_after_agent_end_even_if_sender_is_still_owned() {
1321        let tmp = TempDir::new().unwrap();
1322        let cwd = tmp.path().join("project");
1323        let (agent, handle) = Agent::new(
1324            clone_model(&test_model_with_events(vec![Ok(StreamEvent::MessageEnd {
1325                message: AssistantMessage {
1326                    content: vec![ContentBlock::Text {
1327                        text: "done".into(),
1328                    }],
1329                    usage: None,
1330                    stop_reason: StopReason::EndTurn,
1331                    timestamp: 1,
1332                },
1333            })])),
1334            cwd.clone(),
1335        );
1336
1337        let mut session = ImpSession {
1338            agent: Some(agent),
1339            handle,
1340            session_mgr: SessionManager::in_memory(),
1341            config: Config::default(),
1342            model: test_model_with_events(vec![Ok(StreamEvent::MessageEnd {
1343                message: AssistantMessage {
1344                    content: vec![ContentBlock::Text {
1345                        text: "done".into(),
1346                    }],
1347                    usage: None,
1348                    stop_reason: StopReason::EndTurn,
1349                    timestamp: 1,
1350                },
1351            })]),
1352            auth_store: AuthStore::new(tmp.path().join("auth.json")),
1353            model_registry: ModelRegistry::with_builtins(),
1354            cwd,
1355            agent_task: None,
1356            completed_run_result: None,
1357            pending_persistence_errors: VecDeque::new(),
1358            context_prefill: Vec::new(),
1359            context_prefill_injected: false,
1360        };
1361
1362        session.prompt("latest").await.unwrap();
1363        while let Some(event) = session.recv_event().await {
1364            if matches!(event, AgentEvent::AgentEnd { .. }) {
1365                break;
1366            }
1367        }
1368
1369        let next = tokio::time::timeout(std::time::Duration::from_secs(1), session.recv_event())
1370            .await
1371            .expect("recv_event should not hang after agent end");
1372        assert!(next.is_none());
1373
1374        session.wait().await.unwrap();
1375    }
1376
1377    #[tokio::test]
1378    async fn abort_marks_wait_as_cancelled() {
1379        let tmp = TempDir::new().unwrap();
1380        let cwd = tmp.path().join("project");
1381        let (agent, handle) = Agent::new(
1382            test_model_with_events(vec![Ok(StreamEvent::MessageEnd {
1383                message: AssistantMessage {
1384                    content: vec![ContentBlock::Text {
1385                        text: "done".into(),
1386                    }],
1387                    usage: None,
1388                    stop_reason: StopReason::EndTurn,
1389                    timestamp: 1,
1390                },
1391            })]),
1392            cwd.clone(),
1393        );
1394        let mut session = ImpSession {
1395            agent: Some(agent),
1396            handle,
1397            session_mgr: SessionManager::in_memory(),
1398            config: Config::default(),
1399            model: test_model_with_events(vec![Ok(StreamEvent::MessageEnd {
1400                message: AssistantMessage {
1401                    content: vec![ContentBlock::Text {
1402                        text: "done".into(),
1403                    }],
1404                    usage: None,
1405                    stop_reason: StopReason::EndTurn,
1406                    timestamp: 1,
1407                },
1408            })]),
1409            auth_store: AuthStore::new(tmp.path().join("auth.json")),
1410            model_registry: ModelRegistry::with_builtins(),
1411            cwd,
1412            agent_task: Some(tokio::spawn(async move {
1413                tokio::time::sleep(std::time::Duration::from_secs(60)).await;
1414                (
1415                    Agent::new(
1416                        test_model_with_events(vec![Ok(StreamEvent::MessageEnd {
1417                            message: AssistantMessage {
1418                                content: vec![ContentBlock::Text {
1419                                    text: "done".into(),
1420                                }],
1421                                usage: None,
1422                                stop_reason: StopReason::EndTurn,
1423                                timestamp: 1,
1424                            },
1425                        })]),
1426                        PathBuf::from("/tmp"),
1427                    )
1428                    .0,
1429                    Ok(()),
1430                )
1431            })),
1432            completed_run_result: None,
1433            pending_persistence_errors: VecDeque::new(),
1434            context_prefill: Vec::new(),
1435            context_prefill_injected: false,
1436        };
1437
1438        session.abort();
1439        let result = session.wait().await;
1440        assert!(matches!(result, Err(Error::Cancelled)));
1441    }
1442
1443    #[tokio::test]
1444    async fn prompt_uses_session_history_without_duplicate_active_prompt() {
1445        let tmp = TempDir::new().unwrap();
1446        let cwd = tmp.path().join("project");
1447        let session_dir = tmp.path().join("sessions");
1448        let model = test_model_with_events(vec![Ok(StreamEvent::MessageEnd {
1449            message: AssistantMessage {
1450                content: vec![ContentBlock::Text {
1451                    text: "done".into(),
1452                }],
1453                usage: None,
1454                stop_reason: StopReason::EndTurn,
1455                timestamp: 42,
1456            },
1457        })]);
1458        let mut session_mgr = SessionManager::new(&cwd, &session_dir).unwrap();
1459        session_mgr
1460            .append(SessionEntry::Message {
1461                id: "existing-user".into(),
1462                parent_id: None,
1463                message: imp_llm::Message::user("earlier"),
1464            })
1465            .unwrap();
1466
1467        let (agent, handle) = Agent::new(clone_model(&model), cwd.clone());
1468        let mut session = ImpSession {
1469            agent: Some(agent),
1470            handle,
1471            session_mgr,
1472            config: Config::default(),
1473            model,
1474            auth_store: AuthStore::new(tmp.path().join("auth.json")),
1475            model_registry: ModelRegistry::with_builtins(),
1476            cwd,
1477            agent_task: None,
1478            completed_run_result: None,
1479            pending_persistence_errors: VecDeque::new(),
1480            context_prefill: Vec::new(),
1481            context_prefill_injected: false,
1482        };
1483
1484        session.prompt("latest").await.unwrap();
1485        while let Some(event) = session.recv_event().await {
1486            if matches!(event, AgentEvent::AgentEnd { .. }) {
1487                break;
1488            }
1489        }
1490        session.wait().await.unwrap();
1491
1492        let messages: Vec<_> = session.session_mgr.get_active_messages();
1493        assert_eq!(messages.len(), 3);
1494        match &messages[0] {
1495            imp_llm::Message::User(user) => match user.content.as_slice() {
1496                [ContentBlock::Text { text }] => assert_eq!(text, "earlier"),
1497                other => panic!("unexpected user content: {other:?}"),
1498            },
1499            other => panic!("unexpected message: {other:?}"),
1500        }
1501        match &messages[1] {
1502            imp_llm::Message::User(user) => match user.content.as_slice() {
1503                [ContentBlock::Text { text }] => assert_eq!(text, "latest"),
1504                other => panic!("unexpected user content: {other:?}"),
1505            },
1506            other => panic!("unexpected message: {other:?}"),
1507        }
1508        match &messages[2] {
1509            imp_llm::Message::Assistant(assistant) => match assistant.content.as_slice() {
1510                [ContentBlock::Text { text }] => assert_eq!(text, "done"),
1511                other => panic!("unexpected assistant content: {other:?}"),
1512            },
1513            other => panic!("unexpected message: {other:?}"),
1514        }
1515    }
1516
1517    #[tokio::test]
1518    async fn prompt_uses_compacted_active_history_for_follow_up_turns() {
1519        let tmp = TempDir::new().unwrap();
1520        let cwd = tmp.path().join("project");
1521        let session_dir = tmp.path().join("sessions");
1522        let model = test_model_with_events(vec![Ok(StreamEvent::MessageEnd {
1523            message: AssistantMessage {
1524                content: vec![ContentBlock::Text {
1525                    text: "follow-up done".into(),
1526                }],
1527                usage: None,
1528                stop_reason: StopReason::EndTurn,
1529                timestamp: 99,
1530            },
1531        })]);
1532        let mut session_mgr = SessionManager::new(&cwd, &session_dir).unwrap();
1533        session_mgr
1534            .append(SessionEntry::Message {
1535                id: "u1".into(),
1536                parent_id: None,
1537                message: imp_llm::Message::user("older request"),
1538            })
1539            .unwrap();
1540        session_mgr
1541            .append(SessionEntry::Message {
1542                id: "a1".into(),
1543                parent_id: None,
1544                message: imp_llm::Message::Assistant(AssistantMessage {
1545                    content: vec![ContentBlock::Text {
1546                        text: "older answer".into(),
1547                    }],
1548                    usage: None,
1549                    stop_reason: StopReason::EndTurn,
1550                    timestamp: 1,
1551                }),
1552            })
1553            .unwrap();
1554        session_mgr
1555            .append(SessionEntry::Message {
1556                id: "u2".into(),
1557                parent_id: None,
1558                message: imp_llm::Message::user("recent request"),
1559            })
1560            .unwrap();
1561        session_mgr
1562            .append(SessionEntry::Compaction {
1563                id: "c1".into(),
1564                parent_id: None,
1565                summary: "[CONTEXT COMPACTION] compacted summary".into(),
1566                first_kept_id: "u2".into(),
1567                tokens_before: 100,
1568                tokens_after: 40,
1569            })
1570            .unwrap();
1571
1572        let (agent, handle) = Agent::new(clone_model(&model), cwd.clone());
1573        let mut session = ImpSession {
1574            agent: Some(agent),
1575            handle,
1576            session_mgr,
1577            config: Config::default(),
1578            model,
1579            auth_store: AuthStore::new(tmp.path().join("auth.json")),
1580            model_registry: ModelRegistry::with_builtins(),
1581            cwd,
1582            agent_task: None,
1583            completed_run_result: None,
1584            pending_persistence_errors: VecDeque::new(),
1585            context_prefill: Vec::new(),
1586            context_prefill_injected: false,
1587        };
1588
1589        session.prompt("new follow-up").await.unwrap();
1590        while let Some(event) = session.recv_event().await {
1591            if matches!(event, AgentEvent::AgentEnd { .. }) {
1592                break;
1593            }
1594        }
1595        session.wait().await.unwrap();
1596
1597        let messages = session.session_mgr.get_active_messages();
1598        assert_eq!(messages.len(), 4);
1599        match &messages[0] {
1600            imp_llm::Message::User(user) => match user.content.as_slice() {
1601                [ContentBlock::Text { text }] => assert!(text.contains("CONTEXT COMPACTION")),
1602                other => panic!("unexpected summary content: {other:?}"),
1603            },
1604            other => panic!("unexpected message: {other:?}"),
1605        }
1606        match &messages[1] {
1607            imp_llm::Message::User(user) => match user.content.as_slice() {
1608                [ContentBlock::Text { text }] => assert_eq!(text, "recent request"),
1609                other => panic!("unexpected recent user content: {other:?}"),
1610            },
1611            other => panic!("unexpected message: {other:?}"),
1612        }
1613        match &messages[2] {
1614            imp_llm::Message::User(user) => match user.content.as_slice() {
1615                [ContentBlock::Text { text }] => assert_eq!(text, "new follow-up"),
1616                other => panic!("unexpected follow-up content: {other:?}"),
1617            },
1618            other => panic!("unexpected message: {other:?}"),
1619        }
1620    }
1621
1622    #[test]
1623    fn persist_event_entries_writes_assistant_and_canonical_usage() {
1624        let tmp = TempDir::new().unwrap();
1625        let cwd = tmp.path().join("project");
1626        let session_dir = tmp.path().join("sessions");
1627        let model = test_model();
1628        let session_mgr = SessionManager::new(&cwd, &session_dir).unwrap();
1629        let (_agent, handle) = Agent::new(clone_model(&model), cwd.clone());
1630
1631        let mut session = ImpSession {
1632            agent: None,
1633            handle,
1634            session_mgr,
1635            config: Config::default(),
1636            model,
1637            auth_store: AuthStore::new(tmp.path().join("auth.json")),
1638            model_registry: ModelRegistry::with_builtins(),
1639            cwd,
1640            agent_task: None,
1641            completed_run_result: None,
1642            pending_persistence_errors: VecDeque::new(),
1643            context_prefill: Vec::new(),
1644            context_prefill_injected: false,
1645        };
1646
1647        let message = test_assistant_message(
1648            123,
1649            Some(Usage {
1650                input_tokens: 1_000,
1651                output_tokens: 250,
1652                cache_read_tokens: 100,
1653                cache_write_tokens: 50,
1654            }),
1655        );
1656
1657        let persisted = session.persist_event_entries(&AgentEvent::TurnEnd {
1658            index: 2,
1659            message: message.clone(),
1660            mana_review: crate::mana_review::TurnManaReview::no_change(2),
1661        });
1662
1663        assert_eq!(persisted, vec!["assistant message", "canonical usage"]);
1664
1665        let usage_records = session.session_mgr.usage_records();
1666        assert_eq!(usage_records.len(), 1);
1667        let record = &usage_records[0];
1668        assert_eq!(record.turn_index, Some(2));
1669        assert_eq!(record.provider.as_deref(), Some("test-provider"));
1670        assert_eq!(record.model.as_deref(), Some("test-model"));
1671        assert!(record.request_id.starts_with("assistant:"));
1672        assert!(record.assistant_message_id.is_some());
1673        let cost = record.cost.as_ref().unwrap();
1674        assert!((cost.input - 0.002).abs() < 1e-12);
1675        assert!((cost.output - 0.001).abs() < 1e-12);
1676        assert!((cost.cache_read - 0.00005).abs() < 1e-12);
1677        assert!((cost.cache_write - 0.00005).abs() < 1e-12);
1678        assert!((cost.total - 0.0031).abs() < 1e-12);
1679    }
1680
1681    #[test]
1682    fn persist_event_entries_skips_usage_record_when_usage_missing() {
1683        let tmp = TempDir::new().unwrap();
1684        let cwd = tmp.path().join("project");
1685        let session_dir = tmp.path().join("sessions");
1686        let model = test_model();
1687        let session_mgr = SessionManager::new(&cwd, &session_dir).unwrap();
1688        let (_agent, handle) = Agent::new(clone_model(&model), cwd.clone());
1689
1690        let mut session = ImpSession {
1691            agent: None,
1692            handle,
1693            session_mgr,
1694            config: Config::default(),
1695            model,
1696            auth_store: AuthStore::new(tmp.path().join("auth.json")),
1697            model_registry: ModelRegistry::with_builtins(),
1698            cwd,
1699            agent_task: None,
1700            completed_run_result: None,
1701            pending_persistence_errors: VecDeque::new(),
1702            context_prefill: Vec::new(),
1703            context_prefill_injected: false,
1704        };
1705
1706        let persisted = session.persist_event_entries(&AgentEvent::TurnEnd {
1707            index: 0,
1708            message: test_assistant_message(456, None),
1709            mana_review: crate::mana_review::TurnManaReview::no_change(0),
1710        });
1711
1712        assert_eq!(persisted, vec!["assistant message"]);
1713        assert!(session.session_mgr.usage_records().is_empty());
1714    }
1715
1716    #[test]
1717    fn persist_event_entries_writes_tool_results() {
1718        let tmp = TempDir::new().unwrap();
1719        let cwd = tmp.path().join("project");
1720        let session_dir = tmp.path().join("sessions");
1721        let model = test_model();
1722        let session_mgr = SessionManager::new(&cwd, &session_dir).unwrap();
1723        let (agent, handle) = Agent::new(clone_model(&model), cwd.clone());
1724        std::fs::create_dir_all(&cwd).unwrap();
1725        let file = cwd.join("tracked.rs");
1726        std::fs::write(&file, "original").unwrap();
1727        let checkpoint = agent
1728            .checkpoint_state
1729            .snapshot_paths(
1730                std::slice::from_ref(&file),
1731                Some("before tool result".into()),
1732            )
1733            .unwrap()
1734            .unwrap();
1735        std::fs::write(&file, "modified").unwrap();
1736
1737        let mut session = ImpSession {
1738            agent: Some(agent),
1739            handle,
1740            session_mgr,
1741            config: Config::default(),
1742            model,
1743            auth_store: AuthStore::new(tmp.path().join("auth.json")),
1744            model_registry: ModelRegistry::with_builtins(),
1745            cwd,
1746            agent_task: None,
1747            completed_run_result: None,
1748            pending_persistence_errors: VecDeque::new(),
1749            context_prefill: Vec::new(),
1750            context_prefill_injected: false,
1751        };
1752
1753        let persisted = session.persist_event_entries(&AgentEvent::ToolExecutionEnd {
1754            tool_call_id: "call-1".into(),
1755            result: imp_llm::ToolResultMessage {
1756                tool_call_id: "call-1".into(),
1757                tool_name: "bash".into(),
1758                content: vec![ContentBlock::Text { text: "ok".into() }],
1759                is_error: false,
1760                details: json!({"exit_code": 0}),
1761                timestamp: 999,
1762            },
1763            provenance: None,
1764        });
1765
1766        assert_eq!(persisted, vec!["tool result"]);
1767        assert!(session.session_mgr.entries().iter().any(|entry| matches!(
1768            entry,
1769            SessionEntry::Message {
1770                message: imp_llm::Message::ToolResult(_),
1771                ..
1772            }
1773        )));
1774        let checkpoints = session.session_mgr.checkpoint_records();
1775        assert_eq!(checkpoints.len(), 1);
1776        assert_eq!(checkpoints[0].checkpoint_id, checkpoint.id);
1777        let restored = session
1778            .session_mgr
1779            .restore_checkpoint(
1780                session
1781                    .agent
1782                    .as_ref()
1783                    .expect("agent retained for persistence test")
1784                    .checkpoint_state
1785                    .as_ref(),
1786                &checkpoints[0].checkpoint_id,
1787            )
1788            .unwrap();
1789        assert_eq!(restored, vec![file.clone()]);
1790        assert_eq!(std::fs::read_to_string(&file).unwrap(), "original");
1791    }
1792}