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 <noreply@anthropic.com>
This commit is contained in:
1 parent
e8dbcaa7db
commit
237886c11e
2 files changed
+828
No files matched your search
@@ -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::*;
|
||||
@@ -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<String>,
|
||||
pub options: Vec<QuestionOption>,
|
||||
pub multi_select: bool,
|
||||
pub answers: Vec<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
},
|
||||
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<QuestionCard>,
|
||||
images: Vec<String>,
|
||||
},
|
||||
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<u64>,
|
||||
post_tokens: Option<u64>,
|
||||
},
|
||||
}
|
||||
|
||||
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<TranscriptItem> {
|
||||
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<TranscriptItem> {
|
||||
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<u64>,
|
||||
) -> Vec<TranscriptItem> {
|
||||
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<TranscriptItem> {
|
||||
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<TranscriptItem> = 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<TranscriptItem> {
|
||||
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<TranscriptItem>),
|
||||
}
|
||||
|
||||
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<TranscriptRow> {
|
||||
let mut rows = Vec::new();
|
||||
let mut run: Vec<TranscriptItem> = 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<TranscriptItem>, rows: &mut Vec<TranscriptRow>| 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<TranscriptItem> {
|
||||
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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user