//! What the transcript renders: the event stream folded into displayable //! rows. Ported from `app/.../TranscriptItems.kt` and `ToolRows.kt`'s //! non-Compose half (`TranscriptRow`, `groupToolRuns`). //! //! Events are the only data source, and there is deliberately no second //! shape for history to drift from: a page fetched backwards, a live //! frame, and a line read out of the transcript cache are all the same //! events through the same fold. //! //! **Not ported**: `TranscriptUnits.kt`'s further flatten of a row into //! Compose list units (`TranscriptUnit`, `transcriptUnits`) -- that layer //! exists to bound how much a lazy list composes per frame, which is a //! fact about the UI framework drawing it, not about the transcript. See //! `CLIENT_CORE.md`. //! //! **Known gap**: unlike `Events.kt`'s hand-kept mirror, this crate //! deserializes straight into [`event_model::Event`], which has no //! `Unknown` catch-all -- an event type this build does not recognise //! fails to parse rather than degrading to a placeholder row. Closing that //! gap means giving `event_model::Event` its own forward-compatible //! variant, which is a shared-model decision for both sides of the wire //! and is deliberately left for whoever picks this up next (see //! `CLIENT_CORE.md`). use event_model::{Event, QuestionOption, SeqEvent, SessionStatus}; /// A question this build has already asked the reader about, with what was /// answered so far -- distinct from [`QuestionOption`], which is what could /// be chosen. #[derive(Debug, Clone, PartialEq)] pub struct QuestionCard { pub seq: u64, pub id: String, pub prompt: String, pub header: Option, pub options: Vec, pub multi_select: bool, pub answers: Vec, } /// A tool call cannot be recognised as `AskUserQuestion` from a bare /// `ToolEnd` (its name is not carried), so `runIdFor` and the run-adoption /// logic name it explicitly. pub const ASK_USER_QUESTION: &str = "AskUserQuestion"; /// This item's identity in the list: a `Seq` for everything with no /// identity of its own, `RunId` for a tool call (which keeps one across /// however many calls join or leave its run), matching `TranscriptItem.key` /// in the Kotlin original. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum ItemKey { Seq(u64), RunId(String), } /// One row of the transcript, folded from [`Event`]s. See each variant's /// Kotlin counterpart in `TranscriptItem` for the fuller rationale; this /// doc only says what changed in translation. #[derive(Debug, Clone, PartialEq)] pub enum TranscriptItem { UserMsg { seq: u64, text: String, attachments: Vec, }, AssistantMsg { seq: u64, text: String, /// Whether this reply is finished -- see `AssistantMsg.settled`'s /// Kotlin doc for why the split it licenses matters. settled: bool, }, ToolRun { seq: u64, id: String, run_id: String, tool: String, input: String, output: String, done: bool, /// Whether the result that arrived said the call failed /// ([`Event::ToolEnd`]'s `is_error`). Meaningless while `done` is /// false, and [`ToolState::of`] is the only thing that reads the /// pair, so the two cannot be combined wrongly at a call site. failed: bool, asks: Vec, images: Vec, }, QuestionCard(QuestionCard), ErrorMsg { seq: u64, message: String, }, ImageItem { seq: u64, r#ref: String, }, /// A message from another agent. `arrived` is this row's own identity /// ([`TranscriptItem::key`]); `seq` is where it *sorts*, which /// [`place_peer_note`] may set to the turn's opening seq instead. PeerNote { seq: u64, from: String, text: String, arrived: u64, }, CommandRow { seq: u64, text: String, }, /// Placeholder for an event kind this build could not fold -- see the /// module doc's "known gap". Note { seq: u64, text: String, }, ClearedNote { seq: u64, }, /// The account's usage limit stopped the turn; `resets_at` is epoch /// seconds when the dialect said when it lifts (`LimitNote` in /// `TranscriptItems.kt`). LimitNote { seq: u64, resets_at: Option, }, CompactedNote { seq: u64, pre_tokens: Option, post_tokens: Option, }, } impl TranscriptItem { pub fn seq(&self) -> u64 { match self { Self::UserMsg { seq, .. } | Self::AssistantMsg { seq, .. } | Self::ToolRun { seq, .. } | Self::ErrorMsg { seq, .. } | Self::ImageItem { seq, .. } | Self::PeerNote { seq, .. } | Self::CommandRow { seq, .. } | Self::Note { seq, .. } | Self::ClearedNote { seq } | Self::LimitNote { seq, .. } | Self::CompactedNote { seq, .. } => *seq, Self::QuestionCard(card) => card.seq, } } pub fn key(&self) -> ItemKey { match self { Self::ToolRun { run_id, .. } => ItemKey::RunId(run_id.clone()), Self::PeerNote { arrived, .. } => ItemKey::Seq(*arrived), other => ItemKey::Seq(other.seq()), } } fn as_tool_run(&self) -> Option<&str> { match self { Self::ToolRun { id, .. } => Some(id), _ => None, } } } /// The run a call joins: the one it lands next to, or a new one named /// after itself. See the Kotlin `runIdFor`'s doc for why the name, once /// picked, never changes. fn run_id_for(items: &[TranscriptItem], id: &str, tool: &str) -> String { let Some(TranscriptItem::ToolRun { run_id, tool: previous_tool, .. }) = items.last() else { return id.to_string(); }; if tool == ASK_USER_QUESTION || previous_tool == ASK_USER_QUESTION { id.to_string() } else { run_id.clone() } } fn update_tool( items: &[TranscriptItem], id: &str, change: impl Fn(&mut TranscriptItem), ) -> Vec { items .iter() .cloned() .map(|mut item| { if item.as_tool_run() == Some(id) { change(&mut item); } item }) .collect() } /// Whether a status means the session is still doing something, mirroring /// `sessionWorking` in `Events.kt`. pub fn session_working(status: SessionStatus) -> bool { matches!(status, SessionStatus::Running | SessionStatus::Compacting) } /// A status saying the session stopped working is the moment its newest /// reply is finished. fn settle_reply(items: &[TranscriptItem], status: SessionStatus) -> Vec { if session_working(status) { return items.to_vec(); } let Some(TranscriptItem::AssistantMsg { settled: false, .. }) = items.last() else { return items.to_vec(); }; let mut items = items.to_vec(); if let Some(TranscriptItem::AssistantMsg { settled, .. }) = items.last_mut() { *settled = true; } items } /// A peer message goes above the turn it started, not where it happened to /// arrive. See the Kotlin `placePeerNote`'s doc for the full reasoning; /// `turn_start` is `Event::PeerMessage`'s own field of that name. fn place_peer_note( items: &[TranscriptItem], seq: u64, from: &str, text: &str, turn_start: Option, ) -> Vec { let Some(at) = turn_start else { let mut items = items.to_vec(); items.push(TranscriptItem::PeerNote { seq, from: from.to_string(), text: text.to_string(), arrived: seq, }); return items; }; let note = TranscriptItem::PeerNote { seq: at, from: from.to_string(), text: text.to_string(), arrived: seq, }; let Some(index) = items.iter().position(|i| i.seq() > at) else { let mut items = items.to_vec(); items.push(note); return items; }; let behind = match index.checked_sub(1).and_then(|i| items.get(i)) { Some(TranscriptItem::ToolRun { run_id, .. }) => Some(run_id.clone()), _ => None, }; let mut out = items[..index].to_vec(); out.push(note); out.extend(split_run(&items[index..], behind.as_deref())); out } /// The calls the note now sits in front of, renamed if they were sharing a /// run with the calls behind it. See the Kotlin `splitRun`'s doc. fn split_run(tail: &[TranscriptItem], behind: Option<&str>) -> Vec { let Some(TranscriptItem::ToolRun { run_id: first_run_id, id: first_id, .. }) = tail.first() else { return tail.to_vec(); }; let Some(behind) = behind else { return tail.to_vec(); }; if first_run_id != behind { return tail.to_vec(); } let run_len = tail .iter() .take_while(|i| matches!(i, TranscriptItem::ToolRun { run_id, .. } if run_id == behind)) .count(); let mut out: Vec = tail[..run_len] .iter() .cloned() .map(|mut item| { if let TranscriptItem::ToolRun { run_id, .. } = &mut item { *run_id = first_id.clone(); } item }) .collect(); out.extend(tail[run_len..].iter().cloned()); out } /// Puts a page of older items in front of the ones already loaded, healing /// whatever the page boundary cut in two. Ported from `TranscriptItems.kt`'s /// `joinPages`. /// /// Two things straddle a boundary: a tool call separated from its result, /// and a message separated from the rest of itself. Both were one thing /// before the transcript was cut into pages. /// /// A boundary lands wherever it lands, and roughly half the time that is /// between a call and its result. The newer page then holds a `ToolEnd` /// whose start it never saw, which `fold_event` draws as a row of its own /// -- correctly, because a call that renders as nothing is indistinguishable /// from one that never happened. When the older page arrives it brings the /// real `ToolStart`, and concatenating the two lists left *both*: the same /// call twice. /// /// Merged by the call's own id rather than by position, because position is /// exactly what a page boundary destroys. The older row wins on what a /// start knows and the newer on what an end knows, which is the only way /// round that loses nothing. /// /// The third thing is the *run*, and it is the one the Kotlin original used /// to miss (AGENTS.md's "things that have bitten"): every page ends up /// here, but `adopt_run` must run on *every* join, not only the one where a /// split call was found -- a boundary landing cleanly between two finished /// calls, which is most of them, would otherwise leave the older page's /// calls under the run name they were folded with. On screen: one run of /// tool calls drawn as two groups, with the seam wherever the reader /// happened to have paged. pub fn join_pages(earlier: &[TranscriptItem], later: &[TranscriptItem]) -> Vec { let (older, newer) = heal_split_message(earlier, later); let started_earlier: std::collections::HashSet<&str> = older .iter() .filter_map(TranscriptItem::as_tool_run) .collect(); // Owned rather than borrowed from `newer`: `kept` below needs to consume `newer` by // value, and a map borrowing it would keep that alive. let ended_later: std::collections::HashMap = newer .iter() .filter_map(|item| item.as_tool_run().map(|id| (id.to_string(), item.clone()))) .filter(|(id, _)| started_earlier.contains(id.as_str())) .collect(); let healed: Vec = older .into_iter() .map(|row| match row { TranscriptItem::ToolRun { seq, id, run_id, tool, input, asks: row_asks, images: row_images, .. } if ended_later.contains_key(id.as_str()) => { let &TranscriptItem::ToolRun { ref output, done, failed, asks: ref half_asks, images: ref half_images, .. } = &ended_later[id.as_str()] else { unreachable!("filtered to ToolRun above"); }; TranscriptItem::ToolRun { seq, id, run_id, tool, input, output: output.clone(), done, failed, // Kept from both halves: a question or an image can be // attached to either, depending on which side of the // boundary its event fell. asks: row_asks.into_iter().chain(half_asks.clone()).collect(), images: row_images.into_iter().chain(half_images.clone()).collect(), } } other => other, }) .collect(); let kept: Vec = newer .into_iter() .filter(|item| match item.as_tool_run() { Some(id) => !ended_later.contains_key(id), None => true, }) .collect(); let mut out = adopt_run(&healed, &kept); out.extend(kept); // What this function exists to prevent, checked rather than assumed: the same // call drawn twice, once from the page that saw its start and once from the page // that saw its end. Not a seq-ordering check -- a peer note is stamped with the // seq its turn began at, which can be older than the page it arrived in, so the // two pages' seqs legitimately interleave at the boundary. debug_assert!( { let mut ids: Vec<&str> = out.iter().filter_map(TranscriptItem::as_tool_run).collect(); let before = ids.len(); ids.sort_unstable(); ids.dedup(); ids.len() == before }, "join_pages left the same tool call in both halves" ); out } /// Rejoins a message the page boundary cut, and hands back the two pages to /// concatenate. Ported from `TranscriptItems.kt`'s `healSplitMessage`. /// /// `fold_event` never leaves two assistant messages next to each other /// inside one page, so two meeting at a join are always the two halves of /// one reply, and leaving them apart drew a single answer as two with a /// paragraph break through the middle of a sentence. /// /// The newer half keeps its identity, for the reason `adopt_run`'s doc /// gives. It grows by what the older half brings, which is safe here and /// nowhere else -- the join is at the oldest end of what is loaded, so the /// growth extends off the top of the screen. fn heal_split_message( earlier: &[TranscriptItem], later: &[TranscriptItem], ) -> (Vec, Vec) { let ( Some(TranscriptItem::AssistantMsg { text: head_text, .. }), Some(TranscriptItem::AssistantMsg { seq: tail_seq, text: tail_text, settled: tail_settled, }), ) = (earlier.last(), later.first()) else { return (earlier.to_vec(), later.to_vec()); }; let merged = TranscriptItem::AssistantMsg { seq: *tail_seq, text: format!("{head_text}{tail_text}"), settled: *tail_settled, }; let mut newer = vec![merged]; newer.extend(later[1..].iter().cloned()); (earlier[..earlier.len() - 1].to_vec(), newer) } /// Hands the older calls at the join the name of the run they are joining. /// Ported from `TranscriptItems.kt`'s `adoptRun`. /// /// The two pages were folded separately, so a run split by the boundary /// came back as two runs with two names. Naming the joined run after the /// *older* half would be the obvious way round and is wrong: the newer half /// is the part already on screen, and renaming it is renaming the row the /// reader is looking at, which is how a list loses its anchor. fn adopt_run(earlier: &[TranscriptItem], later: &[TranscriptItem]) -> Vec { let Some(TranscriptItem::ToolRun { run_id, tool, .. }) = later.first() else { return earlier.to_vec(); }; // A question is in a run of its own on both sides of the join, the same as it would be // had the two pages been folded as one. Without this the heal would merge a group // straight through the row the reader was asked something on. if tool == ASK_USER_QUESTION { return earlier.to_vec(); } let joining = run_id.clone(); let tail_len = earlier .iter() .rev() .take_while(|item| matches!(item, TranscriptItem::ToolRun { tool, .. } if tool != ASK_USER_QUESTION)) .count(); if tail_len == 0 { return earlier.to_vec(); } let split = earlier.len() - tail_len; let mut out = earlier[..split].to_vec(); out.extend(earlier[split..].iter().cloned().map(|mut item| { // `take_while` above already restricted this slice to non-question tool calls; // this just guards the invariant rather than trusting it silently. debug_assert!( matches!(&item, TranscriptItem::ToolRun { tool, .. } if tool != ASK_USER_QUESTION), "adopt_run must never rename a question's own run" ); if let TranscriptItem::ToolRun { run_id, .. } = &mut item { *run_id = joining.clone(); } item })); out } /// Folds one transcript event onto `items`, the way `foldEvent` does in /// `TranscriptItems.kt`. Every wire event has a case; see the module doc /// for the one difference from the Kotlin original (no `Unknown` fallback /// at the parse layer). pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec { let seq = entry.seq; match &entry.event { Event::UserMessage { text, attachments, .. } => { let mut items = items.to_vec(); items.push(TranscriptItem::UserMsg { seq, text: text.clone(), attachments: attachments.clone(), }); items } // `MessageTaken` is folded into `UserMessage` by the manager before // it reaches a phone (see `PLAN.md`); if one arrives here anyway // (a raw transcript line, say), it reads the same way. Event::MessageTaken { text, attachments, .. } => { let mut items = items.to_vec(); items.push(TranscriptItem::UserMsg { seq, text: text.clone(), attachments: attachments.clone(), }); items } Event::AssistantText { delta } => { // Deltas accumulate into the message they're streaming, which // keeps the seq of the *first* of them: a row whose identity // changed with every delta would be a new row every frame. // "A message growing again is not finished" -- whatever a // status said in between -- is why this always clears // `settled` rather than preserving it. if let Some(TranscriptItem::AssistantMsg { seq: first_seq, text, .. }) = items.last() { let first_seq = *first_seq; let text = format!("{text}{delta}"); let mut items = items[..items.len() - 1].to_vec(); items.push(TranscriptItem::AssistantMsg { seq: first_seq, text, settled: false, }); items } else { let mut items = items.to_vec(); items.push(TranscriptItem::AssistantMsg { seq, text: delta.clone(), settled: false, }); items } } Event::ToolStart { id, tool, input } => { let run_id = run_id_for(items, id, tool); let mut items = items.to_vec(); items.push(TranscriptItem::ToolRun { seq, id: id.clone(), run_id, tool: tool.clone(), input: input.to_string(), output: String::new(), done: false, failed: false, asks: Vec::new(), images: Vec::new(), }); items } Event::ToolUpdate { id, output } => update_tool(items, id, |item| { if let TranscriptItem::ToolRun { output: out, .. } = item { *out = output.clone(); } }), Event::ToolEnd { id, output, is_error, } => { if items.iter().any(|i| i.as_tool_run() == Some(id.as_str())) { update_tool(items, id, |item| { if let TranscriptItem::ToolRun { output: out, done, failed, .. } = item { *out = output.clone(); *done = true; *failed = *is_error; } }) } else { let run_id = run_id_for(items, id, "tool"); let mut items = items.to_vec(); items.push(TranscriptItem::ToolRun { seq, id: id.clone(), run_id, tool: "tool".to_string(), input: String::new(), output: output.clone(), done: true, failed: *is_error, asks: Vec::new(), images: Vec::new(), }); items } } Event::Question { id, prompt, header, options, multi_select, about, } => { let card = QuestionCard { seq, id: id.clone(), prompt: prompt.clone(), header: header.clone(), options: options.clone(), multi_select: *multi_select, answers: Vec::new(), }; let about_tool = about .as_deref() .is_some_and(|about| items.iter().any(|i| i.as_tool_run() == Some(about))); if about_tool { let about = about.clone().unwrap(); update_tool(items, &about, move |item| { if let TranscriptItem::ToolRun { asks, .. } = item { asks.push(card.clone()); } }) } else { let mut items = items.to_vec(); items.push(TranscriptItem::QuestionCard(card)); items } } Event::Answered { id, answers } => items .iter() .cloned() .map(|item| match item { TranscriptItem::QuestionCard(mut card) if &card.id == id => { card.answers = answers.clone(); TranscriptItem::QuestionCard(card) } TranscriptItem::ToolRun { mut asks, seq, id: tid, run_id, tool, input, output, done, failed, images, } if asks.iter().any(|a| &a.id == id) => { for ask in asks.iter_mut() { if &ask.id == id { ask.answers = answers.clone(); } } TranscriptItem::ToolRun { seq, id: tid, run_id, tool, input, output, done, failed, asks, images, } } other => other, }) .collect(), Event::PeerMessage { from, text, turn_start, } => place_peer_note(items, seq, from, text, *turn_start), Event::CommandSent { text, .. } => { let mut items = items.to_vec(); items.push(TranscriptItem::CommandRow { seq, text: text.clone(), }); items } // Screen-level state, not transcript rows. Event::CommandQueued { .. } | Event::MessageQueued { .. } | Event::MessageDropped { .. } | Event::Settings { .. } | Event::UsageDelta { .. } => items.to_vec(), Event::Status { state } => settle_reply(items, *state), Event::Error { message } => { let mut items = items.to_vec(); items.push(TranscriptItem::ErrorMsg { seq, message: message.clone(), }); items } Event::Image { image, about } => { let about_tool = about .as_deref() .is_some_and(|about| items.iter().any(|i| i.as_tool_run() == Some(about))); if about_tool { let about = about.clone().unwrap(); let image = image.clone(); update_tool(items, &about, move |item| { if let TranscriptItem::ToolRun { images, .. } = item { images.push(image.clone()); } }) } else { let mut items = items.to_vec(); items.push(TranscriptItem::ImageItem { seq, r#ref: image.clone(), }); items } } Event::Cleared => { let mut items = items.to_vec(); items.push(TranscriptItem::ClearedNote { seq }); items } Event::LimitReached { resets_at } => { let mut items = items.to_vec(); items.push(TranscriptItem::LimitNote { seq, resets_at: *resets_at, }); items } Event::Compacted { pre_tokens, post_tokens, .. } => { let mut items = items.to_vec(); items.push(TranscriptItem::CompactedNote { seq, pre_tokens: *pre_tokens, post_tokens: *post_tokens, }); items } } } /// What became of one tool call -- every state a card has to be able to /// draw, including the two that are not answers. /// /// The pair this enum exists for is [`ToolState::Succeeded`] against /// [`ToolState::NoResult`]. A call that finished having printed nothing /// and a call whose result never arrived both leave an empty `output`, /// and drawing them the same way states a verdict nobody reached: "it /// worked and said nothing" reads as a fact, where the truth is that the /// turn ended before anything came back. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ToolState { /// Started, no result yet, and the session is still working -- the /// ordinary state of a call in flight. Running, /// Stopped on the reader: a permission or question this call carries /// has not been answered, so nothing is happening until somebody /// answers it. Distinct from [`Self::Running`] because whose move it /// is differs, which is the Compose card's "your turn". Deciding, /// A result arrived and the tool did not report a failure. Succeeded, /// A result arrived and the tool reported that the call failed /// (`is_error`). Failed, /// No result ever arrived and the session is not working any more -- /// the turn was interrupted, or the process went away. Not a verdict /// on the call: it says only that nobody found out. NoResult, } impl ToolState { /// The state of one call. `session_working` is /// [`session_working`]'s answer for the session this call is in -- /// the only thing here that is not a property of the call itself, and /// what separates "still running" from "never came back". /// /// Written once, over the fields rather than per call site, because /// the five states are decided by four conditions and every place /// that re-derived a subset of them got a different subset. pub fn of(item: &TranscriptItem, session_working: bool) -> Option { let TranscriptItem::ToolRun { done, failed, asks, .. } = item else { return None; }; debug_assert!( !failed || *done, "a call cannot have failed before its result arrived" ); Some(if asks.iter().any(|ask| ask.answers.is_empty()) { // Ahead of `done`: a call waiting on permission has not // finished either, and which of the two the reader is being // told about is the one they can act on. Self::Deciding } else if !*done { match session_working { true => Self::Running, false => Self::NoResult, } } else if *failed { Self::Failed } else { Self::Succeeded }) } } /// One row as the transcript draws it: a run of consecutive tool calls, or /// anything else. Ported from `ToolRows.kt`'s `TranscriptRow` and /// `groupToolRuns` -- the Compose card rendering in that file is not part /// of this crate. #[derive(Debug, Clone, PartialEq)] pub enum TranscriptRow { Single(TranscriptItem), /// Two or more calls with nothing between them. Tools(Vec), } impl TranscriptRow { pub fn key(&self) -> ItemKey { match self { Self::Single(item) => item.key(), Self::Tools(calls) => calls[0].key(), } } pub fn start_seq(&self) -> u64 { match self { Self::Single(item) => item.seq(), Self::Tools(calls) => calls[0].seq(), } } } /// Runs of adjacent tool calls become one row; everything else passes /// through. See the Kotlin `groupRuns`'s doc for why grouping is by the /// run each call names rather than by adjacency worked out here. pub fn group_tool_runs(items: &[TranscriptItem]) -> Vec { let mut rows = Vec::new(); let mut run: Vec = Vec::new(); fn run_id_of(item: &TranscriptItem) -> Option<&str> { match item { TranscriptItem::ToolRun { run_id, .. } => Some(run_id), _ => None, } } let flush = |run: &mut Vec, rows: &mut Vec| match run.len() { 0 => {} 1 => rows.push(TranscriptRow::Single(run.drain(..).next().unwrap())), _ => rows.push(TranscriptRow::Tools(std::mem::take(run))), }; for item in items { let joins = matches!(item, TranscriptItem::ToolRun { .. }) && (run.is_empty() || run_id_of(&run[0]) == run_id_of(item)); if joins { run.push(item.clone()); } else { flush(&mut run, &mut rows); if matches!(item, TranscriptItem::ToolRun { .. }) { run.push(item.clone()); } else { rows.push(TranscriptRow::Single(item.clone())); } } } flush(&mut run, &mut rows); rows } /// Folds a page of raw transcript lines (`ApiClient::fetch_transcript_page`'s /// `Vec`) into the flat item list this module works over. A line /// this build can't parse fails the whole page rather than being skipped -- /// CODE_RULES's "an enumeration must be able to say 'it broke'" -- since /// silently dropping one event could hide, say, a user message that then /// looks like it was never sent. Moved here from `desktop-app`'s `app.rs` /// (RUST.md's E4) when the Android transcript client (I5) needed the same /// fold: "write the logic once" applies to any caller embedding /// `transcript-ui` against a live server, not just the first one. pub fn fold_page(values: &[serde_json::Value]) -> Result, String> { let mut items = Vec::new(); for value in values { let event: SeqEvent = serde_json::from_value(value.clone()).map_err(|e| { format!("the server sent a transcript line this build couldn't parse: {e}") })?; items = fold_event(&items, &event); } Ok(items) } /// The wire `seq` a raw transcript line carries -- the live-stream resume /// cursor after loading a page must be this, not a folded item's `seq()`. /// A folded `AssistantMsg` keeps the seq of the *first* delta it /// accumulated (`fold_event`'s own doc), so resuming from that seq would /// re-deliver every delta already folded into it, duplicating the tail of /// a reply that was mid-stream when the page was fetched -- found via a /// real screenshot in E4 (RUST.md), where the assistant's line doubled. pub fn raw_seq(value: &serde_json::Value) -> Option { value.get("seq")?.as_u64() } #[cfg(test)] mod tests { use super::*; fn event(seq: u64, event: Event) -> SeqEvent { SeqEvent { seq, ts: 1.0, event, } } fn fold_all(events: &[SeqEvent]) -> Vec { events .iter() .fold(Vec::new(), |items, e| fold_event(&items, e)) } #[test] fn assistant_deltas_accumulate_into_one_message() { let items = fold_all(&[ event( 1, Event::AssistantText { delta: "hel".to_string(), }, ), event( 2, Event::AssistantText { delta: "lo".to_string(), }, ), ]); assert_eq!( items, vec![TranscriptItem::AssistantMsg { seq: 1, text: "hello".to_string(), settled: false }] ); } #[test] fn a_status_that_stopped_working_settles_the_newest_reply() { let items = fold_all(&[ event( 1, Event::AssistantText { delta: "hi".to_string(), }, ), event( 2, Event::Status { state: SessionStatus::Idle, }, ), ]); assert_eq!( items, vec![TranscriptItem::AssistantMsg { seq: 1, text: "hi".to_string(), settled: true }] ); } #[test] fn a_working_status_does_not_settle_anything() { let items = fold_all(&[ event( 1, Event::AssistantText { delta: "hi".to_string(), }, ), event( 2, Event::Status { state: SessionStatus::Running, }, ), ]); assert_eq!( items, vec![TranscriptItem::AssistantMsg { seq: 1, text: "hi".to_string(), settled: false }] ); } #[test] fn adjacent_tool_calls_group_and_a_lone_one_does_not() { let items = fold_all(&[ event( 1, Event::ToolStart { id: "a".to_string(), tool: "Bash".to_string(), input: serde_json::json!({}), }, ), event( 2, Event::ToolStart { id: "b".to_string(), tool: "Bash".to_string(), input: serde_json::json!({}), }, ), ]); let rows = group_tool_runs(&items); assert_eq!(rows.len(), 1); assert!(matches!(&rows[0], TranscriptRow::Tools(calls) if calls.len() == 2)); let solo = fold_all(&[event( 1, Event::ToolStart { id: "a".to_string(), tool: "Bash".to_string(), input: serde_json::json!({}), }, )]); let rows = group_tool_runs(&solo); assert_eq!(rows.len(), 1); assert!(matches!( &rows[0], TranscriptRow::Single(TranscriptItem::ToolRun { .. }) )); } #[test] fn a_tool_end_with_no_matching_start_still_draws_a_row() { let items = fold_all(&[event( 5, Event::ToolEnd { id: "x".to_string(), output: "done".to_string(), is_error: false, }, )]); assert_eq!( items, vec![TranscriptItem::ToolRun { seq: 5, id: "x".to_string(), run_id: "x".to_string(), tool: "tool".to_string(), input: String::new(), output: "done".to_string(), done: true, failed: false, asks: Vec::new(), images: Vec::new(), }] ); } #[test] fn a_question_about_a_tool_call_attaches_to_its_row_rather_than_drawing_its_own() { let items = fold_all(&[ event( 1, Event::ToolStart { id: "a".to_string(), tool: "Bash".to_string(), input: serde_json::json!({}), }, ), event( 2, Event::Question { id: "q1".to_string(), prompt: "run it?".to_string(), header: None, options: vec![QuestionOption::plain("yes"), QuestionOption::plain("no")], multi_select: false, about: Some("a".to_string()), }, ), ]); assert_eq!(items.len(), 1); match &items[0] { TranscriptItem::ToolRun { asks, .. } => assert_eq!(asks.len(), 1), other => panic!("expected a ToolRun, got {other:?}"), } } #[test] fn answering_resolves_a_bare_question_card() { let items = fold_all(&[ event( 1, Event::Question { id: "q1".to_string(), prompt: "pick one".to_string(), header: None, options: vec![QuestionOption::plain("a")], multi_select: false, about: None, }, ), event( 2, Event::Answered { id: "q1".to_string(), answers: vec!["a".to_string()], }, ), ]); match &items[0] { TranscriptItem::QuestionCard(card) => assert_eq!(card.answers, vec!["a".to_string()]), other => panic!("expected a QuestionCard, got {other:?}"), } } fn line(seq: u64, json: serde_json::Value) -> serde_json::Value { let mut obj = json; obj["seq"] = serde_json::json!(seq); obj["ts"] = serde_json::json!(1.0); obj } /// The regression for a bug a real `run-headless.sh` screenshot found /// in `desktop-app` (E4, RUST.md): resuming the live stream from the /// last *item's* seq re-delivers the deltas already folded into a /// still-open assistant message, doubling its tail. `raw_seq` of the /// last wire line must be the true high-water mark instead, which for a /// run of deltas is higher than every item's own `seq()`. #[test] fn the_resume_cursor_is_the_last_wire_seq_not_the_last_items_seq() { let values = vec![ line(1, serde_json::json!({"type": "userMessage", "text": "hi"})), line( 2, serde_json::json!({"type": "assistantText", "delta": "a"}), ), line( 3, serde_json::json!({"type": "assistantText", "delta": "b"}), ), line( 4, serde_json::json!({"type": "assistantText", "delta": "c"}), ), ]; let after = raw_seq(values.last().unwrap()).unwrap(); assert_eq!(after, 4); let items = fold_page(&values).unwrap(); let assistant_seq = items .iter() .find(|i| matches!(i, TranscriptItem::AssistantMsg { .. })) .unwrap() .seq(); assert_eq!(assistant_seq, 2); assert_ne!( after, assistant_seq, "the fixed bug: these must differ here" ); } #[test] fn a_page_folds_into_one_settled_assistant_message() { let values = vec![ line(1, serde_json::json!({"type": "userMessage", "text": "hi"})), line( 2, serde_json::json!({"type": "assistantText", "delta": "hel"}), ), line( 3, serde_json::json!({"type": "assistantText", "delta": "lo"}), ), ]; let items = fold_page(&values).unwrap(); assert_eq!( items, vec![ TranscriptItem::UserMsg { seq: 1, text: "hi".to_string(), attachments: Vec::new(), }, TranscriptItem::AssistantMsg { seq: 2, text: "hello".to_string(), settled: false, }, ] ); } #[test] fn an_unparseable_line_fails_the_whole_page() { let values = vec![serde_json::json!({"seq": 1, "ts": 1.0, "type": "not-a-real-type"})]; let err = fold_page(&values).unwrap_err(); assert!(err.contains("couldn't parse")); } fn tool_start(seq: u64, id: &str, tool: &str) -> SeqEvent { event( seq, Event::ToolStart { id: id.to_string(), tool: tool.to_string(), input: serde_json::json!({}), }, ) } fn tool_end(seq: u64, id: &str, output: &str) -> SeqEvent { event( seq, Event::ToolEnd { id: id.to_string(), output: output.to_string(), is_error: false, }, ) } /// AGENTS.md's "things that have bitten": `joinPages` used to run /// `adoptRun` only on the path where a *split* call was found, so a /// boundary landing cleanly between two already-finished calls -- most /// of them -- left the older page's calls under the run name they were /// folded with, drawing one run of tool calls as two groups. Two /// finished, unrelated calls (no id in common) must still end up under /// one run name after the join. #[test] fn a_clean_boundary_between_two_finished_runs_is_still_healed_into_one_run() { let older = fold_all(&[tool_start(1, "a", "Bash"), tool_end(2, "a", "old output")]); let newer = fold_all(&[tool_start(3, "b", "Bash"), tool_end(4, "b", "new output")]); let joined = join_pages(&older, &newer); let run_ids: Vec<_> = joined .iter() .map(|item| match item { TranscriptItem::ToolRun { run_id, .. } => run_id.as_str(), other => panic!("expected only ToolRun items, got {other:?}"), }) .collect(); assert_eq!( run_ids, vec!["b", "b"], "the older call must adopt the newer, already-on-screen run's name" ); } #[test] fn a_call_split_across_the_boundary_merges_into_one_row() { let older = fold_all(&[tool_start(1, "x", "Bash")]); let newer = fold_all(&[tool_end(2, "x", "the result")]); let joined = join_pages(&older, &newer); assert_eq!( joined, vec![TranscriptItem::ToolRun { seq: 1, id: "x".to_string(), run_id: "x".to_string(), tool: "Bash".to_string(), input: "{}".to_string(), output: "the result".to_string(), done: true, failed: false, asks: Vec::new(), images: Vec::new(), }], "the older half's tool/input and the newer half's output/done must both survive" ); } #[test] fn a_message_split_across_the_boundary_is_rejoined_with_the_newer_halfs_identity() { let older = vec![TranscriptItem::AssistantMsg { seq: 1, text: "Hel".to_string(), settled: false, }]; let newer = vec![ TranscriptItem::AssistantMsg { seq: 2, text: "lo".to_string(), settled: true, }, TranscriptItem::UserMsg { seq: 3, text: "next".to_string(), attachments: Vec::new(), }, ]; let joined = join_pages(&older, &newer); assert_eq!( joined, vec![ TranscriptItem::AssistantMsg { seq: 2, text: "Hello".to_string(), settled: true, }, TranscriptItem::UserMsg { seq: 3, text: "next".to_string(), attachments: Vec::new(), }, ] ); } /// A question is in a run of its own on both sides of a join -- healing /// must never rename the run of calls the reader was asked something /// on, the same rule `splitRun` enforces for a live turn boundary. #[test] fn adopt_run_never_renames_into_a_question_row() { let older = fold_all(&[tool_start(1, "a", "Bash"), tool_end(2, "a", "done")]); let newer = vec![TranscriptItem::ToolRun { seq: 3, id: "q".to_string(), run_id: "q".to_string(), tool: ASK_USER_QUESTION.to_string(), input: "{}".to_string(), output: String::new(), done: false, failed: false, asks: Vec::new(), images: Vec::new(), }]; let joined = join_pages(&older, &newer); match &joined[0] { TranscriptItem::ToolRun { run_id, .. } => assert_eq!(run_id, "a"), other => panic!("expected a ToolRun, got {other:?}"), } } } /// [`ToolState`] is what a card colours itself by, so each of its five /// states is asserted from the events that actually produce it rather than /// from a hand-built item -- a mapping that agreed with a fixture and /// disagreed with the fold would be invisible until it was on screen. #[cfg(test)] mod tool_state_tests { use super::*; fn event(seq: u64, e: Event) -> SeqEvent { SeqEvent { seq, ts: 0.0, event: e, } } fn fold_all(events: &[SeqEvent]) -> Vec { events .iter() .fold(Vec::new(), |items, e| fold_event(&items, e)) } fn start(id: &str) -> SeqEvent { event( 1, Event::ToolStart { id: id.to_string(), tool: "Bash".to_string(), input: serde_json::json!({"command": "ls"}), }, ) } fn end(id: &str, output: &str, is_error: bool) -> SeqEvent { event( 2, Event::ToolEnd { id: id.to_string(), output: output.to_string(), is_error, }, ) } fn state_of(events: &[SeqEvent], session_working: bool) -> ToolState { let items = fold_all(events); ToolState::of(&items[0], session_working).expect("the fixture's first item is a tool call") } #[test] fn a_result_that_arrived_is_read_from_is_error() { assert_eq!( state_of(&[start("a"), end("a", "ok", false)], false), ToolState::Succeeded ); assert_eq!( state_of(&[start("a"), end("a", "No such file", true)], false), ToolState::Failed ); } /// The pair this enum exists for. Both calls have an empty `output` /// and nothing else distinguishes them, so a card that only looked at /// the text would draw the interrupted one as a call that ran fine and /// printed nothing. #[test] fn a_call_that_printed_nothing_is_not_a_call_that_never_answered() { assert_eq!( state_of(&[start("a"), end("a", "", false)], false), ToolState::Succeeded, "a result arrived; it was empty" ); assert_eq!( state_of(&[start("a")], false), ToolState::NoResult, "no result, and the session is not working any more" ); } /// The same call, mid-turn: still running rather than abandoned. The /// only thing separating the two is the session's own status, which is /// why `of` takes it. #[test] fn no_result_while_the_session_works_is_still_running() { assert_eq!(state_of(&[start("a")], true), ToolState::Running); } #[test] fn an_unanswered_ask_is_the_readers_move_whatever_else_is_true() { let asking = event( 3, Event::Question { id: "q1".to_string(), prompt: "Allow?".to_string(), header: None, options: vec![QuestionOption { label: "Allow".to_string(), description: None, preview: None, }], multi_select: false, about: Some("a".to_string()), }, ); let answered = event( 4, Event::Answered { id: "q1".to_string(), answers: vec!["Allow".to_string()], }, ); // Ahead of both "still running" and "no result": the reader can // act on this one, and cannot act on either of those. assert_eq!( state_of(&[start("a"), asking.clone()], true), ToolState::Deciding ); assert_eq!( state_of(&[start("a"), asking.clone()], false), ToolState::Deciding ); assert_eq!( state_of( &[start("a"), asking, answered, end("a", "ok", false)], false ), ToolState::Succeeded, "once it is answered the call is an ordinary one again" ); } #[test] fn nothing_but_a_tool_call_has_a_tool_state() { assert_eq!( ToolState::of( &TranscriptItem::UserMsg { seq: 1, text: "hi".to_string(), attachments: Vec::new(), }, true ), None ); } }