P1b's pure half (docs/RUST.md). Three pieces, all testable with no widget in sight: - `event_model::Event::ToolEnd` gains `is_error`, read from the CLI's own `tool_result` field by both the live translator and the import replay (`import::tool_result_is_error`, one reader so the two cannot disagree about the same conversation). Without it a result is all a card has, and a broken call draws exactly as confidently as one that worked -- the missing state, not a wrong one. `#[serde(default)]`, so an older transcript reads back as "not reported to have failed". - `client_core::transcript_fold::ToolState`: Running, Deciding, Succeeded, Failed, NoResult. The pair it exists for is the last two against Succeeded-with-empty-output -- a call that printed nothing and a call whose result never arrived leave the same empty string, and only the session's status separates "still going" from "nobody found out". - `client_core::tool_summary::parse_tool_input` and `client_core::durations`: `ToolInput.kt`'s subject/description/timeout split and `Durations.kt`'s span formatting, ported with their tests. The echo driver's three-call run now has a failing middle call, so the failed appearance is reachable from `ui-sandbox.sh` at all. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1521 lines
51 KiB
Rust
1521 lines
51 KiB
Rust
//! 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,
|
|
/// 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<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,
|
|
},
|
|
/// 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<f64>,
|
|
},
|
|
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::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<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
|
|
}
|
|
|
|
/// 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<TranscriptItem> {
|
|
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<String, TranscriptItem> = 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<TranscriptItem> = 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<TranscriptItem> = 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<TranscriptItem>, Vec<TranscriptItem>) {
|
|
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<TranscriptItem> {
|
|
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<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,
|
|
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<Self> {
|
|
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<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
|
|
}
|
|
|
|
/// Folds a page of raw transcript lines (`ApiClient::fetch_transcript_page`'s
|
|
/// `Vec<Value>`) 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<Vec<TranscriptItem>, 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<u64> {
|
|
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<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(),
|
|
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<TranscriptItem> {
|
|
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
|
|
);
|
|
}
|
|
}
|