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>
245 lines
9.4 KiB
Rust
245 lines
9.4 KiB
Rust
//! A tool call's input, read rather than dumped -- the port of
|
|
//! `ToolInput.kt`'s `parseToolInput`, which is what both the collapsed
|
|
//! card's one-line summary and the expanded card's key/value list are
|
|
//! derived from.
|
|
//!
|
|
//! Every tool's input arrives as JSON, and showing it raw makes the reader
|
|
//! parse `{"command":"…","timeout":120000}` themselves to find the one
|
|
//! line they care about. So the fields that carry the meaning are pulled
|
|
//! out, and anything left over is still shown, because dropping a field
|
|
//! would be claiming the tool has no other input when it might.
|
|
//!
|
|
//! Pure, and here rather than in the widget crate, for the reason the rest
|
|
//! of this crate exists: the derivation is the same on a phone and on a
|
|
//! desktop, and it is testable without a renderer.
|
|
|
|
use crate::durations::format_millis_text;
|
|
use crate::highlight::Language;
|
|
use serde_json::{Map, Value};
|
|
|
|
/// A tool call's input, split into the parts a card draws separately.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
|
pub struct ToolInput {
|
|
/// The thing that will actually be run or read, if this tool has one.
|
|
pub subject: Option<String>,
|
|
/// The language [`ToolInput::subject`] is written in, for
|
|
/// highlighting.
|
|
pub language: Option<Language>,
|
|
/// The tool's own one-line summary, when it wrote one.
|
|
pub description: Option<String>,
|
|
/// How long the call may take, in the largest units it fits. Shown
|
|
/// apart because it is a limit on the call rather than part of what
|
|
/// the call does.
|
|
pub timeout: Option<String>,
|
|
/// Everything else, as `name: value` lines. Never dropped.
|
|
pub rest: Vec<String>,
|
|
}
|
|
|
|
impl ToolInput {
|
|
/// The one line to show when there is only room for one: what this
|
|
/// call is for.
|
|
pub fn title(&self) -> Option<&str> {
|
|
self.description
|
|
.as_deref()
|
|
.or(self.subject.as_deref())
|
|
// A subject that is only whitespace would draw as an empty
|
|
// summary line, which reads as a tool with nothing to say
|
|
// rather than as one whose subject was blank.
|
|
.filter(|t| !t.trim().is_empty())
|
|
}
|
|
}
|
|
|
|
/// Which field of which tool is the subject.
|
|
///
|
|
/// A table rather than a chain of `if`s: adding a tool is a row, and the
|
|
/// shape stops any of them from being the special case that gets its own
|
|
/// code path. Unknown tools fall through to "no subject, everything is
|
|
/// rest".
|
|
const SUBJECTS: &[(&str, &str, Option<Language>)] = &[
|
|
("Bash", "command", Some(Language::Shell)),
|
|
("Read", "file_path", None),
|
|
("Write", "file_path", None),
|
|
("Edit", "file_path", None),
|
|
("Glob", "pattern", None),
|
|
("Grep", "pattern", None),
|
|
("WebFetch", "url", None),
|
|
];
|
|
|
|
/// Fields that are the tool's own prose about itself rather than input to
|
|
/// it.
|
|
const DESCRIPTIONS: &[&str] = &["description", "prompt"];
|
|
|
|
/// One JSON value as the Kotlin's `JSONObject.optString`/`get` wrote it: a
|
|
/// string is its own characters, anything else is its JSON form.
|
|
///
|
|
/// One function rather than two, because the same coercion decides both
|
|
/// what a subject reads as and what a leftover field's value reads as, and
|
|
/// two copies would eventually disagree about a number.
|
|
fn as_text(value: &Value) -> String {
|
|
match value {
|
|
Value::String(s) => s.clone(),
|
|
other => other.to_string(),
|
|
}
|
|
}
|
|
|
|
fn non_blank(value: Option<&Value>) -> Option<String> {
|
|
let text = as_text(value?);
|
|
(!text.trim().is_empty()).then_some(text)
|
|
}
|
|
|
|
/// Split `input` (a tool call's JSON) into the parts a card draws.
|
|
///
|
|
/// Input that is not a JSON object -- older transcripts and some tools
|
|
/// send a bare string -- is still the input, so it is still shown, as the
|
|
/// whole of `rest`.
|
|
pub fn parse_tool_input(tool: &str, input: &str) -> ToolInput {
|
|
let Ok(Value::Object(json)) = serde_json::from_str::<Value>(input) else {
|
|
return ToolInput {
|
|
rest: match input.trim().is_empty() {
|
|
true => Vec::new(),
|
|
false => vec![input.to_string()],
|
|
},
|
|
..ToolInput::default()
|
|
};
|
|
};
|
|
parse_object(tool, &json)
|
|
}
|
|
|
|
fn parse_object(tool: &str, json: &Map<String, Value>) -> ToolInput {
|
|
let (subject_key, language) = SUBJECTS
|
|
.iter()
|
|
.find(|(name, ..)| *name == tool)
|
|
.map(|(_, key, language)| (Some(*key), *language))
|
|
.unwrap_or((None, None));
|
|
let subject = subject_key.and_then(|key| non_blank(json.get(key)));
|
|
let description = DESCRIPTIONS
|
|
.iter()
|
|
.find_map(|key| non_blank(json.get(*key)));
|
|
let timeout = non_blank(json.get("timeout")).map(|t| format_millis_text(&t));
|
|
|
|
// Sorted, so the leftovers are in the same order every time this call
|
|
// is drawn rather than in whatever order the JSON happened to arrive
|
|
// in. A field is left out only when it is already drawn somewhere
|
|
// else on the card.
|
|
let mut keys: Vec<&String> = json
|
|
.keys()
|
|
.filter(|k| Some(k.as_str()) != subject_key || subject.is_none())
|
|
.filter(|k| !DESCRIPTIONS.contains(&k.as_str()) || description.is_none())
|
|
.filter(|k| k.as_str() != "timeout" || timeout.is_none())
|
|
.collect();
|
|
keys.sort();
|
|
let rest = keys
|
|
.into_iter()
|
|
.map(|key| format!("{key}: {}", as_text(&json[key])))
|
|
.collect();
|
|
|
|
ToolInput {
|
|
subject,
|
|
language,
|
|
description,
|
|
timeout,
|
|
rest,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn each_tool_in_the_table_has_its_own_subject() {
|
|
// One assertion per row of `SUBJECTS`, because the table is the
|
|
// whole of the rule and a row lost in an edit would otherwise
|
|
// only show up as a card with no summary line.
|
|
let cases = [
|
|
("Bash", r#"{"command":"ls -la"}"#, "ls -la"),
|
|
("Read", r#"{"file_path":"/tmp/x.rs"}"#, "/tmp/x.rs"),
|
|
("Write", r#"{"file_path":"/tmp/y.rs"}"#, "/tmp/y.rs"),
|
|
("Edit", r#"{"file_path":"/tmp/z.rs"}"#, "/tmp/z.rs"),
|
|
("Glob", r#"{"pattern":"**/*.rs"}"#, "**/*.rs"),
|
|
("Grep", r#"{"pattern":"fn main"}"#, "fn main"),
|
|
("WebFetch", r#"{"url":"https://x/y"}"#, "https://x/y"),
|
|
];
|
|
for (tool, input, expected) in cases {
|
|
let parsed = parse_tool_input(tool, input);
|
|
assert_eq!(parsed.subject.as_deref(), Some(expected), "{tool}");
|
|
assert_eq!(parsed.title(), Some(expected), "{tool}");
|
|
assert!(parsed.rest.is_empty(), "{tool}: {:?}", parsed.rest);
|
|
}
|
|
assert_eq!(
|
|
parse_tool_input("Bash", r#"{"command":"ls"}"#).language,
|
|
Some(Language::Shell),
|
|
"a Bash command is shell, and is the one row that names a language"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_tools_own_description_is_what_the_one_line_says() {
|
|
// The description wins over the subject: it is the tool's own
|
|
// prose about what this call is for, which is what a reader
|
|
// scanning a collapsed run is looking for.
|
|
let parsed = parse_tool_input(
|
|
"Bash",
|
|
r#"{"command":"cargo test -p iris","description":"Run the iris tests"}"#,
|
|
);
|
|
assert_eq!(parsed.title(), Some("Run the iris tests"));
|
|
assert_eq!(parsed.subject.as_deref(), Some("cargo test -p iris"));
|
|
assert!(parsed.rest.is_empty(), "{:?}", parsed.rest);
|
|
}
|
|
|
|
#[test]
|
|
fn a_timeout_is_read_as_a_span_and_kept_apart_from_the_rest() {
|
|
let parsed = parse_tool_input("Bash", r#"{"command":"sleep 500","timeout":480000}"#);
|
|
assert_eq!(parsed.timeout.as_deref(), Some("8m"));
|
|
assert!(parsed.rest.is_empty(), "{:?}", parsed.rest);
|
|
}
|
|
|
|
#[test]
|
|
fn every_field_not_drawn_elsewhere_is_still_shown() {
|
|
// The half the "never dropped" promise is about: a tool this
|
|
// build has never heard of has no subject, so *everything* is
|
|
// rest -- and a known tool's extra fields are too.
|
|
let parsed = parse_tool_input(
|
|
"Edit",
|
|
r#"{"file_path":"/a.rs","old_string":"x","new_string":"y","replace_all":true}"#,
|
|
);
|
|
assert_eq!(
|
|
parsed.rest,
|
|
vec![
|
|
"new_string: y".to_string(),
|
|
"old_string: x".to_string(),
|
|
"replace_all: true".to_string(),
|
|
],
|
|
"sorted, and a non-string value written as JSON"
|
|
);
|
|
let unknown = parse_tool_input("SomeNewTool", r#"{"b":2,"a":"one"}"#);
|
|
assert_eq!(unknown.subject, None);
|
|
assert_eq!(unknown.rest, vec!["a: one".to_string(), "b: 2".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn input_that_is_not_an_object_is_still_the_input() {
|
|
// Older transcripts and some tools send a bare string; a card
|
|
// that dropped it would claim the call had no input at all.
|
|
assert_eq!(
|
|
parse_tool_input("Bash", "just a string").rest,
|
|
vec!["just a string".to_string()]
|
|
);
|
|
assert_eq!(parse_tool_input("Bash", " ").rest, Vec::<String>::new());
|
|
assert_eq!(parse_tool_input("Bash", "").title(), None);
|
|
}
|
|
|
|
#[test]
|
|
fn a_blank_subject_is_no_subject_rather_than_an_empty_summary_line() {
|
|
let parsed = parse_tool_input("Bash", r#"{"command":" ","other":1}"#);
|
|
assert_eq!(parsed.subject, None);
|
|
assert_eq!(parsed.title(), None);
|
|
// Not dropped just because it was blank -- it is still a field
|
|
// the call carried.
|
|
assert_eq!(
|
|
parsed.rest,
|
|
vec!["command: ".to_string(), "other: 1".to_string()]
|
|
);
|
|
}
|
|
}
|