fix: localize synthetic business errors

Synthetic API errors were carrying only English display text, so desktop chat could not distinguish our compatibility guidance from raw upstream failures and recovery logic depended on those English strings.

Add stable business error codes for media, PDF, request-size, prompt-length, and auto-mode errors. Desktop chat now resolves those codes through locale keys and suppresses the stale English fallback for known business errors, while normalizeMessagesForAPI still supports legacy text matching for old transcripts.

Constraint: Raw upstream provider details must remain available for diagnostics but should not drive localized business UX.

Rejected: Localize src/services/api/errors.ts strings directly | that would make persisted transcript text and media recovery depend on the active UI language.

Confidence: high

Scope-risk: moderate

Directive: Add new user-facing synthetic API errors with businessErrorCode values before localizing their display text.

Tested: bun test src/services/api/errors.test.ts tests/mediaRecoveryAndEstimation.test.ts src/server/__tests__/ws-memory-events.test.ts

Tested: cd desktop && bun run test -- run src/stores/chatStore.test.ts src/components/chat/MessageList.test.tsx

Tested: bun run check:server

Tested: bun run check:desktop

Not-tested: Manual desktop screenshot smoke for the rendered Chinese error card
This commit is contained in:
程序员阿江(Relakkes) 2026-06-01 00:36:11 +08:00
parent a5c13848fc
commit 998e85efab
16 changed files with 223 additions and 11 deletions

View File

@ -3637,4 +3637,35 @@ describe('MessageList nested tool calls', () => {
),
).toBeTruthy()
})
it('renders business API errors in the active locale without raw English fallback', () => {
useSettingsStore.setState({ locale: 'zh' })
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
messages: [
{
id: 'error-1',
type: 'error',
code: 'invalid_request',
businessErrorCode: 'image_unsupported',
message:
'This model does not support images. Continue with text, or switch to a vision-capable model and send the image again.',
timestamp: 1,
},
],
}),
},
})
render(<MessageList />)
expect(screen.getByText('错误:')).toBeTruthy()
expect(
screen.getByText(
'当前模型不支持图片。请继续使用文字,或切换到支持视觉的模型后重新发送图片。',
),
).toBeTruthy()
expect(screen.queryByText(/This model does not support images/)).toBeNull()
})
})

View File

@ -2015,16 +2015,26 @@ export const MessageBlock = memo(function MessageBlock({
/>
)
case 'error': {
const businessErrorKey = message.businessErrorCode
? `businessError.${message.businessErrorCode}` as TranslationKey
: null
const businessErrorText = businessErrorKey ? t(businessErrorKey) : null
const errorKey = message.code ? `error.${message.code}` as TranslationKey : null
const errorText = errorKey ? t(errorKey) : null
const displayMessage = (errorText && errorText !== errorKey) ? errorText : message.message
const displayMessage =
businessErrorText && businessErrorText !== businessErrorKey
? businessErrorText
: (errorText && errorText !== errorKey)
? errorText
: message.message
const showRawDetail =
!message.businessErrorCode &&
Boolean(message.message) &&
message.message.trim() !== '' &&
message.message !== displayMessage
return (
<div className="mb-3 px-4 py-2.5 rounded-lg border border-[var(--color-error)]/20 bg-[var(--color-error-container)]/28 text-sm text-[var(--color-error)]">
<strong>Error:</strong> {displayMessage}
<strong>{t('common.error')}:</strong> {displayMessage}
{showRawDetail && (
<div className="mt-1 whitespace-pre-wrap text-xs text-[var(--color-on-error-container)]/85">
{message.message}

View File

@ -14,6 +14,7 @@ export const en = {
'common.enable': 'Enable',
'common.disable': 'Disable',
'common.active': 'ACTIVE',
'common.error': 'Error',
'common.copyFailed': 'Copy failed.',
// ─── Sidebar ──────────────────────────────────────
@ -1627,6 +1628,16 @@ export const en = {
'error.NOT_FOUND': 'Resource not found.',
'error.INTERNAL_ERROR': 'Internal server error.',
// ─── Business Errors ──────────────────────────────────────
'businessError.pdf_too_large': 'The PDF is too large for the selected model. Convert it to text or use a smaller PDF.',
'businessError.pdf_password_protected': 'The PDF is password protected. Unlock or convert it before sending it again.',
'businessError.pdf_invalid': 'The PDF file is not valid. Convert it to text or send a different file.',
'businessError.image_too_large': 'The image is too large for the selected model. Resize it or send a smaller image.',
'businessError.image_unsupported': 'This model does not support images. Continue with text, or switch to a vision-capable model and send the image again.',
'businessError.request_too_large': 'The request is too large for the selected model. Remove large files or retry with a smaller message.',
'businessError.prompt_too_long': 'The prompt is too long for the selected model. Compact the conversation or retry with less context.',
'businessError.auto_mode_unavailable': 'Auto mode is unavailable for your current plan.',
// ─── Server Status Verbs ──────────────────────────────────────
'serverVerb.Thinking': 'Thinking',
'serverVerb.Compacting conversation': 'Compacting context',

View File

@ -16,6 +16,7 @@ export const zh: Record<TranslationKey, string> = {
'common.enable': '启用',
'common.disable': '禁用',
'common.active': '已激活',
'common.error': '错误',
'common.copyFailed': '复制失败。',
// ─── Sidebar ──────────────────────────────────────
@ -1629,6 +1630,16 @@ export const zh: Record<TranslationKey, string> = {
'error.NOT_FOUND': '资源未找到。',
'error.INTERNAL_ERROR': '内部服务器错误。',
// ─── 业务错误 ──────────────────────────────────────
'businessError.pdf_too_large': '这个 PDF 超出了当前模型可处理的大小。请先转成文本,或换一个更小的 PDF。',
'businessError.pdf_password_protected': '这个 PDF 有密码保护。请先解锁或转换后再发送。',
'businessError.pdf_invalid': '这个 PDF 文件无效。请先转成文本,或换一个文件发送。',
'businessError.image_too_large': '这张图片超出了当前模型可处理的大小。请压缩图片,或换一张更小的图片。',
'businessError.image_unsupported': '当前模型不支持图片。请继续使用文字,或切换到支持视觉的模型后重新发送图片。',
'businessError.request_too_large': '这次请求超出了当前模型可处理的大小。请移除大文件,或缩短消息后重试。',
'businessError.prompt_too_long': '当前上下文超出了模型限制。请先压缩会话,或减少上下文后重试。',
'businessError.auto_mode_unavailable': '当前套餐不支持自动模式。',
// ─── Server Status Verbs ──────────────────────────────────────
'serverVerb.Thinking': '思考中',
'serverVerb.Compacting conversation': '正在压缩上下文',

View File

@ -2411,6 +2411,32 @@ describe('chatStore history mapping', () => {
expect(updateTabStatusMock).toHaveBeenLastCalledWith(TEST_SESSION_ID, 'error')
})
it('preserves business error codes from server error messages', () => {
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: makeSession({
messages: [],
chatState: 'streaming',
}),
},
})
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'error',
message: 'This model does not support images.',
code: 'invalid_request',
businessErrorCode: 'image_unsupported',
})
const session = useChatStore.getState().sessions[TEST_SESSION_ID]
expect(session?.messages[session.messages.length - 1]).toMatchObject({
type: 'error',
message: 'This model does not support images.',
code: 'invalid_request',
businessErrorCode: 'image_unsupported',
})
})
it('removes the transient compacting card when compacting status ends without a boundary', () => {
useChatStore.setState({
sessions: {

View File

@ -1649,7 +1649,17 @@ export const useChatStore = create<ChatStore>((set, get) => ({
newMessages = appendAssistantTextMessage(newMessages, pendingText, Date.now())
}
newMessages = dropTailCompactingCompactSummary(newMessages)
newMessages = [...newMessages, { id: nextId(), type: 'error', message: msg.message, code: msg.code, timestamp: Date.now() }]
newMessages = [
...newMessages,
{
id: nextId(),
type: 'error',
message: msg.message,
code: msg.code,
...(msg.businessErrorCode ? { businessErrorCode: msg.businessErrorCode } : {}),
timestamp: Date.now(),
},
]
return {
messages: newMessages,
chatState: 'idle',

View File

@ -84,7 +84,7 @@ export type ServerMessage =
errorType?: string
errorMessage?: string
}
| { type: 'error'; message: string; code: string; retryable?: boolean }
| { type: 'error'; message: string; code: string; retryable?: boolean; businessErrorCode?: string }
| { type: 'system_notification'; subtype: string; message?: string; data?: unknown }
| { type: 'pong' }
| { type: 'team_update'; teamName: string; members: TeamMemberStatus[] }
@ -290,5 +290,5 @@ export type UIMessage =
description?: string
timestamp: number
}
| { id: string; type: 'error'; message: string; code: string; timestamp: number }
| { id: string; type: 'error'; message: string; code: string; businessErrorCode?: string; timestamp: number }
| { id: string; type: 'task_summary'; tasks: TaskSummaryItem[]; timestamp: number }

View File

@ -0,0 +1,24 @@
export const BUSINESS_ERROR_CODES = {
PDF_TOO_LARGE: 'pdf_too_large',
PDF_PASSWORD_PROTECTED: 'pdf_password_protected',
PDF_INVALID: 'pdf_invalid',
IMAGE_TOO_LARGE: 'image_too_large',
IMAGE_UNSUPPORTED: 'image_unsupported',
REQUEST_TOO_LARGE: 'request_too_large',
PROMPT_TOO_LONG: 'prompt_too_long',
AUTO_MODE_UNAVAILABLE: 'auto_mode_unavailable',
} as const
export type BusinessErrorCode =
(typeof BUSINESS_ERROR_CODES)[keyof typeof BUSINESS_ERROR_CODES]
export const BUSINESS_ERROR_MEDIA_BLOCK_TYPES: Partial<
Record<BusinessErrorCode, readonly ('document' | 'image')[]>
> = {
[BUSINESS_ERROR_CODES.PDF_TOO_LARGE]: ['document'],
[BUSINESS_ERROR_CODES.PDF_PASSWORD_PROTECTED]: ['document'],
[BUSINESS_ERROR_CODES.PDF_INVALID]: ['document'],
[BUSINESS_ERROR_CODES.IMAGE_TOO_LARGE]: ['image'],
[BUSINESS_ERROR_CODES.IMAGE_UNSUPPORTED]: ['image'],
[BUSINESS_ERROR_CODES.REQUEST_TOO_LARGE]: ['document', 'image'],
}

View File

@ -23,6 +23,7 @@ import {
logEvent,
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
} from 'src/services/analytics/index.js'
import { BUSINESS_ERROR_CODES } from './constants/businessErrors.js'
import { ImageSizeError } from './utils/imageValidation.js'
import { ImageResizeError } from './utils/imageResizer.js'
import { findToolByName, type ToolUseContext } from './Tool.js'
@ -643,6 +644,7 @@ async function* queryLoop(
yield createAssistantAPIErrorMessage({
content: PROMPT_TOO_LONG_ERROR_MESSAGE,
error: 'invalid_request',
businessErrorCode: BUSINESS_ERROR_CODES.PROMPT_TOO_LONG,
})
return { reason: 'blocking_limit' }
}
@ -974,6 +976,7 @@ async function* queryLoop(
) {
yield createAssistantAPIErrorMessage({
content: error.message,
businessErrorCode: BUSINESS_ERROR_CODES.IMAGE_TOO_LARGE,
})
return { reason: 'image_error' }
}

View File

@ -6,6 +6,26 @@ import {
import { parseSlashCommand } from '../../utils/slashCommandParsing.js'
describe('WebSocket memory events', () => {
it('forwards assistant business error codes to the desktop client', () => {
expect(translateCliMessage({
type: 'assistant',
error: 'invalid_request',
isApiErrorMessage: true,
businessErrorCode: 'image_unsupported',
message: {
role: 'assistant',
content: [{ type: 'text', text: 'This model does not support images.' }],
},
}, 'session-1')).toEqual([
{
type: 'error',
message: 'This model does not support images.',
code: 'invalid_request',
businessErrorCode: 'image_unsupported',
},
])
})
it('forwards CLI memory_saved system messages to the desktop client', () => {
const messages = translateCliMessage(
{

View File

@ -72,7 +72,7 @@ export type ServerMessage =
errorType?: string
errorMessage?: string
}
| { type: 'error'; message: string; code: string; retryable?: boolean }
| { type: 'error'; message: string; code: string; retryable?: boolean; businessErrorCode?: string }
| { type: 'system_notification'; subtype: string; message?: string; data?: unknown }
| { type: 'pong' }
| { type: 'team_update'; teamName: string; members: TeamMemberStatus[] }

View File

@ -1070,6 +1070,9 @@ export function translateCliMessage(cliMsg: any, sessionId: string): ServerMessa
type: 'error',
message,
code,
...(typeof cliMsg.businessErrorCode === 'string'
? { businessErrorCode: cliMsg.businessErrorCode }
: {}),
}]
}

View File

@ -1,4 +1,5 @@
import { describe, expect, test } from 'bun:test'
import { BUSINESS_ERROR_CODES } from '../../constants/businessErrors.js'
import {
getAssistantMessageFromError,
getImageUnsupportedErrorMessage,
@ -29,6 +30,7 @@ describe('image unsupported API errors', () => {
)
expect(msg.isApiErrorMessage).toBe(true)
expect(msg.businessErrorCode).toBe(BUSINESS_ERROR_CODES.IMAGE_UNSUPPORTED)
expect(msg.errorDetails).toBe('This model does not support image blocks')
expect(msg.message.content[0]).toMatchObject({
type: 'text',

View File

@ -8,6 +8,7 @@ import type {
BetaStopReason,
} from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
import { AFK_MODE_BETA_HEADER } from 'src/constants/betas.js'
import { BUSINESS_ERROR_CODES } from 'src/constants/businessErrors.js'
import type { SDKAssistantMessageError } from 'src/entrypoints/agentSdkTypes.js'
import type {
AssistantMessage,
@ -495,6 +496,7 @@ export function getAssistantMessageFromError(
if (error instanceof ImageSizeError || error instanceof ImageResizeError) {
return createAssistantAPIErrorMessage({
content: getImageTooLargeErrorMessage(),
businessErrorCode: BUSINESS_ERROR_CODES.IMAGE_TOO_LARGE,
})
}
@ -511,6 +513,7 @@ export function getAssistantMessageFromError(
content: getImageUnsupportedErrorMessage(),
error: 'invalid_request',
errorDetails: error.message,
businessErrorCode: BUSINESS_ERROR_CODES.IMAGE_UNSUPPORTED,
})
}
@ -633,6 +636,7 @@ export function getAssistantMessageFromError(
content: PROMPT_TOO_LONG_ERROR_MESSAGE,
error: 'invalid_request',
errorDetails: error.message,
businessErrorCode: BUSINESS_ERROR_CODES.PROMPT_TOO_LONG,
})
}
@ -645,6 +649,7 @@ export function getAssistantMessageFromError(
content: getPdfTooLargeErrorMessage(),
error: 'invalid_request',
errorDetails: error.message,
businessErrorCode: BUSINESS_ERROR_CODES.PDF_TOO_LARGE,
})
}
@ -656,6 +661,7 @@ export function getAssistantMessageFromError(
return createAssistantAPIErrorMessage({
content: getPdfPasswordProtectedErrorMessage(),
error: 'invalid_request',
businessErrorCode: BUSINESS_ERROR_CODES.PDF_PASSWORD_PROTECTED,
})
}
@ -669,6 +675,7 @@ export function getAssistantMessageFromError(
return createAssistantAPIErrorMessage({
content: getPdfInvalidErrorMessage(),
error: 'invalid_request',
businessErrorCode: BUSINESS_ERROR_CODES.PDF_INVALID,
})
}
@ -682,6 +689,7 @@ export function getAssistantMessageFromError(
return createAssistantAPIErrorMessage({
content: getImageTooLargeErrorMessage(),
errorDetails: error.message,
businessErrorCode: BUSINESS_ERROR_CODES.IMAGE_TOO_LARGE,
})
}
@ -698,6 +706,7 @@ export function getAssistantMessageFromError(
: 'An image in the conversation exceeds the dimension limit for many-image requests (2000px). Run /compact to remove old images from context, or start a new session.',
error: 'invalid_request',
errorDetails: error.message,
businessErrorCode: BUSINESS_ERROR_CODES.IMAGE_TOO_LARGE,
})
}
@ -714,6 +723,7 @@ export function getAssistantMessageFromError(
return createAssistantAPIErrorMessage({
content: 'Auto mode is unavailable for your plan',
error: 'invalid_request',
businessErrorCode: BUSINESS_ERROR_CODES.AUTO_MODE_UNAVAILABLE,
})
}
@ -723,6 +733,7 @@ export function getAssistantMessageFromError(
return createAssistantAPIErrorMessage({
content: getRequestTooLargeErrorMessage(),
error: 'invalid_request',
businessErrorCode: BUSINESS_ERROR_CODES.REQUEST_TOO_LARGE,
})
}

View File

@ -24,6 +24,10 @@ import type { AgentId } from 'src/types/ids.js'
import { companionIntroText } from '../buddy/prompt.js'
import { NO_CONTENT_MESSAGE } from '../constants/messages.js'
import { OUTPUT_STYLE_CONFIG } from '../constants/outputStyles.js'
import {
type BusinessErrorCode,
BUSINESS_ERROR_MEDIA_BLOCK_TYPES,
} from '../constants/businessErrors.js'
import { isAutoMemoryEnabled } from '../memdir/paths.js'
import {
checkStatsigFeatureGate_CACHED_MAY_BE_STALE,
@ -359,6 +363,7 @@ function baseCreateAssistantMessage({
apiError,
error,
errorDetails,
businessErrorCode,
isVirtual,
usage = {
input_tokens: 0,
@ -381,6 +386,7 @@ function baseCreateAssistantMessage({
apiError?: AssistantMessage['apiError']
error?: SDKAssistantMessageError
errorDetails?: string
businessErrorCode?: BusinessErrorCode
isVirtual?: true
usage?: Usage
}): AssistantMessage {
@ -404,6 +410,7 @@ function baseCreateAssistantMessage({
apiError,
error,
errorDetails,
businessErrorCode,
isApiErrorMessage,
isVirtual,
}
@ -438,11 +445,13 @@ export function createAssistantAPIErrorMessage({
apiError,
error,
errorDetails,
businessErrorCode,
}: {
content: string
apiError?: AssistantMessage['apiError']
error?: SDKAssistantMessageError
errorDetails?: string
businessErrorCode?: BusinessErrorCode
}): AssistantMessage {
return baseCreateAssistantMessage({
content: [
@ -455,6 +464,7 @@ export function createAssistantAPIErrorMessage({
apiError,
error,
errorDetails,
businessErrorCode,
})
}
@ -769,6 +779,9 @@ export function normalizeMessages(messages: Message[]): NormalizedMessage[] {
uuid,
error: message.error,
isApiErrorMessage: message.isApiErrorMessage,
apiError: message.apiError,
errorDetails: message.errorDetails,
businessErrorCode: message.businessErrorCode,
advisorModel: message.advisorModel,
} as NormalizedAssistantMessage
})
@ -2001,7 +2014,9 @@ export function normalizeMessagesForAPI(
m => !((m.type === 'user' || m.type === 'assistant') && m.isVirtual),
)
// Build a map from error text → which block types to strip from the preceding user message.
// Build a fallback map from legacy error text → which block types to strip
// from the preceding user message. New synthetic errors use stable
// businessErrorCode values so translated display text cannot break recovery.
const errorToBlockTypes: Record<string, Set<string>> = {
[getPdfTooLargeErrorMessage()]: new Set(['document']),
[getPdfPasswordProtectedErrorMessage()]: new Set(['document']),
@ -2019,16 +2034,24 @@ export function normalizeMessagesForAPI(
if (!isSyntheticApiErrorMessage(msg)) {
continue
}
// Determine which error this is
let blockTypesToStrip: Set<string> | undefined
const blockTypesFromCode =
typeof msg.businessErrorCode === 'string'
? BUSINESS_ERROR_MEDIA_BLOCK_TYPES[msg.businessErrorCode as BusinessErrorCode]
: undefined
if (blockTypesFromCode) {
blockTypesToStrip = new Set(blockTypesFromCode)
}
// Determine which legacy text error this is.
const errorText =
Array.isArray(msg.message.content) &&
msg.message.content[0]?.type === 'text'
? msg.message.content[0].text
: undefined
if (!errorText) {
continue
if (!blockTypesToStrip && errorText) {
blockTypesToStrip = errorToBlockTypes[errorText]
}
const blockTypesToStrip = errorToBlockTypes[errorText]
if (!blockTypesToStrip) {
continue
}

View File

@ -1,4 +1,5 @@
import { describe, expect, test } from 'bun:test'
import { BUSINESS_ERROR_CODES } from '../src/constants/businessErrors.js'
import { getImageUnsupportedErrorMessage } from '../src/services/api/errors.js'
import { roughTokenCountEstimationForAPIRequest } from '../src/services/tokenEstimation.js'
import type { UserMessage } from '../src/types/message.js'
@ -43,6 +44,32 @@ describe('media error recovery', () => {
expect(serialized).toContain('describe this screenshot')
expect(serialized).toContain('continue with text only')
})
test('strips images using stable business error codes', () => {
const imageUser = createUserMessage({
content: [
{ type: 'text', text: 'describe this screenshot' },
imageBlock('base64-image-payload'),
],
uuid: '00000000-0000-4000-8000-000000000003',
})
const unsupported = createAssistantAPIErrorMessage({
content: 'localized display text',
error: 'invalid_request',
businessErrorCode: BUSINESS_ERROR_CODES.IMAGE_UNSUPPORTED,
})
const nextUser = createUserMessage({
content: 'continue with text only',
uuid: '00000000-0000-4000-8000-000000000004',
})
const normalized = normalizeMessagesForAPI([imageUser, unsupported, nextUser])
const serialized = JSON.stringify(normalized)
expect(serialized).not.toContain('base64-image-payload')
expect(serialized).toContain('describe this screenshot')
expect(serialized).toContain('continue with text only')
})
})
describe('media context estimation', () => {