From 654384280918529f14eb19f046280f5494cac494 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Wed, 14 Oct 2026 10:47:56 +0000 Subject: [PATCH] Show Core errors in the console language --- apps/web/DESIGN.md | 2 +- apps/web/PRODUCT.md | 2 +- apps/web/e2e/resources.spec.ts | 2 +- apps/web/src/features/agents/AgentsPage.tsx | 3 +- .../src/features/api-keys/KeyFlowDialogs.tsx | 2 +- .../src/features/api-keys/key-flows.test.ts | 12 +++-- apps/web/src/features/api-keys/key-flows.ts | 9 +--- .../environment-templates/TemplatesPage.tsx | 3 +- .../features/files/file-operations.test.ts | 33 +------------- .../web/src/features/files/file-operations.ts | 44 +------------------ .../src/features/metrics/AgentMetricsPage.tsx | 10 ++--- .../src/features/metrics/CoreMetricsPage.tsx | 4 +- .../features/metrics/SandboxMetricsPage.tsx | 3 +- .../features/metrics/project-sessions.test.ts | 2 +- .../src/features/metrics/project-sessions.ts | 3 +- .../src/features/overview/OverviewPage.tsx | 26 +++-------- .../features/overview/overview-loader.test.ts | 2 +- .../features/overview/overview-model.test.ts | 2 - .../src/features/overview/overview-model.ts | 9 ++-- apps/web/src/features/sandbox/NodeHost.tsx | 4 +- .../sessions/ExecutorCredentialsSection.tsx | 30 ++++++------- .../features/sessions/SessionDeleteDialog.tsx | 8 ++-- .../web/src/features/sessions/SessionPage.tsx | 13 +++--- .../sessions/SessionRuntimeSection.tsx | 6 +-- .../executor-credential-ownership.test.tsx | 8 +++- .../features/sessions/session-history.test.ts | 5 ++- .../src/features/sessions/session-history.ts | 12 ++--- apps/web/src/features/skills/SkillDetail.tsx | 12 ++--- .../src/features/skills/skill-operations.ts | 13 ------ .../features/system/DefaultModelsSection.tsx | 10 ++--- apps/web/src/features/vaults/VaultsPage.tsx | 9 ++-- apps/web/src/i18n/locales/en/files.ts | 6 --- apps/web/src/i18n/locales/zh-CN/files.ts | 6 --- apps/web/src/i18n/locales/zh-CN/sandbox.ts | 1 - apps/web/src/lib/delete-flow.ts | 10 +++-- apps/web/src/lib/locale.test.ts | 9 ++-- apps/web/src/lib/sandbox-labels.ts | 25 +++++------ 37 files changed, 113 insertions(+), 247 deletions(-) diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index aba2dd85d..5966de38f 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -407,7 +407,7 @@ Signing in and the console tour share one frame: a dark stage on the left (alway The first card on the Overview while any step is to do: a card header ("Getting started", "n of 4 done", a help tip, then a ghost Take the tour button and an icon button that hides it) over four rows split by Faint Rules. Each row has a 22px numbered ring (a check on the tile wash when done), a 13px/600 title over one 12.5px Graphite line, a status dot (Done in green, To do in Pencil, Checking pending, Unknown for a failed read) and one outline action while the step is to do: Set up sandboxes, Add node, Open Nodes or Open sandbox backend; Open Settings; Create project (which continues to the new project's first key) or Issue key; See how to call (the newest active project, preferring one with an active key), or Projects and keys without an active project. Add node, Create project and Issue key open their page with the dialog already open; Open Settings brings the Models and credentials tab to the top of the page body and focuses the default Harness's Set or Replace; See how to call opens the project and, once its keys, usage and address are read, brings its How to call heading to the top of the page body, focused. Only the page body scrolls; the page header stays. Every step done turns it into one line, "You're set", with Take the tour and Dismiss; it stays, through the tour, until dismissed, and the checklist does not come back on its own. The choice is kept per installation in the browser, also while the deployment cannot be read; Show Getting started, a quiet row above the sidebar's account controls, opens it again at any time. ### Sandbox setup -Setting up hosted sandboxes is a set of pages inside Settings → Sandbox and scheduling, one decision each: where sandboxes run (own machines or E2B), then the backend or the E2B account, then the size of each sandbox (three presets; E2B skips it, since each sandbox takes the template build's size), then a review. Choices are large cards that advance on a click; short indigo dashes show the progress; pages slide and blur across. The backend page compares microsandbox and Docker behind a help tip; microsandbox comes first, preselected (a saved backend stays selected), with a neutral Recommended pill beside its title. Docker takes a confirmation (see Dialogs) once per visit to setup; a saved Docker deployment has already made it. The review states where sandboxes run, the size, the Runtime (taken from this console's distribution manifest) and the Core address, read-only: it is config.json's `public_url`, and the console never asks for it. A loopback address carries an amber line under it: only the Core machine reaches it. When Core rejects the configuration for it (E2B with a loopback `public_url`), a red-tinted block under the review keeps Core's message and adds Managed in Settings, which leads to Settings → Service and deployment. A save attempt clears the transient E2B key. Initial setup then asks for it again, with a link to that step; an update may leave it blank to keep the committed key. Advanced settings, one link away, hold the complete form: resources (not for E2B), the Runtime release and the E2B template. A change keeps the saved size and Runtime while the backend stays the same (a saved size outside the presets is offered as Current). Same-backend editing starts at size or E2B credentials with the provider fixed. It is an online configuration update, including when older sandboxes remain: existing node identities and resource ownership are retained. Changing the backend or E2B team requires reset and then a new setup. E2B updates can omit the key to retain it; every explicitly entered key takes the verified replacement path and advances the target generation on success, including the same value. Rejections remain inline with a safe reason and a deliberate way back to reset; never infer teams from a key, auto-reset or auto-resubmit. Optional explanations sit behind help tips; errors and safety consequences remain visible. +Setting up hosted sandboxes is a set of pages inside Settings → Sandbox and scheduling, one decision each: where sandboxes run (own machines or E2B), then the backend or the E2B account, then the size of each sandbox (three presets; E2B skips it, since each sandbox takes the template build's size), then a review. Choices are large cards that advance on a click; short indigo dashes show the progress; pages slide and blur across. The backend page compares microsandbox and Docker behind a help tip; microsandbox comes first, preselected (a saved backend stays selected), with a neutral Recommended pill beside its title. Docker takes a confirmation (see Dialogs) once per visit to setup; a saved Docker deployment has already made it. The review states where sandboxes run, the size, the Runtime (taken from this console's distribution manifest) and the Core address, read-only: it is config.json's `public_url`, and the console never asks for it. A loopback address carries an amber line under it: only the Core machine reaches it. When Core rejects the configuration for it (E2B with a loopback `public_url`), a red-tinted block under the review states the reason and adds Managed in Settings, which leads to Settings → Service and deployment. A save attempt clears the transient E2B key. Initial setup then asks for it again, with a link to that step; an update may leave it blank to keep the committed key. Advanced settings, one link away, hold the complete form: resources (not for E2B), the Runtime release and the E2B template. A change keeps the saved size and Runtime while the backend stays the same (a saved size outside the presets is offered as Current). Same-backend editing starts at size or E2B credentials with the provider fixed. It is an online configuration update, including when older sandboxes remain: existing node identities and resource ownership are retained. Changing the backend or E2B team requires reset and then a new setup. E2B updates can omit the key to retain it; every explicitly entered key takes the verified replacement path and advances the target generation on success, including the same value. Rejections remain inline with a safe reason and a deliberate way back to reset; never infer teams from a key, auto-reset or auto-resubmit. Optional explanations sit behind help tips; errors and safety consequences remain visible. ### Sandbox lifecycle diff --git a/apps/web/PRODUCT.md b/apps/web/PRODUCT.md index fd56dc204..6f1f31bd3 100644 --- a/apps/web/PRODUCT.md +++ b/apps/web/PRODUCT.md @@ -59,7 +59,7 @@ The console runs beside the administrator's own Core, with execution, files and - **Default provider observations.** Each configured Harness offers Usage details for Core's last successful use and any newer classified provider error. Missing records remain unknown; an error at or before the last success is no longer actionable. These are best-effort observations, not readiness checks. Failed refreshes qualify retained records, and replacing the provider starts a new observation history. - **Host connection.** Core's connection observation and credential metadata share one five-second read while visible. Never connected, connected, disconnected, bound credential revoked, and unknown are distinct; a recent heartbeat alone never proves connectivity. Only a fresh connected read marks Host connected. Stale or failed reads withhold completion. Recovery rotates the bound key, stops the installed daemon, replaces the host credential file and starts the daemon again. - **Connect a host.** The Linux/macOS and PowerShell commands come from Core's installation read for the Session's environment; the console shows them as they are, with a link to the native installation guide, and never builds one itself. A command downloads the matching installer, installs the chosen Harnesses, starts the daemon and checks its connection. Its authorization expires after 30 minutes; the console reads a fresh one every 20 minutes, and says the command is unavailable when Core has none. No model readiness is implied. Rotating a credential requires stopping the installed daemon, replacing the configured file and starting that same daemon again. A disconnected daemon may still be running; `start` alone does not replace it. -- **Typed write errors.** Known Core codes use shared bilingual copy and safe typed details. Exact Core field paths attach definite refusals to the relevant input. Other codes show Core's own message in English and name the code in Chinese, since Core's messages are English; uncertain write outcomes stay form-level and are never retried automatically. +- **Typed errors.** Every failure the console shows, read or write, follows one rule. Known Core codes use shared bilingual copy and safe typed details. Exact Core field paths attach definite refusals to the relevant input. Other codes show Core's own message in English and name the code in Chinese, since Core's messages are English; a failure without a code names its HTTP status, and one that never reached Core says so. Uncertain write outcomes stay form-level and are never retried automatically. - **Read failures.** A read is tried again once after a 5xx answer or a failed connection, never after a 4xx answer. Overview and Session log distinguish unavailable reads from successful empty results. Failed reads have a visible retry; a first read that fails never mentions earlier data; retained or partial data says it may be incomplete or out of date, and Session filter totals stay missing while any required read has failed. Only successful empty reads show zero. - **Local-only address.** Overview, Nodes and Settings warn when Core reports `local_only` and lead to Settings → Service and deployment to review the public address. Add node is unavailable with a reason; Getting started keeps the first step to do until the public address is fixed. An unread installation address cannot complete that step, and a failed read offers Retry. - **Figures.** Project, Agent and key usage comes from Core's summary; Agent run, tool and activity figures are still assembled in the browser from bounded reads and state their coverage. Metrics that would need new Core endpoints are not simulated. Usage is cumulative per Session and is not billing. diff --git a/apps/web/e2e/resources.spec.ts b/apps/web/e2e/resources.spec.ts index b836d265e..8344d9291 100644 --- a/apps/web/e2e/resources.spec.ts +++ b/apps/web/e2e/resources.spec.ts @@ -6,7 +6,7 @@ test.afterEach(async ({ request }) => expectManagementBoundary(request)); test("keeps Core's reason when it refuses a deletion, then deletes on confirmation", async ({ page, request }) => { await openConsole(page, request, "agents"); - await failNext(request, { method: "DELETE", path: "/agents/", status: 409, message: "The Agent has Sessions in progress." }); + await failNext(request, { method: "DELETE", path: "/agents/", status: 409, code: "conflict_error", message: "The Agent has Sessions in progress." }); await page.getByRole("button", { name: "Delete Code reviewer?" }).click(); const dialog = page.getByRole("dialog", { name: "Delete Agent?" }); await dialog.getByRole("button", { name: "Delete Agent" }).click(); diff --git a/apps/web/src/features/agents/AgentsPage.tsx b/apps/web/src/features/agents/AgentsPage.tsx index d5fb989fa..b7431d572 100644 --- a/apps/web/src/features/agents/AgentsPage.tsx +++ b/apps/web/src/features/agents/AgentsPage.tsx @@ -8,6 +8,7 @@ import { ConfirmDialog } from "../../components/ConfirmDialog"; import { ApiEmptyState, EmptyState, Kpi, KpiStrip, PageBody, PageHeader, RefreshButton, Section } from "../../components/console-ui"; import { CopyableId, ListToolbar, listSummary, NameCell, RowActions, SearchField } from "../../components/list-ui"; import { useConsoleNavigation } from "../../lib/console-navigation"; +import { coreError } from "../../lib/core-error"; import { useDeleteFlow } from "../../lib/delete-flow"; import { formatCompact, formatDateTime, formatInteger, formatPercent, formatRelative, MISSING } from "../../lib/format"; import { harnessNames, protocolNames } from "../../lib/harness-labels"; @@ -193,7 +194,7 @@ function AgentDetail({ projectId, agentId }: { projectId: string; agentId: strin ); const agent = read.data ?? null; - const failure = read.isError ? (read.error instanceof Error ? read.error.message : String(read.error)) : null; + const failure = read.isError ? coreError(read.error, tCommon) : null; // Detail reads are not polled: each failed read was asked for and is reported. useFailureToast(agent !== null ? failure : null, t("refreshFailed"), "agent-refresh", read.errorUpdatedAt); const name = agent ? agent.name || t("catalog.untitled") : agentId; diff --git a/apps/web/src/features/api-keys/KeyFlowDialogs.tsx b/apps/web/src/features/api-keys/KeyFlowDialogs.tsx index 2077767dc..9accd1df4 100644 --- a/apps/web/src/features/api-keys/KeyFlowDialogs.tsx +++ b/apps/web/src/features/api-keys/KeyFlowDialogs.tsx @@ -13,7 +13,7 @@ export function FlowErrorMessage({ error, name }: { error: FlowError | null; nam const { t } = useTranslation("keys"); const { t: tCommon } = useTranslation("common"); if (!error || (name !== undefined && error.kind === "rejected" && coreFieldError(error.cause, "name", tCommon))) return null; - const text = error.kind === "uncertain" ? t("errors.uncertain") : t("errors.rejected", { message: error.cause ? coreError(error.cause, tCommon) : error.message }); + const text = error.kind === "uncertain" ? t("errors.uncertain") : t("errors.rejected", { message: coreError(error.cause, tCommon) }); return

{text}

; } diff --git a/apps/web/src/features/api-keys/key-flows.test.ts b/apps/web/src/features/api-keys/key-flows.test.ts index 085e63a55..054c63227 100644 --- a/apps/web/src/features/api-keys/key-flows.test.ts +++ b/apps/web/src/features/api-keys/key-flows.test.ts @@ -54,15 +54,13 @@ describe("project and key names", () => { }); describe("write outcomes", () => { - it("separates rejected and uncertain results, keeping Core's reason on a conflict", () => { - expect(flowError(new AgentCoreError("Name is invalid.", 400))).toEqual({ kind: "rejected", status: 400, message: "Name is invalid." }); - expect(flowError(new AgentCoreError("The target Project is archived.", 409))).toEqual({ kind: "rejected", status: 409, message: "The target Project is archived." }); + it("separates rejected and uncertain results, keeping Core's error on a conflict", () => { + const archived = new AgentCoreError("The target Project is archived.", 409, "project_archived"); + expect(flowError(archived)).toEqual({ kind: "rejected", cause: archived }); // A timeout, a server error or a lost connection may have written anyway. expect(flowError(new AgentCoreError("Timeout.", 408))).toEqual({ kind: "uncertain" }); expect(flowError(new AgentCoreError("Down.", 502))).toEqual({ kind: "uncertain" }); expect(flowError(new TypeError("Failed to fetch"))).toEqual({ kind: "uncertain" }); - // The client's own name validation never reached Core. - expect(flowError(new TypeError("A key name has 1–80 characters without leading or trailing spaces."))).toMatchObject({ kind: "rejected" }); }); }); @@ -96,8 +94,8 @@ describe("issuing a key", () => { const busy = run([{ type: "openIssue", project }, { type: "setName", name: "bob" }, { type: "started" }]); expect(keyFlowReducer(busy, { type: "cancel" })).toBe(busy); expect(keyFlowReducer(busy, { type: "setName", name: "changed" })).toBe(busy); - const failed = keyFlowReducer(busy, { type: "failed", error: { kind: "rejected", status: 409, message: "The target Project is archived." } }); - expect(failed).toMatchObject({ step: "issue", busy: false, error: { kind: "rejected", status: 409 }, name: "bob" }); + const failed = keyFlowReducer(busy, { type: "failed", error: flowError(new AgentCoreError("The target Project is archived.", 409, "project_archived")) }); + expect(failed).toMatchObject({ step: "issue", busy: false, error: { kind: "rejected" }, name: "bob" }); expect(keyFlowReducer(failed, { type: "setName", name: "bob-2" })).toMatchObject({ name: "bob-2", error: null }); expect(keyFlowReducer(failed, { type: "cancel" })).toEqual(idleFlow); }); diff --git a/apps/web/src/features/api-keys/key-flows.ts b/apps/web/src/features/api-keys/key-flows.ts index 318d9c605..8d4d04011 100644 --- a/apps/web/src/features/api-keys/key-flows.ts +++ b/apps/web/src/features/api-keys/key-flows.ts @@ -66,16 +66,11 @@ export function isArchiveConfirmed(name: string, activeKeys: number, typed: stri * ("uncertain": check the list first; never retried automatically). */ export type FlowError = - | { kind: "rejected"; status: number; message: string; cause?: AgentCoreError } + | { kind: "rejected"; cause: AgentCoreError } | { kind: "uncertain" }; export function flowError(error: unknown): FlowError { - if (error instanceof AgentCoreError) { - if (error.status >= 400 && error.status < 500 && error.status !== 408) return { kind: "rejected", status: error.status, message: error.message, ...(error.code || error.param ? { cause: error } : {}) }; - return { kind: "uncertain" }; - } - // The client validates names before sending; nothing reached Core. - if (error instanceof TypeError && /name has/i.test(error.message)) return { kind: "rejected", status: 400, message: error.message }; + if (error instanceof AgentCoreError && error.status >= 400 && error.status < 500 && error.status !== 408) return { kind: "rejected", cause: error }; return { kind: "uncertain" }; } diff --git a/apps/web/src/features/environment-templates/TemplatesPage.tsx b/apps/web/src/features/environment-templates/TemplatesPage.tsx index 4b0e04a71..644846134 100644 --- a/apps/web/src/features/environment-templates/TemplatesPage.tsx +++ b/apps/web/src/features/environment-templates/TemplatesPage.tsx @@ -8,6 +8,7 @@ import { ConfirmDialog } from "../../components/ConfirmDialog"; import { ApiEmptyState, EmptyState, HelpTip, PageBody, PageHeader, RefreshButton, StatusDot } from "../../components/console-ui"; import { ListToolbar, listSummary, NameCell, RowActions, SearchField } from "../../components/list-ui"; import { useConsoleNavigation } from "../../lib/console-navigation"; +import { coreError } from "../../lib/core-error"; import { useDeleteFlow } from "../../lib/delete-flow"; import { formatDateTime, MISSING } from "../../lib/format"; import { CreatorCell, CreatorHeading, forgetCreators, ProjectFilter, ProjectName, projectClient, readAllPages, useCreators, useProjectCollection, useProjects, type Owned } from "../../lib/projects"; @@ -183,7 +184,7 @@ function TemplateDetailRoute({ projectId, templateId }: { projectId: string; tem const back = useCallback(() => goBack("templates", { project: projectId }), [goBack, projectId]); const creators = useCreators("environment_template", useMemo(() => [{ projectId, id: templateId }], [projectId, templateId])); const refresh = () => { forgetCreators(); void read.refetch(); }; - const failure = read.isError ? (read.error instanceof Error ? read.error.message : String(read.error)) : null; + const failure = read.isError ? coreError(read.error, tCommon) : null; // Detail reads are not polled: each failed read was asked for and is reported. useFailureToast(read.data ? failure : null, t("refreshFailed"), "template-refresh", read.errorUpdatedAt); diff --git a/apps/web/src/features/files/file-operations.test.ts b/apps/web/src/features/files/file-operations.test.ts index b3a28b58a..90fecd4e0 100644 --- a/apps/web/src/features/files/file-operations.test.ts +++ b/apps/web/src/features/files/file-operations.test.ts @@ -1,13 +1,9 @@ import { describe, expect, it, vi } from "vitest"; -import { AgentCoreError, type SourceFile, type SourceFileListEntry } from "@oac/agents-client"; +import type { SourceFile, SourceFileListEntry } from "@oac/agents-client"; -import i18n from "../../i18n"; import { - classifyFilesListError, - filesErrorReason, filterFiles, - isDefiniteRejection, maxFileUploadBytes, readFilesPage, uploadPrecheck, @@ -23,7 +19,6 @@ function file(id: string, filename: string, created = 1_790_208_000): SourceFile const notes = file("file-16e1f26e-8cf6-4272-9c31-d470b08d31af", "notes.txt"); const input = file("file-2b7c9d10-4e5f-4a6b-8c7d-9e0f1a2b3c4d", "Input.CSV"); const unknown: SourceFileListEntry = { id: "file-3c8d0e21-5f60-4b7c-9d8e-0f1a2b3c4d5e", object: "file", unrecognized: true }; -const t = (key: string) => i18n.t(key as never, { ns: "files" }) as string; describe("upload precheck", () => { it("accepts an empty file and the 512 MiB bound", () => { @@ -43,32 +38,6 @@ describe("upload precheck", () => { }); }); -describe("error mapping", () => { - it("treats only 4xx answers as definite rejections", () => { - expect(isDefiniteRejection(new AgentCoreError("bad", 400))).toBe(true); - expect(isDefiniteRejection(new AgentCoreError("gone", 404))).toBe(true); - // A lost response, a server error or an unrecognized success body may have stored the file. - expect(isDefiniteRejection(new TypeError("Failed to fetch"))).toBe(false); - expect(isDefiniteRejection(new AgentCoreError("down", 503))).toBe(false); - expect(isDefiniteRejection(new AgentCoreError("bad body", 502, "invalid_source_file"))).toBe(false); - }); - - it("classifies a failed first list read", () => { - expect(classifyFilesListError(new AgentCoreError("missing", 404))).toBe("unsupported"); - expect(classifyFilesListError(new AgentCoreError("method", 405))).toBe("unsupported"); - expect(classifyFilesListError(new AgentCoreError("busy", 503))).toBe("failed"); - expect(classifyFilesListError(new TypeError("Failed to fetch"))).toBe("failed"); - }); - - it("gives a short reason without transport details", () => { - expect(filesErrorReason(new AgentCoreError("Invalid purpose.", 400, null, "purpose"), t)).toBe("Invalid purpose."); - expect(filesErrorReason(new AgentCoreError("no", 401), t)).toBe("Core rejected the connection credentials."); - expect(filesErrorReason(new AgentCoreError("big", 413, "request_too_large"), t)).toBe("The file is larger than Core accepts."); - expect(filesErrorReason(new AgentCoreError("x", 502, "invalid_source_file_list"), t)).toBe("Core returned a response this console does not recognize."); - expect(filesErrorReason(new TypeError("Failed to fetch http://10.0.0.1"), t)).toBe("The request did not reach Core or its response was lost."); - }); -}); - describe("loaded-row filter", () => { it("matches file names and IDs case-insensitively", () => { expect(filterFiles([notes, input, unknown], "csv")).toEqual([input]); diff --git a/apps/web/src/features/files/file-operations.ts b/apps/web/src/features/files/file-operations.ts index f7570c530..c4edbf02a 100644 --- a/apps/web/src/features/files/file-operations.ts +++ b/apps/web/src/features/files/file-operations.ts @@ -1,8 +1,4 @@ -import { - AgentCoreError, - type PageOrder, - type SourceFileListEntry, -} from "@oac/agents-client"; +import type { PageOrder, SourceFileListEntry } from "@oac/agents-client"; import type { ProjectClient } from "../../lib/projects"; import { appendCollectionPage } from "../../lib/collection-pagination"; @@ -22,44 +18,6 @@ export function uploadPrecheck(file: { name: string; size: number }): UploadPrec return null; } -/** - * A request that failed with a 4xx answer never stored anything. Everything - * else (a lost response, a server error, an unrecognized success body) may - * have been stored, so the caller must not retry automatically. - */ -export function isDefiniteRejection(error: unknown): error is AgentCoreError { - return error instanceof AgentCoreError && error.status >= 400 && error.status < 500; -} - -export function isAbortError(error: unknown): boolean { - return error instanceof DOMException && error.name === "AbortError"; -} - -export type FilesListFailure = "unsupported" | "failed"; - -/** How a failed first list read is presented. */ -export function classifyFilesListError(error: unknown): FilesListFailure { - if (error instanceof AgentCoreError) { - if (error.status === 404 || error.status === 405) return "unsupported"; - } - return "failed"; -} - -type Translate = (key: string) => string; - -/** A short, safe reason for a failed request. Core's own message is shown for API errors. */ -export function filesErrorReason(error: unknown, t: Translate): string { - if (error instanceof AgentCoreError) { - if (error.status === 401 || error.status === 403) return t("errors.unauthorized"); - if (error.status === 413) return t("errors.tooLarge"); - if (error.status === 502 && typeof error.code === "string" && error.code.startsWith("invalid_source_file")) { - return t("errors.invalidResponse"); - } - return error.message.trim() || t("errors.transport"); - } - return t("errors.transport"); -} - export function isUnrecognizedFile(entry: SourceFileListEntry): entry is Extract { return entry.unrecognized === true; } diff --git a/apps/web/src/features/metrics/AgentMetricsPage.tsx b/apps/web/src/features/metrics/AgentMetricsPage.tsx index d8e303e6a..1e844c558 100644 --- a/apps/web/src/features/metrics/AgentMetricsPage.tsx +++ b/apps/web/src/features/metrics/AgentMetricsPage.tsx @@ -23,6 +23,7 @@ import { import { DashboardSkeleton, TableSkeleton } from "../../components/Skeleton"; import { useFailureToast } from "../../components/Toast"; import { useConsoleNavigation } from "../../lib/console-navigation"; +import { coreError } from "../../lib/core-error"; import { formatClock, formatCompact, formatDuration, formatInteger, formatPercent, formatRelative, MISSING } from "../../lib/format"; import { ProjectFilter, ProjectName, useProjectCollection, useProjects, type ProjectFilterValue } from "../../lib/projects"; import { collections } from "../../lib/queries"; @@ -58,10 +59,6 @@ type KeyUsageState = | { status: "ready"; rows: KeyUsageRow[] } | { status: "failed"; error: string }; -function errorText(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - function bucketLabel(seconds: number, t: TFunction<"metrics">): string { return seconds >= 3_600 ? t("bucket.hours", { count: seconds / 3_600 }) : t("bucket.minutes", { count: seconds / 60 }); } @@ -71,18 +68,19 @@ function bucketLabel(seconds: number, t: TFunction<"metrics">): string { * filter or range keeps the last figures on screen until the new ones arrive. */ function useAgentMetricsData(projects: readonly AdminProject[], projectsReady: boolean, filter: ProjectFilterValue, range: AgentMetricsRange) { + const { t } = useTranslation("common"); const targets = useMemo(() => projects.filter((project) => !filter || project.id === filter), [filter, projects]); const metricsQuery = useQuery({ ...agentMetricsQuery(targets, filter, range), enabled: projectsReady, placeholderData: keepPreviousData }); const keyQuery = useQuery({ ...keyUsageQuery(filter, range), enabled: projectsReady, placeholderData: keepPreviousData }); const loaded = metricsQuery.data ?? null; let state: LoadState; - if (metricsQuery.isError && !metricsQuery.isFetching) state = { status: "failed", error: errorText(metricsQuery.error), previous: loaded }; + if (metricsQuery.isError && !metricsQuery.isFetching) state = { status: "failed", error: coreError(metricsQuery.error, t), previous: loaded }; else if (loaded && !metricsQuery.isFetching) state = { status: "ready", loaded }; else state = { status: "loading", previous: loaded }; let keyUsage: KeyUsageState; - if (keyQuery.isError && !keyQuery.isFetching) keyUsage = { status: "failed", error: errorText(keyQuery.error) }; + if (keyQuery.isError && !keyQuery.isFetching) keyUsage = { status: "failed", error: coreError(keyQuery.error, t) }; else if (keyQuery.data) keyUsage = { status: "ready", rows: keyQuery.data }; else keyUsage = { status: "loading" }; diff --git a/apps/web/src/features/metrics/CoreMetricsPage.tsx b/apps/web/src/features/metrics/CoreMetricsPage.tsx index 0a6a3f06f..2fc0a9297 100644 --- a/apps/web/src/features/metrics/CoreMetricsPage.tsx +++ b/apps/web/src/features/metrics/CoreMetricsPage.tsx @@ -9,6 +9,7 @@ import { TimeSeriesChart } from "../../components/charts/TimeSeriesChart"; import { DashboardSkeleton } from "../../components/Skeleton"; import { useFailureToast } from "../../components/Toast"; import { EmptyState, Kpi, KpiStrip, PageBody, PageHeader, RefreshButton, Section, SegmentedControl, StatusDot, type Tone } from "../../components/console-ui"; +import { coreError } from "../../lib/core-error"; import { epochSeconds, formatBucket, formatBytes, formatClock, formatDuration, formatInteger, formatRelative, MISSING } from "../../lib/format"; import { coreMetricsQuery } from "./metrics-queries"; import "./MetricsView.css"; @@ -49,12 +50,13 @@ function Figure({ value, unit }: { value: ReactNode; unit?: ReactNode }) { */ export function CoreMetricsPage() { const { t, i18n } = useTranslation("metrics"); + const { t: tCommon } = useTranslation("common"); const locale = i18n.resolvedLanguage; const [range, setRange] = useState("1h"); const query = useQuery({ ...coreMetricsQuery(range), placeholderData: keepPreviousData, refetchInterval: REFRESH_MS, refetchIntervalInBackground: false }); const metrics = query.data ?? null; const missing = query.error instanceof AgentCoreError && (query.error.status === 404 || query.error.status === 501); - const error = query.error instanceof Error ? query.error.message : query.error ? String(query.error) : ""; + const error = query.isError ? coreError(query.error, tCommon) : ""; useFailureToast(metrics !== null && query.isError, t("core.stale", { reason: error }), "core-metrics-refresh"); let body: ReactNode; diff --git a/apps/web/src/features/metrics/SandboxMetricsPage.tsx b/apps/web/src/features/metrics/SandboxMetricsPage.tsx index 9ed33e12f..332dcbdf1 100644 --- a/apps/web/src/features/metrics/SandboxMetricsPage.tsx +++ b/apps/web/src/features/metrics/SandboxMetricsPage.tsx @@ -56,6 +56,7 @@ type RuntimeState = /** Hosted Runtimes through the query cache, polled while the page is visible; a refresh keeps the last load on screen. */ function useHostedRuntimes() { + const { t } = useTranslation("common"); const query = useQuery({ ...hostedRuntimesQuery, refetchInterval: RUNTIME_SNAPSHOT_REFRESH_MS, @@ -63,7 +64,7 @@ function useHostedRuntimes() { }); const load = query.data ?? null; let state: RuntimeState; - const error = query.isError ? (query.error instanceof Error ? query.error.message : String(query.error)) : null; + const error = query.isError ? coreError(query.error, t) : null; if (error !== null && !query.isFetching) state = { status: "failed", load, error }; else if (load && !query.isFetching) state = { status: "ready", load }; else state = { status: "loading", load }; diff --git a/apps/web/src/features/metrics/project-sessions.test.ts b/apps/web/src/features/metrics/project-sessions.test.ts index c4cffbd5d..4da4f0a22 100644 --- a/apps/web/src/features/metrics/project-sessions.test.ts +++ b/apps/web/src/features/metrics/project-sessions.test.ts @@ -32,6 +32,6 @@ describe("readProjectsSessions", () => { () => ({ maxSessions: 100 }), ); expect(reads.map((read) => [read.project.id, read.sessions.length])).toEqual([["ok", 3]]); - expect(failures.map((failure) => [failure.project.id, failure.message])).toEqual([["down", "HTTP 503"]]); + expect(failures.map((failure) => failure.project.id)).toEqual(["down"]); }); }); diff --git a/apps/web/src/features/metrics/project-sessions.ts b/apps/web/src/features/metrics/project-sessions.ts index 0df779462..fa8344f1e 100644 --- a/apps/web/src/features/metrics/project-sessions.ts +++ b/apps/web/src/features/metrics/project-sessions.ts @@ -49,7 +49,6 @@ export interface ProjectSessionRead extends SessionRead { export interface ProjectReadFailure { project: AdminProject; - message: string; } /** Reads several projects in parallel; a failing project is reported by name and the others still count. */ @@ -63,7 +62,7 @@ export async function readProjectsSessions( const failures: ProjectReadFailure[] = []; results.forEach((result, index) => { if (result.status === "fulfilled") reads.push(result.value); - else failures.push({ project: projects[index]!, message: result.reason instanceof Error ? result.reason.message : String(result.reason) }); + else failures.push({ project: projects[index]! }); }); return { reads, failures }; } diff --git a/apps/web/src/features/overview/OverviewPage.tsx b/apps/web/src/features/overview/OverviewPage.tsx index 323dbf84b..3bd74942a 100644 --- a/apps/web/src/features/overview/OverviewPage.tsx +++ b/apps/web/src/features/overview/OverviewPage.tsx @@ -35,7 +35,6 @@ import { FleetOverview, FLEET_LIMIT, type CloudHost } from "./FleetOverview"; import { SessionFailure } from "../sessions/session-diagnostics"; import { useWaitingFor } from "../sessions/SessionStatus"; import { GettingStarted } from "./GettingStarted"; -import { type OverviewData } from "./overview-loader"; import { overviewQuery } from "./overview-queries"; import { activityStart, @@ -59,15 +58,6 @@ const ATTENTION_LIMIT = 8; const serviceTone: Record = { healthy: "ok", degraded: "warning", down: "danger", unknown: "pending" }; const coreTone: Record = { checking: "pending", running: "ok", unreachable: "danger" }; -type LoadState = - | { status: "loading"; data: OverviewData | null } - | { status: "ready"; data: OverviewData } - | { status: "failed"; data: OverviewData | null; error: string }; - -function errorText(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - /** * The Overview's reads through the query cache: a revisit opens from the cache, * a refresh retains figures within the same project scope, and the @@ -80,16 +70,10 @@ function useOverviewData(projects: readonly AdminProject[], projectsReady: boole refetchInterval: OVERVIEW_REFRESH_MS, refetchIntervalInBackground: false, }); - const data = query.data ?? null; - let state: LoadState; - if (query.isError && !query.isFetching) state = { status: "failed", data, error: errorText(query.error) }; - else if (data && !query.isFetching) state = { status: "ready", data }; - else state = { status: "loading", data }; - const { refetch } = query; const refresh = useCallback(() => { void refetch(); }, [refetch]); // The failure lasts through the polls that retry it, so its toast shows once. - return { state, refresh, refreshing: query.isFetching, failure: failedLast(query) ? errorText(query.error) : null }; + return { data: query.data ?? null, refresh, refreshing: query.isFetching, failed: failedLast(query) }; } export function OverviewPage() { @@ -102,12 +86,12 @@ export function OverviewPage() { const projectsReady = projectsState.state.status === "ready" || projects.length > 0; const projectsFailed = projectsState.state.status === "failed" || projectsState.refreshError !== null; const installation = useQuery(installationQuery); - const { state, refresh, refreshing, failure } = useOverviewData(projects, projectsReady); + const { data: loaded, refresh, refreshing, failed } = useOverviewData(projects, projectsReady); const { state: fleetState, refresh: refreshFleet, deployment } = useSandboxFleet(); const fleet = fleetSnapshot(fleetState); - const data = projectsReady ? state.data : null; + const data = projectsReady ? loaded : null; const refreshAll = () => { projectsState.refresh(); if (projectsReady) refresh(); refreshFleet(); void installation.refetch(); }; - const readFailed = projectsFailed || failure !== null; + const readFailed = projectsFailed || failed; const sessionReadsFailed = readFailed || Boolean(data?.sessions.failures.length); const now = Math.floor((data?.loadedAt ?? Date.now()) / 1000); @@ -168,7 +152,7 @@ export function OverviewPage() { : summaryError !== null || sessionReadsFailed ? "failed" : null; // Each failed read is its own toast, shown once while it lasts. - useFailureToast(failure !== null, tCommon("readFailure.unknown"), "overview-load"); + useFailureToast(failed, tCommon("readFailure.unknown"), "overview-load"); useFailureToast(summaryError !== null, t("errors.summary", { reason: summaryError === null ? "" : coreError(summaryError, tCommon) }), "overview-summary"); useFailureToast(readFailures.length > 0, tCommon("project.partial", { names: readFailures.map((entry) => entry.project.name).join(", ") }), "overview-partial"); useFailureToast(projectsFailed, t("errors.projects", { reason: projectsState.state.status === "failed" ? projectsState.state.error : projectsState.refreshError ?? "" }), "overview-projects"); diff --git a/apps/web/src/features/overview/overview-loader.test.ts b/apps/web/src/features/overview/overview-loader.test.ts index 8d0404d02..b779c2cb0 100644 --- a/apps/web/src/features/overview/overview-loader.test.ts +++ b/apps/web/src/features/overview/overview-loader.test.ts @@ -68,7 +68,7 @@ describe("loadOverview", () => { }, NOW, new AbortController().signal); expect(data.summary.status).toBe("failed"); expect(data.sessions.sessions).toHaveLength(30); - expect(data.sessions.failures.map((failure) => [failure.project.id, failure.message])).toEqual([["broken", "boom"]]); + expect(data.sessions.failures.map((failure) => failure.project.id)).toEqual(["broken"]); }); it("propagates an abort", async () => { diff --git a/apps/web/src/features/overview/overview-model.test.ts b/apps/web/src/features/overview/overview-model.test.ts index 82b77fedc..f12f7c474 100644 --- a/apps/web/src/features/overview/overview-model.test.ts +++ b/apps/web/src/features/overview/overview-model.test.ts @@ -77,8 +77,6 @@ describe("service health", () => { expect(isGatewayOrNetworkFailure(new AgentCoreError("Core returned HTTP 502.", 502))).toBe(true); expect(isGatewayOrNetworkFailure(new AgentCoreError("Not found.", 404))).toBe(false); expect(isGatewayOrNetworkFailure(new TypeError("Failed to fetch"))).toBe(true); - expect(isGatewayOrNetworkFailure("Core returned HTTP 503.")).toBe(true); - expect(isGatewayOrNetworkFailure("Core returned HTTP 500.")).toBe(false); }); it("decides reachability from any settled read", () => { diff --git a/apps/web/src/features/overview/overview-model.ts b/apps/web/src/features/overview/overview-model.ts index 04eff409e..a77df0860 100644 --- a/apps/web/src/features/overview/overview-model.ts +++ b/apps/web/src/features/overview/overview-model.ts @@ -1,4 +1,4 @@ -import type { AdminProject, AgentSession } from "@oac/agents-client"; +import { AgentCoreError, type AdminProject, type AgentSession } from "@oac/agents-client"; import type { CapacitySummary } from "../fleet/fleet-model"; import type { InProject } from "../metrics/project-sessions"; @@ -80,10 +80,9 @@ export function overviewReadDone(sessions: readonly AgentSession[], since: numbe /** A failure where the Web API itself did not answer: a gateway status or a network error. */ export function isGatewayOrNetworkFailure(error: unknown): boolean { - const status = typeof error === "object" && error !== null && "status" in error ? (error as { status: unknown }).status : undefined; - if (typeof status === "number") return status === 502 || status === 503 || status === 504; - const message = typeof error === "string" ? error : error instanceof Error ? error.message : ""; - return /HTTP 50[234]\b|\(50[234]\)|failed to fetch|networkerror|load failed|network request failed/i.test(message); + if (error instanceof AgentCoreError) return error.status === 502 || error.status === 503 || error.status === 504; + // fetch rejects with a TypeError when no response arrives; each browser words it differently. + return error instanceof TypeError && /failed to fetch|networkerror|load failed|network request failed/i.test(error.message); } export type ReadOutcome = { status: "pending" } | { status: "ready" } | { status: "failed"; error: unknown }; diff --git a/apps/web/src/features/sandbox/NodeHost.tsx b/apps/web/src/features/sandbox/NodeHost.tsx index 1b5599d25..5d7f28249 100644 --- a/apps/web/src/features/sandbox/NodeHost.tsx +++ b/apps/web/src/features/sandbox/NodeHost.tsx @@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next"; import { TimeSeriesChart } from "../../components/charts/TimeSeriesChart"; import { Section, SegmentedControl } from "../../components/console-ui"; import { TableSkeleton } from "../../components/Skeleton"; +import { coreError } from "../../lib/core-error"; import { formatBucket, formatBytes, formatInteger, MISSING } from "../../lib/format"; import { nodeDetailQuery } from "../fleet/fleet-queries"; @@ -25,6 +26,7 @@ function formatShare(value: number, locale?: string): string { */ export function NodeHost({ node, reporting }: { node: SandboxNode; reporting: boolean }) { const { t, i18n } = useTranslation("sandbox"); + const { t: tCommon } = useTranslation("common"); const locale = i18n.resolvedLanguage; const [range, setRange] = useState("1h"); const detail = useQuery({ ...nodeDetailQuery(node.id, range), placeholderData: keepPreviousData, refetchInterval: 30_000, refetchIntervalInBackground: false }); @@ -51,7 +53,7 @@ export function NodeHost({ node, reporting }: { node: SandboxNode; reporting: bo
{t("Free disk")}
{reporting ? formatBytes(node.available_disk_bytes) : MISSING}
{current ? - : detail.isError ?

{t("Host history could not be read. {{reason}}", { reason: detail.error instanceof Error ? detail.error.message : "" })}

+ : detail.isError ?

{t("Host history could not be read. {{reason}}", { reason: coreError(detail.error, tCommon) })}

: } ); diff --git a/apps/web/src/features/sessions/ExecutorCredentialsSection.tsx b/apps/web/src/features/sessions/ExecutorCredentialsSection.tsx index 6ce5664a3..b668a1599 100644 --- a/apps/web/src/features/sessions/ExecutorCredentialsSection.tsx +++ b/apps/web/src/features/sessions/ExecutorCredentialsSection.tsx @@ -11,6 +11,7 @@ import { CopyableId, RowActions } from "../../components/list-ui"; import { Modal } from "../../components/Modal"; import { TableSkeleton } from "../../components/Skeleton"; import { failedLast, useFailureToast, useToast } from "../../components/Toast"; +import { coreError } from "../../lib/core-error"; import { useDeleteFlow } from "../../lib/delete-flow"; import { epochSeconds, formatDateTime, shortId } from "../../lib/format"; import { admin, useProjects } from "../../lib/projects"; @@ -34,22 +35,16 @@ function newKeyId(): string { } /** - * A write either failed before Core acted ("rejected", with Core's reason) or + * A write either failed before Core acted ("rejected", with Core's error) or * has an unknown outcome: no answer, a timeout, a 5xx or an unreadable reply. */ -type WriteFailure = { kind: "rejected"; message: string; code: string | null } | { kind: "uncertain" }; +type WriteFailure = { kind: "rejected"; cause: AgentCoreError } | { kind: "uncertain" }; function writeFailure(error: unknown): WriteFailure { - if (error instanceof AgentCoreError && error.status >= 400 && error.status < 500 && error.status !== 408) { - return { kind: "rejected", message: error.message, code: error.code ?? null }; - } + if (error instanceof AgentCoreError && error.status >= 400 && error.status < 500 && error.status !== 408) return { kind: "rejected", cause: error }; return { kind: "uncertain" }; } -function message(error: unknown): string { - return error instanceof Error ? error.message : String(error ?? ""); -} - function sameKey(a: string, b: string): boolean { return a.toLowerCase() === b.toLowerCase(); } @@ -71,6 +66,7 @@ function sameKey(a: string, b: string): boolean { */ export function ExecutorCredentialsSection({ projectId, sessionId, environmentId }: { projectId: string; sessionId: string; environmentId: string; remoteUrl: string; workspaceDirectory?: string }) { const { t, i18n } = useTranslation("sessions"); + const { t: tCommon } = useTranslation("common"); const locale = i18n.resolvedLanguage; const toast = useToast(); const { byId, refresh: refreshProjects } = useProjects(); @@ -83,7 +79,7 @@ export function ExecutorCredentialsSection({ projectId, sessionId, environmentId const boundCredential = boundExecutorCredential(query.data); const { refetch } = query; const reread = () => { void refetch(); }; - useFailureToast(credentials && failedLast(query) ? message(query.error) : null, t("executor.refreshFailed"), "executor-credentials-read"); + useFailureToast(credentials && failedLast(query) ? coreError(query.error, tCommon) : null, t("executor.refreshFailed"), "executor-credentials-read"); // The key ID of an issuance in flight or with an unknown outcome; the next Issue sends it again unless it is listed. const [kept, setKept] = useState(null); @@ -154,20 +150,20 @@ export function ExecutorCredentialsSection({ projectId, sessionId, environmentId return; } setKept(null); - if (failure.code === "executor_credential_exists") { + if (failure.cause.code === "executor_credential_exists") { // An earlier attempt with this key ID may have been issued after all, its secret lost. const found = await findListed(keyId); if (!found || !recoverLost(found)) { - toast.show(t("executor.issueRejected"), { tone: "error", detail: found?.revoked_at ? t("executor.existsRevoked", { id: shortId(keyId) }) : failure.message }); + toast.show(t("executor.issueRejected"), { tone: "error", detail: found?.revoked_at ? t("executor.existsRevoked", { id: shortId(keyId) }) : coreError(failure.cause, tCommon) }); } return; } - if (failure.code === "project_archived") { + if (failure.cause.code === "project_archived") { // Archived meanwhile: read the project again so issuing and rotating disappear. toast.show(t("executor.issueRejected"), { tone: "error", detail: t("executor.archived") }); refreshProjects(); } else { - toast.show(t("executor.issueRejected"), { tone: "error", detail: failure.message }); + toast.show(t("executor.issueRejected"), { tone: "error", detail: coreError(failure.cause, tCommon) }); } reread(); } finally { @@ -198,11 +194,11 @@ export function ExecutorCredentialsSection({ projectId, sessionId, environmentId setShown({ credential, open: true, done: false }); } catch (error) { const failure = writeFailure(error); - if (failure.kind === "rejected" && failure.code === "project_archived") { + if (failure.kind === "rejected" && failure.cause.code === "project_archived") { setRotationError(t("executor.archived")); refreshProjects(); } else { - setRotationError(failure.kind === "uncertain" ? t("executor.rotateDialog.uncertain") : t("executor.rotateDialog.rejected", { reason: failure.message })); + setRotationError(failure.kind === "uncertain" ? t("executor.rotateDialog.uncertain") : t("executor.rotateDialog.rejected", { reason: coreError(failure.cause, tCommon) })); } } finally { setRotating(false); @@ -219,7 +215,7 @@ export function ExecutorCredentialsSection({ projectId, sessionId, environmentId let body; if (!credentials) { body = query.isError - ? + ? : ; } else if (!credentials.length) { body = ; diff --git a/apps/web/src/features/sessions/SessionDeleteDialog.tsx b/apps/web/src/features/sessions/SessionDeleteDialog.tsx index 52a6ce180..a4dbba0e6 100644 --- a/apps/web/src/features/sessions/SessionDeleteDialog.tsx +++ b/apps/web/src/features/sessions/SessionDeleteDialog.tsx @@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next"; import { ConfirmDialog } from "../../components/ConfirmDialog"; import { useToast } from "../../components/Toast"; +import { coreError } from "../../lib/core-error"; import { projectClient } from "../../lib/projects"; import { shortId } from "../../lib/format"; @@ -17,7 +18,7 @@ export type SessionDeleteOutcome = | { kind: "missing" } /** Core refused: the Session has work in flight. */ | { kind: "not-idle" } - | { kind: "rejected"; reason: string } + | { kind: "rejected"; cause: AgentCoreError } /** No answer from Core; the Session may or may not be gone. Never retried automatically. */ | { kind: "uncertain" }; @@ -34,7 +35,7 @@ export async function deleteSession(target: SessionDeleteTarget): Promise= 400 && error.status < 500) return { kind: "rejected", reason: error.message }; + if (error instanceof AgentCoreError && error.status >= 400 && error.status < 500) return { kind: "rejected", cause: error }; return { kind: "uncertain" }; } @@ -56,6 +57,7 @@ export function SessionDeleteDialog({ onUncertain: () => void; }) { const { t } = useTranslation("sessions"); + const { t: tCommon } = useTranslation("common"); const toast = useToast(); const [busy, setBusy] = useState(false); const [problem, setProblem] = useState(null); @@ -78,7 +80,7 @@ export function SessionDeleteDialog({ return; } if (outcome.kind === "uncertain") onUncertain(); - setProblem(outcome.kind === "not-idle" ? t("delete.notIdle") : outcome.kind === "rejected" ? t("delete.rejected", { reason: outcome.reason }) : t("delete.uncertain")); + setProblem(outcome.kind === "not-idle" ? t("delete.notIdle") : outcome.kind === "rejected" ? t("delete.rejected", { reason: coreError(outcome.cause, tCommon) }) : t("delete.uncertain")); }; return ( diff --git a/apps/web/src/features/sessions/SessionPage.tsx b/apps/web/src/features/sessions/SessionPage.tsx index 9e8bf4a7a..4187e3ddb 100644 --- a/apps/web/src/features/sessions/SessionPage.tsx +++ b/apps/web/src/features/sessions/SessionPage.tsx @@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useFailureToast } from "../../components/Toast"; +import { coreError } from "../../lib/core-error"; import { DetailSkeleton } from "../../components/Skeleton"; import { EmptyState, Kpi, KpiStrip, PageBody, PageHeader, RefreshButton, revealInPageBody, Section, SegmentedControl } from "../../components/console-ui"; import { CopyableId } from "../../components/list-ui"; @@ -30,10 +31,6 @@ import "./sessions.css"; type HistoryView = "conversation" | "trace" | "turns"; -function errorText(error: unknown): string { - return error instanceof Error ? error.message : String(error ?? ""); -} - /** * Monitor › Session log › one Session: its facts, usage, conversation, trace, * Turns and (for hosted sandboxes) runtime, read-only; a self-hosted Session's @@ -86,10 +83,10 @@ export function SessionPage() { if (projectId && sessionId) void queryClient.invalidateQueries({ queryKey: [...sessionKey(projectId, sessionId), "executor-credentials"] }); }; const time = loadedAt ? formatClock(loadedAt, locale) : MISSING; - const itemsError = session ? history.history?.itemsError ?? null : null; - const turnsError = session ? history.history?.turnsError ?? null : null; + const itemsError = session && history.history?.itemsError ? coreError(history.history.itemsError, tCommon) : null; + const turnsError = session && history.history?.turnsError ? coreError(history.history.turnsError, tCommon) : null; // Failed reads that leave the last history on screen are reported in toasts. - useFailureToast(session !== null && !history.gone && history.error ? errorText(history.error) : null, t("detail.stale"), "session-refresh"); + useFailureToast(session !== null && !history.gone && history.error ? coreError(history.error, tCommon) : null, t("detail.stale"), "session-refresh"); useFailureToast(Boolean(itemsError), t("history.itemsFailed", { reason: itemsError ?? "" }), "session-items"); useFailureToast(Boolean(turnsError), t("history.turnsFailed", { reason: turnsError ?? "" }), "session-turns"); @@ -101,7 +98,7 @@ export function SessionPage() { } else if (!session) { if (history.phase === "loading") body = ; else if (isNotFound(history.error)) body = {t("detail.back")}} />; - else body = {t("detail.retry")}} />; + else body = {t("detail.retry")}} />; } else { const items = history.history?.items ?? []; const turns = history.history?.turns ?? []; diff --git a/apps/web/src/features/sessions/SessionRuntimeSection.tsx b/apps/web/src/features/sessions/SessionRuntimeSection.tsx index f5a663aca..ba871fce9 100644 --- a/apps/web/src/features/sessions/SessionRuntimeSection.tsx +++ b/apps/web/src/features/sessions/SessionRuntimeSection.tsx @@ -23,10 +23,6 @@ function sampleSpacing(samples: readonly RuntimeTrendSample[]): number { const HISTORY_REFRESH_MS = 30_000; -function message(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - /** Runs `action` whenever `value` changes after the first render. */ function useOnChange(value: unknown, action: () => void) { const seen = useRef(value); @@ -66,7 +62,7 @@ export function SessionRuntimeSection({ const observationRead = useQuery(sessionObservationQuery(projectId, session.id)); useOnChange(revision, observationRead.refetch); const observation = observationRead.data ?? null; - const observationError = observationRead.isError ? message(observationRead.error) : null; + const observationError = observationRead.isError ? coreError(observationRead.error, tCommon) : null; // The Session object changes on every poll; its ID and the range decide what to read. const historyRead = useQuery({ diff --git a/apps/web/src/features/sessions/executor-credential-ownership.test.tsx b/apps/web/src/features/sessions/executor-credential-ownership.test.tsx index 92034e579..09874e001 100644 --- a/apps/web/src/features/sessions/executor-credential-ownership.test.tsx +++ b/apps/web/src/features/sessions/executor-credential-ownership.test.tsx @@ -1,4 +1,4 @@ -import type { ExecutorCredentialList } from "@oac/agents-client"; +import { AgentCoreError, type ExecutorCredentialList } from "@oac/agents-client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { renderToStaticMarkup } from "react-dom/server"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -32,7 +32,7 @@ function render(value: ExecutorCredentialList | null, failed = false) { const options = executorConnectionQuery("project", "session", "environment"); if (value) client.setQueryData(options.queryKey, value); else client.getQueryCache().build(client, { queryKey: options.queryKey }); - if (failed) client.getQueryCache().find({ queryKey: options.queryKey })!.setState({ status: "error", error: new Error("unavailable"), errorUpdatedAt: Date.now() + 1_000 }); + if (failed) client.getQueryCache().find({ queryKey: options.queryKey })!.setState({ status: "error", error: new AgentCoreError("Credential store unavailable.", 503, "credential_store_unavailable"), errorUpdatedAt: Date.now() + 1_000 }); const html = renderToStaticMarkup(); client.clear(); return html; @@ -80,5 +80,9 @@ describe("executor credential display ownership", () => { expect(html.match(/aria-label="轮换以恢复"/g)).toHaveLength(1); expect(html).toContain("刷新 · 主机连接"); expect(html).toContain('data-host-connected="false"'); + // Core's messages are English: a failed read names its code instead. + const failed = render(null, true); + expect(failed).toContain("Core 请求失败(credential_store_unavailable)。"); + expect(failed).not.toContain("Credential store unavailable."); }); }); diff --git a/apps/web/src/features/sessions/session-history.test.ts b/apps/web/src/features/sessions/session-history.test.ts index 5e53c273c..f6157646d 100644 --- a/apps/web/src/features/sessions/session-history.test.ts +++ b/apps/web/src/features/sessions/session-history.test.ts @@ -83,7 +83,7 @@ describe("Incremental history reads", () => { const history = await loadSessionHistory(client, "session", previous, { incremental: true }); expect(history.session?.status).toBe("idle"); expect(history.items).toBe(previous.items); - expect(history.itemsError).toBe("Items unavailable"); + expect(history.itemsError).toBeInstanceOf(AgentCoreError); expect(history.turns.map((value) => value.status)).toEqual(["completed"]); expect(history.sessionError).toBeNull(); }); @@ -120,7 +120,8 @@ describe("Session deletion and runtime answers", () => { it("tells a busy Session, a missing one, a refusal and an unconfirmed deletion apart", () => { expect(classifyDeleteError(new AgentCoreError("busy", 409, "conflict_error"))).toEqual({ kind: "not-idle" }); expect(classifyDeleteError(new AgentCoreError("missing", 404))).toEqual({ kind: "missing" }); - expect(classifyDeleteError(new AgentCoreError("nope", 403))).toEqual({ kind: "rejected", reason: "nope" }); + const refusal = new AgentCoreError("nope", 403); + expect(classifyDeleteError(refusal)).toEqual({ kind: "rejected", cause: refusal }); expect(classifyDeleteError(new AgentCoreError("down", 503))).toEqual({ kind: "uncertain" }); expect(classifyDeleteError(new TypeError("Failed to fetch"))).toEqual({ kind: "uncertain" }); }); diff --git a/apps/web/src/features/sessions/session-history.ts b/apps/web/src/features/sessions/session-history.ts index ec41ec12c..ec717b9df 100644 --- a/apps/web/src/features/sessions/session-history.ts +++ b/apps/web/src/features/sessions/session-history.ts @@ -83,17 +83,13 @@ export interface SessionHistory { turns: AgentTurn[]; /** Why the Session itself could not be read; earlier data stays shown. */ sessionError: unknown; - itemsError: string | null; - turnsError: string | null; + itemsError: unknown; + turnsError: unknown; loadedAt: number; } type HistoryReader = Pick; -function message(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - /** * Reads the Session, its Items and its Turns in parallel. With `incremental` * only the part of each list after its settled prefix is read again; each part @@ -117,8 +113,8 @@ export async function loadSessionHistory( items: items.status === "fulfilled" ? items.value : previous?.items ?? [], turns: turns.status === "fulfilled" ? turns.value : previous?.turns ?? [], sessionError: session.status === "rejected" ? session.reason : null, - itemsError: items.status === "rejected" ? message(items.reason) : null, - turnsError: turns.status === "rejected" ? message(turns.reason) : null, + itemsError: items.status === "rejected" ? items.reason : null, + turnsError: turns.status === "rejected" ? turns.reason : null, loadedAt: Date.now(), }; } diff --git a/apps/web/src/features/skills/SkillDetail.tsx b/apps/web/src/features/skills/SkillDetail.tsx index cf504ad38..7768f7717 100644 --- a/apps/web/src/features/skills/SkillDetail.tsx +++ b/apps/web/src/features/skills/SkillDetail.tsx @@ -13,10 +13,10 @@ import { EmptyState, PageBody, PageHeader, RefreshButton, Section, StatusDot } f import { Modal } from "../../components/Modal"; import { DetailSkeleton, TableSkeleton } from "../../components/Skeleton"; import { useFailureToast, useToast } from "../../components/Toast"; +import { coreError } from "../../lib/core-error"; import { formatDateTime, MISSING } from "../../lib/format"; import { CopyableId, LatestVersion } from "./skill-parts"; import { - coreErrorMessage, downloadSkillArchive, isDefaultVersionConflict, readSkillVersionsPage, @@ -287,7 +287,7 @@ export function SkillDetail({ core, projectId, skillId, initialSkill, onBack, on const skill = skillRead.data ?? null; const skillError = skillRead.isError ? skillRead.error : null; const status: SkillLoadStatus = isNotFound(skillError) ? "missing" : skillError ? "failed" : skill ? "ready" : "loading"; - const error = skillError ? coreErrorMessage(skillError) : null; + const error = skillError ? coreError(skillError, tCommon) : null; const freshSkill = skillRead.isPlaceholderData ? undefined : skillRead.data; useEffect(() => { @@ -299,7 +299,7 @@ export function SkillDetail({ core, projectId, skillId, initialSkill, onBack, on status: !versionsData && versionsRead.isFetching ? "loading" : versionsRead.isError ? "failed" : versionsData ? "ready" : "loading", items: versionsData?.values ?? [], nextAfter: versionsData?.nextAfter ?? null, - error: versionsRead.isError ? coreErrorMessage(versionsRead.error) : null, + error: versionsRead.isError ? coreError(versionsRead.error, tCommon) : null, loadingMore: more.loading, moreError: more.error, }; @@ -333,7 +333,7 @@ export function SkillDetail({ core, projectId, skillId, initialSkill, onBack, on setMore({ loading: false, error: null }); } catch (reason) { if (!mounted.current) return; - setMore({ loading: false, error: coreErrorMessage(reason) }); + setMore({ loading: false, error: coreError(reason, tCommon) }); } }; @@ -343,7 +343,7 @@ export function SkillDetail({ core, projectId, skillId, initialSkill, onBack, on try { await downloadSkillArchive(core, skill, version); } catch (reason) { - if (mounted.current) toast.show(t("detail.downloadFailed", { reason: coreErrorMessage(reason) }), { tone: "error" }); + if (mounted.current) toast.show(t("detail.downloadFailed", { reason: coreError(reason, tCommon) }), { tone: "error" }); } finally { if (mounted.current) setDownloading(null); } @@ -380,7 +380,7 @@ export function SkillDetail({ core, projectId, skillId, initialSkill, onBack, on listChanged(); load(); } else { - setDialogError(t("detail.actionFailed", { reason: coreErrorMessage(reason) })); + setDialogError(t("detail.actionFailed", { reason: coreError(reason, tCommon) })); } } finally { dialogBusyRef.current = false; diff --git a/apps/web/src/features/skills/skill-operations.ts b/apps/web/src/features/skills/skill-operations.ts index 5bb0fa45d..52fd7a2c1 100644 --- a/apps/web/src/features/skills/skill-operations.ts +++ b/apps/web/src/features/skills/skill-operations.ts @@ -24,19 +24,6 @@ export function classifySkillsError(error: unknown): Exclude { void queryClient.invalidateQueries({ queryKey: harnessesQuery.queryKey }); }, [queryClient]); - useFailureToast(harnesses && failedLast(query) ? message(query.error) : null, t("models.refreshFailed"), "harnesses-read"); + useFailureToast(harnesses && failedLast(query) ? coreError(query.error, tCommon) : null, t("models.refreshFailed"), "harnesses-read"); // From Getting started: bring the default harness's Set or Replace into view and focus. useConsoleIntent("default-model", harnesses ? "ready" : query.isError ? "unavailable" : "wait", () => { window.requestAnimationFrame(() => { @@ -63,7 +61,7 @@ export function DefaultModelsSection() { let body: ReactNode; if (!harnesses) { body = query.isError && !query.isFetching - ? { void query.refetch(); }} /> + ? { void query.refetch(); }} /> : ; } else if (!harnesses.length) { body = ; diff --git a/apps/web/src/features/vaults/VaultsPage.tsx b/apps/web/src/features/vaults/VaultsPage.tsx index decc7ea9d..516cd90e8 100644 --- a/apps/web/src/features/vaults/VaultsPage.tsx +++ b/apps/web/src/features/vaults/VaultsPage.tsx @@ -8,6 +8,7 @@ import { ConfirmDialog } from "../../components/ConfirmDialog"; import { ApiEmptyState, EmptyState, HelpTip, PageBody, PageHeader, RefreshButton, Section } from "../../components/console-ui"; import { CopyableId, ListToolbar, listSummary, NameCell, RowActions, SearchField } from "../../components/list-ui"; import { useConsoleNavigation } from "../../lib/console-navigation"; +import { coreError } from "../../lib/core-error"; import { useDeleteFlow } from "../../lib/delete-flow"; import { formatDateTime } from "../../lib/format"; import { CreatorCell, CreatorHeading, forgetCreators, ProjectFilter, ProjectName, projectClient, readAllPages, useCreators, useProjectCollection, useProjects, type Owned } from "../../lib/projects"; @@ -16,10 +17,6 @@ import { collections } from "../../lib/queries"; import { DetailSkeleton, TableSkeleton } from "../../components/Skeleton"; import { useVaultDetail } from "../resources/detail-queries"; -function errorText(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - /** * Resources › Vault: every project's Vaults and their Credential metadata. * Tokens are write-only and never reach the console; administrators view and @@ -177,8 +174,8 @@ function VaultDetail({ projectId, vaultId }: { projectId: string; vaultId: strin ); const vault = read.data ?? null; - const failure = read.isError ? errorText(read.error) : null; - const credentialsFailure = credentialsRead.isError ? errorText(credentialsRead.error) : null; + const failure = read.isError ? coreError(read.error, tCommon) : null; + const credentialsFailure = credentialsRead.isError ? coreError(credentialsRead.error, tCommon) : null; // Detail reads are not polled: each failed read was asked for and is reported. useFailureToast(vault !== null ? failure : null, t("refreshFailed"), "vault-refresh", read.errorUpdatedAt); useFailureToast(credentialsRead.data ? credentialsFailure : null, t("refreshFailed"), "vault-credentials-refresh", credentialsRead.errorUpdatedAt); diff --git a/apps/web/src/i18n/locales/en/files.ts b/apps/web/src/i18n/locales/en/files.ts index 8ddb6175e..bf426cc53 100644 --- a/apps/web/src/i18n/locales/en/files.ts +++ b/apps/web/src/i18n/locales/en/files.ts @@ -48,10 +48,4 @@ export const files = { rejected: "Core rejected the deletion: {{reason}}", uncertain: "The deletion of {{name}} was not confirmed. Refresh the list to check before trying again.", }, - errors: { - transport: "The request did not reach Core or its response was lost.", - unauthorized: "Core rejected the connection credentials.", - tooLarge: "The file is larger than Core accepts.", - invalidResponse: "Core returned a response this console does not recognize.", - }, } as const; diff --git a/apps/web/src/i18n/locales/zh-CN/files.ts b/apps/web/src/i18n/locales/zh-CN/files.ts index f816833cd..ec42f72bd 100644 --- a/apps/web/src/i18n/locales/zh-CN/files.ts +++ b/apps/web/src/i18n/locales/zh-CN/files.ts @@ -52,10 +52,4 @@ export const files: TranslationShape = { rejected: "Core 拒绝删除:{{reason}}", uncertain: "未能确认 {{name}} 是否已删除。请先刷新列表确认,再决定是否重试。", }, - errors: { - transport: "请求未到达 Core,或响应已丢失。", - unauthorized: "Core 拒绝了当前连接凭据。", - tooLarge: "文件超过 Core 的大小上限。", - invalidResponse: "Core 返回了控制台无法识别的响应。", - }, }; diff --git a/apps/web/src/i18n/locales/zh-CN/sandbox.ts b/apps/web/src/i18n/locales/zh-CN/sandbox.ts index 14a73871b..882058a1c 100644 --- a/apps/web/src/i18n/locales/zh-CN/sandbox.ts +++ b/apps/web/src/i18n/locales/zh-CN/sandbox.ts @@ -233,7 +233,6 @@ export const sandbox = { "Core rejected the sandbox change": "Core 拒绝了此次沙箱更改", "Core rejected the sandbox configuration.": "Core 拒绝了这个沙箱配置。", "The console configuration could not be read. Refresh to try again.": "无法读取控制台配置。请刷新重试。", - "The sandbox request was rejected. Refresh to check the current state.": "沙箱请求被拒绝。请刷新并检查当前状态。", "The sandbox service is unavailable. Refresh to check the current state.": "沙箱服务不可用。请刷新并检查当前状态。", "Disabled": "已禁用", "Waking": "唤醒中", diff --git a/apps/web/src/lib/delete-flow.ts b/apps/web/src/lib/delete-flow.ts index 31261f34c..44f0b7faa 100644 --- a/apps/web/src/lib/delete-flow.ts +++ b/apps/web/src/lib/delete-flow.ts @@ -1,5 +1,8 @@ import { AgentCoreError } from "@oac/agents-client"; import { useCallback, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { coreError } from "./core-error"; export interface DeleteFlow { target: T | null; @@ -12,7 +15,7 @@ export interface DeleteFlow { /** * Confirmed deletion through the Web API. A definite rejection keeps the dialog - * open with Core's reason. An unconfirmed outcome is never retried silently: the + * open with Core's reason in the console's language. An unconfirmed outcome is never retried silently: the * dialog stays open with the uncertain message while the view is re-read * (`messages.reread`, or `onSettled` where settling only refreshes a list), so a * detail page does not navigate away as if the deletion had succeeded. @@ -22,6 +25,7 @@ export function useDeleteFlow( onSettled: () => void, messages: { uncertain: string; reread?: () => void }, ): DeleteFlow { + const { t } = useTranslation("common"); const [target, setTarget] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); @@ -39,7 +43,7 @@ export function useDeleteFlow( setTarget(null); onSettled(); } else if (caught instanceof AgentCoreError && caught.status >= 400 && caught.status < 500) { - setError(caught.message); + setError(coreError(caught, t)); } else { setError(messages.uncertain); (messages.reread ?? onSettled)(); @@ -47,7 +51,7 @@ export function useDeleteFlow( } finally { setBusy(false); } - }, [busy, messages, onSettled, run, target]); + }, [busy, messages, onSettled, run, t, target]); return { target, busy, diff --git a/apps/web/src/lib/locale.test.ts b/apps/web/src/lib/locale.test.ts index ad570bde6..38278a9ad 100644 --- a/apps/web/src/lib/locale.test.ts +++ b/apps/web/src/lib/locale.test.ts @@ -20,12 +20,13 @@ describe("sandbox localization", () => { } expect(sandboxDiagnosticMessage("", "zh-CN")).toBeNull(); }); - it("shows Core's reason for a refusal and keeps other failures to the console's words", () => { + it("shows a refusal in the console's language and keeps other failures to the console's words", () => { // A code with one exact meaning keeps the console's localized words. expect(sandboxRequestError(new AgentCoreError("Node node-edge still has allocations.", 409, "runtime_node_in_use"), "zh-CN")).toBe("节点仍有活跃分配或保留资源。请先清理资源分配、快照、预留资源和待清理项,再移除节点。"); - // A refusal is Core's to explain: one code, such as a 409 conflict, covers several reasons. - expect(sandboxRequestError(new AgentCoreError("This console is read-only.", 403), "zh-CN")).toBe("This console is read-only."); - expect(sandboxRequestError(new AgentCoreError("The session is busy.", 409, "conflict_error"), "zh-CN")).toBe("The session is busy."); + // Core's messages are English: Chinese names the code or the status instead. + expect(sandboxRequestError(new AgentCoreError("This console is read-only.", 403), "zh-CN")).toBe("Core 请求失败(403)。"); + expect(sandboxRequestError(new AgentCoreError("The session is busy.", 409, "conflict_error"), "zh-CN")).toBe("Core 请求失败(conflict_error)。"); + expect(sandboxRequestError(new AgentCoreError("The session is busy.", 409, "conflict_error"), "en")).toBe("The session is busy."); // A sandbox refusal whose reason the client withheld is named without it. expect(sandboxRequestError(new AgentCoreError("withheld", 400, "sandbox_configuration_unconfirmed"), "zh-CN")).toBe("Core 拒绝了这个沙箱配置。"); expect(sandboxRequestError(new AgentCoreError("raw secret", 502), "zh-CN")).not.toContain("raw secret"); diff --git a/apps/web/src/lib/sandbox-labels.ts b/apps/web/src/lib/sandbox-labels.ts index d537c6106..b852464b1 100644 --- a/apps/web/src/lib/sandbox-labels.ts +++ b/apps/web/src/lib/sandbox-labels.ts @@ -1,6 +1,6 @@ import { AgentCoreError, sandboxConfigurationUnconfirmed, type SandboxProvider } from "@oac/agents-client"; import i18n, { type SupportedLanguage } from "../i18n"; -import { knownCoreError } from "./core-error"; +import { coreError, knownCoreError } from "./core-error"; import type { ParseKeys } from "i18next"; const states: Record> = { @@ -11,18 +11,17 @@ const states: Record> = { export function sandboxStateLabel(state: string, locale: SupportedLanguage): string { return i18n.getFixedT(locale, "sandbox")(Object.hasOwn(states, state) ? states[state]! : "Unknown state"); } -/** Localized catalog errors first; unknown refusals retain Core’s message. */ +/** Localized catalog errors first; other refusals follow coreError. */ export function sandboxRequestError(error: unknown, locale: SupportedLanguage): string { - const known = knownCoreError(error, i18n.getFixedT(locale, "common"), "bytes"); + const common = i18n.getFixedT(locale, "common"); + const known = knownCoreError(error, common, "bytes"); if (known) return known; let key: ParseKeys<"sandbox"> = "The sandbox request failed. Refresh to check the current state before trying again."; if (error instanceof AgentCoreError) { const refused = !sandboxWriteUncertain(error); if (error.code === sandboxConfigurationUnconfirmed) { if (refused) key = "Core rejected the sandbox configuration."; else if (error.status >= 500) key = "The sandbox service is unavailable. Refresh to check the current state."; } - else if (refused) { - if (error.message) return error.message; - key = "The sandbox request was rejected. Refresh to check the current state."; - } else if (error.status >= 500) key = "The sandbox service is unavailable. Refresh to check the current state."; + else if (refused) return coreError(error, common); + else if (error.status >= 500) key = "The sandbox service is unavailable. Refresh to check the current state."; } else if (error instanceof Error && error.message === "removal_unconfirmed") key = "Core did not confirm node removal. Refresh to check its state."; return i18n.getFixedT(locale, "sandbox")(key); } @@ -40,16 +39,12 @@ export function sandboxWriteUncertain(error: unknown): boolean { } /** - * Core's own reason when it rejects a deployment configuration it cannot serve, - * such as E2B with a loopback public_url; null for any other failure. Nothing - * was saved, so the administrator corrects the cause and saves again. + * The reason for a refused deployment configuration whose code the console + * explains, such as E2B with a loopback public_url; null for any other failure. + * Nothing was saved, so the administrator corrects the cause and saves again. */ export function sandboxConfigurationRejection(error: unknown, locale: SupportedLanguage = "en"): string | null { - if (error instanceof AgentCoreError && !sandboxWriteUncertain(error) && error.code) { - const known = knownCoreError(error, i18n.getFixedT(locale, "common")); - if (known) return known; - } - return error instanceof AgentCoreError && error.status === 409 && error.code === "sandbox_configuration_error" && error.message ? error.message : null; + return sandboxWriteUncertain(error) ? null : knownCoreError(error, i18n.getFixedT(locale, "common")); } export function sandboxProviderLabel(provider: SandboxProvider | "", locale: SupportedLanguage): string {