generated from nhcarrigan/template
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:
@@ -300,6 +300,16 @@ pub struct ElicitationResultEvent {
|
||||
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)]
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
+144
-2
@@ -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<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.
|
||||
/// Handles escape sequences within the quoted value.
|
||||
fn extract_quoted_value(line: &str, key: &str) -> Option<String> {
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user