mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-15 12:53:31 +08:00
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:
parent
a5c13848fc
commit
998e85efab
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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': '正在压缩上下文',
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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 }
|
||||
|
||||
24
src/constants/businessErrors.ts
Normal file
24
src/constants/businessErrors.ts
Normal 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'],
|
||||
}
|
||||
@ -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' }
|
||||
}
|
||||
|
||||
@ -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(
|
||||
{
|
||||
|
||||
@ -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[] }
|
||||
|
||||
@ -1070,6 +1070,9 @@ export function translateCliMessage(cliMsg: any, sessionId: string): ServerMessa
|
||||
type: 'error',
|
||||
message,
|
||||
code,
|
||||
...(typeof cliMsg.businessErrorCode === 'string'
|
||||
? { businessErrorCode: cliMsg.businessErrorCode }
|
||||
: {}),
|
||||
}]
|
||||
}
|
||||
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user