diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx index fef77b26..5823c4fc 100644 --- a/desktop/src/components/chat/MessageList.test.tsx +++ b/desktop/src/components/chat/MessageList.test.tsx @@ -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() + + expect(screen.getByText('错误:')).toBeTruthy() + expect( + screen.getByText( + '当前模型不支持图片。请继续使用文字,或切换到支持视觉的模型后重新发送图片。', + ), + ).toBeTruthy() + expect(screen.queryByText(/This model does not support images/)).toBeNull() + }) }) diff --git a/desktop/src/components/chat/MessageList.tsx b/desktop/src/components/chat/MessageList.tsx index db0ef1f0..722b8773 100644 --- a/desktop/src/components/chat/MessageList.tsx +++ b/desktop/src/components/chat/MessageList.tsx @@ -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 (
- Error: {displayMessage} + {t('common.error')}: {displayMessage} {showRawDetail && (
{message.message} diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index bf497e40..86f13257 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -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', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index c91d3038..58216bfd 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -16,6 +16,7 @@ export const zh: Record = { 'common.enable': '启用', 'common.disable': '禁用', 'common.active': '已激活', + 'common.error': '错误', 'common.copyFailed': '复制失败。', // ─── Sidebar ────────────────────────────────────── @@ -1629,6 +1630,16 @@ export const zh: Record = { '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': '正在压缩上下文', diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts index 688f3cb9..5106d8fd 100644 --- a/desktop/src/stores/chatStore.test.ts +++ b/desktop/src/stores/chatStore.test.ts @@ -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: { diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts index dadcb835..30a2650e 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -1649,7 +1649,17 @@ export const useChatStore = create((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', diff --git a/desktop/src/types/chat.ts b/desktop/src/types/chat.ts index 4819499d..bc0ce247 100644 --- a/desktop/src/types/chat.ts +++ b/desktop/src/types/chat.ts @@ -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 } diff --git a/src/constants/businessErrors.ts b/src/constants/businessErrors.ts new file mode 100644 index 00000000..542f1a6f --- /dev/null +++ b/src/constants/businessErrors.ts @@ -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 +> = { + [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'], +} diff --git a/src/query.ts b/src/query.ts index ccd17a0f..ef1251f2 100644 --- a/src/query.ts +++ b/src/query.ts @@ -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' } } diff --git a/src/server/__tests__/ws-memory-events.test.ts b/src/server/__tests__/ws-memory-events.test.ts index 0b9cac46..334a9449 100644 --- a/src/server/__tests__/ws-memory-events.test.ts +++ b/src/server/__tests__/ws-memory-events.test.ts @@ -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( { diff --git a/src/server/ws/events.ts b/src/server/ws/events.ts index d729763d..aba96873 100644 --- a/src/server/ws/events.ts +++ b/src/server/ws/events.ts @@ -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[] } diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index 37f1bc90..2c51c768 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -1070,6 +1070,9 @@ export function translateCliMessage(cliMsg: any, sessionId: string): ServerMessa type: 'error', message, code, + ...(typeof cliMsg.businessErrorCode === 'string' + ? { businessErrorCode: cliMsg.businessErrorCode } + : {}), }] } diff --git a/src/services/api/errors.test.ts b/src/services/api/errors.test.ts index 0d49bb11..dbf5ed02 100644 --- a/src/services/api/errors.test.ts +++ b/src/services/api/errors.test.ts @@ -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', diff --git a/src/services/api/errors.ts b/src/services/api/errors.ts index 1b8708eb..bb482455 100644 --- a/src/services/api/errors.ts +++ b/src/services/api/errors.ts @@ -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, }) } diff --git a/src/utils/messages.ts b/src/utils/messages.ts index 8ea0e002..266b21d4 100644 --- a/src/utils/messages.ts +++ b/src/utils/messages.ts @@ -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> = { [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 | 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 } diff --git a/tests/mediaRecoveryAndEstimation.test.ts b/tests/mediaRecoveryAndEstimation.test.ts index ecc36f5e..4b75c102 100644 --- a/tests/mediaRecoveryAndEstimation.test.ts +++ b/tests/mediaRecoveryAndEstimation.test.ts @@ -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', () => {