use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use agent_client_protocol::schema::v1::{ContentBlock, SessionNotification, SessionUpdate};
use serde::Serialize;
use serde_json::value::RawValue;
use sqlx::SqlitePool;
use tokio::sync::mpsc;
use uuid::Uuid;
use crate::acp::chat_persistence;
const DEBOUNCE: Duration = Duration::from_millis(250);
const MAX_LATENCY: Duration = Duration::from_millis(1000);
const WRITER_CHANNEL_CAPACITY: usize = 256;
const RAW_FRAMES_VERSION: u64 = 1;
const MAX_FRAMES: usize = 2000;
const MAX_BLOCKS_BYTES: usize = 128 * 1024;
const MAX_FRAME_BYTES: usize = 64 * 1024;
const _: () = assert!(MAX_FRAME_BYTES <= MAX_BLOCKS_BYTES);
const MAX_TEXT_BYTES: usize = 1024 * 1024;
const TEXT_HEAD_BYTES: usize = 256 * 1024;
const TEXT_MARKER_MAX_BYTES: usize = 96;
const TEXT_TAIL_BYTES: usize = MAX_TEXT_BYTES - TEXT_HEAD_BYTES - TEXT_MARKER_MAX_BYTES;
const TEXT_TAIL_TRIM_SLACK: usize = TEXT_TAIL_BYTES / 4;
const TEXT_OMISSION_PREFIX: &str = "\n…(已省略 ";
const TEXT_OMISSION_SUFFIX: &str = " 字符)…\n";
const _: () = assert!(TEXT_HEAD_BYTES + TEXT_MARKER_MAX_BYTES + TEXT_TAIL_BYTES <= MAX_TEXT_BYTES);
const _: () = assert!(TEXT_TAIL_TRIM_SLACK > 0 && TEXT_TAIL_TRIM_SLACK < TEXT_TAIL_BYTES);
const _: () =
assert!(TEXT_OMISSION_PREFIX.len() + TEXT_OMISSION_SUFFIX.len() + 20 <= TEXT_MARKER_MAX_BYTES);
#[derive(Default)]
struct TurnState {
active: bool,
started: Option<Instant>,
wait_ms: u64,
wait_since: Option<Instant>,
wait_depth: u32,
row_id: Option<String>,
last_timing: Option<TurnTiming>,
frames: VecDeque<Box<RawValue>>,
frames_bytes: usize,
text: BoundedText,
seq: u64,
dirty: bool,
}
#[derive(Default)]
struct BoundedText {
head: String,
head_sealed: bool,
tail: String,
omitted_chars: usize,
}
impl BoundedText {
fn clear(&mut self) {
self.head.clear();
self.head_sealed = false;
self.tail.clear();
self.omitted_chars = 0;
}
fn push_str(&mut self, chunk: &str) {
let mut rest = chunk;
if !self.head_sealed {
let room = TEXT_HEAD_BYTES - self.head.len();
if rest.len() <= room {
self.head.push_str(rest);
return;
}
let cut = rest.floor_char_boundary(room);
self.head.push_str(&rest[..cut]);
self.head_sealed = true;
rest = &rest[cut..];
}
if rest.len() > TEXT_TAIL_BYTES {
let cut = rest.ceil_char_boundary(rest.len() - TEXT_TAIL_BYTES);
self.omitted_chars += rest[..cut].chars().count();
rest = &rest[cut..];
}
self.tail.push_str(rest);
if self.tail.len() > TEXT_TAIL_BYTES {
let target = TEXT_TAIL_BYTES - TEXT_TAIL_TRIM_SLACK;
let cut = self.tail.ceil_char_boundary(self.tail.len() - target);
self.omitted_chars += self.tail[..cut].chars().count();
self.tail.drain(..cut);
}
}
fn render(&self) -> String {
if !self.head_sealed {
return self.head.clone();
}
let mut out =
String::with_capacity(self.head.len() + TEXT_MARKER_MAX_BYTES + self.tail.len());
out.push_str(&self.head);
if self.omitted_chars > 0 {
out.push_str(TEXT_OMISSION_PREFIX);
out.push_str(&self.omitted_chars.to_string());
out.push_str(TEXT_OMISSION_SUFFIX);
}
out.push_str(&self.tail);
out
}
}
struct Sink {
cmd_tx: mpsc::Sender<WriterCmd>,
end_tx: mpsc::UnboundedSender<WriterCmd>,
}
enum WriterCmd {
Flush,
EndTurn(TurnTiming),
}
#[derive(Debug, Clone, Serialize)]
pub struct TurnTiming {
pub work_ms: u64,
pub wait_ms: u64,
#[serde(skip)]
pub row_id: Option<String>,
}
pub struct TurnAccumulator {
inner: Mutex<TurnState>,
sink: Mutex<Option<Sink>>,
}
struct FlushSnapshot {
row_id: String,
text: String,
blocks: String,
last_seq: i64,
}
impl TurnAccumulator {
pub fn new() -> Self {
Self { inner: Mutex::new(TurnState::default()), sink: Mutex::new(None) }
}
pub fn attach_persistence(self: &Arc<Self>, db: SqlitePool, db_session_id: String) {
let (cmd_tx, cmd_rx) = mpsc::channel(WRITER_CHANNEL_CAPACITY);
let (end_tx, end_rx) = mpsc::unbounded_channel();
if let Ok(mut guard) = self.sink.lock() {
*guard = Some(Sink { cmd_tx, end_tx });
}
let acc = self.clone();
tokio::spawn(writer_loop(acc, db, db_session_id, cmd_rx, end_rx));
}
pub fn begin_turn(&self) {
if let Ok(mut st) = self.inner.lock() {
st.active = true;
st.started = Some(Instant::now());
st.wait_ms = 0;
st.wait_since = None;
st.wait_depth = 0;
st.row_id = None;
st.last_timing = None;
st.frames.clear();
st.frames_bytes = 0;
st.text.clear();
st.dirty = false;
}
}
pub fn fold(&self, notification: &SessionNotification) -> Option<u64> {
let seq = {
let Ok(mut st) = self.inner.lock() else { return None };
if !st.active {
return None;
}
st.seq += 1;
if st.row_id.is_none() {
st.row_id = Some(Uuid::new_v4().to_string());
}
if let Some(text) = agent_message_text(¬ification.update) {
st.text.push_str(text);
}
match serde_json::value::to_raw_value(¬ification.update) {
Ok(frame) => {
let bytes = frame.get().len();
if bytes > MAX_FRAME_BYTES {
tracing::debug!(
bytes,
"dropping oversized session update frame from turn window"
);
} else {
st.frames.push_back(frame);
st.frames_bytes += bytes;
while st.frames.len() > MAX_FRAMES || st.frames_bytes > MAX_BLOCKS_BYTES {
match st.frames.pop_front() {
Some(old) => st.frames_bytes -= old.get().len(),
None => break,
}
}
}
}
Err(e) => {
tracing::warn!("failed to serialize session update for persistence: {}", e)
}
}
st.dirty = true;
st.seq
};
self.send_cmd(WriterCmd::Flush);
Some(seq)
}
pub fn begin_wait(&self) {
if let Ok(mut st) = self.inner.lock() {
if !st.active {
return;
}
st.wait_depth += 1;
if st.wait_since.is_none() {
st.wait_since = Some(Instant::now());
}
}
}
pub fn end_wait(&self) {
let Ok(mut st) = self.inner.lock() else { return };
if !st.active {
return;
}
st.wait_depth = st.wait_depth.saturating_sub(1);
if st.wait_depth == 0
&& let Some(since) = st.wait_since.take()
{
st.wait_ms += elapsed_ms(since);
}
}
pub fn end_all_waits(&self) {
let Ok(mut st) = self.inner.lock() else { return };
if !st.active {
return;
}
st.wait_depth = 0;
if let Some(since) = st.wait_since.take() {
st.wait_ms += elapsed_ms(since);
}
}
pub fn finalize_turn(&self) {
let timing = {
let Ok(mut st) = self.inner.lock() else { return };
if !st.active {
return;
}
st.active = false;
let wall_ms = st.started.map_or(0, elapsed_ms);
let mut wait_ms = st.wait_ms;
if let Some(since) = st.wait_since.take() {
wait_ms += elapsed_ms(since);
}
let wait_ms = wait_ms.min(wall_ms);
st.wait_depth = 0;
st.wait_ms = 0;
let timing =
TurnTiming { work_ms: wall_ms - wait_ms, wait_ms, row_id: st.row_id.clone() };
st.last_timing = Some(timing.clone());
timing
};
self.send_end_turn(timing);
}
fn send_cmd(&self, cmd: WriterCmd) {
if let Ok(guard) = self.sink.lock()
&& let Some(sink) = guard.as_ref()
{
let _ = sink.cmd_tx.try_send(cmd);
}
}
fn send_end_turn(&self, timing: TurnTiming) {
if let Ok(guard) = self.sink.lock()
&& let Some(sink) = guard.as_ref()
&& let Err(e) = sink.end_tx.send(WriterCmd::EndTurn(timing))
{
tracing::warn!("turn EndTurn 无法送达(writer 已退出): {}", e);
}
}
fn snapshot(&self) -> Option<FlushSnapshot> {
let st = self.inner.lock().ok()?;
let row_id = st.row_id.clone()?;
Some(FlushSnapshot {
row_id,
text: st.text.render(),
blocks: wrap_frames(&st.frames),
last_seq: st.seq as i64,
})
}
pub fn turn_row_id(&self) -> Option<String> {
self.inner.lock().ok()?.row_id.clone()
}
pub fn turn_timing(&self) -> Option<TurnTiming> {
self.inner.lock().ok()?.last_timing.clone()
}
pub fn turn_snapshot(&self) -> TurnSnapshot {
let Ok(st) = self.inner.lock() else {
return TurnSnapshot::default();
};
TurnSnapshot {
active: st.active,
row_id: st.row_id.clone(),
text: st.text.render(),
blocks: wrap_frames(&st.frames),
seq: st.seq,
}
}
}
fn wrap_frames(frames: &VecDeque<Box<RawValue>>) -> String {
#[derive(Serialize)]
struct Wrapper<'a> {
v: u64,
frames: &'a VecDeque<Box<RawValue>>,
}
match serde_json::to_string(&Wrapper { v: RAW_FRAMES_VERSION, frames }) {
Ok(s) => s,
Err(e) => {
tracing::warn!("failed to serialize turn frames wrapper: {}", e);
format!("{{\"v\":{RAW_FRAMES_VERSION},\"frames\":[]}}")
}
}
}
#[derive(Default)]
pub struct TurnSnapshot {
pub active: bool,
pub row_id: Option<String>,
pub text: String,
pub blocks: String,
pub seq: u64,
}
impl Default for TurnAccumulator {
fn default() -> Self {
Self::new()
}
}
fn elapsed_ms(since: Instant) -> u64 {
since.elapsed().as_millis() as u64
}
fn agent_message_text(update: &SessionUpdate) -> Option<&str> {
if let SessionUpdate::AgentMessageChunk(chunk) = update
&& let ContentBlock::Text(t) = &chunk.content
{
return Some(&t.text);
}
None
}
async fn next_cmd(
cmd_rx: &mut mpsc::Receiver<WriterCmd>,
end_rx: &mut mpsc::UnboundedReceiver<WriterCmd>,
) -> Option<WriterCmd> {
tokio::select! {
biased;
end = end_rx.recv() => end,
flush = cmd_rx.recv() => flush,
}
}
async fn writer_loop(
acc: Arc<TurnAccumulator>,
db: SqlitePool,
session_id: String,
mut cmd_rx: mpsc::Receiver<WriterCmd>,
mut end_rx: mpsc::UnboundedReceiver<WriterCmd>,
) {
let mut pending_since: Option<Instant> = None;
loop {
let cmd = if let Some(since) = pending_since {
let elapsed = since.elapsed();
let wait = if elapsed >= MAX_LATENCY {
Duration::ZERO
} else {
DEBOUNCE.min(MAX_LATENCY - elapsed)
};
match tokio::time::timeout(wait, next_cmd(&mut cmd_rx, &mut end_rx)).await {
Err(_elapsed) => {
flush_once(&acc, &db, &session_id).await;
pending_since = None;
continue;
}
Ok(None) => break, Ok(Some(c)) => c,
}
} else {
match next_cmd(&mut cmd_rx, &mut end_rx).await {
Some(c) => c,
None => break,
}
};
match cmd {
WriterCmd::Flush => {
if pending_since.is_none() {
pending_since = Some(Instant::now());
}
}
WriterCmd::EndTurn(t) => {
while cmd_rx.try_recv().is_ok() {}
flush_once(&acc, &db, &session_id).await;
pending_since = None;
let (work, wait) = (t.work_ms as i64, t.wait_ms as i64);
if let Some(row_id) = &t.row_id
&& let Err(e) =
chat_persistence::finalize_message(&db, row_id, work, wait).await
{
tracing::warn!("failed to finalize streaming chat message: {}", e);
}
if let Err(e) =
chat_persistence::accumulate_turn(&db, &session_id, work, wait).await
{
tracing::warn!("failed to accumulate session work time: {}", e);
}
}
}
}
}
async fn flush_once(acc: &Arc<TurnAccumulator>, db: &SqlitePool, session_id: &str) {
let Some(snap) = acc.snapshot() else { return };
if let Err(e) = chat_persistence::upsert_streaming_message(
db,
&snap.row_id,
session_id,
&snap.text,
Some(&snap.blocks),
snap.last_seq,
)
.await
{
tracing::warn!("failed to upsert streaming chat message: {}", e);
}
}
#[cfg(test)]
mod tests {
use super::*;
use agent_client_protocol::schema::v1::{ContentChunk, SessionId, TextContent};
use serde_json::Value;
const WRAPPER_SLACK: usize = 4096;
fn text_chunk(sid: &SessionId, text: impl Into<String>) -> SessionNotification {
SessionNotification::new(
sid.clone(),
SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::Text(
TextContent::new(text.into()),
))),
)
}
fn retained_frames(acc: &TurnAccumulator) -> (usize, usize) {
let st = acc.inner.lock().expect("lock");
(st.frames.len(), st.frames_bytes)
}
fn capture_cmds(
acc: &TurnAccumulator,
) -> (mpsc::Receiver<WriterCmd>, mpsc::UnboundedReceiver<WriterCmd>) {
let (tx, rx) = mpsc::channel(64);
let (end_tx, end_rx) = mpsc::unbounded_channel();
*acc.sink.lock().expect("sink lock") = Some(Sink { cmd_tx: tx, end_tx });
(rx, end_rx)
}
fn end_turns(rx: &mut mpsc::UnboundedReceiver<WriterCmd>) -> Vec<(u64, u64, Option<String>)> {
let mut out = Vec::new();
while let Ok(cmd) = rx.try_recv() {
if let WriterCmd::EndTurn(t) = cmd {
out.push((t.work_ms, t.wait_ms, t.row_id));
}
}
out
}
fn wait_state(acc: &TurnAccumulator) -> (u32, bool, u64) {
let st = acc.inner.lock().expect("lock");
(st.wait_depth, st.wait_since.is_some(), st.wait_ms)
}
fn nap(ms: u64) {
std::thread::sleep(Duration::from_millis(ms));
}
fn text_buffers(acc: &TurnAccumulator) -> (usize, usize, usize) {
let st = acc.inner.lock().expect("lock");
(st.text.head.len(), st.text.tail.len(), st.text.omitted_chars)
}
fn split_folded(text: &str) -> Option<(&str, usize, &str)> {
let (head, rest) = text.split_once(TEXT_OMISSION_PREFIX)?;
let (count, tail) = rest.split_once(TEXT_OMISSION_SUFFIX)?;
Some((head, count.parse().expect("折叠标记应包含可解析的省略字符数"), tail))
}
fn fold_until(
acc: &TurnAccumulator,
sid: &SessionId,
unit: &str,
total_bytes: usize,
) -> String {
let mut fed = String::new();
while fed.len() < total_bytes {
acc.fold(&text_chunk(sid, unit));
fed.push_str(unit);
}
fed
}
fn parse_frames(blocks: &str) -> Vec<Value> {
let wrapper: Value = serde_json::from_str(blocks).expect("blocks 应为合法 JSON");
wrapper["frames"].as_array().expect("frames 应为数组").clone()
}
#[test]
fn row_id_is_readable_after_finalize_and_reset_by_next_turn() {
let acc = TurnAccumulator::new();
let sid = SessionId::new("s1");
assert_eq!(acc.turn_row_id(), None, "未开始 turn 时无行 id");
acc.begin_turn();
assert_eq!(acc.turn_row_id(), None, "首帧折叠前仍无行 id(行是惰创建的)");
acc.fold(&text_chunk(&sid, "hello"));
let row_id = acc.turn_row_id().expect("首帧折叠后应有行 id");
assert_eq!(acc.turn_snapshot().row_id.as_deref(), Some(row_id.as_str()), "与快照同源");
acc.finalize_turn();
assert_eq!(acc.turn_row_id().as_deref(), Some(row_id.as_str()), "定稿后仍可读到");
acc.begin_turn();
assert_eq!(acc.turn_row_id(), None, "下一 turn 开始才清除");
}
#[test]
fn timing_is_readable_after_finalize_and_reset_by_next_turn() {
let acc = TurnAccumulator::new();
assert!(acc.turn_timing().is_none(), "从未定稿过 → None");
acc.begin_turn();
assert!(acc.turn_timing().is_none(), "turn 进行中不得读到上一 turn 的结算值");
nap(20);
acc.finalize_turn();
let t = acc.turn_timing().expect("定稿后 WS 层仍要能读到");
assert!(t.work_ms >= 12, "墙钟应记进 work_ms,实得 {}", t.work_ms);
assert_eq!(t.wait_ms, 0, "本 turn 没有审批等待");
acc.begin_turn();
assert!(acc.turn_timing().is_none(), "下一 turn 开始才清除");
}
#[test]
fn window_is_bounded_on_both_axes_over_long_turn() {
const FOLD_COUNT: u32 = 5000;
assert!(FOLD_COUNT as usize > MAX_FRAMES, "测试需超过窗口以触发淘汰");
let acc = TurnAccumulator::new();
acc.begin_turn();
let sid = SessionId::new("s1");
for i in 0..FOLD_COUNT {
assert!(
acc.fold(&text_chunk(&sid, format!("chunk-{i} "))).is_some(),
"turn active 时应折叠出 seq"
);
}
let (len, bytes) = retained_frames(&acc);
assert!(len <= MAX_FRAMES, "frames 不得超帧数上限,得到 {len}");
assert!(bytes <= MAX_BLOCKS_BYTES, "frames 不得超字节上限,得到 {bytes}");
let snap = acc.turn_snapshot();
assert!(
snap.blocks.len() <= MAX_BLOCKS_BYTES + WRAPPER_SLACK,
"blocks 列不随 turn 长度增长,得到 {} 字节",
snap.blocks.len()
);
assert_eq!(parse_frames(&snap.blocks).len(), len, "包裹后的帧数应等于窗口帧数");
assert!(
snap.text.len() < MAX_TEXT_BYTES,
"本例输入量应远低于正文上限,否则下方全量断言不成立"
);
assert!(snap.text.starts_with("chunk-0 "), "text 保留最早 chunk");
assert!(snap.text.ends_with("chunk-4999 "), "text 保留最新 chunk");
assert_eq!(snap.text.matches("chunk-").count(), FOLD_COUNT as usize);
}
#[test]
fn long_text_folds_to_head_and_tail_within_cap() {
let acc = TurnAccumulator::new();
acc.begin_turn();
let sid = SessionId::new("s1");
let mut fed = String::new();
let mut i = 0usize;
while fed.len() < MAX_TEXT_BYTES * 2 {
let chunk = format!("line-{i:08}-{}\n", "f".repeat(48));
acc.fold(&text_chunk(&sid, chunk.clone()));
fed.push_str(&chunk);
i += 1;
}
let text = acc.turn_snapshot().text;
assert!(text.len() <= MAX_TEXT_BYTES, "正文不得超字节上限,得到 {} 字节", text.len());
let (head, omitted, tail) = split_folded(&text).expect("超限后应出现可读的折叠标记");
assert!(omitted > 0, "折叠标记必须报出实际省略量");
assert!(head.starts_with("line-00000000-"), "头部必须是正文开头(约 {head:.20})");
assert!(
tail.ends_with(&format!("line-{:08}-{}\n", i - 1, "f".repeat(48))),
"尾部必须是正文结尾"
);
assert_eq!(
head.chars().count() + omitted + tail.chars().count(),
fed.chars().count(),
"保留量与省略量之和必须等于输入量(不得静默丢弃)"
);
let (head_bytes, tail_bytes, _) = text_buffers(&acc);
assert!(head_bytes <= TEXT_HEAD_BYTES, "头部缓冲超预算:{head_bytes}");
assert!(tail_bytes <= TEXT_TAIL_BYTES, "尾窗缓冲超预算:{tail_bytes}");
}
#[test]
fn text_under_cap_is_verbatim() {
let acc = TurnAccumulator::new();
acc.begin_turn();
let sid = SessionId::new("s1");
acc.fold(&text_chunk(&sid, "你好,"));
acc.fold(&text_chunk(&sid, "world \u{1F642}"));
let text = acc.turn_snapshot().text;
assert_eq!(text, "你好,world \u{1F642}");
assert!(!text.contains(TEXT_OMISSION_PREFIX), "未超限不得出现折叠标记");
}
#[test]
fn folding_never_splits_multibyte_chars() {
const UNIT: &str = "中文测试\u{1F642}"; let acc = TurnAccumulator::new();
acc.begin_turn();
let sid = SessionId::new("s1");
let fed = fold_until(&acc, &sid, UNIT, MAX_TEXT_BYTES * 2);
let text = acc.turn_snapshot().text;
assert!(text.len() <= MAX_TEXT_BYTES, "多字节正文仍须守住上限:{}", text.len());
let (head, omitted, tail) = split_folded(&text).expect("超限后应折叠");
let unit_chars: Vec<char> = UNIT.chars().collect();
for (label, part) in [("头部", head), ("尾部", tail)] {
assert!(
part.chars().all(|c| unit_chars.contains(&c)),
"{label}出现不属于输入字符集的字符(说明字符被切坏)"
);
assert!(!part.contains('\u{FFFD}'), "{label}不得出现替换字符");
}
assert_eq!(
head.chars().count() + omitted + tail.chars().count(),
fed.chars().count(),
"字符级守恒在多字节下同样成立"
);
}
#[test]
fn single_oversized_chunk_is_bounded_on_arrival() {
let acc = TurnAccumulator::new();
acc.begin_turn();
let sid = SessionId::new("s1");
let huge = "x".repeat(MAX_TEXT_BYTES * 3);
acc.fold(&text_chunk(&sid, huge.clone()));
let (head_bytes, tail_bytes, _) = text_buffers(&acc);
assert!(head_bytes <= TEXT_HEAD_BYTES, "头部缓冲超预算:{head_bytes}");
assert!(tail_bytes <= TEXT_TAIL_BYTES, "尾窗缓冲超预算:{tail_bytes}");
let text = acc.turn_snapshot().text;
assert!(text.len() <= MAX_TEXT_BYTES, "超大单帧正文仍须守住上限:{}", text.len());
let (head, omitted, tail) = split_folded(&text).expect("超限后应折叠");
assert_eq!(head.chars().count() + omitted + tail.chars().count(), huge.chars().count());
}
#[test]
fn begin_turn_resets_text_folding_state() {
const UNIT: &str = "旧 turn 的冗长正文 ";
let acc = TurnAccumulator::new();
let sid = SessionId::new("s1");
acc.begin_turn();
fold_until(&acc, &sid, UNIT, MAX_TEXT_BYTES + TEXT_TAIL_BYTES);
assert!(text_buffers(&acc).2 > 0, "前一个 turn 应已发生折叠");
acc.begin_turn();
acc.fold(&text_chunk(&sid, "新 turn"));
assert_eq!(text_buffers(&acc), ("新 turn".len(), 0, 0), "新 turn 应从空缓冲开始");
assert_eq!(acc.turn_snapshot().text, "新 turn");
}
#[test]
fn omission_marker_fits_reserved_budget() {
let widest = format!("{TEXT_OMISSION_PREFIX}{}{TEXT_OMISSION_SUFFIX}", usize::MAX);
assert!(
widest.len() <= TEXT_MARKER_MAX_BYTES,
"标记最坏情况 {} 字节超出预算 {TEXT_MARKER_MAX_BYTES}",
widest.len()
);
}
#[test]
fn fat_repeated_frames_stay_within_byte_budget() {
const FRAME_TEXT_BYTES: usize = 4500;
const FOLD_COUNT: usize = 500;
let acc = TurnAccumulator::new();
acc.begin_turn();
let sid = SessionId::new("s1");
let fat = "x".repeat(FRAME_TEXT_BYTES);
for _ in 0..FOLD_COUNT {
acc.fold(&text_chunk(&sid, fat.clone()));
}
let (len, bytes) = retained_frames(&acc);
assert!(bytes <= MAX_BLOCKS_BYTES, "肥帧下字节预算仍须守住,得到 {bytes}");
assert!(
len < FOLD_COUNT,
"字节维度应已触发淘汰(帧数上限 {MAX_FRAMES} 在 {FOLD_COUNT} 肥帧下根本拦不住),得到 {len} 帧"
);
assert!(len > 0, "单帧未超限时窗口不得被清空");
let snap = acc.turn_snapshot();
assert!(
snap.blocks.len() <= MAX_BLOCKS_BYTES + WRAPPER_SLACK,
"blocks 列应被字节预算钉住,得到 {} 字节",
snap.blocks.len()
);
}
#[test]
fn oversized_frame_never_enters_window() {
let acc = TurnAccumulator::new();
acc.begin_turn();
let sid = SessionId::new("s1");
let huge = "y".repeat(MAX_FRAME_BYTES + 1);
assert!(acc.fold(&text_chunk(&sid, huge.clone())).is_some(), "帧被丢弃也应分配 seq");
let (len, bytes) = retained_frames(&acc);
assert_eq!(len, 0, "超大单帧不得入窗");
assert_eq!(bytes, 0, "丢弃的帧不得计入字节账");
let snap = acc.turn_snapshot();
assert_eq!(snap.text, huge, "正文文本仍全量累积");
assert!(parse_frames(&snap.blocks).is_empty(), "blocks 应为合法的空窗口");
}
#[test]
fn begin_turn_resets_byte_accounting() {
let acc = TurnAccumulator::new();
let sid = SessionId::new("s1");
acc.begin_turn();
for _ in 0..50 {
acc.fold(&text_chunk(&sid, "z".repeat(4096)));
}
assert!(retained_frames(&acc).1 > 0, "前一个 turn 应有累积");
acc.begin_turn();
assert_eq!(retained_frames(&acc), (0, 0), "新 turn 应清空帧与字节账");
}
#[test]
fn repeated_finalize_turn_emits_exactly_one_end_turn() {
let acc = TurnAccumulator::new();
let (_flush_rx, mut end_rx) = capture_cmds(&acc);
let sid = SessionId::new("s1");
acc.begin_turn();
acc.fold(&text_chunk(&sid, "hello"));
acc.finalize_turn();
acc.finalize_turn();
acc.finalize_turn();
let turns = end_turns(&mut end_rx);
assert_eq!(turns.len(), 1, "重复定稿不得重复发记账命令,得到 {turns:?}");
assert!(turns[0].2.is_some(), "折叠过帧的 turn 应携带消息行 id");
}
#[test]
fn concurrent_permits_hold_one_wait_segment_until_all_resolve() {
let acc = TurnAccumulator::new();
acc.begin_turn();
acc.begin_wait();
acc.begin_wait();
nap(50);
acc.end_wait();
let (depth, running, waited) = wait_state(&acc);
assert_eq!(depth, 1, "还剩一个未决审批");
assert!(running, "仍有未决审批时等待段不得结束");
assert_eq!(waited, 0, "集合未清空前不得累加,得到 {waited}");
acc.end_wait();
let (depth, running, waited) = wait_state(&acc);
assert_eq!((depth, running), (0, false), "全部应答后等待段应关闭");
assert!(waited >= 30, "清空时应累加整段等待,得到 {waited}ms");
acc.end_wait();
assert_eq!(wait_state(&acc).0, 0, "计数下溢须被 saturating 挡住");
}
#[test]
fn finalize_clips_an_open_wait_segment() {
let acc = TurnAccumulator::new();
let (_flush_rx, mut end_rx) = capture_cmds(&acc);
acc.begin_turn();
acc.begin_wait();
nap(50);
acc.finalize_turn();
let turns = end_turns(&mut end_rx);
assert_eq!(turns.len(), 1, "一个 turn 只应有一条记账:{turns:?}");
let (work, wait, _) = &turns[0];
assert!(*wait >= 30, "未决审批那段应截到定稿时刻计入 wait,得到 {wait}ms");
assert!(*work <= 50, "等待不得反噬工作时长,work={work} wait={wait}");
assert!(*work + *wait >= 50, "work + wait 应覆盖整段墙钟({work} + {wait})");
}
#[test]
fn empty_turn_is_still_accounted() {
let acc = TurnAccumulator::new();
let (_flush_rx, mut end_rx) = capture_cmds(&acc);
acc.begin_turn();
nap(20);
acc.finalize_turn();
let turns = end_turns(&mut end_rx);
assert_eq!(turns.len(), 1, "空 turn 也要记账,得到 {turns:?}");
assert_eq!(turns[0].2, None, "未折叠任何帧 → 无消息行 id");
}
#[test]
fn wait_outside_an_active_turn_is_dropped() {
let acc = TurnAccumulator::new();
let (_flush_rx, mut end_rx) = capture_cmds(&acc);
acc.begin_wait();
acc.end_all_waits();
assert_eq!(wait_state(&acc), (0, false, 0), "无活跃 turn 时两个动作都应为 no-op");
acc.begin_turn();
nap(20);
acc.finalize_turn();
let turns = end_turns(&mut end_rx);
assert_eq!(turns.len(), 1);
assert_eq!(turns[0].1, 0, "turn 外的审批不得算进本 turn 的等待:{:?}", turns[0]);
}
#[test]
fn end_all_waits_closes_an_open_segment_immediately() {
let acc = TurnAccumulator::new();
let (_flush_rx, mut end_rx) = capture_cmds(&acc);
acc.begin_turn();
acc.begin_wait();
acc.begin_wait();
nap(30);
acc.end_all_waits();
let (depth, running, waited) = wait_state(&acc);
assert_eq!((depth, running), (0, false), "全部取消后不得继续走等待表");
assert!(waited >= 18, "取消时刻即该段终点,得到 {waited}ms");
nap(30);
acc.finalize_turn();
let turns = end_turns(&mut end_rx);
assert_eq!(turns.len(), 1, "应恰好一条 EndTurn:{turns:?}");
let (work, wait, _) = &turns[0];
assert!(*work >= 18, "取消后的收尾应算工作,得到 {work}ms");
assert!(*wait < 48, "等待不得把取消后的收尾也算进去,得到 {wait}ms");
}
#[test]
fn end_turn_is_delivered_when_the_flush_signal_channel_is_saturated() {
let acc = TurnAccumulator::new();
let sid = SessionId::new("s1");
let (flush_tx, _flush_rx) = mpsc::channel(2);
let probe = flush_tx.clone();
let (end_tx, mut end_rx) = mpsc::unbounded_channel();
*acc.sink.lock().expect("sink lock") = Some(Sink { cmd_tx: flush_tx, end_tx });
acc.begin_turn();
for i in 0..8 {
acc.fold(&text_chunk(&sid, format!("chunk {i}")));
}
assert!(
probe.try_send(WriterCmd::Flush).is_err(),
"前置条件:Flush 信号通道应已被折叠 ping 塞满"
);
acc.finalize_turn();
let turns = end_turns(&mut end_rx);
assert_eq!(turns.len(), 1, "信号通道饱和时 EndTurn 仍须恰好送达一次:{turns:?}");
assert!(turns[0].2.is_some(), "折叠过帧的 turn 应携带消息行 id");
}
}