1use crate::compaction::{
5 self, estimate_context_tokens, extract_file_ops, file_ops_details, plan_compaction,
6 should_compact,
7};
8use crate::decision::DecisionModel;
9use crate::iterative::IterativeRuntime;
10use crate::session::manager::SessionManager;
11use crate::settings::{CacheWarmingMode, QueueMode, Settings};
12use crate::subagents::{ForkTurns, SUBAGENT_SYSTEM_PROMPT, SubagentRuntime, fork_messages};
13use crate::workflows::{WorkflowApprover, WorkflowRuntime};
14use anyhow::Context as _;
15use futures::FutureExt as _;
16use futures::future::BoxFuture;
17use kiss_agent::{
18 AgentContext, AgentEvent, AgentLoopConfig, AgentMessage, DynTool, EventSink, StreamFn,
19 TurnUpdate,
20};
21use kiss_ai::{Model, Registry, StopReason, ThinkingLevel, Usage};
22use std::collections::VecDeque;
23use std::future::Future;
24use std::sync::{Arc, Mutex, OnceLock};
25use tokio_util::sync::CancellationToken;
26
27#[derive(Debug, Clone)]
29pub enum SessionEvent {
30 Agent(Box<AgentEvent>),
31 PromptEnd {
33 cancelled: bool,
34 },
35 QueueUpdate {
36 steering: Vec<String>,
37 follow_up: Vec<String>,
38 },
39 CompactionStart {
40 auto: bool,
41 },
42 CompactionEnd {
43 summary: String,
44 tokens_before: u64,
45 error: Option<String>,
46 },
47 Retry {
48 attempt: u32,
49 max: u32,
50 delay_ms: u64,
51 error: String,
52 },
53 ModelChanged {
54 provider: String,
55 model_id: String,
56 },
57 ReasoningEffortChanged {
58 decision_model: String,
59 level: ThinkingLevel,
60 generations: u8,
61 },
62 ReasoningEffortFallback {
63 level: ThinkingLevel,
64 reason: String,
65 },
66 Workflow {
69 run: crate::workflows::RunId,
70 version: u64,
71 },
72 WorkflowOutcome {
75 run: Option<crate::workflows::RunId>,
76 name: String,
77 status: WorkflowTurnStatus,
78 },
79 Iterative {
81 job: crate::iterative::JobId,
82 version: u64,
83 },
84 AccountSwitched(kiss_ai::auth::accounts::AccountSwitch),
87}
88
89pub type SessionEventSink = Arc<dyn Fn(SessionEvent) + Send + Sync>;
90
91#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
96pub enum PromptMode {
97 #[default]
98 Ordinary,
99 Workflow,
100}
101
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub enum WorkflowTurnStatus {
105 Cancelled,
106 Completed,
107 Failed,
108 Stopped,
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub enum RunPhase {
114 Idle,
115 Streaming,
117 RetryWait,
119 Compacting,
121}
122
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125pub enum QueueKind {
126 Steering,
128 FollowUp,
130}
131
132struct ActivePhase {
135 session: Arc<AgentSession>,
136 cancel: CancellationToken,
137 armed: bool,
138}
139
140impl ActivePhase {
141 fn take_queued(&mut self) -> Option<(Vec<AgentMessage>, PromptMode)> {
147 let session = &self.session;
148 let settings = session.settings();
149 let mut phase = session.phase.lock().unwrap();
150 if !self.cancel.is_cancelled() {
151 for (queue, queue_mode) in [
152 (&session.steering, settings.steering_mode),
153 (&session.follow_up, settings.follow_up_mode),
154 ] {
155 if let Some(prompt_mode) = queued_mode(queue, queue_mode) {
156 let prompts = drain_queue(queue, queue_mode);
157 *phase = RunPhase::Streaming;
158 drop(phase);
159 session.emit_queues();
160 return Some((prompts, prompt_mode));
161 }
162 }
163 }
164 *phase = RunPhase::Idle;
165 session.idle.notify_waiters();
166 self.armed = false;
167 None
168 }
169}
170
171impl Drop for ActivePhase {
172 fn drop(&mut self) {
173 if self.armed {
174 self.cancel.cancel();
175 *self.session.phase.lock().unwrap() = RunPhase::Idle;
176 self.session.idle.notify_waiters();
177 }
178 }
179}
180
181#[derive(Clone)]
182struct QueuedPrompt {
183 message: AgentMessage,
184 mode: PromptMode,
185}
186
187pub struct TreeNavigationOutcome {
188 pub editor_text: Option<String>,
189 pub summarized: bool,
190 pub cancelled: bool,
191}
192
193#[derive(Debug, Clone)]
194pub struct EphemeralResponse {
195 pub text: String,
196 pub usage: Usage,
197}
198
199const SESSION_TITLE_MAX_CHARS: usize = 36;
200const SESSION_TITLE_PROMPT_MAX_BYTES: usize = 960;
201
202fn bounded_session_title_prompt(prompt: &str) -> &str {
203 let prompt = prompt.trim();
204 let end = prompt.floor_char_boundary(SESSION_TITLE_PROMPT_MAX_BYTES);
205 &prompt[..end]
206}
207
208fn normalize_session_title(text: &str) -> Option<String> {
209 let line = text.lines().find(|line| !line.trim().is_empty())?;
210 let cleaned: String = line
211 .chars()
212 .filter(|character| !character.is_control())
213 .collect();
214 let normalized = cleaned
215 .trim()
216 .trim_matches(|character| matches!(character, '"' | '\'' | '`' | '“' | '”' | '‘' | '’'))
217 .split_whitespace()
218 .collect::<Vec<_>>()
219 .join(" ");
220 let title: String = normalized.chars().take(SESSION_TITLE_MAX_CHARS).collect();
221 let title = title
222 .trim_end_matches(['.', '?', '!', ',', ':', ';'])
223 .trim();
224 (!title.is_empty()).then(|| title.to_string())
225}
226
227pub struct AgentSession {
228 pub manager: Mutex<SessionManager>,
229 pub registry: Arc<Registry>,
230 base_tools: Mutex<Vec<DynTool>>,
231 session_tools: Mutex<Vec<DynTool>>,
232 tools: Mutex<Vec<DynTool>>,
233 settings: Mutex<Settings>,
234 system_prompt: Mutex<String>,
235 model: Mutex<Model>,
236 thinking: Mutex<ThinkingLevel>,
237 fast_mode: Mutex<bool>,
238 steering: Arc<Mutex<VecDeque<QueuedPrompt>>>,
239 follow_up: Arc<Mutex<VecDeque<QueuedPrompt>>>,
240 cancel: Mutex<CancellationToken>,
241 cache_warm_cancel: Mutex<CancellationToken>,
242 phase: Mutex<RunPhase>,
243 idle: tokio::sync::Notify,
244 totals: Mutex<Usage>,
245 context_usage_cache: Mutex<Option<(u64, u64)>>,
246 context_file: Mutex<Option<crate::context_file::ContextFile>>,
247 api_key_override: Option<(String, String)>,
248 sink: SessionEventSink,
249 subagents_allowed: bool,
250 subagents: OnceLock<Arc<SubagentRuntime>>,
251 workflows: OnceLock<Arc<WorkflowRuntime>>,
252 iterative: OnceLock<Arc<IterativeRuntime>>,
253 workflow_approver: Mutex<Option<WorkflowApprover>>,
254 stream_fn: Mutex<Option<StreamFn>>,
257}
258
259struct ReasoningRunState {
260 decision_model: Option<DecisionModel>,
261 lease: crate::decision::ReasoningLease,
262 provider: String,
263 model_id: String,
264 saved_effort: ThinkingLevel,
265}
266
267impl ReasoningRunState {
268 fn checkpoint(
269 &mut self,
270 model: &Model,
271 saved_effort: ThinkingLevel,
272 current_effort: ThinkingLevel,
273 decision_model: Option<DecisionModel>,
274 ) -> (bool, Option<ThinkingLevel>) {
275 let model_changed = self.provider != model.provider || self.model_id != model.id;
276 if model_changed
277 || self.saved_effort != saved_effort
278 || self.decision_model != decision_model
279 || decision_model.is_none()
280 {
281 self.lease.clear();
282 }
283 self.decision_model = decision_model;
284 self.provider.clone_from(&model.provider);
285 self.model_id.clone_from(&model.id);
286 self.saved_effort = saved_effort;
287 if self
288 .lease
289 .current()
290 .is_some_and(|level| level != current_effort)
291 {
292 self.lease.clear();
293 }
294 (model_changed, self.lease.current())
295 }
296}
297
298impl AgentSession {
299 pub(crate) fn emit_iterative(&self, job: crate::iterative::JobId, version: u64) {
300 (self.sink)(SessionEvent::Iterative { job, version });
301 }
302
303 pub(crate) fn emit_workflow(&self, run: crate::workflows::RunId, version: u64) {
304 (self.sink)(SessionEvent::Workflow { run, version });
305 }
306
307 pub(crate) fn emit_workflow_outcome(
308 &self,
309 run: Option<crate::workflows::RunId>,
310 name: String,
311 status: WorkflowTurnStatus,
312 ) {
313 (self.sink)(SessionEvent::WorkflowOutcome { run, name, status });
314 }
315
316 #[allow(clippy::too_many_arguments)]
317 pub fn new(
318 manager: SessionManager,
319 tools: Vec<DynTool>,
320 registry: impl Into<Arc<Registry>>,
321 settings: Settings,
322 system_prompt: String,
323 model: Model,
324 thinking: ThinkingLevel,
325 api_key_override: Option<(String, String)>,
326 sink: SessionEventSink,
327 ) -> Arc<Self> {
328 Self::new_with_subagents_allowed(
329 manager,
330 tools,
331 registry,
332 settings,
333 system_prompt,
334 model,
335 thinking,
336 api_key_override,
337 sink,
338 true,
339 )
340 }
341
342 #[allow(clippy::too_many_arguments)]
343 pub fn new_with_subagents_allowed(
344 manager: SessionManager,
345 tools: Vec<DynTool>,
346 registry: impl Into<Arc<Registry>>,
347 settings: Settings,
348 system_prompt: String,
349 model: Model,
350 thinking: ThinkingLevel,
351 api_key_override: Option<(String, String)>,
352 sink: SessionEventSink,
353 subagents_allowed: bool,
354 ) -> Arc<Self> {
355 let totals = manager.usage_totals();
356 let session = Arc::new(AgentSession {
357 manager: Mutex::new(manager),
358 registry: registry.into(),
359 base_tools: Mutex::new(tools.clone()),
360 session_tools: Mutex::new(Vec::new()),
361 tools: Mutex::new(tools),
362 settings: Mutex::new(settings),
363 system_prompt: Mutex::new(system_prompt),
364 model: Mutex::new(model),
365 thinking: Mutex::new(thinking),
366 fast_mode: Mutex::new(false),
367 steering: Default::default(),
368 follow_up: Default::default(),
369 cancel: Mutex::new(CancellationToken::new()),
370 cache_warm_cancel: Mutex::new(CancellationToken::new()),
371 phase: Mutex::new(RunPhase::Idle),
372 idle: tokio::sync::Notify::new(),
373 totals: Mutex::new(totals),
374 context_usage_cache: Default::default(),
375 context_file: Mutex::new(None),
376 api_key_override,
377 sink,
378 subagents_allowed,
379 subagents: OnceLock::new(),
380 workflows: OnceLock::new(),
381 iterative: OnceLock::new(),
382 workflow_approver: Mutex::new(None),
383 stream_fn: Mutex::new(None),
384 });
385 if subagents_allowed {
386 let runtime = SubagentRuntime::new(Arc::downgrade(&session));
387 assert!(session.subagents.set(runtime).is_ok());
388 let workflows = WorkflowRuntime::new(Arc::downgrade(&session));
389 assert!(session.workflows.set(workflows).is_ok());
390 let iterative = IterativeRuntime::new(Arc::downgrade(&session));
391 assert!(session.iterative.set(iterative).is_ok());
392 }
393 session.rebuild_tools();
394 session
395 }
396
397 pub fn set_stream_fn(&self, stream_fn: Option<StreamFn>) {
403 *self.stream_fn.lock().unwrap() = stream_fn;
404 }
405
406 pub fn model(&self) -> Model {
407 self.model.lock().unwrap().clone()
408 }
409
410 pub fn thinking_level(&self) -> ThinkingLevel {
411 *self.thinking.lock().unwrap()
412 }
413
414 pub fn fast_mode(&self) -> bool {
415 *self.fast_mode.lock().unwrap()
416 }
417
418 pub fn set_fast_mode(&self, enabled: bool) {
419 *self.fast_mode.lock().unwrap() = enabled;
420 }
421
422 pub fn totals(&self) -> Usage {
423 *self.totals.lock().unwrap()
424 }
425
426 pub(crate) fn record_subagent_usage(&self, usage: Usage) {
427 self.totals.lock().unwrap().add(&usage);
428 }
429
430 pub fn run_phase(&self) -> RunPhase {
431 *self.phase.lock().unwrap()
432 }
433
434 pub fn is_running(&self) -> bool {
435 self.run_phase() != RunPhase::Idle
436 }
437
438 pub async fn wait_idle(&self) {
440 loop {
441 let idle = self.idle.notified();
444 if !self.is_running() {
445 return;
446 }
447 idle.await;
448 }
449 }
450
451 fn activate(self: &Arc<Self>, current: &mut RunPhase, phase: RunPhase) -> ActivePhase {
454 *current = phase;
455 let cancel = CancellationToken::new();
456 *self.cancel.lock().unwrap() = cancel.clone();
457 ActivePhase {
458 session: self.clone(),
459 cancel,
460 armed: true,
461 }
462 }
463
464 pub fn settings(&self) -> Settings {
465 self.settings.lock().unwrap().clone()
466 }
467
468 pub fn update_settings(&self, settings: Settings) {
469 *self.context_usage_cache.lock().unwrap() = None;
470 let was_enabled = self.subagents_enabled();
471 let workflows_were_enabled = self.workflows_enabled();
472 let cache_warming = settings.cache_warming;
473 *self.settings.lock().unwrap() = settings;
474 if cache_warming == CacheWarmingMode::Off
475 || (cache_warming == CacheWarmingMode::Streaming && !self.is_running())
476 {
477 self.cache_warm_cancel.lock().unwrap().cancel();
478 }
479 let is_enabled = self.subagents_enabled();
480 if was_enabled && !is_enabled {
481 self.stop_child_work();
482 } else if workflows_were_enabled && !self.workflows_enabled() {
483 self.stop_workflows();
484 }
485 self.rebuild_tools();
486 }
487
488 pub fn reload_runtime(&self, settings: Settings, system_prompt: String, tools: Vec<DynTool>) {
490 *self.context_usage_cache.lock().unwrap() = None;
491 let was_enabled = self.subagents_enabled();
492 let workflows_were_enabled = self.workflows_enabled();
493 let cache_warming = settings.cache_warming;
494 *self.settings.lock().unwrap() = settings;
495 *self.system_prompt.lock().unwrap() = system_prompt;
496 *self.base_tools.lock().unwrap() = tools;
497 if cache_warming == CacheWarmingMode::Off
498 || (cache_warming == CacheWarmingMode::Streaming && !self.is_running())
499 {
500 self.cache_warm_cancel.lock().unwrap().cancel();
501 }
502 let is_enabled = self.subagents_enabled();
503 if was_enabled && !is_enabled {
504 self.stop_child_work();
505 } else if workflows_were_enabled && !self.workflows_enabled() {
506 self.stop_workflows();
507 }
508 self.rebuild_tools();
509 }
510
511 pub fn install_session_tool(&self, tool: DynTool) {
513 let mut tools = self.session_tools.lock().unwrap();
514 if let Some(existing) = tools
515 .iter_mut()
516 .find(|existing| existing.name() == tool.name())
517 {
518 *existing = tool;
519 } else {
520 tools.push(tool);
521 }
522 drop(tools);
523 self.rebuild_tools();
524 }
525
526 pub fn remove_session_tool(&self, name: &str) -> bool {
528 let mut tools = self.session_tools.lock().unwrap();
529 let previous_len = tools.len();
530 tools.retain(|tool| tool.name() != name);
531 let removed = tools.len() != previous_len;
532 drop(tools);
533 if removed {
534 self.rebuild_tools();
535 }
536 removed
537 }
538
539 fn stop_child_work(&self) {
541 if let Some(runtime) = self.subagents.get() {
542 runtime.interrupt_all();
543 }
544 if let Some(runtime) = self.workflows.get() {
545 runtime.stop_all();
546 }
547 if let Some(runtime) = self.iterative.get() {
548 runtime.stop_all();
549 }
550 }
551
552 fn stop_workflows(&self) {
553 if let Some(runtime) = self.workflows.get() {
554 runtime.stop_all();
555 }
556 }
557
558 fn subagents_enabled(&self) -> bool {
559 self.subagents_allowed && self.settings.lock().unwrap().subagents.enabled
560 }
561
562 pub fn workflows_enabled(&self) -> bool {
565 self.subagents_enabled() && self.settings.lock().unwrap().workflows.enabled
566 }
567
568 pub fn prompt_mode_for(&self, text: &str) -> PromptMode {
570 if self.workflows_enabled()
571 && self.settings.lock().unwrap().workflows.keyword_trigger
572 && crate::workflows::workflow_trigger(text).is_some()
573 {
574 PromptMode::Workflow
575 } else {
576 PromptMode::Ordinary
577 }
578 }
579
580 pub fn workflows(&self) -> Option<Arc<WorkflowRuntime>> {
581 self.workflows.get().cloned()
582 }
583
584 pub fn iterative_jobs(&self) -> Option<Arc<IterativeRuntime>> {
585 self.iterative.get().cloned()
586 }
587
588 pub fn set_workflow_approver(&self, approver: WorkflowApprover) {
590 *self.workflow_approver.lock().unwrap() = Some(approver);
591 }
592
593 pub(crate) fn workflow_approver(&self) -> Option<WorkflowApprover> {
594 self.workflow_approver.lock().unwrap().clone()
595 }
596
597 fn rebuild_tools(&self) {
598 let mut tools = self.base_tools.lock().unwrap().clone();
599 for session_tool in self.session_tools.lock().unwrap().iter().cloned() {
600 if let Some(existing) = tools
601 .iter_mut()
602 .find(|existing| existing.name() == session_tool.name())
603 {
604 *existing = session_tool;
605 } else {
606 tools.push(session_tool);
607 }
608 }
609 if self.subagents_enabled()
610 && let Some(runtime) = self.subagents.get()
611 {
612 tools.extend(runtime.control_tools());
613 }
614 *self.tools.lock().unwrap() = tools;
615 }
616
617 fn tools_for(&self, mode: PromptMode) -> Vec<DynTool> {
618 let mut tools = self.tools.lock().unwrap().clone();
619 if mode == PromptMode::Workflow
620 && self.workflows_enabled()
621 && let Some(runtime) = self.workflows.get()
622 {
623 tools.push(runtime.tool());
624 }
625 tools
626 }
627
628 pub fn system_context(&self) -> (String, Vec<(String, String)>) {
630 let tools = self
631 .tools
632 .lock()
633 .unwrap()
634 .iter()
635 .map(|tool| (tool.name().to_string(), tool.description()))
636 .collect();
637 (self.system_prompt.lock().unwrap().clone(), tools)
638 }
639
640 pub fn available_tool_names(&self) -> Vec<String> {
641 self.tools
642 .lock()
643 .unwrap()
644 .iter()
645 .map(|tool| tool.name().to_string())
646 .collect()
647 }
648
649 #[cfg(test)]
650 fn available_tool_names_for(&self, mode: PromptMode) -> Vec<String> {
651 self.tools_for(mode)
652 .iter()
653 .map(|tool| tool.name().to_string())
654 .collect()
655 }
656
657 pub fn replace_manager(&self, manager: SessionManager) -> anyhow::Result<()> {
660 self.replace_manager_with(|| Ok(manager))
661 }
662
663 pub fn new_session(&self) -> anyhow::Result<()> {
665 self.replace_manager_with(|| self.manager.lock().unwrap().create_sibling())
666 }
667
668 fn replace_manager_with(
669 &self,
670 create: impl FnOnce() -> anyhow::Result<SessionManager>,
671 ) -> anyhow::Result<()> {
672 let _phase = self.phase.lock().unwrap();
673 if *_phase != RunPhase::Idle {
674 anyhow::bail!(
675 "The session has an active prompt. Abort it or wait for it to finish before you replace the session."
676 );
677 }
678 let manager = create()?;
679 self.cache_warm_cancel.lock().unwrap().cancel();
680 *self.context_file.lock().unwrap() = None;
681 if let Some(runtime) = self.subagents.get() {
682 runtime.reset();
683 }
684 if let Some(runtime) = self.workflows.get() {
685 runtime.stop_all();
686 }
687 if let Some(runtime) = self.iterative.get() {
688 runtime.stop_all();
689 }
690 let totals = manager.usage_totals();
691 let (saved_model, saved_thinking) = manager.session_settings();
692 if let Some((provider, model_id)) = saved_model
693 && let Some((model, _)) = self.registry.resolve(&model_id, Some(&provider))
694 {
695 *self.model.lock().unwrap() = model;
696 }
697 if let Some(thinking) = saved_thinking {
698 *self.thinking.lock().unwrap() = thinking;
699 }
700 *self.manager.lock().unwrap() = manager;
701 *self.context_usage_cache.lock().unwrap() = None;
702 *self.totals.lock().unwrap() = totals;
703 Ok(())
704 }
705
706 pub fn set_model(&self, model: Model) {
707 self.cache_warm_cancel.lock().unwrap().cancel();
708 {
709 let mut m = self.manager.lock().unwrap();
710 let _ = m.append_model_change(&model.provider, &model.id);
711 }
712 (self.sink)(SessionEvent::ModelChanged {
713 provider: model.provider.clone(),
714 model_id: model.id.clone(),
715 });
716 *self.model.lock().unwrap() = model;
717 }
718
719 pub fn set_thinking_level(&self, level: ThinkingLevel) {
720 let _ = self
721 .manager
722 .lock()
723 .unwrap()
724 .append_thinking_level_change(level);
725 *self.thinking.lock().unwrap() = level;
726 }
727
728 pub async fn navigate_tree(
731 self: &Arc<Self>,
732 target_id: &str,
733 summarize: bool,
734 custom_instructions: Option<String>,
735 cancel: CancellationToken,
736 ) -> anyhow::Result<TreeNavigationOutcome> {
737 let (old_leaf, new_leaf, editor_text, abandoned_messages) = {
738 let manager = self.manager.lock().unwrap();
739 let target = manager
740 .get_entry(target_id)
741 .cloned()
742 .ok_or_else(|| anyhow::anyhow!("unknown tree entry {target_id}"))?;
743 let old_leaf = manager.leaf_id().map(str::to_string);
744 if old_leaf.as_deref() == Some(target_id) {
745 return Ok(TreeNavigationOutcome {
746 editor_text: None,
747 summarized: false,
748 cancelled: false,
749 });
750 }
751
752 let (new_leaf, editor_text) = match &target {
753 crate::session::entry::SessionEntry::Message {
754 message: AgentMessage::User(user),
755 ..
756 } => (
757 target.parent_id().map(str::to_string),
758 Some(user.content.as_text()),
759 ),
760 _ => (Some(target_id.to_string()), None),
761 };
762
763 let target_ancestors: std::collections::HashSet<String> = new_leaf
764 .as_deref()
765 .map(|leaf| {
766 manager
767 .branch_entries(Some(leaf))
768 .into_iter()
769 .map(|entry| entry.id().to_string())
770 .collect()
771 })
772 .unwrap_or_default();
773 let common_ancestor = old_leaf.as_deref().and_then(|leaf| {
774 manager
775 .branch_entries(Some(leaf))
776 .into_iter()
777 .rev()
778 .find(|entry| target_ancestors.contains(entry.id()))
779 .map(|entry| entry.id().to_string())
780 });
781 let abandoned_messages = old_leaf
782 .as_deref()
783 .map(|leaf| manager.branch_messages_after(leaf, common_ancestor.as_deref()))
784 .unwrap_or_default();
785 (old_leaf, new_leaf, editor_text, abandoned_messages)
786 };
787
788 let summary = if summarize && !abandoned_messages.is_empty() {
789 let model = self.model();
790 let credential = self.resolve_credential(&model.provider).await;
791 let serialized = compaction::serialize_agent_messages(&abandoned_messages);
792 let result = compaction::generate_summary(
793 &model,
794 credential,
795 &serialized,
796 None,
797 custom_instructions.as_deref(),
798 false,
799 cancel.clone(),
800 )
801 .await?;
802 if cancel.is_cancelled() {
803 return Ok(TreeNavigationOutcome {
804 editor_text: None,
805 summarized: false,
806 cancelled: true,
807 });
808 }
809 Some(result)
810 } else {
811 None
812 };
813
814 let mut manager = self.manager.lock().unwrap();
815 if manager.leaf_id() != old_leaf.as_deref() {
816 anyhow::bail!("the session tree changed during navigation");
817 }
818 let summarized = if let Some(summary) = summary {
819 let (read, modified) = extract_file_ops(&abandoned_messages);
820 if let Some(usage) = &summary.usage {
821 self.totals.lock().unwrap().add(usage);
822 }
823 manager.branch_with_summary(
824 new_leaf.as_deref(),
825 old_leaf.as_deref().unwrap_or(target_id),
826 summary.summary,
827 summary.usage,
828 Some(file_ops_details(&read, &modified)),
829 )?;
830 true
831 } else {
832 if let Some(new_leaf) = new_leaf.as_deref() {
833 manager.branch(new_leaf)?;
834 } else {
835 manager.reset_leaf();
836 }
837 false
838 };
839
840 Ok(TreeNavigationOutcome {
841 editor_text,
842 summarized,
843 cancelled: false,
844 })
845 }
846
847 pub fn queue_steering(&self, message: AgentMessage) {
848 self.steering.lock().unwrap().push_back(QueuedPrompt {
849 message,
850 mode: PromptMode::Ordinary,
851 });
852 self.emit_queues();
853 }
854
855 pub fn queue_follow_up(&self, message: AgentMessage) {
856 self.follow_up.lock().unwrap().push_back(QueuedPrompt {
857 message,
858 mode: PromptMode::Ordinary,
859 });
860 self.emit_queues();
861 }
862
863 pub fn add_for_next_turn(&self, message: AgentMessage) {
867 let phase = self.phase.lock().unwrap();
868 if *phase != RunPhase::Idle {
869 self.follow_up.lock().unwrap().push_back(QueuedPrompt {
870 message,
871 mode: PromptMode::Ordinary,
872 });
873 drop(phase);
874 self.emit_queues();
875 return;
876 }
877 drop(phase);
880 let _ = self.manager.lock().unwrap().append_message(message);
881 }
882
883 pub fn reclaim_queued(&self) -> Vec<AgentMessage> {
885 let mut out: Vec<AgentMessage> = self
886 .steering
887 .lock()
888 .unwrap()
889 .drain(..)
890 .map(|prompt| prompt.message)
891 .collect();
892 out.extend(
893 self.follow_up
894 .lock()
895 .unwrap()
896 .drain(..)
897 .map(|prompt| prompt.message),
898 );
899 self.emit_queues();
900 out
901 }
902
903 pub fn abort(&self) {
904 self.cancel.lock().unwrap().cancel();
905 }
906
907 fn emit_queues(&self) {
908 let preview = |q: &VecDeque<QueuedPrompt>| {
909 q.iter()
910 .map(|prompt| match &prompt.message {
911 AgentMessage::User(u) => u.content.as_text().chars().take(80).collect(),
912 other => other.role().to_string(),
913 })
914 .collect::<Vec<String>>()
915 };
916 (self.sink)(SessionEvent::QueueUpdate {
917 steering: preview(&self.steering.lock().unwrap()),
918 follow_up: preview(&self.follow_up.lock().unwrap()),
919 });
920 }
921
922 async fn resolve_credential(&self, provider: &str) -> Option<kiss_ai::ResolvedCredential> {
923 if let Some((override_provider, key)) = &self.api_key_override
924 && override_provider == provider
925 {
926 return Some(kiss_ai::ResolvedCredential::api_key(key));
927 }
928 let credential_provider = self.registry.credential_provider(provider);
929 kiss_ai::auth::resolve_credential_async(credential_provider, &self.registry.declared_keys)
930 .await
931 .ok()
932 .flatten()
933 }
934
935 fn loop_config(
936 &self,
937 session_arc: &Arc<Self>,
938 active_prompt_mode: Arc<Mutex<PromptMode>>,
939 ) -> AgentLoopConfig {
940 let mut config = AgentLoopConfig::new(self.model());
941 config.thinking_level = self.thinking_level();
942 config.fast_mode = self.fast_mode();
943 config.session_id = Some(self.manager.lock().unwrap().session_id().to_string());
944 let settings = self.settings();
945 config.transport = settings.transport;
946 let registry = self.registry.clone();
947 let declared = self.registry.declared_keys.clone();
948 let api_key_override = self.api_key_override.clone();
949 config.get_credential = Some(Arc::new(move |provider| {
950 let registry = registry.clone();
951 let declared = declared.clone();
952 let api_key_override = api_key_override.clone();
953 Box::pin(async move {
954 if let Some((override_provider, key)) = api_key_override
955 && override_provider == provider
956 {
957 return Some(kiss_ai::ResolvedCredential::api_key(key));
958 }
959 let credential_provider = registry.credential_provider(&provider).to_string();
960 kiss_ai::auth::resolve_credential_async(&credential_provider, &declared)
961 .await
962 .ok()
963 .flatten()
964 })
965 }));
966
967 let steering = self.steering.clone();
968 let steering_for_mode = self.steering.clone();
969 let steering_mode = settings.steering_mode;
970 let session_for_queues = session_arc.clone();
971 config.get_steering_messages = Some(Arc::new(move || {
972 let drained = drain_queue(&steering, steering_mode);
973 session_for_queues.emit_queues();
974 Box::pin(async move { drained })
975 }));
976 let follow_up = self.follow_up.clone();
977 let follow_up_for_mode = self.follow_up.clone();
978 let follow_up_mode = settings.follow_up_mode;
979 let session_for_queues = session_arc.clone();
980 config.get_follow_up_messages = Some(Arc::new(move || {
981 let drained = drain_queue(&follow_up, follow_up_mode);
982 session_for_queues.emit_queues();
983 Box::pin(async move { drained })
984 }));
985 let reasoning_run = Arc::new(Mutex::new(ReasoningRunState {
986 decision_model: settings.reasoning_effort.mode.decision_model(),
987 lease: crate::decision::ReasoningLease::default(),
988 provider: config.model.provider.clone(),
989 model_id: config.model.id.clone(),
990 saved_effort: config.thinking_level,
991 }));
992 let session_for_reasoning = session_arc.clone();
993 let reasoning_for_generation = reasoning_run.clone();
994 let prompt_mode_for_reasoning = active_prompt_mode.clone();
995 config.prepare_generation = Some(Arc::new(move |current_effort| {
996 let session = session_for_reasoning.clone();
997 let reasoning_run = reasoning_for_generation.clone();
998 let active_prompt_mode = prompt_mode_for_reasoning.clone();
999 Box::pin(async move {
1000 let settings = session.settings();
1001 let context_file_changed = session.sync_context_file();
1002 let model = session.model();
1003 let saved_effort = session.thinking_level();
1004 let decision_model = settings
1005 .reasoning_effort
1006 .mode
1007 .decision_model()
1008 .filter(|_| model.reasoning);
1009 let (model_changed, leased) = {
1010 let mut run = reasoning_run.lock().unwrap();
1011 run.checkpoint(&model, saved_effort, current_effort, decision_model)
1012 };
1013 let mut update = TurnUpdate {
1014 context: (model_changed
1015 || context_file_changed
1016 || settings.experimental_context_file)
1017 .then(|| session.build_context_for(*active_prompt_mode.lock().unwrap())),
1018 model: model_changed.then(|| model.clone()),
1019 thinking_level: Some(saved_effort),
1020 fast_mode: Some(session.fast_mode()),
1021 ..Default::default()
1022 };
1023 if settings.experimental_context_file
1024 && let Some(context) = &mut update.context
1025 {
1026 while matches!(context.messages.last(), Some(AgentMessage::Assistant(assistant)) if assistant.stop_reason == StopReason::Error)
1029 {
1030 context.messages.pop();
1031 }
1032 }
1033 let Some(decision_model) = decision_model else {
1034 return Some(update);
1035 };
1036 if let Some(level) = leased {
1037 update.thinking_level = Some(level);
1038 return Some(update);
1039 }
1040
1041 let selected = match kiss_ai::auth::resolve_api_key_async(
1042 decision_model.provider(),
1043 &session.registry.declared_keys,
1044 )
1045 .await
1046 {
1047 Ok(Some(api_key)) => {
1048 let messages = session
1049 .manager
1050 .lock()
1051 .unwrap()
1052 .build_session_context()
1053 .messages;
1054 let cancel = session.cancel.lock().unwrap().clone();
1055 async {
1056 crate::decision::DecisionClient::new(decision_model, &api_key)?
1057 .select_reasoning(&messages, &[], &model, cancel)
1058 .await
1059 }
1060 .await
1061 .map_err(|error: anyhow::Error| error.to_string())
1062 }
1063 Ok(None) => Err(format!(
1064 "{} credentials are unavailable; run /login {}",
1065 decision_model.label(),
1066 decision_model.provider(),
1067 )),
1068 Err(error) => Err(format!(
1069 "{} credentials could not be read: {error}; run /login {}",
1070 decision_model.label(),
1071 decision_model.provider(),
1072 )),
1073 };
1074 let latest_model = session.model();
1075 let latest_effort = session.thinking_level();
1076 if latest_model.provider != model.provider
1077 || latest_model.id != model.id
1078 || latest_effort != saved_effort
1079 || session.settings().reasoning_effort.mode != settings.reasoning_effort.mode
1080 {
1081 reasoning_run.lock().unwrap().lease.clear();
1082 update.context =
1083 Some(session.build_context_for(*active_prompt_mode.lock().unwrap()));
1084 update.model = Some(latest_model);
1085 update.thinking_level = Some(latest_effort);
1086 return Some(update);
1087 }
1088 match selected {
1089 Ok(selection) => {
1090 update.thinking_level = Some(selection.level);
1091 if selection.level != current_effort {
1092 let sink = session.sink.clone();
1093 let level = selection.level;
1094 let generations = selection.generations;
1095 update.on_applied = Some(Box::new(move |_, applied| {
1096 if applied == level {
1097 sink(SessionEvent::ReasoningEffortChanged {
1098 decision_model: decision_model.label().into(),
1099 level,
1100 generations,
1101 });
1102 }
1103 }));
1104 }
1105 reasoning_run.lock().unwrap().lease.install(&selection);
1106 }
1107 Err(reason) => {
1108 let sink = session.sink.clone();
1109 let reason = reason.chars().take(300).collect();
1110 update.on_applied = Some(Box::new(move |_, applied| {
1111 sink(SessionEvent::ReasoningEffortFallback {
1112 level: applied,
1113 reason,
1114 });
1115 }));
1116 }
1117 }
1118 Some(update)
1119 })
1120 }));
1121 let session_for_compaction = session_arc.clone();
1122 config.prepare_next_turn = Some(Arc::new(move |turn| {
1123 let has_tool_results = !turn.tool_results.is_empty();
1124 let tool_failed = turn.tool_results.iter().any(|result| result.is_error);
1125 let will_continue = turn.will_continue;
1126 let session = session_for_compaction.clone();
1127 let active_prompt_mode = active_prompt_mode.clone();
1128 let steering_for_mode = steering_for_mode.clone();
1129 let follow_up_for_mode = follow_up_for_mode.clone();
1130 let reasoning_run = reasoning_run.clone();
1131 Box::pin(async move {
1132 let has_queued_user_input = !steering_for_mode.lock().unwrap().is_empty()
1133 || (!will_continue && !follow_up_for_mode.lock().unwrap().is_empty());
1134 let queued_mode = queued_mode(&steering_for_mode, steering_mode)
1135 .or_else(|| queued_mode(&follow_up_for_mode, follow_up_mode));
1136 let mode_changed = {
1137 let mut active = active_prompt_mode.lock().unwrap();
1138 let before = *active;
1139 if let Some(queued_mode) = queued_mode {
1140 *active = queued_mode;
1141 } else if *active == PromptMode::Workflow && !has_tool_results {
1142 *active = PromptMode::Ordinary;
1146 }
1147 before != *active
1148 };
1149
1150 let context_file_changed = session.sync_context_file();
1151 let mut context_changed = mode_changed || context_file_changed;
1152 if has_tool_results {
1153 let settings = session.settings();
1154 let cancel = session.cancel.lock().unwrap().clone();
1155 let model = session.model();
1156 let revision_before = {
1157 let manager = session.manager.lock().unwrap();
1158 let context = manager.build_session_context();
1159 if !auto_compaction_needed(
1160 &settings,
1161 &context.messages,
1162 &model,
1163 cancel.is_cancelled(),
1164 ) {
1165 None
1166 } else {
1167 Some(manager.context_revision())
1168 }
1169 };
1170
1171 if let Some(revision_before) = revision_before {
1172 session.auto_compact().await;
1173 let revision_after = session.manager.lock().unwrap().context_revision();
1174 context_changed |= revision_after != revision_before;
1175 }
1176 }
1177
1178 {
1179 let mut run = reasoning_run.lock().unwrap();
1180 run.lease.consume_generation();
1181 if tool_failed || has_queued_user_input {
1182 run.lease.clear();
1183 }
1184 }
1185
1186 Some(TurnUpdate {
1187 context: context_changed
1188 .then(|| session.build_context_for(*active_prompt_mode.lock().unwrap())),
1189 fast_mode: Some(session.fast_mode()),
1190 ..Default::default()
1191 })
1192 })
1193 }));
1194 if let Some(stream_fn) = self.stream_fn.lock().unwrap().clone() {
1195 config.stream_fn = stream_fn;
1196 }
1197 let sink = self.sink.clone();
1198 config.stream_fn = crate::account_failover::wrap(
1199 config.stream_fn,
1200 self.registry.clone(),
1201 Arc::new(move |switch| sink(SessionEvent::AccountSwitched(switch))),
1202 );
1203 config
1204 }
1205
1206 fn sync_context_file(&self) -> bool {
1207 let mut file = self.context_file.lock().unwrap();
1208 if !self.settings().experimental_context_file {
1209 return file.take().is_some();
1210 }
1211 let mut manager = self.manager.lock().unwrap();
1212 let result = (|| {
1213 if file.is_none() {
1214 *file = Some(crate::context_file::ContextFile::new().context(
1215 "Cannot create a context file. A writable temporary directory is required. Set TMPDIR to a writable directory and restart KISS",
1216 )?);
1217 }
1218 file.as_mut().unwrap().sync(&mut manager)
1219 })();
1220 match result {
1221 Ok(changed) => {
1222 if changed {
1223 *self.context_usage_cache.lock().unwrap() = None;
1224 self.cache_warm_cancel.lock().unwrap().cancel();
1225 }
1226 changed
1227 }
1228 Err(error) => {
1229 let message = AgentMessage::Custom(kiss_agent::CustomMessage {
1230 custom_type: "context_file_error".into(),
1231 content: kiss_ai::UserContent::Text(format!("{error:#}")),
1232 display: true,
1233 details: None,
1234 timestamp: kiss_ai::now_ms(),
1235 });
1236 if manager.append_message(message.clone()).is_err() {
1237 return false;
1238 }
1239 drop(manager);
1240 drop(file);
1241 (self.sink)(SessionEvent::Agent(Box::new(AgentEvent::MessageEnd {
1242 message,
1243 })));
1244 *self.context_usage_cache.lock().unwrap() = None;
1245 true
1246 }
1247 }
1248 }
1249
1250 fn build_context(&self) -> AgentContext {
1251 self.build_context_for(PromptMode::Ordinary)
1252 }
1253
1254 fn build_context_for(&self, prompt_mode: PromptMode) -> AgentContext {
1255 let model = self.model();
1256 let manager = self.manager.lock().unwrap();
1257 let (openai_responses_input, messages) = if !self.settings().experimental_context_file
1258 && kiss_ai::api::openai_compaction::supports_remote_compaction(&model)
1259 && let Some(remote) = manager.build_openai_compaction_context(&model)
1260 {
1261 (Some(remote.replacement_history), remote.messages)
1262 } else {
1263 (None, manager.build_session_context().messages)
1264 };
1265 drop(manager);
1266 let mut system_prompt = self.system_prompt.lock().unwrap().clone();
1267 let tools = self.tools_for(prompt_mode);
1268 if model.provider == "cursor"
1270 && let Some(shell) = tools.iter().find(|tool| tool.name() == "bash")
1271 {
1272 system_prompt.push_str("\n\nKISS shell tool (bash): ");
1273 system_prompt.push_str(&shell.description());
1274 }
1275 if self.settings().experimental_context_file
1276 && let Some(file) = self.context_file.lock().unwrap().as_ref()
1277 {
1278 system_prompt.push_str(&format!(
1279 "\n\nExperimental context file: {}\n\
1280 This JSON array contains your live KISS conversation, without system instructions. \
1281 Use your normal file tools to edit it. Changes apply after the tool batch and before the next model request. \
1282 New user messages, your current answer, and tool results are added automatically. \
1283 Keep important task instructions, exact facts, progress, and next steps. Replace stale output with useful notes. \
1284 Keep complete assistant tool-call/result groups, or replace the whole group with a user note. \
1285 A user note has the form {{\"role\":\"user\",\"content\":\"Notes here\",\"timestamp\":0}}. \
1286 Preserve fields of messages you keep, including image and reasoning data. \
1287 The file must be a valid JSON array at most 16 MiB. Invalid edits leave the live conversation unchanged; repair the file after an error. \
1288 Read only the parts you need, because the conversation is already in your context. \
1289 Your model context window is {} tokens. Manage the file before it fills; automatic compaction remains an emergency fallback. \
1290 Batch edits: changing early text can require the provider to process all later text again. \
1291 The full session record remains separate from this editable file.",
1292 file.path().display(), model.context_window
1293 ));
1294 }
1295 if self.subagents_enabled() {
1296 system_prompt.push_str("\n\n");
1297 system_prompt.push_str(SUBAGENT_SYSTEM_PROMPT);
1298 }
1299 if prompt_mode == PromptMode::Workflow
1300 && self.workflows_enabled()
1301 && let Some(runtime) = self.workflows.get()
1302 {
1303 let limits = runtime.limits();
1304 let size = self.settings.lock().unwrap().workflows.size;
1305 system_prompt.push_str("\n\n");
1306 system_prompt.push_str(&crate::workflows::authoring_prompt(
1307 size,
1308 limits.max_agents,
1309 limits.max_fanout,
1310 ));
1311 }
1312 AgentContext {
1313 system_prompt,
1314 openai_responses_input,
1315 messages,
1316 tools,
1317 }
1318 }
1319
1320 pub(crate) fn create_subagent_session(
1321 self: &Arc<Self>,
1322 task_name: &str,
1323 canonical_path: &str,
1324 fork_turns: ForkTurns,
1325 model_pattern: Option<&str>,
1326 reasoning_effort: Option<&str>,
1327 ) -> anyhow::Result<Arc<Self>> {
1328 let (model, suggested_thinking) = match model_pattern {
1329 Some(pattern) => self
1330 .registry
1331 .resolve(pattern, None)
1332 .with_context(|| format!("no model matches subagent model '{pattern}'"))?,
1333 None => (self.model(), None),
1334 };
1335 let thinking = match reasoning_effort {
1336 Some(level) => ThinkingLevel::parse(level)
1337 .with_context(|| format!("unknown subagent reasoning_effort '{level}'"))?,
1338 None => suggested_thinking.unwrap_or_else(|| self.thinking_level()),
1339 };
1340 let (mut manager, parent_messages, parent_id) = {
1341 let parent = self.manager.lock().unwrap();
1342 (
1343 parent.create_child()?,
1344 if fork_turns == ForkTurns::None {
1345 Vec::new()
1346 } else {
1347 parent.build_session_context().messages
1348 },
1349 parent.session_id().to_string(),
1350 )
1351 };
1352 for message in fork_messages(&parent_messages, fork_turns) {
1353 manager.append_message(message)?;
1354 }
1355 manager.append_custom(
1356 "subagent",
1357 Some(serde_json::json!({
1358 "taskName": task_name,
1359 "canonicalPath": canonical_path,
1360 "parentSessionId": parent_id,
1361 })),
1362 )?;
1363
1364 let mut settings = self.settings();
1365 settings.subagents.enabled = false;
1366 let mut system_prompt = self.system_prompt.lock().unwrap().clone();
1367 system_prompt.push_str(&format!(
1368 "\n\nYou are child agent {canonical_path}. Complete only the assigned task. Return a concise result to the parent agent."
1369 ));
1370
1371 let child = Self::new_with_subagents_allowed(
1372 manager,
1373 self.base_tools.lock().unwrap().clone(),
1374 self.registry.clone(),
1375 settings,
1376 system_prompt,
1377 model,
1378 thinking,
1379 self.api_key_override.clone(),
1380 Arc::new(|_| {}),
1381 false,
1382 );
1383 child.set_stream_fn(self.stream_fn.lock().unwrap().clone());
1384 Ok(child)
1385 }
1386
1387 async fn run_ephemeral(
1388 self: &Arc<Self>,
1389 system_prompt: String,
1390 prompt: String,
1391 tools: Vec<DynTool>,
1392 max_tokens: u64,
1393 cancel: CancellationToken,
1394 ) -> anyhow::Result<EphemeralResponse> {
1395 let mut config = self.loop_config(self, Arc::new(Mutex::new(PromptMode::Ordinary)));
1396 config.thinking_level = ThinkingLevel::Off;
1397 config.max_tokens = Some(max_tokens);
1398 config.session_id = Some(format!("ephemeral-{}", uuid::Uuid::new_v4()));
1399 config.get_steering_messages = None;
1400 config.get_follow_up_messages = None;
1401 config.prepare_next_turn = None;
1402 config.prepare_generation = None;
1403
1404 let context = AgentContext {
1405 system_prompt,
1406 openai_responses_input: None,
1407 messages: Vec::new(),
1408 tools,
1409 };
1410 let sink: EventSink = Arc::new(|_| {});
1411 let messages = kiss_agent::run_agent_loop(
1412 vec![AgentMessage::user(prompt)],
1413 context,
1414 config,
1415 cancel.clone(),
1416 sink,
1417 )
1418 .await;
1419 if cancel.is_cancelled() {
1420 anyhow::bail!("request cancelled");
1421 }
1422
1423 let mut usage = Usage::default();
1424 for message in &messages {
1425 if let AgentMessage::Assistant(assistant) = message {
1426 usage.add(&assistant.usage);
1427 }
1428 }
1429 let assistant = messages.iter().rev().find_map(|message| match message {
1430 AgentMessage::Assistant(assistant) => Some(assistant),
1431 _ => None,
1432 });
1433 let Some(assistant) = assistant else {
1434 anyhow::bail!("the provider returned no answer");
1435 };
1436 if assistant.stop_reason == StopReason::Error {
1437 anyhow::bail!(
1438 "{}",
1439 assistant
1440 .error_message
1441 .as_deref()
1442 .unwrap_or("the provider request failed")
1443 );
1444 }
1445 let text = assistant.text();
1446 if text.trim().is_empty() {
1447 anyhow::bail!("the provider returned an empty answer");
1448 }
1449 self.totals.lock().unwrap().add(&usage);
1450 Ok(EphemeralResponse { text, usage })
1451 }
1452
1453 pub async fn answer_btw(
1455 self: &Arc<Self>,
1456 question: &str,
1457 cancel: CancellationToken,
1458 ) -> anyhow::Result<EphemeralResponse> {
1459 let messages = self
1460 .manager
1461 .lock()
1462 .unwrap()
1463 .build_session_context()
1464 .messages;
1465 let transcript = transcript_excerpt(&messages, 4, 4_000);
1466 let prompt = if transcript.is_empty() {
1467 format!("Side question:\n{question}")
1468 } else {
1469 format!("Recent session context:\n{transcript}\n\nSide question:\n{question}")
1470 };
1471 let read_tools = self
1472 .tools
1473 .lock()
1474 .unwrap()
1475 .iter()
1476 .filter(|tool| tool.name() == "read")
1477 .cloned()
1478 .collect();
1479 self.run_ephemeral(
1480 "Answer the side question from the supplied session context. This is a read-only request. Use the read tool only when a file is needed. Do not propose or perform edits. Give no more than 150 words or 600 characters. Use no more than five bullets. Return only the answer.".into(),
1481 prompt,
1482 read_tools,
1483 500,
1484 cancel,
1485 )
1486 .await
1487 }
1488
1489 pub async fn generate_session_title(
1491 self: &Arc<Self>,
1492 prompt: &str,
1493 cancel: CancellationToken,
1494 ) -> anyhow::Result<String> {
1495 let prompt = bounded_session_title_prompt(prompt);
1496 if prompt.is_empty() {
1497 anyhow::bail!("the session prompt is empty");
1498 }
1499 let response = self
1500 .run_ephemeral(
1501 format!(
1502 "Write a one-line title for this task, no more than {SESSION_TITLE_MAX_CHARS} characters. Aim for fewer than five words and start with an imperative verb. Keep ticket IDs and code terms unchanged. Match the user's language. Return the title in sentence case without quotes, Markdown, or ending punctuation."
1503 ),
1504 format!("User prompt:\n{prompt}"),
1505 Vec::new(),
1506 64,
1507 cancel,
1508 )
1509 .await?;
1510 normalize_session_title(&response.text)
1511 .context("the provider returned an invalid session title")
1512 }
1513
1514 pub async fn generate_recap(
1516 self: &Arc<Self>,
1517 previous_recap: Option<&str>,
1518 cancel: CancellationToken,
1519 ) -> anyhow::Result<EphemeralResponse> {
1520 let messages = self
1521 .manager
1522 .lock()
1523 .unwrap()
1524 .build_session_context()
1525 .messages;
1526 let transcript = transcript_excerpt(&messages, 12, 12_000);
1527 if transcript.is_empty() {
1528 anyhow::bail!("the session has no conversation to recap");
1529 }
1530 let previous = previous_recap
1531 .filter(|recap| !recap.trim().is_empty())
1532 .map(|recap| format!("\n\nPrevious recap:\n{recap}"))
1533 .unwrap_or_default();
1534 self.run_ephemeral(
1535 "Summarize the supplied coding session in one plain-text line of at most 120 characters. State what was done and the next action when one is clear. Do not use a prefix, Markdown, or a newline. Return only the recap.".into(),
1536 format!("Session transcript:\n{transcript}{previous}"),
1537 Vec::new(),
1538 160,
1539 cancel,
1540 )
1541 .await
1542 }
1543
1544 pub fn prompt(
1549 self: &Arc<Self>,
1550 prompts: Vec<AgentMessage>,
1551 ) -> impl Future<Output = ()> + Send + 'static + use<> {
1552 self.prompt_with_mode(prompts, PromptMode::Ordinary)
1553 }
1554
1555 pub fn prompt_with_mode(
1557 self: &Arc<Self>,
1558 prompts: Vec<AgentMessage>,
1559 prompt_mode: PromptMode,
1560 ) -> impl Future<Output = ()> + Send + 'static + use<> {
1561 let run = self.submit(prompts, prompt_mode, QueueKind::Steering);
1562 async move {
1563 if let Some(run) = run {
1564 run.await;
1565 }
1566 }
1567 }
1568
1569 pub fn try_start_prompt(
1571 self: &Arc<Self>,
1572 message: AgentMessage,
1573 prompt_mode: PromptMode,
1574 ) -> Option<BoxFuture<'static, ()>> {
1575 self.cache_warm_cancel.lock().unwrap().cancel();
1576 let active = {
1577 let mut phase = self.phase.lock().unwrap();
1578 (*phase == RunPhase::Idle).then(|| self.activate(&mut phase, RunPhase::Streaming))?
1579 };
1580 Some(
1581 self.clone()
1582 .run_prompts(active, vec![message], prompt_mode)
1583 .boxed(),
1584 )
1585 }
1586
1587 pub fn submit(
1590 self: &Arc<Self>,
1591 prompts: Vec<AgentMessage>,
1592 prompt_mode: PromptMode,
1593 kind: QueueKind,
1594 ) -> Option<BoxFuture<'static, ()>> {
1595 self.cache_warm_cancel.lock().unwrap().cancel();
1596 let mut phase = self.phase.lock().unwrap();
1597 if *phase != RunPhase::Idle {
1598 let queue = match kind {
1599 QueueKind::Steering => &self.steering,
1600 QueueKind::FollowUp => &self.follow_up,
1601 };
1602 queue
1603 .lock()
1604 .unwrap()
1605 .extend(prompts.into_iter().map(|message| QueuedPrompt {
1606 message,
1607 mode: prompt_mode,
1608 }));
1609 drop(phase);
1610 self.emit_queues();
1611 return None;
1612 }
1613 let active = self.activate(&mut phase, RunPhase::Streaming);
1614 drop(phase);
1615 Some(
1616 self.clone()
1617 .run_prompts(active, prompts, prompt_mode)
1618 .boxed(),
1619 )
1620 }
1621
1622 async fn run_prompts(
1623 self: Arc<Self>,
1624 active: ActivePhase,
1625 prompts: Vec<AgentMessage>,
1626 prompt_mode: PromptMode,
1627 ) {
1628 let cancel = active.cancel.clone();
1629 {
1630 let mut manager = self.manager.lock().unwrap();
1631 for prompt in prompts {
1632 let _ = manager.append_message(prompt);
1633 }
1634 }
1635 self.run_turns(None, prompt_mode, &cancel).await;
1638 self.run_queued_then_idle(active, true).await;
1639 }
1640
1641 async fn run_queued_then_idle(self: &Arc<Self>, mut active: ActivePhase, mut prompt_ran: bool) {
1644 let cancel = active.cancel.clone();
1645 while let Some((prompts, prompt_mode)) = active.take_queued() {
1646 prompt_ran = true;
1647 self.run_turns(Some(prompts), prompt_mode, &cancel).await;
1648 }
1649 if self.settings.lock().unwrap().cache_warming == CacheWarmingMode::Streaming {
1650 self.cache_warm_cancel.lock().unwrap().cancel();
1651 }
1652 if prompt_ran {
1653 (self.sink)(SessionEvent::PromptEnd {
1654 cancelled: cancel.is_cancelled(),
1655 });
1656 }
1657 }
1658
1659 async fn auto_compact(self: &Arc<Self>) {
1661 *self.phase.lock().unwrap() = RunPhase::Compacting;
1662 self.run_compaction(None, true).await;
1663 *self.phase.lock().unwrap() = RunPhase::Streaming;
1664 }
1665
1666 async fn run_turns(
1669 self: &Arc<Self>,
1670 mut new_prompts: Option<Vec<AgentMessage>>,
1671 prompt_mode: PromptMode,
1672 cancel: &CancellationToken,
1673 ) {
1674 let session = self.clone();
1675 let sink: EventSink = Arc::new(move |event: AgentEvent| {
1676 session.on_agent_event(&event);
1677 (session.sink)(SessionEvent::Agent(Box::new(event)));
1678 });
1679
1680 let active_prompt_mode = Arc::new(Mutex::new(prompt_mode));
1681 let mut config = self.loop_config(self, active_prompt_mode.clone());
1682 let mut context = self.build_context_for(prompt_mode);
1683
1684 let mut attempt: u32 = 0;
1685 loop {
1686 let messages = match new_prompts.take() {
1687 Some(prompts) => {
1688 kiss_agent::run_agent_loop(
1689 prompts,
1690 context,
1691 config.clone(),
1692 cancel.clone(),
1693 sink.clone(),
1694 )
1695 .await
1696 }
1697 None => {
1698 kiss_agent::run_agent_loop_continue(
1699 context,
1700 config.clone(),
1701 cancel.clone(),
1702 sink.clone(),
1703 )
1704 .await
1705 }
1706 };
1707
1708 let last_error = messages.iter().rev().find_map(|m| match m {
1710 AgentMessage::Assistant(a) if a.stop_reason == StopReason::Error => {
1711 Some(a.error_message.clone().unwrap_or_default())
1712 }
1713 _ => None,
1714 });
1715 let settings = self.settings();
1716 let retry = &settings.retry;
1717 if let Some(error) = last_error
1718 && retry.enabled
1719 && attempt < retry.max_retries
1720 && is_transient(&error)
1721 && !cancel.is_cancelled()
1722 {
1723 attempt += 1;
1724 let delay = retry
1725 .base_delay_ms
1726 .saturating_mul(1u64.checked_shl(attempt - 1).unwrap_or(u64::MAX))
1727 .min(retry.max_agent_delay_ms);
1728 *self.phase.lock().unwrap() = RunPhase::RetryWait;
1729 (self.sink)(SessionEvent::Retry {
1730 attempt,
1731 max: retry.max_retries,
1732 delay_ms: delay,
1733 error,
1734 });
1735 tokio::select! {
1736 _ = tokio::time::sleep(std::time::Duration::from_millis(delay)) => {}
1737 _ = cancel.cancelled() => break,
1738 }
1739 *self.phase.lock().unwrap() = RunPhase::Streaming;
1740 context = self.build_context_for(*active_prompt_mode.lock().unwrap());
1741 while matches!(
1743 context.messages.last(),
1744 Some(AgentMessage::Assistant(a)) if a.stop_reason == StopReason::Error
1745 ) {
1746 context.messages.pop();
1747 }
1748 config.model = self.model();
1749 config.thinking_level = self.thinking_level();
1750 config.fast_mode = self.fast_mode();
1751 continue;
1752 }
1753
1754 let ctx = self.manager.lock().unwrap().build_session_context();
1756 if auto_compaction_needed(
1757 &settings,
1758 &ctx.messages,
1759 &self.model(),
1760 cancel.is_cancelled(),
1761 ) {
1762 self.auto_compact().await;
1763 }
1764 break;
1765 }
1766 }
1767
1768 fn on_agent_event(self: &Arc<Self>, event: &AgentEvent) {
1769 match event {
1770 AgentEvent::MessageEnd { message } => {
1771 let persist = match message {
1774 AgentMessage::Assistant(a) => {
1775 self.schedule_cache_warming(a);
1776 let mut totals = self.totals.lock().unwrap();
1777 totals.add(&a.usage);
1778 true
1779 }
1780 AgentMessage::ToolResult(_)
1781 | AgentMessage::User(_)
1782 | AgentMessage::Custom(_) => true,
1783 _ => false,
1784 };
1785 if persist {
1786 let mut manager = self.manager.lock().unwrap();
1789 let duplicate = matches!(
1790 (manager.entries().last(), message),
1791 (Some(crate::session::entry::SessionEntry::Message { message: last, .. }), m) if last == m
1792 );
1793 if !duplicate {
1794 let _ = manager.append_message(message.clone());
1795 }
1796 }
1797 }
1798 AgentEvent::AgentEnd { .. } => {}
1799 _ => {}
1800 }
1801 }
1802
1803 fn schedule_cache_warming(self: &Arc<Self>, assistant: &kiss_ai::AssistantMessage) {
1804 let settings = self.settings();
1805 let model = self.model();
1806 let Some(cache) = model.prompt_cache else {
1807 return;
1808 };
1809 let Some(short_ttl) = cache.short else {
1810 return;
1811 };
1812 let reasoning = self.thinking_level();
1813 let fast_mode = self.fast_mode();
1814 if settings.cache_warming == CacheWarmingMode::Off
1815 || matches!(
1816 assistant.stop_reason,
1817 StopReason::Error | StopReason::Aborted
1818 )
1819 || (assistant.usage.input + assistant.usage.cache_read + assistant.usage.cache_write
1820 == 0)
1821 || (reasoning != ThinkingLevel::Off
1822 && model.api == "anthropic-messages"
1823 && !model
1824 .compat
1825 .as_ref()
1826 .and_then(|compat| compat.force_adaptive_thinking)
1827 .unwrap_or(false))
1828 {
1829 return;
1830 }
1831 let ttl = std::time::Duration::from_secs(short_ttl);
1832 let Some(delay) = cache_warming_delay(ttl) else {
1833 return;
1834 };
1835 let prompt_tokens =
1836 assistant.usage.input + assistant.usage.cache_read + assistant.usage.cache_write;
1837 let agent_context = self.build_context();
1838 let context = kiss_ai::Context {
1839 system_prompt: Some(agent_context.system_prompt),
1840 openai_responses_input: agent_context.openai_responses_input,
1841 messages: kiss_agent::convert_to_llm(&agent_context.messages),
1842 tools: agent_context
1843 .tools
1844 .iter()
1845 .map(|tool| tool.to_def())
1846 .collect(),
1847 };
1848 let cancel = CancellationToken::new();
1849 {
1850 let mut current = self.cache_warm_cancel.lock().unwrap();
1851 current.cancel();
1852 *current = cancel.clone();
1853 }
1854 let session = self.clone();
1855 tokio::spawn(async move {
1856 let started = tokio::time::Instant::now();
1857 loop {
1858 let scheduled = tokio::time::Instant::now();
1859 if tokio::select! {
1860 _ = tokio::time::sleep(delay) => false,
1861 _ = cancel.cancelled() => true,
1862 } {
1863 return;
1864 }
1865 if cache_refresh_deadline_missed(scheduled.elapsed(), ttl, delay) {
1866 return;
1867 }
1868 let idle = !session.is_running();
1869 let max_age = if idle {
1870 std::time::Duration::from_secs(30 * 60)
1871 } else {
1872 std::time::Duration::from_secs(60 * 60)
1873 };
1874 if started.elapsed() > max_age {
1875 return;
1876 }
1877 let priced = |input: u64, output: u64, cache_read: u64, cache_write: u64| {
1878 let mut usage = Usage {
1879 input,
1880 output,
1881 cache_read,
1882 cache_write,
1883 ..Default::default()
1884 };
1885 kiss_ai::api::finalize_cost(&mut usage, &model);
1886 usage.cost.total
1887 };
1888 let hit_cost = priced(0, 0, prompt_tokens, 0);
1889 let miss_cost = if model.cost.cache_write > 0.0 {
1890 priced(0, 0, 0, prompt_tokens)
1891 } else {
1892 priced(prompt_tokens, 0, 0, 0)
1893 };
1894 let warm_cost = priced(0, 1, prompt_tokens, 0);
1895 let probability = if idle { 0.15 } else { 1.0 };
1896 if probability * (miss_cost - hit_cost).max(0.0) - warm_cost < 0.05 {
1897 return;
1898 }
1899 let Some(credential) = session.resolve_credential(&model.provider).await else {
1900 return;
1901 };
1902 let options = kiss_ai::StreamOptions {
1903 credential: Some(credential),
1904 max_tokens: Some(1),
1905 reasoning,
1906 fast_mode,
1907 session_id: Some(session.manager.lock().unwrap().session_id().to_string()),
1908 transport: settings.transport,
1909 cancel: cancel.clone(),
1910 ..Default::default()
1911 };
1912 let stream_fn = session
1913 .stream_fn
1914 .lock()
1915 .unwrap()
1916 .clone()
1917 .unwrap_or_else(|| Arc::new(kiss_ai::stream_simple));
1918 let warmed = stream_fn(&model, &context, &options).result().await;
1919 if matches!(warmed.stop_reason, StopReason::Error | StopReason::Aborted) {
1920 return;
1921 }
1922 session.totals.lock().unwrap().add(&warmed.usage);
1923 let _ = session.manager.lock().unwrap().append_usage(
1924 "cache_warm",
1925 &warmed.provider,
1926 warmed.response_model.as_deref().unwrap_or(&warmed.model),
1927 warmed.usage,
1928 None,
1929 );
1930 }
1931 });
1932 }
1933
1934 pub fn compact(
1938 self: &Arc<Self>,
1939 custom_instructions: Option<String>,
1940 ) -> impl Future<Output = Result<(), String>> + Send + 'static + use<> {
1941 let session = self.clone();
1942 let active = {
1943 let mut phase = self.phase.lock().unwrap();
1944 (*phase == RunPhase::Idle).then(|| self.activate(&mut phase, RunPhase::Compacting))
1945 };
1946 async move {
1947 let Some(active) = active else {
1948 let error = "cannot compact while a prompt or another compaction is running. Wait for it to finish or abort it, then compact again".to_string();
1949 (session.sink)(SessionEvent::CompactionEnd {
1950 summary: String::new(),
1951 tokens_before: 0,
1952 error: Some(error.clone()),
1953 });
1954 return Err(error);
1955 };
1956 session.run_compaction(custom_instructions, false).await;
1957 session.run_queued_then_idle(active, false).await;
1958 Ok(())
1959 }
1960 }
1961
1962 async fn run_compaction(self: &Arc<Self>, custom_instructions: Option<String>, auto: bool) {
1963 (self.sink)(SessionEvent::CompactionStart { auto });
1964 let ctx = self.manager.lock().unwrap().build_session_context();
1965 let previous_summary = ctx.messages.iter().rev().find_map(|m| match m {
1966 AgentMessage::CompactionSummary(c) => Some(c.summary.clone()),
1967 _ => None,
1968 });
1969 let settings = self.settings();
1970 let model = self.model();
1971 let override_settings = settings
1972 .compaction
1973 .model_overrides
1974 .get(&format!("{}/{}", model.provider, model.id));
1975 let keep_recent_tokens = override_settings
1976 .and_then(|value| value.keep_recent_tokens)
1977 .unwrap_or(settings.compaction.keep_recent_tokens);
1978 let reserve_tokens = override_settings
1979 .and_then(|value| value.reserve_tokens)
1980 .unwrap_or(settings.compaction.reserve_tokens);
1981 let plan = plan_compaction(&ctx.messages, keep_recent_tokens);
1982 if plan.to_summarize.is_empty() && plan.turn_prefix.is_empty() {
1983 (self.sink)(SessionEvent::CompactionEnd {
1984 summary: String::new(),
1985 tokens_before: plan.tokens_before,
1986 error: Some("Nothing to compact".into()),
1987 });
1988 return;
1989 }
1990
1991 if let Some(decision_model) = settings.compaction.mode.decision_model()
1992 && let Ok(Some(api_key)) = kiss_ai::auth::resolve_api_key_async(
1993 decision_model.provider(),
1994 &self.registry.declared_keys,
1995 )
1996 .await
1997 && let Ok(client) = crate::decision::DecisionClient::new(decision_model, &api_key)
1998 {
1999 let cancel = self.cancel.lock().unwrap().clone();
2000 let pinned_start = ctx.messages.len().saturating_sub(plan.kept.len());
2001 if let Ok(result) = client.compact(&ctx.messages, pinned_start, cancel).await {
2002 let estimated_before: u64 = ctx
2003 .messages
2004 .iter()
2005 .map(compaction::estimate_message_tokens)
2006 .sum();
2007 let estimated_after: u64 = result
2008 .messages
2009 .iter()
2010 .map(compaction::estimate_message_tokens)
2011 .sum();
2012 let removed = estimated_before.saturating_sub(estimated_after);
2013 let tokens_after = plan.tokens_before.saturating_sub(removed);
2014 let useful =
2015 estimated_after.saturating_mul(4) <= estimated_before.saturating_mul(3);
2016 let resolved_auto_threshold =
2017 !auto || !should_compact(tokens_after, model.context_window, reserve_tokens);
2018 if useful && resolved_auto_threshold {
2019 let summary = format!(
2020 "{} kept {}, truncated {}, and removed {} of {} older tool interactions",
2021 decision_model.label(),
2022 result.stats.kept,
2023 result.stats.truncated,
2024 result.stats.dropped,
2025 result.stats.eligible,
2026 );
2027 let details = serde_json::json!({
2028 "mode": settings.compaction.mode,
2029 "provider": decision_model.provider(),
2030 "model": decision_model.id(),
2031 "stats": result.stats,
2032 "estimatedTokensBefore": plan.tokens_before,
2033 "estimatedTokensAfter": tokens_after,
2034 });
2035 let mut manager = self.manager.lock().unwrap();
2036 let append = manager.append_compaction(
2037 String::new(),
2038 plan.tokens_before,
2039 result.messages,
2040 None,
2041 Some(details),
2042 );
2043 drop(manager);
2044 let error = append.err().map(|error| format!("{error:#}"));
2045 (self.sink)(SessionEvent::CompactionEnd {
2046 summary: if error.is_none() {
2047 summary
2048 } else {
2049 String::new()
2050 },
2051 tokens_before: plan.tokens_before,
2052 error,
2053 });
2054 return;
2055 }
2056 }
2057 }
2058
2059 let credential = self.resolve_credential(&model.provider).await;
2060
2061 let mut serialized = compaction::serialize_agent_messages(&plan.to_summarize);
2062 if plan.is_split_turn {
2063 serialized.push_str(
2064 "\n\n[The following is the earlier part of the still-active task turn:]\n\n",
2065 );
2066 serialized.push_str(&compaction::serialize_agent_messages(&plan.turn_prefix));
2067 }
2068
2069 let summary_cancel = self.cancel.lock().unwrap().clone();
2070 let remote_request = if kiss_ai::api::openai_compaction::supports_remote_compaction(&model)
2071 {
2072 let context = self.build_context();
2073 Some((
2074 kiss_ai::Context {
2075 system_prompt: Some(context.system_prompt),
2076 openai_responses_input: context.openai_responses_input,
2077 messages: kiss_agent::convert_to_llm(&context.messages),
2078 tools: context.tools.iter().map(|tool| tool.to_def()).collect(),
2079 },
2080 kiss_ai::StreamOptions {
2081 credential: credential.clone(),
2082 reasoning: self.thinking_level(),
2083 fast_mode: self.fast_mode(),
2084 session_id: Some(self.manager.lock().unwrap().session_id().to_string()),
2085 cancel: summary_cancel.clone(),
2086 ..Default::default()
2087 },
2088 ))
2089 } else {
2090 None
2091 };
2092 let local_future = compaction::generate_summary(
2093 &model,
2094 credential.clone(),
2095 &serialized,
2096 previous_summary.as_deref(),
2097 custom_instructions.as_deref(),
2098 plan.is_split_turn,
2099 summary_cancel.clone(),
2100 );
2101 let remote_model = model.clone();
2102 let remote_future = async move {
2103 match remote_request {
2104 Some((context, options)) => Some(
2105 kiss_ai::api::openai_compaction::compact(&remote_model, &context, &options)
2106 .await,
2107 ),
2108 None => None,
2109 }
2110 };
2111 let (local_outcome, remote_outcome) = tokio::join!(local_future, remote_future);
2112
2113 match select_compaction_outcome(&model, local_outcome, remote_outcome) {
2114 Ok(result) => {
2115 let mut summarized_all = plan.to_summarize.clone();
2116 summarized_all.extend(plan.turn_prefix.clone());
2117 let (read, modified) = extract_file_ops(&summarized_all);
2118 {
2119 let mut totals = self.totals.lock().unwrap();
2120 if let Some(u) = &result.local_usage {
2121 totals.add(u);
2122 }
2123 if let Some(u) = &result.remote_usage {
2124 totals.add(u);
2125 }
2126 }
2127 let details = merge_compaction_details(
2128 file_ops_details(&read, &modified),
2129 result.remote_details,
2130 );
2131 let mut manager = self.manager.lock().unwrap();
2132 let _ = manager.append_compaction(
2133 result.summary.clone(),
2134 plan.tokens_before,
2135 plan.kept.clone(),
2136 result.local_usage,
2137 Some(details),
2138 );
2139 (self.sink)(SessionEvent::CompactionEnd {
2140 summary: result.summary,
2141 tokens_before: plan.tokens_before,
2142 error: None,
2143 });
2144 }
2145 Err(error) => {
2146 (self.sink)(SessionEvent::CompactionEnd {
2147 summary: String::new(),
2148 tokens_before: plan.tokens_before,
2149 error: Some(format!("{error:#}")),
2150 });
2151 }
2152 }
2153 }
2154
2155 pub fn context_usage(&self) -> (u64, u64) {
2157 let manager = self.manager.lock().unwrap();
2158 let revision = manager.context_revision();
2159 let used = if let Some((cached_revision, tokens)) =
2160 *self.context_usage_cache.lock().unwrap()
2161 && cached_revision == revision
2162 {
2163 tokens
2164 } else {
2165 let messages = manager.context_messages();
2166 let tokens = if self.settings().experimental_context_file {
2167 messages
2168 .map(|message| compaction::estimate_message_tokens(&message))
2169 .sum()
2170 } else {
2171 compaction::estimate_context_tokens_iter(messages)
2172 };
2173 *self.context_usage_cache.lock().unwrap() = Some((revision, tokens));
2174 tokens
2175 };
2176 drop(manager);
2177 (used, self.model().context_window)
2178 }
2179}
2180
2181struct SelectedCompaction {
2182 summary: String,
2183 local_usage: Option<Usage>,
2184 remote_usage: Option<Usage>,
2185 remote_details: Option<serde_json::Value>,
2186}
2187
2188fn select_compaction_outcome(
2189 model: &Model,
2190 local: anyhow::Result<compaction::SummaryOutcome>,
2191 remote: Option<anyhow::Result<kiss_ai::api::openai_compaction::RemoteCompactionResult>>,
2192) -> anyhow::Result<SelectedCompaction> {
2193 match (local, remote) {
2194 (Ok(local), Some(Ok(remote))) => Ok(SelectedCompaction {
2195 summary: local.summary,
2196 local_usage: local.usage,
2197 remote_usage: remote.usage,
2198 remote_details: Some(
2199 kiss_ai::api::openai_compaction::build_remote_compaction_details(model, &remote),
2200 ),
2201 }),
2202 (Ok(local), Some(Err(_)) | None) => Ok(SelectedCompaction {
2203 summary: local.summary,
2204 local_usage: local.usage,
2205 remote_usage: None,
2206 remote_details: None,
2207 }),
2208 (Err(_), Some(Ok(remote))) => Ok(SelectedCompaction {
2209 summary: format!(
2210 "OpenAI server-side compaction was applied for {}/{}. The provider-native context is stored in this session, and this notice keeps the compaction boundary readable for other providers.",
2211 model.provider, model.id
2212 ),
2213 local_usage: None,
2214 remote_usage: remote.usage,
2215 remote_details: Some(
2216 kiss_ai::api::openai_compaction::build_remote_compaction_details(model, &remote),
2217 ),
2218 }),
2219 (Err(local), Some(Err(remote))) => anyhow::bail!(
2220 "local compaction failed: {local:#}. OpenAI remote compaction failed: {remote:#}"
2221 ),
2222 (Err(error), None) => Err(error),
2223 }
2224}
2225
2226fn merge_compaction_details(
2227 mut local: serde_json::Value,
2228 remote: Option<serde_json::Value>,
2229) -> serde_json::Value {
2230 let Some(remote) = remote else {
2231 return local;
2232 };
2233 let Some(local_object) = local.as_object_mut() else {
2234 return remote;
2235 };
2236 if let Some(remote_object) = remote.as_object() {
2237 for (key, value) in remote_object {
2238 local_object.insert(key.clone(), value.clone());
2239 }
2240 }
2241 local
2242}
2243
2244fn transcript_excerpt(messages: &[AgentMessage], max_messages: usize, max_chars: usize) -> String {
2245 let mut entries = messages
2246 .iter()
2247 .rev()
2248 .filter_map(|message| match message {
2249 AgentMessage::User(user) => Some(("User", user.content.as_text())),
2250 AgentMessage::Assistant(assistant) => Some(("Assistant", assistant.text())),
2251 _ => None,
2252 })
2253 .filter(|(_, text)| !text.trim().is_empty())
2254 .take(max_messages)
2255 .collect::<Vec<_>>();
2256 entries.reverse();
2257 let transcript = entries
2258 .into_iter()
2259 .map(|(role, text)| format!("{role}: {}", text.trim()))
2260 .collect::<Vec<_>>()
2261 .join("\n\n");
2262 let count = transcript.chars().count();
2263 if count <= max_chars {
2264 return transcript;
2265 }
2266 let omitted = count - max_chars;
2267 let tail = transcript.chars().skip(omitted).collect::<String>();
2268 format!("[earlier text omitted]\n{tail}")
2269}
2270
2271fn drain_queue(queue: &Arc<Mutex<VecDeque<QueuedPrompt>>>, mode: QueueMode) -> Vec<AgentMessage> {
2272 let mut q = queue.lock().unwrap();
2273 match mode {
2274 QueueMode::All => q.drain(..).map(|prompt| prompt.message).collect(),
2275 QueueMode::OneAtATime => q
2276 .pop_front()
2277 .map(|prompt| prompt.message)
2278 .into_iter()
2279 .collect(),
2280 }
2281}
2282
2283fn queued_mode(queue: &Arc<Mutex<VecDeque<QueuedPrompt>>>, mode: QueueMode) -> Option<PromptMode> {
2284 let queue = queue.lock().unwrap();
2285 match mode {
2286 QueueMode::All => queue
2287 .iter()
2288 .any(|prompt| prompt.mode == PromptMode::Workflow)
2289 .then_some(PromptMode::Workflow)
2290 .or_else(|| (!queue.is_empty()).then_some(PromptMode::Ordinary)),
2291 QueueMode::OneAtATime => queue.front().map(|prompt| prompt.mode),
2292 }
2293}
2294
2295fn auto_compaction_needed(
2296 settings: &Settings,
2297 messages: &[AgentMessage],
2298 model: &Model,
2299 cancelled: bool,
2300) -> bool {
2301 let reserve_tokens = settings
2302 .compaction
2303 .model_overrides
2304 .get(&format!("{}/{}", model.provider, model.id))
2305 .and_then(|value| value.reserve_tokens)
2306 .unwrap_or(settings.compaction.reserve_tokens);
2307 settings.compaction.enabled
2308 && !cancelled
2309 && model.context_window > 0
2310 && should_compact(
2311 if settings.experimental_context_file {
2312 messages
2315 .iter()
2316 .map(compaction::estimate_message_tokens)
2317 .sum()
2318 } else {
2319 estimate_context_tokens(messages)
2320 },
2321 model.context_window,
2322 reserve_tokens,
2323 )
2324}
2325
2326fn cache_warming_delay(ttl: std::time::Duration) -> Option<std::time::Duration> {
2327 (ttl > std::time::Duration::from_secs(10))
2328 .then(|| std::cmp::min(ttl.mul_f64(0.9), ttl - std::time::Duration::from_secs(10)))
2329}
2330
2331fn cache_refresh_deadline_missed(
2332 elapsed: std::time::Duration,
2333 ttl: std::time::Duration,
2334 delay: std::time::Duration,
2335) -> bool {
2336 elapsed > delay + ttl.saturating_sub(delay) / 2
2337}
2338
2339fn is_transient(error: &str) -> bool {
2340 let e = error.to_lowercase();
2341 if let Some(value) = error
2342 .find('{')
2343 .and_then(|start| serde_json::from_str::<serde_json::Value>(&error[start..]).ok())
2344 {
2345 let detail = value.get("error").unwrap_or(&value);
2346 if detail
2349 .get("code")
2350 .is_some_and(|code| *code == 1308 || *code == "1308")
2351 || detail
2352 .get("isRetryable")
2353 .and_then(serde_json::Value::as_bool)
2354 == Some(false)
2355 || detail
2356 .get("details")
2357 .and_then(serde_json::Value::as_array)
2358 .is_some_and(|details| {
2359 details.iter().any(|detail| {
2360 detail
2361 .pointer("/debug/details/isRetryable")
2362 .and_then(serde_json::Value::as_bool)
2363 == Some(false)
2364 })
2365 })
2366 {
2367 return false;
2368 }
2369 }
2370 if e.contains("subscription_sharing_usage_limit_exceeded")
2373 || e.contains("subscription_sharing_workplace_individual_usage_limit_exceeded")
2374 {
2375 return false;
2376 }
2377 let transient_status = [429, 500, 502, 503, 504, 520, 524].iter().any(|status| {
2378 [
2379 format!("http {status}"),
2380 format!("status {status}"),
2381 format!("status: {status}"),
2382 format!("status code {status}"),
2383 ]
2384 .iter()
2385 .any(|marker| e.contains(marker))
2386 });
2387 transient_status
2388 || [
2389 "overloaded",
2390 "currently experiencing high demand",
2391 "rate limit",
2392 "timeout",
2393 "timed out",
2394 "connection reset",
2395 "connection refused",
2396 "connection closed",
2397 "connection aborted",
2398 "connection error",
2399 "failed to connect",
2400 "network error",
2401 "stream error",
2402 "subscription_sharing_usage_unavailable",
2403 "subscription_sharing_user_unavailable",
2404 "server error",
2405 "server_busy",
2406 "servers are currently busy",
2407 "model is at capacity",
2408 "service unavailable",
2409 "service is temporarily unavailable",
2410 "provider returned error",
2411 "provider returned an error",
2412 "unable to forward upstream",
2413 "upstream request failed",
2414 "upstream_stream_read_error",
2415 "stream was interrupted",
2416 "stream disconnected before completion",
2417 "pending stream has been canceled",
2418 "http2 stream failed",
2419 "http/2 stream failed",
2420 ]
2421 .iter()
2422 .any(|needle| e.contains(needle))
2423}
2424
2425#[cfg(test)]
2426mod ephemeral_tests {
2427 use super::*;
2428 use std::collections::BTreeMap;
2429 use std::path::Path;
2430
2431 fn openai_model() -> Model {
2432 Model {
2433 id: "gpt-test".into(),
2434 name: "GPT test".into(),
2435 api: "openai-responses".into(),
2436 provider: "openai".into(),
2437 base_url: "https://api.openai.com/v1".into(),
2438 reasoning: true,
2439 input: vec!["text".into()],
2440 cost: Default::default(),
2441 prompt_cache: None,
2442 context_window: 100_000,
2443 max_tokens: 1_000,
2444 compat: None,
2445 thinking_level_map: BTreeMap::new(),
2446 headers: BTreeMap::new(),
2447 sampling_params: Default::default(),
2448 sampling_params_by_thinking_level: Default::default(),
2449 }
2450 }
2451
2452 #[tokio::test]
2453 async fn prompt_end_follows_retries_and_reports_cancellation() {
2454 for cancel_retry in [false, true] {
2455 let events = Arc::new(Mutex::new(Vec::new()));
2456 let saved_events = events.clone();
2457 let session = AgentSession::new(
2458 SessionManager::in_memory(Path::new("/test")),
2459 Vec::new(),
2460 Registry::load(None),
2461 Settings {
2462 compaction: crate::settings::CompactionSettings {
2463 enabled: false,
2464 ..Default::default()
2465 },
2466 retry: crate::settings::RetrySettings {
2467 base_delay_ms: if cancel_retry { 60_000 } else { 0 },
2468 ..Default::default()
2469 },
2470 ..Default::default()
2471 },
2472 "test".into(),
2473 openai_model(),
2474 ThinkingLevel::Off,
2475 None,
2476 Arc::new(move |event| saved_events.lock().unwrap().push(event)),
2477 );
2478 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
2479 let observed_calls = calls.clone();
2480 session.set_stream_fn(Some(Arc::new(move |_, _, _| {
2481 let step = observed_calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2482 let mut message =
2483 kiss_ai::AssistantMessage::empty("openai-responses", "openai", "gpt-test");
2484 let (sink, stream) = kiss_ai::EventStream::channel();
2485 if step == 0 {
2486 message.stop_reason = StopReason::Error;
2487 message.error_message = Some("HTTP 503".into());
2488 sink.error(message);
2489 } else {
2490 message.stop_reason = StopReason::Stop;
2491 sink.done(message);
2492 }
2493 stream
2494 })));
2495 let task_session = session.clone();
2496 let task = tokio::spawn(async move {
2497 task_session
2498 .prompt(vec![AgentMessage::user("finish")])
2499 .await;
2500 });
2501 if cancel_retry {
2502 tokio::time::timeout(std::time::Duration::from_secs(5), async {
2503 loop {
2504 if events
2505 .lock()
2506 .unwrap()
2507 .iter()
2508 .any(|event| matches!(event, SessionEvent::Retry { .. }))
2509 {
2510 break;
2511 }
2512 tokio::task::yield_now().await;
2513 }
2514 })
2515 .await
2516 .unwrap();
2517 assert!(
2519 !events
2520 .lock()
2521 .unwrap()
2522 .iter()
2523 .any(|event| matches!(event, SessionEvent::PromptEnd { .. }))
2524 );
2525 session.abort();
2526 }
2527 tokio::time::timeout(std::time::Duration::from_secs(5), task)
2528 .await
2529 .unwrap()
2530 .unwrap();
2531 let events = events.lock().unwrap();
2532 assert!(
2533 matches!(events.last(), Some(SessionEvent::PromptEnd { cancelled }) if *cancelled == cancel_retry)
2534 );
2535 assert_eq!(
2536 events
2537 .iter()
2538 .filter(|event| matches!(event, SessionEvent::PromptEnd { .. }))
2539 .count(),
2540 1
2541 );
2542 assert_eq!(events.iter().filter(|event| matches!(event, SessionEvent::Agent(agent) if matches!(agent.as_ref(), AgentEvent::AgentEnd { .. }))).count(), if cancel_retry { 1 } else { 2 });
2543 assert_eq!(
2544 calls.load(std::sync::atomic::Ordering::SeqCst),
2545 if cancel_retry { 1 } else { 2 }
2546 );
2547 assert!(!session.is_running());
2548 }
2549 }
2550
2551 fn session_with_blocked_failure(
2554 release: Arc<tokio::sync::Notify>,
2555 ) -> (
2556 Arc<AgentSession>,
2557 Arc<std::sync::atomic::AtomicUsize>,
2558 Arc<Mutex<Vec<SessionEvent>>>,
2559 ) {
2560 let events = Arc::new(Mutex::new(Vec::new()));
2561 let saved_events = events.clone();
2562 let session = AgentSession::new(
2563 SessionManager::in_memory(Path::new("/test")),
2564 Vec::new(),
2565 Registry::load(None),
2566 Settings {
2567 compaction: crate::settings::CompactionSettings {
2568 enabled: false,
2569 ..Default::default()
2570 },
2571 ..Default::default()
2572 },
2573 "test".into(),
2574 openai_model(),
2575 ThinkingLevel::Off,
2576 None,
2577 Arc::new(move |event| saved_events.lock().unwrap().push(event)),
2578 );
2579 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
2580 let observed_calls = calls.clone();
2581 session.set_stream_fn(Some(Arc::new(move |_, _, _| {
2582 let step = observed_calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2583 let mut message =
2584 kiss_ai::AssistantMessage::empty("openai-responses", "openai", "gpt-test");
2585 let (sink, stream) = kiss_ai::EventStream::channel();
2586 if step == 0 {
2587 let release = release.clone();
2588 tokio::spawn(async move {
2589 release.notified().await;
2590 message.stop_reason = StopReason::Error;
2591 message.error_message = Some("HTTP 400 bad request".into());
2592 sink.error(message);
2593 });
2594 } else {
2595 message.stop_reason = StopReason::Stop;
2596 sink.done(message);
2597 }
2598 stream
2599 })));
2600 (session, calls, events)
2601 }
2602
2603 async fn wait_for_first_request(calls: &std::sync::atomic::AtomicUsize) {
2604 tokio::time::timeout(std::time::Duration::from_secs(5), async {
2605 while calls.load(std::sync::atomic::Ordering::SeqCst) == 0 {
2606 tokio::task::yield_now().await;
2607 }
2608 })
2609 .await
2610 .expect("the first model request starts");
2611 }
2612
2613 #[tokio::test]
2614 async fn a_message_queued_during_a_failed_turn_runs_after_it() {
2615 let release = Arc::new(tokio::sync::Notify::new());
2616 let (session, calls, _events) = session_with_blocked_failure(release.clone());
2617 let task_session = session.clone();
2618 let task = tokio::spawn(async move {
2619 task_session.prompt(vec![AgentMessage::user("first")]).await;
2620 });
2621 wait_for_first_request(&calls).await;
2622
2623 session.queue_steering(AgentMessage::user("second"));
2624 release.notify_one();
2625 tokio::time::timeout(std::time::Duration::from_secs(5), task)
2626 .await
2627 .unwrap()
2628 .unwrap();
2629
2630 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 2);
2631 assert!(session.reclaim_queued().is_empty());
2632 let context = session.manager.lock().unwrap().build_session_context();
2633 assert!(context.messages.iter().any(
2634 |message| matches!(message, AgentMessage::User(user) if user.content.as_text() == "second")
2635 ));
2636 }
2637
2638 #[tokio::test]
2639 async fn manual_compaction_is_refused_during_a_prompt() {
2640 let release = Arc::new(tokio::sync::Notify::new());
2641 let (session, calls, events) = session_with_blocked_failure(release.clone());
2642 let task_session = session.clone();
2643 let task = tokio::spawn(async move {
2644 task_session.prompt(vec![AgentMessage::user("first")]).await;
2645 });
2646 wait_for_first_request(&calls).await;
2647
2648 let refused = session.compact(None).await.unwrap_err();
2649 assert!(refused.contains("cannot compact"), "{refused}");
2650 assert!(
2651 !events
2652 .lock()
2653 .unwrap()
2654 .iter()
2655 .any(|event| { matches!(event, SessionEvent::CompactionStart { .. }) })
2656 );
2657 release.notify_one();
2658 task.await.unwrap();
2659 }
2660
2661 #[tokio::test]
2662 async fn a_message_queued_before_a_compaction_runs_when_it_ends() {
2663 let release = Arc::new(tokio::sync::Notify::new());
2664 release.notify_one();
2665 let (session, calls, _events) = session_with_blocked_failure(release);
2666 session.queue_steering(AgentMessage::user("queued"));
2667 session.compact(None).await.unwrap();
2668 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
2669 assert!(!session.is_running());
2670 }
2671
2672 #[tokio::test]
2673 async fn a_message_added_while_idle_costs_no_extra_model_request() {
2674 let session = settings_test_session(
2675 Settings {
2676 compaction: crate::settings::CompactionSettings {
2677 enabled: false,
2678 ..Default::default()
2679 },
2680 ..Default::default()
2681 },
2682 false,
2683 );
2684 let contexts = Arc::new(Mutex::new(Vec::new()));
2685 let saved_contexts = contexts.clone();
2686 session.set_stream_fn(Some(Arc::new(move |_, context, _| {
2687 saved_contexts
2688 .lock()
2689 .unwrap()
2690 .push(format!("{:?}", context.messages));
2691 let mut message =
2692 kiss_ai::AssistantMessage::empty("openai-responses", "openai", "gpt-test");
2693 message.stop_reason = StopReason::Stop;
2694 let (sink, stream) = kiss_ai::EventStream::channel();
2695 sink.done(message);
2696 stream
2697 })));
2698
2699 session.add_for_next_turn(AgentMessage::user("the workflow report"));
2700 session.compact(None).await.unwrap();
2701 assert!(contexts.lock().unwrap().is_empty());
2702
2703 session.prompt(vec![AgentMessage::user("next")]).await;
2704 let contexts = contexts.lock().unwrap();
2705 assert_eq!(contexts.len(), 1);
2706 assert!(contexts[0].contains("the workflow report"));
2707 }
2708
2709 #[test]
2710 fn session_replacement_refuses_active_work() {
2711 let session = settings_test_session(Settings::default(), false);
2712 let original = session.manager.lock().unwrap().session_id().to_string();
2713 let run = session.prompt(vec![AgentMessage::user("first")]);
2714 session
2715 .replace_manager(SessionManager::in_memory(Path::new("/other")))
2716 .unwrap_err();
2717 assert_eq!(session.manager.lock().unwrap().session_id(), original);
2718 drop(run);
2719 session
2720 .replace_manager(SessionManager::in_memory(Path::new("/other")))
2721 .unwrap();
2722 assert_ne!(session.manager.lock().unwrap().session_id(), original);
2723 }
2724
2725 #[test]
2726 fn a_refused_new_session_does_not_create_a_file() {
2727 let directory = tempfile::tempdir().unwrap();
2728 let mut manager =
2729 SessionManager::create(directory.path(), Some(directory.path().join("sessions")))
2730 .unwrap();
2731 manager.append_message(AgentMessage::user("saved")).unwrap();
2732 let files = manager
2733 .session_file()
2734 .unwrap()
2735 .parent()
2736 .unwrap()
2737 .to_path_buf();
2738 let original = manager.session_id().to_string();
2739 let session = settings_test_session(Settings::default(), false);
2740 session.replace_manager(manager).unwrap();
2741 let before = std::fs::read_dir(&files).unwrap().count();
2742 let run = session.prompt(vec![AgentMessage::user("first")]);
2743 session.new_session().unwrap_err();
2744 assert_eq!(std::fs::read_dir(&files).unwrap().count(), before);
2745 assert_eq!(session.manager.lock().unwrap().session_id(), original);
2746 drop(run);
2747 session.new_session().unwrap();
2748 assert_eq!(std::fs::read_dir(&files).unwrap().count(), before);
2750 session
2751 .manager
2752 .lock()
2753 .unwrap()
2754 .append_message(AgentMessage::user("new saved entry"))
2755 .unwrap();
2756 assert_eq!(std::fs::read_dir(&files).unwrap().count(), before + 1);
2757 assert_ne!(session.manager.lock().unwrap().session_id(), original);
2758 }
2759
2760 #[test]
2761 fn a_dropped_prompt_future_returns_the_session_to_idle() {
2762 let session = settings_test_session(Settings::default(), false);
2763 let run = session.prompt(vec![AgentMessage::user("first")]);
2764 let cancel = session.cancel.lock().unwrap().clone();
2765 assert_eq!(session.run_phase(), RunPhase::Streaming);
2766 drop(run);
2767 assert_eq!(session.run_phase(), RunPhase::Idle);
2768 assert!(
2769 cancel.is_cancelled(),
2770 "dropping a run must cancel its provider work"
2771 );
2772 }
2773
2774 #[tokio::test]
2775 async fn context_file_bash_edits_reach_next_request_and_survive_resume() {
2776 let directory = tempfile::tempdir().unwrap();
2777 let mut manager =
2778 SessionManager::create(directory.path(), Some(directory.path().join("sessions")))
2779 .unwrap();
2780 manager
2781 .append_message(AgentMessage::user("obsolete output"))
2782 .unwrap();
2783 manager
2784 .append_compaction(
2785 "portable summary".into(),
2786 100,
2787 vec![AgentMessage::user("obsolete output")],
2788 None,
2789 Some(serde_json::json!({"remoteCompaction": {
2790 "version": 2,
2791 "provider": "openai-responses-compaction",
2792 "modelKey": "openai:openai-responses:gpt-test",
2793 "replacementHistory": [{"type": "compaction", "encrypted_content": "opaque"}]
2794 }})),
2795 )
2796 .unwrap();
2797 assert!(
2798 manager
2799 .build_openai_compaction_context(&openai_model())
2800 .is_some()
2801 );
2802 let session_path = manager.session_file().unwrap().to_path_buf();
2803 let settings = Settings {
2804 experimental_context_file: true,
2805 compaction: crate::settings::CompactionSettings {
2806 enabled: false,
2807 ..Default::default()
2808 },
2809 retry: crate::settings::RetrySettings {
2810 base_delay_ms: 0,
2811 ..Default::default()
2812 },
2813 ..Default::default()
2814 };
2815 let events = Arc::new(Mutex::new(Vec::new()));
2816 let saved_events = events.clone();
2817 let session = AgentSession::new(
2818 manager,
2819 vec![Arc::new(kiss_agent::tools::bash::BashTool::new(
2820 directory.path().to_path_buf(),
2821 ))],
2822 Registry::load(None),
2823 settings,
2824 "test".into(),
2825 openai_model(),
2826 ThinkingLevel::Off,
2827 None,
2828 Arc::new(move |event| saved_events.lock().unwrap().push(event)),
2829 );
2830 let weak = Arc::downgrade(&session);
2831 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
2832 let observed_calls = calls.clone();
2833 session.set_stream_fn(Some(Arc::new(move |_, context, _| {
2834 let step = observed_calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2835 let prompt = context.system_prompt.as_ref().unwrap();
2836 let path = prompt.lines().find_map(|line| line.strip_prefix("Experimental context file: ")).unwrap();
2837 assert!(Path::new(path).is_file());
2838 assert!(context.openai_responses_input.is_none());
2839 let mut message = kiss_ai::AssistantMessage::empty("openai-responses", "openai", "gpt-test");
2840 let replacement = match step {
2841 0 => {
2842 weak.upgrade().unwrap().queue_steering(AgentMessage::user("new instruction"));
2843 Some(r#"[{"role":"user","content":"saved notes","timestamp":0}]"#)
2844 }
2845 1 => {
2846 let text = serde_json::to_string(&context.messages).unwrap();
2847 assert!(text.contains("saved notes"));
2848 assert!(!text.contains("obsolete output"));
2849 assert!(text.contains("new instruction"));
2850 assert!(context.messages.iter().any(|message| matches!(message, kiss_ai::Message::ToolResult(result) if result.tool_call_id == "edit_0" && !result.is_error)));
2851 Some("invalid JSON")
2852 }
2853 2 => {
2854 let text = serde_json::to_string(&context.messages).unwrap();
2855 assert!(text.contains("saved notes"));
2856 assert!(text.contains("Repair the file"));
2857 assert_eq!(std::fs::read_to_string(path).unwrap(), "invalid JSON");
2858 Some(r#"[{"role":"user","content":"repaired notes","timestamp":0}]"#)
2859 }
2860 3 => {
2861 let text = serde_json::to_string(&context.messages).unwrap();
2862 assert!(text.contains("repaired notes"));
2863 assert!(context.messages.iter().any(|message| matches!(message, kiss_ai::Message::User(user) if user.content.as_text() == "repaired notes")));
2864 assert!(context.messages.iter().any(|message| matches!(message, kiss_ai::Message::ToolResult(result) if result.tool_call_id == "edit_2" && !result.is_error)));
2865 None
2866 }
2867 4 => {
2868 assert!(!matches!(context.messages.last(), Some(kiss_ai::Message::Assistant(assistant)) if assistant.stop_reason == StopReason::Error));
2869 assert!(context.messages.iter().any(|message| matches!(message, kiss_ai::Message::User(user) if user.content.as_text() == "repaired notes")));
2870 None
2871 }
2872 _ => panic!("unexpected model request"),
2873 };
2874 if let Some(replacement) = replacement {
2875 let quoted_path = path.replace('\'', "'\\''");
2876 message.content.push(kiss_ai::ContentBlock::ToolCall(kiss_ai::ToolCall {
2877 id: format!("edit_{step}"),
2878 name: "bash".into(),
2879 arguments: serde_json::json!({"command": format!("printf '%s' '{replacement}' > '{quoted_path}'")}),
2880 thought_signature: None,
2881 arguments_error: None,
2882 }));
2883 message.stop_reason = StopReason::ToolUse;
2884 } else if step == 3 {
2885 message.stop_reason = StopReason::Error;
2886 message.error_message = Some("HTTP 503".into());
2887 } else {
2888 message.content.push(kiss_ai::ContentBlock::text("done"));
2889 message.stop_reason = StopReason::Stop;
2890 }
2891 let (sink, stream) = kiss_ai::EventStream::channel();
2892 sink.send(kiss_ai::AssistantEvent::Start { partial: message.clone() });
2893 if message.stop_reason == StopReason::Error {
2894 sink.error(message);
2895 } else {
2896 sink.done(message);
2897 }
2898 stream
2899 })));
2900 session
2901 .prompt(vec![AgentMessage::user("manage context")])
2902 .await;
2903 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 5);
2904 let context = session
2905 .manager
2906 .lock()
2907 .unwrap()
2908 .build_session_context()
2909 .messages;
2910 assert_eq!(
2911 SessionManager::open(&session_path)
2912 .unwrap()
2913 .build_session_context()
2914 .messages,
2915 context
2916 );
2917 assert!(
2918 std::fs::read_to_string(session_path)
2919 .unwrap()
2920 .contains("obsolete output")
2921 );
2922 let errors = events.lock().unwrap().iter().filter(|event| matches!(event, SessionEvent::Agent(event) if matches!(event.as_ref(), AgentEvent::MessageEnd { message: AgentMessage::Custom(custom) } if custom.custom_type == "context_file_error"))).count();
2923 assert_eq!(errors, 1);
2924 }
2925
2926 #[test]
2927 fn context_file_lifecycle_is_opt_in_and_session_local() {
2928 let session = AgentSession::new(
2929 SessionManager::in_memory(Path::new("/test")),
2930 Vec::new(),
2931 Registry::load(None),
2932 Settings::default(),
2933 "test".into(),
2934 openai_model(),
2935 ThinkingLevel::Off,
2936 None,
2937 Arc::new(|_| {}),
2938 );
2939 assert!(!session.sync_context_file());
2940 assert!(session.context_file.lock().unwrap().is_none());
2941 assert_eq!(session.build_context().system_prompt, "test");
2942 let mut settings = session.settings();
2943 settings.experimental_context_file = true;
2944 session.update_settings(settings);
2945 session.sync_context_file();
2946 let path = session
2947 .context_file
2948 .lock()
2949 .unwrap()
2950 .as_ref()
2951 .unwrap()
2952 .path()
2953 .to_path_buf();
2954 let child = session
2955 .create_subagent_session("task", "/root/task", ForkTurns::None, None, None)
2956 .unwrap();
2957 child.sync_context_file();
2958 let child_path = child
2959 .context_file
2960 .lock()
2961 .unwrap()
2962 .as_ref()
2963 .unwrap()
2964 .path()
2965 .to_path_buf();
2966 assert_ne!(path, child_path);
2967 assert!(child_path.is_file());
2968 session
2969 .replace_manager(SessionManager::in_memory(Path::new("/other")))
2970 .unwrap();
2971 assert!(!path.exists());
2972 assert!(session.context_file.lock().unwrap().is_none());
2973 session.sync_context_file();
2974 let new_path = session
2975 .context_file
2976 .lock()
2977 .unwrap()
2978 .as_ref()
2979 .unwrap()
2980 .path()
2981 .to_path_buf();
2982 assert_ne!(new_path, child_path);
2983 let mut settings = session.settings();
2984 settings.experimental_context_file = false;
2985 session.update_settings(settings);
2986 assert!(session.sync_context_file());
2987 assert!(!new_path.exists());
2988 assert_eq!(session.build_context().system_prompt, "test");
2989 }
2990
2991 #[test]
2992 fn reasoning_lease_ends_when_model_saved_effort_or_applied_effort_changes() {
2993 let model = openai_model();
2994 let mut run = ReasoningRunState {
2995 decision_model: Some(DecisionModel::Jev),
2996 lease: crate::decision::ReasoningLease::default(),
2997 provider: model.provider.clone(),
2998 model_id: model.id.clone(),
2999 saved_effort: ThinkingLevel::Medium,
3000 };
3001 let selection = crate::decision::ReasoningSelection {
3002 level: ThinkingLevel::High,
3003 generations: 5,
3004 };
3005 run.lease.install(&selection);
3006 assert_eq!(
3007 run.checkpoint(
3008 &model,
3009 ThinkingLevel::Medium,
3010 ThinkingLevel::High,
3011 Some(DecisionModel::Jev)
3012 ),
3013 (false, Some(ThinkingLevel::High))
3014 );
3015
3016 assert_eq!(
3017 run.checkpoint(
3018 &model,
3019 ThinkingLevel::Medium,
3020 ThinkingLevel::High,
3021 Some(DecisionModel::D1Ultra)
3022 ),
3023 (false, None)
3024 );
3025 run.lease.install(&selection);
3026
3027 let mut other_model = model.clone();
3028 other_model.provider = "other".into();
3029 assert_eq!(
3030 run.checkpoint(
3031 &other_model,
3032 ThinkingLevel::Medium,
3033 ThinkingLevel::High,
3034 Some(DecisionModel::Jev)
3035 ),
3036 (true, None)
3037 );
3038 run.lease.install(&selection);
3039 assert_eq!(
3040 run.checkpoint(
3041 &other_model,
3042 ThinkingLevel::Low,
3043 ThinkingLevel::High,
3044 Some(DecisionModel::Jev)
3045 ),
3046 (false, None)
3047 );
3048 run.lease.install(&selection);
3049 assert_eq!(
3050 run.checkpoint(
3051 &other_model,
3052 ThinkingLevel::Low,
3053 ThinkingLevel::Low,
3054 Some(DecisionModel::Jev)
3055 ),
3056 (false, None)
3057 );
3058 run.lease.install(&selection);
3059 assert_eq!(
3060 run.checkpoint(&other_model, ThinkingLevel::Low, ThinkingLevel::High, None),
3061 (false, None)
3062 );
3063 }
3064
3065 #[test]
3066 fn late_cache_refreshes_are_skipped_before_the_cache_expires() {
3067 let ttl = std::time::Duration::from_secs(300);
3068 let delay = cache_warming_delay(ttl).unwrap();
3069 assert!(!cache_refresh_deadline_missed(
3070 std::time::Duration::from_secs(284),
3071 ttl,
3072 delay,
3073 ));
3074 assert!(cache_refresh_deadline_missed(
3075 std::time::Duration::from_secs(286),
3076 ttl,
3077 delay,
3078 ));
3079 }
3080
3081 #[test]
3082 fn transient_errors_require_a_status_or_specific_network_failure() {
3083 assert!(is_transient("request failed with HTTP 503"));
3084 assert!(is_transient("Cloudflare returned HTTP 520"));
3085 assert!(is_transient("Azure is currently experiencing high demand"));
3086 assert!(is_transient("connection reset by peer"));
3087 assert!(is_transient("rate limit exceeded"));
3088 assert!(!is_transient("model has a 500 token limit"));
3089 assert!(!is_transient("connection settings are invalid"));
3090 assert!(!is_transient(
3091 "HTTP 429: subscription_sharing_usage_limit_exceeded"
3092 ));
3093 assert!(is_transient(
3094 "Selected model is at capacity. Please try again"
3095 ));
3096 assert!(is_transient(r#"{"error":{"code":"server_busy"}}"#));
3097 assert!(is_transient("The service is temporarily unavailable"));
3098 assert!(is_transient("The upstream provider returned an error"));
3099 assert!(is_transient("Unable to forward upstream stream"));
3100 assert!(is_transient(
3101 "stream disconnected before completion: stream closed before response.completed"
3102 ));
3103 assert!(is_transient("The pending stream has been canceled"));
3104 assert!(is_transient("Provider finish_reason: error (server error)"));
3105 assert!(!is_transient(
3106 "request req_5030 failed: invalid tool schema"
3107 ));
3108 assert!(!is_transient(
3109 "subscription_sharing_workplace_individual_usage_limit_exceeded"
3110 ));
3111 assert!(!is_transient(
3112 r#"HTTP 429: {"error":{"code":"1308","message":"Usage limit reached for 5 hour."}}"#
3113 ));
3114 assert!(!is_transient(r#"HTTP 429: {"code":1308,"msg":"limit"}"#));
3115 assert!(is_transient(r#"HTTP 429: {"error":{"code":"13080"}}"#));
3116 assert!(is_transient("subscription_sharing_usage_unavailable"));
3117 assert!(is_transient("subscription_sharing_user_unavailable"));
3118 assert!(!is_transient(
3119 r#"Cursor request failed: stream ended: {"error":{"code":"not_found","message":"Model name is not valid: auto"}}"#
3120 ));
3121 assert!(!is_transient(
3122 r#"Cursor request failed: stream ended: {"error":{"code":"internal","message":"KISS does not run Cursor-native tools"}}"#
3123 ));
3124 assert!(!is_transient(
3125 r#"Cursor request failed: stream ended: {"error":{"details":[{"debug":{"details":{"isRetryable":false,"detail":"rate limit exceeded"}}}]}}"#
3126 ));
3127 }
3128
3129 #[test]
3130 fn session_tools_install_replace_and_remove_without_changing_base_tools() {
3131 let registry = Registry::load(None);
3132 let session = AgentSession::new(
3133 SessionManager::in_memory(std::path::Path::new("/test")),
3134 Vec::new(),
3135 registry,
3136 Settings::default(),
3137 "test".into(),
3138 openai_model(),
3139 ThinkingLevel::Off,
3140 None,
3141 Arc::new(|_| {}),
3142 );
3143 let tool = || {
3144 Arc::new(crate::tools::grep::GrepTool {
3145 cwd: std::path::PathBuf::from("/test"),
3146 }) as DynTool
3147 };
3148
3149 assert!(!session.available_tool_names().contains(&"grep".into()));
3150 session.install_session_tool(tool());
3151 session.install_session_tool(tool());
3152 assert_eq!(
3153 session
3154 .available_tool_names()
3155 .iter()
3156 .filter(|name| name.as_str() == "grep")
3157 .count(),
3158 1
3159 );
3160 assert!(session.remove_session_tool("grep"));
3161 assert!(!session.remove_session_tool("grep"));
3162 assert!(!session.available_tool_names().contains(&"grep".into()));
3163 }
3164
3165 fn remote_result() -> kiss_ai::api::openai_compaction::RemoteCompactionResult {
3166 kiss_ai::api::openai_compaction::RemoteCompactionResult {
3167 replacement_history: vec![serde_json::json!({
3168 "type": "compaction",
3169 "encrypted_content": "opaque"
3170 })],
3171 usage: Some(Usage {
3172 input: 10,
3173 output: 2,
3174 total_tokens: 12,
3175 ..Default::default()
3176 }),
3177 }
3178 }
3179
3180 fn settings_test_session(settings: Settings, subagents_allowed: bool) -> Arc<AgentSession> {
3181 let registry = Registry::from_builtin();
3182 let model = registry.all().first().expect("built-in model").clone();
3183 AgentSession::new_with_subagents_allowed(
3184 SessionManager::in_memory(std::path::Path::new("/test")),
3185 Vec::new(),
3186 registry,
3187 settings,
3188 "root prompt".into(),
3189 model,
3190 ThinkingLevel::Off,
3191 None,
3192 Arc::new(|_| {}),
3193 subagents_allowed,
3194 )
3195 }
3196
3197 #[test]
3198 fn cursor_native_shell_receives_registered_shell_instructions() {
3199 let session = settings_test_session(Settings::default(), false);
3200 let mut model = session.model();
3201 model.provider = "cursor".into();
3202 session.set_model(model);
3203 for (path, syntax) in [
3204 ("pwsh.exe", "PowerShell 7"),
3205 ("powershell.exe", "Windows PowerShell 5.1"),
3206 ("cmd.exe", "cmd.exe syntax"),
3207 ("bash", "POSIX shell syntax"),
3208 ] {
3209 let mut tool = kiss_agent::tools::bash::BashTool::new("/test".into());
3210 tool.shell_path = Some(path.into());
3211 session.install_session_tool(Arc::new(tool));
3212 let context = session.build_context();
3213 assert!(
3214 context.system_prompt.contains(path),
3215 "{}",
3216 context.system_prompt
3217 );
3218 assert!(
3219 context.system_prompt.contains(syntax),
3220 "{}",
3221 context.system_prompt
3222 );
3223 assert!(context.tools[0].to_def().description.contains(syntax));
3224 }
3225 assert!(session.remove_session_tool("bash"));
3226 assert_eq!(session.build_context().system_prompt, "root prompt");
3227 }
3228
3229 fn benchmark_tools() -> Vec<DynTool> {
3230 let cwd = std::path::PathBuf::from("/synthetic");
3231 vec![
3232 Arc::new(kiss_agent::tools::read::ReadTool { cwd: cwd.clone() }),
3233 Arc::new(kiss_agent::tools::write::WriteTool { cwd: cwd.clone() }),
3234 Arc::new(kiss_agent::tools::edit::EditTool { cwd: cwd.clone() }),
3235 Arc::new(kiss_agent::tools::bash::BashTool::new(cwd)),
3236 ]
3237 }
3238
3239 #[test]
3240 fn subagent_tools_follow_settings_and_command_line_authority() {
3241 let session = settings_test_session(Settings::default(), true);
3242 assert!(session.available_tool_names().is_empty());
3243 assert!(
3244 !session
3245 .build_context()
3246 .system_prompt
3247 .contains("Subagent coordination")
3248 );
3249
3250 let mut enabled = session.settings();
3251 enabled.subagents.enabled = true;
3252 session.update_settings(enabled.clone());
3253 assert_eq!(
3254 session.available_tool_names(),
3255 [
3256 "spawn_agent",
3257 "send_message",
3258 "followup_task",
3259 "wait_agent",
3260 "list_agents",
3261 "interrupt_agent"
3262 ]
3263 );
3264 assert!(
3265 session
3266 .build_context()
3267 .system_prompt
3268 .contains("Subagent coordination")
3269 );
3270
3271 enabled.subagents.enabled = false;
3272 session.update_settings(enabled);
3273 assert!(session.available_tool_names().is_empty());
3274
3275 let mut blocked_settings = Settings::default();
3276 blocked_settings.subagents.enabled = true;
3277 let blocked = settings_test_session(blocked_settings, false);
3278 assert!(blocked.available_tool_names().is_empty());
3279 assert!(
3280 !blocked
3281 .build_context()
3282 .system_prompt
3283 .contains("Subagent coordination")
3284 );
3285 }
3286
3287 #[test]
3288 fn the_workflow_tool_appears_only_in_workflow_prompt_mode() {
3289 let mut settings = Settings::default();
3290 settings.subagents.enabled = true;
3291 let session = settings_test_session(settings, true);
3292
3293 assert!(
3296 !session
3297 .available_tool_names()
3298 .contains(&"run_workflow".into())
3299 );
3300 assert_eq!(
3301 session.prompt_mode_for("run a dynamic workflow for this task"),
3302 PromptMode::Workflow
3303 );
3304 assert_eq!(
3305 session.prompt_mode_for("fix this small function"),
3306 PromptMode::Ordinary
3307 );
3308 assert!(
3309 !session
3310 .build_context()
3311 .system_prompt
3312 .contains("Writing a dynamic workflow")
3313 );
3314
3315 assert!(
3316 session
3317 .available_tool_names_for(PromptMode::Workflow)
3318 .contains(&"run_workflow".into())
3319 );
3320 assert!(
3321 session
3322 .build_context_for(PromptMode::Workflow)
3323 .system_prompt
3324 .contains("Writing a dynamic workflow")
3325 );
3326 assert!(
3327 !session
3328 .available_tool_names()
3329 .contains(&"run_workflow".into())
3330 );
3331 }
3332
3333 #[test]
3334 fn workflow_prompt_mode_does_nothing_while_subagents_are_off() {
3335 let session = settings_test_session(Settings::default(), true);
3338 assert!(!session.workflows_enabled());
3339 assert!(
3340 !session
3341 .available_tool_names_for(PromptMode::Workflow)
3342 .contains(&"run_workflow".into())
3343 );
3344
3345 let mut settings = session.settings();
3346 settings.subagents.enabled = true;
3347 settings.workflows.enabled = false;
3348 session.update_settings(settings.clone());
3349 assert!(!session.workflows_enabled());
3350 assert!(
3351 !session
3352 .available_tool_names_for(PromptMode::Workflow)
3353 .contains(&"run_workflow".into())
3354 );
3355
3356 settings.workflows.enabled = true;
3357 session.update_settings(settings);
3358 assert!(session.workflows_enabled());
3359 assert!(
3360 session
3361 .available_tool_names_for(PromptMode::Workflow)
3362 .contains(&"run_workflow".into())
3363 );
3364 }
3365
3366 #[test]
3367 fn one_at_a_time_queues_keep_each_prompts_mode() {
3368 let queue = Arc::new(Mutex::new(VecDeque::from([
3369 QueuedPrompt {
3370 message: AgentMessage::user("ordinary"),
3371 mode: PromptMode::Ordinary,
3372 },
3373 QueuedPrompt {
3374 message: AgentMessage::user("workflow"),
3375 mode: PromptMode::Workflow,
3376 },
3377 ])));
3378
3379 assert_eq!(
3380 queued_mode(&queue, QueueMode::OneAtATime),
3381 Some(PromptMode::Ordinary)
3382 );
3383 assert_eq!(drain_queue(&queue, QueueMode::OneAtATime).len(), 1);
3384 assert_eq!(
3385 queued_mode(&queue, QueueMode::OneAtATime),
3386 Some(PromptMode::Workflow)
3387 );
3388 }
3389
3390 #[test]
3391 fn a_session_without_subagent_authority_has_no_workflow_runtime() {
3392 let mut settings = Settings::default();
3393 settings.subagents.enabled = true;
3394 let child = settings_test_session(settings, false);
3395 assert!(child.workflows().is_none());
3396 assert!(!child.workflows_enabled());
3397 }
3398
3399 #[test]
3400 fn child_session_has_safe_forked_context_without_control_tools() {
3401 let mut settings = Settings::default();
3402 settings.subagents.enabled = true;
3403 let parent = settings_test_session(settings, true);
3404 parent
3405 .manager
3406 .lock()
3407 .unwrap()
3408 .append_message(AgentMessage::user("parent context"))
3409 .unwrap();
3410
3411 let child = parent
3412 .create_subagent_session("inspect", "/root/inspect", ForkTurns::All, None, None)
3413 .unwrap();
3414 assert!(Arc::ptr_eq(&parent.registry, &child.registry));
3415 assert!(child.available_tool_names().is_empty());
3416 let context = child.manager.lock().unwrap().build_session_context();
3417 assert!(matches!(
3418 context.messages.as_slice(),
3419 [AgentMessage::User(user)] if user.content.as_text() == "parent context"
3420 ));
3421 }
3422
3423 #[test]
3424 fn child_without_forked_turns_does_not_copy_parent_context() {
3425 let parent = settings_test_session(Settings::default(), true);
3426 parent
3427 .manager
3428 .lock()
3429 .unwrap()
3430 .append_message(AgentMessage::user("parent context"))
3431 .unwrap();
3432
3433 let child = parent
3434 .create_subagent_session("inspect", "/root/inspect", ForkTurns::None, None, None)
3435 .unwrap();
3436 assert!(
3437 child
3438 .manager
3439 .lock()
3440 .unwrap()
3441 .build_session_context()
3442 .messages
3443 .is_empty()
3444 );
3445 }
3446
3447 #[test]
3448 #[ignore = "release-mode performance benchmark"]
3449 fn benchmark_performance_subagent_overhead() {
3450 let registry = Registry::from_builtin();
3451 let model = registry.all().first().expect("built-in model").clone();
3452 let tools = benchmark_tools();
3453 let make_session = |enabled: bool| {
3454 let mut settings = Settings::default();
3455 settings.subagents.enabled = enabled;
3456 AgentSession::new_with_subagents_allowed(
3457 SessionManager::in_memory(std::path::Path::new("/synthetic")),
3458 tools.clone(),
3459 registry.clone(),
3460 settings,
3461 "benchmark root prompt".into(),
3462 model.clone(),
3463 ThinkingLevel::Off,
3464 None,
3465 Arc::new(|_| {}),
3466 true,
3467 )
3468 };
3469
3470 kiss_bench::measure_pair(
3471 (
3472 "agent_session_create_subagents_off",
3473 "agent_session_create_subagents_on",
3474 ),
3475 21,
3476 500,
3477 (
3478 "new_root_session_4_base_tools_0_control_tools",
3479 "new_root_session_4_base_tools_6_control_tools",
3480 ),
3481 || make_session(false),
3482 || make_session(true),
3483 );
3484
3485 let off = make_session(false);
3486 let on = make_session(true);
3487 kiss_bench::measure_pair(
3488 (
3489 "agent_context_build_subagents_off",
3490 "agent_context_build_subagents_on",
3491 ),
3492 21,
3493 10_000,
3494 (
3495 "empty_session_4_base_tools_0_control_tools",
3496 "empty_session_4_base_tools_6_control_tools",
3497 ),
3498 || off.build_context(),
3499 || on.build_context(),
3500 );
3501 }
3502
3503 #[test]
3504 #[ignore = "release-mode performance benchmark"]
3505 fn benchmark_performance_workflow_tool_exposure() {
3506 let registry = Registry::from_builtin();
3510 let model = registry.all().first().expect("built-in model").clone();
3511 let tools = benchmark_tools();
3512 let make_session = || {
3513 let mut settings = Settings::default();
3514 settings.subagents.enabled = true;
3515 AgentSession::new_with_subagents_allowed(
3516 SessionManager::in_memory(std::path::Path::new("/synthetic")),
3517 tools.clone(),
3518 registry.clone(),
3519 settings,
3520 "benchmark root prompt".into(),
3521 model.clone(),
3522 ThinkingLevel::Off,
3523 None,
3524 Arc::new(|_| {}),
3525 true,
3526 )
3527 };
3528
3529 let ordinary = make_session();
3530 let workflow = make_session();
3531 kiss_bench::measure_pair(
3532 (
3533 "agent_context_build_workflow_disarmed",
3534 "agent_context_build_workflow_armed",
3535 ),
3536 21,
3537 10_000,
3538 (
3539 "empty_session_subagents_on_workflow_disarmed",
3540 "empty_session_subagents_on_workflow_armed",
3541 ),
3542 || ordinary.build_context_for(PromptMode::Ordinary),
3543 || workflow.build_context_for(PromptMode::Workflow),
3544 );
3545 }
3546
3547 #[test]
3548 fn transcript_excerpt_keeps_only_recent_user_and_assistant_text() {
3549 let messages = vec![
3550 AgentMessage::user("old"),
3551 AgentMessage::BashExecution(kiss_agent::BashExecutionMessage {
3552 command: "pwd".into(),
3553 output: "ignored".into(),
3554 exit_code: Some(0),
3555 cancelled: false,
3556 truncated: false,
3557 full_output_path: None,
3558 exclude_from_context: false,
3559 timestamp: 1,
3560 }),
3561 AgentMessage::user("new"),
3562 ];
3563 let excerpt = transcript_excerpt(&messages, 1, 100);
3564 assert_eq!(excerpt, "User: new");
3565 }
3566
3567 #[test]
3568 fn transcript_excerpt_enforces_character_budget_from_the_tail() {
3569 let excerpt = transcript_excerpt(&[AgentMessage::user("abcdefghij")], 4, 5);
3570 assert!(excerpt.ends_with("fghij"));
3571 assert!(excerpt.starts_with("[earlier text omitted]"));
3572 }
3573
3574 #[test]
3575 fn hybrid_compaction_keeps_local_summary_and_remote_details() {
3576 let selected = select_compaction_outcome(
3577 &openai_model(),
3578 Ok(compaction::SummaryOutcome {
3579 summary: "portable".into(),
3580 usage: None,
3581 }),
3582 Some(Ok(remote_result())),
3583 )
3584 .unwrap();
3585 assert_eq!(selected.summary, "portable");
3586 assert_eq!(selected.remote_usage.unwrap().input, 10);
3587 assert_eq!(
3588 selected.remote_details.unwrap()["remoteCompaction"]["version"],
3589 2
3590 );
3591 }
3592
3593 #[test]
3594 fn remote_failure_falls_back_to_local_compaction() {
3595 let selected = select_compaction_outcome(
3596 &openai_model(),
3597 Ok(compaction::SummaryOutcome {
3598 summary: "portable".into(),
3599 usage: None,
3600 }),
3601 Some(Err(anyhow::anyhow!("remote unavailable"))),
3602 )
3603 .unwrap();
3604 assert_eq!(selected.summary, "portable");
3605 assert!(selected.remote_details.is_none());
3606 }
3607
3608 #[test]
3609 fn remote_success_survives_local_summary_failure() {
3610 let selected = select_compaction_outcome(
3611 &openai_model(),
3612 Err(anyhow::anyhow!("summary unavailable")),
3613 Some(Ok(remote_result())),
3614 )
3615 .unwrap();
3616 assert!(
3617 selected
3618 .summary
3619 .contains("server-side compaction was applied")
3620 );
3621 assert!(selected.remote_details.is_some());
3622 }
3623
3624 #[test]
3625 fn details_merge_keeps_file_operations_and_remote_artifact() {
3626 let merged = merge_compaction_details(
3627 serde_json::json!({"readFiles": ["a.rs"], "modifiedFiles": []}),
3628 Some(serde_json::json!({"remoteCompaction": {"version": 2}})),
3629 );
3630 assert_eq!(merged["readFiles"][0], "a.rs");
3631 assert_eq!(merged["remoteCompaction"]["version"], 2);
3632 }
3633
3634 #[test]
3635 fn auto_compaction_guard_checks_settings_threshold_and_cancel() {
3636 let mut settings = Settings::default();
3637 settings.compaction.reserve_tokens = 20;
3638 let messages = vec![AgentMessage::user("x".repeat(360))];
3639 let mut model = openai_model();
3640 model.context_window = 100;
3641 assert!(auto_compaction_needed(&settings, &messages, &model, false));
3642 assert!(!auto_compaction_needed(&settings, &messages, &model, true));
3643 settings.compaction.enabled = false;
3644 assert!(!auto_compaction_needed(&settings, &messages, &model, false));
3645 settings.compaction.enabled = true;
3646 let mut assistant = kiss_ai::AssistantMessage::empty("test", "test", "test");
3647 assistant.usage.input = 1000;
3648 assistant
3649 .content
3650 .push(kiss_ai::ContentBlock::text("short note"));
3651 let edited = vec![
3652 AgentMessage::user("task"),
3653 AgentMessage::Assistant(assistant),
3654 ];
3655 assert!(auto_compaction_needed(&settings, &edited, &model, false));
3656 settings.experimental_context_file = true;
3657 assert!(!auto_compaction_needed(&settings, &edited, &model, false));
3658 }
3659
3660 #[test]
3661 fn session_title_normalization_is_safe_and_bounded() {
3662 assert_eq!(
3663 normalize_session_title(" `Fix AUTH-123 login flow!` \nignored").as_deref(),
3664 Some("Fix AUTH-123 login flow")
3665 );
3666 assert_eq!(normalize_session_title("\n\t"), None);
3667 assert_eq!(
3668 normalize_session_title("🚀".repeat(50).as_str())
3669 .unwrap()
3670 .chars()
3671 .count(),
3672 SESSION_TITLE_MAX_CHARS
3673 );
3674 }
3675
3676 #[test]
3677 fn session_title_prompt_is_utf8_safe_and_bounded() {
3678 let prompt = "🚀".repeat(SESSION_TITLE_PROMPT_MAX_BYTES);
3679 let bounded = bounded_session_title_prompt(&prompt);
3680 assert!(bounded.len() <= SESSION_TITLE_PROMPT_MAX_BYTES);
3681 assert!(std::str::from_utf8(bounded.as_bytes()).is_ok());
3682 }
3683
3684 #[test]
3685 fn cache_warming_uses_ninety_percent_with_ten_second_margin() {
3686 assert_eq!(
3687 cache_warming_delay(std::time::Duration::from_secs(300)),
3688 Some(std::time::Duration::from_secs(270))
3689 );
3690 assert_eq!(
3691 cache_warming_delay(std::time::Duration::from_secs(60)),
3692 Some(std::time::Duration::from_secs(50))
3693 );
3694 assert_eq!(
3695 cache_warming_delay(std::time::Duration::from_secs(10)),
3696 None
3697 );
3698 }
3699}