feat(maestro): report StopFailure as Error with the error spelling as detail

This commit is contained in:
Miguel Palhas
2026-08-24 22:54:21 +01:00
parent 2a43c76176
commit 6471da2624
2 changed files with 55 additions and 5 deletions
+20 -5
View File
@@ -27,8 +27,11 @@ pub const SCRIPT_FILE_NAME: &str = "maestro-claude-hook.sh";
/// Claude Code hook event → `ActivityState` spelling posted to the daemon.
///
/// `PreToolUse` is what makes a session `Active` *during* a long tool call —
/// `PostToolUse` alone only fires once it ends (#114).
pub const HOOK_EVENTS: [(&str, &str); 4] = [
/// `PostToolUse` alone only fires once it ends (#114). `StopFailure` is
/// Claude Code's own name for "the turn ended on an API error"; there is no
/// failure signal in `Stop` or `SessionEnd` payloads, so those never report
/// `Error`.
pub const HOOK_EVENTS: [(&str, &str); 5] = [
// Tool starting: the agent is demonstrably producing work, even if the
// tool itself runs for minutes.
("PreToolUse", "Active"),
@@ -38,6 +41,9 @@ pub const HOOK_EVENTS: [(&str, &str); 4] = [
("Notification", "AwaitingInput"),
// Turn ended: waiting on the user again.
("Stop", "AwaitingInput"),
// Turn ended on an API error; the payload's `error` field is forwarded
// as the report's `detail` (#114).
("StopFailure", "Error"),
];
/// The shipped hook script. POSIX sh so any `/bin/sh` works.
@@ -49,14 +55,23 @@ pub const HOOK_SCRIPT: &str = r#"#!/bin/sh
# PostToolUse -> Active (tool finished)
# Notification -> AwaitingInput (permission / idle notice)
# Stop -> AwaitingInput (turn ended; waiting on the user)
# StopFailure -> Error (turn ended on an API error)
state="$1"
[ -n "$MAESTRO_SESSION_ID" ] || exit 0
[ -n "$MAESTRO_URL" ] || exit 0
# Drain the hook payload so the parent never sees a broken pipe.
cat >/dev/null 2>&1
payload=$(cat 2>/dev/null) || true
body="{\"state\":\"$state\"}"
# StopFailure payloads carry the outcome in "error". Only a plain enum
# spelling is reported as the detail — anything else (quotes, backslashes,
# prose) would risk breaking the JSON body, and the spelling already names
# the failure.
detail=$(printf '%s' "$payload" | sed -n 's/.*"error"[[:space:]]*:[[:space:]]*"\([a-z_]*\)".*/\1/p')
if [ -n "$detail" ]; then
body="{\"state\":\"$state\",\"detail\":\"$detail\"}"
fi
curl -fsS --connect-timeout 1 --max-time 2 \
-H 'Content-Type: application/json' \
-d "{\"state\":\"$state\"}" \
-d "$body" \
"$MAESTRO_URL/api/sessions/$MAESTRO_SESSION_ID/activity" \
>/dev/null 2>&1 || true
exit 0
+35
View File
@@ -104,6 +104,40 @@ fn event_state_mapping_matches_the_contract() {
assert_eq!(map["PostToolUse"], "Active");
assert_eq!(map["Notification"], "AwaitingInput");
assert_eq!(map["Stop"], "AwaitingInput");
assert_eq!(map["StopFailure"], "Error");
}
/// A `StopFailure` payload names the API error that ended the turn; the
/// script reports its `error` spelling as the report's `detail` so a card
/// can show why (#114).
#[test]
fn script_reports_the_error_spelling_as_detail() {
let request = capture_request(|url| {
sh(
HOOK_SCRIPT,
&["Error"],
&owned_envs(&session_envs(&url)),
br#"{"session_id":"abc","hook_event_name":"StopFailure","error":"rate_limit","error_details":"429 Too Many Requests"}"#,
)
});
assert!(request.contains(r#""state":"Error""#), "{request}");
assert!(request.contains(r#""detail":"rate_limit""#), "{request}");
}
/// An `error` value that is not a plain enum spelling (or a payload without
/// one at all) must never produce a broken body — no detail is posted.
#[test]
fn script_posts_no_detail_for_an_unexpected_error_value() {
let request = capture_request(|url| {
sh(
HOOK_SCRIPT,
&["Error"],
&owned_envs(&session_envs(&url)),
br#"{"hook_event_name":"StopFailure","error":"429 \"Too Many\" \\ requests"}"#,
)
});
assert!(request.contains(r#""state":"Error""#), "{request}");
assert!(!request.contains("detail"), "{request}");
}
/// Events whose payload carries no error fields post a bare body — never an
@@ -171,6 +205,7 @@ fn fresh_install_registers_every_event() {
assert_eq!(cmds.len(), HOOK_EVENTS.len());
let joined = cmds.join("\n");
assert_eq!(joined.matches("Active").count(), 2);
assert!(joined.contains("Error"));
assert_eq!(joined.matches("AwaitingInput").count(), 2);
for cmd in &cmds {
assert!(cmd.contains(SCRIPT_FILE_NAME));