diff --git a/crates/maestro-hooks/src/lib.rs b/crates/maestro-hooks/src/lib.rs index 1b70b11..fcd4359 100644 --- a/crates/maestro-hooks/src/lib.rs +++ b/crates/maestro-hooks/src/lib.rs @@ -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 diff --git a/crates/maestro-hooks/src/tests.rs b/crates/maestro-hooks/src/tests.rs index 0d79533..6f73169 100644 --- a/crates/maestro-hooks/src/tests.rs +++ b/crates/maestro-hooks/src/tests.rs @@ -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));