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