use std::borrow::Cow;
use serde::{Deserialize, Deserializer, Serialize};
#[cfg(test)]
mod golden;
mod portable;
mod validate;
pub use portable::retain_portable_content;
pub use validate::{HistoryIssue, ValidationMode, validate};
use crate::adapter::describe_origins;
use crate::provider_data::ALLAMA_NAMESPACE;
use crate::{
ChatRequest, ContentBlock, ContentPart, FinishReason, Message, ProviderData, ProviderIdentity,
Subject, ToolResultContent, Usage, UserContent, Warning, WarningKind,
};
pub const HISTORY_FORMAT_VERSION: u32 = 1;
pub const TURN_ENVELOPE_KEY: &str = "allama.turn";
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct History {
pub messages: Vec<Message>,
loaded_format_version: Option<u32>,
}
impl History {
#[must_use]
pub fn new(messages: Vec<Message>) -> Self {
Self {
messages,
loaded_format_version: None,
}
}
#[must_use]
pub fn loaded_format_version(&self) -> Option<u32> {
self.loaded_format_version
}
#[must_use]
pub fn from_request(request: &ChatRequest) -> Self {
Self::new(request.messages.clone())
}
pub fn retain_provider_state_for<P>(&mut self, provider: &P) -> Vec<Warning>
where
P: ProviderIdentity + ?Sized,
{
retain_provider_state_for(&mut self.messages, provider)
}
pub fn retain_portable_content(&mut self) -> Vec<Warning> {
retain_portable_content(&mut self.messages)
}
pub fn retain_provider_state(&mut self, keep: &[&str]) -> Vec<Warning> {
retain_provider_state(&mut self.messages, keep)
}
pub fn validate(&self, mode: ValidationMode) -> Result<(), Vec<HistoryIssue>> {
validate(&self.messages, mode)
}
#[must_use]
pub fn into_request(self, model: impl Into<String>) -> ChatRequest {
ChatRequest::new(model).extend_messages(self.messages)
}
}
impl PartialEq for History {
fn eq(&self, other: &Self) -> bool {
self.messages == other.messages
}
}
impl Serialize for History {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
#[derive(Serialize)]
struct Raw<'a> {
format_version: u32,
messages: &'a [Message],
}
Raw {
format_version: HISTORY_FORMAT_VERSION,
messages: &self.messages,
}
.serialize(serializer)
}
}
impl<'de> Deserialize<'de> for History {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
#[derive(Deserialize)]
struct Raw {
format_version: u32,
#[serde(default)]
system: Option<serde::de::IgnoredAny>,
messages: Vec<Message>,
}
let raw = Raw::deserialize(deserializer)?;
if raw.format_version == 0 || raw.format_version > HISTORY_FORMAT_VERSION {
return Err(serde::de::Error::custom(format!(
"unsupported history format version {} (this build supports up to {HISTORY_FORMAT_VERSION})",
raw.format_version
)));
}
if raw.system.is_some() {
return Err(serde::de::Error::custom(
"a history holds no `system` field: store what rebuilds the system prompt next to the history and set it on every request",
));
}
Ok(Self {
messages: raw.messages,
loaded_format_version: Some(raw.format_version),
})
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct TurnEnvelope {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub request_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<Cow<'static, str>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage: Option<Usage>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub finish_reason: Option<FinishReason>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub finish_reason_raw: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<Warning>,
}
pub fn retain_provider_state_for<P>(messages: &mut [Message], provider: &P) -> Vec<Warning>
where
P: ProviderIdentity + ?Sized,
{
retain_where(messages, &|namespace| provider.reads_namespace(namespace))
}
pub fn retain_provider_state(messages: &mut [Message], keep: &[&str]) -> Vec<Warning> {
retain_where(messages, &|namespace| keep.contains(&namespace))
}
fn retain_where(messages: &mut [Message], reads: &dyn Fn(&str) -> bool) -> Vec<Warning> {
let kept = |namespace: &str| namespace == ALLAMA_NAMESPACE || reads(namespace);
let mut warnings = Vec::new();
let mut warn = |message: usize, block: Option<usize>, text: String| {
warnings.push(Warning::new(
WarningKind::Dropped,
Subject::Content { message, block },
text,
));
};
let strip = |data: &mut ProviderData| -> Vec<String> {
let foreign: Vec<String> = data
.namespaces()
.map(|(namespace, _)| namespace)
.filter(|namespace| !kept(namespace))
.map(str::to_owned)
.collect();
data.retain_namespaces(kept);
foreign
};
let foreign_origin = |origin: &Option<String>| origin.as_deref().is_some_and(|o| !kept(o));
for (index, message) in messages.iter_mut().enumerate() {
let message_data = match message {
Message::User(m) => &mut m.provider_data,
Message::Assistant(m) => &mut m.provider_data,
Message::Tool(m) => &mut m.provider_data,
};
let removed = strip(message_data);
if !removed.is_empty() {
warn(
index,
None,
format!(
"message state from {} belongs to a provider that is not kept; it was dropped",
describe_origins(&removed)
),
);
}
match message {
Message::User(m) => {
if let UserContent::Parts(parts) = &mut m.content {
retain_parts(parts, index, &strip, &foreign_origin, &mut warn);
}
}
Message::Tool(m) => {
if let ToolResultContent::Parts(parts) = &mut m.content {
retain_parts(parts, index, &strip, &foreign_origin, &mut warn);
}
}
Message::Assistant(m) => {
let mut position = 0;
m.content.retain_mut(|block| {
let block_index = position;
position += 1;
match block {
ContentBlock::Reasoning(reasoning) => {
let had_state = !reasoning.provider_data.is_empty();
let removed = strip(&mut reasoning.provider_data);
if had_state && reasoning.provider_data.is_empty() {
warn(
index,
Some(block_index),
format!("reasoning from {} belongs to a provider that is not kept; it was dropped", describe_origins(&removed)),
);
return false;
}
true
}
ContentBlock::ProviderToolCall(_) | ContentBlock::ProviderToolResult(_)
if block.origin().is_some_and(|origin| !kept(origin)) =>
{
warn(
index,
Some(block_index),
format!(
"`{}` content from `{}` belongs to a provider that is not kept; it was dropped",
block.type_name(),
block.origin().unwrap_or_default()
),
);
false
}
ContentBlock::ProviderToolCall(call) => {
report(
index,
block_index,
strip(&mut call.provider_data),
&mut warn,
);
true
}
ContentBlock::ProviderToolResult(result) => {
report(
index,
block_index,
strip(&mut result.provider_data),
&mut warn,
);
true
}
ContentBlock::Other(other) if foreign_origin(&other.origin) => {
warn(
index,
Some(block_index),
format!(
"`{}` content from `{}` belongs to a provider that is not kept; it was dropped",
other.type_name,
other.origin.as_deref().unwrap_or_default()
),
);
false
}
ContentBlock::Text(text) => {
report(
index,
block_index,
strip(&mut text.provider_data),
&mut warn,
);
true
}
ContentBlock::Refusal(refusal) => {
report(
index,
block_index,
strip(&mut refusal.provider_data),
&mut warn,
);
true
}
ContentBlock::Image(image) => {
report(
index,
block_index,
strip(&mut image.provider_data),
&mut warn,
);
true
}
ContentBlock::ToolCall(call) => {
report(
index,
block_index,
strip(&mut call.provider_data),
&mut warn,
);
true
}
ContentBlock::Other(other) => {
report(
index,
block_index,
strip(&mut other.provider_data),
&mut warn,
);
true
}
}
});
}
}
}
warnings
}
fn retain_parts(
parts: &mut Vec<ContentPart>,
message: usize,
strip: &impl Fn(&mut ProviderData) -> Vec<String>,
foreign_origin: &impl Fn(&Option<String>) -> bool,
warn: &mut impl FnMut(usize, Option<usize>, String),
) {
let mut position = 0;
parts.retain_mut(|part| {
let block = position;
position += 1;
match part {
ContentPart::Other(other) if foreign_origin(&other.origin) => {
warn(
message,
Some(block),
format!(
"`{}` content from `{}` belongs to a provider that is not kept; it was dropped",
other.type_name,
other.origin.as_deref().unwrap_or_default()
),
);
false
}
ContentPart::Text(text) => {
report(message, block, strip(&mut text.provider_data), warn);
true
}
ContentPart::Image(image) => {
report(message, block, strip(&mut image.provider_data), warn);
true
}
ContentPart::File(file) => {
report(message, block, strip(&mut file.provider_data), warn);
true
}
ContentPart::Other(other) => {
report(message, block, strip(&mut other.provider_data), warn);
true
}
}
});
}
fn report(
message: usize,
block: usize,
removed: Vec<String>,
warn: &mut impl FnMut(usize, Option<usize>, String),
) {
if !removed.is_empty() {
warn(
message,
Some(block),
format!(
"state from {} belongs to a provider that is not kept; it was dropped",
describe_origins(&removed)
),
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{OtherContent, Reasoning, Text, ToolCall};
use serde_json::json;
fn data(namespace: &str) -> ProviderData {
ProviderData::new().with_raw(namespace, "signature", json!("sig"))
}
struct Gemini;
impl ProviderIdentity for Gemini {
fn provider_name(&self) -> &str {
"gemini"
}
fn reads_namespace(&self, namespace: &str) -> bool {
namespace == "google-ai"
}
}
struct Anthropic;
impl ProviderIdentity for Anthropic {
fn provider_name(&self) -> &str {
"anthropic"
}
fn reads_namespace(&self, namespace: &str) -> bool {
namespace == "anthropic"
}
}
fn mixed_conversation() -> Vec<Message> {
vec![
Message::user("Hi"),
Message::Assistant(crate::AssistantMessage::new(vec![
Reasoning::new("a")
.with_provider_data(data("google-ai"))
.into(),
Reasoning::new("b")
.with_provider_data(data("anthropic"))
.into(),
Text::new("Hello")
.with_provider_data(data("gemini").with_raw(ALLAMA_NAMESPACE, "note", json!(1)))
.into(),
])),
]
}
#[test]
fn retain_for_keeps_the_namespaces_the_provider_reads() {
let mut messages = mixed_conversation();
let warnings = retain_provider_state_for(&mut messages, &Gemini);
assert_eq!(warnings.len(), 2, "{warnings:?}");
let blocks = &messages[1].as_assistant().unwrap().content;
assert_eq!(blocks.len(), 2);
let ContentBlock::Text(text) = &blocks[1] else {
panic!("{blocks:?}")
};
let namespaces: Vec<_> = text.provider_data.namespaces().map(|(n, _)| n).collect();
assert_eq!(namespaces, [ALLAMA_NAMESPACE]);
let mut by_namespace = mixed_conversation();
retain_provider_state(&mut by_namespace, &["google-ai"]);
assert_eq!(messages, by_namespace);
}
#[test]
fn retain_for_a_fallback_keeps_what_either_side_reads() {
let provider = crate::wrappers::FallbackProvider::new(Anthropic, Gemini);
let mut history = History::new(mixed_conversation());
let warnings = history.retain_provider_state_for(&provider);
assert_eq!(
warnings.len(),
1,
"only the `gemini` state goes: {warnings:?}"
);
assert_eq!(history.messages[1].as_assistant().unwrap().content.len(), 3);
}
#[test]
fn retain_for_works_through_type_erasure() {
let boxed: Box<dyn ProviderIdentity> = Box::new(Gemini);
let mut messages = mixed_conversation();
let warnings = retain_provider_state_for(&mut messages, boxed.as_ref());
assert_eq!(warnings.len(), 2);
}
#[test]
fn provider_tool_blocks_follow_their_origin() {
let call = |origin: &str| {
ContentBlock::from(
crate::ProviderToolCall::new("c", "web_search", "{}").with_origin(origin),
)
};
let result = |origin: &str| {
ContentBlock::from(
crate::ProviderToolResult::new("c", json!({"status": "completed"}))
.with_origin(origin),
)
};
let mut messages = vec![
Message::user("Search"),
Message::Assistant(crate::AssistantMessage::new(vec![
call("openai"),
result("openai"),
call("anthropic"),
result("anthropic"),
ContentBlock::text("Done."),
])),
];
let warnings = retain_provider_state(&mut messages, &["anthropic"]);
assert_eq!(warnings.len(), 2);
let kept = &messages[1].as_assistant().unwrap().content;
assert_eq!(kept.len(), 3);
assert!(kept.iter().take(2).all(|b| b.origin() == Some("anthropic")));
}
#[test]
fn retain_provider_state_removes_only_foreign_opaque_state() {
let mut history = History::new(vec![
Message::user("Weather in Paris?"),
Message::assistant_blocks(vec![
ContentBlock::Reasoning(
Reasoning::new("thinking").with_provider_data(data("anthropic")),
),
ContentBlock::Reasoning(Reasoning::new("portable reasoning")),
ContentBlock::Other(
OtherContent::new("server_tool_use", Default::default())
.with_origin("anthropic"),
),
ContentBlock::Text(Text::new("Checking.").with_provider_data(data("anthropic"))),
ContentBlock::ToolCall(
ToolCall::new("call_1", "get_weather", r#"{"city":"Paris"}"#)
.with_provider_data(data("google-ai").with_raw("allama", "note", json!(1))),
),
]),
Message::tool_result("call_1", "get_weather", "Sunny"),
Message::assistant_blocks(vec![ContentBlock::Reasoning(
Reasoning::new("").with_provider_data(data("anthropic")),
)]),
]);
let warnings = history.retain_provider_state(&["google-ai"]);
assert_eq!(history.messages.len(), 4, "no message is removed");
assert!(
history.messages[3].is_empty(),
"the opaque-only assistant turn is left empty"
);
let Message::Assistant(turn) = &history.messages[1] else {
panic!("expected assistant turn");
};
assert_eq!(turn.content.len(), 3);
assert!(
matches!(&turn.content[0], ContentBlock::Reasoning(r) if r.text == "portable reasoning")
);
assert!(matches!(&turn.content[1], ContentBlock::Text(t) if t.provider_data.is_empty()));
let ContentBlock::ToolCall(call) = &turn.content[2] else {
panic!("expected tool call");
};
assert!(
call.provider_data.namespace("google-ai").is_some(),
"kept namespace survives"
);
assert!(
call.provider_data.namespace("allama").is_some(),
"allama namespace survives"
);
assert!(warnings.iter().all(|w| w.kind == WarningKind::Dropped));
assert_eq!(warnings.len(), 4, "{warnings:?}");
assert_eq!(
warnings[0].subject,
Subject::Content {
message: 1,
block: Some(0)
}
);
assert!(history.retain_provider_state(&["google-ai"]).is_empty());
}
#[test]
fn kept_other_content_loses_only_foreign_provider_data() {
let other = || {
OtherContent::new("search_result", Default::default())
.with_origin("anthropic")
.with_provider_data(
data("anthropic")
.with_raw("google-ai", "thought_signature", json!("g"))
.with_raw("allama", "note", json!(1)),
)
};
let mut messages = vec![
Message::user(vec![ContentPart::Other(other())]),
Message::assistant_blocks(vec![ContentBlock::Other(other())]),
];
let warnings = retain_provider_state(&mut messages, &["anthropic"]);
let expected = data("anthropic").with_raw("allama", "note", json!(1));
let Message::User(user) = &messages[0] else {
panic!("expected user message");
};
let UserContent::Parts(parts) = &user.content else {
panic!("expected parts");
};
let ContentPart::Other(part) = &parts[0] else {
panic!("expected other part");
};
assert_eq!(part.provider_data, expected);
assert_eq!(
messages[1].as_assistant().unwrap().content[0].provider_data(),
&expected
);
let subjects: Vec<_> = warnings.iter().map(|w| w.subject.clone()).collect();
assert_eq!(
subjects,
[
Subject::Content {
message: 0,
block: Some(0)
},
Subject::Content {
message: 1,
block: Some(0)
},
]
);
assert!(warnings.iter().all(|w| w.message.contains("google-ai")));
assert!(retain_provider_state(&mut messages, &["anthropic"]).is_empty());
}
#[test]
fn retain_provider_state_leaves_app_data_untouched() {
let app_data = |id: &str| {
let mut map = crate::ExtraMap::new();
map.insert("myapp.id".into(), json!(id));
map.insert("anthropic".into(), json!({"looks": "like a namespace"}));
map
};
let mut user = crate::UserMessage::new(vec![ContentPart::text("Hi")]);
user.provider_data = data("anthropic");
user.app_data = app_data("u1");
let mut assistant = crate::AssistantMessage::new(vec![ContentBlock::Text(
Text::new("Hello").with_provider_data(data("anthropic")),
)]);
assistant.provider_data = data("anthropic");
assistant.app_data = app_data("a1");
let mut tool = crate::ToolMessage::new("c1", "f", "ok");
tool.provider_data = data("anthropic");
tool.app_data = app_data("t1");
let mut messages = vec![
Message::User(user),
Message::Assistant(assistant),
Message::Tool(tool),
];
let warnings = retain_provider_state(&mut messages, &["openai"]);
assert_eq!(warnings.len(), 4, "{warnings:?}");
for (message, id) in messages.iter().zip(["u1", "a1", "t1"]) {
assert!(message.provider_data().is_empty());
assert_eq!(message.app_data(), &app_data(id));
}
}
#[test]
fn an_emptied_message_keeps_its_app_data_and_turn_envelope() {
let mut reply = Message::assistant_blocks(vec![ContentBlock::Reasoning(
Reasoning::new("").with_provider_data(data("anthropic")),
)]);
let envelope = TurnEnvelope {
id: Some("msg_01".into()),
usage: Some(Usage::new().output_tokens(42)),
..TurnEnvelope::default()
};
reply.app_data_insert(TURN_ENVELOPE_KEY, &envelope).unwrap();
reply.app_data_insert("myapp.id", &"a1").unwrap();
let mut history = History::new(vec![
Message::user("Hi"),
reply,
Message::user(vec![ContentPart::Other(
OtherContent::new("custom_tool_call_output", Default::default())
.with_origin("openai"),
)]),
Message::user("Still there?"),
]);
let before = history.messages.clone();
let warnings = history.retain_provider_state(&["google-ai"]);
assert_eq!(
history.messages.len(),
before.len(),
"no message is removed"
);
for (after, before) in history.messages.iter().zip(&before) {
assert_eq!(after.app_data(), before.app_data());
}
assert!(history.messages[1].is_empty());
assert!(history.messages[2].is_empty());
let stored: Option<TurnEnvelope> =
history.messages[1].app_data_get(TURN_ENVELOPE_KEY).unwrap();
assert_eq!(stored, Some(envelope));
let subjects: Vec<_> = warnings.into_iter().map(|w| w.subject).collect();
assert_eq!(
subjects,
[
Subject::Content {
message: 1,
block: Some(0)
},
Subject::Content {
message: 2,
block: Some(0)
},
]
);
}
#[test]
fn round_trip_and_request_conversion() {
let request = ChatRequest::new("m")
.system("sys")
.user("hi")
.assistant("hello");
let history = History::from_request(&request);
assert_eq!(history.loaded_format_version(), None);
let value = serde_json::to_value(&history).unwrap();
assert_eq!(value["format_version"], HISTORY_FORMAT_VERSION);
assert!(value.get("system").is_none(), "{value}");
let back: History = serde_json::from_value(value).unwrap();
assert_eq!(back, history);
assert_eq!(back.loaded_format_version(), Some(HISTORY_FORMAT_VERSION));
let rebuilt = back.into_request("m2");
assert_eq!(rebuilt.messages, request.messages);
assert!(rebuilt.system.is_empty());
}
#[test]
fn a_stored_system_key_fails_to_load_and_names_it() {
let err = serde_json::from_value::<History>(json!({
"format_version": 1,
"system": [{"content": "Be brief."}],
"messages": []
}))
.unwrap_err();
assert!(err.to_string().contains("no `system` field"), "{err}");
}
#[test]
fn newer_versions_are_rejected() {
let err = serde_json::from_value::<History>(json!({"format_version": 99, "messages": []}))
.unwrap_err();
assert!(
err.to_string()
.contains("unsupported history format version 99")
);
}
#[test]
fn saving_writes_the_current_version() {
let mut history: History =
serde_json::from_value(json!({"format_version": 1, "messages": []})).unwrap();
history.loaded_format_version = Some(0);
history.messages.push(Message::user("hi"));
let value = serde_json::to_value(&history).unwrap();
assert_eq!(value["format_version"], HISTORY_FORMAT_VERSION);
assert_eq!(
history,
History::new(vec![Message::user("hi")]),
"equality ignores the loaded version"
);
}
fn sent_prompts(provider: &crate::mock::MockProvider) -> Vec<String> {
let request = provider.last_request().expect("a request was sent");
request
.system
.into_iter()
.map(|prompt| prompt.content)
.collect()
}
#[tokio::test]
async fn every_resume_sends_the_system_prompt_once() {
use crate::ChatProvider;
use crate::mock::MockProvider;
const PROMPT: &str = "You are concise.";
let provider = MockProvider::build(|b| b.text("1").text("2").text("3").text("4"));
let save = |request: &ChatRequest, reply: &crate::ChatResponse| {
let mut history = History::from_request(request);
history.messages.push(reply.to_assistant_message());
let stored = serde_json::to_string(&history).unwrap();
assert!(!stored.contains(PROMPT), "{stored}");
stored
};
let first = ChatRequest::new("m").system(PROMPT).user("Hi");
let mut stored = save(&first, &provider.chat(&first).await.unwrap());
for turn in ["Go on", "And?", "Thanks"] {
let loaded: History = serde_json::from_str(&stored).unwrap();
let request = loaded.into_request("m").system(PROMPT).user(turn);
let reply = provider.chat(&request).await.unwrap();
assert_eq!(
sent_prompts(&provider),
[PROMPT],
"resuming before {turn:?}"
);
stored = save(&request, &reply);
}
assert_eq!(provider.last_request().unwrap().messages.len(), 7);
}
#[tokio::test]
async fn a_reloaded_conversation_with_a_new_prompt_sends_only_the_new_prompt() {
use crate::ChatProvider;
use crate::mock::MockProvider;
let provider = MockProvider::build(|b| b.text("Hello!").text("Sure."));
let first = ChatRequest::new("m").system("v1 prompt").user("Hi");
let reply = provider.chat(&first).await.unwrap();
let mut history = History::from_request(&first);
history.messages.push(reply.to_assistant_message());
let stored = serde_json::to_string(&history).unwrap();
let loaded: History = serde_json::from_str(&stored).unwrap();
let next = loaded.into_request("m").system("v2 prompt").user("Thanks");
provider.chat(&next).await.unwrap();
assert_eq!(sent_prompts(&provider), ["v2 prompt"]);
assert_eq!(provider.last_request().unwrap().messages.len(), 3);
}
}