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>
519 lines
18 KiB
Rust
519 lines
18 KiB
Rust
//! L1 tests for [`ChatBridge`] — the structured-reply ↔ Channel registry, the
|
|
//! twin of [`PtyBridge`] (ARCHITECTURE §17.7) — and for [`chunk_from_event`], the
|
|
//! single `ReplyEvent → ReplyChunk` translation point.
|
|
//!
|
|
//! These exercise the load-bearing transport logic the D4 commands are thin
|
|
//! shells over: the `agent_send` pump's delivery + scrollback recording, the
|
|
//! `reattach_agent_chat` repaint-without-respawn, the `close_agent_session`
|
|
//! teardown, and — the most critical invariant — generation supersede (no double
|
|
//! emission after a re-attach). A real [`tauri::ipc::Channel`] built from a
|
|
//! capturing closure is used (no Tauri runtime, no real CLI/PTY/session).
|
|
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use tauri::ipc::{Channel, InvokeResponseBody};
|
|
|
|
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;
|
|
use uuid::Uuid;
|
|
|
|
/// Builds a `Channel<ReplyChunk>` whose sent chunks are recorded into `sink`.
|
|
///
|
|
/// `ReplyChunk` is `Serialize`/`Deserialize`, so chunks arrive as a JSON string
|
|
/// in an `InvokeResponseBody::Json`; we parse them back to `ReplyChunk` for
|
|
/// assertions (round-tripping the exact camelCase tagged shape on the way).
|
|
fn capturing_channel(sink: Arc<Mutex<Vec<ReplyChunk>>>) -> Channel<ReplyChunk> {
|
|
Channel::new(move |body: InvokeResponseBody| {
|
|
let chunk: ReplyChunk = match body {
|
|
InvokeResponseBody::Json(s) => serde_json::from_str(&s).unwrap(),
|
|
InvokeResponseBody::Raw(b) => serde_json::from_slice(&b).unwrap(),
|
|
};
|
|
sink.lock().unwrap().push(chunk);
|
|
Ok(())
|
|
})
|
|
}
|
|
|
|
fn sid() -> SessionId {
|
|
SessionId::from_uuid(Uuid::new_v4())
|
|
}
|
|
|
|
fn delta(s: &str) -> ReplyChunk {
|
|
ReplyChunk::TextDelta { text: s.to_owned() }
|
|
}
|
|
|
|
fn final_chunk(s: &str) -> ReplyChunk {
|
|
ReplyChunk::Final {
|
|
content: s.to_owned(),
|
|
}
|
|
}
|
|
|
|
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)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn chunk_from_event_maps_text_delta() {
|
|
assert_eq!(
|
|
chunk_from_event(ReplyEvent::TextDelta { text: "hi".into() }),
|
|
Some(ReplyChunk::TextDelta { text: "hi".into() })
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn chunk_from_event_maps_tool_activity() {
|
|
assert_eq!(
|
|
chunk_from_event(ReplyEvent::ToolActivity {
|
|
label: "reads file".into()
|
|
}),
|
|
Some(ReplyChunk::ToolActivity {
|
|
label: "reads file".into()
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn chunk_from_event_maps_final() {
|
|
assert_eq!(
|
|
chunk_from_event(ReplyEvent::Final {
|
|
content: "done".into()
|
|
}),
|
|
Some(ReplyChunk::Final {
|
|
content: "done".into()
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn chunk_from_event_drops_heartbeat() {
|
|
// A heartbeat is a non-terminal liveness proof with no chat content (lot 1) ⇒ no
|
|
// wire chunk; the pump skips it while still draining to the Final.
|
|
assert_eq!(chunk_from_event(ReplyEvent::Heartbeat), None);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// agent_send pump behaviour: deltas* then exactly one Final (zone 2)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Replays a turn's events through the bridge exactly as the `agent_send` pump
|
|
/// does (`chunk_from_event` + `send_output`), then `detach_if(gen)` at stream
|
|
/// end. This is the body of the spawned pump thread, run inline.
|
|
fn pump_turn(bridge: &ChatBridge, session: &SessionId, gen: u64, events: Vec<ReplyEvent>) {
|
|
for event in events {
|
|
// Mirror the real pump: heartbeats map to no chunk and are skipped.
|
|
if let Some(chunk) = chunk_from_event(event) {
|
|
let _ = bridge.send_output(session, chunk);
|
|
}
|
|
}
|
|
bridge.detach_if(session, gen);
|
|
}
|
|
|
|
#[test]
|
|
fn pump_delivers_deltas_then_exactly_one_final_in_order() {
|
|
let bridge = ChatBridge::new();
|
|
let session = sid();
|
|
let sink = Arc::new(Mutex::new(Vec::new()));
|
|
let gen = bridge.register(session, capturing_channel(Arc::clone(&sink)));
|
|
|
|
pump_turn(
|
|
&bridge,
|
|
&session,
|
|
gen,
|
|
vec![
|
|
ReplyEvent::TextDelta { text: "Hel".into() },
|
|
ReplyEvent::TextDelta { text: "lo".into() },
|
|
ReplyEvent::Final {
|
|
content: "Hello".into(),
|
|
},
|
|
],
|
|
);
|
|
|
|
let got = sink.lock().unwrap();
|
|
assert_eq!(
|
|
got.as_slice(),
|
|
&[delta("Hel"), delta("lo"), final_chunk("Hello")],
|
|
"deltas in order, then the single Final"
|
|
);
|
|
// Exactly one Final, and it is last (deterministic terminal chunk).
|
|
let finals = got
|
|
.iter()
|
|
.filter(|c| matches!(c, ReplyChunk::Final { .. }))
|
|
.count();
|
|
assert_eq!(finals, 1, "exactly one Final per turn");
|
|
assert!(matches!(got.last(), Some(ReplyChunk::Final { .. })));
|
|
}
|
|
|
|
#[test]
|
|
fn pump_with_only_a_final_delivers_just_the_final() {
|
|
// Zero deltas is valid (deltas*); the turn still ends on exactly one Final.
|
|
let bridge = ChatBridge::new();
|
|
let session = sid();
|
|
let sink = Arc::new(Mutex::new(Vec::new()));
|
|
let gen = bridge.register(session, capturing_channel(Arc::clone(&sink)));
|
|
|
|
pump_turn(
|
|
&bridge,
|
|
&session,
|
|
gen,
|
|
vec![ReplyEvent::Final {
|
|
content: "ok".into(),
|
|
}],
|
|
);
|
|
|
|
assert_eq!(sink.lock().unwrap().as_slice(), &[final_chunk("ok")]);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Generation supersede — the critical invariant (zone 1)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// A `reattach_agent_chat` mid-turn: a first pump is in flight (old generation)
|
|
/// when the view re-attaches (new channel, bumped generation). The old pump's
|
|
/// remaining chunks must NOT reach the *new* channel, and when the old pump ends
|
|
/// its `detach_if(old_gen)` must be a no-op (never tearing down the live one).
|
|
#[test]
|
|
fn reattach_supersedes_old_pump_no_double_emission() {
|
|
let bridge = ChatBridge::new();
|
|
let session = sid();
|
|
let old = Arc::new(Mutex::new(Vec::new()));
|
|
let new = Arc::new(Mutex::new(Vec::new()));
|
|
|
|
// Turn starts: old channel registered, pump emits a first delta.
|
|
let old_gen = bridge.register(session, capturing_channel(Arc::clone(&old)));
|
|
bridge.send_output(&session, delta("a")); // delivered to old
|
|
|
|
// View navigates away & back → reattach registers a NEW channel (bumps gen).
|
|
let _new_gen = bridge.register(session, capturing_channel(Arc::clone(&new)));
|
|
|
|
// Old pump keeps draining its (now stale) stream, then ends with its own gen.
|
|
bridge.send_output(&session, delta("b")); // recorded to scrollback, routed to NEW channel
|
|
bridge.detach_if(&session, old_gen); // MUST be a no-op (newer gen present)
|
|
|
|
// The new (live) attach must NOT have been detached: it is still deliverable.
|
|
assert!(
|
|
bridge.send_output(&session, final_chunk("done")),
|
|
"live re-attach channel must survive a superseded pump's detach_if"
|
|
);
|
|
|
|
// The OLD channel only ever saw the single pre-reattach delta — no double emit.
|
|
assert_eq!(
|
|
old.lock().unwrap().as_slice(),
|
|
&[delta("a")],
|
|
"superseded channel receives nothing after the reattach"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn detach_if_current_generation_stops_delivery() {
|
|
// When the pump that owns the current generation ends (e.g. Final reached and
|
|
// no reattach happened), detach_if drops the channel: further output is not
|
|
// delivered (turn over) but the session stays known until close.
|
|
let bridge = ChatBridge::new();
|
|
let session = sid();
|
|
let sink = Arc::new(Mutex::new(Vec::new()));
|
|
let gen = bridge.register(session, capturing_channel(Arc::clone(&sink)));
|
|
|
|
bridge.send_output(&session, final_chunk("end"));
|
|
bridge.detach_if(&session, gen);
|
|
|
|
// Channel detached: a stray chunk is recorded to scrollback but not delivered.
|
|
assert!(!bridge.send_output(&session, delta("late")));
|
|
assert_eq!(sink.lock().unwrap().as_slice(), &[final_chunk("end")]);
|
|
// Session is still tracked (scrollback survives until close).
|
|
assert_eq!(bridge.active_sessions(), 1);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// reattach_agent_chat: scrollback repaint without re-spawn (zone 3)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn scrollback_accumulates_every_routed_chunk_in_order() {
|
|
let bridge = ChatBridge::new();
|
|
let session = sid();
|
|
let gen = bridge.register(session, capturing_channel(Arc::new(Mutex::new(Vec::new()))));
|
|
|
|
pump_turn(
|
|
&bridge,
|
|
&session,
|
|
gen,
|
|
vec![
|
|
ReplyEvent::TextDelta { text: "x".into() },
|
|
ReplyEvent::ToolActivity {
|
|
label: "runs".into(),
|
|
},
|
|
ReplyEvent::Final {
|
|
content: "x".into(),
|
|
},
|
|
],
|
|
);
|
|
|
|
assert_eq!(
|
|
bridge.scrollback(&session),
|
|
vec![
|
|
delta("x"),
|
|
ReplyChunk::ToolActivity {
|
|
label: "runs".into()
|
|
},
|
|
final_chunk("x"),
|
|
],
|
|
"scrollback retains the full conversation, in order"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn reattach_repaints_scrollback_and_preserves_it_without_resend() {
|
|
// Models reattach_agent_chat: a first turn streamed, the view detached, then a
|
|
// new channel registers. The scrollback survives the re-register (no session
|
|
// method is called — no re-spawn), and is what the command returns.
|
|
let bridge = ChatBridge::new();
|
|
let session = sid();
|
|
|
|
let g0 = bridge.register(session, capturing_channel(Arc::new(Mutex::new(Vec::new()))));
|
|
pump_turn(
|
|
&bridge,
|
|
&session,
|
|
g0,
|
|
vec![
|
|
ReplyEvent::TextDelta { text: "Hi".into() },
|
|
ReplyEvent::Final {
|
|
content: "Hi".into(),
|
|
},
|
|
],
|
|
);
|
|
|
|
// Snapshot exactly what reattach_agent_chat returns BEFORE swapping channel.
|
|
let repainted = bridge.scrollback(&session);
|
|
assert_eq!(repainted, vec![delta("Hi"), final_chunk("Hi")]);
|
|
|
|
// Re-attach: register a fresh channel. Scrollback is preserved (not cleared,
|
|
// not duplicated), generation bumped.
|
|
let new_sink = Arc::new(Mutex::new(Vec::new()));
|
|
let g1 = bridge.register(session, capturing_channel(Arc::clone(&new_sink)));
|
|
assert_eq!(g1, g0 + 1, "re-attach bumps the generation");
|
|
assert_eq!(
|
|
bridge.scrollback(&session),
|
|
repainted,
|
|
"scrollback unchanged by re-attach (no re-send, no re-spawn)"
|
|
);
|
|
// The fresh channel got nothing yet: reattach does NOT replay over the channel
|
|
// (the command returns the scrollback for the front to replay itself).
|
|
assert!(
|
|
new_sink.lock().unwrap().is_empty(),
|
|
"re-attach must not push the old turns onto the new channel"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn scrollback_of_unknown_session_is_empty() {
|
|
let bridge = ChatBridge::new();
|
|
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)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn unregister_purges_channel_and_scrollback() {
|
|
let bridge = ChatBridge::new();
|
|
let session = sid();
|
|
let sink = Arc::new(Mutex::new(Vec::new()));
|
|
let gen = bridge.register(session, capturing_channel(Arc::clone(&sink)));
|
|
pump_turn(
|
|
&bridge,
|
|
&session,
|
|
gen,
|
|
vec![ReplyEvent::Final {
|
|
content: "bye".into(),
|
|
}],
|
|
);
|
|
assert_eq!(bridge.active_sessions(), 1);
|
|
assert!(!bridge.scrollback(&session).is_empty());
|
|
|
|
bridge.unregister(&session);
|
|
|
|
assert_eq!(bridge.active_sessions(), 0, "session removed");
|
|
assert!(
|
|
bridge.scrollback(&session).is_empty(),
|
|
"scrollback purged on close"
|
|
);
|
|
assert!(
|
|
!bridge.send_output(&session, delta("z")),
|
|
"no delivery after close"
|
|
);
|
|
// The closed channel must not receive the post-close chunk.
|
|
assert_eq!(sink.lock().unwrap().as_slice(), &[final_chunk("bye")]);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Registry basics, mirrored from pty_bridge.rs for parity
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn register_returns_monotonic_generation_per_session() {
|
|
let bridge = ChatBridge::new();
|
|
let session = sid();
|
|
let g0 = bridge.register(session, capturing_channel(Arc::new(Mutex::new(Vec::new()))));
|
|
let g1 = bridge.register(session, capturing_channel(Arc::new(Mutex::new(Vec::new()))));
|
|
assert_eq!(g0, 0);
|
|
assert_eq!(g1, 1);
|
|
assert_eq!(bridge.active_sessions(), 1, "same id replaced, not added");
|
|
}
|
|
|
|
#[test]
|
|
fn send_output_to_unknown_session_returns_false() {
|
|
let bridge = ChatBridge::new();
|
|
assert!(!bridge.send_output(&sid(), delta("x")));
|
|
}
|
|
|
|
#[test]
|
|
fn register_same_session_replaces_channel() {
|
|
let bridge = ChatBridge::new();
|
|
let session = sid();
|
|
let first = Arc::new(Mutex::new(Vec::new()));
|
|
let second = Arc::new(Mutex::new(Vec::new()));
|
|
bridge.register(session, capturing_channel(Arc::clone(&first)));
|
|
bridge.register(session, capturing_channel(Arc::clone(&second)));
|
|
|
|
bridge.send_output(&session, delta("9"));
|
|
assert!(first.lock().unwrap().is_empty(), "old channel unused");
|
|
assert_eq!(second.lock().unwrap().as_slice(), &[delta("9")]);
|
|
}
|