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