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
@@ -700,6 +700,7 @@ impl Translator {
|
||||
events.push(Event::ToolEnd {
|
||||
id: about.clone(),
|
||||
output: texts.join("\n"),
|
||||
is_error: crate::session::import::tool_result_is_error(block),
|
||||
});
|
||||
// Deliberately does *not* finish a subagent `about` might name:
|
||||
// the Task tool runs in the background by default, so this
|
||||
@@ -1015,7 +1016,44 @@ mod tests {
|
||||
},
|
||||
Event::ToolEnd {
|
||||
id: "toolu_01".to_string(),
|
||||
output: "probe-ok".to_string()
|
||||
output: "probe-ok".to_string(),
|
||||
is_error: false,
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// The other half of the test above, and the one it cannot stand in
|
||||
/// for: a call the tool itself reported as failed. Both lines are
|
||||
/// `tool_result`s and both carry output, so nothing but `is_error`
|
||||
/// tells them apart -- which is why dropping the field made a broken
|
||||
/// call draw exactly like one that worked.
|
||||
#[test]
|
||||
fn a_failed_tool_result_says_so() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
|
||||
let events = translate_lines(
|
||||
&mut translator,
|
||||
&[
|
||||
r#"{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_02","type":"tool_result","content":"No such file or directory","is_error":true}]},"parent_tool_use_id":null}"#,
|
||||
// No `is_error` at all: every transcript written before
|
||||
// the field was read looks like this, and it means the
|
||||
// call was not reported to have failed.
|
||||
r#"{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_03","type":"tool_result","content":"fine"}]},"parent_tool_use_id":null}"#,
|
||||
],
|
||||
);
|
||||
assert_eq!(
|
||||
events,
|
||||
vec![
|
||||
Event::ToolEnd {
|
||||
id: "toolu_02".to_string(),
|
||||
output: "No such file or directory".to_string(),
|
||||
is_error: true,
|
||||
},
|
||||
Event::ToolEnd {
|
||||
id: "toolu_03".to_string(),
|
||||
output: "fine".to_string(),
|
||||
is_error: false,
|
||||
},
|
||||
]
|
||||
);
|
||||
@@ -1459,6 +1497,7 @@ mod tests {
|
||||
Event::ToolEnd {
|
||||
id: "toolu_05".to_string(),
|
||||
output: "took a screenshot".to_string(),
|
||||
is_error: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -134,6 +134,7 @@ impl EchoDriver {
|
||||
self.emit(Event::ToolEnd {
|
||||
id,
|
||||
output: format!("{label} step {index} finished"),
|
||||
is_error: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -645,6 +646,7 @@ impl EchoDriver {
|
||||
send(Event::ToolEnd {
|
||||
id,
|
||||
output: format!("call {i} finished"),
|
||||
is_error: false,
|
||||
});
|
||||
}
|
||||
finish();
|
||||
@@ -698,6 +700,7 @@ impl EchoDriver {
|
||||
send(Event::ToolEnd {
|
||||
id,
|
||||
output: format!("ran: {command}"),
|
||||
is_error: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -717,6 +720,7 @@ impl EchoDriver {
|
||||
send(Event::ToolEnd {
|
||||
id,
|
||||
output: format!("echoed: {input}"),
|
||||
is_error: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -817,6 +821,7 @@ async fn write_beat(sink: &EventSink, session_dir: &Path, beat: usize) {
|
||||
send(Event::ToolEnd {
|
||||
id,
|
||||
output: format!("beat {beat}: forty-two lines of nothing in particular"),
|
||||
is_error: false,
|
||||
});
|
||||
}
|
||||
// A run of three, which the app folds into one collapsed group -- the
|
||||
@@ -829,9 +834,20 @@ async fn write_beat(sink: &EventSink, session_dir: &Path, beat: usize) {
|
||||
tool: if i % 2 == 0 { "Bash" } else { "Grep" }.to_string(),
|
||||
input: serde_json::json!({ "command": format!("grep -rn 'beat {beat}' /tmp") }),
|
||||
});
|
||||
// The middle one fails, so this fixture carries a run in
|
||||
// which the three calls are not all in the same state --
|
||||
// the case a card drawing every finished call the same way
|
||||
// looks correct on. `is_error` is the CLI's own field
|
||||
// (`import::tool_result_is_error`), and a driver that
|
||||
// never sets it makes the failed appearance unreachable
|
||||
// from the sandbox.
|
||||
send(Event::ToolEnd {
|
||||
id,
|
||||
output: format!("beat {beat}, call {i} of 3"),
|
||||
output: match i == 2 {
|
||||
true => format!("beat {beat}, call {i} of 3: No such file or directory"),
|
||||
false => format!("beat {beat}, call {i} of 3"),
|
||||
},
|
||||
is_error: i == 2,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -856,6 +872,7 @@ async fn write_beat(sink: &EventSink, session_dir: &Path, beat: usize) {
|
||||
send(Event::ToolEnd {
|
||||
id,
|
||||
output: format!("beat {beat}: captured"),
|
||||
is_error: false,
|
||||
});
|
||||
}
|
||||
// Somebody else's voice, which is its own row shape.
|
||||
@@ -904,6 +921,7 @@ async fn run_helper(id: String, sink: EventSink, subagents: Arc<Subagents>) {
|
||||
Event::ToolEnd {
|
||||
id: tool_id,
|
||||
output: "helper done".to_string(),
|
||||
is_error: false,
|
||||
},
|
||||
);
|
||||
let target = Duration::from_secs(3);
|
||||
@@ -915,6 +933,7 @@ async fn run_helper(id: String, sink: EventSink, subagents: Arc<Subagents>) {
|
||||
let _ = sink.send(Event::ToolEnd {
|
||||
id,
|
||||
output: "subagent finished".to_string(),
|
||||
is_error: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1069,6 +1088,7 @@ impl Driver for EchoDriver {
|
||||
self.emit(Event::ToolEnd {
|
||||
id: call,
|
||||
output: format!("answered: {answer}"),
|
||||
is_error: false,
|
||||
});
|
||||
// The work carries on where it left off, which is what makes the
|
||||
// asked-here row a boundary with a group on each side rather than
|
||||
|
||||
@@ -363,6 +363,22 @@ fn is_hidden(record: &Value) -> bool {
|
||||
|| record.get("isMeta").and_then(Value::as_bool) == Some(true)
|
||||
}
|
||||
|
||||
/// Whether a `tool_result` block says the call itself failed.
|
||||
///
|
||||
/// One reader for the field rather than one per caller: the live
|
||||
/// translator (`translate.rs`) and this replay of the CLI's own file look
|
||||
/// at the same block shape, and a call drawn as failed in one and as
|
||||
/// succeeded in the other would be the same conversation disagreeing with
|
||||
/// itself. Absent means "not reported to have failed" -- which is what the
|
||||
/// CLI writes for a call that went fine, and also what every transcript
|
||||
/// written before this field was read says.
|
||||
pub(crate) fn tool_result_is_error(block: &Value) -> bool {
|
||||
block
|
||||
.get("is_error")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn text_of(content: &Value) -> String {
|
||||
match content {
|
||||
Value::String(text) => text.clone(),
|
||||
@@ -549,6 +565,7 @@ fn push_user(events: &mut Vec<Event>, content: &Value, session_dir: &std::path::
|
||||
events.push(Event::ToolEnd {
|
||||
id: id.to_string(),
|
||||
output: text_of(block.get("content").unwrap_or(&Value::Null)),
|
||||
is_error: tool_result_is_error(block),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -744,6 +744,7 @@ mod tests {
|
||||
Event::ToolEnd {
|
||||
id: "t1".into(),
|
||||
output: "done".into(),
|
||||
is_error: false,
|
||||
},
|
||||
Event::Image {
|
||||
image: "img1".into(),
|
||||
|
||||
Reference in new issue
Block a user