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
+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", () => {
it("adds a persistent update toast with the correct fields", () => {
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);
}
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();
+19
View File
@@ -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() {
+6
View File
@@ -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 {