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>,
|
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
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user