use super::*;
use std::collections::VecDeque;
use std::sync::Mutex;
use tokio_util::sync::CancellationToken;
use super::super::compaction::{CompactEnd, CompactOrigin};
use crate::features::compaction::summary_system_message;
use crate::shared::api::ChatRequest;
use crate::shared::api::contract::ChatStream;
use crate::shared::config::{CompactionSettings, DEFAULT_COMPACTION_SUMMARY_WORDS};
use crate::shared::i18n::{Lang, locale};
struct RecordingBackend {
requests: Mutex<Vec<ChatRequest>>,
replies: Mutex<VecDeque<String>>,
usage: Mutex<Option<crate::shared::api::contract::TokenUsage>>,
}
impl RecordingBackend {
fn new(replies: &[&str]) -> Arc<Self> {
Arc::new(Self {
requests: Mutex::new(Vec::new()),
replies: Mutex::new(replies.iter().map(|s| (*s).to_string()).collect()),
usage: Mutex::new(None),
})
}
fn report_usage(&self, usage: crate::shared::api::contract::TokenUsage) {
*self.usage.lock().unwrap() = Some(usage);
}
fn requests(&self) -> Vec<ChatRequest> {
self.requests.lock().unwrap().clone()
}
fn rolls(&self) -> Vec<ChatRequest> {
let sys = summary_system_message(locale(Lang::default()), DEFAULT_COMPACTION_SUMMARY_WORDS);
self.requests()
.into_iter()
.filter(|r| r.system.as_deref() == Some(sys.as_str()))
.collect()
}
}
#[async_trait::async_trait]
impl EngineBackend for RecordingBackend {
async fn chat_stream(
&self,
req: ChatRequest,
_cancel: CancellationToken,
) -> anyhow::Result<ChatStream> {
self.requests.lock().unwrap().push(req);
let reply = self
.replies
.lock()
.unwrap()
.pop_front()
.unwrap_or_else(|| "ок".to_string());
let usage = *self.usage.lock().unwrap();
let s = async_stream::stream! {
yield ChatChunk::Text(reply);
if let Some(u) = usage {
yield ChatChunk::Usage(u);
}
yield ChatChunk::Finished(FinishReason::Stop);
};
Ok(Box::pin(s))
}
}
fn compact_cfg(tail_tokens: usize) -> AppConfig {
let mut cfg = no_auto_cfg();
cfg.compaction = CompactionSettings {
enabled: true,
tail_tokens,
..Default::default()
};
cfg
}
type HistoryFixture = (
tempfile::TempDir,
Orchestrator,
UnboundedReceiver<AppEvent>,
Uuid,
Arc<RecordingBackend>,
);
fn orch_with_history(exchanges: usize) -> HistoryFixture {
let (dir, mut orch, rx) = bare_orch_rx();
let profile = Profile::new("P", "sys");
let mut chat = Chat::from_profile(&profile, "t");
for i in 0..exchanges {
chat.push_message(Message::user(format!("вопрос {i}")));
chat.push_message(Message::assistant(format!("ответ {i}")));
}
let chat_id = chat.id;
orch.profiles.push(profile);
orch.chats.push(chat);
orch.active_id = Some(chat_id);
let backend = RecordingBackend::new(&["сводка"]);
orch.engines.backend = Some(backend.clone() as Arc<dyn EngineBackend>);
(dir, orch, rx, chat_id, backend)
}
fn chat_of(orch: &Orchestrator, id: Uuid) -> &Chat {
orch.chats.iter().find(|c| c.id == id).expect("the chat")
}
fn drain(rx: &mut UnboundedReceiver<AppEvent>) -> Vec<AppEvent> {
let mut out = Vec::new();
while let Ok(e) = rx.try_recv() {
out.push(e);
}
out
}
async fn turn(
cmd_tx: &UnboundedSender<AppCommand>,
evt_rx: &mut UnboundedReceiver<AppEvent>,
text: &str,
) {
cmd_tx.send(AppCommand::SendMessage(text.into())).unwrap();
wait_for(evt_rx, |e| matches!(e, AppEvent::Finished { .. }))
.await
.unwrap();
wait_for(evt_rx, |e| matches!(e, AppEvent::ChatList(_)))
.await
.unwrap();
}
async fn wait_compacted(rx: &mut UnboundedReceiver<AppEvent>) -> (Uuid, Uuid, String, usize) {
let ev = tokio::time::timeout(
std::time::Duration::from_secs(10),
wait_for(rx, |e| matches!(e, AppEvent::Compacted { .. })),
)
.await
.expect("a Compacted event within 10s")
.expect("a Compacted event");
match ev {
AppEvent::Compacted {
chat_id,
boundary,
summary,
folded,
} => (chat_id, boundary, summary, folded),
_ => unreachable!(),
}
}
fn saved_chat(dir: &tempfile::TempDir) -> Chat {
Storage::open(Paths::with_root(dir.path()))
.unwrap()
.json()
.load_chats()
.unwrap()
.into_iter()
.next()
.expect("one chat")
}
#[tokio::test]
async fn the_master_switch_makes_compact_inert() {
let (_d, mut orch, mut rx, chat_id, backend) = orch_with_history(3);
orch.config.compaction = CompactionSettings {
enabled: false,
tail_tokens: 1, ..Default::default()
};
orch.handle_compact();
let events = drain(&mut rx);
assert!(
events.iter().any(|e| matches!(e, AppEvent::Notice(_))),
"the command must be answered: {events:?}"
);
assert!(
!events.iter().any(|e| matches!(e, AppEvent::Error(_))),
"a switch that is off is not a failure: {events:?}"
);
assert!(chat_of(&orch, chat_id).compaction.is_none());
assert!(!orch.bg_running(BackgroundKind::Compaction));
assert!(
backend.requests().is_empty(),
"no roll may be started while the switch is off"
);
}
#[tokio::test]
async fn a_conversation_that_is_still_short_is_answered() {
let (_d, mut orch, mut rx, chat_id, backend) = orch_with_history(2);
orch.config.compaction = CompactionSettings {
enabled: true,
tail_tokens: 100_000,
..Default::default()
};
orch.handle_compact();
let events = drain(&mut rx);
assert!(
events.iter().any(|e| matches!(e, AppEvent::Notice(_))),
"nothing to compact must still be reported: {events:?}"
);
assert!(chat_of(&orch, chat_id).compaction.is_none());
assert!(!orch.bg_running(BackgroundKind::Compaction));
assert!(
backend.requests().is_empty(),
"a refusal must not cost a generation"
);
}
#[tokio::test]
async fn a_successful_roll_stores_the_summary_and_tells_the_feed() {
const SUMMARY: &str = "Ранее: обсудили первый и второй вопрос.";
let backend = RecordingBackend::new(&["ответ один", "ответ два", SUMMARY]);
let (dir, cmd_tx, mut evt_rx, handle) = spawn_orch_cfg(Some(backend.clone()), compact_cfg(1));
let activated = wait_for(&mut evt_rx, |e| matches!(e, AppEvent::ChatActivated { .. }))
.await
.unwrap();
let active_id = match activated {
AppEvent::ChatActivated { id, .. } => id,
_ => unreachable!(),
};
turn(&cmd_tx, &mut evt_rx, "первый вопрос").await;
turn(&cmd_tx, &mut evt_rx, "второй вопрос").await;
cmd_tx.send(AppCommand::Compact).unwrap();
let (event_chat, boundary, summary, folded) = wait_compacted(&mut evt_rx).await;
assert_eq!(
event_chat, active_id,
"the event names the chat it is about"
);
assert_eq!(summary, SUMMARY, "the event carries what the model wrote");
cmd_tx.send(AppCommand::Quit).unwrap();
handle.await.unwrap();
let chat = saved_chat(&dir);
let c = chat.compaction.expect("a stored summary");
assert_eq!(c.summary, SUMMARY);
assert_eq!(c.rolls, 1, "the first compaction of this chat");
assert_eq!(
c.upto, folded,
"the event reports the same span as is stored"
);
assert_eq!(c.boundary_id, boundary);
assert_eq!(
chat.messages[c.upto].id, c.boundary_id,
"the stored index and the stored id must point at the same message"
);
assert_eq!(
chat.messages[c.upto].role,
MessageRole::User,
"the cut always lands on a user message, so a request can never split \
an assistant turn from its tool results"
);
assert_eq!(chat.messages.len(), 4);
assert!(c.upto > 0 && c.upto < chat.messages.len());
}
async fn wait_for_requests(backend: &RecordingBackend, n: usize) -> Vec<ChatRequest> {
for _ in 0..200 {
let reqs = backend.requests();
if reqs.len() >= n {
return reqs;
}
tokio::task::yield_now().await;
}
panic!("the engine was never sent {n} request(s)");
}
fn offers_history_tools(req: &ChatRequest) -> bool {
use crate::features::tools::history::{HISTORY_READ_ID, HISTORY_SEARCH_ID};
req.tools
.iter()
.any(|t| t.name == HISTORY_READ_ID || t.name == HISTORY_SEARCH_ID)
}
#[tokio::test]
async fn the_read_back_tools_are_offered_only_after_a_compaction() {
let (_d, mut orch, _rx, chat_id, backend) = orch_with_history(4);
orch.config = compact_cfg(1);
orch.profiles[0].enabled_tools = crate::features::tools::default_tool_ids();
orch.handle_send("первый вопрос".into());
let reqs = wait_for_requests(&backend, 1).await;
assert!(
!offers_history_tools(&reqs[0]),
"nothing folded yet — the tools must not be offered"
);
let boundary_id = chat_of(&orch, chat_id).messages[2].id;
orch.gen_state = crate::app::gen_state::GenState::Idle;
orch.handle_compact_result(CompactResult {
origin: CompactOrigin::Manual,
chat_id,
boundary_id,
rolls: 1,
prefill: None,
text: Ok("сводка".into()),
});
orch.handle_send("второй вопрос".into());
let reqs = wait_for_requests(&backend, 2).await;
let turn = reqs.last().unwrap();
assert!(
offers_history_tools(turn),
"with a folded range the tools must be offered"
);
assert!(
turn.system
.as_deref()
.unwrap_or_default()
.contains("сводка"),
"the summary block travels with the tools"
);
}
#[test]
fn the_turn_snapshot_carries_the_folded_range() {
use crate::features::compaction::HistoryView;
let (_d, mut orch, _rx, chat_id, _backend) = orch_with_history(4);
orch.config = compact_cfg(1);
let boundary_id = chat_of(&orch, chat_id).messages[2].id;
orch.handle_compact_result(CompactResult {
origin: CompactOrigin::Manual,
chat_id,
boundary_id,
rolls: 1,
prefill: None,
text: Ok("сводка".into()),
});
let chat = chat_of(&orch, chat_id);
let (_, upto) = chat.compaction_view(true).expect("a folded range");
let view = HistoryView::render(&chat.messages[..upto], locale(Lang::default()))
.expect("the folded range renders");
assert!(view.page(64, 1).is_some());
let whole: String = (1..=view.page_count(64))
.map(|p| view.page(64, p).unwrap())
.collect();
assert!(whole.contains("вопрос 0"), "{whole}");
assert!(
!whole.contains(&chat.messages[upto].text),
"the verbatim tail is already in the prompt: {whole}"
);
}
#[tokio::test]
async fn compressing_never_edits_the_conversation() {
let (_d, mut orch, _rx, chat_id, _backend) = orch_with_history(3);
let before: Vec<Uuid> = chat_of(&orch, chat_id)
.messages
.iter()
.map(|m| m.id)
.collect();
let boundary_id = chat_of(&orch, chat_id).messages[2].id;
orch.handle_compact_result(CompactResult {
origin: CompactOrigin::Manual,
chat_id,
boundary_id,
rolls: 1,
prefill: None,
text: Ok("сводка".into()),
});
let chat = chat_of(&orch, chat_id);
assert!(chat.compaction.is_some(), "the summary was applied");
let after: Vec<Uuid> = chat.messages.iter().map(|m| m.id).collect();
assert_eq!(
before, after,
"the feed, search, export and the reflection watermark all keep seeing \
the whole conversation"
);
}
#[tokio::test]
async fn a_second_roll_rolls_the_summary_forward() {
const FIRST: &str = "Ранее: обсудили погоду.";
const SECOND: &str = "Ранее: обсудили погоду и встречу.";
let backend =
RecordingBackend::new(&["ответ 1", "ответ 2", FIRST, "ответ 3", "ответ 4", SECOND]);
let (dir, cmd_tx, mut evt_rx, handle) = spawn_orch_cfg(Some(backend.clone()), compact_cfg(1));
wait_for(&mut evt_rx, |e| matches!(e, AppEvent::ChatActivated { .. }))
.await
.unwrap();
turn(&cmd_tx, &mut evt_rx, "какая погода").await;
turn(&cmd_tx, &mut evt_rx, "какой прогноз").await;
cmd_tx.send(AppCommand::Compact).unwrap();
let (_, _, _, first_upto) = wait_compacted(&mut evt_rx).await;
turn(&cmd_tx, &mut evt_rx, "во сколько встреча").await;
turn(&cmd_tx, &mut evt_rx, "перенеси встречу").await;
cmd_tx.send(AppCommand::Compact).unwrap();
let (_, _, second_summary, second_upto) = wait_compacted(&mut evt_rx).await;
cmd_tx.send(AppCommand::Quit).unwrap();
handle.await.unwrap();
assert_eq!(second_summary, SECOND);
assert!(
second_upto > first_upto,
"the boundary must move forward: {first_upto} → {second_upto}"
);
let chat = saved_chat(&dir);
let c = chat.compaction.expect("a stored summary");
assert_eq!(c.rolls, 2, "a roll, not a fresh first compaction");
assert_eq!(
c.summary, SECOND,
"the newest summary replaces the previous"
);
let rolls = backend.rolls();
assert_eq!(rolls.len(), 2, "one request per compaction");
let second = &rolls[1].messages[0].content;
assert!(
second.contains(FIRST),
"the second roll must carry the previous summary forward: {second}"
);
assert!(
!second.contains("какая погода"),
"what the first roll already folded must not be re-summarized: {second}"
);
assert!(
second.contains("какой прогноз"),
"the span since the previous boundary must be there: {second}"
);
assert!(
!rolls[0].messages[0].content.contains(FIRST),
"the first roll has no previous summary to roll forward"
);
}
#[tokio::test]
async fn a_boundary_that_vanished_mid_roll_discards_the_summary() {
let (_d, mut orch, mut rx, chat_id, _backend) = orch_with_history(3);
orch.begin_bg(BackgroundKind::Compaction, CancellationToken::new(), None);
let _ = drain(&mut rx);
orch.handle_compact_result(CompactResult {
origin: CompactOrigin::Manual,
chat_id,
boundary_id: Uuid::new_v4(), rolls: 1,
prefill: None,
text: Ok("сводка".into()),
});
let chat = chat_of(&orch, chat_id);
assert!(
chat.compaction.is_none(),
"a summary with nowhere to attach is discarded, not stored"
);
let events = drain(&mut rx);
assert!(
!events
.iter()
.any(|e| matches!(e, AppEvent::Compacted { .. })),
"nothing to tell the feed about: {events:?}"
);
assert!(!orch.bg_running(BackgroundKind::Compaction));
assert_eq!(
orch.bg_failures(BackgroundKind::Compaction),
0,
"the history moving under the roll is not a failure of the roll"
);
}
#[tokio::test]
async fn a_compaction_does_not_bump_modified_at() {
let (_d, mut orch, _rx, chat_id, _backend) = orch_with_history(3);
let before = chat_of(&orch, chat_id).modified_at;
let boundary_id = chat_of(&orch, chat_id).messages[2].id;
orch.handle_compact_result(CompactResult {
origin: CompactOrigin::Manual,
chat_id,
boundary_id,
rolls: 1,
prefill: None,
text: Ok("сводка".into()),
});
let chat = chat_of(&orch, chat_id);
assert!(chat.compaction.is_some(), "the summary was applied");
assert_eq!(
chat.modified_at, before,
"compressing is housekeeping — it must not bump the chat up the list"
);
}
#[tokio::test]
async fn a_failed_roll_is_reported_and_clears_the_indicator() {
let (_d, mut orch, mut rx, chat_id, _backend) = orch_with_history(3);
orch.begin_bg(BackgroundKind::Compaction, CancellationToken::new(), None);
let _ = drain(&mut rx);
orch.handle_compact_result(CompactResult {
origin: CompactOrigin::Manual,
chat_id,
boundary_id: chat_of(&orch, chat_id).messages[2].id,
rolls: 1,
prefill: None,
text: Err(CompactEnd::Failed("сервер недоступен".into())),
});
let events = drain(&mut rx);
assert!(
events
.iter()
.any(|e| matches!(e, AppEvent::Error(m) if m.contains("сервер недоступен"))),
"the reason reaches the user: {events:?}"
);
assert!(
events.iter().any(|e| matches!(
e,
AppEvent::BackgroundTask {
kind: BackgroundKind::Compaction,
active: false
}
)),
"the status-bar indicator is cleared: {events:?}"
);
assert!(!orch.bg_running(BackgroundKind::Compaction));
assert!(chat_of(&orch, chat_id).compaction.is_none());
assert_eq!(orch.bg_failures(BackgroundKind::Compaction), 0);
}
use super::super::generation::TurnUsage;
use crate::shared::config::{EngineSettings, ManagedSettings, ServerMode};
fn auto_cfg(context_size: u32, threshold_pct: u8) -> AppConfig {
AppConfig {
compaction: CompactionSettings {
enabled: true,
tail_tokens: 1,
threshold_pct,
..Default::default()
},
engine: EngineSettings {
mode: ServerMode::Managed,
managed: ManagedSettings {
context_size,
..Default::default()
},
..Default::default()
},
..Default::default()
}
}
fn usage(prompt: u32, completion: u64) -> Option<TurnUsage> {
Some(TurnUsage {
prompt_tokens: prompt,
completion_tokens: completion,
prefill: None,
})
}
#[tokio::test]
async fn auto_compaction_fires_once_a_turn_crosses_the_threshold() {
let (_d, mut orch, _rx, chat_id, _backend) = orch_with_history(3);
orch.config = auto_cfg(1000, 75);
orch.maybe_auto_compact(chat_id, usage(700, 100));
assert!(
orch.bg_running(BackgroundKind::Compaction),
"a roll must be under way"
);
}
#[tokio::test]
async fn the_reply_counts_towards_the_next_prompt() {
let (_d, mut orch, _rx, chat_id, _backend) = orch_with_history(3);
orch.config = auto_cfg(1000, 75);
orch.maybe_auto_compact(chat_id, usage(700, 0));
assert!(!orch.bg_running(BackgroundKind::Compaction), "700 < 750");
orch.maybe_auto_compact(chat_id, usage(700, 60));
assert!(orch.bg_running(BackgroundKind::Compaction), "760 >= 750");
}
#[test]
fn without_exact_usage_the_trigger_stays_quiet() {
let (_d, mut orch, _rx, chat_id, _backend) = orch_with_history(3);
orch.config = auto_cfg(10, 75); orch.maybe_auto_compact(chat_id, None);
assert!(!orch.bg_running(BackgroundKind::Compaction));
}
#[test]
fn the_switch_and_a_zero_threshold_both_disable_the_auto_path() {
for (enabled, pct) in [(false, 75), (true, 0)] {
let (_d, mut orch, _rx, chat_id, _backend) = orch_with_history(3);
let mut cfg = auto_cfg(1000, pct);
cfg.compaction.enabled = enabled;
orch.config = cfg;
orch.maybe_auto_compact(chat_id, usage(900, 50));
assert!(
!orch.bg_running(BackgroundKind::Compaction),
"enabled={enabled} pct={pct}"
);
}
}
#[test]
fn a_roll_already_running_is_not_started_twice() {
let (_d, mut orch, _rx, chat_id, backend) = orch_with_history(3);
orch.config = auto_cfg(1000, 75);
orch.begin_bg(BackgroundKind::Compaction, CancellationToken::new(), None);
let before = backend.requests().len();
orch.maybe_auto_compact(chat_id, usage(900, 50));
assert_eq!(backend.requests().len(), before, "no second roll was sent");
}
#[test]
fn nothing_left_to_fold_is_silent() {
let (_d, mut orch, mut rx, chat_id, _backend) = orch_with_history(1);
orch.config = auto_cfg(10, 75);
let _ = drain(&mut rx);
orch.maybe_auto_compact(chat_id, usage(900, 50));
assert!(!orch.bg_running(BackgroundKind::Compaction));
assert!(
drain(&mut rx).is_empty(),
"an unavoidable state must not nag every turn"
);
}
#[test]
fn an_explicit_setting_outranks_every_other_source() {
let (_d, mut orch, _rx, _chat_id, _backend) = orch_with_history(1);
orch.config = auto_cfg(1000, 75);
orch.config.compaction.context_tokens = Some(4096);
assert_eq!(orch.context_budget(), Some(4096));
orch.config.engine.mode = ServerMode::OpenAi;
assert_eq!(orch.context_budget(), Some(4096));
}
#[test]
fn a_managed_server_is_measured_against_its_own_c_flag() {
let (_d, mut orch, _rx, _chat_id, _backend) = orch_with_history(1);
orch.config = auto_cfg(3072, 75);
assert_eq!(orch.context_budget(), Some(3072));
}
#[test]
fn an_engine_that_cannot_say_leaves_the_budget_unknown() {
let (_d, mut orch, _rx, chat_id, _backend) = orch_with_history(3);
orch.config = auto_cfg(1000, 75);
orch.config.engine.mode = ServerMode::External;
let epoch = orch.context.epoch();
orch.handle_budget_result(
epoch,
crate::app::orchestrator::compaction::EngineFacts::default(),
);
assert_eq!(orch.context_budget(), None);
orch.maybe_auto_compact(chat_id, usage(900, 50));
assert!(!orch.bg_running(BackgroundKind::Compaction));
}
#[test]
fn a_gateway_is_measured_against_the_catalogue_it_publishes() {
let (_d, mut orch, _rx, _chat_id, _backend) = orch_with_history(1);
orch.config = auto_cfg(1000, 75);
orch.config.engine.mode = ServerMode::External;
let epoch = orch.context.epoch();
orch.handle_budget_result(
epoch,
crate::app::orchestrator::compaction::EngineFacts {
budget: None,
caps: Some(crate::shared::api::contract::ModelCapabilities {
context_length: Some(64000),
sampling_fields: None,
}),
},
);
assert_eq!(orch.context_budget(), Some(64000));
}
#[test]
fn a_reported_window_wins_over_the_catalogues() {
let (_d, mut orch, _rx, _chat_id, _backend) = orch_with_history(1);
orch.config = auto_cfg(1000, 75);
orch.config.engine.mode = ServerMode::External;
let epoch = orch.context.epoch();
orch.handle_budget_result(
epoch,
crate::app::orchestrator::compaction::EngineFacts {
budget: Some(16384),
caps: Some(crate::shared::api::contract::ModelCapabilities {
context_length: Some(64000),
sampling_fields: None,
}),
},
);
assert_eq!(orch.context_budget(), Some(16384));
orch.config.compaction.context_tokens = Some(8192);
assert_eq!(
orch.context_budget(),
Some(8192),
"the user's own number is still first"
);
}
#[test]
fn the_published_sampling_fields_reach_the_gates() {
let (_d, mut orch, _rx, _chat_id, _backend) = orch_with_history(1);
orch.config.engine.mode = ServerMode::External;
assert!(
orch.endpoint_sampling_fields().is_none(),
"nothing is known before the catalogue answers"
);
let epoch = orch.context.epoch();
orch.handle_budget_result(
epoch,
crate::app::orchestrator::compaction::EngineFacts {
budget: None,
caps: Some(crate::shared::api::contract::ModelCapabilities {
context_length: None,
sampling_fields: Some(vec!["temperature".to_string()].into()),
}),
},
);
let fields = orch
.endpoint_sampling_fields()
.expect("the catalogue published a list");
assert_eq!(fields.as_ref(), ["temperature".to_string()].as_slice());
assert_eq!(
crate::entities::sampling::available_sampling_fields(None, Some(&fields)),
vec!["temperature"],
"and it narrows the offer to exactly that"
);
}
#[tokio::test]
async fn a_discovered_window_is_used_and_can_be_re_asked() {
let (_d, mut orch, _rx, _chat_id, _backend) = orch_with_history(1);
orch.config = auto_cfg(1000, 75);
orch.config.engine.mode = ServerMode::External;
let epoch = orch.context.epoch();
orch.handle_budget_result(
epoch,
crate::app::orchestrator::compaction::EngineFacts {
budget: Some(16384),
caps: None,
},
);
assert_eq!(orch.context_budget(), Some(16384));
orch.context.invalidate();
assert_eq!(orch.context_budget(), None);
}
#[tokio::test]
async fn an_answer_about_a_replaced_engine_is_dropped() {
let (_d, mut orch, _rx, _chat_id, _backend) = orch_with_history(1);
orch.config = auto_cfg(1000, 75);
orch.config.engine.mode = ServerMode::External;
let stale = orch.context.epoch();
orch.context.invalidate();
orch.handle_budget_result(
stale,
crate::app::orchestrator::compaction::EngineFacts {
budget: Some(131072),
caps: None,
},
);
assert_eq!(
orch.context_budget(),
None,
"the late answer belonged to an engine that is gone"
);
}
#[tokio::test]
async fn a_readiness_flip_asks_the_engine_again_at_once() {
let (_d, mut orch, _rx, _chat_id, _backend) = orch_with_history(1);
orch.config.engine.mode = ServerMode::External;
let before = orch.context.epoch();
orch.handle_chat_status(crate::shared::server::ServerStatus::Ready);
assert!(
orch.context.epoch() > before,
"the flip forgets the previous answer"
);
assert!(
orch.context.pending(),
"and asks again now, not at the next turn"
);
}
#[test]
fn an_automatic_failure_advances_the_streak_without_reporting() {
let (_d, mut orch, mut rx, chat_id, _backend) = orch_with_history(3);
let _ = drain(&mut rx);
let boundary_id = chat_of(&orch, chat_id).messages[2].id;
orch.handle_compact_result(CompactResult {
chat_id,
boundary_id,
rolls: 1,
origin: CompactOrigin::Auto,
text: Err(CompactEnd::Failed("сервер недоступен".into())),
prefill: None,
});
assert_eq!(orch.bg_failures(BackgroundKind::Compaction), 1);
let events = drain(&mut rx);
assert!(
!events
.iter()
.any(|e| matches!(e, AppEvent::Error(m) if m.contains("сервер недоступен"))),
"a background failure is not announced on its own: {events:?}"
);
}
#[test]
fn an_empty_automatic_summary_is_counted_not_announced() {
let (_d, mut orch, mut rx, chat_id, _backend) = orch_with_history(3);
let _ = drain(&mut rx);
let boundary_id = chat_of(&orch, chat_id).messages[2].id;
orch.handle_compact_result(CompactResult {
chat_id,
boundary_id,
rolls: 1,
origin: CompactOrigin::Auto,
text: Ok(" ".into()),
prefill: None,
});
assert_eq!(orch.bg_failures(BackgroundKind::Compaction), 1);
assert!(
!drain(&mut rx)
.iter()
.any(|e| matches!(e, AppEvent::Error(_))),
"no error is shown for a silent run"
);
assert!(chat_of(&orch, chat_id).compaction.is_none());
}
struct OverflowingBackend;
const OVERFLOW_BODY: &str = "engine returned status 400 Bad Request: \
{\"error\":{\"code\":400,\"message\":\"the request exceeds the available context size\",\
\"type\":\"exceed_context_size_error\",\"n_prompt_tokens\":32706,\"n_ctx\":16384}}";
#[async_trait::async_trait]
impl EngineBackend for OverflowingBackend {
async fn chat_stream(
&self,
_req: ChatRequest,
_cancel: CancellationToken,
) -> anyhow::Result<ChatStream> {
anyhow::bail!("{OVERFLOW_BODY}")
}
}
async fn overflow_message(compaction_enabled: bool) -> String {
let mut cfg = compact_cfg(1);
cfg.compaction.enabled = compaction_enabled;
let backend: Arc<dyn EngineBackend> = Arc::new(OverflowingBackend);
let (_dir, cmd_tx, mut evt_rx, handle) = spawn_orch_cfg(Some(backend), cfg);
wait_for(&mut evt_rx, |e| matches!(e, AppEvent::ChatActivated { .. }))
.await
.unwrap();
cmd_tx
.send(AppCommand::SendMessage("вопрос".into()))
.unwrap();
let ev = wait_for(&mut evt_rx, |e| matches!(e, AppEvent::Error(_)))
.await
.unwrap();
cmd_tx.send(AppCommand::Quit).unwrap();
handle.await.unwrap();
match ev {
AppEvent::Error(m) => m,
_ => unreachable!(),
}
}
#[tokio::test]
async fn a_full_window_is_explained_and_never_points_at_a_dead_end() {
let on = overflow_message(true).await;
assert!(
on.contains("/compact"),
"with compression on, name the command that fixes it: {on}"
);
let off = overflow_message(false).await;
assert!(
!off.contains("/compact"),
"with compression off, /compact would refuse — do not send the user there: {off}"
);
assert_ne!(on, off, "the two situations need different advice");
for msg in [&on, &off] {
assert!(
msg.contains("n_ctx") && msg.contains("16384"),
"the raw reason is still there: {msg}"
);
}
}
#[tokio::test]
async fn an_unrelated_failure_is_not_dressed_up_as_an_overflow() {
struct Broken;
#[async_trait::async_trait]
impl EngineBackend for Broken {
async fn chat_stream(
&self,
_req: ChatRequest,
_cancel: CancellationToken,
) -> anyhow::Result<ChatStream> {
anyhow::bail!("connection refused (os error 10061)")
}
}
let backend: Arc<dyn EngineBackend> = Arc::new(Broken);
let (_dir, cmd_tx, mut evt_rx, handle) = spawn_orch_cfg(Some(backend), compact_cfg(1));
wait_for(&mut evt_rx, |e| matches!(e, AppEvent::ChatActivated { .. }))
.await
.unwrap();
cmd_tx
.send(AppCommand::SendMessage("вопрос".into()))
.unwrap();
let ev = wait_for(&mut evt_rx, |e| matches!(e, AppEvent::Error(_)))
.await
.unwrap();
cmd_tx.send(AppCommand::Quit).unwrap();
handle.await.unwrap();
match ev {
AppEvent::Error(m) => {
assert!(!m.contains("/compact"), "no compaction advice here: {m}");
assert!(m.contains("connection refused"), "{m}");
}
_ => unreachable!(),
}
}
#[tokio::test]
async fn impersonation_sends_the_compacted_view() {
const SUMMARY: &str = "Ранее: обсудили первый и второй вопрос.";
let backend = RecordingBackend::new(&["ответ один", "ответ два", SUMMARY, "моя реплика"]);
let (_dir, cmd_tx, mut evt_rx, handle) = spawn_orch_cfg(Some(backend.clone()), compact_cfg(1));
wait_for(&mut evt_rx, |e| matches!(e, AppEvent::ChatActivated { .. }))
.await
.unwrap();
turn(&cmd_tx, &mut evt_rx, "первый вопрос").await;
turn(&cmd_tx, &mut evt_rx, "второй вопрос").await;
cmd_tx.send(AppCommand::Compact).unwrap();
let (_, _, _, folded) = wait_compacted(&mut evt_rx).await;
assert!(folded > 0, "something was actually folded");
cmd_tx
.send(AppCommand::Impersonate {
seed: String::new(),
})
.unwrap();
wait_for(&mut evt_rx, |e| {
matches!(e, AppEvent::ImpersonationFinished { .. })
})
.await
.unwrap();
cmd_tx.send(AppCommand::Quit).unwrap();
handle.await.unwrap();
let last = backend.requests().pop().expect("an impersonation request");
let system = last.system.as_deref().unwrap_or_default();
assert!(
system.contains(SUMMARY),
"the summary must reach the impersonation prompt: {system}"
);
let sent: Vec<&str> = last.messages.iter().map(|m| m.content.as_str()).collect();
assert!(
!sent.iter().any(|t| t.contains("первый вопрос")),
"the folded exchange must not be sent verbatim: {sent:?}"
);
}
use super::super::compaction::collect_roll;
use crate::shared::api::contract::{Prefill, TokenUsage};
const SLOW: Prefill = Prefill {
tokens: 1900,
ms: 50_000,
};
const FAST: Prefill = Prefill {
tokens: 1466,
ms: 628,
};
const ROUTE: &str = "-b 256 -ub 256";
fn usage_with(prefill: Prefill) -> TokenUsage {
TokenUsage {
prompt_tokens: prefill.tokens,
completion_tokens: 3,
reasoning_tokens: 0,
prefill: Some(prefill),
}
}
async fn external_with_two_turns(
backend: Arc<RecordingBackend>,
) -> (
tempfile::TempDir,
UnboundedSender<AppCommand>,
UnboundedReceiver<AppEvent>,
tokio::task::JoinHandle<()>,
) {
let mut cfg = compact_cfg(1);
cfg.engine.mode = ServerMode::External;
let (dir, cmd_tx, mut evt_rx, handle) = spawn_orch_cfg(Some(backend), cfg);
wait_for(&mut evt_rx, |e| matches!(e, AppEvent::ChatActivated { .. }))
.await
.unwrap();
turn(&cmd_tx, &mut evt_rx, "первый вопрос").await;
turn(&cmd_tx, &mut evt_rx, "второй вопрос").await;
(dir, cmd_tx, evt_rx, handle)
}
async fn notes_after_quit(
cmd_tx: &UnboundedSender<AppCommand>,
evt_rx: &mut UnboundedReceiver<AppEvent>,
handle: tokio::task::JoinHandle<()>,
) -> Vec<String> {
cmd_tx.send(AppCommand::Quit).unwrap();
handle.await.unwrap();
drain(evt_rx)
.into_iter()
.filter_map(|e| match e {
AppEvent::Notice(t) if t.contains(ROUTE) => Some(t),
_ => None,
})
.collect()
}
#[tokio::test]
async fn a_slow_roll_is_followed_by_the_note() {
let backend = RecordingBackend::new(&["ответ один", "ответ два", "сводка"]);
let (_d, cmd_tx, mut evt_rx, handle) = external_with_two_turns(backend.clone()).await;
backend.report_usage(usage_with(SLOW));
cmd_tx.send(AppCommand::Compact).unwrap();
wait_compacted(&mut evt_rx).await;
let note = tokio::time::timeout(
std::time::Duration::from_secs(3),
wait_for(
&mut evt_rx,
|e| matches!(e, AppEvent::Notice(t) if t.contains(ROUTE)),
),
)
.await
.expect("the note follows the landing")
.unwrap();
let AppEvent::Notice(text) = note else {
unreachable!()
};
assert!(text.contains("38"), "the engine's own figure: {text}");
let again = notes_after_quit(&cmd_tx, &mut evt_rx, handle).await;
assert!(again.is_empty(), "one note per server session: {again:?}");
}
#[tokio::test]
async fn a_fast_roll_says_nothing() {
let backend = RecordingBackend::new(&["ответ один", "ответ два", "сводка"]);
let (_d, cmd_tx, mut evt_rx, handle) = external_with_two_turns(backend.clone()).await;
backend.report_usage(usage_with(FAST));
cmd_tx.send(AppCommand::Compact).unwrap();
wait_compacted(&mut evt_rx).await;
let notes = notes_after_quit(&cmd_tx, &mut evt_rx, handle).await;
assert!(notes.is_empty(), "{notes:?}");
}
#[tokio::test]
async fn a_roll_after_the_turn_was_told_says_nothing_again() {
let backend = RecordingBackend::new(&["ответ один", "ответ два", "сводка"]);
backend.report_usage(usage_with(SLOW));
let mut cfg = compact_cfg(1);
cfg.engine.mode = ServerMode::External;
let (_d, cmd_tx, mut evt_rx, handle) = spawn_orch_cfg(Some(backend), cfg);
async fn until(
evt_rx: &mut UnboundedReceiver<AppEvent>,
notes: &mut Vec<String>,
pred: fn(&AppEvent) -> bool,
) {
loop {
let e = tokio::time::timeout(std::time::Duration::from_secs(10), evt_rx.recv())
.await
.expect("an event within 10 s")
.expect("the loop is alive");
if let AppEvent::Notice(t) = &e
&& t.contains(ROUTE)
{
notes.push(t.clone());
}
if pred(&e) {
break;
}
}
}
let mut notes = Vec::new();
until(&mut evt_rx, &mut notes, |e| {
matches!(e, AppEvent::ChatActivated { .. })
})
.await;
for text in ["первый вопрос", "второй вопрос"] {
cmd_tx.send(AppCommand::SendMessage(text.into())).unwrap();
until(&mut evt_rx, &mut notes, |e| {
matches!(e, AppEvent::Finished { .. })
})
.await;
until(&mut evt_rx, &mut notes, |e| {
matches!(e, AppEvent::ChatList(_))
})
.await;
}
cmd_tx.send(AppCommand::Compact).unwrap();
until(&mut evt_rx, &mut notes, |e| {
matches!(e, AppEvent::Compacted { .. })
})
.await;
notes.extend(notes_after_quit(&cmd_tx, &mut evt_rx, handle).await);
assert_eq!(
notes.len(),
1,
"the turn told it, the roll did not repeat it: {notes:?}"
);
}
#[tokio::test]
async fn the_collect_keeps_the_figure_only_off_a_stream_that_ended() {
use crate::shared::api::FinishReason;
let request = || ChatRequest {
continue_final: false,
system: None,
messages: Vec::new(),
sampling: Default::default(),
tools: Vec::new(),
};
let scripted =
|chunks: Vec<ChatChunk>| Arc::new(MockBackend::scripted(chunks)) as Arc<dyn EngineBackend>;
let whole = scripted(vec![
ChatChunk::Text("сводка".into()),
ChatChunk::Usage(usage_with(FAST)),
ChatChunk::Finished(FinishReason::Stop),
]);
let c = collect_roll(&whole, request(), CancellationToken::new())
.await
.unwrap();
assert_eq!(c.text, "сводка");
assert_eq!(
c.usage.and_then(|u| u.prefill).map(|p| (p.tokens, p.ms)),
Some((1466, 628))
);
assert!(!c.cancelled && !c.truncated);
let cut = scripted(vec![
ChatChunk::Text("сво".into()),
ChatChunk::Finished(FinishReason::Cancelled),
]);
let c = collect_roll(&cut, request(), CancellationToken::new())
.await
.unwrap();
assert!(c.cancelled, "read as a cut, not a summary");
assert!(c.usage.is_none(), "the usage chunk never came");
let broken = scripted(vec![
ChatChunk::Text("сво".into()),
ChatChunk::Usage(usage_with(SLOW)),
ChatChunk::Error {
message: "boom".into(),
transient: false,
},
ChatChunk::Finished(FinishReason::Error),
]);
assert!(
collect_roll(&broken, request(), CancellationToken::new())
.await
.is_err(),
"an error is an error, whatever arrived before it"
);
}
#[tokio::test]
async fn a_discarded_summarys_landing_still_offers_the_sample() {
let (_d, mut orch, mut rx, chat_id, _backend) = orch_with_history(3);
orch.config.engine.mode = ServerMode::External;
orch.begin_bg(BackgroundKind::Compaction, CancellationToken::new(), None);
let _ = drain(&mut rx);
orch.handle_compact_result(CompactResult {
origin: CompactOrigin::Manual,
chat_id,
boundary_id: Uuid::new_v4(), rolls: 1,
prefill: Some(SLOW),
text: Ok("сводка".into()),
});
assert!(chat_of(&orch, chat_id).compaction.is_none());
let events = drain(&mut rx);
assert!(
events
.iter()
.any(|e| matches!(e, AppEvent::Notice(t) if t.contains(ROUTE))),
"the engine's figure, whatever became of the text: {events:?}"
);
}
#[tokio::test]
async fn a_roll_records_its_usage_for_the_budget() {
let (_d, mut orch, _rx, _chat_id, backend) = orch_with_history(3);
orch.config.compaction = CompactionSettings {
enabled: true,
tail_tokens: 1,
..Default::default()
};
backend.report_usage(TokenUsage {
prompt_tokens: 100_000,
completion_tokens: 3,
reasoning_tokens: 0,
prefill: None,
});
let budget = orch.session_budget();
assert_eq!(
budget.density(crate::shared::session_budget::Shape::Roll),
1.0,
"nothing recorded yet"
);
orch.handle_compact();
assert!(
orch.bg_running(BackgroundKind::Compaction),
"the roll started"
);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while budget.density(crate::shared::session_budget::Shape::Roll) == 1.0
&& std::time::Instant::now() < deadline
{
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
let rolls = backend.rolls();
assert_eq!(rolls.len(), 1, "one roll streamed");
let estimate = super::super::generation::estimate_prompt_tokens(&rolls[0]);
assert!(estimate > 0);
let expected = 100_000.0 / estimate as f64;
assert!(
(budget.density(crate::shared::session_budget::Shape::Roll) - expected).abs() < 1e-9,
"exact over the roll's own estimate: {} against {expected}",
budget.density(crate::shared::session_budget::Shape::Roll)
);
assert_eq!(
budget.density(crate::shared::session_budget::Shape::Turn),
1.0,
"the roll's record is the roll's kind's (title-impersonation-usage §3.1)"
);
}