fix(renderer): preserve desktop error codes

This commit is contained in:
ZacharyZhang-NY committed 2026-04-22 15:00:35 -04:00
1 parent eec26a09a3
commit 1453a8fa95
15 files changed
+326 -40

No files matched your search

+11 -4
View File
@@ -15,7 +15,7 @@ import { useCompanyController } from "./hooks/useCompanyController";
import { useCommandPaletteHotkeys } from "./hooks/useCommandPaletteHotkeys";
import { AppActionsContext, type AppActions } from "./hooks/useAppActions";
import { SectionContext } from "./hooks/useSectionContext";
import { unwrap } from "./lib/desktop";
import { createAppErrorNotice, describeDesktopError, formatDesktopResultError, unwrap } from "./lib/desktop";
function Shell({
locale,
@@ -55,9 +55,16 @@ export function App() {
const isMac = !showCustomTitleBar;
const startRun = useCallback(async (taskId: string) => {
if (!company.currentCompany) { setError("Select a company before starting a run."); return; }
if (!company.currentCompany) {
setError(createAppErrorNotice(
"Select a company before starting a run.",
"COMPANY_REQUIRED",
"Select a company in the sidebar, then start the run.",
));
return;
}
const result = await window.agentCompany.startTaskRun({ companyId: company.currentCompany.id, taskId });
if (!result.ok) { setError(result.error.message); return; }
if (!result.ok) { setError(formatDesktopResultError(result.error)); return; }
setSelectedRunId(result.data);
await company.refresh();
// eslint-disable-next-line react-hooks/exhaustive-deps -- Zustand setters and company.refresh are stable
@@ -70,7 +77,7 @@ export function App() {
unwrap(await window.agentCompany.cancelRun({ companyId: run.companyId, runId }));
await company.refresh();
setError(null);
} catch (err) { setError(err instanceof Error ? err.message : String(err)); }
} catch (err) { setError(describeDesktopError(err)); }
// eslint-disable-next-line react-hooks/exhaustive-deps -- setError is a stable Zustand setter
}, [company.refresh, company.runs]);
+24 -3
View File
@@ -2,6 +2,7 @@ import { AnimatePresence, motion } from "framer-motion"
import { CheckCircle, Warning, X, XCircle } from "./ui/icons"
import { useAppStore } from "../state/store"
import { useReducedMotion } from "./ui"
import type { Toast } from "../state/slices/ui"
const TONE_CONFIG = {
success: { icon: CheckCircle, className: "bg-success-muted border-success text-success" },
@@ -34,9 +35,7 @@ export default function ToastContainer() {
className={`flex w-[320px] items-start gap-2.5 rounded-lg border px-4 py-3 shadow-lg ${cfg.className}`}
>
<Icon size={16} weight="fill" className="mt-px shrink-0" />
<span className="max-h-[6.5rem] min-w-0 flex-1 overflow-y-auto break-words text-[13px] font-medium leading-relaxed">
{toast.message}
</span>
<ToastMessage message={toast.message} />
<button
type="button"
onClick={() => dismiss(toast.id)}
@@ -52,3 +51,25 @@ export default function ToastContainer() {
</div>
)
}
function ToastMessage({ message }: { message: Toast["message"] }) {
if (typeof message === "string") {
return (
<span className="max-h-[6.5rem] min-w-0 flex-1 overflow-y-auto break-words text-[13px] font-medium leading-relaxed">
{message}
</span>
)
}
return (
<div className="max-h-[7.5rem] min-w-0 flex-1 overflow-y-auto">
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<span className="rounded bg-background/80 px-1.5 py-0.5 font-mono text-[10px] font-semibold uppercase tracking-[0.08em]">
{message.code}
</span>
<span className="min-w-0 break-words text-[12px] font-semibold leading-5">{message.actionHint}</span>
</div>
<div className="mt-1 break-words text-[12px] leading-5 opacity-80">{message.message}</div>
</div>
)
}
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from "react";
import type { AgentRecord, SkillRecord } from "@shared/types";
import { useAppStore } from "../../state/store";
import { formatDesktopResultError } from "../../lib/desktop";
type SkillAgentTogglesProps =
| { mode: "skill"; companyId: string; skillId: string; agents: AgentRecord[] }
@@ -48,7 +49,7 @@ export function SkillAgentToggles(props: SkillAgentTogglesProps) {
return next;
});
} else {
addToast(result.error.message, "danger");
addToast(formatDesktopResultError(result.error), "danger");
}
setTogglingId(null);
},
@@ -14,6 +14,7 @@ import { ActionButton, StatusPill, Eyebrow } from "../ui";
import { SkillAgentToggles } from "./SkillAgentToggles";
import { buildTree, SkillFileTree } from "./SkillFileTree";
import { useT, type TranslationFn } from "../../i18n";
import { formatDesktopResultError } from "../../lib/desktop";
const sourceIconMap: Record<string, typeof GithubLogo> = {
github: GithubLogo,
@@ -114,7 +115,7 @@ export function SkillDetail({
if (result.ok) {
setDetail(result.data);
} else {
addToast(result.error.message, "danger");
addToast(formatDesktopResultError(result.error), "danger");
}
}
void load();
@@ -147,7 +148,7 @@ export function SkillDetail({
if (result.ok) {
setUpdateStatus(result.data);
} else {
addToast(result.error.message, "danger");
addToast(formatDesktopResultError(result.error), "danger");
}
}, [companyId, skillId, detail, addToast]);
@@ -157,7 +158,7 @@ export function SkillDetail({
addToast(t("skill.deleted"), "success");
onDeleted();
} else {
addToast(result.error.message, "danger");
addToast(formatDesktopResultError(result.error), "danger");
}
}, [companyId, skillId, addToast, onDeleted, t]);
@@ -8,6 +8,7 @@ import {
import { Modal, ActionButton, Input } from "../ui";
import { useAppStore } from "../../state/store";
import { useT } from "../../i18n";
import { describeDesktopError, formatDesktopResultError } from "../../lib/desktop";
type TabId = "github" | "local" | "url" | "skills_sh";
@@ -95,10 +96,10 @@ export function SkillImportModal({
onImported();
handleClose();
} else {
addToast(result.error.message, "danger");
addToast(formatDesktopResultError(result.error), "danger");
}
} catch (error) {
addToast(error instanceof Error ? error.message : t("skillImport.failed"), "danger");
addToast(describeDesktopError(error), "danger");
} finally {
setIsImporting(false);
}
@@ -10,6 +10,7 @@ import { SkillList } from "./SkillList";
import { SkillImportModal } from "./SkillImportModal";
import { SkillDetail } from "./SkillDetail";
import { useT } from "../../i18n";
import { describeDesktopError, formatDesktopResultError } from "../../lib/desktop";
export function SkillsSection({
companyId,
@@ -37,10 +38,10 @@ export function SkillsSection({
if (result.ok) {
setSkills(result.data);
} else {
addToast(result.error.message, "danger");
addToast(formatDesktopResultError(result.error), "danger");
}
} catch (error) {
addToast(error instanceof Error ? error.message : "Failed to load skills", "danger");
addToast(describeDesktopError(error), "danger");
}
}, [companyId, setSkills, addToast]);
+2 -1
View File
@@ -1,8 +1,9 @@
import { createContext, useContext } from "react";
import type { AppErrorInput } from "../lib/desktop";
export interface AppActions {
refresh: () => Promise<void>;
setError: (error: string | null) => void;
setError: (error: AppErrorInput) => void;
openPathWithFeedback: (path: string) => Promise<void>;
startRun: (taskId: string) => Promise<void>;
cancelRun: (runId: string) => Promise<void>;
+5 -5
View File
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef } from "react";
import type { CompanyRecord, OnboardingBootstrapResult } from "@shared/types";
import { useAppStore } from "../state/store";
import { NEW_ENTITY_ID } from "../lib/constants";
import { unwrap } from "../lib/desktop";
import { describeDesktopError, formatDesktopResultError, unwrap } from "../lib/desktop";
import { useDesktopEvents } from "./useDesktopEvents";
import { useDataLoader } from "./useDataLoader";
import { useCompanyData } from "./useCompanyData";
@@ -98,7 +98,7 @@ export function useCompanyController() {
incrementLogRevision();
return;
}
useAppStore.getState().setError(result.error.message);
useAppStore.getState().setError(formatDesktopResultError(result.error));
})();
}, [activeRunId, companyData.runs, incrementLogRevision]);
@@ -110,7 +110,7 @@ export function useCompanyController() {
clearCompanyScopedUiState();
await refresh();
store.setError(null);
} catch (err) { store.setError(err instanceof Error ? err.message : String(err)); }
} catch (err) { store.setError(describeDesktopError(err)); }
}, [clearCompanyScopedUiState, refresh]);
const deleteCompany = useCallback(async (companyId: string) => {
@@ -120,7 +120,7 @@ export function useCompanyController() {
store.setConfirmDeleteCompanyId(null);
store.setCompanySwitcherOpen(false);
await refresh();
} catch (err) { store.setError(err instanceof Error ? err.message : String(err)); }
} catch (err) { store.setError(describeDesktopError(err)); }
}, [refresh]);
const handleOnboardingDone = useCallback(async (summary: OnboardingBootstrapResult) => {
@@ -136,7 +136,7 @@ export function useCompanyController() {
const openPathWithFeedback = useCallback(async (path: string) => {
const store = useAppStore.getState();
try { unwrap(await window.agentCompany.openPath(path)); store.setError(null); }
catch (err) { store.setError(err instanceof Error ? err.message : String(err)); }
catch (err) { store.setError(describeDesktopError(err)); }
}, []);
// HOOK-002 fix: return a STABLE useMemo'd object. Deps are each primitive
+2 -2
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect } from "react";
import type { ProfileSnapshot, SectionId } from "@shared/types";
import { unwrap } from "../lib/desktop";
import { describeDesktopError, unwrap } from "../lib/desktop";
import { useAppStore } from "../state/store";
export function useDataLoader(
@@ -32,7 +32,7 @@ export function useDataLoader(
}
store.setError(null);
} catch (nextError) {
store.setError(nextError instanceof Error ? nextError.message : String(nextError));
store.setError(describeDesktopError(nextError));
}
}, []);
+155
View File
@@ -1,5 +1,58 @@
import type { DesktopResult } from "@shared/contracts";
export interface DesktopErrorEnvelope {
code: string;
message: string;
}
export interface AppErrorNotice {
code: string;
message: string;
actionHint: string;
}
export type AppErrorInput =
| AppErrorNotice
| DesktopErrorEnvelope
| Error
| string
| null;
const GENERIC_ERROR_CODE = "APP_ERROR";
const ACTION_REQUIRED_CODE = "ACTION_REQUIRED";
const DEFAULT_ACTION_HINT = "Use this code when reporting the issue.";
const ACTION_HINTS_BY_CODE: Record<string, string> = {
ACTION_REQUIRED: "Complete the required selection, then retry the action.",
AGENT_ALREADY_RUNNING: "Open Runs, review the active run, then retry from the task.",
AGENT_PENDING_APPROVAL: "Open Approvals, resolve the hire request, then retry the task.",
AGENT_NOT_RUNNABLE: "Open Agents, activate the assigned agent, then retry the task.",
APP_SHUTTING_DOWN: "Restart AgentCompany after shutdown completes.",
BUDGET_POLICY_BLOCKED: "Open Budget, update the blocking policy, then retry the run.",
CHECKOUT_RACE_LOST: "Refresh tasks, review the current assignee, then retry the run.",
COMPANY_NOT_ACTIVE: "Select the active company, then retry the action.",
COMPANY_NOT_FOUND: "Refresh companies, select an existing company, then retry the action.",
CONNECTOR_MODEL_NOT_ALLOWED: "Open Connectors, enable an allowed model, then retry the run.",
CONNECTOR_NOT_FOUND: "Open Connectors, select an available connector, then retry the action.",
CONNECTOR_NOT_READY: "Open Connectors, complete the credential setup, then retry the run.",
INVALID_COMPANY_REFERENCE: "Refresh the company data, then retry with a current selection.",
INVALID_INPUT: "Review the entered fields, then retry the action.",
INVALID_PAYLOAD: "Review the entered fields, then retry the action.",
INVALID_TASK_STATE: "Refresh the task, review its current state, then retry the action.",
MODEL_PICK_FAILED: "Open Connectors, choose an enabled model, then retry the run.",
OPEN_PATH_FAILED: "Verify the path exists on disk, then open it again.",
OPEN_PATH_FORBIDDEN: "Open a path inside the managed workspace.",
RESTORE_BLOCKED: "Stop active runs, then restore the profile backup.",
RUN_NOT_FOUND: "Refresh Runs, select an existing run, then retry the action.",
SERVICE_NOT_READY: "Wait for startup to finish, then retry the action.",
TASK_NOT_FOUND: "Refresh Tasks, select an existing task, then retry the action.",
TASK_UNASSIGNED: "Assign an agent to the task, then start the run.",
TASK_VALIDATION_FAILED: "Review the task fields, then save again.",
WORKER_UNAVAILABLE: "Wait for the worker to become ready, then retry the run.",
WORKSPACE_COMPANY_MISMATCH: "Select a workspace owned by the current company.",
WORKSPACE_UNRESOLVED: "Open Workspaces, repair the selected workspace path, then retry the run.",
};
export class DesktopError extends Error {
constructor(public readonly code: string, message: string) {
super(message);
@@ -7,9 +60,111 @@ export class DesktopError extends Error {
}
}
export function createAppErrorNotice(
message: string,
code: string = ACTION_REQUIRED_CODE,
actionHint?: string,
): AppErrorNotice {
return {
code: normalizeCode(code),
message: normalizeMessage(message),
actionHint: actionHint?.trim() || actionHintForCode(code),
};
}
export function formatDesktopResultError(error: DesktopErrorEnvelope): AppErrorNotice {
return createAppErrorNotice(error.message, error.code);
}
export function describeDesktopError(error: unknown): AppErrorNotice {
if (isAppErrorNotice(error)) {
return error;
}
if (error instanceof DesktopError) {
return createAppErrorNotice(error.message, error.code);
}
if (isDesktopErrorEnvelope(error)) {
return formatDesktopResultError(error);
}
if (error instanceof Error) {
return createAppErrorNotice(error.message, GENERIC_ERROR_CODE);
}
return createAppErrorNotice(String(error), GENERIC_ERROR_CODE);
}
export function normalizeAppErrorNotice(error: AppErrorInput): AppErrorNotice | null {
if (error === null) {
return null;
}
if (typeof error === "string") {
return createAppErrorNotice(error);
}
return describeDesktopError(error);
}
export function unwrap<T>(result: DesktopResult<T>): T {
if (!result.ok) {
throw new DesktopError(result.error.code, result.error.message);
}
return result.data;
}
function normalizeCode(code: string): string {
const trimmed = code.trim();
return trimmed.length > 0 ? trimmed : GENERIC_ERROR_CODE;
}
function normalizeMessage(message: string): string {
const trimmed = message.trim();
return trimmed.length > 0 ? trimmed : "The action could not be completed.";
}
function actionHintForCode(code: string): string {
const normalized = normalizeCode(code);
const exactHint = ACTION_HINTS_BY_CODE[normalized.toUpperCase()];
if (exactHint) {
return exactHint;
}
const lowerCode = normalized.toLowerCase();
if (lowerCode.includes("connector") || lowerCode.includes("model")) {
return "Open Connectors, review the provider setup, then retry the action.";
}
if (lowerCode.includes("workspace") || lowerCode.includes("path")) {
return "Open Workspaces, verify the path, then retry the action.";
}
if (lowerCode.includes("permission") || lowerCode.includes("forbidden") || lowerCode.includes("unauthorized")) {
return "Review the account permissions, then retry the action.";
}
if (lowerCode.includes("validation") || lowerCode.includes("invalid")) {
return "Review the entered fields, then retry the action.";
}
if (lowerCode.includes("not_found")) {
return "Refresh the view, select an existing item, then retry the action.";
}
return DEFAULT_ACTION_HINT;
}
function isDesktopErrorEnvelope(error: unknown): error is DesktopErrorEnvelope {
return Boolean(
error &&
typeof error === "object" &&
"code" in error &&
"message" in error &&
typeof (error as { code?: unknown }).code === "string" &&
typeof (error as { message?: unknown }).message === "string",
);
}
function isAppErrorNotice(error: unknown): error is AppErrorNotice {
return Boolean(
isDesktopErrorEnvelope(error) &&
"actionHint" in error &&
typeof (error as { actionHint?: unknown }).actionHint === "string",
);
}
+12 -5
View File
@@ -9,6 +9,13 @@ import type {
StandupReport,
TaskRecord,
} from "@shared/types";
import {
describeDesktopError,
formatDesktopResultError,
normalizeAppErrorNotice,
type AppErrorInput,
type AppErrorNotice,
} from "../../lib/desktop";
// Sidebar badge counts and inbox items both live in the renderer —
// derived on every read from the snapshot (see `@shared/sidebar-badges`
@@ -26,11 +33,11 @@ export interface CompanySlice {
snapshotRevision: number;
companyMetrics: CompanyMetrics | null;
standupReport: StandupReport | null;
error: string | null;
error: AppErrorNotice | null;
setSnapshot: (snapshot: ProfileSnapshot | null) => void;
setCompanyMetrics: (metrics: CompanyMetrics | null) => void;
setStandupReport: (report: StandupReport | null) => void;
setError: (error: string | null) => void;
setError: (error: AppErrorInput) => void;
refreshSnapshot: () => Promise<void>;
patchTask: (task: TaskRecord) => void;
removeTask: (taskId: string) => void;
@@ -68,7 +75,7 @@ export const createCompanySlice: StateCreator<
}),
setError: (error) =>
set((state) => {
state.error = error;
state.error = normalizeAppErrorNotice(error);
}),
refreshSnapshot: async () => {
try {
@@ -80,12 +87,12 @@ export const createCompanySlice: StateCreator<
});
} else {
set((state) => {
state.error = result.error.message;
state.error = formatDesktopResultError(result.error);
});
}
} catch (err) {
set((state) => {
state.error = err instanceof Error ? err.message : String(err);
state.error = describeDesktopError(err);
});
}
},
+3 -2
View File
@@ -2,10 +2,11 @@ import type { StateCreator } from "zustand";
import type { AppState } from "../store";
import type { AppLocale } from "@shared/locale";
import { DEFAULT_LOCALE } from "@shared/locale";
import type { AppErrorNotice } from "../../lib/desktop";
export interface Toast {
id: string;
message: string;
message: string | AppErrorNotice;
tone: "success" | "danger" | "warn";
}
@@ -29,7 +30,7 @@ export interface UiSlice {
setTaskViewMode: (mode: "list" | "kanban") => void;
setTaskInspectorTab: (tab: TaskInspectorTab) => void;
incrementLogRevision: () => void;
addToast: (message: string, tone?: Toast["tone"]) => void;
addToast: (message: Toast["message"], tone?: Toast["tone"]) => void;
dismissToast: (id: string) => void;
}
+16 -4
View File
@@ -1,6 +1,7 @@
import { AnimatePresence, motion } from "framer-motion";
import { WarningCircle } from "../components/ui/icons";
import type { SectionId } from "@shared/types";
import type { AppErrorNotice } from "../lib/desktop";
import { ErrorBoundary } from "../components/ui";
import { OrganizationSections } from "./sections/OrganizationSections";
import { ExecutionSections } from "./sections/ExecutionSections";
@@ -8,7 +9,7 @@ import { OperationsSections } from "./sections/OperationsSections";
interface SectionContentViewProps {
section: SectionId;
error: string | null;
error: AppErrorNotice | null;
pageTransition: { duration: number; ease?: readonly [number, number, number, number] } | { duration: number };
}
@@ -19,9 +20,20 @@ export function SectionContentView({ section, error, pageTransition }: SectionCo
<div className={isCommunication ? "flex min-h-0 flex-1 flex-col" : "mx-auto flex w-full max-w-[1040px] flex-col px-6 py-6 lg:px-10 lg:py-8"}>
{error ? (
<div className={`flex items-center gap-3 rounded-lg bg-destructive-muted px-4 py-3 text-[13px] text-destructive ${isCommunication ? "mx-6 mt-4" : "mb-6"}`}>
<WarningCircle size={18} />
<span>{error}</span>
<div
role="alert"
className={`flex items-start gap-3 rounded-lg bg-destructive-muted px-4 py-3 text-destructive ${isCommunication ? "mx-6 mt-4" : "mb-6"}`}
>
<WarningCircle size={18} className="mt-0.5 shrink-0" />
<div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<span className="rounded bg-background/80 px-1.5 py-0.5 font-mono text-[11px] font-semibold uppercase tracking-[0.08em]">
{error.code}
</span>
<span className="min-w-0 text-[13px] font-semibold leading-5">{error.actionHint}</span>
</div>
<div className="mt-1 break-words text-[12px] leading-5 text-destructive/80">{error.message}</div>
</div>
</div>
) : null}
@@ -21,7 +21,7 @@ import { SprintManager } from "../../components/company-operations/SprintManager
import { ActionButton, EmptyState, Modal } from "../../components/ui";
import { Plus } from "../../components/ui/icons";
import { useT } from "../../i18n";
import { unwrap } from "../../lib/desktop";
import { describeDesktopError, unwrap } from "../../lib/desktop";
import { useAppStore } from "../../state/store";
import { useSectionContext } from "../../hooks/useSectionContext";
import { useAppActions } from "../../hooks/useAppActions";
@@ -135,7 +135,7 @@ export function OperationsSections({ section }: OperationsSectionsProps) {
unwrap(await window.agentCompany.triggerBrowserLogin({ companyId: snapshot.currentCompanyId, socialAccountId: accountId }));
setError(null);
} catch (error) {
setError(error instanceof Error ? error.message : String(error));
setError(describeDesktopError(error));
}
}}
onDelete={async (id) => {
@@ -145,7 +145,7 @@ export function OperationsSections({ section }: OperationsSectionsProps) {
await refresh();
setError(null);
} catch (error) {
setError(error instanceof Error ? error.message : String(error));
setError(describeDesktopError(error));
}
}}
onLogin={async (accountId) => {
@@ -155,7 +155,7 @@ export function OperationsSections({ section }: OperationsSectionsProps) {
await refresh();
setError(null);
} catch (error) {
setError(error instanceof Error ? error.message : String(error));
setError(describeDesktopError(error));
}
}}
onOpenPath={async (path) => {
@@ -163,7 +163,7 @@ export function OperationsSections({ section }: OperationsSectionsProps) {
unwrap(await window.agentCompany.openPath(path));
setError(null);
} catch (error) {
setError(error instanceof Error ? error.message : String(error));
setError(describeDesktopError(error));
}
}}
onCancelAction={async (actionId) => {
@@ -173,7 +173,7 @@ export function OperationsSections({ section }: OperationsSectionsProps) {
await refresh();
setError(null);
} catch (error) {
setError(error instanceof Error ? error.message : String(error));
setError(describeDesktopError(error));
}
}}
onNavigate={(sectionName, entityId) => {
+78
View File
@@ -0,0 +1,78 @@
import type { DesktopResult } from "@shared/contracts";
import { describe, expect, it } from "vitest";
import {
createAppErrorNotice,
describeDesktopError,
DesktopError,
formatDesktopResultError,
normalizeAppErrorNotice,
unwrap,
} from "../../src/renderer/lib/desktop";
describe("renderer desktop error notices", () => {
it("keeps DesktopResult error codes when unwrap throws", () => {
const result: DesktopResult<string> = {
ok: false,
error: {
code: "CONNECTOR_MODEL_NOT_ALLOWED",
message: "The assigned agent model is disabled.",
},
};
expect(() => unwrap(result)).toThrow(DesktopError);
try {
unwrap(result);
throw new Error("unwrap should have thrown");
} catch (error) {
expect(error).toBeInstanceOf(DesktopError);
expect((error as DesktopError).code).toBe("CONNECTOR_MODEL_NOT_ALLOWED");
expect((error as DesktopError).message).toBe("The assigned agent model is disabled.");
}
});
it("turns IPC error envelopes into actionable notices", () => {
const notice = formatDesktopResultError({
code: "CONNECTOR_MODEL_NOT_ALLOWED",
message: "The assigned agent model is disabled or invalid for its connector.",
});
expect(notice).toEqual({
code: "CONNECTOR_MODEL_NOT_ALLOWED",
message: "The assigned agent model is disabled or invalid for its connector.",
actionHint: "Open Connectors, enable an allowed model, then retry the run.",
});
});
it("describes thrown DesktopError instances with their original code", () => {
const notice = describeDesktopError(new DesktopError("TASK_UNASSIGNED", "Assign an agent before starting a run."));
expect(notice).toEqual({
code: "TASK_UNASSIGNED",
message: "Assign an agent before starting a run.",
actionHint: "Assign an agent to the task, then start the run.",
});
});
it("normalizes local string errors into action-required notices", () => {
expect(normalizeAppErrorNotice("Select a company before starting a run.")).toEqual({
code: "ACTION_REQUIRED",
message: "Select a company before starting a run.",
actionHint: "Complete the required selection, then retry the action.",
});
});
it("preserves explicit local action hints", () => {
expect(
createAppErrorNotice(
"Select a company before starting a run.",
"COMPANY_REQUIRED",
"Select a company in the sidebar, then start the run.",
),
).toEqual({
code: "COMPANY_REQUIRED",
message: "Select a company before starting a run.",
actionHint: "Select a company in the sidebar, then start the run.",
});
});
});