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);
#[derive(Default)]
struct TurnState {
active: bool,
row_id: Option<String>,
frames: VecDeque<Box<RawValue>>,
frames_bytes: usize,
text: String,
seq: u64,
dirty: bool,
}
struct Sink {
cmd_tx: mpsc::Sender<WriterCmd>,
}
enum WriterCmd {
Flush,
Finalize,
}
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);
if let Ok(mut guard) = self.sink.lock() {
*guard = Some(Sink { cmd_tx });
}
let acc = self.clone();
tokio::spawn(writer_loop(acc, db, db_session_id, cmd_rx));
}
pub fn begin_turn(&self) {
if let Ok(mut st) = self.inner.lock() {
st.active = true;
st.row_id = 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 finalize_turn(&self) {
let should_finalize = {
let Ok(mut st) = self.inner.lock() else { return };
if !st.active {
return;
}
st.active = false;
st.row_id.is_some()
};
if should_finalize {
self.send_cmd(WriterCmd::Finalize);
}
}
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 snapshot(&self) -> Option<FlushSnapshot> {
let st = self.inner.lock().ok()?;
let row_id = st.row_id.clone()?;
Some(FlushSnapshot {
row_id,
text: st.text.clone(),
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_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.clone(),
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 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 writer_loop(
acc: Arc<TurnAccumulator>,
db: SqlitePool,
session_id: String,
mut cmd_rx: mpsc::Receiver<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, cmd_rx.recv()).await {
Err(_elapsed) => {
flush_once(&acc, &db, &session_id).await;
pending_since = None;
continue;
}
Ok(None) => break, Ok(Some(c)) => c,
}
} else {
match cmd_rx.recv().await {
Some(c) => c,
None => break,
}
};
match cmd {
WriterCmd::Flush => {
if pending_since.is_none() {
pending_since = Some(Instant::now());
}
}
WriterCmd::Finalize => {
flush_once(&acc, &db, &session_id).await;
pending_since = None;
if let Some(snap) = acc.snapshot()
&& let Err(e) = chat_persistence::finalize_message(&db, &snap.row_id).await
{
tracing::warn!("failed to finalize streaming chat message: {}", 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 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 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.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 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 应清空帧与字节账");
}
}