diff --git a/crates/ironclaw_webui/frontend/src/i18n/ar.ts b/crates/ironclaw_webui/frontend/src/i18n/ar.ts
index bd7f1e62250..ddb95fc921e 100644
--- a/crates/ironclaw_webui/frontend/src/i18n/ar.ts
+++ b/crates/ironclaw_webui/frontend/src/i18n/ar.ts
@@ -743,6 +743,7 @@ registerPack("ar", {
"automations.filter.completed": "مكتمل",
"automations.refresh": "تحديث عمليات الأتمتة",
"automations.error.loadFailed": "غير قادر على تحميل عمليات الأتمتة",
+ "automations.error.actionFailed": "تعذّر تحديث الأتمتة. يُرجى المحاولة مرة أخرى.",
"automations.rename.action": "إعادة تسمية الأتمتة",
"automations.rename.nameLabel": "اسم الأتمتة",
"automations.rename.nameRequired": "اسم الأتمتة مطلوب.",
diff --git a/crates/ironclaw_webui/frontend/src/i18n/de.ts b/crates/ironclaw_webui/frontend/src/i18n/de.ts
index d9e646fa345..820ab29f161 100644
--- a/crates/ironclaw_webui/frontend/src/i18n/de.ts
+++ b/crates/ironclaw_webui/frontend/src/i18n/de.ts
@@ -743,6 +743,7 @@ registerPack("de", {
"automations.filter.completed": "Abgeschlossen",
"automations.refresh": "Automatisierungen aktualisieren",
"automations.error.loadFailed": "Automatisierungen können nicht geladen werden",
+ "automations.error.actionFailed": "Die Automatisierung konnte nicht aktualisiert werden. Versuche es erneut.",
"automations.rename.action": "Automatisierung umbenennen",
"automations.rename.nameLabel": "Automatisierungsname",
"automations.rename.nameRequired": "Automatisierungsname ist erforderlich.",
diff --git a/crates/ironclaw_webui/frontend/src/i18n/en.ts b/crates/ironclaw_webui/frontend/src/i18n/en.ts
index eed71bfb0d8..96911d871f4 100644
--- a/crates/ironclaw_webui/frontend/src/i18n/en.ts
+++ b/crates/ironclaw_webui/frontend/src/i18n/en.ts
@@ -790,6 +790,7 @@ registerPack("en", {
"automations.filter.completed": "Completed",
"automations.refresh": "Refresh automations",
"automations.error.loadFailed": "Unable to load automations",
+ "automations.error.actionFailed": "Unable to update the automation. Please try again.",
"automations.rename.action": "Rename automation",
"automations.rename.nameLabel": "Automation name",
"automations.rename.nameRequired": "Automation name is required.",
diff --git a/crates/ironclaw_webui/frontend/src/i18n/es.ts b/crates/ironclaw_webui/frontend/src/i18n/es.ts
index 28f92ac54da..3077f0e5264 100644
--- a/crates/ironclaw_webui/frontend/src/i18n/es.ts
+++ b/crates/ironclaw_webui/frontend/src/i18n/es.ts
@@ -743,6 +743,7 @@ registerPack("es", {
"automations.filter.completed": "Completadas",
"automations.refresh": "Actualizar automatizaciones",
"automations.error.loadFailed": "No se pueden cargar automatizaciones",
+ "automations.error.actionFailed": "No se pudo actualizar la automatización. Inténtalo de nuevo.",
"automations.rename.action": "Cambiar nombre de automatización",
"automations.rename.nameLabel": "Nombre de automatización",
"automations.rename.nameRequired": "El nombre de la automatización es obligatorio.",
diff --git a/crates/ironclaw_webui/frontend/src/i18n/fr.ts b/crates/ironclaw_webui/frontend/src/i18n/fr.ts
index ca43464842f..37dcfba422a 100644
--- a/crates/ironclaw_webui/frontend/src/i18n/fr.ts
+++ b/crates/ironclaw_webui/frontend/src/i18n/fr.ts
@@ -743,6 +743,7 @@ registerPack("fr", {
"automations.filter.completed": "Terminées",
"automations.refresh": "Actualiser les automatisations",
"automations.error.loadFailed": "Impossible de charger les automatisations",
+ "automations.error.actionFailed": "Impossible de mettre à jour l’automatisation. Réessayez.",
"automations.rename.action": "Renommer l’automatisation",
"automations.rename.nameLabel": "Nom de l’automatisation",
"automations.rename.nameRequired": "Le nom de l’automatisation est requis.",
diff --git a/crates/ironclaw_webui/frontend/src/i18n/hi.ts b/crates/ironclaw_webui/frontend/src/i18n/hi.ts
index 68cbfbb9117..81b0f7fa915 100644
--- a/crates/ironclaw_webui/frontend/src/i18n/hi.ts
+++ b/crates/ironclaw_webui/frontend/src/i18n/hi.ts
@@ -743,6 +743,7 @@ registerPack("hi", {
"automations.filter.completed": "पूर्ण",
"automations.refresh": "ऑटोमेशन ताज़ा करें",
"automations.error.loadFailed": "ऑटोमेशन लोड करने में असमर्थ",
+ "automations.error.actionFailed": "ऑटोमेशन अपडेट नहीं किया जा सका। फिर से कोशिश करें।",
"automations.rename.action": "ऑटोमेशन का नाम बदलें",
"automations.rename.nameLabel": "ऑटोमेशन नाम",
"automations.rename.nameRequired": "ऑटोमेशन नाम आवश्यक है.",
diff --git a/crates/ironclaw_webui/frontend/src/i18n/ja.ts b/crates/ironclaw_webui/frontend/src/i18n/ja.ts
index 6f7fbb62309..19d88fec9af 100644
--- a/crates/ironclaw_webui/frontend/src/i18n/ja.ts
+++ b/crates/ironclaw_webui/frontend/src/i18n/ja.ts
@@ -743,6 +743,7 @@ registerPack("ja", {
"automations.filter.completed": "完了",
"automations.refresh": "自動化の更新",
"automations.error.loadFailed": "自動化をロードできません",
+ "automations.error.actionFailed": "自動化を更新できませんでした。もう一度お試しください。",
"automations.rename.action": "自動化名を変更",
"automations.rename.nameLabel": "自動化名",
"automations.rename.nameRequired": "自動化名は必須です。",
diff --git a/crates/ironclaw_webui/frontend/src/i18n/ko.ts b/crates/ironclaw_webui/frontend/src/i18n/ko.ts
index 51f6be15a8e..624920f8ccf 100644
--- a/crates/ironclaw_webui/frontend/src/i18n/ko.ts
+++ b/crates/ironclaw_webui/frontend/src/i18n/ko.ts
@@ -743,6 +743,7 @@ registerPack("ko", {
"automations.filter.completed": "완료됨",
"automations.refresh": "자동화 새로 고침",
"automations.error.loadFailed": "자동화를 로드할 수 없음",
+ "automations.error.actionFailed": "자동화를 업데이트할 수 없습니다. 다시 시도하세요.",
"automations.rename.action": "자동화 이름 변경",
"automations.rename.nameLabel": "자동화 이름",
"automations.rename.nameRequired": "자동화 이름은 필수입니다.",
diff --git a/crates/ironclaw_webui/frontend/src/i18n/pt-BR.ts b/crates/ironclaw_webui/frontend/src/i18n/pt-BR.ts
index c231a26d4be..0ddbd003b56 100644
--- a/crates/ironclaw_webui/frontend/src/i18n/pt-BR.ts
+++ b/crates/ironclaw_webui/frontend/src/i18n/pt-BR.ts
@@ -743,6 +743,7 @@ registerPack("pt-BR", {
"automations.filter.completed": "Concluídas",
"automations.refresh": "Atualizar automações",
"automations.error.loadFailed": "Não é possível carregar automações",
+ "automations.error.actionFailed": "Não foi possível atualizar a automação. Tente novamente.",
"automations.rename.action": "Renomear automação",
"automations.rename.nameLabel": "Nome da automação",
"automations.rename.nameRequired": "O nome da automação é obrigatório.",
diff --git a/crates/ironclaw_webui/frontend/src/i18n/uk.ts b/crates/ironclaw_webui/frontend/src/i18n/uk.ts
index eb5abffee26..b8b23e8bcb7 100644
--- a/crates/ironclaw_webui/frontend/src/i18n/uk.ts
+++ b/crates/ironclaw_webui/frontend/src/i18n/uk.ts
@@ -743,6 +743,7 @@ registerPack("uk", {
"automations.filter.completed": "Завершені",
"automations.refresh": "Оновити автоматизації",
"automations.error.loadFailed": "Неможливо завантажити автоматизації",
+ "automations.error.actionFailed": "Не вдалося оновити автоматизацію. Спробуйте ще раз.",
"automations.rename.action": "Перейменувати автоматизацію",
"automations.rename.nameLabel": "Назва автоматизації",
"automations.rename.nameRequired": "Назва автоматизації обов'язкова.",
diff --git a/crates/ironclaw_webui/frontend/src/i18n/zh-CN.ts b/crates/ironclaw_webui/frontend/src/i18n/zh-CN.ts
index e97a72e8279..d611093bd3e 100644
--- a/crates/ironclaw_webui/frontend/src/i18n/zh-CN.ts
+++ b/crates/ironclaw_webui/frontend/src/i18n/zh-CN.ts
@@ -743,6 +743,7 @@ registerPack("zh-CN", {
"automations.filter.completed": "已完成",
"automations.refresh": "更新最新自动化",
"automations.error.loadFailed": "提取系统自动化计划列表不成功!",
+ "automations.error.actionFailed": "无法更新自动化。请重试。",
"automations.rename.action": "重命名自动化",
"automations.rename.nameLabel": "自动化名称",
"automations.rename.nameRequired": "自动化名称不能为空。",
diff --git a/crates/ironclaw_webui/frontend/src/lib/i18n.test.ts b/crates/ironclaw_webui/frontend/src/lib/i18n.test.ts
index 81d6fe61728..5f4bd91aceb 100644
--- a/crates/ironclaw_webui/frontend/src/lib/i18n.test.ts
+++ b/crates/ironclaw_webui/frontend/src/lib/i18n.test.ts
@@ -230,6 +230,16 @@ test("locale packs include skill auto-activation controls", () => {
}
});
+test("locale packs include automation action failure copy", () => {
+ const key = "automations.error.actionFailed";
+
+ for (const locale of LOCALES) {
+ const pack = loadLocalePack(locale);
+ assert.equal(typeof pack[key], "string", `${locale} missing ${key}`);
+ assert.notEqual(pack[key].trim(), "", `${locale} has empty ${key}`);
+ }
+});
+
test("locale packs include admin write-only secret management copy", () => {
const requiredKeys = [
"admin.user.secrets.title",
diff --git a/crates/ironclaw_webui/frontend/src/lib/toast.test.ts b/crates/ironclaw_webui/frontend/src/lib/toast.test.ts
index 1a91d25b49c..c357830fcaf 100644
--- a/crates/ironclaw_webui/frontend/src/lib/toast.test.ts
+++ b/crates/ironclaw_webui/frontend/src/lib/toast.test.ts
@@ -3,12 +3,14 @@ import { beforeEach, test, vi } from "vitest";
const hotToast = vi.hoisted(() => ({
blank: vi.fn((..._args: unknown[]) => "blank-id"),
+ dismiss: vi.fn((..._args: unknown[]) => {}),
error: vi.fn((..._args: unknown[]) => "error-id"),
success: vi.fn((..._args: unknown[]) => "success-id"),
}));
vi.mock("react-hot-toast", () => ({
default: Object.assign(hotToast.blank, {
+ dismiss: hotToast.dismiss,
error: hotToast.error,
success: hotToast.success,
}),
@@ -17,6 +19,7 @@ vi.mock("react-hot-toast", () => ({
import {
DEFAULT_ERROR_TOAST_DURATION,
DEFAULT_TOAST_DURATION,
+ dismissToast,
toast,
} from "./toast";
@@ -62,3 +65,10 @@ test("toast preserves an explicit duration", () => {
},
]);
});
+
+test("dismissToast removes the matching active toast", () => {
+ dismissToast("error-id");
+ dismissToast(null);
+
+ assert.deepEqual(hotToast.dismiss.mock.calls, [["error-id"]]);
+});
diff --git a/crates/ironclaw_webui/frontend/src/lib/toast.ts b/crates/ironclaw_webui/frontend/src/lib/toast.ts
index 2913b3b5701..5e8af0aa11d 100644
--- a/crates/ironclaw_webui/frontend/src/lib/toast.ts
+++ b/crates/ironclaw_webui/frontend/src/lib/toast.ts
@@ -28,3 +28,8 @@ export function toast(message: string, opts: ToastOptions = {}) {
if (tone === "success") return hotToast.success(message, options);
return hotToast(message, options);
}
+
+export function dismissToast(id: string | null | undefined) {
+ if (id == null) return;
+ hotToast.dismiss(id);
+}
diff --git a/crates/ironclaw_webui/frontend/src/pages/automations/automations-page.tsx b/crates/ironclaw_webui/frontend/src/pages/automations/automations-page.tsx
index 1e107e4a6e4..28ca6aa8ec3 100644
--- a/crates/ironclaw_webui/frontend/src/pages/automations/automations-page.tsx
+++ b/crates/ironclaw_webui/frontend/src/pages/automations/automations-page.tsx
@@ -57,15 +57,6 @@ export function AutomationsPage() {
{t("automations.error.loadFailed")}
)}
- {automationsState.actionError &&
- (
-
- {automationsState.actionError.message}
-
- )}
-
{showErrorOnly
? null
: (
diff --git a/crates/ironclaw_webui/frontend/src/pages/automations/hooks/useAutomations.test.ts b/crates/ironclaw_webui/frontend/src/pages/automations/hooks/useAutomations.test.ts
new file mode 100644
index 00000000000..90e27c1db9e
--- /dev/null
+++ b/crates/ironclaw_webui/frontend/src/pages/automations/hooks/useAutomations.test.ts
@@ -0,0 +1,80 @@
+import assert from "node:assert/strict";
+import { test } from "vitest";
+
+import {
+ createAutomationMutationConfig,
+ createAutomationMutationLifecycle,
+} from "./useAutomations";
+
+test("automation mutation configs share an explicit latest-action lifecycle", async () => {
+ const latestActionSequence = { current: 0 };
+ const actionErrorToastId = { current: null as string | null };
+ const dismissedToastIds: Array = [];
+ const shownToastIds: string[] = [];
+ let invalidationCount = 0;
+
+ const lifecycle = createAutomationMutationLifecycle({
+ latestActionSequence,
+ actionErrorToastId,
+ dismissErrorToast: (id) => dismissedToastIds.push(id),
+ showErrorToast: () => {
+ const id = `toast-${shownToastIds.length + 1}`;
+ shownToastIds.push(id);
+ return id;
+ },
+ invalidateAutomations: () => {
+ invalidationCount += 1;
+ },
+ });
+ const pause = async (automationId: string) => ({ automationId });
+ const rename = async (variables: { automationId: string; name: string }) =>
+ variables;
+ const pauseConfig = createAutomationMutationConfig(pause, lifecycle);
+ const renameConfig = createAutomationMutationConfig(rename, lifecycle);
+
+ assert.equal(pauseConfig.mutationFn, pause);
+ assert.equal(renameConfig.mutationFn, rename);
+ for (const callbackName of ["onMutate", "onError", "onSuccess"] as const) {
+ assert.equal(pauseConfig[callbackName], lifecycle[callbackName]);
+ assert.equal(renameConfig[callbackName], lifecycle[callbackName]);
+ }
+
+ const firstAction = await lifecycle.onMutate("automation-1");
+ const secondAction = await lifecycle.onMutate({
+ automationId: "automation-2",
+ name: "New name",
+ });
+ assert.deepEqual(dismissedToastIds, []);
+
+ lifecycle.onError(
+ new Error("raw backend detail"),
+ "automation-1",
+ firstAction
+ );
+ assert.deepEqual(shownToastIds, []);
+
+ lifecycle.onError(
+ new Error("raw backend detail"),
+ { automationId: "automation-2", name: "New name" },
+ secondAction
+ );
+ assert.deepEqual(shownToastIds, ["toast-1"]);
+ assert.equal(actionErrorToastId.current, "toast-1");
+
+ // A late callback from the older action must not dismiss or overwrite the
+ // toast now owned by the latest action.
+ lifecycle.onError(new Error("late failure"), "automation-1", firstAction);
+ lifecycle.onSuccess({ updated: true }, "automation-1", firstAction);
+ assert.deepEqual(shownToastIds, ["toast-1"]);
+ assert.deepEqual(dismissedToastIds, []);
+ assert.equal(actionErrorToastId.current, "toast-1");
+ assert.equal(invalidationCount, 1);
+
+ const thirdAction = await lifecycle.onMutate("automation-3");
+ assert.deepEqual(dismissedToastIds, ["toast-1"]);
+ assert.equal(actionErrorToastId.current, null);
+
+ lifecycle.onSuccess({ updated: true }, "automation-3", thirdAction);
+ assert.deepEqual(dismissedToastIds, ["toast-1"]);
+ assert.equal(invalidationCount, 2);
+});
diff --git a/crates/ironclaw_webui/frontend/src/pages/automations/hooks/useAutomations.ts b/crates/ironclaw_webui/frontend/src/pages/automations/hooks/useAutomations.ts
index d611933e30f..ed127ad4249 100644
--- a/crates/ironclaw_webui/frontend/src/pages/automations/hooks/useAutomations.ts
+++ b/crates/ironclaw_webui/frontend/src/pages/automations/hooks/useAutomations.ts
@@ -1,4 +1,10 @@
-import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import {
+ type MutationFunction,
+ type UseMutationOptions,
+ useMutation,
+ useQuery,
+ useQueryClient,
+} from "@tanstack/react-query";
import React from "react";
import {
deleteAutomation,
@@ -8,6 +14,7 @@ import {
resumeAutomation,
} from "../../../lib/api";
import { useI18n } from "../../../lib/i18n";
+import { dismissToast, toast } from "../../../lib/toast";
import {
automationSummary,
@@ -26,9 +33,90 @@ type RenameAutomationVariables = {
name: string;
};
+export type ActionMutationContext = {
+ sequence: number;
+};
+
+type MutableRef = {
+ current: T;
+};
+
+export type AutomationMutationLifecycle = {
+ onMutate: (_variables: unknown) => ActionMutationContext;
+ onError: (
+ _error: unknown,
+ _variables: unknown,
+ context: ActionMutationContext | undefined
+ ) => void;
+ onSuccess: (
+ _data: unknown,
+ _variables: unknown,
+ context: ActionMutationContext | undefined
+ ) => void;
+};
+
+type AutomationMutationLifecycleOptions = {
+ latestActionSequence: MutableRef;
+ actionErrorToastId: MutableRef;
+ dismissErrorToast: (id: string | null | undefined) => void;
+ showErrorToast: () => string;
+ invalidateAutomations: () => void;
+};
+
+export function createAutomationMutationLifecycle({
+ latestActionSequence,
+ actionErrorToastId,
+ dismissErrorToast,
+ showErrorToast,
+ invalidateAutomations,
+}: AutomationMutationLifecycleOptions): AutomationMutationLifecycle {
+ const clearActionError = () => {
+ if (actionErrorToastId.current !== null) {
+ dismissErrorToast(actionErrorToastId.current);
+ }
+ actionErrorToastId.current = null;
+ };
+
+ return {
+ onMutate: () => {
+ const sequence = latestActionSequence.current + 1;
+ latestActionSequence.current = sequence;
+ clearActionError();
+ return { sequence };
+ },
+ onError: (_error, _variables, context) => {
+ // A newer action deliberately supersedes older results: a late failure
+ // must neither dismiss nor replace the latest action's toast.
+ if (context?.sequence !== latestActionSequence.current) return;
+ clearActionError();
+ actionErrorToastId.current = showErrorToast();
+ },
+ onSuccess: (_data, _variables, context) => {
+ if (context?.sequence === latestActionSequence.current) {
+ clearActionError();
+ }
+ invalidateAutomations();
+ },
+ };
+}
+
+export function createAutomationMutationConfig(
+ mutationFn: MutationFunction,
+ lifecycle: AutomationMutationLifecycle
+): UseMutationOptions {
+ return {
+ mutationFn,
+ onMutate: lifecycle.onMutate,
+ onError: lifecycle.onError,
+ onSuccess: lifecycle.onSuccess,
+ };
+}
+
export function useAutomations(includeCompleted = false) {
const { t, lang } = useI18n();
const queryClient = useQueryClient();
+ const latestActionSequence = React.useRef(0);
+ const actionErrorToastId = React.useRef(null);
const query = useQuery({
queryKey: ["automations", { includeCompleted }],
queryFn: () =>
@@ -73,23 +161,49 @@ export function useAutomations(includeCompleted = false) {
const invalidateAutomations = React.useCallback(() => {
queryClient.invalidateQueries({ queryKey: ["automations"] });
}, [queryClient]);
- const pauseMutation = useMutation({
- mutationFn: (automationId: string) => pauseAutomation({ automationId }),
- onSuccess: invalidateAutomations,
- });
- const resumeMutation = useMutation({
- mutationFn: (automationId: string) => resumeAutomation({ automationId }),
- onSuccess: invalidateAutomations,
- });
- const renameMutation = useMutation({
- mutationFn: ({ automationId, name }: RenameAutomationVariables) =>
- renameAutomation({ automationId, name }),
- onSuccess: invalidateAutomations,
- });
- const deleteMutation = useMutation({
- mutationFn: (automationId: string) => deleteAutomation({ automationId }),
- onSuccess: invalidateAutomations,
- });
+ const showActionErrorToast = React.useCallback(
+ () =>
+ toast(t("automations.error.actionFailed"), {
+ tone: "error",
+ }),
+ [t]
+ );
+ const mutationLifecycle = React.useMemo(
+ () =>
+ createAutomationMutationLifecycle({
+ latestActionSequence,
+ actionErrorToastId,
+ dismissErrorToast: dismissToast,
+ showErrorToast: showActionErrorToast,
+ invalidateAutomations,
+ }),
+ [invalidateAutomations, showActionErrorToast]
+ );
+ const pauseMutation = useMutation(
+ createAutomationMutationConfig(
+ (automationId: string) => pauseAutomation({ automationId }),
+ mutationLifecycle
+ )
+ );
+ const resumeMutation = useMutation(
+ createAutomationMutationConfig(
+ (automationId: string) => resumeAutomation({ automationId }),
+ mutationLifecycle
+ )
+ );
+ const renameMutation = useMutation(
+ createAutomationMutationConfig(
+ ({ automationId, name }: RenameAutomationVariables) =>
+ renameAutomation({ automationId, name }),
+ mutationLifecycle
+ )
+ );
+ const deleteMutation = useMutation(
+ createAutomationMutationConfig(
+ (automationId: string) => deleteAutomation({ automationId }),
+ mutationLifecycle
+ )
+ );
return {
automations,
@@ -103,12 +217,6 @@ export function useAutomations(includeCompleted = false) {
renameMutation.isPending ||
deleteMutation.isPending,
error: query.error || null,
- actionError:
- pauseMutation.error ||
- resumeMutation.error ||
- renameMutation.error ||
- deleteMutation.error ||
- null,
pauseAutomation: pauseMutation.mutate,
resumeAutomation: resumeMutation.mutate,
renameAutomation: renameMutation.mutate,
diff --git a/tests/e2e/helpers.py b/tests/e2e/helpers.py
index 512280a5388..489a325a2a3 100644
--- a/tests/e2e/helpers.py
+++ b/tests/e2e/helpers.py
@@ -265,7 +265,6 @@
"workspace_directory_entry_for": (
"[data-testid='workspace-directory-entry'][data-entry-path='{path}']"
),
- "toast": "[data-testid='toast']",
"thread_delete_for": (
'[data-testid="thread-delete"][data-thread-id="{id}"]'
),
diff --git a/tests/e2e/scenarios/test_reborn_webui_v2_smoke.py b/tests/e2e/scenarios/test_reborn_webui_v2_smoke.py
index 76ea6615c22..27cf9d10939 100644
--- a/tests/e2e/scenarios/test_reborn_webui_v2_smoke.py
+++ b/tests/e2e/scenarios/test_reborn_webui_v2_smoke.py
@@ -516,6 +516,135 @@ async def test_reborn_v2_automation_rename_persists_from_ui(
assert renamed["automation_id"] == automation_id
+async def test_reborn_v2_automation_action_error_toast_is_safe_dismissible_and_cleared_on_retry(
+ reborn_v2_server, reborn_v2_page
+):
+ """Automation mutation toasts stay visible, private, and clear on retry."""
+ automation_id = "11111111-2222-3333-4444-555555555555"
+ automation_name = "Safe action error regression"
+ raw_error = "postgres failed: secret_internal_automation_table"
+ attempt_count = 0
+ mutation_requests: list[tuple[str, str]] = []
+ console_messages: list[str] = []
+ retry_started = asyncio.Event()
+ release_retry = asyncio.Event()
+ retry_completed = asyncio.Event()
+
+ page = reborn_v2_page
+ page.on("console", lambda message: console_messages.append(message.text))
+
+ async def handle_automations(route) -> None:
+ nonlocal attempt_count
+ if route.request.method == "GET":
+ await route.fulfill(
+ status=200,
+ content_type="application/json",
+ body=json.dumps(
+ {
+ "scheduler_enabled": True,
+ "automations": [
+ {
+ "automation_id": automation_id,
+ "name": automation_name,
+ "source": {
+ "type": "schedule",
+ "cron": "0 9 * * *",
+ "timezone": "UTC",
+ },
+ "state": "active",
+ "next_run_at": "2026-07-18T09:00:00Z",
+ "recent_runs": [],
+ }
+ ],
+ }
+ ),
+ )
+ return
+
+ mutation_requests.append(
+ (route.request.method, urlparse(route.request.url).path)
+ )
+ attempt_count += 1
+ if attempt_count <= 2:
+ await route.fulfill(
+ status=500,
+ content_type="text/plain",
+ body=raw_error,
+ )
+ return
+
+ retry_started.set()
+ await release_retry.wait()
+ await route.fulfill(
+ status=200,
+ content_type="application/json",
+ body=json.dumps({"updated": True}),
+ )
+ retry_completed.set()
+
+ await page.route("**/api/webchat/v2/automations**", handle_automations)
+ row_selector = SEL_V2["automation_row_for"].format(id=automation_id)
+ error_toast = page.locator(SEL_V2["toast"]).filter(
+ has_text="Unable to update the automation. Please try again."
+ )
+
+ async def submit_rename(name: str) -> None:
+ row = page.locator(row_selector)
+ await expect(row).to_be_visible(timeout=15000)
+ await row.locator(
+ SEL_V2["automation_name_button_for"].format(id=automation_id)
+ ).click()
+ await page.locator(SEL_V2["automation_rename_button"]).click()
+ rename_input = page.locator(SEL_V2["automation_rename_input"])
+ await rename_input.fill(name)
+ await page.locator(SEL_V2["automation_rename_save"]).click()
+
+ try:
+ await page.goto(f"{reborn_v2_server}/automations?token={REBORN_V2_AUTH_TOKEN}")
+
+ await submit_rename("First failed rename")
+ await expect(error_toast).to_be_visible(timeout=10000)
+ await expect(error_toast).to_have_text(
+ "Unable to update the automation. Please try again."
+ )
+ await expect(error_toast).not_to_contain_text(raw_error)
+ assert not any(raw_error in message for message in console_messages)
+ await error_toast.get_by_role("button", name="Dismiss").click()
+ await expect(error_toast).to_have_count(0, timeout=3000)
+
+ pause_button = page.get_by_role(
+ "button", name=f"Pause: {automation_name}", exact=True
+ )
+ await pause_button.click()
+ await expect(error_toast).to_be_visible(timeout=10000)
+ assert not any(raw_error in message for message in console_messages)
+
+ await pause_button.click()
+ await asyncio.wait_for(retry_started.wait(), timeout=10)
+ await expect(error_toast).to_have_count(0)
+
+ release_retry.set()
+ await asyncio.wait_for(retry_completed.wait(), timeout=10)
+ await expect(error_toast).to_have_count(0)
+ assert not any(raw_error in message for message in console_messages)
+ assert mutation_requests == [
+ (
+ "POST",
+ f"/api/webchat/v2/automations/{automation_id}",
+ ),
+ (
+ "POST",
+ f"/api/webchat/v2/automations/{automation_id}/pause",
+ ),
+ (
+ "POST",
+ f"/api/webchat/v2/automations/{automation_id}/pause",
+ ),
+ ]
+ finally:
+ release_retry.set()
+
+
async def test_reborn_v2_automation_failed_run_actions_are_clickable(
reborn_v2_server, reborn_v2_browser
):