feat: handle StopFailure hook event for API error turns (#224)

Parses [StopFailure Hook] from stderr, emits claude:stop-failure, and
transitions the character to error state with a toast notification.
This commit is contained in:
2026-03-20 09:30:17 -07:00
committed by Naomi Carrigan
parent efdc7af58a
commit 6c853ae73d
6 changed files with 256 additions and 3 deletions
+52
View File
@@ -300,6 +300,16 @@ pub struct ElicitationResultEvent {
pub conversation_id: Option<String>, pub conversation_id: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StopFailureEvent {
#[serde(skip_serializing_if = "Option::is_none")]
pub stop_reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub conversation_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentStartEvent { pub struct AgentStartEvent {
pub tool_use_id: String, pub tool_use_id: String,
@@ -645,4 +655,46 @@ mod tests {
assert!(!serialized.contains("request_id")); assert!(!serialized.contains("request_id"));
assert!(!serialized.contains("conversation_id")); assert!(!serialized.contains("conversation_id"));
} }
#[test]
fn test_stop_failure_event_serialization() {
let event = StopFailureEvent {
stop_reason: Some("api_error".to_string()),
error_type: Some("rate_limit".to_string()),
conversation_id: Some("conv-abc".to_string()),
};
let serialized = serde_json::to_string(&event).unwrap();
assert!(serialized.contains("\"stop_reason\":\"api_error\""));
assert!(serialized.contains("\"error_type\":\"rate_limit\""));
assert!(serialized.contains("\"conversation_id\":\"conv-abc\""));
}
#[test]
fn test_stop_failure_event_omits_none_fields() {
let event = StopFailureEvent {
stop_reason: None,
error_type: None,
conversation_id: None,
};
let serialized = serde_json::to_string(&event).unwrap();
assert!(!serialized.contains("stop_reason"));
assert!(!serialized.contains("error_type"));
assert!(!serialized.contains("conversation_id"));
}
#[test]
fn test_stop_failure_event_partial_fields() {
let event = StopFailureEvent {
stop_reason: Some("api_error".to_string()),
error_type: None,
conversation_id: None,
};
let serialized = serde_json::to_string(&event).unwrap();
assert!(serialized.contains("\"stop_reason\":\"api_error\""));
assert!(!serialized.contains("error_type"));
assert!(!serialized.contains("conversation_id"));
}
} }
+144 -2
View File
@@ -17,8 +17,8 @@ use crate::types::{
AgentEndEvent, AgentStartEvent, CharacterState, ClaudeMessage, ConnectionEvent, AgentEndEvent, AgentStartEvent, CharacterState, ClaudeMessage, ConnectionEvent,
ConnectionStatus, ContentBlock, ElicitationEvent, ElicitationResultEvent, MessageCost, ConnectionStatus, ContentBlock, ElicitationEvent, ElicitationResultEvent, MessageCost,
OutputEvent, PermissionPromptEvent, PermissionPromptEventItem, QuestionOption, SessionEvent, OutputEvent, PermissionPromptEvent, PermissionPromptEventItem, QuestionOption, SessionEvent,
StateChangeEvent, TodoItem, TodoUpdateEvent, UserQuestionEvent, WorkingDirectoryEvent, StateChangeEvent, StopFailureEvent, TodoItem, TodoUpdateEvent, UserQuestionEvent,
WorktreeEvent, WorktreeInfo, WorkingDirectoryEvent, WorktreeEvent, WorktreeInfo,
}; };
use parking_lot::RwLock; use parking_lot::RwLock;
use std::cell::RefCell; use std::cell::RefCell;
@@ -1054,6 +1054,7 @@ fn handle_stderr(
let is_worktree_remove = line.contains("[WorktreeRemove Hook]"); let is_worktree_remove = line.contains("[WorktreeRemove Hook]");
let is_elicitation = line.contains("[Elicitation Hook]"); let is_elicitation = line.contains("[Elicitation Hook]");
let is_elicitation_result = line.contains("[ElicitationResult Hook]"); let is_elicitation_result = line.contains("[ElicitationResult Hook]");
let is_stop_failure = line.contains("[StopFailure Hook]");
let line_type = if is_worktree_create || is_worktree_remove { let line_type = if is_worktree_create || is_worktree_remove {
"worktree" "worktree"
@@ -1061,6 +1062,8 @@ fn handle_stderr(
"config-change" "config-change"
} else if is_elicitation || is_elicitation_result { } else if is_elicitation || is_elicitation_result {
"elicitation" "elicitation"
} else if is_stop_failure {
"error"
} else { } else {
"error" "error"
}; };
@@ -1153,6 +1156,30 @@ fn handle_stderr(
parent_tool_use_id: None, parent_tool_use_id: None,
}, },
); );
} else if is_stop_failure {
let data = parse_stop_failure_hook(&line);
let friendly_content = build_stop_failure_message(&data);
let _ = app.emit(
"claude:stop-failure",
StopFailureEvent {
stop_reason: data.stop_reason,
error_type: data.error_type,
conversation_id: conversation_id.clone(),
},
);
let _ = app.emit(
"claude:output",
OutputEvent {
line_type: "error".to_string(),
content: friendly_content,
tool_name: None,
conversation_id: conversation_id.clone(),
cost: None,
parent_tool_use_id: None,
},
);
} else { } else {
let _ = app.emit( let _ = app.emit(
"claude:output", "claude:output",
@@ -1328,6 +1355,34 @@ fn parse_elicitation_result_hook(line: &str) -> ElicitationResultData {
ElicitationResultData { action, request_id } ElicitationResultData { action, request_id }
} }
#[derive(Debug)]
struct StopFailureData {
stop_reason: Option<String>,
error_type: Option<String>,
}
fn parse_stop_failure_hook(line: &str) -> StopFailureData {
let stop_reason = extract_quoted_value(line, "stop_reason");
let error_type = extract_debug_string_value(line, "error_type");
StopFailureData { stop_reason, error_type }
}
/// Builds a user-friendly message from a `StopFailureData` instance.
fn build_stop_failure_message(data: &StopFailureData) -> String {
match data.stop_reason.as_deref() {
Some("rate_limit") => "Session stopped: rate limit reached".to_string(),
Some("auth_failure") | Some("authentication") => {
"Session stopped: authentication failed".to_string()
}
Some(reason) => format!("Session stopped due to API error: {}", reason),
None => match data.error_type.as_deref() {
Some(et) => format!("Session stopped due to API error: {}", et),
None => "Session stopped due to an unknown API error".to_string(),
},
}
}
/// Extracts a double-quoted string value from a `key="value"` pair in a hook line. /// Extracts a double-quoted string value from a `key="value"` pair in a hook line.
/// Handles escape sequences within the quoted value. /// Handles escape sequences within the quoted value.
fn extract_quoted_value(line: &str, key: &str) -> Option<String> { fn extract_quoted_value(line: &str, key: &str) -> Option<String> {
@@ -3378,4 +3433,91 @@ mod tests {
assert_eq!(data.action, "cancel"); assert_eq!(data.action, "cancel");
assert_eq!(data.request_id, None); assert_eq!(data.request_id, None);
} }
#[test]
fn test_parse_stop_failure_hook_with_all_fields() {
let line = r#"[StopFailure Hook] stop_reason="api_error", error_type=Some("rate_limit"), conversation_id=Some("conv-123")"#;
let data = parse_stop_failure_hook(line);
assert_eq!(data.stop_reason, Some("api_error".to_string()));
assert_eq!(data.error_type, Some("rate_limit".to_string()));
}
#[test]
fn test_parse_stop_failure_hook_missing_optional_error_type() {
let line = r#"[StopFailure Hook] stop_reason="api_error", error_type=None"#;
let data = parse_stop_failure_hook(line);
assert_eq!(data.stop_reason, Some("api_error".to_string()));
assert_eq!(data.error_type, None);
}
#[test]
fn test_parse_stop_failure_hook_invalid_line() {
let line = "[StopFailure Hook] some unstructured data";
let data = parse_stop_failure_hook(line);
assert_eq!(data.stop_reason, None);
assert_eq!(data.error_type, None);
}
#[test]
fn test_build_stop_failure_message_rate_limit() {
let data = StopFailureData {
stop_reason: Some("rate_limit".to_string()),
error_type: None,
};
assert_eq!(build_stop_failure_message(&data), "Session stopped: rate limit reached");
}
#[test]
fn test_build_stop_failure_message_auth_failure() {
let data = StopFailureData {
stop_reason: Some("auth_failure".to_string()),
error_type: None,
};
assert_eq!(build_stop_failure_message(&data), "Session stopped: authentication failed");
}
#[test]
fn test_build_stop_failure_message_authentication() {
let data = StopFailureData {
stop_reason: Some("authentication".to_string()),
error_type: None,
};
assert_eq!(build_stop_failure_message(&data), "Session stopped: authentication failed");
}
#[test]
fn test_build_stop_failure_message_unknown_reason() {
let data = StopFailureData {
stop_reason: Some("server_error".to_string()),
error_type: None,
};
assert_eq!(
build_stop_failure_message(&data),
"Session stopped due to API error: server_error"
);
}
#[test]
fn test_build_stop_failure_message_no_reason_with_error_type() {
let data = StopFailureData {
stop_reason: None,
error_type: Some("timeout".to_string()),
};
assert_eq!(
build_stop_failure_message(&data),
"Session stopped due to API error: timeout"
);
}
#[test]
fn test_build_stop_failure_message_no_fields() {
let data = StopFailureData {
stop_reason: None,
error_type: None,
};
assert_eq!(
build_stop_failure_message(&data),
"Session stopped due to an unknown API error"
);
}
} }
+27
View File
@@ -187,6 +187,33 @@ describe("toastStore", () => {
}); });
}); });
describe("addError", () => {
it("adds an error toast with the warning icon", () => {
toastStore.addError("Something went wrong");
const toasts = get(toastStore);
expect(toasts).toHaveLength(1);
const toast = toasts[0];
expect(toast.kind).toBe("info");
if (toast.kind === "info") {
expect(toast.message).toBe("Something went wrong");
expect(toast.icon).toBe("⚠️");
expect(typeof toast.id).toBe("string");
expect(toast.id.length).toBeGreaterThan(0);
}
});
it("auto-dismisses after 6000ms", () => {
toastStore.addError("Rate limit reached");
expect(get(toastStore)).toHaveLength(1);
vi.advanceTimersByTime(5999);
expect(get(toastStore)).toHaveLength(1);
vi.advanceTimersByTime(1);
expect(get(toastStore)).toHaveLength(0);
});
});
describe("addUpdate", () => { describe("addUpdate", () => {
it("adds a persistent update toast with the correct fields", () => { it("adds a persistent update toast with the correct fields", () => {
toastStore.addUpdate("2.0.0", "1.9.0", "https://example.com/release"); toastStore.addUpdate("2.0.0", "1.9.0", "https://example.com/release");
+8 -1
View File
@@ -68,6 +68,13 @@ function createToastStore() {
setTimeout(() => remove(id), 4000); setTimeout(() => remove(id), 4000);
} }
function addError(message: string) {
const id = crypto.randomUUID();
const toast: InfoToast = { id, kind: "info", message, icon: "⚠️" };
update((toasts) => [...toasts, toast]);
setTimeout(() => remove(id), 6000);
}
function addAchievement(achievement: AchievementUnlockedEvent["achievement"]) { function addAchievement(achievement: AchievementUnlockedEvent["achievement"]) {
const id = crypto.randomUUID(); const id = crypto.randomUUID();
const toast: AchievementToast = { id, kind: "achievement", achievement }; const toast: AchievementToast = { id, kind: "achievement", achievement };
@@ -82,7 +89,7 @@ function createToastStore() {
// Update toasts are persistent — no auto-dismiss // Update toasts are persistent — no auto-dismiss
} }
return { subscribe, addInfo, addAchievement, addUpdate, remove }; return { subscribe, addInfo, addError, addAchievement, addUpdate, remove };
} }
export const toastStore = createToastStore(); export const toastStore = createToastStore();
+19
View File
@@ -10,6 +10,7 @@ import type {
ConnectionStatus, ConnectionStatus,
ElicitationEvent, ElicitationEvent,
PermissionPromptEvent, PermissionPromptEvent,
StopFailureEvent,
UserQuestionEvent, UserQuestionEvent,
} from "$lib/types/messages"; } from "$lib/types/messages";
import type { CharacterState } from "$lib/types/states"; import type { CharacterState } from "$lib/types/states";
@@ -640,6 +641,24 @@ export async function initializeTauriListeners() {
} }
); );
unlisteners.push(elicitationResultUnlisten); unlisteners.push(elicitationResultUnlisten);
const stopFailureUnlisten = await listen<StopFailureEvent>("claude:stop-failure", (event) => {
const { stop_reason, error_type } = event.payload;
characterState.setTemporaryState("error", 3000);
let message: string;
if (stop_reason === "rate_limit") {
message = "Rate limit reached";
} else if (stop_reason === "auth_failure" || stop_reason === "authentication") {
message = "Authentication failed";
} else {
message = `API error: ${stop_reason ?? error_type ?? "unknown"}`;
}
toastStore.addError(message);
});
unlisteners.push(stopFailureUnlisten);
} }
export function cleanupTauriListeners() { export function cleanupTauriListeners() {
+6
View File
@@ -176,6 +176,12 @@ export interface ElicitationResultEvent {
conversation_id?: string; conversation_id?: string;
} }
export interface StopFailureEvent {
stop_reason?: string;
error_type?: string;
conversation_id?: string;
}
export type ConnectionStatus = "disconnected" | "connecting" | "connected" | "error"; export type ConnectionStatus = "disconnected" | "connecting" | "connected" | "error";
export interface Attachment { export interface Attachment {