use anyhow::Result;
use crate::entities::profile::ToolId;
use super::introspection::empty_object;
use super::{Tool, ToolContext, ToolOutcome};
pub const GET_LLM_NAME_ID: &str = "get_llm_name";
pub const GET_LLM_HISTORY_ID: &str = "get_llm_history";
pub struct GetLlmName;
#[async_trait::async_trait]
impl Tool for GetLlmName {
fn id(&self) -> ToolId {
GET_LLM_NAME_ID.into()
}
fn concurrent(&self) -> bool {
true
}
fn group(&self) -> crate::features::tools::meta::ToolGroup {
crate::features::tools::meta::ToolGroup::Introspection
}
fn ui_label(&self) -> &'static str {
"show LLM name"
}
fn description(&self, loc: &crate::shared::i18n::Locale) -> String {
loc.t("tool.get_llm_name.desc").into()
}
fn parameters(&self, _loc: &crate::shared::i18n::Locale) -> serde_json::Value {
empty_object()
}
async fn invoke(&self, ctx: &ToolContext, _args: serde_json::Value) -> Result<ToolOutcome> {
let mode = ctx.engine_mode.key();
let text = match &ctx.model_name {
Some(name) => ctx.loc.tf(
"tool.get_llm_name.result",
&[("name", name), ("mode", mode)],
),
None => ctx.loc.tf("tool.get_llm_name.unknown", &[("mode", mode)]),
};
Ok(ToolOutcome::text(text))
}
}
pub struct GetLlmHistory;
#[async_trait::async_trait]
impl Tool for GetLlmHistory {
fn id(&self) -> ToolId {
GET_LLM_HISTORY_ID.into()
}
fn concurrent(&self) -> bool {
true
}
fn group(&self) -> crate::features::tools::meta::ToolGroup {
crate::features::tools::meta::ToolGroup::Introspection
}
fn ui_label(&self) -> &'static str {
"LLM history"
}
fn description(&self, loc: &crate::shared::i18n::Locale) -> String {
loc.t("tool.get_llm_history.desc").into()
}
fn parameters(&self, _loc: &crate::shared::i18n::Locale) -> serde_json::Value {
empty_object()
}
async fn invoke(&self, ctx: &ToolContext, _args: serde_json::Value) -> Result<ToolOutcome> {
let records = ctx.storage.db().llm_history(ctx.profile_id)?;
if records.is_empty() {
let mut text: String = ctx.loc.t("tool.get_llm_history.empty").into();
if let Some(name) = &ctx.model_name {
text.push(' ');
text.push_str(&ctx.loc.tf(
"tool.get_llm_history.current",
&[("name", name), ("mode", ctx.engine_mode.key())],
));
}
return Ok(ToolOutcome::text(text));
}
let mut out = ctx.loc.tf(
"tool.get_llm_history.header",
&[("count", &records.len().to_string())],
);
for r in &records {
out.push_str(&format!(
"\n{} — {} ({})",
r.changed_at.format("%Y-%m-%d %H:%M UTC"),
r.model,
r.mode.key()
));
}
Ok(ToolOutcome::text(out))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::entities::profile::LlmChange;
use crate::features::tools::testkit;
use crate::shared::config::ServerMode;
use crate::shared::i18n::{Lang, locale};
use chrono::{TimeZone, Utc};
use uuid::Uuid;
#[test]
fn llm_tools_are_in_catalog_and_defaults() {
let all = crate::features::tools::all_tool_ids();
let defaults = crate::features::tools::default_tool_ids();
for id in [GET_LLM_NAME_ID, GET_LLM_HISTORY_ID] {
assert!(all.iter().any(|t| t == id), "{id} missing from catalog");
assert!(defaults.iter().any(|t| t == id), "{id} not in defaults");
}
}
#[test]
fn descriptions_are_localized() {
let (ru, en) = (locale(Lang::Ru), locale(Lang::En));
for tool in [&GetLlmName as &dyn Tool, &GetLlmHistory] {
let (d_ru, d_en) = (tool.description(ru), tool.description(en));
assert_ne!(d_ru, d_en, "{} description is not localized", tool.id());
assert!(
!d_en.chars().any(|c| ('\u{0400}'..='\u{04FF}').contains(&c)),
"{} en description contains Cyrillic",
tool.id()
);
}
}
#[tokio::test]
async fn get_llm_name_reports_the_turn_snapshot() {
let (_dir, _storage, mut ctx) = testkit::ctx_with_storage(Uuid::new_v4());
ctx.model_name = Some("gemma-4-31B".into());
ctx.engine_mode = ServerMode::Managed;
let out = GetLlmName
.invoke(&ctx, serde_json::json!({}))
.await
.unwrap();
assert!(out.result.contains("gemma-4-31B"), "{}", out.result);
assert!(out.result.contains("managed"), "{}", out.result);
}
#[tokio::test]
async fn get_llm_name_closes_the_door_when_the_engine_does_not_say() {
let (_dir, _storage, mut ctx) = testkit::ctx_with_storage(Uuid::new_v4());
ctx.model_name = None;
ctx.engine_mode = ServerMode::External;
let out = GetLlmName
.invoke(&ctx, serde_json::json!({}))
.await
.unwrap();
assert_eq!(
out.result,
ctx.loc
.tf("tool.get_llm_name.unknown", &[("mode", "external")])
);
}
#[tokio::test]
async fn get_llm_history_renders_records_oldest_first() {
let pid = Uuid::new_v4();
let (_dir, storage, ctx) = testkit::ctx_with_storage(pid);
for (ts, model, mode) in [
("2026-01-01T12:00:00Z", "gemma-4", ServerMode::Managed),
("2026-02-01T12:00:00Z", "qwen-3.6", ServerMode::External),
] {
storage
.db()
.llm_history_note(
pid,
&LlmChange {
changed_at: ts.parse().unwrap(),
model: model.into(),
mode,
},
)
.unwrap();
}
let out = GetLlmHistory
.invoke(&ctx, serde_json::json!({}))
.await
.unwrap();
let gemma = out.result.find("2026-01-01 12:00 UTC — gemma-4 (managed)");
let qwen = out
.result
.find("2026-02-01 12:00 UTC — qwen-3.6 (external)");
assert!(gemma.is_some() && qwen.is_some(), "{}", out.result);
assert!(
gemma < qwen,
"history must render oldest first: {}",
out.result
);
let header = ctx.loc.tf("tool.get_llm_history.header", &[("count", "2")]);
assert!(out.result.starts_with(&header), "{}", out.result);
}
#[tokio::test]
async fn get_llm_history_empty_names_the_current_model_when_known() {
let (_dir, _storage, mut ctx) = testkit::ctx_with_storage(Uuid::new_v4());
ctx.model_name = Some("gpt-5.2".into());
ctx.engine_mode = ServerMode::OpenAi;
let out = GetLlmHistory
.invoke(&ctx, serde_json::json!({}))
.await
.unwrap();
assert_eq!(
out.result,
format!(
"{} {}",
ctx.loc.t("tool.get_llm_history.empty"),
ctx.loc.tf(
"tool.get_llm_history.current",
&[("name", "gpt-5.2"), ("mode", "openai")]
)
)
);
ctx.model_name = None;
let out = GetLlmHistory
.invoke(&ctx, serde_json::json!({}))
.await
.unwrap();
assert_eq!(out.result, ctx.loc.t("tool.get_llm_history.empty"));
}
#[test]
fn history_line_format_is_stable() {
let rec = LlmChange {
changed_at: Utc.with_ymd_and_hms(2026, 8, 29, 9, 5, 0).unwrap(),
model: "m".into(),
mode: ServerMode::Grok,
};
let line = format!(
"{} — {} ({})",
rec.changed_at.format("%Y-%m-%d %H:%M UTC"),
rec.model,
rec.mode.key()
);
assert_eq!(line, "2026-08-29 09:05 UTC — m (grok)");
}
}