1use 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#[derive(Debug, Clone, Default)]
56pub enum SessionChoice {
57 #[default]
59 New,
60 InMemory,
62 Continue,
64 Open(PathBuf),
66}
67
68use crate::tools::LuaToolLoader;
69use crate::workflow::{AutonomyMode, VerificationGate};
70
71pub struct SessionOptions {
75 pub cwd: PathBuf,
77
78 pub model_override: Option<Model>,
81
82 pub model: Option<String>,
85
86 pub provider: Option<String>,
88
89 pub api_key: Option<String>,
91
92 pub thinking: Option<ThinkingLevel>,
94
95 pub mode: Option<AgentMode>,
97
98 pub autonomy_mode: Option<AutonomyMode>,
100
101 pub verification_gates: Vec<VerificationGate>,
103
104 pub max_turns: Option<u32>,
106
107 pub max_tokens: Option<u32>,
109
110 pub system_prompt: Option<String>,
112
113 pub no_tools: bool,
115
116 pub session: SessionChoice,
118
119 pub task: Option<TaskContext>,
121
122 pub facts: Vec<Fact>,
124
125 pub lua_loader: Option<LuaToolLoader>,
129
130 pub run_policy: RunPolicy,
132
133 pub ui: Option<Arc<dyn UserInterface>>,
135
136 pub auth_path: Option<PathBuf>,
138
139 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
187pub 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
225pub 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 agent_task: Option<JoinHandle<(Agent, Result<()>)>>,
242 completed_run_result: Option<Result<()>>,
243 pending_persistence_errors: VecDeque<String>,
244 context_prefill: Vec<imp_llm::Message>,
246 context_prefill_injected: bool,
247}
248
249impl ImpSession {
250 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 let mut config = Config::resolve(&Config::user_config_dir(), Some(&cwd))?;
260
261 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 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 let _ = key; }
283
284 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 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 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 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 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 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 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 if !self.context_prefill_injected && !self.context_prefill.is_empty() {
459 for msg in &self.context_prefill {
460 history.push(msg.clone());
461 }
462 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 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 pub async fn prompt_and_wait(&mut self, text: &str) -> Result<()> {
493 self.prompt(text).await?;
494 self.wait().await
495 }
496
497 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 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 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 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 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 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 pub fn event_rx(&mut self) -> &mut mpsc::Receiver<AgentEvent> {
593 &mut self.handle.event_rx
594 }
595
596 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 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 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 pub fn model(&self) -> &Model {
638 &self.model
639 }
640
641 pub fn config(&self) -> &Config {
643 &self.config
644 }
645
646 pub fn session_manager(&self) -> &SessionManager {
648 &self.session_mgr
649 }
650
651 pub fn session_manager_mut(&mut self) -> &mut SessionManager {
653 &mut self.session_mgr
654 }
655
656 pub fn cwd(&self) -> &PathBuf {
658 &self.cwd
659 }
660
661 pub fn auth_store(&self) -> &AuthStore {
663 &self.auth_store
664 }
665
666 pub fn auth_store_mut(&mut self) -> &mut AuthStore {
668 &mut self.auth_store
669 }
670
671 pub fn model_registry(&self) -> &ModelRegistry {
673 &self.model_registry
674 }
675
676 pub fn is_running(&self) -> bool {
678 self.agent_task.is_some()
679 }
680
681 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}
735async 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 ®istry,
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 ®istry,
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 ®istry,
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 ®istry,
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 ®istry,
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 ®istry,
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}