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