From fa20fd467d464c973c91fc952ca118a4a372ad6a Mon Sep 17 00:00:00 2001 From: Hsin <2129830748@qq.com> Date: Fri, 21 Aug 2026 10:57:14 +0800 Subject: [PATCH 1/5] fix(runtime): classify provider capacity errors Fixes #3341 Generated-by: Codex --- .../session-error-presentation.test.ts | 24 +++++++++++++++++ .../src/renderer/locales/conversation-copy.ts | 7 ++--- .../renderer/session-error-presentation.ts | 2 ++ .../renderer/session-status-presentation.ts | 3 +++ .../provider-error-classification.test.ts | 26 +++++++++++++++++++ packages/runtime/src/ai-sdk-backend.ts | 2 ++ packages/runtime/src/model-adapter.ts | 4 +++ packages/runtime/src/model-protocol.ts | 1 + .../src/provider-error-classification.ts | 22 ++++++++++++++++ 9 files changed, 88 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/session-error-presentation.test.ts diff --git a/apps/desktop/src/main/__tests__/session-error-presentation.test.ts b/apps/desktop/src/main/__tests__/session-error-presentation.test.ts new file mode 100644 index 0000000000..7a66477eae --- /dev/null +++ b/apps/desktop/src/main/__tests__/session-error-presentation.test.ts @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { describeSessionErrorReason } from '../../renderer/session-error-presentation.js'; +import { deriveFailedTurnRecovery, describeTurnErrorClass } from '../../renderer/session-status-presentation.js'; + +describe('provider capacity presentation', () => { + it('uses capacity-specific copy instead of the unknown error fallback', () => { + assert.match(describeSessionErrorReason('provider_capacity') ?? '', /满载/); + assert.match(describeTurnErrorClass('provider_capacity'), /满载/); + }); + + it('does not recommend an immediate direct retry', () => { + const recovery = deriveFailedTurnRecovery({ + errorClass: 'provider_capacity', + partialOutputRetained: false, + toolActivityCount: 0, + erroredToolCount: 0, + }); + assert.equal(recovery.action, 'retry'); + assert.match(recovery.label, /等待几分钟|切换模型/); + assert.doesNotMatch(recovery.label, /直接重试/); + }); +}); diff --git a/apps/desktop/src/renderer/locales/conversation-copy.ts b/apps/desktop/src/renderer/locales/conversation-copy.ts index ee1efd9804..5b05c4c1cc 100644 --- a/apps/desktop/src/renderer/locales/conversation-copy.ts +++ b/apps/desktop/src/renderer/locales/conversation-copy.ts @@ -307,6 +307,7 @@ export interface DesktopConversationCopy { timeout: string; auth: string; providerBilling: string; + providerCapacity: string; rateLimit: string; network: string; provider: string; @@ -315,7 +316,7 @@ export interface DesktopConversationCopy { permission: string; restarted: string; sandboxBoundaryClosed: string; - recovery: Record<'safeResume' | 'stepCap' | 'toolError' | 'connection' | 'partial' | 'toolRecord' | 'retry' | 'contextOverflow' | 'sandboxBoundaryClosed', string>; + recovery: Record<'safeResume' | 'stepCap' | 'toolError' | 'connection' | 'partial' | 'toolRecord' | 'retry' | 'capacity' | 'sandboxBoundaryClosed', string>; }; } @@ -588,7 +589,7 @@ const COPY = { reauth: { label: '上次连接测试鉴权失败', tooltip: '最近一次连接测试返回鉴权失败(401 / 403),密钥可能已过期或被吊销。这不会拦截发送,但若发送失败请到 设置 · 模型 重新登录。' }, testError: { label: '上次连接测试失败', tooltip: '最近一次连接测试因网络 / 超时 / 5xx 失败。这不会拦截发送,但若问题持续请到 设置 · 模型 检查 Base URL / 代理。' }, }, - turnError: { unknown: '未知错误', contextOverflow: '上下文窗口已超出限制', timeout: '请求超时', auth: '鉴权失败', providerBilling: '模型服务计费受限', rateLimit: '触发模型速率限制', network: '网络错误', provider: '模型服务返回错误', stepCap: '达到工具步骤上限', tool: '工具调用失败', permission: '等待权限确认', restarted: '本地应用重启,上一轮没有完成', sandboxBoundaryClosed: '本地应用重启,等待确认的「允许访问工作区以外的内容」请求已按拒绝关闭', recovery: { safeResume: '检查当前状态后,可尝试安全恢复', stepCap: '任务可能尚未完成,可以继续', toolError: '先检查工具结果,再决定是否重试', connection: '先检查模型连接或登录状态', partial: '已保留部分输出,可从这里继续', toolRecord: '工具记录已保留,重试前先看结果', retry: '没有执行工具,可直接重试', contextOverflow: '上下文仍超出限制,请减少附件或开启新任务', sandboxBoundaryClosed: '访问范围没有放开,重试本轮后可重新决定' } }, + turnError: { unknown: '未知错误', contextOverflow: '上下文窗口已超出限制', timeout: '请求超时', auth: '鉴权失败', providerBilling: '模型服务计费受限', providerCapacity: '模型服务暂时满载,请稍后重试或切换模型', rateLimit: '触发模型速率限制', network: '网络错误', provider: '模型服务返回错误', stepCap: '达到工具步骤上限', tool: '工具调用失败', permission: '等待权限确认', restarted: '本地应用重启,上一轮没有完成', sandboxBoundaryClosed: '本地应用重启,等待确认的「允许访问工作区以外的内容」请求已按拒绝关闭', recovery: { safeResume: '检查当前状态后,可尝试安全恢复', stepCap: '任务可能尚未完成,可以继续', toolError: '先检查工具结果,再决定是否重试', connection: '先检查模型连接或登录状态', partial: '已保留部分输出,可从这里继续', toolRecord: '工具记录已保留,重试前先看结果', capacity: '模型服务暂时满载,请等待几分钟或切换模型后重试', contextOverflow: '上下文仍超出限制,请减少附件或开启新任务', sandboxBoundaryClosed: '访问范围没有放开,重试本轮后可重新决定' } }, }, en: { actions: { stopFailedTitle: 'Failed to stop', stopFailedFallback: 'The task action failed. Try again later.', refreshSessionsFailedTitle: 'Failed to refresh tasks', refreshSessionsFailedFallback: 'The task list could not be refreshed. Try again later.', conversationErrorTitle: 'Task error', conversationErrorFallback: 'The task run failed. Try again later.', regenerateStartedTitle: 'Regeneration started', regenerateStartedDescription: 'Generating a new response', branchCreatedTitle: 'Branch created', branchCreatedDescription: (name) => `New task: ${name}`, revisionStartedTitle: 'Edit draft ready', revisionStartedDescription: 'The original task is kept; sending creates a new version', revisionReadyTitle: 'Ready to edit and resend', revisionReadyDescription: 'Rewound to before that message; edit and send when ready', revisionUnavailableTitle: 'This message cannot be edited yet', revisionAttachmentsUnsupported: 'Edit & resend does not yet support historical attachments. Copy the text into a new message instead.', revisionTransformedTextUnsupported: 'Edit & resend does not yet support messages sent with an explicit skill. Copy the text and select the skill again instead.', revisionDraftAttachmentConflict: 'The composer already has pending attachments. Send or remove them before editing a sent message.', revisionCommandUnsupported: 'You cannot run /compact, /side, or orchestration commands while editing a sent message. Cancel the edit first.', revisionAlreadyActive: 'Another message is already being edited. Send or cancel that edit first.', revisionCancelLabel: 'Cancel', revisionBannerTitle: 'Editing sent message', revisionBannerDetail: '· New version on send', revisionUnchanged: 'Nothing changed. Use Regenerate if you only want a new answer.', operationFailedTitle: 'Action failed', operationFailedFallback: 'The task action failed. Try again later.', attachmentFailedTitle: 'Failed to add attachment', tryAgain: 'Try again later.', modelReboundTitle: 'Switched to an available model', modelReboundDescription: (modelId) => `The previous connection is unavailable${modelId ? ` · ${modelId}` : ''}`, messageReadFailedTitle: 'Failed to load task', returnLatest: 'Return to latest', scrollMainToBottom: 'Scroll main conversation to bottom' }, @@ -794,7 +795,7 @@ const COPY = { reauth: { label: 'Last connection test failed authentication', tooltip: 'The latest test returned 401 / 403. Sending is not blocked, but sign in again under Settings · Models if it fails.' }, testError: { label: 'Last connection test failed', tooltip: 'The latest test failed because of a network, timeout, or 5xx error. Sending is not blocked; check Base URL or proxy settings if it persists.' }, }, - turnError: { unknown: 'Unknown error', contextOverflow: 'Context window exceeded', timeout: 'Request timed out', auth: 'Authentication failed', providerBilling: 'Provider billing required', rateLimit: 'Model rate limit reached', network: 'Network error', provider: 'Model service error', stepCap: 'Tool-step limit reached', tool: 'Tool call failed', permission: 'Waiting for permission', restarted: 'The app restarted before the previous turn completed', sandboxBoundaryClosed: 'The app restarted, so the pending request to reach outside the workspace was closed as denied', recovery: { safeResume: 'Inspect the current state, then try safe recovery', stepCap: 'The task may be incomplete; continue from here', toolError: 'Inspect the tool result before retrying', connection: 'Check the model connection or sign-in status', partial: 'Partial output was retained; continue from here', toolRecord: 'Tool history was retained; inspect it before retrying', retry: 'No tools ran; retry directly', contextOverflow: 'Context is still too large; reduce attachments or start a new task', sandboxBoundaryClosed: 'Access was not widened; retry the turn to decide again' } }, + turnError: { unknown: 'Unknown error', contextOverflow: 'Context window exceeded', timeout: 'Request timed out', auth: 'Authentication failed', providerBilling: 'Provider billing required', providerCapacity: 'The model service is temporarily at capacity. Wait and retry, or switch models.', rateLimit: 'Model rate limit reached', network: 'Network error', provider: 'Model service error', stepCap: 'Tool-step limit reached', tool: 'Tool call failed', permission: 'Waiting for permission', restarted: 'The app restarted before the previous turn completed', sandboxBoundaryClosed: 'The app restarted, so the pending request to reach outside the workspace was closed as denied', recovery: { safeResume: 'Inspect the current state, then try safe recovery', stepCap: 'The task may be incomplete; continue from here', toolError: 'Inspect the tool result before retrying', connection: 'Check the model connection or sign-in status', partial: 'Partial output was retained; continue from here', toolRecord: 'Tool history was retained; inspect it before retrying', retry: 'No tools ran; retry directly', capacity: 'The model service is at capacity. Wait a few minutes or switch models before retrying.', contextOverflow: 'Context is still too large; reduce attachments or start a new task', sandboxBoundaryClosed: 'Access was not widened; retry the turn to decide again' } }, }, } satisfies UiCatalog; diff --git a/apps/desktop/src/renderer/session-error-presentation.ts b/apps/desktop/src/renderer/session-error-presentation.ts index 1c6a32c2e7..3dde9920c1 100644 --- a/apps/desktop/src/renderer/session-error-presentation.ts +++ b/apps/desktop/src/renderer/session-error-presentation.ts @@ -36,6 +36,8 @@ export function describeSessionErrorReason(reason: string | undefined, locale: U return copy.auth; case 'provider_billing': return copy.providerBilling; + case 'provider_capacity': + return copy.providerCapacity; case 'provider_unavailable': return copy.provider; case 'rate_limit': diff --git a/apps/desktop/src/renderer/session-status-presentation.ts b/apps/desktop/src/renderer/session-status-presentation.ts index db1ef108b3..37c8751481 100644 --- a/apps/desktop/src/renderer/session-status-presentation.ts +++ b/apps/desktop/src/renderer/session-status-presentation.ts @@ -198,6 +198,9 @@ export function deriveFailedTurnRecovery(input: FailedTurnRecoveryInput, locale: if (lower === 'provider_billing' || lower === 'auth' || lower.includes('auth') || lower === '401' || lower === '403') { return { action: 'check_connection', label: copy.connection }; } + if (lower === 'provider_capacity') { + return { action: 'retry', label: copy.capacity }; + } if (input.partialOutputRetained) { return { action: 'continue', label: copy.partial }; } diff --git a/packages/runtime/src/__tests__/provider-error-classification.test.ts b/packages/runtime/src/__tests__/provider-error-classification.test.ts index f209baf708..f80401d5e9 100644 --- a/packages/runtime/src/__tests__/provider-error-classification.test.ts +++ b/packages/runtime/src/__tests__/provider-error-classification.test.ts @@ -195,6 +195,32 @@ describe('Provider error classification', () => { assert.equal(providerFailureDiagnostic(delayedRateLimit).retryable, true); }); + test('classifies provider capacity errors and retries with backoff', () => { + const capacity = Object.assign( + new Error('The model is currently at capacity due to high demand.'), + { + name: 'AI_APICallError', + data: { error: { code: 'resource-exhausted' } }, + }, + ); + + assert.equal(classifyError(capacity), 'ProviderCapacity'); + assert.deepEqual(providerRetryMetadata(capacity), { retryable: true }); + assert.deepEqual( + providerRetryMetadata( + Object.assign(capacity, { + responseHeaders: { 'retry-after': '12' }, + }), + ), + { retryable: true, retryAfterMs: 12_000 }, + ); + + const topLevelCode = Object.assign(new Error('The model is currently at capacity'), { + code: 'resource-exhausted', + }); + assert.equal(classifyError(topLevelCode), 'ProviderCapacity'); + }); + test('classifies context overflow by predicate, carrier shape, and evidence precedence', () => { const overflow = (message: string, extra: Record = {}) => classifyError(Object.assign(new Error(message), { name: 'AI_APICallError', ...extra })); diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index 93ffbf0993..fdd9dca1a9 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -932,6 +932,8 @@ function providerRetryReason(kind: ModelFailureKind): ProviderRetryReason { case 'rate_limit': case 'timeout': return kind; + case 'provider_capacity': + return 'provider_unavailable'; default: return 'unknown'; } diff --git a/packages/runtime/src/model-adapter.ts b/packages/runtime/src/model-adapter.ts index 15fca19c72..a21f89f3e1 100644 --- a/packages/runtime/src/model-adapter.ts +++ b/packages/runtime/src/model-adapter.ts @@ -927,6 +927,8 @@ function modelFailureKind(errorClass: string): ModelFailureKind { return 'network'; case 'ProviderBilling': return 'provider_billing'; + case 'ProviderCapacity': + return 'provider_capacity'; case 'ProviderUnavailable': return 'provider_unavailable'; case 'RateLimit': @@ -950,6 +952,8 @@ function errorClassFromFailureKind(kind: ModelFailureKind): string { return 'Network'; case 'provider_billing': return 'ProviderBilling'; + case 'provider_capacity': + return 'ProviderCapacity'; case 'provider_unavailable': return 'ProviderUnavailable'; case 'rate_limit': diff --git a/packages/runtime/src/model-protocol.ts b/packages/runtime/src/model-protocol.ts index 7042731658..fe1a1db769 100644 --- a/packages/runtime/src/model-protocol.ts +++ b/packages/runtime/src/model-protocol.ts @@ -333,6 +333,7 @@ export type ModelFailureKind = | 'auth' | 'context_overflow' | 'network' + | 'provider_capacity' | 'provider_billing' | 'provider_unavailable' | 'rate_limit' diff --git a/packages/runtime/src/provider-error-classification.ts b/packages/runtime/src/provider-error-classification.ts index 5e38033607..00eff3df1f 100644 --- a/packages/runtime/src/provider-error-classification.ts +++ b/packages/runtime/src/provider-error-classification.ts @@ -36,6 +36,12 @@ const PROVIDER_UNAVAILABLE_PROVIDER_CODES: ReadonlySet = new Set([ 'server_error', // OpenAI-compatible stream errors can omit the HTTP status. ]); +/** Provider codes meaning the model is temporarily at capacity. */ +const PROVIDER_CAPACITY_CODES: ReadonlySet = new Set([ + 'resource-exhausted', + 'resource_exhausted', +]); + /** * A provider failure normalized into classification evidence. classifyError's * real input domain is NOT just Error instances: a request-level failure is @@ -142,6 +148,13 @@ export function providerRetryMetadata(error: unknown): ProviderRetryMetadata { const status = Number(evidence.statusCode || evidence.code); const errorClass = classifyProviderFacts(facts); const retryAfterMs = parseRetryAfterMs(facts.responseHeaders ?? {}); + if (errorClass === 'ProviderCapacity') { + if (retryAfterMs === null) return { retryable: false }; + return { + retryable: true, + ...(retryAfterMs !== undefined ? { retryAfterMs } : {}), + }; + } if (errorClass === 'RateLimit' || status === 429) { if (retryAfterMs === undefined || retryAfterMs === null) return { retryable: false }; return { retryable: true, retryAfterMs }; @@ -304,6 +317,7 @@ const DURABLE_PROVIDER_ERROR_CLASSES: ReadonlySet = new Set([ 'Auth', 'ContextLength', 'Network', + 'ProviderCapacity', 'ProviderBilling', 'ProviderUnavailable', 'RateLimit', @@ -611,6 +625,12 @@ function classifyProviderFacts(facts: ProviderErrorFacts): string { if (statusCode === '429' || code === '429') return 'RateLimit'; if (statusCode === '401' || statusCode === '403' || code === '401' || code === '403') return 'Auth'; + if ( + PROVIDER_CAPACITY_CODES.has(code) || + structuredCodes.some((c) => PROVIDER_CAPACITY_CODES.has(c)) + ) { + return 'ProviderCapacity'; + } // Structured provider evidence: the parsed error JSON's code/type is the // only unconditional signal for a context overflow. if (structuredCodes.some((c) => CONTEXT_OVERFLOW_PROVIDER_CODES.has(c))) return 'ContextLength'; @@ -649,6 +669,8 @@ export function errorPresentationFromClass(errorClass: string): { return { reason: 'auth', message: 'Authentication failed' }; case 'ProviderBilling': return { reason: 'provider_billing', message: 'Provider billing required' }; + case 'ProviderCapacity': + return { reason: 'provider_capacity', message: 'Model service is temporarily at capacity' }; case 'ProviderUnavailable': return { reason: 'provider_unavailable', message: 'Provider returned an error' }; case 'RateLimit': From cbfb61b069778089dce66301b96e8dad69022f9f Mon Sep 17 00:00:00 2001 From: Hsin <2129830748@qq.com> Date: Sat, 22 Aug 2026 21:08:11 +0800 Subject: [PATCH 2/5] fix(runtime): refine provider capacity retry classification Keep xAI capacity errors distinct from quota exhaustion and fall back to bounded local retry when Retry-After is malformed. Generated-by: gpt-5.6-sol --- .../provider-error-classification.test.ts | 28 +++++++++++++------ packages/runtime/src/ai-sdk-backend.ts | 3 ++ .../src/provider-error-classification.ts | 20 +++++++------ 3 files changed, 35 insertions(+), 16 deletions(-) diff --git a/packages/runtime/src/__tests__/provider-error-classification.test.ts b/packages/runtime/src/__tests__/provider-error-classification.test.ts index f80401d5e9..a9b99ae5ae 100644 --- a/packages/runtime/src/__tests__/provider-error-classification.test.ts +++ b/packages/runtime/src/__tests__/provider-error-classification.test.ts @@ -196,29 +196,41 @@ describe('Provider error classification', () => { }); test('classifies provider capacity errors and retries with backoff', () => { - const capacity = Object.assign( - new Error('The model is currently at capacity due to high demand.'), - { + const capacity = () => + Object.assign(new Error('The model is currently at capacity due to high demand.'), { name: 'AI_APICallError', data: { error: { code: 'resource-exhausted' } }, - }, - ); + }); - assert.equal(classifyError(capacity), 'ProviderCapacity'); - assert.deepEqual(providerRetryMetadata(capacity), { retryable: true }); + assert.equal(classifyError(capacity()), 'ProviderCapacity'); + assert.deepEqual(providerRetryMetadata(capacity()), { retryable: true }); assert.deepEqual( providerRetryMetadata( - Object.assign(capacity, { + Object.assign(capacity(), { responseHeaders: { 'retry-after': '12' }, }), ), { retryable: true, retryAfterMs: 12_000 }, ); + assert.deepEqual( + providerRetryMetadata( + Object.assign(capacity(), { + responseHeaders: { 'retry-after': 'not-a-delay' }, + }), + ), + { retryable: true }, + ); const topLevelCode = Object.assign(new Error('The model is currently at capacity'), { code: 'resource-exhausted', }); assert.equal(classifyError(topLevelCode), 'ProviderCapacity'); + + const ambiguousQuotaCode = Object.assign(new Error('resource exhausted'), { + name: 'AI_APICallError', + data: { error: { code: 'resource_exhausted' } }, + }); + assert.notEqual(classifyError(ambiguousQuotaCode), 'ProviderCapacity'); }); test('classifies context overflow by predicate, carrier shape, and evidence precedence', () => { diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index fdd9dca1a9..3e96a760dc 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -933,6 +933,9 @@ function providerRetryReason(kind: ModelFailureKind): ProviderRetryReason { case 'timeout': return kind; case 'provider_capacity': + // ProviderRetryReason intentionally remains transport-oriented; the + // durable failure kind and Desktop presentation retain the capacity + // distinction while retry progress uses the existing availability lane. return 'provider_unavailable'; default: return 'unknown'; diff --git a/packages/runtime/src/provider-error-classification.ts b/packages/runtime/src/provider-error-classification.ts index 00eff3df1f..3b3fba72f3 100644 --- a/packages/runtime/src/provider-error-classification.ts +++ b/packages/runtime/src/provider-error-classification.ts @@ -36,11 +36,13 @@ const PROVIDER_UNAVAILABLE_PROVIDER_CODES: ReadonlySet = new Set([ 'server_error', // OpenAI-compatible stream errors can omit the HTTP status. ]); -/** Provider codes meaning the model is temporarily at capacity. */ -const PROVIDER_CAPACITY_CODES: ReadonlySet = new Set([ - 'resource-exhausted', - 'resource_exhausted', -]); +/** + * xAI emits this code for transient model capacity failures, including when + * the same payload is relayed through an OpenAI-compatible gateway. Do not + * add the generic gRPC/Google `resource_exhausted` spelling here: that code + * represents quota exhaustion and needs different user guidance. + */ +const PROVIDER_CAPACITY_CODES: ReadonlySet = new Set(['resource-exhausted']); /** * A provider failure normalized into classification evidence. classifyError's @@ -149,10 +151,11 @@ export function providerRetryMetadata(error: unknown): ProviderRetryMetadata { const errorClass = classifyProviderFacts(facts); const retryAfterMs = parseRetryAfterMs(facts.responseHeaders ?? {}); if (errorClass === 'ProviderCapacity') { - if (retryAfterMs === null) return { retryable: false }; + // Capacity is transient even when the provider sends a malformed delay; + // fall back to the adapter's bounded local backoff in that case. return { retryable: true, - ...(retryAfterMs !== undefined ? { retryAfterMs } : {}), + ...(retryAfterMs !== undefined && retryAfterMs !== null ? { retryAfterMs } : {}), }; } if (errorClass === 'RateLimit' || status === 429) { @@ -619,6 +622,7 @@ export function classifyError(error: unknown): string { function classifyProviderFacts(facts: ProviderErrorFacts): string { const { target: classificationTarget, evidence } = facts; const { text, statusCode, code, structuredCodes } = evidence; + const normalizedCode = code.toLowerCase(); if (text.includes('abort')) return 'Abort'; if (code === OPENAI_RESPONSES_WEBSOCKET_TRANSPORT_ERROR) return 'Network'; if (statusCode === '402' || code === '402') return 'ProviderBilling'; @@ -626,7 +630,7 @@ function classifyProviderFacts(facts: ProviderErrorFacts): string { if (statusCode === '401' || statusCode === '403' || code === '401' || code === '403') return 'Auth'; if ( - PROVIDER_CAPACITY_CODES.has(code) || + PROVIDER_CAPACITY_CODES.has(normalizedCode) || structuredCodes.some((c) => PROVIDER_CAPACITY_CODES.has(c)) ) { return 'ProviderCapacity'; From f0a9378ccd83a8fb93319c427bff911d33b9c702 Mon Sep 17 00:00:00 2001 From: Hsin <2129830748@qq.com> Date: Sat, 22 Aug 2026 21:42:35 +0800 Subject: [PATCH 3/5] fix(runtime): preserve provider capacity retry reason Generated-by: gpt-5.6-sol --- .../session-error-presentation.test.ts | 19 ++++++ .../src/renderer/locales/conversation-copy.ts | 4 +- packages/core/src/events.ts | 1 + .../__tests__/handshake-compatibility.test.ts | 4 +- .../src/__tests__/protocol.test.ts | 4 +- packages/runtime-host/src/protocol/index.ts | 4 +- packages/runtime-host/src/protocol/turn.ts | 1 + .../src/__tests__/ai-sdk-backend.test.ts | 63 +++++++++++++++++++ .../runtime/src/__tests__/ai-sdk-flow.test.ts | 23 +++++++ packages/runtime/src/ai-sdk-backend.ts | 5 +- .../__tests__/live-turn-projection.test.ts | 49 ++++++++++++++- packages/ui/src/conversation-copy.ts | 4 +- 12 files changed, 167 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/main/__tests__/session-error-presentation.test.ts b/apps/desktop/src/main/__tests__/session-error-presentation.test.ts index 7a66477eae..1f89682187 100644 --- a/apps/desktop/src/main/__tests__/session-error-presentation.test.ts +++ b/apps/desktop/src/main/__tests__/session-error-presentation.test.ts @@ -1,3 +1,22 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; diff --git a/apps/desktop/src/renderer/locales/conversation-copy.ts b/apps/desktop/src/renderer/locales/conversation-copy.ts index 5b05c4c1cc..40f757ee2e 100644 --- a/apps/desktop/src/renderer/locales/conversation-copy.ts +++ b/apps/desktop/src/renderer/locales/conversation-copy.ts @@ -316,7 +316,7 @@ export interface DesktopConversationCopy { permission: string; restarted: string; sandboxBoundaryClosed: string; - recovery: Record<'safeResume' | 'stepCap' | 'toolError' | 'connection' | 'partial' | 'toolRecord' | 'retry' | 'capacity' | 'sandboxBoundaryClosed', string>; + recovery: Record<'safeResume' | 'stepCap' | 'toolError' | 'connection' | 'partial' | 'toolRecord' | 'retry' | 'capacity' | 'contextOverflow' | 'sandboxBoundaryClosed', string>; }; } @@ -589,7 +589,7 @@ const COPY = { reauth: { label: '上次连接测试鉴权失败', tooltip: '最近一次连接测试返回鉴权失败(401 / 403),密钥可能已过期或被吊销。这不会拦截发送,但若发送失败请到 设置 · 模型 重新登录。' }, testError: { label: '上次连接测试失败', tooltip: '最近一次连接测试因网络 / 超时 / 5xx 失败。这不会拦截发送,但若问题持续请到 设置 · 模型 检查 Base URL / 代理。' }, }, - turnError: { unknown: '未知错误', contextOverflow: '上下文窗口已超出限制', timeout: '请求超时', auth: '鉴权失败', providerBilling: '模型服务计费受限', providerCapacity: '模型服务暂时满载,请稍后重试或切换模型', rateLimit: '触发模型速率限制', network: '网络错误', provider: '模型服务返回错误', stepCap: '达到工具步骤上限', tool: '工具调用失败', permission: '等待权限确认', restarted: '本地应用重启,上一轮没有完成', sandboxBoundaryClosed: '本地应用重启,等待确认的「允许访问工作区以外的内容」请求已按拒绝关闭', recovery: { safeResume: '检查当前状态后,可尝试安全恢复', stepCap: '任务可能尚未完成,可以继续', toolError: '先检查工具结果,再决定是否重试', connection: '先检查模型连接或登录状态', partial: '已保留部分输出,可从这里继续', toolRecord: '工具记录已保留,重试前先看结果', capacity: '模型服务暂时满载,请等待几分钟或切换模型后重试', contextOverflow: '上下文仍超出限制,请减少附件或开启新任务', sandboxBoundaryClosed: '访问范围没有放开,重试本轮后可重新决定' } }, + turnError: { unknown: '未知错误', contextOverflow: '上下文窗口已超出限制', timeout: '请求超时', auth: '鉴权失败', providerBilling: '模型服务计费受限', providerCapacity: '模型服务暂时满载,请稍后重试或切换模型', rateLimit: '触发模型速率限制', network: '网络错误', provider: '模型服务返回错误', stepCap: '达到工具步骤上限', tool: '工具调用失败', permission: '等待权限确认', restarted: '本地应用重启,上一轮没有完成', sandboxBoundaryClosed: '本地应用重启,等待确认的「允许访问工作区以外的内容」请求已按拒绝关闭', recovery: { safeResume: '检查当前状态后,可尝试安全恢复', stepCap: '任务可能尚未完成,可以继续', toolError: '先检查工具结果,再决定是否重试', connection: '先检查模型连接或登录状态', partial: '已保留部分输出,可从这里继续', toolRecord: '工具记录已保留,重试前先看结果', retry: '没有执行工具,可直接重试', capacity: '模型服务暂时满载,请等待几分钟或切换模型后重试', contextOverflow: '上下文仍超出限制,请减少附件或开启新任务', sandboxBoundaryClosed: '访问范围没有放开,重试本轮后可重新决定' } }, }, en: { actions: { stopFailedTitle: 'Failed to stop', stopFailedFallback: 'The task action failed. Try again later.', refreshSessionsFailedTitle: 'Failed to refresh tasks', refreshSessionsFailedFallback: 'The task list could not be refreshed. Try again later.', conversationErrorTitle: 'Task error', conversationErrorFallback: 'The task run failed. Try again later.', regenerateStartedTitle: 'Regeneration started', regenerateStartedDescription: 'Generating a new response', branchCreatedTitle: 'Branch created', branchCreatedDescription: (name) => `New task: ${name}`, revisionStartedTitle: 'Edit draft ready', revisionStartedDescription: 'The original task is kept; sending creates a new version', revisionReadyTitle: 'Ready to edit and resend', revisionReadyDescription: 'Rewound to before that message; edit and send when ready', revisionUnavailableTitle: 'This message cannot be edited yet', revisionAttachmentsUnsupported: 'Edit & resend does not yet support historical attachments. Copy the text into a new message instead.', revisionTransformedTextUnsupported: 'Edit & resend does not yet support messages sent with an explicit skill. Copy the text and select the skill again instead.', revisionDraftAttachmentConflict: 'The composer already has pending attachments. Send or remove them before editing a sent message.', revisionCommandUnsupported: 'You cannot run /compact, /side, or orchestration commands while editing a sent message. Cancel the edit first.', revisionAlreadyActive: 'Another message is already being edited. Send or cancel that edit first.', revisionCancelLabel: 'Cancel', revisionBannerTitle: 'Editing sent message', revisionBannerDetail: '· New version on send', revisionUnchanged: 'Nothing changed. Use Regenerate if you only want a new answer.', operationFailedTitle: 'Action failed', operationFailedFallback: 'The task action failed. Try again later.', attachmentFailedTitle: 'Failed to add attachment', tryAgain: 'Try again later.', modelReboundTitle: 'Switched to an available model', modelReboundDescription: (modelId) => `The previous connection is unavailable${modelId ? ` · ${modelId}` : ''}`, messageReadFailedTitle: 'Failed to load task', returnLatest: 'Return to latest', scrollMainToBottom: 'Scroll main conversation to bottom' }, diff --git a/packages/core/src/events.ts b/packages/core/src/events.ts index bdfa3d4f66..6ae6ec1aa3 100644 --- a/packages/core/src/events.ts +++ b/packages/core/src/events.ts @@ -1096,6 +1096,7 @@ export interface QueueUpdateEvent extends BaseEvent { export type ProviderRetryReason = | 'network' + | 'provider_capacity' | 'provider_unavailable' | 'rate_limit' | 'timeout' diff --git a/packages/runtime-host/src/__tests__/handshake-compatibility.test.ts b/packages/runtime-host/src/__tests__/handshake-compatibility.test.ts index 3fda3aa337..69e9dddd9d 100644 --- a/packages/runtime-host/src/__tests__/handshake-compatibility.test.ts +++ b/packages/runtime-host/src/__tests__/handshake-compatibility.test.ts @@ -108,7 +108,7 @@ test('receives structured incompatibility guidance from the released v0.1.11 Hos ); }); -test('rejects an epoch-23 Host before any domain command', async () => { +test('rejects an epoch-39 Host before any domain command', async () => { let admittedRequest: RequestFrame | undefined; await withForgedHandshakePeer( async (transport, hostEpoch, rootId) => { @@ -120,7 +120,7 @@ test('rejects an epoch-23 Host before any domain command', async () => { hostEpoch, connectionId: 'forged-epoch-connection', selectedProtocol: RUNTIME_HOST_PROTOCOL_VERSION, - compatibilityEpoch: 23, + compatibilityEpoch: 39, compositionId: 'maka.interactive', compositionRevision: '1', state: 'ready', diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index dcdc8c60c1..4064810411 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -205,8 +205,8 @@ describe('Runtime Host bootstrap protocol', () => { ); }); - test('publishes a new compatibility epoch for typed context compaction outcomes', () => { - assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 39); + test('publishes a new compatibility epoch for provider capacity retry progress', () => { + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 41); }); test('selects the highest mutually supported protocol and rejects a gap', () => { diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 9a199ba813..d1418d69c2 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -91,7 +91,9 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 41 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 42 as const; +// 42: Turn provider retry progress adds `provider_capacity`. Older peers reject +// that strict retry-reason enum value, so mixed versions must fail handshake. // 41: Context compaction returns a typed terminal outcome on both Turn // snapshots and context.compact results. Epoch-40 peers reject these closed // shapes after admission, so mixed peers must fail during the handshake. diff --git a/packages/runtime-host/src/protocol/turn.ts b/packages/runtime-host/src/protocol/turn.ts index 5ee9829c2a..100cb6c230 100644 --- a/packages/runtime-host/src/protocol/turn.ts +++ b/packages/runtime-host/src/protocol/turn.ts @@ -762,6 +762,7 @@ export function decodeTurnProviderRetry(value: unknown): TurnProviderRetry { function requireProviderRetryReason(value: unknown): ProviderRetryReason { if ( value === 'network' || + value === 'provider_capacity' || value === 'provider_unavailable' || value === 'rate_limit' || value === 'timeout' || diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index 5ad1573527..98844d2eef 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -8578,6 +8578,69 @@ describe('AiSdkBackend RunTrace', () => { ); }); + test('preserves provider capacity in retry progress', async () => { + let calls = 0; + const model = new MockLanguageModelV4({ + doStream: async () => { + calls += 1; + if (calls === 1) { + throw new APICallError({ + message: 'The model is currently at capacity due to high demand.', + url: 'https://api.x.ai/v1/chat/completions', + requestBodyValues: {}, + data: { error: { code: 'resource-exhausted' } }, + }); + } + return { + stream: simulateReadableStream({ + chunks: [ + { type: 'stream-start', warnings: [] }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: { + inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 1, text: 1, reasoning: 0 }, + }, + }, + ], + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }; + }, + }); + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection: connection(), + apiKey: 'sk-test', + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + newId: idGenerator(), + now: monotonicClock(), + providerRetrySleep: async () => {}, + }); + + const events: SessionEvent[] = []; + for await (const event of backend.send({ turnId: 'turn-1', text: 'hi', context: [] })) { + events.push(event); + } + + assert.equal(calls, 2); + assert.deepEqual( + events + .filter((event) => event.type === 'provider_retry') + .map(({ phase, reason }) => ({ phase, reason })), + [ + { phase: 'scheduled', reason: 'provider_capacity' }, + { phase: 'started', reason: 'provider_capacity' }, + ], + ); + }); + test('retries one idle watchdog timeout after preserving partial thinking', async () => { const timers = manualWatchdogTimer(); const assistants: AssistantMessage[] = []; diff --git a/packages/runtime/src/__tests__/ai-sdk-flow.test.ts b/packages/runtime/src/__tests__/ai-sdk-flow.test.ts index 3a4a1d1edc..d08fcea84e 100644 --- a/packages/runtime/src/__tests__/ai-sdk-flow.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-flow.test.ts @@ -704,6 +704,29 @@ describe('AiSdkFlow seam', () => { }, }); }); + + test('maps provider capacity retry progress without collapsing its reason', () => { + const retry = ev({ + type: 'provider_retry', + phase: 'scheduled', + attempt: 2, + maxAttempts: 10, + delayMs: 4_000, + reason: 'provider_capacity', + }); + + const mapped = mapSessionEventToRuntimeEvent(retry, ctx); + + assert.deepEqual(mapped.actions?.stateDelta, { + providerRetry: { + phase: 'scheduled', + attempt: 2, + maxAttempts: 10, + delayMs: 4_000, + reason: 'provider_capacity', + }, + }); + }); }); // ============================================================================ diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index 3e96a760dc..48bb09af68 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -933,10 +933,7 @@ function providerRetryReason(kind: ModelFailureKind): ProviderRetryReason { case 'timeout': return kind; case 'provider_capacity': - // ProviderRetryReason intentionally remains transport-oriented; the - // durable failure kind and Desktop presentation retain the capacity - // distinction while retry progress uses the existing availability lane. - return 'provider_unavailable'; + return 'provider_capacity'; default: return 'unknown'; } diff --git a/packages/ui/src/__tests__/live-turn-projection.test.ts b/packages/ui/src/__tests__/live-turn-projection.test.ts index 233533d0b1..55cdd8e5f4 100644 --- a/packages/ui/src/__tests__/live-turn-projection.test.ts +++ b/packages/ui/src/__tests__/live-turn-projection.test.ts @@ -30,6 +30,7 @@ import { } from '../live-turn-projection.js'; import { overlayLiveTurn, type ToolActivityItem } from '../materialize.js'; import { redactSecrets } from '../redact.js'; +import { getConversationCopy } from '../conversation-copy.js'; // A client that just sent cannot read "has my turn started" off session status: // it is the same before the turn starts and after it ends. The arm carries @@ -67,6 +68,16 @@ describe('the unconfirmed claim an arm carries', () => { }); }); +describe('provider retry copy', () => { + it('describes capacity retries without collapsing them into generic unavailability', () => { + assert.match(getConversationCopy('zh').messages.providerRetryReason.provider_capacity, /满载/); + assert.match( + getConversationCopy('en').messages.providerRetryReason.provider_capacity, + /capacity/, + ); + }); +}); + describe('applyLiveTurnEvent', () => { it('keeps every streamed prefix oracle-equivalent and drops raw state on terminal events', () => { const input = 'api_key=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY tail'; @@ -194,7 +205,16 @@ describe('applyLiveTurnEvent', () => { maxAttempts: 10, reason: 'rate_limit', }); - assert.equal(started?.providerRetry?.phase, 'started'); + assert.deepEqual(started?.providerRetry, { + type: 'provider_retry', + id: 'retry-2', + turnId: 'turn-1', + ts: 101, + phase: 'started', + attempt: 2, + maxAttempts: 10, + reason: 'rate_limit', + }); const streamed = applyLiveTurnEvent(started, { type: 'text_delta', @@ -207,6 +227,33 @@ describe('applyLiveTurnEvent', () => { assert.equal(streamed?.providerRetry, undefined); }); + it('keeps provider capacity visible through retry projection', () => { + const live = applyLiveTurnEvent(armLiveTurn('turn-1'), { + type: 'provider_retry', + id: 'retry-capacity', + turnId: 'turn-1', + ts: 100, + phase: 'scheduled', + attempt: 2, + maxAttempts: 10, + delayMs: 4_000, + reason: 'provider_capacity', + }); + + const started = applyLiveTurnEvent(live, { + type: 'provider_retry', + id: 'retry-capacity-started', + turnId: 'turn-1', + ts: 101, + phase: 'started', + attempt: 2, + maxAttempts: 10, + reason: 'provider_capacity', + }); + + assert.equal(started?.providerRetry?.reason, 'provider_capacity'); + }); + it('replaces the live reasoning with thinking_complete on the same step', () => { diff --git a/packages/ui/src/conversation-copy.ts b/packages/ui/src/conversation-copy.ts index ee43ed20e1..c949c86fab 100644 --- a/packages/ui/src/conversation-copy.ts +++ b/packages/ui/src/conversation-copy.ts @@ -469,7 +469,7 @@ const CONVERSATION_COPY = { chooseAriaLabel: (label, branch) => branch ? `选择项目:${label},当前分支 ${branch}` : `选择项目:${label}`, }, messages: { - you: '你', assistant: 'Maka', processing: '正在处理…', continuing: '继续中…', workingPhrases: ['正在琢磨…', '正在推敲…', '正在盘算…', '正在钻研…', '正在忙活…', '正在梳理…', '正在打磨…', '正在鼓捣…', '正在酝酿…', '正在攻坚…', '正在权衡…', '正在拾掇…'], providerRetryScheduled: (seconds, attempt, maxAttempts) => `${seconds} 秒后重试(${attempt}/${maxAttempts})`, providerRetryStarted: (attempt, maxAttempts) => `正在重试(${attempt}/${maxAttempts})`, providerRetryReason: { network: '网络中断', provider_unavailable: '模型服务暂时不可用', rate_limit: '触发模型速率限制', timeout: '请求超时', unknown: '模型请求失败' }, safeResumePending: '正在验证…', safeResume: '安全恢复', thinking: '深度思考', truncated: '已截断', copied: '已复制', copying: '复制中', copyFailed: '复制失败', copy: '复制', editMessage: '编辑并重发', editMessageDisabledRunning: '当前回答仍在进行中,结束后再编辑', editMessageDisabledAttachments: '包含附件的历史消息暂不支持编辑并重发', editMessageDisabledQuotes: '包含引用的历史消息暂不支持编辑并重发', editMessageDisabledTransformedText: '通过显式技能发送的历史消息暂不支持编辑并重发', + you: '你', assistant: 'Maka', processing: '正在处理…', continuing: '继续中…', workingPhrases: ['正在琢磨…', '正在推敲…', '正在盘算…', '正在钻研…', '正在忙活…', '正在梳理…', '正在打磨…', '正在鼓捣…', '正在酝酿…', '正在攻坚…', '正在权衡…', '正在拾掇…'], providerRetryScheduled: (seconds, attempt, maxAttempts) => `${seconds} 秒后重试(${attempt}/${maxAttempts})`, providerRetryStarted: (attempt, maxAttempts) => `正在重试(${attempt}/${maxAttempts})`, providerRetryReason: { network: '网络中断', provider_capacity: '模型服务暂时满载', provider_unavailable: '模型服务暂时不可用', rate_limit: '触发模型速率限制', timeout: '请求超时', unknown: '模型请求失败' }, safeResumePending: '正在验证…', safeResume: '安全恢复', thinking: '深度思考', truncated: '已截断', copied: '已复制', copying: '复制中', copyFailed: '复制失败', copy: '复制', editMessage: '编辑并重发', editMessageDisabledRunning: '当前回答仍在进行中,结束后再编辑', editMessageDisabledAttachments: '包含附件的历史消息暂不支持编辑并重发', editMessageDisabledQuotes: '包含引用的历史消息暂不支持编辑并重发', editMessageDisabledTransformedText: '通过显式技能发送的历史消息暂不支持编辑并重发', userAriaLabel: '你发送的消息', systemAriaLabel: '系统消息', assistantAriaLabel: 'Maka 的回答', answerActionsAriaLabel: (context) => `回答操作${context ? `:${context}` : ''}`, answerActionAriaLabel: (action, context) => `${action}回答${context ? `:${context}` : ''}`, messageActionAriaLabel: (action, context) => `${action}消息${context ? `:${context}` : ''}`, sourceAriaLabel: '本轮回答的来源', derivativesAriaLabel: '本轮回答的衍生', scheduledTaskTriggered: '定时任务触发', scheduledTaskTitle: (id) => `由定时任务触发 · ${id}`, legacyAutomationTriggered: '旧版自动化(仅历史)', legacyAutomationTitle: (id) => `由旧版自动化触发 · ${id} · 仅保留历史,不会再次执行`, goalContinued: 'Goal 自动继续', goalTitle: (id) => `由 Goal 继续执行 · ${id}`, agentGraphTriggered: 'Agent Graph 自动继续', agentGraphTitle: (graphId) => `由 Agent Graph 调度器触发 · ${graphId}`, thinkingTruncatedTitle: '部分 reasoning 已截断;显示的是最近的内容', outputTruncatedTitle: '助手输出已超过单次回合上限,超出部分未渲染。如需完整内容请重新生成或查看持久化的任务日志。', removeAttachmentAriaLabel: (name) => `移除 ${name}`, quoteLabel: '引用', quoteExpandAriaLabel: '展开引用全文', quoteCollapseAriaLabel: '收起引用', removeQuoteAriaLabel: '移除引用', aborted: '(已中断)', abortedByStop: '(已中断 · 由停止按钮触发)', systemNotes: { @@ -617,7 +617,7 @@ const CONVERSATION_COPY = { chooseAriaLabel: (label, branch) => branch ? `Choose project: ${label}, current branch ${branch}` : `Choose project: ${label}`, }, messages: { - you: 'You', assistant: 'Maka', processing: 'Working…', continuing: 'Continuing…', workingPhrases: ['Pondering…', 'Tinkering…', 'Untangling…', 'Digging in…', 'Mulling…', 'Chewing on it…', 'Wrangling…', 'Piecing it together…'], providerRetryScheduled: (seconds, attempt, maxAttempts) => `Retrying in ${seconds}s (${attempt}/${maxAttempts})`, providerRetryStarted: (attempt, maxAttempts) => `Retrying (${attempt}/${maxAttempts})`, providerRetryReason: { network: 'Network interrupted', provider_unavailable: 'Model service temporarily unavailable', rate_limit: 'Model rate limit reached', timeout: 'Request timed out', unknown: 'Model request failed' }, safeResumePending: 'Checking…', safeResume: 'Safe recovery', thinking: 'Thinking', truncated: 'Truncated', copied: 'Copied', copying: 'Copying', copyFailed: 'Copy failed', copy: 'Copy', editMessage: 'Edit & resend', editMessageDisabledRunning: 'Wait for this answer to finish before editing', editMessageDisabledAttachments: 'Edit & resend does not yet support messages with attachments', editMessageDisabledQuotes: 'Edit & resend does not yet support messages with quotes', editMessageDisabledTransformedText: 'Edit & resend does not yet support messages sent with an explicit skill', + you: 'You', assistant: 'Maka', processing: 'Working…', continuing: 'Continuing…', workingPhrases: ['Pondering…', 'Tinkering…', 'Untangling…', 'Digging in…', 'Mulling…', 'Chewing on it…', 'Wrangling…', 'Piecing it together…'], providerRetryScheduled: (seconds, attempt, maxAttempts) => `Retrying in ${seconds}s (${attempt}/${maxAttempts})`, providerRetryStarted: (attempt, maxAttempts) => `Retrying (${attempt}/${maxAttempts})`, providerRetryReason: { network: 'Network interrupted', provider_capacity: 'The model service is temporarily at capacity', provider_unavailable: 'Model service temporarily unavailable', rate_limit: 'Model rate limit reached', timeout: 'Request timed out', unknown: 'Model request failed' }, safeResumePending: 'Checking…', safeResume: 'Safe recovery', thinking: 'Thinking', truncated: 'Truncated', copied: 'Copied', copying: 'Copying', copyFailed: 'Copy failed', copy: 'Copy', editMessage: 'Edit & resend', editMessageDisabledRunning: 'Wait for this answer to finish before editing', editMessageDisabledAttachments: 'Edit & resend does not yet support messages with attachments', editMessageDisabledQuotes: 'Edit & resend does not yet support messages with quotes', editMessageDisabledTransformedText: 'Edit & resend does not yet support messages sent with an explicit skill', userAriaLabel: 'Your message', systemAriaLabel: 'System message', assistantAriaLabel: "Maka's response", answerActionsAriaLabel: (context) => `Response actions${context ? `: ${context}` : ''}`, answerActionAriaLabel: (action, context) => `${action} response${context ? `: ${context}` : ''}`, messageActionAriaLabel: (action, context) => `${action} message${context ? `: ${context}` : ''}`, sourceAriaLabel: 'Source of this response', derivativesAriaLabel: 'Responses derived from this one', scheduledTaskTriggered: 'Triggered by scheduled task', scheduledTaskTitle: (id) => `Triggered by scheduled task · ${id}`, legacyAutomationTriggered: 'Legacy Automation (history only)', legacyAutomationTitle: (id) => `Triggered by legacy Automation · ${id} · Historical only; it will not run again`, goalContinued: 'Continued by Goal', goalTitle: (id) => `Continued by Goal · ${id}`, agentGraphTriggered: 'Continued by Agent Graph', agentGraphTitle: (graphId) => `Triggered by the Agent Graph scheduler · ${graphId}`, thinkingTruncatedTitle: 'Some reasoning was truncated; showing the most recent content', outputTruncatedTitle: 'The assistant output exceeded the per-turn limit. Regenerate it or inspect the persisted task log for the complete content.', removeAttachmentAriaLabel: (name) => `Remove ${name}`, quoteLabel: 'Quote', quoteExpandAriaLabel: 'Show the full quoted excerpt', quoteCollapseAriaLabel: 'Collapse the quoted excerpt', removeQuoteAriaLabel: 'Remove quote', aborted: '(Interrupted)', abortedByStop: '(Interrupted · Stop button)', systemNotes: { From f779f22305052feda263439d4f6ded6499470bad Mon Sep 17 00:00:00 2001 From: Hsin <2129830748@qq.com> Date: Sun, 23 Aug 2026 12:42:49 +0800 Subject: [PATCH 4/5] fix(runtime): prioritize capacity evidence safely --- .../session-status-presentation.test.ts | 14 +++++++++++ .../renderer/session-status-presentation.ts | 6 ++--- .../provider-error-classification.test.ts | 14 +++++++++++ .../src/provider-error-classification.ts | 25 +++++++++---------- 4 files changed, 43 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src/main/__tests__/session-status-presentation.test.ts b/apps/desktop/src/main/__tests__/session-status-presentation.test.ts index 9115e92754..0107b6ebc8 100644 --- a/apps/desktop/src/main/__tests__/session-status-presentation.test.ts +++ b/apps/desktop/src/main/__tests__/session-status-presentation.test.ts @@ -79,5 +79,19 @@ describe('failed turn recovery presentation', () => { ), { action: 'inspect_tool', label: 'Inspect the tool result before retrying' }, ); + assert.deepEqual( + deriveFailedTurnRecovery( + { ...outputFreeFailure, errorClass: 'provider_capacity', partialOutputRetained: true }, + 'en', + ), + { action: 'continue', label: 'Partial output was retained; continue from here' }, + ); + assert.deepEqual( + deriveFailedTurnRecovery( + { ...outputFreeFailure, errorClass: 'provider_capacity', toolActivityCount: 1 }, + 'en', + ), + { action: 'inspect_tool', label: 'Tool history was retained; inspect it before retrying' }, + ); }); }); diff --git a/apps/desktop/src/renderer/session-status-presentation.ts b/apps/desktop/src/renderer/session-status-presentation.ts index 37c8751481..0dbf4204a0 100644 --- a/apps/desktop/src/renderer/session-status-presentation.ts +++ b/apps/desktop/src/renderer/session-status-presentation.ts @@ -198,15 +198,15 @@ export function deriveFailedTurnRecovery(input: FailedTurnRecoveryInput, locale: if (lower === 'provider_billing' || lower === 'auth' || lower.includes('auth') || lower === '401' || lower === '403') { return { action: 'check_connection', label: copy.connection }; } - if (lower === 'provider_capacity') { - return { action: 'retry', label: copy.capacity }; - } if (input.partialOutputRetained) { return { action: 'continue', label: copy.partial }; } if (input.toolActivityCount > 0) { return { action: 'inspect_tool', label: copy.toolRecord }; } + if (lower === 'provider_capacity') { + return { action: 'retry', label: copy.capacity }; + } if (lower === 'context_overflow') { return { action: 'continue', label: copy.contextOverflow }; } diff --git a/packages/runtime/src/__tests__/provider-error-classification.test.ts b/packages/runtime/src/__tests__/provider-error-classification.test.ts index a9b99ae5ae..c5caae7ec3 100644 --- a/packages/runtime/src/__tests__/provider-error-classification.test.ts +++ b/packages/runtime/src/__tests__/provider-error-classification.test.ts @@ -226,6 +226,20 @@ describe('Provider error classification', () => { }); assert.equal(classifyError(topLevelCode), 'ProviderCapacity'); + const capacityWithAbortText = Object.assign(new Error('Request aborted by upstream'), { + name: 'AI_APICallError', + data: { error: { code: 'resource-exhausted' } }, + }); + assert.equal(classifyError(capacityWithAbortText), 'ProviderCapacity'); + + const capacityWithRateLimitStatus = Object.assign(new Error('Too many requests'), { + name: 'AI_APICallError', + statusCode: 429, + data: { error: { code: 'resource-exhausted' } }, + }); + assert.equal(classifyError(capacityWithRateLimitStatus), 'ProviderCapacity'); + assert.deepEqual(providerRetryMetadata(capacityWithRateLimitStatus), { retryable: true }); + const ambiguousQuotaCode = Object.assign(new Error('resource exhausted'), { name: 'AI_APICallError', data: { error: { code: 'resource_exhausted' } }, diff --git a/packages/runtime/src/provider-error-classification.ts b/packages/runtime/src/provider-error-classification.ts index 3b3fba72f3..b0302de83b 100644 --- a/packages/runtime/src/provider-error-classification.ts +++ b/packages/runtime/src/provider-error-classification.ts @@ -604,14 +604,13 @@ export function isContextOverflowErrorText(text: string): boolean { /** * Classifies a provider error by DESCENDING evidence strength over the - * normalized evidence (Error, string, or plain stream-error-part object): - * abort → 402 → 429 → 401/403 (numeric fields, never substrings) → the - * provider's structured overflow code → bare 413 (HTTP: request entity too - * large — itself input-side evidence, Cerebras sends it with no body) → - * vetoable free-text overflow relations → generic 5xx → weak word - * heuristics. Specific overflow evidence outranks a generic 5xx because - * proxies (LiteLLM) wrap provider overflows in 503s; the weak heuristics - * rank last so "generate" can never become a rate limit. + * normalized evidence (Error, string, or plain stream-error-part object): an + * explicit RetryError abort → known transport codes → the provider's + * structured capacity and overflow codes → numeric HTTP fallbacks → + * vetoable free-text relations → generic 5xx → weak word heuristics. Exact + * provider evidence outranks generic HTTP/text evidence because gateways can + * wrap a provider failure in a misleading status or message; the weak + * heuristics rank last so "generate" can never become a rate limit. */ export function classifyError(error: unknown): string { if (RetryError.isInstance(error) && error.reason === 'abort') return 'Abort'; @@ -623,12 +622,7 @@ function classifyProviderFacts(facts: ProviderErrorFacts): string { const { target: classificationTarget, evidence } = facts; const { text, statusCode, code, structuredCodes } = evidence; const normalizedCode = code.toLowerCase(); - if (text.includes('abort')) return 'Abort'; if (code === OPENAI_RESPONSES_WEBSOCKET_TRANSPORT_ERROR) return 'Network'; - if (statusCode === '402' || code === '402') return 'ProviderBilling'; - if (statusCode === '429' || code === '429') return 'RateLimit'; - if (statusCode === '401' || statusCode === '403' || code === '401' || code === '403') - return 'Auth'; if ( PROVIDER_CAPACITY_CODES.has(normalizedCode) || structuredCodes.some((c) => PROVIDER_CAPACITY_CODES.has(c)) @@ -638,6 +632,11 @@ function classifyProviderFacts(facts: ProviderErrorFacts): string { // Structured provider evidence: the parsed error JSON's code/type is the // only unconditional signal for a context overflow. if (structuredCodes.some((c) => CONTEXT_OVERFLOW_PROVIDER_CODES.has(c))) return 'ContextLength'; + if (text.includes('abort')) return 'Abort'; + if (statusCode === '402' || code === '402') return 'ProviderBilling'; + if (statusCode === '429' || code === '429') return 'RateLimit'; + if (statusCode === '401' || statusCode === '403' || code === '401' || code === '403') + return 'Auth'; if (statusCode === '413' || code === '413') return 'ContextLength'; // Free-text overflow relations on the composite text, veto-first inside. if (isContextOverflowErrorText(text)) return 'ContextLength'; From 569f7b066dd8a9b2a8ab5963dfb484fb21f81772 Mon Sep 17 00:00:00 2001 From: Hsin <2129830748@qq.com> Date: Sun, 23 Aug 2026 13:22:35 +0800 Subject: [PATCH 5/5] fix(runtime): preserve capacity diagnostics --- .../provider-error-classification.test.ts | 16 ++++++++++++++++ .../runtime/src/provider-error-classification.ts | 6 +++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/runtime/src/__tests__/provider-error-classification.test.ts b/packages/runtime/src/__tests__/provider-error-classification.test.ts index c5caae7ec3..38195bb01c 100644 --- a/packages/runtime/src/__tests__/provider-error-classification.test.ts +++ b/packages/runtime/src/__tests__/provider-error-classification.test.ts @@ -239,6 +239,22 @@ describe('Provider error classification', () => { }); assert.equal(classifyError(capacityWithRateLimitStatus), 'ProviderCapacity'); assert.deepEqual(providerRetryMetadata(capacityWithRateLimitStatus), { retryable: true }); + assert.deepEqual(providerFailureDiagnostic(capacityWithRateLimitStatus), { + errorClass: 'ProviderCapacity', + httpStatus: 429, + providerCode: 'resource-exhausted', + retryable: true, + }); + assert.equal( + providerFailureDiagnostic( + Object.assign(new Error('The model is at capacity'), { + name: 'AI_APICallError', + statusCode: 503, + data: { error: { code: 'resource-exhausted' } }, + }), + ).errorClass, + 'ProviderCapacity', + ); const ambiguousQuotaCode = Object.assign(new Error('resource exhausted'), { name: 'AI_APICallError', diff --git a/packages/runtime/src/provider-error-classification.ts b/packages/runtime/src/provider-error-classification.ts index b0302de83b..30399ed799 100644 --- a/packages/runtime/src/provider-error-classification.ts +++ b/packages/runtime/src/provider-error-classification.ts @@ -360,9 +360,9 @@ export function providerFailureDiagnostic(error: unknown): ProviderFailureDiagno } function durableProviderErrorClass(classified: string, httpStatus: number | undefined): string { - // Structured context-overflow evidence can legitimately arrive behind a - // generic 4xx/5xx proxy response and remains stronger than the wrapper code. - if (classified === 'ContextLength') return classified; + // Structured context-overflow and capacity evidence can legitimately arrive + // behind a generic 4xx/5xx proxy response and remains stronger than the wrapper code. + if (classified === 'ContextLength' || classified === 'ProviderCapacity') return classified; if (httpStatus === 401 || httpStatus === 403) return 'Auth'; if (httpStatus === 402) return 'ProviderBilling'; if (httpStatus === 408) return 'Timeout';