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