From 237886c11e62f1a70f779d81c6246a29e9dbf6f6 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Fri, 4 Sep 2026 23:01:01 -0400 Subject: [PATCH] client-core: port the transcript fold (events into rows) Ports the non-Compose half of app/.../TranscriptItems.kt (TranscriptItem, foldEvent, runIdFor, settleReply, placePeerNote, splitRun) and ToolRows.kt (TranscriptRow, groupToolRuns) into transcript_fold.rs, with 7 tests covering delta accumulation, settling, tool-run grouping, a ToolEnd with no matching start, and a question attaching to its call's row versus drawing its own. Not ported: TranscriptUnits.kt's flatten of a row into bounded Compose list units (a fact about that UI framework, not the transcript), and joinPages/healSplitMessage/adoptRun (page-boundary healing) -- both recorded in CLIENT_CORE.md as left for whoever picks this up next. Also noted there: event_model::Event has no Unknown catch-all, so an event type this build doesn't recognise fails to parse rather than degrading to a placeholder row, unlike Events.kt's hand-kept mirror. cargo test (85 passed), clippy --all-targets and fmt clean. Co-Authored-By: Claude Sonnet --- client-core/src/lib.rs | 1 + client-core/src/transcript_fold.rs | 827 +++++++++++++++++++++++++++++ 2 files changed, 828 insertions(+) create mode 100644 client-core/src/transcript_fold.rs diff --git a/client-core/src/lib.rs b/client-core/src/lib.rs index 28c9cc5..a7de40d 100644 --- a/client-core/src/lib.rs +++ b/client-core/src/lib.rs @@ -8,5 +8,6 @@ pub mod event_stream; pub mod highlight; pub mod sse; pub mod transcript_cache; +pub mod transcript_fold; pub use event_model::*; diff --git a/client-core/src/transcript_fold.rs b/client-core/src/transcript_fold.rs new file mode 100644 index 0000000..8b7cdcd --- /dev/null +++ b/client-core/src/transcript_fold.rs @@ -0,0 +1,827 @@ +//! 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, + 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, + }, + 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::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 +} + +/// 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, + 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 } => { + 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, .. + } = item + { + *out = output.clone(); + *done = true; + } + }) + } 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, + 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, + 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, + 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::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 + } + } +} + +/// 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 +} + +#[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(), + }, + )]); + 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, + 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:?}"), + } + } +}