Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
31bf719
feat(web): project connector runtime failures onto a wire-safe error
AlexLiu190625 Aug 28, 2026
abb3e06
feat(web): carry an error code on the terminal task_error frame
AlexLiu190625 Aug 28, 2026
566fcb4
feat(web): classify connector runtime failures at terminal settlement
AlexLiu190625 Aug 28, 2026
58593b7
fix(frontend): render the terminal error bubble and name the missing key
AlexLiu190625 Aug 28, 2026
e65fabb
refactor(web): tie each public reason to its own raise site
AlexLiu190625 Aug 28, 2026
d908ebd
docs(web): describe what client_error_messages now holds
AlexLiu190625 Aug 28, 2026
230a032
fix(web): degrade instead of raising on the terminal error path
AlexLiu190625 Aug 28, 2026
377eea5
docs(web): name the /v1 sibling projection and why it differs
AlexLiu190625 Aug 28, 2026
abc51dd
fix(frontend): dedup terminal errors by code and reason, drop unread …
AlexLiu190625 Aug 28, 2026
5437644
fix(web): drop owner key names from the connector-runtime reason whit…
AlexLiu190625 Sep 1, 2026
ca26ab8
fix(frontend): drop the missing-key wording, match the server's dropp…
AlexLiu190625 Sep 1, 2026
1075fbe
fix(frontend): dedup terminal errors on (code, reason), not the fixed…
AlexLiu190625 Sep 1, 2026
662ae6e
fix(frontend): only flag the terminal task_error frame as a turn's re…
AlexLiu190625 Sep 1, 2026
8587668
docs(web): name the code set and the fixture's two producers in comments
AlexLiu190625 Sep 1, 2026
b6b8324
refactor(frontend): derive the error frame's display values in one fu…
AlexLiu190625 Sep 2, 2026
4b8b1eb
fix(frontend): dedup terminal errors by the frame's own state version
AlexLiu190625 Sep 2, 2026
08c3739
fix(frontend): localize connector runtime codes through the client er…
AlexLiu190625 Sep 2, 2026
a59c5ca
fix(web): type-gate the code before the closed-set membership test
AlexLiu190625 Sep 2, 2026
2ef06c7
docs(frontend): fix three positional comment references broken by the…
AlexLiu190625 Sep 2, 2026
0841ea9
Merge remote-tracking branch 'upstream/main' into feat/connector-runt…
AlexLiu190625 Sep 3, 2026
bb3940a
fix(web): whitelist the access-resolution reason main now raises
AlexLiu190625 Sep 3, 2026
0a3c3f9
docs(frontend): anchor two websocket producer references by symbol, n…
AlexLiu190625 Sep 3, 2026
62bcf13
refactor(web): send only the error code on terminal task_error frames
AlexLiu190625 Sep 3, 2026
1710e8b
fix(web): scope the terminal-frame code closed set to connector-runti…
AlexLiu190625 Sep 3, 2026
0ad1b7d
test(web): pin where terminal-frame code arguments come from
AlexLiu190625 Sep 3, 2026
946bfa7
test(frontend): cover the resume-settlement task_error frame and the …
AlexLiu190625 Sep 3, 2026
ecf9554
refactor(web): drop the unused fallback parameter
AlexLiu190625 Sep 3, 2026
9d01552
docs: describe protected invariants without review-round references
AlexLiu190625 Sep 3, 2026
cdadb67
test(web): name the closed-set producer guard for what it pins and dr…
AlexLiu190625 Sep 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,024 changes: 1,023 additions & 1 deletion frontend/src/contexts/app-context-chat.test.tsx

Large diffs are not rendered by default.

165 changes: 153 additions & 12 deletions frontend/src/contexts/app-context-chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ type TaskControlState =
| "completed"
| "failed"

// The structured half of a terminal task_error frame: the stable error code
// the client renders and localizes. The frame carries nothing else
// connector-specific -- its audience includes anonymous widget and
// share-link visitors.
type TaskErrorProjection = {
code: string
}

type TaskControlEnvelope = {
isStateEvent: boolean
taskId?: number
Expand Down Expand Up @@ -350,7 +358,7 @@ import { generateClientMessageId, getApiUrl, getUploadApiUrl, shouldAutoOpenTask
import { apiRequest, classifyUploadError, getApiErrorMessage, isJsonRecord, parseApiResponse } from "@/lib/api-wrapper"
import { clientErrorTranslationKey, readClientErrorCode } from "@/lib/client-errors"
import { normalizeUploadFileIds } from "@/lib/upload-file-ids"
import { useI18n } from "@/contexts/i18n-context"
import { useI18n, type Translate } from "@/contexts/i18n-context"
import { normalizeTimestampMs } from "@/lib/time-utils"
import { unwrapFinalAnswerContent } from "@/lib/final-answer"
import { normalizeTaskCompletedMessage } from "@/lib/task-completion"
Expand Down Expand Up @@ -918,6 +926,20 @@ const getWebSocketErrorCode = (message: WebSocketMessage) => {
return errorCode.present ? readClientErrorCode(errorCode.value) : null
}

// The frame deliberately carries nothing connector-specific beyond the code:
// its audience includes anonymous widget and share-link visitors. Which
// connector, which key, and each key's declared type all come from the
// per-task requirements endpoint, which selects on
// `Task.id == task_id AND Task.user_id == current_user.id`.
const getTaskErrorProjection = (
message: WebSocketMessage,
): TaskErrorProjection | null => {
const root = message as unknown as Record<string, unknown>
const data = isJsonRecord(message.data) ? message.data : null
const code = getString(data?.code) || getString(root.code)
return code ? { code } : null
}

const getWebSocketTaskStatus = (message: WebSocketMessage): Task["status"] | null => {
const root = message as unknown as Record<string, unknown>
const data = isJsonRecord(message.data) ? message.data : null
Expand All @@ -929,6 +951,117 @@ const getWebSocketTaskStatus = (message: WebSocketMessage): Task["status"] | nul
const shouldStopProcessingForTaskStatus = (status: unknown): boolean =>
isStoppedTaskStatus(status)

export type ErrorFrameDisplay = {
/** Terminal (`task_error`) or a rejection on the mixed root `error` channel. */
isTerminal: boolean
/** Status carried by the frame, or null when it carries none. */
taskStatus: Task["status"] | null
stopsProcessing: boolean
/** First argument to the dedup check: the server sentence, or the constant
* that stands in for it on a transport that marks legacy prose untrusted. */
dedupText: string
/** Third argument to the dedup check. Undefined means "no identity, key on
* the text alone". */
occurrenceIdentity: string | undefined
bubbleContent: string
isResult: boolean
}

// One place where a frame on the error/task_error handler becomes the five
// values the handler needs: the bubble's wording, the dedup text, the dedup
// identity, the result flag, and the task status to dispatch. Each of those
// needs a different subset of "is this terminal / is legacy prose trusted /
// did a code survive / is there a state version", and deriving each subset at
// its own use site is what let five separate defects land in this handler.
// Pure on purpose: no dispatch, no refs, nothing
// outside its arguments, so every cell of that matrix is unit-testable
// without rendering the provider -- the same shape extractTaskControlEnvelope
// above already uses.
export const projectErrorFrameForDisplay = (
message: WebSocketMessage,
options: {
trustLegacyErrorProse: boolean
translate: Translate
controlEnvelope: TaskControlEnvelope
},
): ErrorFrameDisplay => {
const { trustLegacyErrorProse, translate, controlEnvelope } = options
const websocketErrorCode = getWebSocketErrorCode(message)
const dedupText = websocketErrorCode
? translate(clientErrorTranslationKey(websocketErrorCode))
: getWebSocketErrorMessage(message, trustLegacyErrorProse)
const taskStatus = getWebSocketTaskStatus(message)
// Only task_error is terminal. Every frame of that type is emitted after
// the row has been committed FAILED -- task_orchestrator.py's settled
// branch, and websocket.py's legacy helper, which settles under
// only_if_running=True and does not broadcast when that update matches
// no row -- and task_error is also the only frame that carries the
// structured code. The root "error" type is a mixed
// channel: rejected chat messages, rejected pause and rejected resume
// all arrive on it while the viewed task is still RUNNING or
// WAITING_FOR_USER, and a rejection is not this turn's answer.
const isTerminal = message.type === "task_error"
const projection = isTerminal ? getTaskErrorProjection(message) : null
// The frame's own code decides the wording, and one code has one sentence
// for every audience. This is the same table the root error channel already
// uses for its error_code field, extended with the connector-runtime codes
// that reach this frame -- not a second vocabulary beside it. It also fixes
// what the relayed sentence could not: on a transport that marks legacy
// prose untrusted, getWebSocketErrorMessage returns a constant by design
// (#1938: never render server free text there), so before this the curated
// sentence existed for three codes and every other code read "Unknown
// error". Nothing here relays server prose; the wording is the client's own,
// selected by code. A code the table does not list keeps the generic
// prefixed wording.
const projectedCode = projection ? readClientErrorCode(projection.code) : null
const connectorRuntimeBubble = projectedCode
? translate(clientErrorTranslationKey(projectedCode))
: null
// The dedup identity has to name WHICH occurrence this frame reports, not
// which class of failure it belongs to. broadcast_to_task stamps every frame
// of this type with the row's (run_id, state_version) pair before it goes
// out -- task_error is in websocket.py's _VERSIONED_TASK_EVENT_TYPES -- and
// state_version is bumped by every control transition that actually changes
// (status, control_state). So one settlement broadcast twice carries one
// version and still collapses, while two failed turns are at least two
// versions apart (the retry takes the lease FAILED -> RUNNING, then settles
// RUNNING -> FAILED) and both are shown. Keying on the failure's class
// instead -- the code, the reason, or the rendered sentence -- cannot tell
// those two apart, and on this handler the collapsed frame is the turn's
// result. The identity is withheld when the frame carries no version (the
// row was already gone when it was broadcast, so no state tuple was
// attached), which falls back to keying on the text alone:
// canAcceptTaskControlVersion drops such a frame once any versioned event
// has been seen for the task, and when none has, two of them key on the
// same text and the second still collapses -- the behaviour that predates
// this change. Withholding the identity is the honest answer there;
// attaching a state tuple needs the row, and a settled FAILED task has one.
const occurrenceIdentity =
isTerminal && controlEnvelope.stateVersion !== undefined
? `${controlEnvelope.runId ?? ""}:${controlEnvelope.stateVersion}`
: undefined
return {
isTerminal,
taskStatus,
stopsProcessing: shouldStopProcessingForTaskStatus(taskStatus),
dedupText,
occurrenceIdentity,
bubbleContent:
connectorRuntimeBubble
?? `${translate('agent.logs.event.messages.errorPrefix')} ${dedupText}`,
// A terminal failure IS this turn's result: without the flag the
// conversation panel (which renders only user / isResult / system-notice
// messages) filters the bubble out and falls back to a virtual "unknown
// error" placeholder until reload. A non-terminal rejection is not, and
// flagging it would close the live progress indicator and the
// waiting-answer form of a turn that is still running, and drain this
// turn's accumulated trace events into the rejection bubble (see the
// ADD_MESSAGE reducer case's isResult branch, which merges
// state.traceEvents into the message and clears it).
isResult: isTerminal,
}
}

const stepsFromPlanData = (planData: unknown, existingSteps: StepExecution[]): StepExecution[] | null => {
const planRecord = planData && typeof planData === "object" ? planData as Record<string, unknown> : null
const planSteps = Array.isArray(planRecord?.steps) ? planRecord.steps : null
Expand Down Expand Up @@ -5655,35 +5788,43 @@ export function AppProvider({
break

case "error":
case "task_error":
case "task_error": {
console.trace('Original message:', JSON.stringify(message), 'Handler: handleMessage (error)')
const websocketErrorCode = getWebSocketErrorCode(message)
const websocketErrorMessage = websocketErrorCode
? t(clientErrorTranslationKey(websocketErrorCode))
: getWebSocketErrorMessage(message, trustLegacyErrorProse)
const websocketTaskStatus = getWebSocketTaskStatus(message)
const errorFrame = projectErrorFrameForDisplay(message, {
trustLegacyErrorProse,
translate: t,
controlEnvelope,
})

if (websocketTaskStatus) {
dispatch({ type: "UPDATE_TASK_STATUS", payload: { status: websocketTaskStatus } })
if (errorFrame.taskStatus) {
dispatch({ type: "UPDATE_TASK_STATUS", payload: { status: errorFrame.taskStatus } })
dispatch({ type: "TRIGGER_TASK_UPDATE" })
}
if (shouldStopProcessingForTaskStatus(websocketTaskStatus)) {
if (errorFrame.stopsProcessing) {
dispatch({ type: "SET_PROCESSING", payload: false })
}

if (!isDuplicateMessageForViewedTask(websocketErrorMessage, "agent-error")) {
if (
!isDuplicateMessageForViewedTask(
errorFrame.dedupText,
"agent-error",
errorFrame.occurrenceIdentity,
)
) {
dispatch({
type: "ADD_MESSAGE",
payload: {
id: generateMessageId("msg-error"),
role: "assistant",
content: `${t('agent.logs.event.messages.errorPrefix')} ${websocketErrorMessage}`,
content: errorFrame.bubbleContent,
timestamp: message.timestamp,
status: "failed",
isResult: errorFrame.isResult,
},
})
}
break
}

case "message_received":
console.trace('Original message:', JSON.stringify(message), 'Handler: handleMessage (message_received)')
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ const en = {
uploadTooLarge: "File is too large. Please reduce the upload size and try again.",
uploadProxyError: "Upload failed before reaching the application. Please check the server upload limit.",
uploadFailed: "Upload failed. Please try again.",
missingRuntimeContext: "This connector needs additional runtime input before it can run.",
runtimeSecretUnavailable: "This connector needs a runtime credential that is not available.",
scheduledSecretUnavailable: "A scheduled run needs a runtime credential that is not available.",
invalidRuntimeContext: "This connector's runtime input is not valid, so the task could not run.",
connectorRuntimeUnavailable: "A service this connector needs is unavailable. Please try again later.",
},
common: {
optional: "(Optional)",
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/i18n/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ const zh = {
uploadTooLarge: "文件过大,请减小上传大小后重试。",
uploadProxyError: "上传请求未到达应用,请检查服务器的上传大小限制。",
uploadFailed: "上传失败,请重试。",
missingRuntimeContext: "这个连接器需要额外的运行时输入才能运行。",
runtimeSecretUnavailable: "这个连接器需要的运行时凭据当前不可用。",
scheduledSecretUnavailable: "定时运行需要的运行时凭据当前不可用。",
invalidRuntimeContext: "这个连接器的运行时输入无效,任务无法运行。",
connectorRuntimeUnavailable: "这个连接器依赖的服务当前不可用,请稍后重试。",
},
common: {
optional: "(可选)",
Expand Down
18 changes: 18 additions & 0 deletions frontend/src/lib/client-errors.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { describe, expect, it } from "vitest"

import en from "@/i18n/locales/en"
import {
CLIENT_ERROR_CODES,
clientErrorFallback,
clientErrorTranslationKey,
readClientErrorCode,
Expand Down Expand Up @@ -30,6 +32,11 @@ describe("client error wire contract", () => {
["upload_too_large", "clientErrors.uploadTooLarge", "File is too large. Please reduce the upload size and try again."],
["upload_proxy_error", "clientErrors.uploadProxyError", "Upload failed before reaching the application. Please check the server upload limit."],
["upload_failed", "clientErrors.uploadFailed", "Upload failed. Please try again."],
["missing_runtime_context", "clientErrors.missingRuntimeContext", "This connector needs additional runtime input before it can run."],
["runtime_secret_unavailable", "clientErrors.runtimeSecretUnavailable", "This connector needs a runtime credential that is not available."],
["scheduled_secret_unavailable", "clientErrors.scheduledSecretUnavailable", "A scheduled run needs a runtime credential that is not available."],
["invalid_runtime_context", "clientErrors.invalidRuntimeContext", "This connector's runtime input is not valid, so the task could not run."],
["connector_runtime_unavailable", "clientErrors.connectorRuntimeUnavailable", "A service this connector needs is unavailable. Please try again later."],
] as const)("maps %s to a typed translation key", (code, key, fallback) => {
expect(readClientErrorCode(code)).toBe(code)
expect(clientErrorTranslationKey(code)).toBe(key)
Expand All @@ -40,4 +47,15 @@ describe("client error wire contract", () => {
expect(readClientErrorCode("provider_secret")).toBeNull()
expect(readClientErrorCode({ error_code: "upload_failed" })).toBeNull()
})

it("keeps the fallback strings identical to the English locale", () => {
for (const code of CLIENT_ERROR_CODES) {
const translationKey = clientErrorTranslationKey(code)
const localeKey = translationKey.replace(
/^clientErrors\./,
"",
) as keyof typeof en.clientErrors
expect(clientErrorFallback(code)).toBe(en.clientErrors[localeKey])
}
})
})
38 changes: 37 additions & 1 deletion frontend/src/lib/client-errors.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { TranslationKey } from "@/i18n/translations"

const CLIENT_ERROR_CODES = [
export const CLIENT_ERROR_CODES = [
"message_processing_failed",
"task_execution_failed",
"guidance_in_progress",
Expand All @@ -23,6 +23,32 @@ const CLIENT_ERROR_CODES = [
"upload_too_large",
"upload_proxy_error",
"upload_failed",
// Connector-runtime codes that reach the client on a terminal task_error
// frame's `code` field. They come from V1ErrorCode rather than the backend's
// ClientErrorCode enum -- this list is already a superset of that enum (the
// three upload_* codes are client-side only) and stays one table so a code
// has one wording for every audience. Only codes with a producer that can
// reach that frame today are listed: a listed code nothing produces is an
// entry with no expiry date. Which codes those are is a fact about this
// repository's raise sites, not a property the wire holds -- the field is
// typed as a bare string and validated against the connector-runtime closed
// set the server keeps next to its fallback table, and a resolver installed
// through set_connector_runtime_resolver lives outside this repository and
// can raise any member of that set. The closed set has eight members, and
// only five have their own entry below; the other three --
// connector_not_found, runtime_context_immutable, and
// runtime_secret_not_allowed -- have no producer that can reach a terminal
// frame in this repository today, so they keep the generic prefixed
// wording. Two further connector-runtime codes are outside the closed set
// entirely (mcp_oauth_authorization_failed, delegated_authorization_failed):
// each names the outcome of an authorization check, so the server drops
// them before this frame is built. A code this table does not list keeps
// the generic prefixed wording.
"missing_runtime_context",
"runtime_secret_unavailable",
"scheduled_secret_unavailable",
"invalid_runtime_context",
"connector_runtime_unavailable",
] as const

export type ClientErrorCode = (typeof CLIENT_ERROR_CODES)[number]
Expand Down Expand Up @@ -50,6 +76,11 @@ const CLIENT_ERROR_TRANSLATION_KEYS: Record<ClientErrorCode, TranslationKey> = {
upload_too_large: "clientErrors.uploadTooLarge",
upload_proxy_error: "clientErrors.uploadProxyError",
upload_failed: "clientErrors.uploadFailed",
missing_runtime_context: "clientErrors.missingRuntimeContext",
runtime_secret_unavailable: "clientErrors.runtimeSecretUnavailable",
scheduled_secret_unavailable: "clientErrors.scheduledSecretUnavailable",
invalid_runtime_context: "clientErrors.invalidRuntimeContext",
connector_runtime_unavailable: "clientErrors.connectorRuntimeUnavailable",
}

const CLIENT_ERROR_FALLBACKS: Record<ClientErrorCode, string> = {
Expand All @@ -75,6 +106,11 @@ const CLIENT_ERROR_FALLBACKS: Record<ClientErrorCode, string> = {
upload_too_large: "File is too large. Please reduce the upload size and try again.",
upload_proxy_error: "Upload failed before reaching the application. Please check the server upload limit.",
upload_failed: "Upload failed. Please try again.",
missing_runtime_context: "This connector needs additional runtime input before it can run.",
runtime_secret_unavailable: "This connector needs a runtime credential that is not available.",
scheduled_secret_unavailable: "A scheduled run needs a runtime credential that is not available.",
invalid_runtime_context: "This connector's runtime input is not valid, so the task could not run.",
connector_runtime_unavailable: "A service this connector needs is unavailable. Please try again later.",
}

const CLIENT_ERROR_CODE_SET = new Set<string>(CLIENT_ERROR_CODES)
Expand Down
Loading