fix(chat): borner le scrollback de ChatBridge (#34)
Le scrollback de ChatBridge croissait sans limite, faisant enfler la mémoire sur les conversations longues. Il sert de buffer de replay transport (reattach), pas d'historique durable. Introduit un double cap : MAX_CHAT_SCROLLBACK_CHUNKS=2000 et MAX_CHAT_SCROLLBACK_BYTES=512 KiB. Un helper trim_scrollback, appelé après chaque send_output, drop des chunks entiers par la tête tout en conservant l'ordre et les chunks les plus récents. Doc de chat.rs corrigée pour refléter cette nature de buffer borné. Aucun changement de ReplyChunk / reattach_agent_chat / DTO. Tests : suite chat_bridge 19/19. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -13,7 +13,9 @@ use std::sync::{Arc, Mutex};
|
||||
|
||||
use tauri::ipc::{Channel, InvokeResponseBody};
|
||||
|
||||
use app_tauri_lib::chat::{chunk_from_event, ChatBridge};
|
||||
use app_tauri_lib::chat::{
|
||||
chunk_from_event, ChatBridge, MAX_CHAT_SCROLLBACK_BYTES, MAX_CHAT_SCROLLBACK_CHUNKS,
|
||||
};
|
||||
use app_tauri_lib::dto::ReplyChunk;
|
||||
use domain::ids::SessionId;
|
||||
use domain::ports::ReplyEvent;
|
||||
@ -49,6 +51,14 @@ fn final_chunk(s: &str) -> ReplyChunk {
|
||||
}
|
||||
}
|
||||
|
||||
fn chunk_bytes(chunk: &ReplyChunk) -> usize {
|
||||
match chunk {
|
||||
ReplyChunk::TextDelta { text } => text.len(),
|
||||
ReplyChunk::ToolActivity { label } => label.len(),
|
||||
ReplyChunk::Final { content } => content.len(),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// chunk_from_event — exhaustive mapping (zone 6)
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -311,6 +321,131 @@ fn scrollback_of_unknown_session_is_empty() {
|
||||
assert!(bridge.scrollback(&sid()).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrollback_drops_oldest_chunks_when_chunk_cap_is_exceeded() {
|
||||
let bridge = ChatBridge::new();
|
||||
let session = sid();
|
||||
let gen = bridge.register(session, capturing_channel(Arc::new(Mutex::new(Vec::new()))));
|
||||
bridge.detach_if(&session, gen);
|
||||
|
||||
for i in 0..(MAX_CHAT_SCROLLBACK_CHUNKS + 10) {
|
||||
bridge.send_output(&session, delta(&format!("chunk-{i}")));
|
||||
}
|
||||
|
||||
let scrollback = bridge.scrollback(&session);
|
||||
assert_eq!(scrollback.len(), MAX_CHAT_SCROLLBACK_CHUNKS);
|
||||
assert_eq!(scrollback.first(), Some(&delta("chunk-10")));
|
||||
assert_eq!(
|
||||
scrollback.last(),
|
||||
Some(&delta(&format!("chunk-{}", MAX_CHAT_SCROLLBACK_CHUNKS + 9)))
|
||||
);
|
||||
assert_eq!(
|
||||
scrollback.iter().take(3).cloned().collect::<Vec<_>>(),
|
||||
vec![delta("chunk-10"), delta("chunk-11"), delta("chunk-12")],
|
||||
"remaining chunks keep their relative order and exact content"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrollback_drops_oldest_whole_chunks_when_byte_cap_is_exceeded() {
|
||||
let bridge = ChatBridge::new();
|
||||
let session = sid();
|
||||
let gen = bridge.register(session, capturing_channel(Arc::new(Mutex::new(Vec::new()))));
|
||||
bridge.detach_if(&session, gen);
|
||||
let chunk_size = MAX_CHAT_SCROLLBACK_BYTES / 4;
|
||||
|
||||
for i in 0..6 {
|
||||
let text = format!("{i:02}:{}", "x".repeat(chunk_size - 3));
|
||||
bridge.send_output(&session, delta(&text));
|
||||
}
|
||||
|
||||
let scrollback = bridge.scrollback(&session);
|
||||
let retained_bytes: usize = scrollback.iter().map(chunk_bytes).sum();
|
||||
assert!(
|
||||
retained_bytes <= MAX_CHAT_SCROLLBACK_BYTES,
|
||||
"retained bytes {retained_bytes} must stay under cap {MAX_CHAT_SCROLLBACK_BYTES}"
|
||||
);
|
||||
assert_eq!(scrollback.len(), 4);
|
||||
assert!(matches!(
|
||||
scrollback.first(),
|
||||
Some(ReplyChunk::TextDelta { text }) if text.starts_with("02:")
|
||||
));
|
||||
assert!(matches!(
|
||||
scrollback.last(),
|
||||
Some(ReplyChunk::TextDelta { text }) if text.starts_with("05:")
|
||||
));
|
||||
assert!(scrollback.iter().all(|chunk| matches!(
|
||||
chunk,
|
||||
ReplyChunk::TextDelta { text } if text.len() == chunk_size
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_scrollback_replays_valid_unmodified_chunks_in_order() {
|
||||
let bridge = ChatBridge::new();
|
||||
let session = sid();
|
||||
let gen = bridge.register(session, capturing_channel(Arc::new(Mutex::new(Vec::new()))));
|
||||
bridge.detach_if(&session, gen);
|
||||
|
||||
for i in 0..(MAX_CHAT_SCROLLBACK_CHUNKS + 3) {
|
||||
bridge.send_output(&session, delta(&format!("delta-{i}")));
|
||||
}
|
||||
bridge.send_output(
|
||||
&session,
|
||||
ReplyChunk::ToolActivity {
|
||||
label: "tool".into(),
|
||||
},
|
||||
);
|
||||
bridge.send_output(&session, final_chunk("done"));
|
||||
|
||||
let scrollback = bridge.scrollback(&session);
|
||||
assert_eq!(scrollback.len(), MAX_CHAT_SCROLLBACK_CHUNKS);
|
||||
assert_eq!(scrollback[0], delta("delta-5"));
|
||||
assert_eq!(
|
||||
&scrollback[(MAX_CHAT_SCROLLBACK_CHUNKS - 2)..],
|
||||
&[
|
||||
ReplyChunk::ToolActivity {
|
||||
label: "tool".into()
|
||||
},
|
||||
final_chunk("done")
|
||||
],
|
||||
"chunks are retained whole and unmodified"
|
||||
);
|
||||
|
||||
let replay_sink = Arc::new(Mutex::new(Vec::new()));
|
||||
let replay = ChatBridge::new();
|
||||
let replay_session = sid();
|
||||
replay.register(replay_session, capturing_channel(Arc::clone(&replay_sink)));
|
||||
for chunk in scrollback.clone() {
|
||||
assert!(replay.send_output(&replay_session, chunk));
|
||||
}
|
||||
assert_eq!(*replay_sink.lock().unwrap(), scrollback);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detach_keeps_scrollback_bounded_and_unregister_purges_it() {
|
||||
let bridge = ChatBridge::new();
|
||||
let session = sid();
|
||||
let gen = bridge.register(session, capturing_channel(Arc::new(Mutex::new(Vec::new()))));
|
||||
bridge.detach_if(&session, gen);
|
||||
|
||||
for i in 0..(MAX_CHAT_SCROLLBACK_CHUNKS + 25) {
|
||||
bridge.send_output(&session, delta(&format!("detached-{i}")));
|
||||
}
|
||||
|
||||
assert_eq!(bridge.active_sessions(), 1);
|
||||
assert_eq!(
|
||||
bridge.scrollback(&session).len(),
|
||||
MAX_CHAT_SCROLLBACK_CHUNKS,
|
||||
"detached sessions keep bounded replay buffers"
|
||||
);
|
||||
|
||||
bridge.unregister(&session);
|
||||
|
||||
assert_eq!(bridge.active_sessions(), 0);
|
||||
assert!(bridge.scrollback(&session).is_empty());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// close_agent_session: unregister purges channel AND scrollback (zone 4)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user