use uuid::Uuid;
use super::Orchestrator;
use crate::entities::chat::Chat;
use crate::entities::message::MessageRole;
use crate::entities::profile::LlmChange;
impl Orchestrator {
pub(super) fn seed_llm_history(&self) {
for profile in &self.profiles {
match self.storage.db().llm_history(profile.id) {
Ok(existing) if !existing.is_empty() => continue,
Ok(_) => {}
Err(err) => {
tracing::warn!(error = %err, profile = %profile.id,
"failed to read the language-model history — not seeding it");
continue;
}
}
let records = derive_llm_history(profile.id, &self.chats);
if records.is_empty() {
continue;
}
match self.storage.db().llm_history_seed(profile.id, &records) {
Ok(0) => {}
Ok(n) => tracing::info!(profile = %profile.id, records = n,
"seeded the language-model history from the chats' stored metadata"),
Err(err) => tracing::warn!(error = %err, profile = %profile.id,
"failed to seed the language-model history"),
}
}
}
}
fn derive_llm_history(profile_id: Uuid, chats: &[Chat]) -> Vec<LlmChange> {
let mut records: Vec<LlmChange> = chats
.iter()
.filter(|c| c.profile_id == profile_id)
.flat_map(|c| c.messages.iter())
.filter(|m| m.role == MessageRole::Assistant)
.filter_map(|m| {
let md = m.metadata.as_ref()?;
Some(LlmChange {
changed_at: m.timestamp,
model: md.model.clone()?,
mode: md.mode,
})
})
.collect();
records.sort_by_key(|r| r.changed_at);
records.dedup_by(|a, b| a.model == b.model && a.mode == b.mode);
records
}
#[cfg(test)]
mod tests {
use super::*;
use crate::entities::message::{Message, MessageMetadata};
use crate::entities::profile::Profile;
use crate::entities::sampling::SamplingConfig;
use crate::shared::config::ServerMode;
use chrono::{DateTime, Utc};
fn reply(ts: &str, model: Option<&str>, mode: ServerMode) -> Message {
let mut msg = Message::assistant("ответ");
msg.timestamp = ts.parse::<DateTime<Utc>>().unwrap();
msg.metadata = Some(MessageMetadata {
sampling: SamplingConfig::default(),
mode,
model: model.map(Into::into),
finish: None,
});
msg
}
fn ask(ts: &str) -> Message {
let mut msg = Message::user("вопрос");
msg.timestamp = ts.parse::<DateTime<Utc>>().unwrap();
msg
}
fn chat_of(profile: &Profile, messages: Vec<Message>) -> Chat {
let mut chat = Chat::from_profile(profile, "t");
for msg in messages {
chat.push_message(msg);
}
chat
}
fn triples(records: &[LlmChange]) -> Vec<(String, &str, ServerMode)> {
records
.iter()
.map(|r| {
(
r.changed_at.format("%Y-%m-%d").to_string(),
r.model.as_str(),
r.mode,
)
})
.collect()
}
#[test]
fn derives_the_sequence_the_recorder_would_have_written() {
let profile = Profile::new("P", "sys");
let a = chat_of(
&profile,
vec![
ask("2026-01-01T10:00:00Z"),
reply("2026-01-01T10:00:01Z", Some("gemma-4"), ServerMode::Managed),
reply("2026-01-01T10:00:02Z", Some("gemma-4"), ServerMode::Managed),
reply("2026-03-01T10:00:00Z", Some("gemma-4"), ServerMode::Managed),
],
);
let b = chat_of(
&profile,
vec![
reply(
"2026-02-01T10:00:00Z",
Some("qwen-3.6"),
ServerMode::Managed,
),
reply(
"2026-02-02T10:00:00Z",
Some("qwen-3.6"),
ServerMode::External,
),
],
);
let records = derive_llm_history(profile.id, &[a, b]);
assert_eq!(
triples(&records),
[
("2026-01-01".into(), "gemma-4", ServerMode::Managed),
("2026-02-01".into(), "qwen-3.6", ServerMode::Managed),
("2026-02-02".into(), "qwen-3.6", ServerMode::External),
("2026-03-01".into(), "gemma-4", ServerMode::Managed),
]
);
}
#[test]
fn skips_replies_that_name_no_model_and_messages_that_are_not_replies() {
let profile = Profile::new("P", "sys");
let mut user = ask("2026-01-01T09:00:00Z");
user.metadata = Some(MessageMetadata {
sampling: SamplingConfig::default(),
mode: ServerMode::OpenAi,
model: Some("not-a-reply".into()),
finish: None,
});
let chat = chat_of(
&profile,
vec![
user,
Message::assistant("старый ответ"),
reply("2026-01-01T10:00:00Z", None, ServerMode::External),
reply("2026-01-02T10:00:00Z", Some("gemma-4"), ServerMode::Managed),
],
);
let records = derive_llm_history(profile.id, &[chat]);
assert_eq!(
triples(&records),
[("2026-01-02".into(), "gemma-4", ServerMode::Managed)]
);
}
#[test]
fn reads_only_the_profiles_own_chats() {
let (mine, theirs) = (Profile::new("A", "sys"), Profile::new("B", "sys"));
let chats = [
chat_of(
&mine,
vec![reply(
"2026-01-01T10:00:00Z",
Some("mine"),
ServerMode::Managed,
)],
),
chat_of(
&theirs,
vec![reply(
"2026-01-02T10:00:00Z",
Some("theirs"),
ServerMode::Managed,
)],
),
];
assert_eq!(
derive_llm_history(mine.id, &chats)
.iter()
.map(|r| r.model.as_str())
.collect::<Vec<_>>(),
["mine"]
);
assert_eq!(
derive_llm_history(theirs.id, &chats)
.iter()
.map(|r| r.model.as_str())
.collect::<Vec<_>>(),
["theirs"]
);
assert!(derive_llm_history(Uuid::new_v4(), &chats).is_empty());
}
#[test]
fn a_sub_agent_transcripts_replies_are_not_the_parents_history() {
let profile = Profile::new("P", "sys");
let mut run = crate::entities::subagent::SubagentRun::fixture("t", &["вопрос", "ответ"]);
run.messages = vec![reply(
"2026-01-01T09:00:00Z",
Some("nested"),
ServerMode::Grok,
)];
let mut parent = reply("2026-01-01T10:00:00Z", Some("gemma-4"), ServerMode::Managed);
parent.tool_calls.push(run.on_record());
let records = derive_llm_history(profile.id, &[chat_of(&profile, vec![parent])]);
assert_eq!(
records.iter().map(|r| r.model.as_str()).collect::<Vec<_>>(),
["gemma-4"]
);
}
}