use crate::models::{Message, SystemPrompt, Usage};
use crate::prefix_cache::PrefixStabilityManager;
use crate::project_context::{ProjectContext, load_project_context_with_parents};
use crate::prompt_zones::{AppendLog, FrozenPrefix};
use crate::tui::approval::ApprovalMode;
use crate::working_set::WorkingSet;
use std::collections::{HashSet, VecDeque};
use std::path::PathBuf;
pub(crate) const TOOL_ACTIVATION_CACHE_MAX_NAMES: usize = 8;
pub(crate) const TOOL_ACTIVATION_CACHE_MAX_SCHEMA_BYTES: usize = 16 * 1024;
#[derive(Debug, Clone, Default)]
pub(crate) struct ToolActivationCache {
names: VecDeque<String>,
}
#[derive(Debug, Clone, Default, Eq, PartialEq)]
pub(crate) struct ToolActivationDelta {
pub(crate) admitted: Vec<String>,
pub(crate) evicted: Vec<String>,
pub(crate) rejected: Vec<String>,
}
impl ToolActivationCache {
pub(crate) fn clear(&mut self) {
self.names.clear();
}
fn catalog_tool<'a>(
catalog: &'a [crate::models::Tool],
name: &str,
) -> Option<&'a crate::models::Tool> {
catalog
.iter()
.find(|tool| tool.name == name && tool.defer_loading.unwrap_or(false))
}
fn serialized_bytes(tool: &crate::models::Tool) -> usize {
serde_json::to_vec(tool).map_or(usize::MAX, |bytes| bytes.len())
}
fn total_serialized_bytes(&self, catalog: &[crate::models::Tool]) -> usize {
self.names
.iter()
.filter_map(|name| Self::catalog_tool(catalog, name))
.map(Self::serialized_bytes)
.fold(0usize, usize::saturating_add)
}
pub(crate) fn revalidate(&mut self, catalog: &[crate::models::Tool]) -> Vec<String> {
let mut evicted = Vec::new();
self.names.retain(|name| {
let keep = Self::catalog_tool(catalog, name).is_some_and(|tool| {
Self::serialized_bytes(tool) <= TOOL_ACTIVATION_CACHE_MAX_SCHEMA_BYTES
});
if !keep {
evicted.push(name.clone());
}
keep
});
while self.names.len() > TOOL_ACTIVATION_CACHE_MAX_NAMES
|| self.total_serialized_bytes(catalog) > TOOL_ACTIVATION_CACHE_MAX_SCHEMA_BYTES
{
if let Some(name) = self.names.pop_front() {
evicted.push(name);
} else {
break;
}
}
evicted
}
pub(crate) fn activate(
&mut self,
catalog: &[crate::models::Tool],
requested: &[String],
) -> ToolActivationDelta {
let mut delta = ToolActivationDelta {
evicted: self.revalidate(catalog),
..ToolActivationDelta::default()
};
let mut seen = HashSet::new();
for name in requested {
if !seen.insert(name.clone()) {
continue;
}
let Some(tool) = Self::catalog_tool(catalog, name) else {
delta.rejected.push(name.clone());
continue;
};
if Self::serialized_bytes(tool) > TOOL_ACTIVATION_CACHE_MAX_SCHEMA_BYTES {
delta.rejected.push(name.clone());
continue;
}
if let Some(index) = self.names.iter().position(|cached| cached == name) {
self.names.remove(index);
}
self.names.push_back(name.clone());
while self.names.len() > TOOL_ACTIVATION_CACHE_MAX_NAMES
|| self.total_serialized_bytes(catalog) > TOOL_ACTIVATION_CACHE_MAX_SCHEMA_BYTES
{
if let Some(evicted) = self.names.pop_front() {
delta.evicted.push(evicted);
}
}
}
let retained = self.names.iter().collect::<HashSet<_>>();
delta.admitted = requested
.iter()
.filter(|name| retained.contains(name))
.cloned()
.collect();
delta.evicted.sort();
delta.evicted.dedup();
delta.rejected.sort();
delta.rejected.dedup();
delta
}
pub(crate) fn names(&self) -> impl Iterator<Item = &str> {
self.names.iter().map(String::as_str)
}
}
#[derive(Debug, Clone)]
pub struct Session {
pub model: String,
pub reasoning_effort: Option<String>,
pub reasoning_effort_auto: bool,
pub auto_model: bool,
pub workspace: PathBuf,
pub system_prompt: Option<SystemPrompt>,
pub system_prompt_override: bool,
pub last_system_prompt_hash: Option<u64>,
pub pending_prefix_change_reason: Option<String>,
pub(crate) pinned_prompt_context: Option<crate::core::engine::NextTurnPromptContext>,
pub(crate) context_update_baseline: Option<String>,
pub compaction_summary_prompt: Option<SystemPrompt>,
pub messages: AppendLog,
pub total_usage: SessionUsage,
pub allow_shell: bool,
pub trust_mode: bool,
pub auto_approve: bool,
pub approval_mode: ApprovalMode,
pub notes_path: PathBuf,
pub mcp_config_path: PathBuf,
pub id: String,
pub project_context: Option<ProjectContext>,
pub working_set: WorkingSet,
pub prefix_stability: Option<PrefixStabilityManager>,
pub frozen_prefix: Option<FrozenPrefix>,
pub(super) tool_activation_cache: ToolActivationCache,
pub messages_revision: u64,
}
#[derive(Debug, Clone, Default)]
#[allow(clippy::struct_field_names)]
pub struct SessionUsage {
pub input_tokens: u64,
pub output_tokens: u64,
pub cache_creation_input_tokens: Option<u64>,
pub cache_read_input_tokens: Option<u64>,
}
impl SessionUsage {
pub fn add(&mut self, usage: &Usage) {
self.input_tokens += u64::from(usage.input_tokens);
self.output_tokens += u64::from(usage.output_tokens);
if let Some(tokens) = usage.prompt_cache_write_tokens {
self.cache_creation_input_tokens =
Some(self.cache_creation_input_tokens.unwrap_or(0) + u64::from(tokens));
}
if let Some(tokens) = usage.prompt_cache_hit_tokens {
self.cache_read_input_tokens =
Some(self.cache_read_input_tokens.unwrap_or(0) + u64::from(tokens));
}
}
}
impl Session {
pub fn new(
model: String,
workspace: PathBuf,
allow_shell: bool,
trust_mode: bool,
notes_path: PathBuf,
mcp_config_path: PathBuf,
) -> Self {
let project_context = load_project_context_with_parents(&workspace);
let has_context = project_context.has_instructions();
Self {
model,
reasoning_effort: None,
reasoning_effort_auto: false,
auto_model: false,
workspace,
system_prompt: None,
system_prompt_override: false,
compaction_summary_prompt: None,
messages: AppendLog::new(),
total_usage: SessionUsage::default(),
allow_shell,
trust_mode,
auto_approve: false,
approval_mode: ApprovalMode::Suggest,
notes_path,
mcp_config_path,
id: uuid::Uuid::new_v4().to_string(),
project_context: if has_context {
Some(project_context)
} else {
None
},
last_system_prompt_hash: None,
pending_prefix_change_reason: None,
pinned_prompt_context: None,
context_update_baseline: None,
working_set: WorkingSet::default(),
prefix_stability: None,
frozen_prefix: None,
tool_activation_cache: ToolActivationCache::default(),
messages_revision: 0,
}
}
pub fn add_message(&mut self, message: Message) {
self.messages.push(message);
self.messages_revision = self.messages_revision.saturating_add(1);
}
#[allow(dead_code)]
pub fn replace_messages(&mut self, messages: Vec<Message>) {
self.messages = messages.into();
self.messages_revision = self.messages_revision.saturating_add(1);
}
pub fn bump_messages_revision(&mut self) {
self.messages_revision = self.messages_revision.saturating_add(1);
}
pub fn rebuild_working_set(&mut self) {
self.working_set
.rebuild_from_messages(&self.messages, &self.workspace);
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn deferred_tool(name: &str, description_bytes: usize) -> crate::models::Tool {
crate::models::Tool {
tool_type: None,
name: name.to_string(),
description: "x".repeat(description_bytes),
input_schema: json!({"type": "object", "properties": {}}),
allowed_callers: None,
defer_loading: Some(true),
input_examples: None,
strict: None,
cache_control: None,
}
}
#[test]
fn session_usage_cache_starts_none() {
let usage = SessionUsage::default();
assert!(usage.cache_creation_input_tokens.is_none());
assert!(usage.cache_read_input_tokens.is_none());
}
#[test]
fn session_usage_cache_remains_none_when_api_omits_cache() {
let mut usage = SessionUsage::default();
let api_usage = Usage {
input_tokens: 100,
output_tokens: 50,
prompt_cache_hit_tokens: None,
prompt_cache_miss_tokens: None,
prompt_cache_write_tokens: None,
reasoning_tokens: None,
reasoning_replay_tokens: None,
server_tool_use: None,
};
usage.add(&api_usage);
assert!(usage.cache_creation_input_tokens.is_none());
assert!(usage.cache_read_input_tokens.is_none());
}
#[test]
fn session_usage_cache_accumulates_when_reported() {
let mut usage = SessionUsage::default();
let api_usage = Usage {
input_tokens: 100,
output_tokens: 50,
prompt_cache_hit_tokens: Some(30),
prompt_cache_miss_tokens: Some(50),
prompt_cache_write_tokens: Some(20),
reasoning_tokens: None,
reasoning_replay_tokens: None,
server_tool_use: None,
};
usage.add(&api_usage);
assert_eq!(usage.cache_read_input_tokens, Some(30));
assert_eq!(usage.cache_creation_input_tokens, Some(20));
usage.add(&api_usage);
assert_eq!(usage.cache_read_input_tokens, Some(60));
assert_eq!(usage.cache_creation_input_tokens, Some(40));
}
#[test]
fn session_usage_cache_preserves_explicit_zero() {
let mut usage = SessionUsage::default();
let api_usage = Usage {
input_tokens: 100,
output_tokens: 50,
prompt_cache_hit_tokens: Some(0), prompt_cache_miss_tokens: Some(50),
prompt_cache_write_tokens: Some(1234),
reasoning_tokens: None,
reasoning_replay_tokens: None,
server_tool_use: None,
};
usage.add(&api_usage);
assert_eq!(usage.cache_read_input_tokens, Some(0));
assert_eq!(usage.cache_creation_input_tokens, Some(1234));
}
#[test]
fn tool_activation_cache_is_lru_bounded_to_eight_names() {
let catalog = (0..10)
.map(|index| deferred_tool(&format!("tool_{index}"), 8))
.collect::<Vec<_>>();
let requested = catalog
.iter()
.map(|tool| tool.name.clone())
.collect::<Vec<_>>();
let mut cache = ToolActivationCache::default();
let delta = cache.activate(&catalog, &requested);
assert_eq!(cache.names().count(), TOOL_ACTIVATION_CACHE_MAX_NAMES);
assert_eq!(
cache.names().collect::<Vec<_>>(),
vec![
"tool_2", "tool_3", "tool_4", "tool_5", "tool_6", "tool_7", "tool_8", "tool_9"
]
);
assert_eq!(delta.admitted.len(), TOOL_ACTIVATION_CACHE_MAX_NAMES);
assert!(delta.evicted.contains(&"tool_0".to_string()));
assert!(delta.evicted.contains(&"tool_1".to_string()));
}
#[test]
fn touching_a_cached_tool_makes_it_most_recent() {
let catalog = (0..9)
.map(|index| deferred_tool(&format!("tool_{index}"), 8))
.collect::<Vec<_>>();
let first_eight = catalog[..8]
.iter()
.map(|tool| tool.name.clone())
.collect::<Vec<_>>();
let mut cache = ToolActivationCache::default();
cache.activate(&catalog, &first_eight);
cache.activate(&catalog, &["tool_0".to_string()]);
cache.activate(&catalog, &["tool_8".to_string()]);
let names = cache.names().collect::<Vec<_>>();
assert!(names.contains(&"tool_0"));
assert!(!names.contains(&"tool_1"));
assert_eq!(names.last().copied(), Some("tool_8"));
}
#[test]
fn oversized_schema_is_never_admitted() {
let catalog = vec![deferred_tool(
"huge",
TOOL_ACTIVATION_CACHE_MAX_SCHEMA_BYTES + 1,
)];
let mut cache = ToolActivationCache::default();
let delta = cache.activate(&catalog, &["huge".to_string()]);
assert_eq!(cache.names().count(), 0);
assert_eq!(delta.rejected, vec!["huge"]);
}
#[test]
fn revalidate_drops_removed_denied_or_eager_tools() {
let catalog = vec![deferred_tool("kept", 8), deferred_tool("gone", 8)];
let mut cache = ToolActivationCache::default();
cache.activate(&catalog, &["kept".to_string(), "gone".to_string()]);
let mut next_catalog = vec![deferred_tool("kept", 8), deferred_tool("gone", 8)];
next_catalog[1].defer_loading = Some(false);
let evicted = cache.revalidate(&next_catalog);
assert_eq!(cache.names().collect::<Vec<_>>(), vec!["kept"]);
assert_eq!(evicted, vec!["gone"]);
}
#[test]
fn clearing_for_session_sync_forgets_all_activated_tools() {
let catalog = vec![deferred_tool("one", 8), deferred_tool("two", 8)];
let mut cache = ToolActivationCache::default();
cache.activate(&catalog, &["one".to_string(), "two".to_string()]);
cache.clear();
assert_eq!(cache.names().count(), 0);
}
}