use super::*;
fn script(text: &str) -> Vec<ChatChunk> {
vec![
ChatChunk::Text(text.into()),
ChatChunk::Finished(FinishReason::Stop),
]
}
async fn orch_with_scripts(
scripts: Vec<Vec<ChatChunk>>,
cfg: AppConfig,
) -> (
tempfile::TempDir,
UnboundedSender<AppCommand>,
UnboundedReceiver<AppEvent>,
tokio::task::JoinHandle<()>,
Uuid,
) {
let backend = Arc::new(MockBackend::sequence(scripts)) as Arc<dyn EngineBackend>;
let (d, cmd_tx, mut evt_rx, handle) = spawn_orch_cfg(Some(backend), cfg);
let active = wait_for(&mut evt_rx, |e| matches!(e, AppEvent::ChatActivated { .. }))
.await
.unwrap();
let chat_id = match active {
AppEvent::ChatActivated { id, .. } => id,
_ => unreachable!(),
};
(d, cmd_tx, evt_rx, handle, chat_id)
}
async fn wait_renamed(evt_rx: &mut UnboundedReceiver<AppEvent>) -> (Uuid, String) {
match wait_for(evt_rx, |e| matches!(e, AppEvent::ChatRenamed { .. }))
.await
.unwrap()
{
AppEvent::ChatRenamed { id, title } => (id, title),
_ => unreachable!(),
}
}
fn title_result(chat_id: Uuid, text: Result<&str, &str>, origin: TitleOrigin) -> TitleResult {
TitleResult {
chat_id,
text: text.map(str::to_string).map_err(str::to_string),
origin,
prefill: None,
}
}
#[tokio::test]
async fn auto_rename_sets_title_from_model() {
let (_d, cmd_tx, mut evt_rx, handle, chat_id) = orch_with_scripts(
vec![script("ответ"), script("«Тема разговора»")],
no_auto_cfg(),
)
.await;
cmd_tx
.send(AppCommand::SendMessage("привет".into()))
.unwrap();
wait_for(&mut evt_rx, |e| matches!(e, AppEvent::Finished { .. }))
.await
.unwrap();
wait_for(&mut evt_rx, |e| matches!(e, AppEvent::ChatList(_)))
.await
.unwrap();
cmd_tx.send(AppCommand::AutoRenameChat(chat_id)).unwrap();
let (id, title) = wait_renamed(&mut evt_rx).await;
assert_eq!(id, chat_id);
assert_eq!(title, "Тема разговора", "the model's quotes are stripped");
cmd_tx.send(AppCommand::Quit).unwrap();
handle.await.unwrap();
}
#[test]
fn salvage_prefers_text_else_last_thought_line() {
assert_eq!(
salvage_title_source("Заголовок".into(), "мысли".into()),
"Заголовок"
);
assert_eq!(
salvage_title_source(" ".into(), "рассуждаю\nитог: Планы\n\n".into()),
"итог: Планы"
);
assert_eq!(salvage_title_source(String::new(), String::new()), "");
}
#[test]
fn auto_rename_without_messages_emits_error() {
let (_d, mut orch, mut rx, chat_id) = bare_with_chat(vec![]);
orch.handle_auto_rename(chat_id);
let ev = rx.try_recv().unwrap();
assert!(matches!(ev, AppEvent::ChatListError(_)));
}
#[tokio::test]
async fn first_reply_titles_the_chat_automatically() {
let (_d, cmd_tx, mut evt_rx, handle, chat_id) = orch_with_scripts(
vec![script("ответ"), script("«Планы на дачу»")],
AppConfig::default(),
)
.await;
cmd_tx
.send(AppCommand::SendMessage("привет".into()))
.unwrap();
wait_for(&mut evt_rx, |e| matches!(e, AppEvent::Finished { .. }))
.await
.unwrap();
let (id, title) = wait_renamed(&mut evt_rx).await;
assert_eq!(id, chat_id);
assert_eq!(title, "Планы на дачу");
cmd_tx
.send(AppCommand::SendMessage("ещё вопрос".into()))
.unwrap();
wait_for(&mut evt_rx, |e| matches!(e, AppEvent::Finished { .. }))
.await
.unwrap();
cmd_tx.send(AppCommand::Quit).unwrap();
handle.await.unwrap();
let mut late_renames = 0;
while let Ok(ev) = evt_rx.try_recv() {
if matches!(ev, AppEvent::ChatRenamed { .. }) {
late_renames += 1;
}
}
assert_eq!(late_renames, 0, "the second exchange must not re-title");
}
#[tokio::test]
async fn after_user_mode_titles_on_send() {
let mut cfg = AppConfig::default();
cfg.interface.auto_title = crate::shared::config::AutoTitleMode::AfterUserMessage;
let (_d, cmd_tx, mut evt_rx, handle, chat_id) =
orch_with_scripts(vec![script("Дачный сезон")], cfg).await;
cmd_tx
.send(AppCommand::SendMessage("привет".into()))
.unwrap();
let (id, title) = wait_renamed(&mut evt_rx).await;
assert_eq!(id, chat_id);
assert_eq!(title, "Дачный сезон");
cmd_tx.send(AppCommand::Quit).unwrap();
handle.await.unwrap();
}
#[tokio::test]
async fn regenerating_the_first_reply_retitles() {
let (_d, cmd_tx, mut evt_rx, handle, _chat) = orch_with_scripts(
vec![
script("ответ №1"),
script("«Первое имя»"),
script("ответ №2"),
script("«Второе имя»"),
],
AppConfig::default(),
)
.await;
cmd_tx
.send(AppCommand::SendMessage("привет".into()))
.unwrap();
let (_, first) = wait_renamed(&mut evt_rx).await;
assert_eq!(first, "Первое имя");
cmd_tx.send(AppCommand::RegenerateLast).unwrap();
let (_, second) = wait_renamed(&mut evt_rx).await;
assert_eq!(second, "Второе имя");
cmd_tx.send(AppCommand::Quit).unwrap();
handle.await.unwrap();
}
#[test]
fn manual_rename_outranks_the_automatic_title() {
let (_d, mut orch, mut rx, chat_id) = bare_with_chat(vec![
Message::user("привет"),
Message::assistant("здравствуйте"),
]);
orch.handle_rename(chat_id, "Моё имя".into());
assert!(
orch.chats[0].renamed_manually,
"a manual rename must set the flag"
);
while rx.try_recv().is_ok() {}
orch.handle_title_result(title_result(
chat_id,
Ok("«Модельное имя»"),
TitleOrigin::Auto,
));
assert_eq!(orch.chats[0].title, "Моё имя");
assert!(rx.try_recv().is_err(), "an automatic result must be silent");
orch.handle_title_result(title_result(
chat_id,
Ok("«Модельное имя»"),
TitleOrigin::Requested,
));
assert_eq!(orch.chats[0].title, "Модельное имя");
assert!(matches!(
rx.try_recv().unwrap(),
AppEvent::ChatList(_) | AppEvent::ChatRenamed { .. }
));
}
#[test]
fn manual_rename_outranks_the_automatic_title_on_a_transcript() {
let run = crate::entities::subagent::SubagentRun::fixture("Критик", &["x", "y"]);
let run_id = run.id;
let mut carrier = Message::assistant("делегировал");
carrier.tool_calls = vec![run.on_record()];
let (_d, mut orch, mut rx, chat_id) = bare_with_chat(vec![Message::user("привет"), carrier]);
orch.handle_rename(run_id, "Моё имя".into());
assert!(orch.chats[0].child(run_id).unwrap().renamed_manually);
while rx.try_recv().is_ok() {}
orch.handle_title_result(title_result(
run_id,
Ok("«Модельное имя»"),
TitleOrigin::Auto,
));
assert_eq!(orch.chats[0].child(run_id).unwrap().title, "Моё имя");
assert!(rx.try_recv().is_err(), "an automatic result must be silent");
orch.handle_title_result(title_result(
run_id,
Ok("«Модельное имя»"),
TitleOrigin::Requested,
));
assert_eq!(orch.chats[0].child(run_id).unwrap().title, "Модельное имя");
assert!(matches!(
rx.try_recv().unwrap(),
AppEvent::ChatList(_) | AppEvent::ChatRenamed { .. }
));
assert_eq!(orch.chats[0].title, "Новый чат");
assert_eq!(orch.chats[0].id, chat_id);
}
#[test]
fn automatic_title_failures_are_quiet_requested_ones_are_loud() {
let (_d, mut orch, mut rx, chat_id) = bare_with_chat(vec![]);
orch.maybe_auto_title(
chat_id,
crate::shared::config::AutoTitleMode::AfterAssistantReply,
);
assert!(
rx.try_recv().is_err(),
"the automatic path must not emit UI events"
);
orch.handle_title_result(title_result(
chat_id,
Err("engine exploded"),
TitleOrigin::Auto,
));
assert!(
rx.try_recv().is_err(),
"an automatic failure must be silent"
);
orch.handle_auto_rename(chat_id);
assert!(matches!(rx.try_recv().unwrap(), AppEvent::ChatListError(_)));
orch.handle_title_result(title_result(
chat_id,
Err("engine exploded"),
TitleOrigin::Requested,
));
assert!(matches!(rx.try_recv().unwrap(), AppEvent::ChatListError(_)));
}
#[test]
fn auto_rename_when_server_not_ready_errors_into_chat_list() {
let (_d, mut orch, mut rx, chat_id) = bare_with_chat(vec![
Message::user("привет"),
Message::assistant("здравствуйте"),
]);
orch.engines.server_status = ServerStatus::Connecting;
orch.handle_auto_rename(chat_id);
let ev = rx.try_recv().unwrap();
assert!(
matches!(ev, AppEvent::ChatListError(_)),
"a not-ready error during auto-titling must go into the chat list, got: {ev:?}"
);
}
#[tokio::test]
async fn the_title_records_its_usage_under_its_own_kind() {
use crate::shared::api::contract::TokenUsage;
use crate::shared::session_budget::Shape;
let (_d, mut orch, _rx, chat_id) = bare_with_chat(vec![
Message::user("Как назвать этот чат?"),
Message::assistant("Разговор о названиях."),
]);
orch.engines.backend = Some(Arc::new(MockBackend::scripted(vec![
ChatChunk::Text("Названия".into()),
ChatChunk::Usage(TokenUsage {
prompt_tokens: 50_000,
completion_tokens: 2,
reasoning_tokens: 0,
prefill: None,
}),
ChatChunk::Finished(FinishReason::Stop),
])) as Arc<dyn EngineBackend>);
let budget = orch.session_budget();
assert_eq!(budget.density(Shape::Title), 1.0);
orch.handle_auto_rename(chat_id);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while budget.density(Shape::Title) == 1.0 && std::time::Instant::now() < deadline {
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
assert!(
budget.density(Shape::Title) > 1.0,
"50 000 exact over a small estimate: {}",
budget.density(Shape::Title)
);
assert_eq!(
budget.density(Shape::Turn),
1.0,
"the title's record is the title's"
);
}
#[test]
fn the_titles_landing_offers_its_sample() {
let (_d, mut orch, mut rx, chat_id) =
bare_with_chat(vec![crate::entities::message::Message::user("Привет!")]);
orch.config.engine.mode = crate::shared::config::ServerMode::External;
while rx.try_recv().is_ok() {}
let cold = Some(crate::shared::api::contract::Prefill {
tokens: 1000,
ms: 26_000,
});
let mut res = title_result(chat_id, Ok("«Имя»"), TitleOrigin::Requested);
res.prefill = cold;
orch.handle_title_result(res);
let events: Vec<AppEvent> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
let renamed = events
.iter()
.position(|e| matches!(e, AppEvent::ChatRenamed { .. }))
.expect("the rename landed");
let note = events
.iter()
.position(|e| matches!(e, AppEvent::Notice(t) if t.contains("-b 256 -ub 256")))
.expect("the note");
assert!(renamed < note, "the landing first, the note after it");
let mut again = title_result(chat_id, Ok("«Ещё имя»"), TitleOrigin::Requested);
again.prefill = cold;
orch.handle_title_result(again);
let notes = std::iter::from_fn(|| rx.try_recv().ok())
.filter(|e| matches!(e, AppEvent::Notice(_)))
.count();
assert_eq!(notes, 0, "one note per server session");
}
#[test]
fn a_failed_titles_prompt_was_processed_all_the_same() {
let (_d, mut orch, mut rx, chat_id) =
bare_with_chat(vec![crate::entities::message::Message::user("Привет!")]);
orch.config.engine.mode = crate::shared::config::ServerMode::External;
while rx.try_recv().is_ok() {}
let mut res = title_result(chat_id, Err("boom"), TitleOrigin::Auto);
res.prefill = Some(crate::shared::api::contract::Prefill {
tokens: 1000,
ms: 26_000,
});
orch.handle_title_result(res);
let notes: Vec<String> = std::iter::from_fn(|| rx.try_recv().ok())
.filter_map(|e| match e {
AppEvent::Notice(t) => Some(t),
_ => None,
})
.collect();
assert_eq!(notes.len(), 1, "{notes:?}");
assert!(notes[0].contains("-b 256 -ub 256"), "{}", notes[0]);
}