Skip to main content

kiss_coding/
session_runner.rs

1//! AgentSession: the high-level facade every mode drives. Owns the session
2//! manager, tool set, queues, retry, auto-compaction, and cost accounting.
3
4use 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/// Harness-level events layered over the loop's AgentEvents.
25#[derive(Debug, Clone)]
26pub enum SessionEvent {
27    Agent(Box<AgentEvent>),
28    /// The full prompt finished, after all retries and automatic compaction.
29    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    /// A dynamic workflow changed. The terminal redraws from the shared
64    /// snapshot. The event carries only the cheap version marker.
65    Workflow {
66        run: crate::workflows::RunId,
67        version: u64,
68    },
69    /// The verified result of a workflow tool call. Frontends show this after
70    /// the assistant answer so model text cannot replace the real run state.
71    WorkflowOutcome {
72        run: Option<crate::workflows::RunId>,
73        name: String,
74        status: WorkflowTurnStatus,
75    },
76    /// A loop or autoresearch job changed. The terminal reads its snapshot.
77    Iterative {
78        job: crate::iterative::JobId,
79        version: u64,
80    },
81    /// A pooled account hit its rate limit and the request moved to the
82    /// next account. `index` is zero-based; `total` is the pool size.
83    AccountSwitched(kiss_ai::auth::accounts::AccountSwitch),
84}
85
86pub type SessionEventSink = Arc<dyn Fn(SessionEvent) + Send + Sync>;
87
88/// Tools and instructions that belong to one submitted user turn.
89///
90/// Workflow mode is explicit and local to the prompt invocation. This keeps
91/// one prompt from enabling or disabling workflow tools for another prompt.
92#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
93pub enum PromptMode {
94    #[default]
95    Ordinary,
96    Workflow,
97}
98
99/// The result that the workflow runtime, rather than the model, observed.
100#[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    /// Optional replacement for the provider streaming function. Embedders and
181    /// tests install one to run the whole loop against a scripted fake model.
182    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    /// Replace the provider streaming function used by every later request.
323    ///
324    /// Pass `None` to restore the real provider. Child sessions created after
325    /// this call inherit the override so a scripted fake model also serves
326    /// subagents and compaction.
327    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    /// Replace resources used by the next model request.
384    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    /// Add or replace one tool for this process without changing saved tool configuration.
407    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    /// Remove one process-only tool. Return true when a tool was removed.
422    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    /// Stop every child agent and workflow run this session started.
435    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    /// Dynamic workflows are built on child agents, so they need subagents on
458    /// as well as their own setting.
459    pub fn workflows_enabled(&self) -> bool {
460        self.subagents_enabled() && self.settings.lock().unwrap().workflows.enabled
461    }
462
463    /// Select workflow mode from one submitted user prompt.
464    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    /// Install the callback that asks the user to approve a run.
484    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    /// The system prompt and each active tool's name and description.
524    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    /// Switch the active session without appending synthetic history.
553    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    /// Move the active leaf in the current session tree, with an optional
603    /// summary of the branch that is no longer active.
604    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    /// Drain both queues back to the caller (Escape restores to editor).
746    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                    // A retry must not put the failed answer back in the
889                    // provider request when the file refreshes the context.
890                    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                        // A workflow turn remains active across its tool call.
1005                        // Its final assistant response has no tool results, so
1006                        // the next queued or follow-up turn is ordinary again.
1007                        *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        // Cursor also advertises its native Shell tool; it must use the registered shell's syntax.
1131        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    /// Answer a short side question without changing the active session.
1316    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    /// Generate a short session title without changing conversation history.
1352    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    /// Create a one-line recap without changing the active session.
1377    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    /// Run one prompt to completion, including retry and auto-compaction.
1407    pub async fn prompt(self: &Arc<Self>, prompts: Vec<AgentMessage>) {
1408        self.prompt_with_mode(prompts, PromptMode::Ordinary).await;
1409    }
1410
1411    /// Run one prompt with tools and instructions selected for this turn.
1412    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                // Already running: enqueue as steering instead.
1422                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        // Persist prompts and run.
1437        {
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        // The prompts were already persisted. Context includes them, so run
1454        // as a continuation without a second prompt list.
1455
1456        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            // Retry on transient error stops.
1467            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                // Drop the trailing error assistant message from context.
1498                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            // Auto-compaction check after a completed run.
1511            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                // Persist assistant + tool results (prompts persisted earlier;
1536                // steering/follow-up user messages arrive here too).
1537                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                    // User prompts were persisted in prompt(). Avoid double
1551                    // writes by checking the current leaf message identity.
1552                    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    /// Manual or automatic compaction.
1699    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    /// Current estimated context tokens and window fraction.
1893    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                // Usage in retained assistant messages describes the old
2050                // request, not the conversation after a model-owned edit.
2051                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        // Zhipu/Z.ai code 1308 is the plan's usage limit, sent as HTTP 429.
2084        // It resets after hours, not seconds.
2085        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    // ChatGPT usage limits, including a Business workspace limit that only
2108    // the workspace admin can raise.
2109    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                // Intermediate agent completion must not announce a finished prompt.
2255                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        // Subagents on, workflow not armed: an ordinary coding turn pays
2806        // nothing for the feature.
2807        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        // Workflows are built on child agents, so the subagent setting is the
2848        // authority for both.
2849        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        // An ordinary coding turn must pay nothing for dynamic workflows. The
3019        // disarmed session is the baseline. The armed one carries the extra
3020        // tool and the authoring instructions.
3021        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}