use std::sync::Mutex;
use crate::llm::{Message, MessageContent};
use crate::session::History;
const IMAGE_TOKEN_ESTIMATE: usize = 2_000;
const CHARS_PER_TOKEN: usize = 4;
#[derive(Default)]
pub struct VecHistory {
inner: Mutex<Inner>,
}
#[derive(Default)]
struct Inner {
messages: Vec<Message>,
last_real_input: Option<u64>,
est_since_baseline: u64,
}
impl VecHistory {
pub fn new() -> Self {
Self::default()
}
pub fn from_messages(messages: Vec<Message>) -> Self {
Self {
inner: Mutex::new(Inner {
messages,
last_real_input: None,
est_since_baseline: 0,
}),
}
}
}
impl History for VecHistory {
fn append(&self, msg: Message) {
let mut inner = self.inner.lock().expect("VecHistory mutex poisoned");
if inner.last_real_input.is_some() {
inner.est_since_baseline = inner
.est_since_baseline
.saturating_add(estimate_message_tokens(&msg));
}
inner.messages.push(msg);
}
fn snapshot(&self) -> Vec<Message> {
self.inner
.lock()
.expect("VecHistory mutex poisoned")
.messages
.clone()
}
fn replace(&self, messages: Vec<Message>) {
let mut inner = self.inner.lock().expect("VecHistory mutex poisoned");
inner.messages = messages;
inner.last_real_input = None;
inner.est_since_baseline = 0;
}
fn splice_prefix(&self, drop_count: usize, summary: Message) -> usize {
let mut inner = self.inner.lock().expect("VecHistory mutex poisoned");
debug_assert!(
drop_count <= inner.messages.len(),
"splice_prefix invariant violated: drop_count={drop_count} > current len={}; \
history shrank mid-flight (concurrent mid-list deletion?)",
inner.messages.len()
);
let drop_count = drop_count.min(inner.messages.len());
let tail = inner.messages.split_off(drop_count);
inner.messages = Vec::with_capacity(tail.len() + 1);
inner.messages.push(summary);
inner.messages.extend(tail);
inner.last_real_input = None;
inner.est_since_baseline = 0;
drop_count
}
fn len(&self) -> usize {
self.inner
.lock()
.expect("VecHistory mutex poisoned")
.messages
.len()
}
fn truncate(&self, len: usize) {
let mut inner = self.inner.lock().expect("VecHistory mutex poisoned");
if len >= inner.messages.len() {
return;
}
inner.messages.truncate(len);
inner.last_real_input = None;
inner.est_since_baseline = 0;
}
fn record_input_tokens(&self, tokens: u64) {
let mut inner = self.inner.lock().expect("VecHistory mutex poisoned");
inner.last_real_input = Some(tokens);
inner.est_since_baseline = 0;
}
fn token_estimate(&self) -> Option<u64> {
let inner = self.inner.lock().expect("VecHistory mutex poisoned");
match inner.last_real_input {
Some(real) => Some(real.saturating_add(inner.est_since_baseline)),
None => {
if inner.messages.is_empty() {
return None;
}
Some(
inner
.messages
.iter()
.map(estimate_message_tokens)
.fold(0u64, u64::saturating_add),
)
}
}
}
}
pub(crate) fn estimate_message_tokens(msg: &Message) -> u64 {
let chars: usize = msg
.content
.iter()
.map(|c| match c {
MessageContent::Text { text } => text.len() / CHARS_PER_TOKEN,
MessageContent::Thinking { text, signature } => {
(text.len() + signature.as_ref().map_or(0, |s| s.len())) / CHARS_PER_TOKEN
}
MessageContent::ToolUse { name, args, .. } => {
(name.len() + args.to_string().len()) / CHARS_PER_TOKEN
}
MessageContent::ToolResult { output, .. } => {
tool_result_chars(output) / CHARS_PER_TOKEN
}
MessageContent::Image { .. } => IMAGE_TOKEN_ESTIMATE,
MessageContent::ProviderActivity { .. } => 0,
})
.sum();
chars as u64
}
fn tool_result_chars(output: &crate::llm::ToolResultBody) -> usize {
use crate::llm::{ToolResultBody, ToolResultContent};
match output {
ToolResultBody::Text { text } => text.len(),
ToolResultBody::Json { value } => value.to_string().len(),
ToolResultBody::Content { blocks } => blocks
.iter()
.map(|b| match b {
ToolResultContent::Text { text } => text.len(),
ToolResultContent::Image { data, .. } => image_data_chars(data),
})
.sum(),
}
}
fn image_data_chars(data: &crate::llm::ImageData) -> usize {
match data {
crate::llm::ImageData::Base64 { encoded } => encoded.len(),
crate::llm::ImageData::Url { url } => url.len(),
}
}
#[cfg(test)]
mod tests;