A tool call can say it failed, and what it is for, without a renderer
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>
This commit is contained in:
1 parent
69525bd131
commit
9079276ec8
9 files changed
+679
-4
No files matched your search
@@ -78,6 +78,11 @@ pub enum TranscriptItem {
|
||||
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>,
|
||||
},
|
||||
@@ -352,6 +357,7 @@ pub fn join_pages(earlier: &[TranscriptItem], later: &[TranscriptItem]) -> Vec<T
|
||||
let &TranscriptItem::ToolRun {
|
||||
ref output,
|
||||
done,
|
||||
failed,
|
||||
asks: ref half_asks,
|
||||
images: ref half_images,
|
||||
..
|
||||
@@ -367,6 +373,7 @@ pub fn join_pages(earlier: &[TranscriptItem], later: &[TranscriptItem]) -> Vec<T
|
||||
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.
|
||||
@@ -562,6 +569,7 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
||||
input: input.to_string(),
|
||||
output: String::new(),
|
||||
done: false,
|
||||
failed: false,
|
||||
asks: Vec::new(),
|
||||
images: Vec::new(),
|
||||
});
|
||||
@@ -572,15 +580,23 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
||||
*out = output.clone();
|
||||
}
|
||||
}),
|
||||
Event::ToolEnd { id, output } => {
|
||||
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, ..
|
||||
output: out,
|
||||
done,
|
||||
failed,
|
||||
..
|
||||
} = item
|
||||
{
|
||||
*out = output.clone();
|
||||
*done = true;
|
||||
*failed = *is_error;
|
||||
}
|
||||
})
|
||||
} else {
|
||||
@@ -594,6 +610,7 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
||||
input: String::new(),
|
||||
output: output.clone(),
|
||||
done: true,
|
||||
failed: *is_error,
|
||||
asks: Vec::new(),
|
||||
images: Vec::new(),
|
||||
});
|
||||
@@ -650,6 +667,7 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
||||
input,
|
||||
output,
|
||||
done,
|
||||
failed,
|
||||
images,
|
||||
} if asks.iter().any(|a| &a.id == id) => {
|
||||
for ask in asks.iter_mut() {
|
||||
@@ -665,6 +683,7 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
||||
input,
|
||||
output,
|
||||
done,
|
||||
failed,
|
||||
asks,
|
||||
images,
|
||||
}
|
||||
@@ -750,6 +769,74 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -989,6 +1076,7 @@ mod tests {
|
||||
Event::ToolEnd {
|
||||
id: "x".to_string(),
|
||||
output: "done".to_string(),
|
||||
is_error: false,
|
||||
},
|
||||
)]);
|
||||
assert_eq!(
|
||||
@@ -1001,6 +1089,7 @@ mod tests {
|
||||
input: String::new(),
|
||||
output: "done".to_string(),
|
||||
done: true,
|
||||
failed: false,
|
||||
asks: Vec::new(),
|
||||
images: Vec::new(),
|
||||
}]
|
||||
@@ -1166,6 +1255,7 @@ mod tests {
|
||||
Event::ToolEnd {
|
||||
id: id.to_string(),
|
||||
output: output.to_string(),
|
||||
is_error: false,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -1211,6 +1301,7 @@ mod tests {
|
||||
input: "{}".to_string(),
|
||||
output: "the result".to_string(),
|
||||
done: true,
|
||||
failed: false,
|
||||
asks: Vec::new(),
|
||||
images: Vec::new(),
|
||||
}],
|
||||
@@ -1269,6 +1360,7 @@ mod tests {
|
||||
input: "{}".to_string(),
|
||||
output: String::new(),
|
||||
done: false,
|
||||
failed: false,
|
||||
asks: Vec::new(),
|
||||
images: Vec::new(),
|
||||
}];
|
||||
@@ -1279,3 +1371,150 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user