From 6c853ae73db3581b8a7b812efe78ed6574f7dbf7 Mon Sep 17 00:00:00 2001 From: Hikari Date: Fri, 20 Mar 2026 09:30:17 -0700 Subject: [PATCH] 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. --- src-tauri/src/types.rs | 52 ++++++++++++ src-tauri/src/wsl_bridge.rs | 146 +++++++++++++++++++++++++++++++++- src/lib/stores/toasts.test.ts | 27 +++++++ src/lib/stores/toasts.ts | 9 ++- src/lib/tauri.ts | 19 +++++ src/lib/types/messages.ts | 6 ++ 6 files changed, 256 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index d603b47..aa9223d 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -300,6 +300,16 @@ pub struct ElicitationResultEvent { pub conversation_id: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StopFailureEvent { + #[serde(skip_serializing_if = "Option::is_none")] + pub stop_reason: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub conversation_id: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AgentStartEvent { pub tool_use_id: String, @@ -645,4 +655,46 @@ mod tests { assert!(!serialized.contains("request_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")); + } } diff --git a/src-tauri/src/wsl_bridge.rs b/src-tauri/src/wsl_bridge.rs index 240dc7d..410725a 100644 --- a/src-tauri/src/wsl_bridge.rs +++ b/src-tauri/src/wsl_bridge.rs @@ -17,8 +17,8 @@ use crate::types::{ AgentEndEvent, AgentStartEvent, CharacterState, ClaudeMessage, ConnectionEvent, ConnectionStatus, ContentBlock, ElicitationEvent, ElicitationResultEvent, MessageCost, OutputEvent, PermissionPromptEvent, PermissionPromptEventItem, QuestionOption, SessionEvent, - StateChangeEvent, TodoItem, TodoUpdateEvent, UserQuestionEvent, WorkingDirectoryEvent, - WorktreeEvent, WorktreeInfo, + StateChangeEvent, StopFailureEvent, TodoItem, TodoUpdateEvent, UserQuestionEvent, + WorkingDirectoryEvent, WorktreeEvent, WorktreeInfo, }; use parking_lot::RwLock; use std::cell::RefCell; @@ -1054,6 +1054,7 @@ fn handle_stderr( let is_worktree_remove = line.contains("[WorktreeRemove Hook]"); let is_elicitation = line.contains("[Elicitation 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 { "worktree" @@ -1061,6 +1062,8 @@ fn handle_stderr( "config-change" } else if is_elicitation || is_elicitation_result { "elicitation" + } else if is_stop_failure { + "error" } else { "error" }; @@ -1153,6 +1156,30 @@ fn handle_stderr( 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 { let _ = app.emit( "claude:output", @@ -1328,6 +1355,34 @@ fn parse_elicitation_result_hook(line: &str) -> ElicitationResultData { ElicitationResultData { action, request_id } } +#[derive(Debug)] +struct StopFailureData { + stop_reason: Option, + error_type: Option, +} + +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. /// Handles escape sequences within the quoted value. fn extract_quoted_value(line: &str, key: &str) -> Option { @@ -3378,4 +3433,91 @@ mod tests { assert_eq!(data.action, "cancel"); 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" + ); + } } diff --git a/src/lib/stores/toasts.test.ts b/src/lib/stores/toasts.test.ts index 113baed..0066e37 100644 --- a/src/lib/stores/toasts.test.ts +++ b/src/lib/stores/toasts.test.ts @@ -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", () => { it("adds a persistent update toast with the correct fields", () => { toastStore.addUpdate("2.0.0", "1.9.0", "https://example.com/release"); diff --git a/src/lib/stores/toasts.ts b/src/lib/stores/toasts.ts index 897d357..423b518 100644 --- a/src/lib/stores/toasts.ts +++ b/src/lib/stores/toasts.ts @@ -68,6 +68,13 @@ function createToastStore() { 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"]) { const id = crypto.randomUUID(); const toast: AchievementToast = { id, kind: "achievement", achievement }; @@ -82,7 +89,7 @@ function createToastStore() { // Update toasts are persistent — no auto-dismiss } - return { subscribe, addInfo, addAchievement, addUpdate, remove }; + return { subscribe, addInfo, addError, addAchievement, addUpdate, remove }; } export const toastStore = createToastStore(); diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index 7684450..6b2a884 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -10,6 +10,7 @@ import type { ConnectionStatus, ElicitationEvent, PermissionPromptEvent, + StopFailureEvent, UserQuestionEvent, } from "$lib/types/messages"; import type { CharacterState } from "$lib/types/states"; @@ -640,6 +641,24 @@ export async function initializeTauriListeners() { } ); unlisteners.push(elicitationResultUnlisten); + + const stopFailureUnlisten = await listen("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() { diff --git a/src/lib/types/messages.ts b/src/lib/types/messages.ts index bc2387b..0012cab 100644 --- a/src/lib/types/messages.ts +++ b/src/lib/types/messages.ts @@ -176,6 +176,12 @@ export interface ElicitationResultEvent { conversation_id?: string; } +export interface StopFailureEvent { + stop_reason?: string; + error_type?: string; + conversation_id?: string; +} + export type ConnectionStatus = "disconnected" | "connecting" | "connected" | "error"; export interface Attachment {