From b3b1d47dd6eeb0863bfb8e2b66afa6cc254f94a1 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Sat, 5 Sep 2026 22:14:27 -0400 Subject: [PATCH] iris: streaming a transcript event no longer rebuilds the whole screen Every client (bench_client, transcript_client, desktop-app) refolded and rebuilt the ~3,200-row widget tree from scratch per SSE event, which is the streaming-phase cost the P0 benchmark gate would otherwise measure against a Compose app that updates one row. iris::widget::List gains replace_back (swap the last row's widget in place, keeping its slot so a pinned list stays pinned) and clear (the full-rebuild fallback); transcript_ui::TranscriptScreen::apply diffs the folded row lists and picks the cheapest update -- unchanged, append, replace-the-last-row, or (rare regroup) a full rebuild, counted. TextEditCtx::set_with_spans lets a row's text and span list land together on a streamed update. Co-Authored-By: Claude Fable 5.1 --- iris/android-app/src/bench_client.rs | 35 ++-- iris/android-app/src/transcript_client.rs | 35 +++- iris/desktop-app/src/app.rs | 36 ++-- iris/src/widget/list.rs | 145 ++++++++++++++ iris/src/widget/text/edit.rs | 14 ++ iris/transcript-ui/src/lib.rs | 234 ++++++++++++++++++++++ 6 files changed, 460 insertions(+), 39 deletions(-) diff --git a/iris/android-app/src/bench_client.rs b/iris/android-app/src/bench_client.rs index 2b8c755..ed4c878 100644 --- a/iris/android-app/src/bench_client.rs +++ b/iris/android-app/src/bench_client.rs @@ -4,17 +4,21 @@ //! `transcript-ui`'s real screen with no server -- a frame-time comparison //! that measures the renderer rather than the data or the network. //! -//! **Reuses `transcript_client.rs`'s shape** (folded items, a full -//! `transcript_ui::build_tree` rebuild per event) with the network half -//! replaced by the checked-in fixture, embedded with `include_str!` -- -//! `app/bench-fixture/assets/transcript.jsonl`, 1,915,760 bytes, generated -//! by `app/bench-fixture/generate.py` and never a real transcript (that -//! file's own README). The first 3,200 lines are the opening backlog, -//! folded once through `client_core::transcript_fold::fold_page` exactly -//! as a real `/transcript` page would be; the remaining ~400 are the -//! streaming tail, replayed one at a time through `fold_event` -- the same -//! fold path a live SSE reply arrives on -- by the "Run benchmark" -//! control below. +//! **Reuses `transcript_client.rs`'s shape** (folded items, the same +//! `TranscriptScreen::apply` incremental update on every event) with the +//! network half replaced by the checked-in fixture, embedded with +//! `include_str!` -- `app/bench-fixture/assets/transcript.jsonl`, +//! 1,915,760 bytes, generated by `app/bench-fixture/generate.py` and never +//! a real transcript (that file's own README). The first 3,200 lines are +//! the opening backlog, folded once through +//! `client_core::transcript_fold::fold_page` exactly as a real +//! `/transcript` page would be (then a full `transcript_ui::build_tree`, +//! same as any first load); the remaining ~400 are the streaming tail, +//! replayed one at a time through `fold_event` -- the same fold path a +//! live SSE reply arrives on -- by the "Run benchmark" control below. +//! Streaming through `apply` rather than a full rebuild per event is what +//! this file exists to measure -- see docs/RUST.md's P0 box for the +//! before/after report. use crate::bench_jni::PlatformHandle; use android_view::jni::{JavaVM, objects::GlobalRef}; @@ -344,8 +348,15 @@ impl BenchClient { let mut sent = 0usize; for event in stream_tail.into_iter().take(total) { ctx.update(move |state: &mut BenchClient, rsc| { + let old_items = state.items.clone(); state.items = fold_event(&state.items, &event); - state.rebuild_transcript(rsc); + match &state.screen { + // The path P0 asked to measure: update only the + // row(s) that changed instead of rebuilding all + // ~3,200 of them per event. + Some(screen) => screen.apply(rsc, &old_items, &state.items), + None => state.rebuild_transcript(rsc), + } }); redraw.request_redraw(); sent += 1; diff --git a/iris/android-app/src/transcript_client.rs b/iris/android-app/src/transcript_client.rs index b664ade..9e2b76f 100644 --- a/iris/android-app/src/transcript_client.rs +++ b/iris/android-app/src/transcript_client.rs @@ -20,14 +20,22 @@ //! **Reuses `iris/desktop-app`'s `app.rs` shape almost exactly** -- //! `fold_event`/`group_tool_runs`/`fold_page`/`raw_seq` from //! `client_core::transcript_fold`, a `generation` counter guarding against -//! a stale background response, and a full rebuild of the widget tree on -//! every event (same tradeoff, same reason: `push_row` cannot update a row -//! already on screen, and this rig's conversations are small). What -//! differs is only the redraw mechanism: android-view has no -//! `winit::EventLoopProxy`, so this uses `iris::task::Tasks::redraw_handle` -//! (new, added alongside this box) to request a frame after each -//! `TaskCtx::update` instead of relying on `Tasks::spawn`'s single -//! end-of-future redraw -- see that method's own doc for why. +//! a stale background response. What differs is only the redraw +//! mechanism: android-view has no `winit::EventLoopProxy`, so this uses +//! `iris::task::Tasks::redraw_handle` (new, added alongside this box) to +//! request a frame after each `TaskCtx::update` instead of relying on +//! `Tasks::spawn`'s single end-of-future redraw -- see that method's own +//! doc for why. +//! +//! **Streaming no longer costs a full rebuild** (fixed after the P0 gate +//! showed why it mattered -- 20 events/second means 20 rebuilds/second of +//! a ~3,200-row transcript otherwise): `apply_event` calls +//! `transcript_ui::TranscriptScreen::apply` with the item list before and +//! after `fold_event`, which updates only the row(s) that actually +//! changed (almost always just the one open assistant message) instead of +//! refolding and rebuilding every row. `rebuild_transcript` still runs +//! the whole widget tree once, for the opening page and for `apply`'s own +//! rare regroup fallback. use client_core::api::{ApiClient, UreqTransport}; use client_core::event_stream::{StreamItem, follow_session_events}; @@ -360,8 +368,17 @@ impl TranscriptClient { } fn apply_event(&mut self, rsc: &mut AndroidRsc, event: &SeqEvent) { + let old_items = self.items.clone(); self.items = fold_event(&self.items, event); - self.rebuild_transcript(rsc); + match &self.screen { + // The common path: update only the row(s) that actually + // changed instead of refolding and rebuilding all ~3,200 of + // them per event (RUST.md's P0 streaming-phase fix). + Some(screen) => screen.apply(rsc, &old_items, &self.items), + // No screen yet (the opening page hasn't landed) -- build one + // the ordinary way once it has. + None => self.rebuild_transcript(rsc), + } } fn send_message(&mut self, session_id: String, text: String) { diff --git a/iris/desktop-app/src/app.rs b/iris/desktop-app/src/app.rs index 683494f..bb3a9df 100644 --- a/iris/desktop-app/src/app.rs +++ b/iris/desktop-app/src/app.rs @@ -15,23 +15,19 @@ //! +-----------+--------------------------------------+ //! ``` //! -//! **Deliberately left simple, and why**: every incoming SSE event refolds -//! the *entire* transcript (`client_core::transcript_fold::fold_event` is -//! already `O(items)` and a desktop session's conversation is small) and -//! rebuilds the whole right-hand widget tree from scratch, rather than -//! reaching for `TranscriptScreen::push_row`'s incremental append. -//! `push_row` cannot update a row already on screen -- only append a new -//! one -- and a streaming assistant reply is exactly a row whose *text* -//! keeps changing after it first appears (see `transcript-ui`'s own doc on -//! `fold_event` folding deltas into one growing item). A full rebuild -//! shows that growth correctly at the cost of redrawing everything each -//! time; fine for this proof, wrong for a long, fast-streaming transcript -//! -- the incremental path that fixes it needs `transcript-ui` to expose -//! updating a row in place, which it does not yet. The composer's -//! in-progress text survives a rebuild (`rebuild_transcript`'s -//! `in_progress` local) since the user typing a followup while a reply -//! streams in is the one case a naive rebuild would otherwise lose data -//! on. +//! **Incoming SSE events go through `TranscriptScreen::apply`**, not a +//! full rebuild: `client_core::transcript_fold::fold_event` folds the new +//! item list as before, then `apply` updates only the row(s) that actually +//! changed (almost always the one still-open assistant message a delta +//! landed in) instead of rebuilding the whole right-hand widget tree from +//! scratch. `rebuild_transcript` still runs the whole tree once, for a +//! freshly loaded/selected session and for `apply`'s own rare +//! full-rebuild fallback (a `group_tool_runs` regroup touching a row +//! before the tail). The composer's in-progress text survives a rebuild +//! (`rebuild_transcript`'s `in_progress` local) since the user typing a +//! followup while a reply streams in is the one case a naive rebuild +//! would otherwise lose data on -- `apply`'s own path never touches the +//! composer at all, so this only matters on the fallback. //! //! Background network I/O (`client_core::api`/`event_stream`, both //! blocking by design -- see `client-core`'s `Cargo.toml`) runs on plain @@ -209,8 +205,12 @@ impl DefaultAppState for Client { event, } => { if self.current(&session_id, generation) { + let old_items = self.items.clone(); self.items = fold_event(&self.items, &event); - self.rebuild_transcript(rsc); + match &self.screen { + Some(screen) => screen.apply(rsc, &old_items, &self.items), + None => self.rebuild_transcript(rsc), + } } } AppEvent::StreamEnded { diff --git a/iris/src/widget/list.rs b/iris/src/widget/list.rs index 63d1578..d58a0bc 100644 --- a/iris/src/widget/list.rs +++ b/iris/src/widget/list.rs @@ -317,6 +317,40 @@ impl List { self.extents.clear(); } + /// Swap the last row's widget for a new one **without moving it**: the + /// slot index is unchanged, so an anchor already pointing at this slot + /// (in particular `snap_end`'s pinned-to-newest case) stays pinned, and + /// an anchor pointing anywhere else -- this row scrolled out of view -- + /// is untouched, so nothing currently on screen moves. This is what a + /// streamed reply needs: the row whose *content* keeps changing after + /// it first appears is still the same row by position, even if its + /// `RowKey` happens to change too (rare -- only `heights`/`extents` care + /// about the key, and both are invalidated here the same way + /// `pop_back` already invalidates them for the row it removes). + /// `None` if the list is empty. O(1), same as `push_back`/`pop_back`. + pub fn replace_back(&mut self, row: ListRow) -> Option { + let idx = self.items.len().checked_sub(1)?; + let old = std::mem::replace(&mut self.items[idx], row); + self.heights.remove(&old.key); + self.extents.clear(); + Some(old) + } + + /// Drop every loaded row and reset to the same state `List::new` would + /// give -- the fallback path for a change `apply`-style incremental + /// callers can't express as a replace-or-append (RUST.md: `group_tool_runs` + /// regrouping an earlier row). `more_before`/`more_after` are left + /// alone: a full paging reset is a different operation from "the + /// content changed," and a caller that wants both calls + /// `set_more_before(None)`/`set_more_after(None)` itself. + pub fn clear(&mut self) { + self.items.clear(); + self.anchor = None; + self.snap_end = true; + self.heights.clear(); + self.extents.clear(); + } + /// Move the anchor's edge by `amt` pixels. Positive moves later /// content into view (mirrors `Scroll::scroll`'s sign convention). /// Deliberately unclamped -- see the module doc's "what is not @@ -1032,4 +1066,115 @@ mod tests { assert!(moves <= 12, "n={n}: expected O(visible) moves, got {moves}"); } } + + /// The streamed-reply case (RUST.md's "streaming still costs a full + /// rebuild" fix, `transcript-ui::TranscriptScreen::apply`): a delta + /// swaps the last row's widget for a taller one, same key, same slot. + /// A list flush with its own end (the default, `snap_end`) must stay + /// flush -- the row grows *upward* from the pinned bottom edge, not + /// the other way around, exactly like an ordinary resize of that same + /// row would (`expanding_a_row_holds_the_bottom_edge_when_tap_is_lower`). + #[test] + fn replacing_the_last_row_stays_pinned_to_the_bottom() { + let mut rsc = TestRsc { + ui: UiData::default(), + }; + let mut list = List::new(Axis::Y); + push_rows(&mut rsc, &mut list, &[0, 1, 2, 3, 4], 20.0); + let (list_weak, root) = add_list(&mut rsc, list); + + let mut render = UiRenderState::new(); + render.resize((100.0, 60.0)); + render.update(&root, &mut rsc); + + // Row 4 is flush with the viewport's bottom edge before the replace. + { + let list_ref = rsc.ui.widgets.get(&list_weak).unwrap(); + assert!((list_ref.extents[&4].bottom - 60.0).abs() < 0.01); + } + + let (_weak, new_row) = fixed_row(&mut rsc, 40.0); + let old = rsc + .ui + .widgets + .get_mut(&list_weak) + .unwrap() + .replace_back(ListRow::new(4, new_row)); + assert!( + old.is_some(), + "replace_back should hand back the row it evicted" + ); + + render.update(&root, &mut rsc); + + let list_ref = rsc.ui.widgets.get(&list_weak).unwrap(); + let row4 = list_ref.extents[&4]; + assert!( + (row4.bottom - 60.0).abs() < 0.01, + "still pinned to the newest end after the replace: {row4:?}" + ); + assert!( + (row4.top - 20.0).abs() < 0.01, + "grew upward, from the pinned bottom edge: {row4:?}" + ); + } + + /// The other half of the same fix's contract: replacing a row that is + /// *not* on screen must not move anything that is. `replace_back` only + /// touches the last slot's own widget and this file's own `heights`/ + /// `extents` caches for that one key -- nothing about `Anchor` changes + /// -- so the already-placed rows above it should come out at the exact + /// same boxes on the next frame. + #[test] + fn replacing_the_last_row_out_of_view_does_not_move_visible_rows() { + let mut rsc = TestRsc { + ui: UiData::default(), + }; + let mut list = List::new(Axis::Y); + push_rows(&mut rsc, &mut list, &[0, 1, 2, 3, 4], 20.0); + let (list_weak, root) = add_list(&mut rsc, list); + + let mut render = UiRenderState::new(); + render.resize((100.0, 60.0)); + // Settle at the default (bottom) anchor first -- `jump_to_start` + // does not touch `snap_end`, and `repair_anchor` only leaves a + // freshly-set anchor's offset alone once `viewport_len` has + // already matched `last_viewport_len` once, the same reason + // `moves_stay_o1_across_list_size` settles before the tick it + // actually measures. + render.update(&root, &mut rsc); + // Scrolled to the oldest content: rows 0,1,2 visible, row 4 is far + // below the viewport. + rsc.ui.widgets.get_mut(&list_weak).unwrap().jump_to_start(); + render.update(&root, &mut rsc); + + let (before0, before1, before2) = { + let list_ref = rsc.ui.widgets.get(&list_weak).unwrap(); + assert!(!list_ref.extents.contains_key(&4)); + ( + list_ref.extents[&0], + list_ref.extents[&1], + list_ref.extents[&2], + ) + }; + + let (_weak, new_row) = fixed_row(&mut rsc, 999.0); + rsc.ui + .widgets + .get_mut(&list_weak) + .unwrap() + .replace_back(ListRow::new(4, new_row)); + + render.update(&root, &mut rsc); + + let list_ref = rsc.ui.widgets.get(&list_weak).unwrap(); + for (key, before) in [(0u64, before0), (1, before1), (2, before2)] { + let after = list_ref.extents[&key]; + assert_eq!( + (after.top, after.bottom), + (before.top, before.bottom), + "row {key} moved after an off-screen replace" + ); + } + } } diff --git a/iris/src/widget/text/edit.rs b/iris/src/widget/text/edit.rs index a915fb4..2044ab6 100644 --- a/iris/src/widget/text/edit.rs +++ b/iris/src/widget/text/edit.rs @@ -167,6 +167,20 @@ impl<'a> TextEditCtx<'a> { self.text.selection = None; } + /// [`set`](Self::set) plus a fresh set of [`SpanStyle`]s in one call -- + /// what a streamed transcript row needs, since its markdown re-renders + /// to a new string *and* a new span list on every delta and the two + /// have to land together (a stale span list drawn against new text can + /// point past its end). Used by `transcript-ui`'s incremental apply + /// (RUST.md's "streaming still costs a full rebuild" fix) rather than + /// tearing the row's widget down and rebuilding it from scratch. + pub fn set_with_spans(&mut self, text: &str, spans: Vec) { + let text = self.string(text); + self.text.view.buf.set_text(text); + self.text.view.buf.set_spans(spans); + self.text.selection = None; + } + pub fn motion(&mut self, motion: Motion, select: bool) { let Some(sel) = self.text.selection else { return; diff --git a/iris/transcript-ui/src/lib.rs b/iris/transcript-ui/src/lib.rs index 24baaa2..1885551 100644 --- a/iris/transcript-ui/src/lib.rs +++ b/iris/transcript-ui/src/lib.rs @@ -60,6 +60,12 @@ pub struct TranscriptScreen { pub list: WeakWidget, pub composer: composer::Composer, selection: Rc>, + /// How many times [`Self::apply`] has fallen back to a full rebuild -- + /// `Cell` rather than requiring `&mut self`, matching every other + /// method here (the real state lives behind `list`/`selection`'s own + /// interior mutability, per `push_row`'s existing `&self`). Drained by + /// [`Self::take_rebuilds`]. + rebuilds: std::cell::Cell, } impl TranscriptScreen { @@ -75,6 +81,93 @@ impl TranscriptScreen { (self.list)(rsc).push_back(ListRow::new(key, widget)); } + /// Apply the effect of one more folded event without rebuilding the + /// whole screen -- RUST.md's "streaming still costs a full rebuild" + /// fix. `old`/`new` are `client_core::transcript_fold::fold_event`'s + /// own before/after item lists (never grouped into rows -- that + /// happens here, over both, so the common tail cases can be told + /// apart; `group_tool_runs` is pure bookkeeping over already-folded + /// items, no widget is built doing it). + /// + /// Three cases, cheapest first: + /// - **nothing changed**: no-op. + /// - **pure append** (a still-open reply's row now closed and stable, + /// a new tool call, a new message): every new row is `push_back`ed, + /// same cost as [`Self::push_row`]. + /// - **only the last row's content changed** (the common case: a delta + /// folded into a still-open assistant message): that one row is + /// rebuilt (`row::build_row`, the same path a fresh row goes + /// through) and swapped in with [`List::replace_back`] -- every + /// other row is untouched, so nothing else redraws or moves. Any + /// further new rows are appended after it, for the (also common) + /// case of a delta that both finishes the open reply and starts the + /// next row in the same event. + /// + /// Anything else -- a row *before* the tail changed, which only + /// happens when `group_tool_runs` regroups already-seen items (a tool + /// run's calls that used to be separate rows join once the run closes) + /// -- falls back to a full rebuild: every row is dropped + /// (`List::clear`) and rebuilt from `new`. Counted in + /// [`Self::take_rebuilds`] so a caller (a report, a test) can see how + /// often the fallback actually fires rather than assuming it never + /// does. + pub fn apply( + &self, + rsc: &mut Rsc, + old: &[client_core::transcript_fold::TranscriptItem], + new: &[client_core::transcript_fold::TranscriptItem], + ) where + Rsc::State: FocusHost, + { + use client_core::transcript_fold::group_tool_runs; + + let old_rows = group_tool_runs(old); + let new_rows = group_tool_runs(new); + + match diff_rows(&old_rows, &new_rows) { + RowDiff::Unchanged => {} + RowDiff::Appended { common } => { + // Pure append: every already-drawn row is byte-for-byte the + // same `FoldedRow` it was last time. + for row in &new_rows[common..] { + self.push_row(rsc, row); + } + } + RowDiff::ReplaceLast { common } => { + // Only the tail row's content changed -- rebuild that one + // row and swap it in place, keeping every row before it + // untouched. + let old_key = row::row_key(&old_rows[common].key()); + let (new_key, widget) = + row::build_row(rsc, self.list, self.selection.clone(), &new_rows[common]); + if new_key != old_key { + self.selection.borrow_mut().unregister(old_key); + } + let evicted = (self.list)(rsc).replace_back(ListRow::new(new_key, widget)); + drop(evicted); // frees the old row's widget, same as a pop would + for row in &new_rows[common + 1..] { + self.push_row(rsc, row); + } + } + RowDiff::Rebuild => { + // A row before the tail changed (a regroup) -- nothing + // short of a full rebuild expresses that. + self.rebuilds.set(self.rebuilds.get() + 1); + (self.list)(rsc).clear(); + for row in &new_rows { + self.push_row(rsc, row); + } + } + } + } + + /// How many times [`Self::apply`] has fallen back to a full rebuild + /// since the last call, reset to 0 by reading it -- the same + /// take-and-reset shape `AccessTree::take_rebuilds` already uses (I4). + pub fn take_rebuilds(&self) -> usize { + self.rebuilds.replace(0) + } + /// The concatenated text of whatever is currently selected across one /// or more rows, `None` if nothing is -- what a copy command reads. pub fn selected_text(&self, rsc: &mut impl UiRsc) -> Option { @@ -139,7 +232,148 @@ where list, composer, selection, + rebuilds: std::cell::Cell::new(0), }, tree, ) } + +/// What changed at the tail between two folded row lists -- the decision +/// [`TranscriptScreen::apply`] acts on. Kept as its own pure function, no +/// widget and no `Rsc`, so the three cases can be tested directly against +/// synthetic `Vec`s (below) rather than needing a full widget +/// harness to exercise logic that never touches one. +#[derive(Debug, PartialEq, Eq)] +enum RowDiff { + /// `old` and `new` are the same length and every row is identical. + Unchanged, + /// Rows `[common..]` of `new` are new; everything before `common` is + /// byte-for-byte the same `FoldedRow` `old` already had. + Appended { common: usize }, + /// Row `common` is the only one whose content differs; anything past + /// it in `new` is a pure append after the replacement. + ReplaceLast { common: usize }, + /// A row *before* the tail differs -- only `group_tool_runs` regrouping + /// an earlier run does this, and nothing short of a full rebuild + /// expresses it. + Rebuild, +} + +fn diff_rows(old: &[FoldedRow], new: &[FoldedRow]) -> RowDiff { + let common = old + .iter() + .zip(new.iter()) + .take_while(|(a, b)| a == b) + .count(); + + if common == old.len() && common == new.len() { + RowDiff::Unchanged + } else if common == old.len() { + RowDiff::Appended { common } + } else if !old.is_empty() && common == old.len() - 1 && common < new.len() { + // The `common < new.len()` guard is what tells "the tail row's + // content changed" apart from "the tail row was removed and + // nothing replaced it" (a shrinking list) -- the latter has + // nothing at `new[common]` to rebuild into place. + RowDiff::ReplaceLast { common } + } else { + RowDiff::Rebuild + } +} + +#[cfg(test)] +mod diff_tests { + use super::*; + use client_core::transcript_fold::TranscriptItem; + + fn user(seq: u64, text: &str) -> FoldedRow { + FoldedRow::Single(TranscriptItem::UserMsg { + seq, + text: text.to_string(), + attachments: Vec::new(), + }) + } + + fn assistant(seq: u64, text: &str, settled: bool) -> FoldedRow { + FoldedRow::Single(TranscriptItem::AssistantMsg { + seq, + text: text.to_string(), + settled, + }) + } + + fn tool(seq: u64, run_id: &str) -> TranscriptItem { + TranscriptItem::ToolRun { + seq, + id: format!("id{seq}"), + run_id: run_id.to_string(), + tool: "grep".to_string(), + input: "x".to_string(), + output: String::new(), + done: false, + asks: Vec::new(), + images: Vec::new(), + } + } + + #[test] + fn identical_lists_are_unchanged() { + let rows = vec![user(1, "hi"), assistant(2, "hello", true)]; + assert_eq!(diff_rows(&rows, &rows.clone()), RowDiff::Unchanged); + } + + #[test] + fn an_empty_list_growing_by_one_is_an_append_from_zero() { + let old: Vec = Vec::new(); + let new = vec![user(1, "hi")]; + assert_eq!(diff_rows(&old, &new), RowDiff::Appended { common: 0 }); + } + + #[test] + fn a_new_message_after_a_settled_reply_is_a_pure_append() { + // The row that used to be the tail (a now-closed assistant + // message) is unchanged; a new user message is appended after it + // -- the transition every reply's *last* delta makes once the + // next turn starts. + let old = vec![user(1, "hi"), assistant(2, "hello", true)]; + let new = vec![user(1, "hi"), assistant(2, "hello", true), user(3, "and?")]; + assert_eq!(diff_rows(&old, &new), RowDiff::Appended { common: 2 }); + } + + #[test] + fn a_delta_into_the_open_reply_is_a_last_row_replace() { + // The common streaming case: the assistant message's key (its + // first delta's seq) never changes, only its text grows. + let old = vec![user(1, "hi"), assistant(2, "hel", false)]; + let new = vec![user(1, "hi"), assistant(2, "hello", false)]; + assert_eq!(diff_rows(&old, &new), RowDiff::ReplaceLast { common: 1 }); + } + + #[test] + fn a_delta_that_both_settles_the_reply_and_starts_the_next_row_is_still_a_replace() { + // `ReplaceLast` only claims the row it names; `apply` appends + // whatever comes after it separately -- this just confirms the + // diff still recognises the replace even with a trailing append. + let old = vec![user(1, "hi"), assistant(2, "hel", false)]; + let new = vec![user(1, "hi"), assistant(2, "hello", true), user(3, "and?")]; + assert_eq!(diff_rows(&old, &new), RowDiff::ReplaceLast { common: 1 }); + } + + #[test] + fn a_tool_run_closing_and_joining_an_earlier_call_is_a_regroup_fallback() { + // Two separate `Single` rows for the same run id become one + // `Tools` row once `group_tool_runs` sees them adjacent -- that + // changes row 0, not just the tail, so nothing short of a full + // rebuild expresses it. + let old = vec![FoldedRow::Single(tool(1, "run-a")), user(2, "meanwhile")]; + let new = vec![FoldedRow::Tools(vec![tool(1, "run-a"), tool(3, "run-a")])]; + assert_eq!(diff_rows(&old, &new), RowDiff::Rebuild); + } + + #[test] + fn shrinking_the_list_is_a_rebuild() { + let old = vec![user(1, "hi"), assistant(2, "hello", true)]; + let new = vec![user(1, "hi")]; + assert_eq!(diff_rows(&old, &new), RowDiff::Rebuild); + } +}