From 682a295f3d4df7fc6337d9eb56558c831c710f77 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Tue, 11 Aug 2026 18:15:34 +0800 Subject: [PATCH 01/20] fix(runtime): distinguish account limits from auth errors Keep ambiguous 403 responses out of authentication classification, preserve the bounded provider explanation added by #2675, and use structured provider identifiers for stable account-state meanings. Generated-by: Maka --- .../provider-failure-presentation.test.ts | 33 ++++++ .../src/renderer/locales/conversation-copy.ts | 6 +- .../renderer/session-error-presentation.ts | 4 + .../renderer/session-status-presentation.ts | 21 +++- .../cli/src/__tests__/pi-transcript.test.ts | 25 +++++ .../src/__tests__/model-adapter.test.ts | 27 +++++ .../provider-error-classification.test.ts | 103 ++++++++++++++++++ packages/runtime/src/model-adapter.ts | 8 ++ packages/runtime/src/model-protocol.ts | 2 + .../src/provider-error-classification.ts | 97 ++++++++++++++--- 10 files changed, 306 insertions(+), 20 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/provider-failure-presentation.test.ts diff --git a/apps/desktop/src/main/__tests__/provider-failure-presentation.test.ts b/apps/desktop/src/main/__tests__/provider-failure-presentation.test.ts new file mode 100644 index 0000000000..d7f692c458 --- /dev/null +++ b/apps/desktop/src/main/__tests__/provider-failure-presentation.test.ts @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +import { describeSessionErrorReason } from '../../renderer/session-error-presentation.js'; +import { + deriveFailedTurnRecovery, + describeTurnErrorClass, +} from '../../renderer/session-status-presentation.js'; + +describe('provider failure presentation', () => { + test('keeps provider account and access failures distinct in both locales', () => { + assert.equal(describeSessionErrorReason('usage_limit'), '模型使用额度已用完'); + assert.equal(describeSessionErrorReason('provider_permission'), '模型服务拒绝访问'); + assert.equal(describeSessionErrorReason('usage_limit', 'en'), 'Model usage limit reached'); + assert.equal(describeSessionErrorReason('provider_permission', 'en'), 'Provider access denied'); + }); + + test('does not present a bare 403 as an authentication failure', () => { + assert.equal(describeTurnErrorClass('403'), '未知错误'); + assert.deepEqual( + deriveFailedTurnRecovery({ + errorClass: 'usage_limit', + partialOutputRetained: false, + toolActivityCount: 0, + erroredToolCount: 0, + }), + { + action: 'check_account', + label: '检查模型服务的额度、套餐或恢复时间', + }, + ); + }); +}); diff --git a/apps/desktop/src/renderer/locales/conversation-copy.ts b/apps/desktop/src/renderer/locales/conversation-copy.ts index 14fe0721d6..59881568be 100644 --- a/apps/desktop/src/renderer/locales/conversation-copy.ts +++ b/apps/desktop/src/renderer/locales/conversation-copy.ts @@ -331,7 +331,9 @@ export interface DesktopConversationCopy { auth: string; providerBilling: string; providerCapacity: string; + providerPermission: string; rateLimit: string; + usageLimit: string; network: string; provider: string; stepCap: string; @@ -339,7 +341,7 @@ export interface DesktopConversationCopy { permission: string; restarted: string; sandboxBoundaryClosed: string; - recovery: Record<'safeResume' | 'stepCap' | 'toolError' | 'connection' | 'partial' | 'toolRecord' | 'retry' | 'capacity' | 'contextOverflow' | 'sandboxBoundaryClosed', string>; + recovery: Record<'safeResume' | 'stepCap' | 'toolError' | 'connection' | 'account' | 'partial' | 'toolRecord' | 'retry' | 'capacity' | 'contextOverflow' | 'sandboxBoundaryClosed', string>; }; } @@ -637,6 +639,7 @@ const COPY = { 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: '工具记录已保留,重试前先看结果', retry: '没有执行工具,可直接重试', capacity: '模型服务暂时满载,请等待几分钟或切换模型后重试', contextOverflow: '上下文仍超出限制,请减少附件或开启新任务', sandboxBoundaryClosed: '访问范围没有放开,重试本轮后可重新决定' } }, + turnError: { unknown: '未知错误', contextOverflow: '上下文窗口已超出限制', timeout: '请求超时', auth: '鉴权失败', providerBilling: '模型服务计费受限', providerPermission: '模型服务拒绝访问', rateLimit: '触发模型速率限制', usageLimit: '模型使用额度已用完', network: '网络错误', provider: '模型服务返回错误', stepCap: '达到工具步骤上限', tool: '工具调用失败', permission: '等待权限确认', restarted: '本地应用重启,上一轮没有完成', sandboxBoundaryClosed: '本地应用重启,等待确认的「允许访问工作区以外的内容」请求已按拒绝关闭', recovery: { safeResume: '检查当前状态后,可尝试安全恢复', stepCap: '任务可能尚未完成,可以继续', toolError: '先检查工具结果,再决定是否重试', connection: '先检查模型连接或登录状态', account: '检查模型服务的额度、套餐或恢复时间', partial: '已保留部分输出,可从这里继续', toolRecord: '工具记录已保留,重试前先看结果', retry: '没有执行工具,可直接重试', 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' }, @@ -868,6 +871,7 @@ const COPY = { 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', 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' } }, + turnError: { unknown: 'Unknown error', contextOverflow: 'Context window exceeded', timeout: 'Request timed out', auth: 'Authentication failed', providerBilling: 'Provider billing required', providerPermission: 'Provider access denied', rateLimit: 'Model rate limit reached', usageLimit: 'Model usage 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', account: 'Check the provider allowance, plan, or reset time', 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' } }, }, } satisfies UiCatalog; diff --git a/apps/desktop/src/renderer/session-error-presentation.ts b/apps/desktop/src/renderer/session-error-presentation.ts index 3dde9920c1..ecc2500f21 100644 --- a/apps/desktop/src/renderer/session-error-presentation.ts +++ b/apps/desktop/src/renderer/session-error-presentation.ts @@ -38,10 +38,14 @@ export function describeSessionErrorReason(reason: string | undefined, locale: U return copy.providerBilling; case 'provider_capacity': return copy.providerCapacity; + case 'provider_permission': + return copy.providerPermission; case 'provider_unavailable': return copy.provider; case 'rate_limit': return copy.rateLimit; + case 'usage_limit': + return copy.usageLimit; case 'network': return copy.network; default: diff --git a/apps/desktop/src/renderer/session-status-presentation.ts b/apps/desktop/src/renderer/session-status-presentation.ts index 74deaa7cb8..08dbdf4e03 100644 --- a/apps/desktop/src/renderer/session-status-presentation.ts +++ b/apps/desktop/src/renderer/session-status-presentation.ts @@ -108,7 +108,9 @@ export function describeTurnErrorClass(errorClass: string | undefined, locale: U // (#1612), and it must never fall through to the "permission"/"tool" catch-alls. if (lower === SANDBOX_BOUNDARY_RESTART_CLOSURE_CLASS) return copy.sandboxBoundaryClosed; if (lower === 'timeout' || lower.includes('timeout')) return copy.timeout; - if (lower === 'auth' || lower.includes('auth') || lower === '401' || lower === '403') return copy.auth; + if (lower === 'auth' || lower.includes('auth') || lower === '401') return copy.auth; + if (lower === 'provider_permission') return copy.providerPermission; + if (lower === 'usage_limit') return copy.usageLimit; if (lower === 'rate_limit' || lower.includes('rate')) return copy.rateLimit; if (lower === 'network' || lower.includes('network') || lower.includes('fetch') || lower.includes('econn')) { return copy.network; @@ -126,7 +128,12 @@ export function describeTurnErrorClass(errorClass: string | undefined, locale: U return copy.unknown; } -export type FailedTurnRecoveryAction = 'retry' | 'continue' | 'inspect_tool' | 'check_connection'; +export type FailedTurnRecoveryAction = + | 'retry' + | 'continue' + | 'inspect_tool' + | 'check_connection' + | 'check_account'; export interface FailedTurnRecoveryPresentation { action: FailedTurnRecoveryAction; @@ -165,9 +172,17 @@ export function deriveFailedTurnRecovery(input: FailedTurnRecoveryInput, locale: if (input.erroredToolCount > 0 || lower === 'tool_failed' || lower.includes('tool')) { return { action: 'inspect_tool', label: copy.toolError }; } - if (lower === 'provider_billing' || lower === 'auth' || lower.includes('auth') || lower === '401' || lower === '403') { + if ( + lower === 'provider_permission' || + lower === 'auth' || + lower.includes('auth') || + lower === '401' + ) { return { action: 'check_connection', label: copy.connection }; } + if (lower === 'provider_billing' || lower === 'usage_limit') { + return { action: 'check_account', label: copy.account }; + } if (input.partialOutputRetained) { return { action: 'continue', label: copy.partial }; } diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index b57e43775c..7eee3a17e8 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -244,6 +244,31 @@ describe('Maka Pi TUI transcript', () => { ); }); + test('renders a Runtime Host provider-limit explanation as the turn error', () => { + const state = createMakaPiTranscriptState(); + const message = + "You've reached your usage limit for this billing cycle. Your quota will be refreshed in the next cycle."; + + applyMakaSessionEventToTranscript( + state, + event({ + type: 'error', + recoverable: false, + code: 'permission_error', + message, + }), + ); + + assert.deepEqual(state.entries.at(-1), { + kind: 'notice', + level: 'error', + text: message, + }); + assert.match( + renderMakaPiTranscript(state, meta(), 100).map(stripAnsi).join('\n'), + /usage limit/, + ); + }); test('keeps assistant text after a tool call visible after the tool block', () => { const state = createMakaPiTranscriptState(); appendUserPrompt(state, 'inspect the package'); diff --git a/packages/runtime/src/__tests__/model-adapter.test.ts b/packages/runtime/src/__tests__/model-adapter.test.ts index 99b08f8991..8705adc838 100644 --- a/packages/runtime/src/__tests__/model-adapter.test.ts +++ b/packages/runtime/src/__tests__/model-adapter.test.ts @@ -670,6 +670,33 @@ describe('ModelAdapter stream and error normalization', () => { assert.equal(event.message.includes('sk-live-secret-token-value'), false); }); + test('projects the observed Kimi plan limit as a non-retryable provider explanation', () => { + const observedMessage = + "You've reached your usage limit for this billing cycle. Your quota will be refreshed in the next cycle. To continue now, purchase extra usage or upgrade your plan: https://www.kimi.com/code/#pricing"; + const error = Object.assign(new Error(observedMessage), { + name: 'AI_APICallError', + statusCode: 403, + data: { + type: 'error', + error: { type: 'permission_error', message: observedMessage }, + }, + }); + const adapter = newAdapter(); + + const failure = adapter.normalizeFailure(error); + assert.deepEqual(failure, { + type: 'model_failure', + kind: 'unknown', + retryable: false, + code: 'permission_error', + message: `${observedMessage} (code=permission_error, status=403)`, + }); + const event = adapter.makeErrorEvent('turn-1', failure); + assert.equal(event.reason, undefined); + assert.equal(event.code, 'permission_error'); + assert.equal(event.message, `${observedMessage} (code=permission_error, status=403)`); + }); + test('normalizes cache and reasoning usage variants in the adapter module', () => { assert.deepEqual( normalizeAiSdkUsage({ diff --git a/packages/runtime/src/__tests__/provider-error-classification.test.ts b/packages/runtime/src/__tests__/provider-error-classification.test.ts index f1a7872356..2af992b198 100644 --- a/packages/runtime/src/__tests__/provider-error-classification.test.ts +++ b/packages/runtime/src/__tests__/provider-error-classification.test.ts @@ -26,6 +26,7 @@ import { z } from 'zod/v4'; import { classifyError, providerFailureDiagnostic, + errorPresentationFromClass, providerFailureSummary, providerRetryMetadata, } from '../provider-error-classification.js'; @@ -556,6 +557,108 @@ describe('Provider error classification', () => { 'AI_RetryError', ); }); + + test('separates structured account limits, permission, and transient throttling', () => { + const providerError = ( + statusCode: number, + message: string, + structured: Record = {}, + ) => + Object.assign(new Error(message), { + name: 'AI_APICallError', + statusCode, + data: { error: { message, ...structured } }, + }); + + const insufficientQuota = providerError(429, 'You exceeded your current quota', { + type: 'insufficient_quota', + code: 'insufficient_quota', + }); + assert.equal(classifyError(insufficientQuota), 'ProviderBilling'); + assert.deepEqual(providerRetryMetadata(insufficientQuota), { retryable: false }); + + const planUsageLimit = providerError(429, 'Your subscription usage limit has been reached', { + type: 'usage_limit_reached', + }); + assert.equal(classifyError(planUsageLimit), 'UsageLimit'); + assert.deepEqual(providerRetryMetadata(planUsageLimit), { retryable: false }); + + const permission = providerError(403, 'This key cannot access the requested model', { + type: 'permission_denied', + }); + assert.equal(classifyError(permission), 'ProviderPermission'); + assert.deepEqual(providerRetryMetadata(permission), { retryable: false }); + + const throttle = providerError(429, 'Too many requests', { + code: 'rate_limit_exceeded', + }); + assert.equal(classifyError(throttle), 'RateLimit'); + assert.deepEqual(providerRetryMetadata(throttle), { retryable: true }); + assert.deepEqual(providerRetryMetadata(Object.assign(throttle, { isRetryable: false })), { + retryable: false, + }); + + assert.equal(classifyError(providerError(401, 'Invalid API key')), 'Auth'); + assert.equal(classifyError(providerError(403, 'Request forbidden')), 'AI_APICallError'); + assert.equal(classifyError(providerError(400, 'Quota exceeded')), 'AI_APICallError'); + }); + + test('keeps the observed Kimi plan-limit explanation without guessing its account state', async () => { + const handler = createJsonErrorResponseHandler({ + errorSchema: z.object({ + type: z.literal('error'), + error: z.object({ + type: z.string(), + message: z.string(), + }), + }), + errorToMessage: (data) => data.error.message, + }); + const observedMessage = + "You've reached your usage limit for this billing cycle. Your quota will be refreshed in the next cycle. To continue now, purchase extra usage or upgrade your plan: https://www.kimi.com/code/#pricing"; + const planCycleLimit = ( + await handler({ + response: new Response( + JSON.stringify({ + error: { type: 'permission_error', message: observedMessage }, + type: 'error', + }), + { + status: 403, + headers: { 'content-type': 'application/json; charset=utf-8' }, + }, + ), + url: 'https://api.example.test/coding/v1/messages', + requestBodyValues: {}, + }) + ).value; + + assert.equal(classifyError(planCycleLimit), 'AI_APICallError'); + assert.deepEqual(providerRetryMetadata(planCycleLimit), { retryable: false }); + assert.deepEqual(providerFailureSummary(planCycleLimit), { + message: `${observedMessage} (code=permission_error, status=403)`, + code: 'permission_error', + }); + }); + + test('maps provider classes to stable user-safe presentations', () => { + assert.deepEqual(errorPresentationFromClass('ProviderBilling'), { + reason: 'provider_billing', + message: 'Provider billing required', + }); + assert.deepEqual(errorPresentationFromClass('ProviderPermission'), { + reason: 'provider_permission', + message: 'Provider access denied', + }); + assert.deepEqual(errorPresentationFromClass('RateLimit'), { + reason: 'rate_limit', + message: 'Rate limit exceeded', + }); + assert.deepEqual(errorPresentationFromClass('UsageLimit'), { + reason: 'usage_limit', + message: 'Usage limit reached', + }); + }); }); test('auth classification matches authentication without matching authority', () => { diff --git a/packages/runtime/src/model-adapter.ts b/packages/runtime/src/model-adapter.ts index 9c99c2527a..221dc09ee5 100644 --- a/packages/runtime/src/model-adapter.ts +++ b/packages/runtime/src/model-adapter.ts @@ -929,12 +929,16 @@ function modelFailureKind(errorClass: string): ModelFailureKind { return 'provider_billing'; case 'ProviderCapacity': return 'provider_capacity'; + case 'ProviderPermission': + return 'provider_permission'; case 'ProviderUnavailable': return 'provider_unavailable'; case 'RateLimit': return 'rate_limit'; case 'Timeout': return 'timeout'; + case 'UsageLimit': + return 'usage_limit'; default: return 'unknown'; } @@ -954,12 +958,16 @@ function errorClassFromFailureKind(kind: ModelFailureKind): string { return 'ProviderBilling'; case 'provider_capacity': return 'ProviderCapacity'; + case 'provider_permission': + return 'ProviderPermission'; case 'provider_unavailable': return 'ProviderUnavailable'; case 'rate_limit': return 'RateLimit'; case 'timeout': return 'Timeout'; + case 'usage_limit': + return 'UsageLimit'; case 'unknown': return 'Other'; } diff --git a/packages/runtime/src/model-protocol.ts b/packages/runtime/src/model-protocol.ts index fe1a1db769..03373c6c07 100644 --- a/packages/runtime/src/model-protocol.ts +++ b/packages/runtime/src/model-protocol.ts @@ -335,9 +335,11 @@ export type ModelFailureKind = | 'network' | 'provider_capacity' | 'provider_billing' + | 'provider_permission' | 'provider_unavailable' | 'rate_limit' | 'timeout' + | 'usage_limit' | 'unknown'; export interface ModelFailure { diff --git a/packages/runtime/src/provider-error-classification.ts b/packages/runtime/src/provider-error-classification.ts index 9bd1fdbd8e..053b05edad 100644 --- a/packages/runtime/src/provider-error-classification.ts +++ b/packages/runtime/src/provider-error-classification.ts @@ -32,9 +32,31 @@ const CONTEXT_OVERFLOW_PROVIDER_CODES: ReadonlySet = new Set([ 'request_too_large', // Anthropic byte-size overflow (HTTP 413): error.type ]); +/** Stable account-state meanings exposed by provider-owned structured fields. */ +const PROVIDER_AUTH_CODES: ReadonlySet = new Set([ + 'authentication', + 'authentication_error', + 'invalid_api_key', +]); +const PROVIDER_BILLING_CODES: ReadonlySet = new Set([ + 'insufficient_quota', + 'payment_required', +]); +const PROVIDER_PERMISSION_CODES: ReadonlySet = new Set(['permission_denied']); +const PROVIDER_USAGE_LIMIT_CODES: ReadonlySet = new Set(['usage_limit_reached']); +const PROVIDER_RATE_LIMIT_CODES: ReadonlySet = new Set([ + 'rate_limit_error', + 'rate_limit_exceeded', + 'rate_limited', +]); const PROVIDER_UNAVAILABLE_PROVIDER_CODES: ReadonlySet = new Set([ 'server_error', // OpenAI-compatible stream errors can omit the HTTP status. ]); +const PROVIDER_UNAVAILABLE_CODES: ReadonlySet = new Set([ + 'overloaded_error', + 'provider_overloaded', + 'provider_unavailable', +]); /** * xAI emits this code for transient model capacity failures, including when @@ -92,7 +114,7 @@ interface ProviderErrorEvidence { statusCode: string; /** Top-level code field as a string ('' when absent). */ code: string; - /** Structured provider identifiers (code/type), lowercased. */ + /** Structured provider identifiers (code/type/error_type/provider_code), lowercased. */ structuredCodes: string[]; } @@ -179,6 +201,16 @@ export function providerRetryMetadata(error: unknown): ProviderRetryMetadata { const status = Number(evidence.statusCode || evidence.code); const errorClass = classifyProviderFacts(facts); + // Account state cannot be repaired by immediately repeating the same + // physical request, even when a provider reports it through HTTP 429. + if ( + errorClass === 'Auth' || + errorClass === 'ProviderBilling' || + errorClass === 'ProviderPermission' || + errorClass === 'UsageLimit' + ) { + return { retryable: false }; + } const retryAfterMs = parseRetryAfterMs(facts.responseHeaders ?? {}); if (errorClass === 'ProviderCapacity') { // Capacity is transient even when the provider sends a malformed delay; @@ -206,18 +238,27 @@ export function providerRetryMetadata(error: unknown): ProviderRetryMetadata { }; } -/** Collects `code`/`type` strings from a payload and from its `error` wrapper. */ +/** Collects stable identifiers from the provider envelopes Maka receives. */ function collectStructuredCodes(payload: unknown, out: string[]): void { const fromRecord = (record: Record | undefined) => { if (!record) return; - for (const key of ['code', 'type'] as const) { + for (const key of ['code', 'type', 'error_type', 'provider_code'] as const) { const value = safeField(record, key); if (typeof value === 'string' && value) out.push(value.toLowerCase()); } }; const record = providerRecord(payload); fromRecord(record); - fromRecord(record ? providerRecord(safeField(record, 'error')) : undefined); + if (!record) return; + fromRecord(providerRecord(safeField(record, 'metadata'))); + const error = providerRecord(safeField(record, 'error')); + fromRecord(error); + fromRecord(error ? providerRecord(safeField(error, 'metadata')) : undefined); + const response = providerRecord(safeField(record, 'response')); + fromRecord(response); + const responseError = response ? providerRecord(safeField(response, 'error')) : undefined; + fromRecord(responseError); + fromRecord(responseError ? providerRecord(safeField(responseError, 'metadata')) : undefined); } function normalizeProviderError(error: unknown): ProviderErrorFacts | undefined { @@ -234,8 +275,9 @@ function normalizeProviderError(error: unknown): ProviderErrorFacts | undefined const rawBody = (target as { responseBody?: unknown }).responseBody; const body = typeof rawBody === 'string' ? rawBody : ''; const structuredCodes: string[] = []; + collectStructuredCodes(target, structuredCodes); collectStructuredCodes((target as { data?: unknown }).data, structuredCodes); - if (structuredCodes.length === 0 && body) { + if (body) { // The failed-response handler keeps the raw body even when the provider // JSON failed the schema (which is exactly when `data` is absent). try { @@ -634,13 +676,17 @@ 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): 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. + * normalized evidence (Error, string, or plain stream-error-part object): + * abort → structured account state → 402 → 429 → 401 (numeric fields, + * never substrings) → the provider's structured capacity and overflow codes → + * bare 413 (HTTP: request entity too large — itself input-side evidence, + * Cerebras sends it with no body) → 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; 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. */ export function classifyError(error: unknown): string { if (RetryError.isInstance(error) && error.reason === 'abort') return 'Abort'; @@ -680,16 +726,31 @@ function classifyProviderFacts(facts: ProviderErrorFacts): string { } return 'Auth'; } + if (structuredCodes.some((value) => PROVIDER_AUTH_CODES.has(value))) return 'Auth'; + if (structuredCodes.some((value) => PROVIDER_BILLING_CODES.has(value))) return 'ProviderBilling'; + if (structuredCodes.some((value) => PROVIDER_PERMISSION_CODES.has(value))) + return 'ProviderPermission'; + if (structuredCodes.some((value) => PROVIDER_USAGE_LIMIT_CODES.has(value))) return 'UsageLimit'; + if (structuredCodes.some((value) => PROVIDER_RATE_LIMIT_CODES.has(value))) return 'RateLimit'; + if (structuredCodes.some((value) => PROVIDER_UNAVAILABLE_CODES.has(value))) + return 'ProviderUnavailable'; + if (statusCode === '402' || code === '402') return 'ProviderBilling'; + if (statusCode === '429' || code === '429') return 'RateLimit'; + if (statusCode === '401' || code === '401') return 'Auth'; + // A bare 403 is intentionally unknown: providers use it for valid-key + // permission failures, guardrails, subscription limits, and occasionally + // authentication. The provider's bounded diagnostic remains available. + // 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 (statusCode === '413' || code === '413') return 'ContextLength'; // Free-text overflow relations on the composite text, veto-first inside. if (isContextOverflowErrorText(text)) return 'ContextLength'; if (structuredCodes.some((c) => PROVIDER_UNAVAILABLE_PROVIDER_CODES.has(c))) return 'ProviderUnavailable'; if (/^5\d\d$/.test(statusCode) || /^5\d\d$/.test(code)) return 'ProviderUnavailable'; - // Weak word heuristics, last: they only catch errors that carried no - // stronger evidence for any other class. `rate` must be word-shaped - // ("generate"/"separate" are not rate limits) while still matching the - // rate_limit/RateLimitError identifier spellings. + // Weak word heuristics remain as compatibility fallbacks after all stronger + // provider facts. They must not override a structured account state. if (/\brate\b|rate[_-]?limit/.test(text)) return 'RateLimit'; if (isAuthenticationErrorText(text)) return 'Auth'; if (text.includes('timeout')) return 'Timeout'; @@ -717,10 +778,14 @@ export function errorPresentationFromClass(errorClass: string): { return { reason: 'provider_billing', message: 'Provider billing required' }; case 'ProviderCapacity': return { reason: 'provider_capacity', message: 'Model service is temporarily at capacity' }; + case 'ProviderPermission': + return { reason: 'provider_permission', message: 'Provider access denied' }; case 'ProviderUnavailable': return { reason: 'provider_unavailable', message: 'Provider returned an error' }; case 'RateLimit': return { reason: 'rate_limit', message: 'Rate limit exceeded' }; + case 'UsageLimit': + return { reason: 'usage_limit', message: 'Usage limit reached' }; case 'Network': return { reason: 'network', message: 'Network error' }; default: From 77eae24d211f17383e516f6773a58594c0b99b7b Mon Sep 17 00:00:00 2001 From: me2seeks Date: Thu, 13 Aug 2026 12:36:02 +0800 Subject: [PATCH 02/20] fix(desktop): preserve neutral provider failures Generated-by: Maka --- .../provider-failure-presentation.test.ts | 40 +++++++++++++++++++ .../src/renderer/model-connection-errors.ts | 10 ++++- .../renderer/session-status-presentation.ts | 2 +- 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/main/__tests__/provider-failure-presentation.test.ts b/apps/desktop/src/main/__tests__/provider-failure-presentation.test.ts index d7f692c458..f690ee00b3 100644 --- a/apps/desktop/src/main/__tests__/provider-failure-presentation.test.ts +++ b/apps/desktop/src/main/__tests__/provider-failure-presentation.test.ts @@ -1,6 +1,8 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; +import type { SessionEvent } from '@maka/core/events'; +import { sessionEventErrorMessage } from '../../renderer/model-connection-errors.js'; import { describeSessionErrorReason } from '../../renderer/session-error-presentation.js'; import { deriveFailedTurnRecovery, @@ -30,4 +32,42 @@ describe('provider failure presentation', () => { }, ); }); + + test('does not present a provider permission code as a local permission wait', () => { + assert.equal(describeTurnErrorClass('permission_required'), '等待权限确认'); + assert.equal(describeTurnErrorClass('permission_error'), '未知错误'); + }); + + test('preserves the bounded provider summary for a neutral Kimi plan-limit event', () => { + const message = + "You've reached your usage limit for this billing cycle. Your quota will be refreshed in the next cycle. " + + 'To continue now, purchase extra usage or upgrade your plan: https://www.kimi.com/code/#pricing ' + + '(code=permission_error, status=403)'; + const event: Extract = { + type: 'error', + id: 'event-kimi-plan-limit', + turnId: 'turn-kimi-plan-limit', + ts: 1, + recoverable: false, + code: 'permission_error', + message, + }; + + assert.equal(sessionEventErrorMessage(event), message); + assert.equal(sessionEventErrorMessage(event, 'en'), message); + }); + + test('uses generic copy when an error has neither a known reason nor provider evidence', () => { + const event: Extract = { + type: 'error', + id: 'event-unknown', + turnId: 'turn-unknown', + ts: 1, + recoverable: false, + message: '403 permission denied', + }; + + assert.equal(sessionEventErrorMessage(event), '对话运行失败,请稍后重试。'); + assert.equal(sessionEventErrorMessage(event, 'en'), 'The conversation run failed. Try again later.'); + }); }); diff --git a/apps/desktop/src/renderer/model-connection-errors.ts b/apps/desktop/src/renderer/model-connection-errors.ts index cb9a34de3f..4677f6c8fe 100644 --- a/apps/desktop/src/renderer/model-connection-errors.ts +++ b/apps/desktop/src/renderer/model-connection-errors.ts @@ -22,7 +22,6 @@ import type { SessionEvent } from '@maka/core/events'; import type { UiLocale } from '@maka/core/ui-locale'; import { parseNoRealConnectionError } from '@maka/core/connection-error-copy'; import { getDesktopConversationCopy } from './locales/conversation-copy.js'; -import { localizedShellErrorMessage } from './locales/shell-copy.js'; import { describeSessionErrorReason } from './session-error-presentation.js'; const NO_REAL_CONNECTION_CODE = 'NO_REAL_CONNECTION'; @@ -62,8 +61,15 @@ export function sessionEventErrorMessage( } const reasonDescription = describeSessionErrorReason(event.reason, locale); if (reasonDescription) return reasonDescription; + + // Provider errors reach this boundary with a stable code plus the + // allowlisted, redacted, bounded summary produced by ModelAdapter. Keep that + // structured result authoritative instead of reclassifying words or HTTP + // status fragments in the presentation layer. + if (event.code !== undefined && event.message.length > 0) return event.message; + const fallback = getDesktopConversationCopy(locale).actions.conversationErrorFallback; - return localizedShellErrorMessage(new Error(event.message), fallback, locale); + return fallback; } /** diff --git a/apps/desktop/src/renderer/session-status-presentation.ts b/apps/desktop/src/renderer/session-status-presentation.ts index 08dbdf4e03..b3dead8de5 100644 --- a/apps/desktop/src/renderer/session-status-presentation.ts +++ b/apps/desktop/src/renderer/session-status-presentation.ts @@ -123,7 +123,7 @@ export function describeTurnErrorClass(errorClass: string | undefined, locale: U return copy.provider; if (lower === 'tool_step_cap_reached') return copy.stepCap; if (lower === 'tool_failed' || lower.includes('tool')) return copy.tool; - if (lower === 'permission_required' || lower.includes('permission')) return copy.permission; + if (lower === 'permission_required') return copy.permission; if (lower === 'app_restarted') return copy.restarted; return copy.unknown; } From 00fc6c3d8a35dd1f8dae2e99aef2aaa414927143 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Mon, 17 Aug 2026 21:34:07 +0800 Subject: [PATCH 03/20] fix(runtime): bound provider summaries and reuse the shared classifier Mark ModelFailure messages taken from the provider-failure summary as bounded, display-safe provider wording and carry the marker through the durable error content so presentation layers never render unbounded transport text. Connection testing previously kept a parallel status-only classifier that mapped every 401/403 to auth. Route probe failures through the shared provider-failure authority instead: a Kimi 403 carrying a permission envelope stays neutral rather than demanding re-authentication. The durable diagnostic also prefers the status-bearing cause over an SDK wrapper whose transport code would otherwise shadow the real HTTP status. Generated-by: Maka --- packages/core/src/events.ts | 7 + packages/core/src/runtime-event.ts | 9 +- .../runtime/src/__tests__/ai-sdk-flow.test.ts | 1289 +++++++++++++++++ .../src/__tests__/model-adapter.test.ts | 2 + .../__tests__/provider-conformance.test.ts | 53 + .../provider-error-classification.test.ts | 12 +- .../runtime/src/connection-effect-outcome.ts | 20 +- packages/runtime/src/model-adapter.ts | 8 +- packages/runtime/src/model-protocol.ts | 5 + .../src/provider-error-classification.ts | 13 +- .../src/session-event-runtime-mapper.ts | 1 + packages/runtime/src/test-connection.ts | 43 +- 12 files changed, 1444 insertions(+), 18 deletions(-) create mode 100644 packages/runtime/src/__tests__/ai-sdk-flow.test.ts diff --git a/packages/core/src/events.ts b/packages/core/src/events.ts index 4a58287e49..59d1a4a31f 100644 --- a/packages/core/src/events.ts +++ b/packages/core/src/events.ts @@ -1139,6 +1139,13 @@ export interface ErrorEvent extends BaseEvent { /** Stable machine-readable reason for UI / telemetry routing. */ reason?: string; message: string; + /** + * Marks `message` as the allowlisted, redacted, bounded provider summary + * produced by the Runtime provider-failure boundary. Presentation layers may + * render such a message verbatim; an unmarked message must not be shown raw + * even when `code` is present (Node error codes carry unbounded text). + */ + boundedProviderMessage?: boolean; /** Adapter MUST scrub secrets before populating this field. */ details?: string[] | Record; } diff --git a/packages/core/src/runtime-event.ts b/packages/core/src/runtime-event.ts index 9a80ea99b0..6f1c5c9ace 100644 --- a/packages/core/src/runtime-event.ts +++ b/packages/core/src/runtime-event.ts @@ -196,6 +196,13 @@ export interface RuntimeEventErrorContent { /** Stable machine-readable reason for routing; mirrors ErrorEvent.reason. */ reason?: string; message: string; + /** + * Marks `message` as the allowlisted, redacted, bounded provider summary + * produced by the Runtime provider-failure boundary. Presentation layers + * may render such a message verbatim; an unmarked message must not be + * shown raw even when `code` is present. + */ + boundedProviderMessage?: boolean; /** Adapter MUST scrub secrets before populating this field. */ details?: string[] | Record; } @@ -497,7 +504,7 @@ const FUNCTION_RESPONSE_CONTENT_SHAPE = defineObjectShape()( ['kind', 'message'], - ['code', 'reason', 'details'], + ['code', 'reason', 'boundedProviderMessage', 'details'], ); const RUNTIME_ACTIONS_SHAPE = defineObjectShape()( [], diff --git a/packages/runtime/src/__tests__/ai-sdk-flow.test.ts b/packages/runtime/src/__tests__/ai-sdk-flow.test.ts new file mode 100644 index 0000000000..5f8ce6f161 --- /dev/null +++ b/packages/runtime/src/__tests__/ai-sdk-flow.test.ts @@ -0,0 +1,1289 @@ +/* + * 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, test } from 'node:test'; + +import type { BackendKind } from '@maka/core/session'; +import type { AgentRunHeader } from '@maka/core/agent-run'; +import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; +import type { SessionEvent } from '@maka/core/events'; +import type { BackendSendInput, BackendSessionEvent } from '@maka/core/backend-types'; +import type { RuntimeEvent } from '@maka/core/runtime-event'; +import { + decodeRuntimeEvent, + isTerminalRuntimeEvent, + isPartialRuntimeEvent, +} from '@maka/core/runtime-event'; + +import { + AiSdkFlow, + mapCompleteStopReason, + mapSessionEventToRuntimeEvent, + createSessionEventMapMemory, +} from '../ai-sdk-flow.js'; +import type { AgentBackend } from '@maka/core/backend-types'; +import { RuntimeRunner } from '../runtime-runner.js'; +import type { InvocationContext } from '../invocation-context.js'; +import { + isUnclaimedRuntimeEventDiagnostic, + projectRuntimeEventsToStoredMessages, +} from '../runtime-event-read-model.js'; +import { isNonTerminalErrorRuntimeEvent } from '../agent-run.js'; +import { backfillRuntimeEventsFromStoredMessages } from '../runtime-event-backfill.js'; + +// ============================================================================ +// Fake backend — scripted SessionEvent stream + recorded control calls +// ============================================================================ + +interface ScriptedBackendCtor { + kind?: BackendKind; + sessionId?: string; + events: SessionEvent[]; + /** Optional gate: send() awaits this after yielding each event. */ + gate?: () => Promise; + stopFailure?: Error; +} + +class ScriptedBackend implements AgentBackend { + readonly kind: BackendKind; + readonly sessionId: string; + readonly stopCalls: Array<'user_stop' | 'redirect'> = []; + readonly permissionCalls: SandboxBoundaryResponse[] = []; + readonly sendInputs: BackendSendInput[] = []; + disposeCalls = 0; + sendCalls = 0; + yieldedEvents = 0; + private readonly events: SessionEvent[]; + private readonly gate?: () => Promise; + private readonly stopFailure?: Error; + + constructor(c: ScriptedBackendCtor) { + this.kind = c.kind ?? 'ai-sdk'; + this.sessionId = c.sessionId ?? 'session-1'; + this.events = c.events; + this.gate = c.gate; + this.stopFailure = c.stopFailure; + } + + async *send(input: BackendSendInput): AsyncIterable { + this.sendCalls += 1; + this.sendInputs.push(input); + for (const e of this.events) { + this.yieldedEvents += 1; + yield e; + if (this.gate) await this.gate(); + } + } + + async stop(reason: 'user_stop' | 'redirect'): Promise { + this.stopCalls.push(reason); + if (this.stopFailure) throw this.stopFailure; + } + + async respondToSandboxBoundary(decision: SandboxBoundaryResponse): Promise { + this.permissionCalls.push(decision); + } + + async dispose(): Promise { + this.disposeCalls += 1; + } +} + +// ============================================================================ +// Event builders +// ============================================================================ + +let __seq = 0; +type DistributiveOmit = T extends any ? Omit : never; +function ev( + e: DistributiveOmit & Partial>, +): SessionEvent { + __seq += 1; + return { id: `evt-${__seq}`, turnId: 'turn-1', ts: e.ts ?? __seq, ...e } as SessionEvent; +} + +const ctx = { + sessionId: 'session-1', + invocationId: 'inv-1', + runId: 'run-1', + turnId: 'turn-1', + source: 'test', + startedAt: 999, + request: { + sessionId: 'session-1', + invocationId: 'inv-1', + runId: 'run-1', + turnId: 'turn-1', + text: 'hi', + source: 'test', + }, + newId: () => 'rt-id', + now: () => 1000, +} satisfies InvocationContext; + +function collect(stream: AsyncIterable): Promise { + const out: RuntimeEvent[] = []; + return (async () => { + for await (const e of stream) out.push(e); + return out; + })(); +} + +// ============================================================================ +// Tests +// ============================================================================ + +describe('AiSdkFlow seam', () => { + test('maps the original steering content digest into the durable Runtime event', () => { + const digest = `sha256:${'a'.repeat(64)}` as const; + const runtimeEvent = mapSessionEventToRuntimeEvent( + { + id: 'steering-event', + turnId: 'turn-1', + ts: 1, + type: 'steering_message', + messageId: 'steering-message', + content: { text: 'Prepared' }, + submittedContentDigest: digest, + }, + ctx, + ); + + assert.equal(runtimeEvent.refs?.sourceMessageDigest, digest); + }); + + test('single-flights a pending backend stop and retries after it settles', async () => { + const stopFailure = new Error('backend stop failed'); + const backend = new ScriptedBackend({ events: [], stopFailure }); + const flow = new AiSdkFlow({ backend }); + + const results = await Promise.allSettled([flow.stop('user_stop'), flow.stop('redirect')]); + + assert.deepEqual( + results.map((result) => result.status), + ['rejected', 'rejected'], + ); + assert.equal(results[0]?.status === 'rejected' && results[0].reason, stopFailure); + assert.equal(results[1]?.status === 'rejected' && results[1].reason, stopFailure); + assert.deepEqual(backend.stopCalls, ['user_stop']); + + await assert.rejects(flow.stop('redirect'), stopFailure); + assert.deepEqual(backend.stopCalls, ['user_stop', 'redirect']); + }); + + test('maps a normal turn preserving event order and terminal guarantee', async () => { + const backend = new ScriptedBackend({ + events: [ + ev({ type: 'text_delta', messageId: 'm1', text: 'Hel' }), + ev({ type: 'text_delta', messageId: 'm1', text: 'lo' }), + ev({ type: 'text_complete', messageId: 'm1', text: 'Hello' }), + ev({ + type: 'token_usage', + input: 10, + output: 5, + costUsd: 0.001, + systemPromptHash: 'sys-hash', + providerRequestTraceId: 'provider-trace-1', + }), + ev({ type: 'complete', stopReason: 'end_turn' }), + ], + }); + const flow = new AiSdkFlow({ backend }); + + const out = await collect(flow.run(ctx, { text: 'hi', context: [] })); + + assert.equal(out.length, 5); + // Order preserved. + assert.deepEqual( + out.map((e) => e.content?.kind ?? null), + ['text', 'text', 'text', null, null], + ); + // Deltas are partial; complete is not. + assert.equal(isPartialRuntimeEvent(out[0]), true); + assert.equal(isPartialRuntimeEvent(out[2]), false); + // Identity spine propagated. + assert.equal(out[0].invocationId, 'inv-1'); + assert.equal(out[0].runId, 'run-1'); + assert.equal(out[0].sessionId, 'session-1'); + assert.equal(out[0].turnId, 'turn-1'); + // id reused from source for 1:1 dedup linkage. + assert.equal(out[0].id, 'evt-1'); + // Token usage carried as an action. + assert.deepEqual(out[3].actions?.tokenUsage, { + input: 10, + output: 5, + costUsd: 0.001, + systemPromptHash: 'sys-hash', + }); + assert.deepEqual(out[3].refs, { providerRequestTraceId: 'provider-trace-1' }); + // Stream closes with a terminal event. + assert.equal(isTerminalRuntimeEvent(out[out.length - 1]), true); + assert.equal(out[out.length - 1].status, 'completed'); + assert.equal(out[out.length - 1].actions?.endInvocation, true); + // send was invoked exactly once with the turn id. + assert.equal(backend.sendCalls, 1); + }); + + test('RuntimeRunner dispatches AiSdkFlow with defined context and preserved attachments', async () => { + const attachment = { + kind: 'image' as const, + name: 'chart.png', + mimeType: 'image/png', + bytes: 123, + ref: { + kind: 'session_file' as const, + sessionId: 'session-1', + relativePath: 'attachments/chart.png', + }, + }; + const history = [ + { + type: 'user' as const, + id: 'u-prev', + turnId: 'turn-prev', + ts: 1, + text: 'previous', + }, + ]; + const runtimeContext: RuntimeEvent[] = [ + { + id: 'rt-prev', + invocationId: 'inv-prev', + runId: 'run-prev', + sessionId: 'session-1', + turnId: 'turn-prev', + ts: 1, + partial: false, + role: 'user', + author: 'user', + content: { kind: 'text', text: 'previous' }, + }, + ]; + const backend = new ScriptedBackend({ + events: [ + ev({ type: 'text_complete', messageId: 'm1', text: 'ok' }), + ev({ type: 'complete', stopReason: 'end_turn' }), + ], + }); + const flow = new AiSdkFlow({ backend }); + let idSeq = 0; + const runner = new RuntimeRunner({ + flow, + providers: { + newId: () => `rt-${(idSeq += 1)}`, + now: () => 1000, + }, + }); + + const result = await runner.run({ + sessionId: 'session-1', + turnId: 'turn-1', + text: 'hi', + attachments: [attachment], + context: history, + runtimeContext, + source: 'test', + }); + + assert.equal(result.status, 'completed'); + assert.equal(result.finalOutput, 'ok'); + assert.equal(backend.sendInputs.length, 1); + assert.deepEqual(backend.sendInputs[0], { + invocationId: 'rt-1', + runId: 'rt-2', + turnId: 'turn-1', + text: 'hi', + attachments: [attachment], + context: history, + runtimeContext, + }); + }); + + test('maps thinking deltas/signature onto model thinking content', async () => { + const backend = new ScriptedBackend({ + events: [ + ev({ type: 'thinking_delta', messageId: 'm1', text: 'hm' }), + ev({ type: 'thinking_complete', messageId: 'm1', text: 'hmm', signature: 'sig' }), + ev({ type: 'complete', stopReason: 'end_turn' }), + ], + }); + const flow = new AiSdkFlow({ backend }); + const out = await collect(flow.run(ctx, { text: 'hi', context: [] })); + + assert.equal(isPartialRuntimeEvent(out[0]), true); + assert.equal(out[1].content?.kind, 'thinking'); + assert.equal((out[1].content as { signature?: string }).signature, 'sig'); + assert.equal(isPartialRuntimeEvent(out[1]), false); + }); + + test('preserves toolName linkage between tool_start and tool_result', async () => { + const backend = new ScriptedBackend({ + events: [ + ev({ type: 'tool_start', toolUseId: 'tu-1', toolName: 'read', args: { path: '/a' } }), + ev({ + type: 'tool_result', + toolUseId: 'tu-1', + isError: false, + content: { kind: 'text', text: 'body' }, + durationMs: 42, + }), + ev({ type: 'complete', stopReason: 'end_turn' }), + ], + }); + const flow = new AiSdkFlow({ backend }); + const out = await collect(flow.run(ctx, { text: 'read it', context: [] })); + + // tool_start -> function_call + const call = out[0]; + assert.equal(call.role, 'model'); + assert.equal(call.author, 'agent'); + assert.equal(call.content?.kind, 'function_call'); + const fnCall = call.content as { id: string; name: string; args: unknown }; + assert.equal(fnCall.name, 'read'); + assert.equal(fnCall.id, 'tu-1'); + assert.equal(call.refs?.toolCallId, 'tu-1'); + + // tool_result -> function_response with the remembered name + const result = out[1]; + assert.equal(result.role, 'tool'); + assert.equal(result.author, 'tool'); + assert.equal(result.content?.kind, 'function_response'); + const fnResp = result.content as { + id: string; + name: string; + result: unknown; + isError?: boolean; + }; + assert.equal(fnResp.name, 'read', 'tool_result recovers toolName from the prior tool_start'); + assert.equal(fnResp.isError, undefined); + assert.equal(result.refs?.toolCallId, 'tu-1'); + assert.deepEqual(result.actions?.stateDelta, { durationMs: 42 }); + }); + + test('maps sandbox boundary requests and decisions as first-class runtime actions', async () => { + const backend = new ScriptedBackend({ + events: [ + ev({ + type: 'sandbox_boundary_request', + requestId: 'boundary-1', + toolUseId: 'tu-boundary', + justification: 'Write the requested export.', + expansion: { + filesystem: { + entries: [{ path: '/tmp/export.txt', access: 'write', scope: 'exact' }], + }, + }, + }), + ev({ + type: 'sandbox_boundary_decision_ack', + requestId: 'boundary-1', + toolUseId: 'tu-boundary', + decision: 'allow', + status: 'approved', + revision: 2, + }), + ev({ type: 'complete', stopReason: 'end_turn' }), + ], + }); + const flow = new AiSdkFlow({ backend }); + const out = await collect(flow.run(ctx, { text: 'do it', context: [] })); + + const req = out[0]; + assert.equal(req.author, 'system'); + assert.deepEqual(req.actions?.stateDelta?.sandboxBoundaryRequest, { + requestId: 'boundary-1', + toolUseId: 'tu-boundary', + justification: 'Write the requested export.', + expansion: { + filesystem: { + entries: [{ path: '/tmp/export.txt', access: 'write', scope: 'exact' }], + }, + }, + }); + + const ack = out[1]; + assert.equal(ack.author, 'user'); + assert.deepEqual(ack.actions?.stateDelta?.sandboxBoundaryDecision, { + requestId: 'boundary-1', + decision: 'allow', + status: 'approved', + revision: 2, + }); + assert.equal(out[2].status, 'completed'); + }); + + test('maps the error path preserving error content + terminal failed', async () => { + const backend = new ScriptedBackend({ + events: [ + ev({ + type: 'error', + recoverable: false, + code: 'AUTH', + reason: 'auth_failed', + message: 'no token', + boundedProviderMessage: true, + }), + ev({ type: 'complete', stopReason: 'error' }), + ], + }); + const flow = new AiSdkFlow({ backend }); + const out = await collect(flow.run(ctx, { text: 'hi', context: [] })); + + const err = out[0]; + assert.equal(err.content?.kind, 'error'); + const errContent = err.content as { + code?: string; + reason?: string; + message: string; + boundedProviderMessage?: boolean; + }; + assert.equal(errContent.message, 'no token'); + assert.equal(errContent.code, 'AUTH'); + assert.equal(errContent.reason, 'auth_failed'); + assert.equal(errContent.boundedProviderMessage, true); + // error event itself is non-terminal; the trailing complete carries failed. + assert.equal(isTerminalRuntimeEvent(err), false); + + assert.equal(out[1].status, 'failed'); + assert.equal(isTerminalRuntimeEvent(out[1]), true); + assert.deepEqual(out[1].content, err.content); + }); + + test('synthesizes a failed terminal event when the backend exhausts without one', async () => { + const seen: SessionEvent[] = []; + let idSeq = 0; + const backend = new ScriptedBackend({ + events: [ev({ type: 'text_delta', messageId: 'm1', text: 'partial answer' })], + }); + const flow = new AiSdkFlow({ + backend, + onSessionEvent: (sessionEvent) => { + seen.push(sessionEvent); + }, + }); + const out = await collect( + flow.run( + { ...ctx, newId: () => `synthetic-${(idSeq += 1)}`, now: () => 2000 }, + { text: 'hi', context: [] }, + ), + ); + + assert.deepEqual( + seen.map((event) => event.type), + ['text_delta', 'error', 'complete'], + ); + assert.equal(seen[1]?.type, 'error'); + assert.equal( + (seen[1] as Extract).reason, + 'missing_terminal_event', + ); + assert.equal(seen[2]?.type, 'complete'); + assert.equal((seen[2] as Extract).stopReason, 'error'); + assert.equal(out.at(-2)?.content?.kind, 'error'); + assert.equal( + (out.at(-2)?.content as { reason?: string } | undefined)?.reason, + 'missing_terminal_event', + ); + assert.equal(out.at(-1)?.status, 'failed'); + assert.equal(out.filter(isTerminalRuntimeEvent).length, 1); + }); + + test('maps the abort path to exactly one terminal event', async () => { + const backend = new ScriptedBackend({ + events: [ + ev({ type: 'text_delta', messageId: 'm1', text: 'par' }), + ev({ type: 'abort', reason: 'user_stop' }), + ev({ type: 'complete', stopReason: 'user_stop' }), + ], + }); + const flow = new AiSdkFlow({ backend }); + const out = await collect(flow.run(ctx, { text: 'hi', context: [] })); + + // AgentFlow guarantees exactly one terminal event, so the trailing + // complete(user_stop) from the legacy backend is coalesced away. + assert.equal(out.length, 2); + assert.equal(out[1].status, 'aborted'); + assert.equal(out[1].actions?.endInvocation, true); + assert.equal(isTerminalRuntimeEvent(out[1]), true); + assert.equal(out.filter(isTerminalRuntimeEvent).length, 1); + }); + + test('stops yielding after the first terminal event', async () => { + const backend = new ScriptedBackend({ + events: [ + ev({ type: 'abort', reason: 'user_stop' }), + ev({ type: 'text_delta', messageId: 'm1', text: 'after-terminal' }), + ev({ type: 'complete', stopReason: 'user_stop' }), + ], + }); + const flow = new AiSdkFlow({ backend }); + const out = await collect(flow.run(ctx, { text: 'hi', context: [] })); + + assert.equal(out.length, 1); + assert.equal(out[0]?.status, 'aborted'); + assert.equal(isTerminalRuntimeEvent(out[0]), true); + }); + + test('can silently drain backend events after a terminal while coalescing duplicate terminals', async () => { + const seen: SessionEvent[] = []; + const backend = new ScriptedBackend({ + events: [ + ev({ type: 'abort', reason: 'user_stop' }), + ev({ type: 'text_delta', messageId: 'm1', text: 'cleanup-after-terminal' }), + ev({ type: 'complete', stopReason: 'user_stop' }), + ], + }); + const flow = new AiSdkFlow({ + backend, + drainAfterTerminal: true, + onSessionEvent: (sessionEvent) => { + seen.push(sessionEvent); + }, + }); + const out = await collect(flow.run(ctx, { text: 'hi', context: [] })); + + assert.equal(backend.yieldedEvents, 3); + assert.deepEqual( + seen.map((event) => event.type), + ['abort'], + ); + assert.deepEqual( + out.map((event) => event.content?.kind ?? event.status ?? null), + ['aborted'], + ); + assert.equal(out.filter(isTerminalRuntimeEvent).length, 1); + }); + + test('reports terminal onSessionEvent failures before accepting the terminal event', async () => { + const seenErrors: string[] = []; + const backend = new ScriptedBackend({ + events: [ + ev({ type: 'complete', stopReason: 'end_turn' }), + ev({ type: 'text_delta', messageId: 'm1', text: 'after-terminal' }), + ], + }); + const flow = new AiSdkFlow({ + backend, + drainAfterTerminal: true, + onSessionEvent: () => { + throw new Error('terminal write failed'); + }, + onError: (error) => { + seenErrors.push(error instanceof Error ? error.message : String(error)); + }, + }); + + await assert.rejects( + collect(flow.run(ctx, { text: 'hi', context: [] })), + /terminal write failed/, + ); + assert.deepEqual(seenErrors, ['terminal write failed']); + assert.equal(backend.yieldedEvents, 1); + }); + + test('RuntimeRunner consumes AiSdkFlow abort as one coherent failed outcome', async () => { + const backend = new ScriptedBackend({ + events: [ + ev({ type: 'text_delta', messageId: 'm1', text: 'par' }), + ev({ type: 'abort', reason: 'user_stop' }), + ev({ type: 'complete', stopReason: 'user_stop' }), + ], + }); + const flow = new AiSdkFlow({ backend }); + let idSeq = 0; + const runner = new RuntimeRunner({ + flow, + providers: { + newId: () => `id-${(idSeq += 1)}`, + now: () => 1000, + }, + }); + + const result = await runner.run({ + sessionId: 'session-1', + turnId: 'turn-1', + text: 'hi', + source: 'test', + }); + + assert.equal(result.status, 'failed'); + assert.equal(result.failure?.class, 'aborted'); + assert.equal(result.events.filter(isTerminalRuntimeEvent).length, 1); + }); + + test('delegates stop / respondToSandboxBoundary / dispose to the wrapped backend', async () => { + const backend = new ScriptedBackend({ events: [] }); + const flow = new AiSdkFlow({ backend }); + + await flow.stop('redirect'); + await flow.respondToSandboxBoundary({ requestId: 'r', decision: 'allow' }); + await flow.dispose(); + + assert.deepEqual(backend.stopCalls, ['redirect']); + assert.deepEqual(backend.permissionCalls, [{ requestId: 'r', decision: 'allow' }]); + assert.equal(backend.disposeCalls, 1); + }); + + test('throws on session id mismatch between ctx and backend', async () => { + const backend = new ScriptedBackend({ sessionId: 'session-1', events: [] }); + const flow = new AiSdkFlow({ backend }); + + await assert.rejects( + collect(flow.run({ ...ctx, sessionId: 'other' }, { text: 'hi', context: [] })), + /AiSdkFlow session mismatch/, + ); + }); + + test('bridges FlowInput.abortSignal onto backend.stop("user_stop")', async () => { + let releaseGate: () => void = () => {}; + const gate = new Promise((resolve) => { + releaseGate = resolve; + }); + + const backend = new ScriptedBackend({ + events: [ + ev({ type: 'text_delta', messageId: 'm1', text: 'x' }), + ev({ type: 'complete', stopReason: 'end_turn' }), + ], + gate: () => gate, + }); + // stop releases the gate so send() can advance to the terminal event. + const realStop = backend.stop.bind(backend); + backend.stop = async (reason) => { + await realStop(reason); + releaseGate(); + }; + + const flow = new AiSdkFlow({ backend }); + const ctrl = new AbortController(); + const runPromise = collect( + flow.run(ctx, { text: 'hi', context: [], abortSignal: ctrl.signal }), + ); + + // Let the generator yield the first event and park on the gate. + await new Promise((r) => setTimeout(r, 0)); + ctrl.abort(); + const out = await runPromise; + + assert.deepEqual(backend.stopCalls, ['user_stop']); + assert.equal(out.length, 2); + assert.equal(isTerminalRuntimeEvent(out[out.length - 1]), true); + }); + + test('maps provider retry progress as a partial non-terminal runtime fact', () => { + const retry = ev({ + type: 'provider_retry', + phase: 'scheduled', + attempt: 2, + maxAttempts: 10, + delayMs: 4_000, + reason: 'rate_limit', + }); + + const mapped = mapSessionEventToRuntimeEvent(retry, ctx); + + assert.equal(mapped.partial, true); + assert.equal(isTerminalRuntimeEvent(mapped), false); + assert.deepEqual(mapped.actions?.stateDelta, { + providerRetry: { + phase: 'scheduled', + attempt: 2, + maxAttempts: 10, + delayMs: 4_000, + reason: 'rate_limit', + }, + }); + }); + + 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', + }, + }); + }); +}); + +// ============================================================================ +// Pure mapping unit tests +// ============================================================================ + +describe('mapSessionEventToRuntimeEvent (pure)', () => { + test('mapCompleteStopReason covers all stop reasons', () => { + assert.equal(mapCompleteStopReason('end_turn'), 'completed'); + assert.equal(mapCompleteStopReason('max_tokens'), 'completed'); + assert.equal(mapCompleteStopReason('plan_handoff'), 'completed'); + assert.equal(mapCompleteStopReason('graph_yield'), 'completed'); + assert.equal(mapCompleteStopReason('permission_handoff'), 'completed'); + assert.equal(mapCompleteStopReason('user_stop'), 'aborted'); + assert.equal(mapCompleteStopReason('error'), 'failed'); + assert.equal(mapCompleteStopReason('step_limit'), 'failed'); + }); + + test('step_limit uses the established tool-step-cap failure class', () => { + const mapped = mapSessionEventToRuntimeEvent( + ev({ type: 'complete', stopReason: 'step_limit' }), + ctx, + createSessionEventMapMemory(), + ); + + assert.deepEqual(mapped.actions?.stateDelta, { + stopReason: 'step_limit', + failureClass: 'tool_step_cap_reached', + }); + }); + + test('context_budget_exhausted keeps its detail in the durable terminal state', () => { + const mapped = mapSessionEventToRuntimeEvent( + ev({ + type: 'complete', + stopReason: 'context_budget_exhausted', + contextBudgetExhaustedDetail: 'head_anchor_exceeds_capacity', + }), + ctx, + createSessionEventMapMemory(), + ); + + assert.equal(mapped.status, 'failed'); + assert.deepEqual(mapped.actions?.stateDelta, { + stopReason: 'context_budget_exhausted', + failureClass: 'context_budget_exhausted', + contextBudgetExhaustedDetail: 'head_anchor_exceeds_capacity', + }); + }); + + test('tool_output_delta and tool_progress map to partial tool-role heartbeats', () => { + const mem = createSessionEventMapMemory(); + const a = mapSessionEventToRuntimeEvent( + ev({ + type: 'tool_output_delta', + sessionId: 'session-1', + toolCallId: 'tu-1', + toolUseId: 'tu-1', + seq: 1, + stream: 'stdout', + chunk: 'c', + redacted: false, + createdAt: 1, + }), + ctx, + mem, + ); + assert.equal(a.partial, true); + assert.equal(a.role, 'tool'); + assert.equal(a.author, 'tool'); + assert.equal(a.refs?.toolCallId, 'tu-1'); + + const b = mapSessionEventToRuntimeEvent( + ev({ type: 'tool_progress', toolUseId: 'tu-1', chunk: 'c' }), + ctx, + mem, + ); + assert.equal(b.partial, true); + assert.equal(b.role, 'tool'); + }); + + test('tool activity mapping retains nested CodeMode replay and parent identity', () => { + const memory = createSessionEventMapMemory(); + const start = mapSessionEventToRuntimeEvent( + ev({ + type: 'tool_start', + toolUseId: 'nested-1', + toolName: 'Read', + operationId: 'nested-op-1', + args: {}, + origin: 'code_mode', + modelVisibility: 'hidden', + parentToolCallId: 'exec-1', + parentOperationId: 'exec-op-1', + }), + ctx, + memory, + ); + const result = mapSessionEventToRuntimeEvent( + ev({ + type: 'tool_result', + toolUseId: 'nested-1', + operationId: 'nested-op-1', + isError: false, + content: { kind: 'text', text: 'ok' }, + origin: 'code_mode', + modelVisibility: 'hidden', + parentToolCallId: 'exec-1', + parentOperationId: 'exec-op-1', + }), + ctx, + memory, + ); + + for (const event of [start, result]) { + assert.equal(event.origin, 'code_mode'); + assert.equal(event.modelVisibility, 'hidden'); + assert.equal(event.refs?.parentToolCallId, 'exec-1'); + assert.equal(event.refs?.parentOperationId, 'exec-op-1'); + } + }); + + test('owns independent tool args across SessionEvent to RuntimeEvent mappings', () => { + const sourceArgs = { content: 'approved', layout: { cols: 120 } }; + const sourceEvent = ev({ + type: 'tool_start', + toolUseId: 'tu-owned', + toolName: 'Write', + args: sourceArgs, + }); + const mapped = mapSessionEventToRuntimeEvent(sourceEvent, ctx, createSessionEventMapMemory()); + const mappedArgs = ( + mapped.content?.kind === 'function_call' ? mapped.content.args : undefined + ) as typeof sourceArgs; + + assert.notStrictEqual(mappedArgs, sourceArgs); + assert.notStrictEqual(mappedArgs.layout, sourceArgs.layout); + sourceArgs.layout.cols = 80; + assert.equal(mappedArgs.layout.cols, 120); + mappedArgs.content = 'runtime'; + assert.equal(sourceArgs.content, 'approved'); + }); + + test('plan_submitted maps to an agent-authored state delta', () => { + const a = mapSessionEventToRuntimeEvent( + ev({ type: 'plan_submitted', planId: 'p1', title: 'T', markdownPath: '/p.md' }), + ctx, + ); + assert.equal(a.role, 'system'); + assert.equal(a.author, 'agent'); + assert.deepEqual(a.actions?.stateDelta, { planId: 'p1', title: 'T', markdownPath: '/p.md' }); + }); + + test('user_question_request maps to one system-authored runtime action', () => { + const mapped = mapSessionEventToRuntimeEvent( + ev({ + type: 'user_question_request', + requestId: 'question-1', + toolUseId: 'tool-1', + questions: [ + { + question: 'Choose an approach', + options: [ + { label: 'Extend', description: 'Reuse the runtime seam' }, + { label: 'Separate' }, + ], + }, + ], + }), + ctx, + ); + + assert.equal(mapped.role, 'system'); + assert.equal(mapped.author, 'system'); + assert.deepEqual(mapped.actions?.userQuestionRequest, { + requestId: 'question-1', + toolUseId: 'tool-1', + questions: [ + { + question: 'Choose an approach', + options: [ + { label: 'Extend', description: 'Reuse the runtime seam' }, + { label: 'Separate' }, + ], + }, + ], + }); + }); + + test('user_question_answer_ack maps without duplicating the canonical answer', () => { + const mapped = mapSessionEventToRuntimeEvent( + ev({ + type: 'user_question_answer_ack', + requestId: 'question-1', + toolUseId: 'tool-1', + }), + ctx, + ); + + assert.equal(mapped.role, 'system'); + assert.equal(mapped.author, 'user'); + assert.deepEqual(mapped.actions?.userQuestionAnswerAccepted, { + requestId: 'question-1', + }); + assert.equal(mapped.refs?.toolCallId, 'tool-1'); + }); + + test('tool_result without a prior tool_start still maps (name falls back to empty)', () => { + const a = mapSessionEventToRuntimeEvent( + ev({ + type: 'tool_result', + toolUseId: 'orphan', + isError: true, + content: { kind: 'text', text: 'boom' }, + }), + ctx, + ); + const fnResp = a.content as { name: string; isError?: boolean }; + assert.equal(fnResp.name, ''); + assert.equal(fnResp.isError, true); + }); + + test('branch is propagated when present on the context', () => { + const a = mapSessionEventToRuntimeEvent(ev({ type: 'complete', stopReason: 'end_turn' }), { + ...ctx, + branch: 'agent-b', + }); + assert.equal(a.branch, 'agent-b'); + }); +}); + +// ============================================================================ +// Projection coverage contract +// ============================================================================ + +/** + * One sample per backend-mappable SessionEvent variant. `subject` is typed to + * its own key, so a new variant cannot be satisfied by an empty list or by + * some other event that happens to project cleanly; `before` and `after` carry + * only the companions that variant's projection needs. + */ +type ProjectionSamples = { + [K in BackendSessionEvent['type']]: { + subject: Extract; + before?: SessionEvent[]; + after?: SessionEvent[]; + }; +}; + +const PROJECTION_SAMPLES: ProjectionSamples = { + text_delta: { + subject: { type: 'text_delta', id: 'e', turnId: 'turn-1', ts: 1, messageId: 'm1', text: 'h' }, + }, + text_complete: { + subject: { + type: 'text_complete', + id: 'e', + turnId: 'turn-1', + ts: 1, + messageId: 'm1', + text: 'hi', + }, + }, + thinking_delta: { + subject: { + type: 'thinking_delta', + id: 'e', + turnId: 'turn-1', + ts: 1, + messageId: 'm1', + text: 'h', + }, + }, + thinking_complete: { + subject: { + type: 'thinking_complete', + id: 'e1', + turnId: 'turn-1', + ts: 1, + messageId: 'm1', + text: 'why', + }, + // Thinking is held until the assistant text row that shares its message id. + after: [ + { type: 'text_complete', id: 'e2', turnId: 'turn-1', ts: 2, messageId: 'm1', text: 'hi' }, + ], + }, + tool_start: { + subject: { + type: 'tool_start', + id: 'e', + turnId: 'turn-1', + ts: 1, + toolUseId: 'tool-1', + toolName: 'Read', + args: { path: '/tmp/a' }, + }, + }, + tool_output_delta: { + subject: { + type: 'tool_output_delta', + id: 'e', + turnId: 'turn-1', + ts: 1, + sessionId: 'session-1', + toolCallId: 'tool-1', + toolUseId: 'tool-1', + seq: 1, + stream: 'stdout', + chunk: 'out', + redacted: false, + createdAt: 1, + }, + }, + tool_progress: { + subject: { + type: 'tool_progress', + id: 'e', + turnId: 'turn-1', + ts: 1, + toolUseId: 'tool-1', + chunk: 'x', + }, + }, + tool_result_preview: { + subject: { + type: 'tool_result_preview', + id: 'e', + turnId: 'turn-1', + ts: 1, + toolUseId: 'tool-1', + isError: false, + content: { + kind: 'subagent', + childSessionId: 'child-1', + agentName: 'Local Read', + turnId: 'child-turn', + status: 'running', + permissionMode: 'explore', + }, + }, + }, + tool_result: { + subject: { + type: 'tool_result', + id: 'e2', + turnId: 'turn-1', + ts: 2, + toolUseId: 'tool-1', + isError: false, + content: { kind: 'text', text: 'ok' }, + }, + // A result carries no tool name of its own; the mapper reads it from the call. + before: [ + { + type: 'tool_start', + id: 'e1', + turnId: 'turn-1', + ts: 1, + toolUseId: 'tool-1', + toolName: 'Read', + args: { path: '/tmp/a' }, + }, + ], + }, + sandbox_boundary_request: { + subject: { + type: 'sandbox_boundary_request', + id: 'e', + turnId: 'turn-1', + ts: 1, + requestId: 'boundary-1', + toolUseId: 'tool-1', + justification: 'read a file outside the workspace', + expansion: { + filesystem: { entries: [{ path: '/tmp/outside.txt', access: 'read', scope: 'exact' }] }, + }, + }, + }, + sandbox_boundary_decision_ack: { + subject: { + type: 'sandbox_boundary_decision_ack', + id: 'e', + turnId: 'turn-1', + ts: 1, + requestId: 'boundary-1', + toolUseId: 'tool-1', + decision: 'allow', + status: 'approved', + revision: 2, + }, + }, + user_question_request: { + subject: { + type: 'user_question_request', + id: 'e', + turnId: 'turn-1', + ts: 1, + requestId: 'q-1', + toolUseId: 'tool-1', + questions: [{ question: 'Which one?', options: [{ label: 'A', description: 'a' }] }], + }, + }, + user_question_answer_ack: { + subject: { + type: 'user_question_answer_ack', + id: 'e', + turnId: 'turn-1', + ts: 1, + requestId: 'q-1', + toolUseId: 'tool-1', + }, + }, + plan_submitted: { + subject: { + type: 'plan_submitted', + id: 'e', + turnId: 'turn-1', + ts: 1, + planId: 'plan-1', + title: 'Plan', + }, + }, + token_usage: { + subject: { + type: 'token_usage', + id: 'e', + turnId: 'turn-1', + ts: 1, + input: 10, + output: 5, + total: 15, + }, + }, + steering_message: { + subject: { + type: 'steering_message', + id: 'e', + turnId: 'turn-1', + ts: 1, + messageId: 'm2', + content: { text: 'steer' }, + }, + }, + provider_retry: { + subject: { + type: 'provider_retry', + id: 'e', + turnId: 'turn-1', + ts: 1, + phase: 'started', + attempt: 2, + maxAttempts: 3, + reason: 'rate_limit', + }, + }, + error: { + subject: { + type: 'error', + id: 'e1', + turnId: 'turn-1', + ts: 1, + recoverable: false, + message: 'boom', + }, + // An error is always followed by a terminal complete carrying the failure. + after: [{ type: 'complete', id: 'e2', turnId: 'turn-1', ts: 2, stopReason: 'error' }], + }, + complete: { + subject: { type: 'complete', id: 'e', turnId: 'turn-1', ts: 1, stopReason: 'end_turn' }, + }, + abort: { subject: { type: 'abort', id: 'e', turnId: 'turn-1', ts: 1, reason: 'user_stop' } }, +}; + +const projectionRunHeader: AgentRunHeader = { + runId: 'run-1', + sessionId: 'session-1', + turnId: 'turn-1', + status: 'completed', + backendKind: 'ai-sdk', + llmConnectionSlug: 'anthropic', + modelId: 'model-1', + cwd: '/tmp', + permissionMode: 'ask', + createdAt: 1, + updatedAt: 2, + completedAt: 2, +}; + +describe('SessionEvent projection coverage', () => { + // The contract is over what a reader can actually meet: every mapped event + // AgentRun admits to the ledger has to project. It asserts on the unclaimed + // codes at either severity, not on the hard one alone — a control fact whose + // gap only degrades the view is still a gap, and must be found here rather + // than by a user opening the session. + for (const [type, sample] of Object.entries(PROJECTION_SAMPLES)) { + test(`${type} projects without an unclaimed-event diagnostic`, () => { + let seq = 0; + const memory = createSessionEventMapMemory(); + const runtimeEvents = [...(sample.before ?? []), sample.subject, ...(sample.after ?? [])] + .map((event) => + mapSessionEventToRuntimeEvent( + event, + { + ...ctx, + newId: () => { + seq += 1; + return `rt-${seq}`; + }, + }, + memory, + ), + ) + .filter((event) => !isNonTerminalErrorRuntimeEvent(event)); + + const projected = projectRuntimeEventsToStoredMessages(runtimeEvents, { + runHeaders: [projectionRunHeader], + }); + + assert.deepEqual(projected.diagnostics.filter(isUnclaimedRuntimeEventDiagnostic), []); + }); + } + + // The guard's fallback is what a variant added without a claim actually + // becomes. It has to stay on the degradable side of the line: control-only, + // so the session it lands in still opens, and still reported so the gap the + // coverage contract would have caught is not invisible at runtime. + test('an unmapped SessionEvent maps to a reported control-only fact', () => { + const unmapped = { type: 'not_yet_mapped', id: 'e', turnId: 'turn-1', ts: 1 }; + const memory = createSessionEventMapMemory(); + const runtimeEvent = mapSessionEventToRuntimeEvent( + unmapped as unknown as SessionEvent, + ctx, + memory, + ); + + assert.equal(runtimeEvent.content, undefined); + assert.equal(runtimeEvent.actions?.stateDelta?.unmappedSessionEventType, 'not_yet_mapped'); + + const projected = projectRuntimeEventsToStoredMessages([runtimeEvent], { + runHeaders: [projectionRunHeader], + }); + assert.deepEqual(projected.messages, []); + // Filtered through the predicate the contract above uses, not just compared + // to the code string: dropping the soft code from the predicate would + // otherwise loosen the contract to `unsupported_event` only, silently. + assert.deepEqual( + projected.diagnostics.filter(isUnclaimedRuntimeEventDiagnostic).map((d) => d.code), + ['unclaimed_control_fact'], + ); + assert.equal(projected.diagnostics.length, 1); + }); +}); diff --git a/packages/runtime/src/__tests__/model-adapter.test.ts b/packages/runtime/src/__tests__/model-adapter.test.ts index 8705adc838..e85e1cee51 100644 --- a/packages/runtime/src/__tests__/model-adapter.test.ts +++ b/packages/runtime/src/__tests__/model-adapter.test.ts @@ -690,11 +690,13 @@ describe('ModelAdapter stream and error normalization', () => { retryable: false, code: 'permission_error', message: `${observedMessage} (code=permission_error, status=403)`, + boundedProviderMessage: true, }); const event = adapter.makeErrorEvent('turn-1', failure); assert.equal(event.reason, undefined); assert.equal(event.code, 'permission_error'); assert.equal(event.message, `${observedMessage} (code=permission_error, status=403)`); + assert.equal(event.boundedProviderMessage, true); }); test('normalizes cache and reasoning usage variants in the adapter module', () => { diff --git a/packages/runtime/src/__tests__/provider-conformance.test.ts b/packages/runtime/src/__tests__/provider-conformance.test.ts index 06c3cad86a..a5d3cb7a6f 100644 --- a/packages/runtime/src/__tests__/provider-conformance.test.ts +++ b/packages/runtime/src/__tests__/provider-conformance.test.ts @@ -583,6 +583,59 @@ describe('models.dev provider conformance', () => { assert.deepEqual(requestedModels, ['custom-moonshot-preview']); }); + test('connection probe keeps a Kimi permission 403 out of authentication', async () => { + const server = await startJsonServer(async (request, response) => { + assert.equal(request.method, 'POST'); + assert.equal(request.url, '/v1/chat/completions'); + await readBody(request); + respondJson(response, 403, { + error: { + type: 'permission_error', + message: 'You have reached the plan usage limit for this model.', + }, + }); + }); + const connection: LlmConnection = { + slug: 'moonshot-plan-limit', + name: 'Moonshot Plan Limit', + providerType: 'moonshot', + baseUrl: `${server.url}/v1`, + defaultModel: 'kimi-k2.6', + enabled: true, + createdAt: 1, + updatedAt: 1, + }; + + const result = await testConnection(connection, 'moonshot-key'); + + assert.equal(result.ok, false); + assert.equal(result.statusCode, 403); + assert.equal(result.errorClass, 'unknown'); + }); + + test('connection probe still classifies a bare 401 as authentication', async () => { + const server = await startJsonServer(async (request, response) => { + await readBody(request); + respondJson(response, 401, {}); + }); + const connection: LlmConnection = { + slug: 'moonshot-bare-401', + name: 'Moonshot Bare 401', + providerType: 'moonshot', + baseUrl: `${server.url}/v1`, + defaultModel: 'kimi-k2.6', + enabled: true, + createdAt: 1, + updatedAt: 1, + }; + + const result = await testConnection(connection, 'moonshot-key'); + + assert.equal(result.ok, false); + assert.equal(result.statusCode, 401); + assert.equal(result.errorClass, 'auth'); + }); + test('OpenAI routes gpt-5* through the Responses wire and other models through Chat Completions by declaration', async () => { const requests: string[] = []; const server = await startJsonServer(async (request, response) => { diff --git a/packages/runtime/src/__tests__/provider-error-classification.test.ts b/packages/runtime/src/__tests__/provider-error-classification.test.ts index 2af992b198..a32de24eee 100644 --- a/packages/runtime/src/__tests__/provider-error-classification.test.ts +++ b/packages/runtime/src/__tests__/provider-error-classification.test.ts @@ -593,9 +593,15 @@ describe('Provider error classification', () => { code: 'rate_limit_exceeded', }); assert.equal(classifyError(throttle), 'RateLimit'); - assert.deepEqual(providerRetryMetadata(throttle), { retryable: true }); - assert.deepEqual(providerRetryMetadata(Object.assign(throttle, { isRetryable: false })), { - retryable: false, + // A bare rate limit fails closed; automatic retries need a named delay. + assert.deepEqual(providerRetryMetadata(throttle), { retryable: false }); + const delayedThrottle = Object.assign( + providerError(429, 'Too many requests', { code: 'rate_limit_exceeded' }), + { responseHeaders: { 'retry-after': '40' } }, + ); + assert.deepEqual(providerRetryMetadata(delayedThrottle), { + retryable: true, + retryAfterMs: 40_000, }); assert.equal(classifyError(providerError(401, 'Invalid API key')), 'Auth'); diff --git a/packages/runtime/src/connection-effect-outcome.ts b/packages/runtime/src/connection-effect-outcome.ts index c578d20b4f..9a27f50f10 100644 --- a/packages/runtime/src/connection-effect-outcome.ts +++ b/packages/runtime/src/connection-effect-outcome.ts @@ -18,6 +18,7 @@ */ import type { ModelDiscoverySource, ModelInfo, ProviderType } from '@maka/core/llm-connections'; +import { classifyError } from './provider-error-classification.js'; export interface ConnectionEffectConnection { readonly providerType: ProviderType; @@ -73,10 +74,21 @@ export class ConnectionEffectInvalidResponseError extends Error { } export function classifyConnectionEffectStatus(statusCode: number): ConnectionEffectError { - if (statusCode === 401 || statusCode === 403) return { kind: 'auth', statusCode }; - if (statusCode === 408) return { kind: 'timeout', statusCode }; - if (statusCode === 429 || (statusCode >= 500 && statusCode <= 599)) { - return { kind: 'provider_unavailable', statusCode }; + // Status-only evidence still routes through the shared provider-failure + // authority. A bare 403 is intentionally not authentication: providers use + // it for valid-key permission failures, guardrails, and subscription limits. + switch (classifyError({ statusCode })) { + case 'Auth': + return { kind: 'auth', statusCode }; + case 'RateLimit': + case 'ProviderUnavailable': + case 'ProviderBilling': + case 'ProviderPermission': + case 'UsageLimit': + return { kind: 'provider_unavailable', statusCode }; + default: + break; } + if (statusCode === 408) return { kind: 'timeout', statusCode }; return { kind: 'unknown', statusCode }; } diff --git a/packages/runtime/src/model-adapter.ts b/packages/runtime/src/model-adapter.ts index 221dc09ee5..45e7fb0b2c 100644 --- a/packages/runtime/src/model-adapter.ts +++ b/packages/runtime/src/model-adapter.ts @@ -439,6 +439,7 @@ export class ModelAdapter { ts: this.input.now(), recoverable: false, ...(failure.code !== undefined ? { code: failure.code } : {}), + ...(failure.boundedProviderMessage === true ? { boundedProviderMessage: true } : {}), ...(failure.kind !== 'abort' && failure.kind !== 'unknown' ? { reason: failure.kind } : {}), message: failure.message, }; @@ -898,10 +899,15 @@ function normalizeProviderFailure(error: unknown): ModelFailure { if (isModelFailure(error)) return error; const summary = providerFailureSummary(error); const failure = normalizeModelFailure(error); + // The bounded summary is display-safe provider wording; a generalized + // presentation message is not. The marker must follow the message, not the + // presence of a code (Error.code and provider codes both exist here). + const boundedProviderMessage = failure.kind === 'unknown' && summary !== undefined; return { ...failure, ...(summary?.code !== undefined ? { code: summary.code } : {}), - ...(failure.kind === 'unknown' && summary !== undefined ? { message: summary.message } : {}), + ...(boundedProviderMessage ? { message: summary.message } : {}), + ...(boundedProviderMessage ? { boundedProviderMessage: true } : {}), }; } diff --git a/packages/runtime/src/model-protocol.ts b/packages/runtime/src/model-protocol.ts index 03373c6c07..88ff7d9956 100644 --- a/packages/runtime/src/model-protocol.ts +++ b/packages/runtime/src/model-protocol.ts @@ -351,6 +351,11 @@ export interface ModelFailure { /** Provider-requested delay for the next physical attempt, in milliseconds. */ retryAfterMs?: number; code?: string; + /** + * True when `message` is the bounded, redacted provider summary from the + * provider-failure boundary rather than a generalized error message. + */ + boundedProviderMessage?: boolean; } /** diff --git a/packages/runtime/src/provider-error-classification.ts b/packages/runtime/src/provider-error-classification.ts index 053b05edad..8fd2849df6 100644 --- a/packages/runtime/src/provider-error-classification.ts +++ b/packages/runtime/src/provider-error-classification.ts @@ -452,22 +452,27 @@ function durableProviderErrorClass(classified: string, httpStatus: number | unde function providerFailureDiagnosticFacts(error: unknown): ProviderErrorFacts | undefined { let current = providerErrorTarget(error); let fallback: ProviderErrorFacts | undefined; + let structuredFallback: ProviderErrorFacts | undefined; let codedFallback: ProviderErrorFacts | undefined; const seen = new Set(); for (let depth = 0; depth < 4 && current !== undefined && !seen.has(current); depth += 1) { seen.add(current); const facts = normalizeProviderError(current); fallback ??= facts; - if (facts && (facts.evidence.statusCode || facts.evidence.structuredCodes.length > 0)) { - return facts; - } + // HTTP status is the strongest durable evidence: an SDK wrapper can carry + // its own transport `code` (for example `FETCH_FAILED`) while the real + // provider status sits on the wrapped cause. Prefer the status-bearing + // fact, and only fall back to wrapper-level structured codes when no fact + // in the chain carries one. + if (facts?.evidence.statusCode) return facts; + structuredFallback ??= facts && facts.evidence.structuredCodes.length > 0 ? facts : undefined; if (facts?.evidence.code) codedFallback ??= facts; current = current && typeof current === 'object' ? safeField(current as Record, 'cause') : undefined; } - return codedFallback ?? fallback; + return structuredFallback ?? codedFallback ?? fallback; } interface ProviderFailureSources { diff --git a/packages/runtime/src/session-event-runtime-mapper.ts b/packages/runtime/src/session-event-runtime-mapper.ts index e282d2e69e..2ffcbf2350 100644 --- a/packages/runtime/src/session-event-runtime-mapper.ts +++ b/packages/runtime/src/session-event-runtime-mapper.ts @@ -555,6 +555,7 @@ function mapBackendSessionEvent( ...(event.code !== undefined ? { code: event.code } : {}), ...(event.reason !== undefined ? { reason: event.reason } : {}), message: event.message, + ...(event.boundedProviderMessage === true ? { boundedProviderMessage: true } : {}), ...(event.details !== undefined ? { details: event.details } : {}), }; memory.failureContent = content; diff --git a/packages/runtime/src/test-connection.ts b/packages/runtime/src/test-connection.ts index 1af8805cf7..9cfb0bfa9f 100644 --- a/packages/runtime/src/test-connection.ts +++ b/packages/runtime/src/test-connection.ts @@ -45,6 +45,7 @@ import { type ConnectionEffectError, type ConnectionTestEffectOutcome, } from './connection-effect-outcome.js'; +import { classifyError } from './provider-error-classification.js'; const CONNECTION_TEST_TIMEOUT_MS = 15_000; @@ -469,7 +470,7 @@ async function httpFailure(r: ConnectionEffectResponse, t0: number): Promise= 500) return 'provider_unavailable'; - return 'unknown'; +function classifyHttpFailure(statusCode: number, body: string): ConnectionTestResult['errorClass'] { + // The response body carries the provider's structured envelope; the numeric + // status is the fallback when the body is empty or unstructured. Both flow + // through the one shared provider-failure authority instead of a parallel + // status-only classifier. + const fromBody = classifyError(body); + const fromStatus = classifyError({ statusCode }); + return connectionTestErrorClassFromProviderClass(fromBody !== 'Other' ? fromBody : fromStatus); +} + +function connectionTestErrorClassFromProviderClass(errorClass: string): ConnectionTestErrorClass { + switch (errorClass) { + case 'Auth': + return 'auth'; + case 'Timeout': + return 'timeout'; + case 'Network': + return 'network'; + case 'RateLimit': + case 'ProviderUnavailable': + case 'ProviderBilling': + case 'ProviderPermission': + case 'UsageLimit': + return 'provider_unavailable'; + default: + return 'unknown'; + } } function connectionTestFailure( @@ -514,6 +538,15 @@ function classifyConnectionTestError(error: unknown): ConnectionEffectError { } function classifyConnectionTestResult(result: ConnectionTestResult): ConnectionEffectError { + // The body-aware classification computed at the probe boundary is + // authoritative; the status-only fallback must not override it (a Kimi 403 + // carrying a permission envelope is neutral, not a reauth). + if (result.errorClass !== undefined && result.errorClass !== 'unknown') { + return { + kind: connectionTestErrorKind(result.errorClass), + ...(result.statusCode === undefined ? {} : { statusCode: result.statusCode }), + }; + } if (result.statusCode !== undefined) { const statusError = classifyConnectionEffectStatus(result.statusCode); if (statusError.kind !== 'unknown') return statusError; From b7167c5c7d37728ab32921ce942809854bc4c12b Mon Sep 17 00:00:00 2001 From: me2seeks Date: Mon, 17 Aug 2026 21:34:07 +0800 Subject: [PATCH 04/20] fix(runtime-host): preserve provider failure codes in turn snapshots Failed turn snapshots now carry the stable failure code and the bounded provider-summary marker from the canonical terminal error fact, and the session projector forwards both to the projected error event. Desktop can therefore render the bounded provider wording without re-deriving meaning from HTTP status or message text. Generated-by: Maka --- .../canonical-session-projection.test.ts | 3 + .../connection-effect-coordinator.test.ts | 39 +++++++++++ .../src/__tests__/protocol.test.ts | 24 +++++++ .../src/__tests__/session-projector.test.ts | 66 +++++++++++++++++++ .../src/adapter/session-projector.ts | 2 + packages/runtime-host/src/protocol/turn.ts | 19 +++++- .../src/server/canonical-turn-snapshot.ts | 22 +++++-- 7 files changed, 168 insertions(+), 7 deletions(-) diff --git a/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts b/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts index bb7a568554..f4a4d7a587 100644 --- a/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts +++ b/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts @@ -377,6 +377,7 @@ test('projects a failed Turn message from the canonical terminal event', async ( ts: 12, recoverable: false, code: 'provider_error', + boundedProviderMessage: true, message: 'canonical provider failure api_key=sk-test-secret-value', }, context, @@ -417,6 +418,8 @@ test('projects a failed Turn message from the canonical terminal event', async ( canonical.rootTurn.failureMessage, 'canonical provider failure api_key=[redacted]', ); + assert.equal(canonical.rootTurn.failureCode, 'provider_error'); + assert.equal(canonical.rootTurn.boundedProviderMessage, true); } }); }); diff --git a/packages/runtime-host/src/__tests__/connection-effect-coordinator.test.ts b/packages/runtime-host/src/__tests__/connection-effect-coordinator.test.ts index a9f10d071a..18fb5e681e 100644 --- a/packages/runtime-host/src/__tests__/connection-effect-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/connection-effect-coordinator.test.ts @@ -1168,6 +1168,45 @@ test('connection test derives a persisted summary from one bounded projection', }); }); +test('keeps a neutral provider permission failure out of needs_reauth', async () => { + await withFixture(async ({ stores }) => { + const connection = await createConnection(stores, 0, connectionDraft('plan-limit', 'openai')); + await setConnectionCredential(stores, connection, 'test-credential'); + const coordinator = new HostConnectionEffectCoordinator({ + stores, + activation: new RuntimePolicyActivationGate(), + oauthCredentials: new HostOAuthExecutionAuthority(stores), + now: () => Date.parse('2026-07-29T12:00:00.000Z'), + createTransport: () => recordingTransport(() => {}), + runConnectionTest: async (_connection, _apiKey, _options, modelId) => { + assert.equal(modelId, 'gpt-5'); + return { + ok: false, + error: { kind: 'unknown', statusCode: 403 }, + modelId: 'gpt-5', + latencyMs: 17, + }; + }, + }); + + const outcome = await coordinator.handlers['connection.test.run']( + { connectionId: connection.connectionId, modelId: 'gpt-5' }, + context, + ); + + assert.equal(outcome.ok, true); + if (!outcome.ok || outcome.result.kind !== 'committed') { + throw new Error('connection test did not commit'); + } + const persisted = await stores.connectionCatalog.getSnapshot(); + assert.deepEqual(persisted.connections[0]?.lastTest, { + status: 'error', + checkedAt: outcome.result.test.checkedAt, + errorClass: 'unknown', + }); + }); +}); + test('projects credential changes during provider I/O as semantic superseded and closes transport', async () => { await withFixture(async ({ stores }) => { const connection = await createConnection(stores, 0, connectionDraft('superseded', 'openai')); diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index c4f2fad83b..ba609f762f 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -1603,6 +1603,8 @@ describe('Runtime Host bootstrap protocol', () => { terminalEventId: 'event-1', failureClass: 'unknown', failureMessage: 'Provider request failed', + failureCode: 'permission_error', + boundedProviderMessage: true, }, }; @@ -1618,6 +1620,28 @@ describe('Runtime Host bootstrap protocol', () => { }), isInvalidFrame, ); + assert.throws( + () => + decodeHostFrame({ + ...response, + result: { + ...response.result, + failureCode: 'x'.repeat(129), + }, + }), + isInvalidFrame, + ); + assert.throws( + () => + decodeHostFrame({ + ...response, + result: { + ...response.result, + boundedProviderMessage: 'true', + }, + }), + isInvalidFrame, + ); }); test('bounds encoded protocol messages', () => { diff --git a/packages/runtime-host/src/__tests__/session-projector.test.ts b/packages/runtime-host/src/__tests__/session-projector.test.ts index e2138d3f78..7bd9c8ed1f 100644 --- a/packages/runtime-host/src/__tests__/session-projector.test.ts +++ b/packages/runtime-host/src/__tests__/session-projector.test.ts @@ -100,6 +100,72 @@ test('reseeds the latest provider retry when the active Turn still carries one', assert.equal(seeded[0] && 'phase' in seeded[0] ? seeded[0].phase : undefined, 'scheduled'); }); +test('projects a failed Turn with its provider code and bounded-summary marker', () => { + const projector = new RuntimeHostSessionProjector( + snapshot(), + createRuntimeHostSessionProjectionSeed([], snapshot()), + () => 10, + ); + + const failed = projector.accept({ + kind: 'subscription.session_projection', + hostEpoch: 'host-1', + subscriptionId: 'subscription-1', + sequence: 2, + snapshot: snapshot({ + projectionRevision: 2, + rootTurn: { + sessionId: 'session-1', + turnId: 'turn-1', + runId: 'run-1', + status: 'failed', + terminalEventId: 'terminal-1', + failureClass: 'permission_error', + failureCode: 'permission_error', + failureMessage: 'bounded provider wording', + boundedProviderMessage: true, + }, + }), + }).events; + const errorEvent = failed.find((event) => event.type === 'error'); + assert.ok(errorEvent && errorEvent.type === 'error'); + assert.equal(errorEvent.code, 'permission_error'); + assert.equal(errorEvent.boundedProviderMessage, true); + assert.equal(errorEvent.message, 'bounded provider wording'); +}); + +test('does not mark an unmarked failed-Turn message as a bounded provider summary', () => { + const projector = new RuntimeHostSessionProjector( + snapshot(), + createRuntimeHostSessionProjectionSeed([], snapshot()), + () => 10, + ); + + const failed = projector.accept({ + kind: 'subscription.session_projection', + hostEpoch: 'host-1', + subscriptionId: 'subscription-1', + sequence: 2, + snapshot: snapshot({ + projectionRevision: 2, + rootTurn: { + sessionId: 'session-1', + turnId: 'turn-1', + runId: 'run-1', + status: 'failed', + terminalEventId: 'terminal-1', + failureClass: 'ECONNRESET', + failureCode: 'ECONNRESET', + failureMessage: 'raw internal socket text', + }, + }), + }).events; + const errorEvent = failed.find((event) => event.type === 'error'); + assert.ok(errorEvent && errorEvent.type === 'error'); + assert.equal(errorEvent.code, 'ECONNRESET'); + assert.equal(errorEvent.boundedProviderMessage, undefined); +}); + test('emits a live provider retry when the snapshot overlay appears, then drops it after content', () => { const projector = new RuntimeHostSessionProjector( snapshot(), diff --git a/packages/runtime-host/src/adapter/session-projector.ts b/packages/runtime-host/src/adapter/session-projector.ts index 09760af34a..467865da7a 100644 --- a/packages/runtime-host/src/adapter/session-projector.ts +++ b/packages/runtime-host/src/adapter/session-projector.ts @@ -419,6 +419,8 @@ export class RuntimeHostSessionProjector { recoverable: false, reason: root.failureClass, message: root.failureMessage ?? `Turn failed: ${root.failureClass}`, + ...(root.failureCode !== undefined ? { code: root.failureCode } : {}), + ...(root.boundedProviderMessage === true ? { boundedProviderMessage: true } : {}), }); } else { events.push({ diff --git a/packages/runtime-host/src/protocol/turn.ts b/packages/runtime-host/src/protocol/turn.ts index 100cb6c230..dbfacaa2d6 100644 --- a/packages/runtime-host/src/protocol/turn.ts +++ b/packages/runtime-host/src/protocol/turn.ts @@ -193,6 +193,10 @@ export type TurnSnapshot = terminalEventId: string; failureClass: string; failureMessage?: string; + /** Stable provider/transport code preserved from the terminal error fact. */ + failureCode?: string; + /** Marks `failureMessage` as a safe, bounded provider summary. */ + boundedProviderMessage?: boolean; }) | (TurnSnapshotBase & { status: 'cancelled'; @@ -333,6 +337,11 @@ export const TURN_OPERATION_SPECS = { }), } as const; +function decodeBoundedProviderMessage(value: unknown): boolean { + if (typeof value !== 'boolean') throw invalidProtocolFrame('Invalid boundedProviderMessage'); + return value; +} + function decodeTurnStartInput(value: unknown): TurnStartInput { const record = requireShapedRecord( value, @@ -660,13 +669,16 @@ export function decodeTurnSnapshot(value: unknown): TurnSnapshot { record, 'failed Turn snapshot', ['sessionId', 'turnId', 'runId', 'status', 'terminalEventId', 'failureClass'], - ['failureMessage'], + ['failureMessage', 'failureCode', 'boundedProviderMessage'], ); return { ...base, status, terminalEventId: requireId(record.terminalEventId, 'terminalEventId'), failureClass: requireString(record.failureClass, 'failureClass', 128), + ...(record.failureCode !== undefined + ? { failureCode: requireString(record.failureCode, 'failureCode', 128) } + : {}), ...(record.failureMessage !== undefined ? { failureMessage: requireUtf8String( @@ -677,6 +689,11 @@ export function decodeTurnSnapshot(value: unknown): TurnSnapshot { ), } : {}), + ...(record.boundedProviderMessage !== undefined + ? { + boundedProviderMessage: decodeBoundedProviderMessage(record.boundedProviderMessage), + } + : {}), }; } if (status === 'cancelled') { diff --git a/packages/runtime-host/src/server/canonical-turn-snapshot.ts b/packages/runtime-host/src/server/canonical-turn-snapshot.ts index 24cf1f3e7a..1f2fc15bf6 100644 --- a/packages/runtime-host/src/server/canonical-turn-snapshot.ts +++ b/packages/runtime-host/src/server/canonical-turn-snapshot.ts @@ -70,14 +70,20 @@ export async function readCanonicalTurnSnapshot( } if (fact.runStatus === 'failed') { if (!fact.failureClass) throw new Error('Failed terminal fact has no failure class'); + const errorContent = fact.terminalEvent.content; const failureMessage = - fact.terminalEvent.content?.kind === 'error' - ? truncateUtf8( - redactSecrets(fact.terminalEvent.content.message), - TURN_FAILURE_MESSAGE_MAX_BYTES, - '…', - ) + errorContent?.kind === 'error' + ? truncateUtf8(redactSecrets(errorContent.message), TURN_FAILURE_MESSAGE_MAX_BYTES, '…') : undefined; + const failureCode = + errorContent?.kind === 'error' && + typeof errorContent.code === 'string' && + errorContent.code.length > 0 && + errorContent.code.length <= 128 + ? errorContent.code + : undefined; + const boundedProviderMessage = + errorContent?.kind === 'error' && errorContent.boundedProviderMessage === true; return { sessionId, turnId, @@ -85,7 +91,9 @@ export async function readCanonicalTurnSnapshot( status: 'failed', terminalEventId: fact.terminalEvent.id, failureClass: fact.failureClass, + ...(failureCode !== undefined ? { failureCode } : {}), ...(failureMessage ? { failureMessage } : {}), + ...(boundedProviderMessage ? { boundedProviderMessage: true } : {}), }; } if (!fact.abortSource) throw new Error('Cancelled terminal fact has no abort source'); @@ -132,7 +140,9 @@ export function worstCaseFailedTurnSnapshot(identity: CanonicalTurnIdentity): Tu status: 'failed', terminalEventId: 'x'.repeat(128), failureClass: '\0'.repeat(128), + failureCode: '\0'.repeat(128), failureMessage: '\0'.repeat(TURN_FAILURE_MESSAGE_MAX_BYTES), + boundedProviderMessage: true, }; } From 60a741d60fc5c4e53ceb195195836611cf2c982d Mon Sep 17 00:00:00 2001 From: me2seeks Date: Mon, 17 Aug 2026 21:34:07 +0800 Subject: [PATCH 05/20] fix(desktop): render only bounded provider summaries verbatim The error-toast fallback now requires the bounded provider-summary marker before showing a message raw. A bare code is no longer enough, since Node transport codes carry unbounded internal text. Generated-by: Maka --- .../provider-failure-presentation.test.ts | 20 +++++++++++++++++-- .../src/renderer/model-connection-errors.ts | 12 +++++++++-- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/main/__tests__/provider-failure-presentation.test.ts b/apps/desktop/src/main/__tests__/provider-failure-presentation.test.ts index f690ee00b3..71e289e881 100644 --- a/apps/desktop/src/main/__tests__/provider-failure-presentation.test.ts +++ b/apps/desktop/src/main/__tests__/provider-failure-presentation.test.ts @@ -50,6 +50,7 @@ describe('provider failure presentation', () => { ts: 1, recoverable: false, code: 'permission_error', + boundedProviderMessage: true, message, }; @@ -57,6 +58,21 @@ describe('provider failure presentation', () => { assert.equal(sessionEventErrorMessage(event, 'en'), message); }); + test('does not render a coded message verbatim without the bounded-provider marker', () => { + const event: Extract = { + type: 'error', + id: 'event-ecodes', + turnId: 'turn-ecodes', + ts: 1, + recoverable: false, + code: 'ECONNRESET', + message: 'socket hang up at internal-connect.ts:42 (raw internal text)', + }; + + assert.equal(sessionEventErrorMessage(event), '任务运行失败,请稍后重试。'); + assert.equal(sessionEventErrorMessage(event, 'en'), 'The task run failed. Try again later.'); + }); + test('uses generic copy when an error has neither a known reason nor provider evidence', () => { const event: Extract = { type: 'error', @@ -67,7 +83,7 @@ describe('provider failure presentation', () => { message: '403 permission denied', }; - assert.equal(sessionEventErrorMessage(event), '对话运行失败,请稍后重试。'); - assert.equal(sessionEventErrorMessage(event, 'en'), 'The conversation run failed. Try again later.'); + assert.equal(sessionEventErrorMessage(event), '任务运行失败,请稍后重试。'); + assert.equal(sessionEventErrorMessage(event, 'en'), 'The task run failed. Try again later.'); }); }); diff --git a/apps/desktop/src/renderer/model-connection-errors.ts b/apps/desktop/src/renderer/model-connection-errors.ts index 4677f6c8fe..79b607c18b 100644 --- a/apps/desktop/src/renderer/model-connection-errors.ts +++ b/apps/desktop/src/renderer/model-connection-errors.ts @@ -65,8 +65,16 @@ export function sessionEventErrorMessage( // Provider errors reach this boundary with a stable code plus the // allowlisted, redacted, bounded summary produced by ModelAdapter. Keep that // structured result authoritative instead of reclassifying words or HTTP - // status fragments in the presentation layer. - if (event.code !== undefined && event.message.length > 0) return event.message; + // status fragments in the presentation layer. The bounded marker is the only + // proof that `message` is safe to render verbatim: a code alone can be a Node + // transport code whose message is unbounded internal text. + if ( + event.boundedProviderMessage === true && + event.code !== undefined && + event.message.length > 0 + ) { + return event.message; + } const fallback = getDesktopConversationCopy(locale).actions.conversationErrorFallback; return fallback; From e12418a540eb07890caffb8b908578631ad0213a Mon Sep 17 00:00:00 2001 From: me2seeks Date: Tue, 18 Aug 2026 19:02:47 +0800 Subject: [PATCH 06/20] fix(runtime): unify provider failure authority Generated-by: Maka --- .../provider-failure-presentation.test.ts | 43 ++++++++++ .../runtime-host-connections-ipc-main.test.ts | 32 +++++++ .../main/runtime-host-connections-ipc-main.ts | 3 + apps/desktop/src/renderer/app-shell-copy.ts | 29 +++++-- .../settings/provider-panel-shared.ts | 33 +++++--- packages/core/package.json | 1 + packages/core/src/llm-connections.ts | 1 + packages/core/src/provider-failure.ts | 37 +++++++++ .../connection-effect-coordinator.test.ts | 24 +++++- .../connection-effects-protocol.test.ts | 29 +++++++ .../src/protocol/connection-effects.ts | 66 +++++++++++++++ .../server/connection-effect-coordinator.ts | 3 + .../__tests__/provider-conformance.test.ts | 39 +++++++++ .../provider-error-classification.test.ts | 31 +++++++ .../provider-request-telemetry.test.ts | 10 +-- .../runtime/src/connection-effect-outcome.ts | 15 ++-- packages/runtime/src/model-adapter.ts | 21 +++-- .../src/provider-error-classification.ts | 83 +++++++++++++------ packages/runtime/src/test-connection.ts | 36 +++----- 19 files changed, 445 insertions(+), 91 deletions(-) create mode 100644 packages/core/src/provider-failure.ts diff --git a/apps/desktop/src/main/__tests__/provider-failure-presentation.test.ts b/apps/desktop/src/main/__tests__/provider-failure-presentation.test.ts index 71e289e881..0dd62da936 100644 --- a/apps/desktop/src/main/__tests__/provider-failure-presentation.test.ts +++ b/apps/desktop/src/main/__tests__/provider-failure-presentation.test.ts @@ -8,6 +8,8 @@ import { deriveFailedTurnRecovery, describeTurnErrorClass, } from '../../renderer/session-status-presentation.js'; +import { commandPaletteConnectionTestFailureMessage } from '../../renderer/app-shell-copy.js'; +import { connectionTestFailureMessage } from '../../renderer/settings/provider-panel-shared.js'; describe('provider failure presentation', () => { test('keeps provider account and access failures distinct in both locales', () => { @@ -86,4 +88,45 @@ describe('provider failure presentation', () => { assert.equal(sessionEventErrorMessage(event), '任务运行失败,请稍后重试。'); assert.equal(sessionEventErrorMessage(event, 'en'), 'The task run failed. Try again later.'); }); + + test('does not reclassify a neutral connection-test 403 as authentication', () => { + const result = { + ok: false, + statusCode: 403, + errorClass: 'unknown' as const, + errorMessage: '403 permission_error usage limit', + }; + + assert.equal( + connectionTestFailureMessage(result, { + auth: 'AUTH SHOULD NOT WIN', + recheck: 'RECHECK', + }, 'en'), + 'RECHECK', + ); + assert.notEqual(commandPaletteConnectionTestFailureMessage(result, 'en'), 'Authentication failed. Check the model key, subscription login, or credentials and try again.'); + }); + + test('renders only the Runtime-marked connection-test provider summary verbatim', () => { + const message = 'Plan allowance exhausted. (code=permission_error, status=403)'; + const result = { + ok: false, + statusCode: 403, + errorClass: 'unknown' as const, + providerFailure: { + errorClass: 'RequestRejected' as const, + httpStatus: 403, + providerCode: 'permission_error', + retryable: false, + message, + boundedProviderMessage: true as const, + }, + }; + + assert.equal( + connectionTestFailureMessage(result, { auth: 'AUTH', recheck: 'RECHECK' }, 'en'), + message, + ); + assert.equal(commandPaletteConnectionTestFailureMessage(result, 'en'), message); + }); }); diff --git a/apps/desktop/src/main/__tests__/runtime-host-connections-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-connections-ipc-main.test.ts index 7eda0a88ce..9909edc5d3 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-connections-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-connections-ipc-main.test.ts @@ -349,6 +349,38 @@ test('preserves the Host-tested model and diagnostics for the existing Desktop U errorClass: 'provider_unavailable', }, ); + const providerFailure = { + errorClass: 'RequestRejected' as const, + httpStatus: 403, + providerCode: 'permission_error', + retryable: false, + message: 'Plan allowance exhausted. (code=permission_error, status=403)', + boundedProviderMessage: true as const, + }; + assert.deepEqual( + projectHostConnectionTest({ + kind: 'committed', + catalogRevision: 10, + connection: { connectionId: 'connection-1', revision: 7 }, + test: { + kind: 'failed', + checkedAt: '2026-08-05T00:00:02.000Z', + modelId: 'model-1', + latencyMs: 300, + statusCode: 403, + errorClass: 'unknown', + providerFailure, + }, + }), + { + ok: false, + modelTested: 'model-1', + latencyMs: 300, + statusCode: 403, + errorClass: 'unknown', + providerFailure, + }, + ); }); function catalog(): ConnectionCatalogSnapshot { diff --git a/apps/desktop/src/main/runtime-host-connections-ipc-main.ts b/apps/desktop/src/main/runtime-host-connections-ipc-main.ts index 64716cf624..2f501ecb26 100644 --- a/apps/desktop/src/main/runtime-host-connections-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-connections-ipc-main.ts @@ -322,6 +322,9 @@ export function projectHostConnectionTest(result: ConnectionTestRunResult): Conn errorClass: result.test.errorClass === 'invalid_response' ? 'unknown' : result.test.errorClass, + ...(result.test.providerFailure === undefined + ? {} + : { providerFailure: result.test.providerFailure }), }; } diff --git a/apps/desktop/src/renderer/app-shell-copy.ts b/apps/desktop/src/renderer/app-shell-copy.ts index 2f15529095..c31e974cf9 100644 --- a/apps/desktop/src/renderer/app-shell-copy.ts +++ b/apps/desktop/src/renderer/app-shell-copy.ts @@ -60,20 +60,31 @@ export function openPathActionErrorMessage( export function commandPaletteConnectionTestFailureMessage(result: ConnectionTestResult, locale: UiLocale): string { const fallback = commandPaletteConnectionTestFailureFallback(result, locale); - if (!result.errorMessage) return fallback; - return localizedErrorMessage(new Error(result.errorMessage), fallback, locale); + const failure = result.providerFailure; + return failure?.boundedProviderMessage === true && failure.message + ? failure.message + : fallback; } function commandPaletteConnectionTestFailureFallback(result: ConnectionTestResult, locale: UiLocale): string { const copy = getShellCopy(locale).commandActions.connectionFailures; - if (result.statusCode === 429) return copy.rateLimit; - if (result.errorClass === 'timeout') return copy.timeout; - if (result.errorClass === 'auth' || result.statusCode === 401 || result.statusCode === 403) { - return copy.auth; + switch (result.providerFailure?.errorClass) { + case 'Auth': + return copy.auth; + case 'Timeout': + return copy.timeout; + case 'RateLimit': + return copy.rateLimit; + case 'Network': + return copy.network; + case 'ProviderUnavailable': + return copy.provider; + default: + break; } + if (result.errorClass === 'timeout') return copy.timeout; + if (result.errorClass === 'auth') return copy.auth; if (result.errorClass === 'network') return copy.network; - if (result.errorClass === 'provider_unavailable' || (result.statusCode && result.statusCode >= 500)) { - return copy.provider; - } + if (result.errorClass === 'provider_unavailable') return copy.provider; return copy.unknown; } diff --git a/apps/desktop/src/renderer/settings/provider-panel-shared.ts b/apps/desktop/src/renderer/settings/provider-panel-shared.ts index ec77a98ce6..35b4c7bf47 100644 --- a/apps/desktop/src/renderer/settings/provider-panel-shared.ts +++ b/apps/desktop/src/renderer/settings/provider-panel-shared.ts @@ -77,7 +77,7 @@ export function providerPanelActionErrorMessage(error: unknown, locale: UiLocale } export interface ConnectionTestTroubleshootingCopy { - /** Auth-class failure copy (errorClass 'auth' or HTTP 401/403). */ + /** Auth-class failure copy from the Runtime-owned structured result. */ auth: string; /** Final fallback copy when no failure class matched. */ recheck: string; @@ -92,14 +92,23 @@ export function connectionTestFailureFallback( locale: UiLocale = 'zh', ): string { const shared = getProviderSettingsCopy(locale).shared; - if (result.statusCode === 429) return shared.rateLimit; - if (result.errorClass === 'timeout') return shared.timeout; - if (result.errorClass === 'auth' || result.statusCode === 401 || result.statusCode === 403) { - return copy.auth; - } - if (result.errorClass === 'provider_unavailable' || (result.statusCode !== undefined && result.statusCode >= 500)) { - return shared.unavailable; + switch (result.providerFailure?.errorClass) { + case 'Auth': + return copy.auth; + case 'Timeout': + return shared.timeout; + case 'RateLimit': + return shared.rateLimit; + case 'Network': + return shared.network; + case 'ProviderUnavailable': + return shared.unavailable; + default: + break; } + if (result.errorClass === 'auth') return copy.auth; + if (result.errorClass === 'timeout') return shared.timeout; + if (result.errorClass === 'provider_unavailable') return shared.unavailable; if (result.errorClass === 'network') return shared.network; return copy.recheck; } @@ -110,10 +119,10 @@ export function connectionTestFailureMessage( locale: UiLocale = 'zh', ): string { const fallback = connectionTestFailureFallback(result, copy, locale); - if (!result.errorMessage) return fallback; - return locale === 'zh' - ? generalizedErrorMessageChinese(new Error(result.errorMessage), fallback) - : generalizedErrorMessage(new Error(result.errorMessage), fallback); + const failure = result.providerFailure; + return failure?.boundedProviderMessage === true && failure.message + ? failure.message + : fallback; } export function connectionLastTestMessageDisplay(message: string | undefined, locale: UiLocale = 'zh'): string | undefined { diff --git a/packages/core/package.json b/packages/core/package.json index 88bb6d7655..1594d6148f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -89,6 +89,7 @@ "./chat-model-choice": "./dist/chat-model-choice.js", "./connection-readiness": "./dist/connection-readiness.js", "./provider-auth": "./dist/provider-auth.js", + "./provider-failure": "./dist/provider-failure.js", "./oauth-subscription": "./dist/oauth-subscription.js", "./onboarding": "./dist/onboarding.js", "./onboarding-milestone": "./dist/onboarding-milestone.js", diff --git a/packages/core/src/llm-connections.ts b/packages/core/src/llm-connections.ts index 75bbe31742..7e64a8353a 100644 --- a/packages/core/src/llm-connections.ts +++ b/packages/core/src/llm-connections.ts @@ -446,6 +446,7 @@ export interface ConnectionTestResult { errorMessage?: string; statusCode?: number; errorClass?: ConnectionTestErrorClass; + providerFailure?: import('./provider-failure.js').ProviderFailureResult; } export const PROVIDER_DEFAULTS = PROVIDER_REGISTRY; diff --git a/packages/core/src/provider-failure.ts b/packages/core/src/provider-failure.ts new file mode 100644 index 0000000000..1e3e57868d --- /dev/null +++ b/packages/core/src/provider-failure.ts @@ -0,0 +1,37 @@ +export const PROVIDER_FAILURE_CLASSES = [ + 'Abort', + 'Auth', + 'ContextLength', + 'Network', + 'Other', + 'ProviderBilling', + 'ProviderPermission', + 'ProviderUnavailable', + 'RateLimit', + 'RequestRejected', + 'Timeout', + 'UsageLimit', +] as const; + +export type ProviderFailureClass = (typeof PROVIDER_FAILURE_CLASSES)[number]; + +/** + * One provider-owned failure interpretation produced at the Runtime boundary. + * Consumers may project or persist these fields, but must not rebuild the + * taxonomy from HTTP status or message text. + */ +export interface ProviderFailureResult { + readonly errorClass: ProviderFailureClass; + readonly retryable: boolean; + readonly retryAfterMs?: number; + readonly httpStatus?: number; + readonly providerCode?: string; + readonly providerRequestId?: string; + readonly message?: string; + /** Proves that `message` was allowlisted, redacted, and bounded by Runtime. */ + readonly boundedProviderMessage?: true; +} + +export function isProviderFailureClass(value: unknown): value is ProviderFailureClass { + return (PROVIDER_FAILURE_CLASSES as readonly unknown[]).includes(value); +} diff --git a/packages/runtime-host/src/__tests__/connection-effect-coordinator.test.ts b/packages/runtime-host/src/__tests__/connection-effect-coordinator.test.ts index 18fb5e681e..4e5ecd7fa7 100644 --- a/packages/runtime-host/src/__tests__/connection-effect-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/connection-effect-coordinator.test.ts @@ -1182,7 +1182,18 @@ test('keeps a neutral provider permission failure out of needs_reauth', async () assert.equal(modelId, 'gpt-5'); return { ok: false, - error: { kind: 'unknown', statusCode: 403 }, + error: { + kind: 'unknown', + statusCode: 403, + providerFailure: { + errorClass: 'RequestRejected', + httpStatus: 403, + providerCode: 'permission_error', + retryable: false, + message: 'Plan allowance exhausted. (code=permission_error, status=403)', + boundedProviderMessage: true, + }, + }, modelId: 'gpt-5', latencyMs: 17, }; @@ -1198,6 +1209,17 @@ test('keeps a neutral provider permission failure out of needs_reauth', async () if (!outcome.ok || outcome.result.kind !== 'committed') { throw new Error('connection test did not commit'); } + assert.deepEqual( + outcome.result.test.kind === 'failed' ? outcome.result.test.providerFailure : undefined, + { + errorClass: 'RequestRejected', + httpStatus: 403, + providerCode: 'permission_error', + retryable: false, + message: 'Plan allowance exhausted. (code=permission_error, status=403)', + boundedProviderMessage: true, + }, + ); const persisted = await stores.connectionCatalog.getSnapshot(); assert.deepEqual(persisted.connections[0]?.lastTest, { status: 'error', diff --git a/packages/runtime-host/src/__tests__/connection-effects-protocol.test.ts b/packages/runtime-host/src/__tests__/connection-effects-protocol.test.ts index ef3bd06012..bfafc1b5bc 100644 --- a/packages/runtime-host/src/__tests__/connection-effects-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/connection-effects-protocol.test.ts @@ -189,6 +189,14 @@ describe('Runtime Host connection effects protocol', () => { latencyMs: 42, statusCode: 401, errorClass: 'auth', + providerFailure: { + errorClass: 'Auth', + retryable: false, + httpStatus: 401, + providerCode: 'invalid_api_key', + message: 'Invalid API key. (code=invalid_api_key, status=401)', + boundedProviderMessage: true, + }, }, }; const failed = response('connection.test.run', failedResult); @@ -224,6 +232,27 @@ describe('Runtime Host connection effects protocol', () => { ...failedResult, test: { ...failedResult.test, statusCode: 600 }, }); + assertInvalidResponse('connection.test.run', { + ...failedResult, + test: { + ...failedResult.test, + providerFailure: { + ...failedResult.test.providerFailure, + boundedProviderMessage: true, + message: undefined, + }, + }, + }); + assertInvalidResponse('connection.test.run', { + ...failedResult, + test: { + ...failedResult.test, + providerFailure: { + ...failedResult.test.providerFailure, + boundedProviderMessage: undefined, + }, + }, + }); }); }); diff --git a/packages/runtime-host/src/protocol/connection-effects.ts b/packages/runtime-host/src/protocol/connection-effects.ts index add65435a1..451338aae9 100644 --- a/packages/runtime-host/src/protocol/connection-effects.ts +++ b/packages/runtime-host/src/protocol/connection-effects.ts @@ -30,6 +30,7 @@ import { type ModelDiscoverySource, } from '@maka/core/runtime-policy'; import type { ModelInfo, ProviderType } from '@maka/core/llm-connections'; +import { isProviderFailureClass, type ProviderFailureResult } from '@maka/core/provider-failure'; import { requireCount, requireEntityId, @@ -182,6 +183,7 @@ export type ConnectionTestProjection = readonly latencyMs: number | null; readonly statusCode: number | null; readonly errorClass: ConnectionEffectFailureClass; + readonly providerFailure?: ProviderFailureResult; }; export type ConnectionTestRunResult = @@ -460,6 +462,7 @@ function decodeConnectionTestProjection(value: unknown): ConnectionTestProjectio 'latencyMs', 'statusCode', 'errorClass', + ...(Object.hasOwn(projection, 'providerFailure') ? ['providerFailure'] : []), ]); if (failed.kind !== 'failed') throw invalidProtocolFrame('Invalid connection test projection'); return { @@ -474,6 +477,69 @@ function decodeConnectionTestProjection(value: unknown): ConnectionTestProjectio ? null : boundedInteger(failed.statusCode, 'connection test status code', 100, 599), errorClass: effectFailureClass(failed.errorClass), + ...(failed.providerFailure === undefined + ? {} + : { providerFailure: decodeProviderFailureResult(failed.providerFailure) }), + }; +} + +function decodeProviderFailureResult(value: unknown): ProviderFailureResult { + const candidate = requireRecord(value, 'provider failure result'); + const record = requireExactRecord(candidate, 'provider failure result', [ + 'errorClass', + 'retryable', + ...(Object.hasOwn(candidate, 'retryAfterMs') ? ['retryAfterMs'] : []), + ...(Object.hasOwn(candidate, 'httpStatus') ? ['httpStatus'] : []), + ...(Object.hasOwn(candidate, 'providerCode') ? ['providerCode'] : []), + ...(Object.hasOwn(candidate, 'providerRequestId') ? ['providerRequestId'] : []), + ...(Object.hasOwn(candidate, 'message') ? ['message'] : []), + ...(Object.hasOwn(candidate, 'boundedProviderMessage') ? ['boundedProviderMessage'] : []), + ]); + if (!isProviderFailureClass(record.errorClass)) { + throw invalidProtocolFrame('Invalid provider failure class'); + } + if (typeof record.retryable !== 'boolean') { + throw invalidProtocolFrame('Invalid provider failure retryability'); + } + if (record.boundedProviderMessage !== undefined && record.boundedProviderMessage !== true) { + throw invalidProtocolFrame('Invalid bounded provider message marker'); + } + if (record.boundedProviderMessage === true && record.message === undefined) { + throw invalidProtocolFrame('Bounded provider message marker requires a message'); + } + if (record.message !== undefined && record.boundedProviderMessage !== true) { + throw invalidProtocolFrame('Provider failure message requires bounded provenance'); + } + return { + errorClass: record.errorClass, + retryable: record.retryable, + ...(record.retryAfterMs === undefined + ? {} + : { + retryAfterMs: boundedInteger( + record.retryAfterMs, + 'provider retry delay', + 1, + 2_147_483_647, + ), + }), + ...(record.httpStatus === undefined + ? {} + : { + httpStatus: boundedInteger(record.httpStatus, 'provider HTTP status', 100, 599), + }), + ...(record.providerCode === undefined + ? {} + : { providerCode: requireString(record.providerCode, 'provider code', 256) }), + ...(record.providerRequestId === undefined + ? {} + : { + providerRequestId: requireString(record.providerRequestId, 'provider request id', 256), + }), + ...(record.message === undefined + ? {} + : { message: requireString(record.message, 'provider failure message', 2_048) }), + ...(record.boundedProviderMessage === true ? { boundedProviderMessage: true } : {}), }; } diff --git a/packages/runtime-host/src/server/connection-effect-coordinator.ts b/packages/runtime-host/src/server/connection-effect-coordinator.ts index 84e591e2fd..2fea4219b2 100644 --- a/packages/runtime-host/src/server/connection-effect-coordinator.ts +++ b/packages/runtime-host/src/server/connection-effect-coordinator.ts @@ -532,6 +532,9 @@ function projectConnectionTest( latencyMs: outcome.latencyMs ?? null, statusCode: outcome.error.statusCode ?? null, errorClass: outcome.error.kind, + ...(outcome.error.providerFailure === undefined + ? {} + : { providerFailure: outcome.error.providerFailure }), }; } diff --git a/packages/runtime/src/__tests__/provider-conformance.test.ts b/packages/runtime/src/__tests__/provider-conformance.test.ts index a5d3cb7a6f..a53e13540f 100644 --- a/packages/runtime/src/__tests__/provider-conformance.test.ts +++ b/packages/runtime/src/__tests__/provider-conformance.test.ts @@ -611,6 +611,45 @@ describe('models.dev provider conformance', () => { assert.equal(result.ok, false); assert.equal(result.statusCode, 403); assert.equal(result.errorClass, 'unknown'); + assert.deepEqual(result.providerFailure, { + errorClass: 'RequestRejected', + httpStatus: 403, + providerCode: 'permission_error', + retryable: false, + message: + 'You have reached the plan usage limit for this model. (code=permission_error, status=403)', + boundedProviderMessage: true, + }); + }); + + test('connection probe distinguishes a structured usage limit from transient 429', async () => { + const server = await startJsonServer(async (request, response) => { + await readBody(request); + respondJson(response, 429, { + error: { + type: 'usage_limit_reached', + message: 'Your plan allowance is exhausted.', + }, + }); + }); + const connection: LlmConnection = { + slug: 'moonshot-usage-limit', + name: 'Moonshot Usage Limit', + providerType: 'moonshot', + baseUrl: `${server.url}/v1`, + defaultModel: 'kimi-k2.6', + enabled: true, + createdAt: 1, + updatedAt: 1, + }; + + const result = await testConnection(connection, 'moonshot-key'); + + assert.equal(result.ok, false); + assert.equal(result.errorClass, 'provider_unavailable'); + assert.equal(result.providerFailure?.errorClass, 'UsageLimit'); + assert.equal(result.providerFailure?.retryable, false); + assert.equal(result.providerFailure?.boundedProviderMessage, true); }); test('connection probe still classifies a bare 401 as authentication', async () => { diff --git a/packages/runtime/src/__tests__/provider-error-classification.test.ts b/packages/runtime/src/__tests__/provider-error-classification.test.ts index a32de24eee..c0be8d2e00 100644 --- a/packages/runtime/src/__tests__/provider-error-classification.test.ts +++ b/packages/runtime/src/__tests__/provider-error-classification.test.ts @@ -26,6 +26,7 @@ import { z } from 'zod/v4'; import { classifyError, providerFailureDiagnostic, + providerFailureResult, errorPresentationFromClass, providerFailureSummary, providerRetryMetadata, @@ -155,6 +156,22 @@ describe('Provider error classification', () => { [Object.assign(new Error('bad request'), { statusCode: 400 }), 'RequestRejected'], [Object.assign(new Error('slow down'), { statusCode: 429 }), 'RateLimit'], [Object.assign(new Error('upstream failed'), { statusCode: 503 }), 'ProviderUnavailable'], + [ + Object.assign(new Error('access denied'), { + statusCode: 403, + data: { error: { type: 'permission_denied' } }, + }), + 'ProviderPermission', + ], + [ + Object.assign(new Error('plan exhausted'), { + statusCode: 429, + data: { error: { type: 'usage_limit_reached' } }, + }), + 'UsageLimit', + ], + [{ error: { type: 'permission_denied' } }, 'ProviderPermission'], + [{ error: { type: 'usage_limit_reached' } }, 'UsageLimit'], [new DOMException('request timed out', 'TimeoutError'), 'Timeout'], [new TypeError('fetch failed'), 'Network'], [ @@ -645,6 +662,20 @@ describe('Provider error classification', () => { message: `${observedMessage} (code=permission_error, status=403)`, code: 'permission_error', }); + assert.deepEqual(providerFailureResult(planCycleLimit), { + errorClass: 'RequestRejected', + httpStatus: 403, + providerCode: 'permission_error', + retryable: false, + message: `${observedMessage} (code=permission_error, status=403)`, + boundedProviderMessage: true, + }); + assert.deepEqual(providerFailureDiagnostic(planCycleLimit), { + errorClass: 'RequestRejected', + httpStatus: 403, + providerCode: 'permission_error', + retryable: false, + }); }); test('maps provider classes to stable user-safe presentations', () => { diff --git a/packages/runtime/src/__tests__/provider-request-telemetry.test.ts b/packages/runtime/src/__tests__/provider-request-telemetry.test.ts index 9ef2d8412e..9543127abe 100644 --- a/packages/runtime/src/__tests__/provider-request-telemetry.test.ts +++ b/packages/runtime/src/__tests__/provider-request-telemetry.test.ts @@ -1010,7 +1010,7 @@ describe('canonical model-call accounting', () => { name: 'AI_APICallError', statusCode: 429, data: { - error: { code: 'rate_limit_exceeded', message: 'private response body' }, + error: { code: 'usage_limit_reached', message: 'private response body' }, }, responseHeaders: { 'x-request-id': 'req-compact-1' }, requestBodyValues: { input: 'private request body' }, @@ -1030,15 +1030,15 @@ describe('canonical model-call accounting', () => { const attempt = decodeModelCallAttempt(recorded[0]); assert.equal(attempt.historyCompactRoute, 'provider_native'); - assert.equal(attempt.errorClass, 'RateLimit'); + assert.equal(attempt.errorClass, 'UsageLimit'); assert.equal(attempt.httpStatus, 429); - assert.equal(attempt.providerCode, 'rate_limit_exceeded'); + assert.equal(attempt.providerCode, 'usage_limit_reached'); assert.equal(attempt.providerRequestId, 'req-compact-1'); assert.equal(attempt.retryable, false); assert.deepEqual(diagnosticAttempts[0]?.failure, { - errorClass: 'RateLimit', + errorClass: 'UsageLimit', httpStatus: 429, - providerCode: 'rate_limit_exceeded', + providerCode: 'usage_limit_reached', providerRequestId: 'req-compact-1', retryable: false, }); diff --git a/packages/runtime/src/connection-effect-outcome.ts b/packages/runtime/src/connection-effect-outcome.ts index 9a27f50f10..002d8650cd 100644 --- a/packages/runtime/src/connection-effect-outcome.ts +++ b/packages/runtime/src/connection-effect-outcome.ts @@ -18,7 +18,8 @@ */ import type { ModelDiscoverySource, ModelInfo, ProviderType } from '@maka/core/llm-connections'; -import { classifyError } from './provider-error-classification.js'; +import type { ProviderFailureResult } from '@maka/core/provider-failure'; +import { providerFailureResult } from './provider-error-classification.js'; export interface ConnectionEffectConnection { readonly providerType: ProviderType; @@ -40,6 +41,7 @@ export type ConnectionEffectErrorKind = export interface ConnectionEffectError { readonly kind: ConnectionEffectErrorKind; readonly statusCode?: number; + readonly providerFailure?: ProviderFailureResult; } export type ConnectionModelDiscoveryEffectOutcome = @@ -77,18 +79,19 @@ export function classifyConnectionEffectStatus(statusCode: number): ConnectionEf // Status-only evidence still routes through the shared provider-failure // authority. A bare 403 is intentionally not authentication: providers use // it for valid-key permission failures, guardrails, and subscription limits. - switch (classifyError({ statusCode })) { + const providerFailure = providerFailureResult({ statusCode }); + switch (providerFailure.errorClass) { case 'Auth': - return { kind: 'auth', statusCode }; + return { kind: 'auth', statusCode, providerFailure }; case 'RateLimit': case 'ProviderUnavailable': case 'ProviderBilling': case 'ProviderPermission': case 'UsageLimit': - return { kind: 'provider_unavailable', statusCode }; + return { kind: 'provider_unavailable', statusCode, providerFailure }; default: break; } - if (statusCode === 408) return { kind: 'timeout', statusCode }; - return { kind: 'unknown', statusCode }; + if (statusCode === 408) return { kind: 'timeout', statusCode, providerFailure }; + return { kind: 'unknown', statusCode, providerFailure }; } diff --git a/packages/runtime/src/model-adapter.ts b/packages/runtime/src/model-adapter.ts index 45e7fb0b2c..87e99a1054 100644 --- a/packages/runtime/src/model-adapter.ts +++ b/packages/runtime/src/model-adapter.ts @@ -59,7 +59,7 @@ import { resolveModelRuntime, type ResolvedModelRuntime } from './model-runtime. import { classifyError, errorPresentationFromClass, - providerFailureSummary, + providerFailureResult, providerRetryMetadata, } from './provider-error-classification.js'; import type { ProviderRequestTracker } from './provider-request-telemetry.js'; @@ -897,16 +897,23 @@ function normalizeModelFailure(error: unknown): ModelFailure { function normalizeProviderFailure(error: unknown): ModelFailure { if (isModelFailure(error)) return error; - const summary = providerFailureSummary(error); - const failure = normalizeModelFailure(error); + const result = providerFailureResult(error); + const presentation = errorPresentationFromClass(result.errorClass); // The bounded summary is display-safe provider wording; a generalized // presentation message is not. The marker must follow the message, not the // presence of a code (Error.code and provider codes both exist here). - const boundedProviderMessage = failure.kind === 'unknown' && summary !== undefined; + const kind = modelFailureKind(result.errorClass); + const boundedProviderMessage = kind === 'unknown' && result.boundedProviderMessage === true; return { - ...failure, - ...(summary?.code !== undefined ? { code: summary.code } : {}), - ...(boundedProviderMessage ? { message: summary.message } : {}), + type: 'model_failure', + kind, + retryable: result.retryable, + ...(result.retryAfterMs !== undefined ? { retryAfterMs: result.retryAfterMs } : {}), + ...(result.providerCode !== undefined ? { code: result.providerCode } : {}), + message: + boundedProviderMessage && result.message + ? result.message + : (presentation.message ?? generalizedErrorMessage(error)), ...(boundedProviderMessage ? { boundedProviderMessage: true } : {}), }; } diff --git a/packages/runtime/src/provider-error-classification.ts b/packages/runtime/src/provider-error-classification.ts index 8fd2849df6..ac507716cc 100644 --- a/packages/runtime/src/provider-error-classification.ts +++ b/packages/runtime/src/provider-error-classification.ts @@ -19,6 +19,7 @@ import { RetryError } from 'ai'; import { truncateUtf8 } from '@maka/core/diagnostic-log'; +import { isProviderFailureClass, type ProviderFailureResult } from '@maka/core/provider-failure'; import { isAuthenticationErrorText, redactSecrets } from '@maka/core/redaction'; /** @@ -131,14 +132,11 @@ export interface ProviderRetryMetadata { retryAfterMs?: number; } -/** Bounded, allowlisted provider failure facts safe for durable telemetry. */ -export interface ProviderFailureDiagnostic { - errorClass: string; - httpStatus?: number; - providerCode?: string; - providerRequestId?: string; - retryable: boolean; -} +/** Bounded provider facts safe for durable telemetry (no presentation text). */ +export type ProviderFailureDiagnostic = Pick< + ProviderFailureResult, + 'errorClass' | 'httpStatus' | 'providerCode' | 'providerRequestId' | 'retryable' +>; interface ProviderFailureSummary { message: string; @@ -195,6 +193,10 @@ function parseRetryAfterMs(headers: Record): number | null | und export function providerRetryMetadata(error: unknown): ProviderRetryMetadata { const facts = normalizeProviderError(error); if (!facts) return { retryable: false }; + return providerRetryMetadataFromFacts(facts); +} + +function providerRetryMetadataFromFacts(facts: ProviderErrorFacts): ProviderRetryMetadata { const { evidence } = facts; if (RUNTIME_RETRYABLE_ERROR_CODES.has(evidence.code)) return { retryable: true }; @@ -327,6 +329,11 @@ function normalizeProviderError(error: unknown): ProviderErrorFacts | undefined }; const structuredCodes: string[] = []; collectStructuredCodes(record, structuredCodes); + const rawBody = safeField(record, 'responseBody'); + if (typeof rawBody === 'string') { + const parsedBody = parsedProviderValue(rawBody); + if (parsedBody !== undefined) collectStructuredCodes(parsedBody, structuredCodes); + } let text: string; try { // Serialize the whole value so message/code text is evidence no matter @@ -358,6 +365,12 @@ function normalizeProviderError(error: unknown): ProviderErrorFacts | undefined export function providerFailureSummary(error: unknown): ProviderFailureSummary | undefined { const facts = normalizeProviderError(error); if (!facts) return undefined; + return providerFailureSummaryFromFacts(facts); +} + +function providerFailureSummaryFromFacts( + facts: ProviderErrorFacts, +): ProviderFailureSummary | undefined { const sources = facts.summarySources; const message = firstProviderMessage(facts); const code = firstProviderField(sources, ['code']) ?? firstProviderField(sources, ['type']); @@ -387,24 +400,29 @@ export function providerFailureSummary(error: unknown): ProviderFailureSummary | }; } -const DURABLE_PROVIDER_ERROR_CLASSES: ReadonlySet = new Set([ - 'Abort', - 'Auth', - 'ContextLength', - 'Network', - 'ProviderCapacity', - 'ProviderBilling', - 'ProviderUnavailable', - 'RateLimit', - 'Timeout', -]); - /** * Projects provider errors into a small durable fingerprint. Unlike the * presentation summary, this intentionally excludes provider messages and * response bodies: even redacted free text can echo prompts or credentials. */ export function providerFailureDiagnostic(error: unknown): ProviderFailureDiagnostic { + const failure = providerFailureResult(error); + return { + errorClass: failure.errorClass, + ...(failure.httpStatus !== undefined ? { httpStatus: failure.httpStatus } : {}), + ...(failure.providerCode !== undefined ? { providerCode: failure.providerCode } : {}), + ...(failure.providerRequestId !== undefined + ? { providerRequestId: failure.providerRequestId } + : {}), + retryable: failure.retryable, + }; +} + +/** The single structured provider-failure authority for Runtime consumers. */ +export function providerFailureResult(error: unknown): ProviderFailureResult { + if (RetryError.isInstance(error) && error.reason === 'abort') { + return { errorClass: 'Abort', retryable: false }; + } const facts = providerFailureDiagnosticFacts(error); if (!facts) return { errorClass: 'Other', retryable: false }; const sources = facts.summarySources; @@ -416,25 +434,36 @@ export function providerFailureDiagnostic(error: unknown): ProviderFailureDiagno ? numericStatus : undefined; const classified = classifyProviderFacts(facts); - const errorClass = durableProviderErrorClass(classified, httpStatus); + const errorClass = normalizedProviderFailureClass(classified, httpStatus); const providerCode = firstProviderField(sources, ['code']) ?? firstProviderField(sources, ['type']); const providerRequestId = firstProviderField(sources, ['requestId', 'request_id']) ?? boundedProviderField(facts.responseHeaders?.['x-request-id']); + const retry = providerRetryMetadataFromFacts(facts); + const summary = providerFailureSummaryFromFacts(facts); return { errorClass, ...(httpStatus !== undefined ? { httpStatus } : {}), ...(providerCode !== undefined ? { providerCode } : {}), ...(providerRequestId !== undefined ? { providerRequestId } : {}), - retryable: providerRetryMetadata(facts.target).retryable, + retryable: retry.retryable, + ...(retry.retryAfterMs !== undefined ? { retryAfterMs: retry.retryAfterMs } : {}), + ...(summary !== undefined + ? { message: summary.message, boundedProviderMessage: true as const } + : {}), }; } -function durableProviderErrorClass(classified: string, httpStatus: number | undefined): string { - // 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; +function normalizedProviderFailureClass( + classified: string, + httpStatus: number | undefined, +): ProviderFailureResult['errorClass'] { + // The semantic class already includes structured provider identifiers + // (capacity and context-overflow included) and therefore outranks the + // transport status used only as a fallback — structured capacity evidence + // can legitimately arrive behind a generic 4xx/5xx proxy response. + if (isProviderFailureClass(classified) && classified !== 'Other') return classified; if (httpStatus === 401 || httpStatus === 403) return 'Auth'; if (httpStatus === 402) return 'ProviderBilling'; if (httpStatus === 408) return 'Timeout'; @@ -446,7 +475,7 @@ function durableProviderErrorClass(classified: string, httpStatus: number | unde if (httpStatus !== undefined && httpStatus >= 500 && httpStatus <= 599) { return 'ProviderUnavailable'; } - return DURABLE_PROVIDER_ERROR_CLASSES.has(classified) ? classified : 'Other'; + return 'Other'; } function providerFailureDiagnosticFacts(error: unknown): ProviderErrorFacts | undefined { diff --git a/packages/runtime/src/test-connection.ts b/packages/runtime/src/test-connection.ts index 9cfb0bfa9f..08e2b8934c 100644 --- a/packages/runtime/src/test-connection.ts +++ b/packages/runtime/src/test-connection.ts @@ -45,7 +45,7 @@ import { type ConnectionEffectError, type ConnectionTestEffectOutcome, } from './connection-effect-outcome.js'; -import { classifyError } from './provider-error-classification.js'; +import { providerFailureResult } from './provider-error-classification.js'; const CONNECTION_TEST_TIMEOUT_MS = 15_000; @@ -454,23 +454,14 @@ async function probeGoogle( async function httpFailure(r: ConnectionEffectResponse, t0: number): Promise { const statusCode = r.status; - if (statusCode === 429) { - await r.cancel(); - return { - ok: false, - errorMessage: - 'OAuth 已登录,但当前账号或 provider 正在 rate limit。请稍后重试,或先切换到其它可用模型。', - statusCode, - errorClass: 'provider_unavailable', - latencyMs: Date.now() - t0, - }; - } const errorBody = await r.readText(CONNECTION_EFFECT_ERROR_BODY_MAX_BYTES); + const providerFailure = providerFailureResult({ statusCode, responseBody: errorBody }); return { ok: false, - errorMessage: `${statusCode} ${errorBody.slice(0, 200)}`, + ...(providerFailure.message !== undefined ? { errorMessage: providerFailure.message } : {}), statusCode, - errorClass: classifyHttpFailure(statusCode, errorBody), + errorClass: connectionTestErrorClassFromProviderClass(providerFailure.errorClass), + providerFailure, latencyMs: Date.now() - t0, }; } @@ -479,16 +470,6 @@ function stripTrailing(u: string): string { return u.replace(/\/+$/, ''); } -function classifyHttpFailure(statusCode: number, body: string): ConnectionTestResult['errorClass'] { - // The response body carries the provider's structured envelope; the numeric - // status is the fallback when the body is empty or unstructured. Both flow - // through the one shared provider-failure authority instead of a parallel - // status-only classifier. - const fromBody = classifyError(body); - const fromStatus = classifyError({ statusCode }); - return connectionTestErrorClassFromProviderClass(fromBody !== 'Other' ? fromBody : fromStatus); -} - function connectionTestErrorClassFromProviderClass(errorClass: string): ConnectionTestErrorClass { switch (errorClass) { case 'Auth': @@ -541,6 +522,13 @@ function classifyConnectionTestResult(result: ConnectionTestResult): ConnectionE // The body-aware classification computed at the probe boundary is // authoritative; the status-only fallback must not override it (a Kimi 403 // carrying a permission envelope is neutral, not a reauth). + if (result.providerFailure !== undefined) { + return { + kind: connectionTestErrorKind(result.errorClass), + ...(result.statusCode === undefined ? {} : { statusCode: result.statusCode }), + providerFailure: result.providerFailure, + }; + } if (result.errorClass !== undefined && result.errorClass !== 'unknown') { return { kind: connectionTestErrorKind(result.errorClass), From c5f61d08f61b5cbfa8f42740c1f98f349da8a26c Mon Sep 17 00:00:00 2001 From: me2seeks Date: Tue, 18 Aug 2026 20:03:44 +0800 Subject: [PATCH 07/20] fix(runtime): preserve provider message provenance Generated-by: Maka --- .../provider-failure-presentation.test.ts | 5 +- .../cli/src/__tests__/pi-transcript.test.ts | 22 +++++++++ packages/cli/src/pi-transcript.ts | 5 +- .../core/src/__tests__/runtime-event.test.ts | 29 ++++++++++++ packages/core/src/runtime-event.ts | 2 + .../src/__tests__/model-adapter.test.ts | 5 ++ .../__tests__/provider-conformance.test.ts | 2 + .../provider-error-classification.test.ts | 9 ++++ packages/runtime/src/model-adapter.ts | 7 +-- .../src/provider-error-classification.ts | 46 +++++++++++++++++-- packages/runtime/src/test-connection.ts | 4 +- 11 files changed, 126 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/main/__tests__/provider-failure-presentation.test.ts b/apps/desktop/src/main/__tests__/provider-failure-presentation.test.ts index 0dd62da936..983fdcaef3 100644 --- a/apps/desktop/src/main/__tests__/provider-failure-presentation.test.ts +++ b/apps/desktop/src/main/__tests__/provider-failure-presentation.test.ts @@ -104,7 +104,10 @@ describe('provider failure presentation', () => { }, 'en'), 'RECHECK', ); - assert.notEqual(commandPaletteConnectionTestFailureMessage(result, 'en'), 'Authentication failed. Check the model key, subscription login, or credentials and try again.'); + assert.equal( + commandPaletteConnectionTestFailureMessage(result, 'en'), + 'The connection test failed. Try again later.', + ); }); test('renders only the Runtime-marked connection-test provider summary verbatim', () => { diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index 7eee3a17e8..13195f7b9b 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -256,6 +256,7 @@ describe('Maka Pi TUI transcript', () => { recoverable: false, code: 'permission_error', message, + boundedProviderMessage: true, }), ); @@ -269,6 +270,27 @@ describe('Maka Pi TUI transcript', () => { /usage limit/, ); }); + + test('hides an unmarked Runtime error behind the safe fallback', () => { + const state = createMakaPiTranscriptState(); + + applyMakaSessionEventToTranscript( + state, + event({ + type: 'error', + recoverable: false, + code: 'permission_error', + message: 'unbounded provider response', + }), + ); + + assert.deepEqual(state.entries.at(-1), { + kind: 'notice', + level: 'error', + text: 'The task run failed. Try again later.', + }); + }); + test('keeps assistant text after a tool call visible after the tool block', () => { const state = createMakaPiTranscriptState(); appendUserPrompt(state, 'inspect the package'); diff --git a/packages/cli/src/pi-transcript.ts b/packages/cli/src/pi-transcript.ts index 35de5bbd80..421a96186f 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -742,7 +742,10 @@ export function applyMakaSessionEventToTranscript( state.entries.push({ kind: 'notice', level: 'error', - text: event.message, + text: + event.boundedProviderMessage === true + ? event.message + : 'The task run failed. Try again later.', }); break; diff --git a/packages/core/src/__tests__/runtime-event.test.ts b/packages/core/src/__tests__/runtime-event.test.ts index 120d2eac3a..58880f1a6e 100644 --- a/packages/core/src/__tests__/runtime-event.test.ts +++ b/packages/core/src/__tests__/runtime-event.test.ts @@ -173,6 +173,35 @@ describe('continuation-start protocol', () => { }); describe('RuntimeEvent content variants', () => { + test('accepts only boolean provider-message bounds on error content', () => { + const decoded = decodeRuntimeEvent( + baseEvent({ + content: { + kind: 'error', + message: 'bounded provider response', + boundedProviderMessage: true, + }, + }), + ); + assert.equal( + decoded.content?.kind === 'error' ? decoded.content.boundedProviderMessage : undefined, + true, + ); + assert.throws( + () => + decodeRuntimeEvent( + baseEvent({ + content: { + kind: 'error', + message: 'untrusted provider response', + boundedProviderMessage: 'true', + } as never, + }), + ), + /RuntimeEvent schema/, + ); + }); + test('preserves sent inline references as message identity', () => { const inlineReferences = [ { kind: 'skill', value: '/skill:writer', label: 'Writer', start: 8 }, diff --git a/packages/core/src/runtime-event.ts b/packages/core/src/runtime-event.ts index 6f1c5c9ace..193707bcc6 100644 --- a/packages/core/src/runtime-event.ts +++ b/packages/core/src/runtime-event.ts @@ -710,6 +710,8 @@ function isRuntimeEventContent(value: unknown): value is RuntimeEventContent { hasExactShape(value, ERROR_CONTENT_SHAPE) && isOptionalString(value.code) && isOptionalString(value.reason) && + (value.boundedProviderMessage === undefined || + typeof value.boundedProviderMessage === 'boolean') && typeof value.message === 'string' && (value.details === undefined || isStringArray(value.details) || isRecord(value.details)) ); diff --git a/packages/runtime/src/__tests__/model-adapter.test.ts b/packages/runtime/src/__tests__/model-adapter.test.ts index e85e1cee51..aa9889069c 100644 --- a/packages/runtime/src/__tests__/model-adapter.test.ts +++ b/packages/runtime/src/__tests__/model-adapter.test.ts @@ -641,6 +641,7 @@ describe('ModelAdapter stream and error normalization', () => { assert.equal(adapter.classifyError(error), 'Error'); assert.equal(event.reason, undefined); assert.equal(event.message, 'Network error'); + assert.equal(event.boundedProviderMessage, undefined); }); test('projects string provider errors through the same classification', () => { @@ -697,6 +698,10 @@ describe('ModelAdapter stream and error normalization', () => { assert.equal(event.code, 'permission_error'); assert.equal(event.message, `${observedMessage} (code=permission_error, status=403)`); assert.equal(event.boundedProviderMessage, true); + + const rawEvent = adapter.makeErrorEvent('turn-1', error); + assert.equal(rawEvent.message, `${observedMessage} (code=permission_error, status=403)`); + assert.equal(rawEvent.boundedProviderMessage, true); }); test('normalizes cache and reasoning usage variants in the adapter module', () => { diff --git a/packages/runtime/src/__tests__/provider-conformance.test.ts b/packages/runtime/src/__tests__/provider-conformance.test.ts index a53e13540f..d96c9c68ba 100644 --- a/packages/runtime/src/__tests__/provider-conformance.test.ts +++ b/packages/runtime/src/__tests__/provider-conformance.test.ts @@ -673,6 +673,8 @@ describe('models.dev provider conformance', () => { assert.equal(result.ok, false); assert.equal(result.statusCode, 401); assert.equal(result.errorClass, 'auth'); + assert.equal(result.errorMessage, undefined); + assert.equal(result.providerFailure?.boundedProviderMessage, undefined); }); test('OpenAI routes gpt-5* through the Responses wire and other models through Chat Completions by declaration', async () => { diff --git a/packages/runtime/src/__tests__/provider-error-classification.test.ts b/packages/runtime/src/__tests__/provider-error-classification.test.ts index c0be8d2e00..72cec528a9 100644 --- a/packages/runtime/src/__tests__/provider-error-classification.test.ts +++ b/packages/runtime/src/__tests__/provider-error-classification.test.ts @@ -678,6 +678,15 @@ describe('Provider error classification', () => { }); }); + test('does not mark a metadata-only fallback as provider wording', () => { + assert.deepEqual(providerFailureResult({ statusCode: 403 }), { + errorClass: 'RequestRejected', + httpStatus: 403, + retryable: false, + message: 'Provider request failed (status=403)', + }); + }); + test('maps provider classes to stable user-safe presentations', () => { assert.deepEqual(errorPresentationFromClass('ProviderBilling'), { reason: 'provider_billing', diff --git a/packages/runtime/src/model-adapter.ts b/packages/runtime/src/model-adapter.ts index 87e99a1054..f4fe40ad26 100644 --- a/packages/runtime/src/model-adapter.ts +++ b/packages/runtime/src/model-adapter.ts @@ -431,7 +431,7 @@ export class ModelAdapter { } makeErrorEvent(turnId: string, err: unknown): ErrorEvent { - const failure = normalizeModelFailure(err); + const failure = normalizeProviderFailure(err); return { type: 'error', id: this.input.newId(), @@ -898,11 +898,12 @@ function normalizeModelFailure(error: unknown): ModelFailure { function normalizeProviderFailure(error: unknown): ModelFailure { if (isModelFailure(error)) return error; const result = providerFailureResult(error); - const presentation = errorPresentationFromClass(result.errorClass); + const errorClass = result.errorClass === 'Other' ? classifyError(error) : result.errorClass; + const presentation = errorPresentationFromClass(errorClass); // The bounded summary is display-safe provider wording; a generalized // presentation message is not. The marker must follow the message, not the // presence of a code (Error.code and provider codes both exist here). - const kind = modelFailureKind(result.errorClass); + const kind = modelFailureKind(errorClass); const boundedProviderMessage = kind === 'unknown' && result.boundedProviderMessage === true; return { type: 'model_failure', diff --git a/packages/runtime/src/provider-error-classification.ts b/packages/runtime/src/provider-error-classification.ts index ac507716cc..050d8619cb 100644 --- a/packages/runtime/src/provider-error-classification.ts +++ b/packages/runtime/src/provider-error-classification.ts @@ -143,6 +143,10 @@ interface ProviderFailureSummary { code?: string; } +interface ProviderFailureSummaryEvidence extends ProviderFailureSummary { + boundedProviderMessage: boolean; +} + const PROVIDER_FAILURE_SUMMARY_MAX_BYTES = 2 * 1024; const PROVIDER_FAILURE_FIELD_MAX_BYTES = 256; @@ -365,12 +369,17 @@ function normalizeProviderError(error: unknown): ProviderErrorFacts | undefined export function providerFailureSummary(error: unknown): ProviderFailureSummary | undefined { const facts = normalizeProviderError(error); if (!facts) return undefined; - return providerFailureSummaryFromFacts(facts); + const summary = providerFailureSummaryFromFacts(facts); + if (!summary) return undefined; + return { + message: summary.message, + ...(summary.code !== undefined ? { code: summary.code } : {}), + }; } function providerFailureSummaryFromFacts( facts: ProviderErrorFacts, -): ProviderFailureSummary | undefined { +): ProviderFailureSummaryEvidence | undefined { const sources = facts.summarySources; const message = firstProviderMessage(facts); const code = firstProviderField(sources, ['code']) ?? firstProviderField(sources, ['type']); @@ -397,6 +406,7 @@ function providerFailureSummaryFromFacts( return { message: truncateUtf8(summary, PROVIDER_FAILURE_SUMMARY_MAX_BYTES, '…'), ...(code || statusCode ? { code: code ?? statusCode } : {}), + boundedProviderMessage: message !== undefined && hasProviderMessageSource(facts), }; } @@ -434,7 +444,15 @@ export function providerFailureResult(error: unknown): ProviderFailureResult { ? numericStatus : undefined; const classified = classifyProviderFacts(facts); - const errorClass = normalizedProviderFailureClass(classified, httpStatus); + const errorClass = normalizedProviderFailureClass( + classified === 'Auth' && + httpStatus !== undefined && + httpStatus !== 401 && + !facts.evidence.structuredCodes.some((value) => PROVIDER_AUTH_CODES.has(value)) + ? 'Other' + : classified, + httpStatus, + ); const providerCode = firstProviderField(sources, ['code']) ?? firstProviderField(sources, ['type']); const providerRequestId = @@ -450,7 +468,12 @@ export function providerFailureResult(error: unknown): ProviderFailureResult { retryable: retry.retryable, ...(retry.retryAfterMs !== undefined ? { retryAfterMs: retry.retryAfterMs } : {}), ...(summary !== undefined - ? { message: summary.message, boundedProviderMessage: true as const } + ? { + message: summary.message, + ...(summary.boundedProviderMessage === true + ? { boundedProviderMessage: true as const } + : {}), + } : {}), }; } @@ -545,6 +568,21 @@ function firstProviderMessage(facts: ProviderErrorFacts): string | undefined { .find((value) => value !== undefined); } +function hasProviderMessageSource(facts: ProviderErrorFacts): boolean { + if (!(facts.target instanceof Error)) return true; + const targetRecord = objectRecord(facts.target); + return ( + facts.summarySources.records.some( + (source) => + source !== targetRecord && + boundedProviderMessage(safeField(source, 'message')) !== undefined, + ) || + facts.summarySources.stringErrors.some( + (candidate) => boundedProviderMessage(candidate) !== undefined, + ) + ); +} + function firstProviderField( sources: ProviderFailureSources, keys: readonly string[], diff --git a/packages/runtime/src/test-connection.ts b/packages/runtime/src/test-connection.ts index 08e2b8934c..3543240d6a 100644 --- a/packages/runtime/src/test-connection.ts +++ b/packages/runtime/src/test-connection.ts @@ -458,7 +458,9 @@ async function httpFailure(r: ConnectionEffectResponse, t0: number): Promise Date: Tue, 18 Aug 2026 20:20:26 +0800 Subject: [PATCH 08/20] test(runtime): align provider failure assertions Generated-by: Maka --- .../src/__tests__/model-adapter-onerror.test.ts | 1 - .../__tests__/scoped-fetch-transport.test.ts | 14 ++++++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/runtime/src/__tests__/model-adapter-onerror.test.ts b/packages/runtime/src/__tests__/model-adapter-onerror.test.ts index 0b2dd3de7c..91efb6579f 100644 --- a/packages/runtime/src/__tests__/model-adapter-onerror.test.ts +++ b/packages/runtime/src/__tests__/model-adapter-onerror.test.ts @@ -99,7 +99,6 @@ describe('ModelAdapter.startStream onError', () => { { type: 'model_failure', kind: 'rate_limit', - code: '429', message: 'Rate limit exceeded', retryable: true, retryAfterMs: 2500, diff --git a/packages/runtime/src/network/__tests__/scoped-fetch-transport.test.ts b/packages/runtime/src/network/__tests__/scoped-fetch-transport.test.ts index 201705ab6a..0f3c1c337a 100644 --- a/packages/runtime/src/network/__tests__/scoped-fetch-transport.test.ts +++ b/packages/runtime/src/network/__tests__/scoped-fetch-transport.test.ts @@ -379,7 +379,16 @@ describe('connection effect network transport', () => { assert.equal(outcome.ok, false); if (outcome.ok) return; - assert.deepEqual(outcome.error, { kind: 'auth', statusCode: 401 }); + assert.deepEqual(outcome.error, { + kind: 'auth', + statusCode: 401, + providerFailure: { + errorClass: 'Auth', + httpStatus: 401, + retryable: false, + message: 'Provider request failed (status=401)', + }, + }); assert.equal(outcome.modelId, undefined); assert.equal(typeof outcome.latencyMs, 'number'); assert.deepEqual(Object.keys(outcome).sort(), ['error', 'latencyMs', 'ok']); @@ -388,7 +397,8 @@ describe('connection effect network transport', () => { const legacy = await testConnection(connection, 'provider-key', undefined, { fetch: transport.fetch, }); - assert.match(legacy.errorMessage ?? '', /raw-provider-auth-detail/); + assert.equal(legacy.errorMessage, undefined); + assert.equal(legacy.providerFailure?.boundedProviderMessage, undefined); } finally { await transport.close(); await closeServer(server); From 473da2e6c59480bb1e4b688ef859a55130b13285 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Tue, 18 Aug 2026 21:31:21 +0800 Subject: [PATCH 09/20] fix(runtime): preserve structured cause semantics Generated-by: Maka --- .../provider-failure-presentation.test.ts | 15 ++++ .../src/renderer/model-connection-errors.ts | 6 +- .../cli/src/__tests__/pi-transcript.test.ts | 40 +++++++++ packages/cli/src/pi-transcript.ts | 45 +++++++++- .../provider-error-classification.test.ts | 23 +++++ .../src/provider-error-classification.ts | 84 ++++++++++++++----- 6 files changed, 184 insertions(+), 29 deletions(-) diff --git a/apps/desktop/src/main/__tests__/provider-failure-presentation.test.ts b/apps/desktop/src/main/__tests__/provider-failure-presentation.test.ts index 983fdcaef3..bd2bce52d6 100644 --- a/apps/desktop/src/main/__tests__/provider-failure-presentation.test.ts +++ b/apps/desktop/src/main/__tests__/provider-failure-presentation.test.ts @@ -60,6 +60,21 @@ describe('provider failure presentation', () => { assert.equal(sessionEventErrorMessage(event, 'en'), message); }); + test('preserves a bounded provider summary without a provider code', () => { + const event: Extract = { + type: 'error', + id: 'event-provider-summary', + turnId: 'turn-provider-summary', + ts: 1, + recoverable: false, + boundedProviderMessage: true, + message: 'Provider request failed safely.', + }; + + assert.equal(sessionEventErrorMessage(event), event.message); + assert.equal(sessionEventErrorMessage(event, 'en'), event.message); + }); + test('does not render a coded message verbatim without the bounded-provider marker', () => { const event: Extract = { type: 'error', diff --git a/apps/desktop/src/renderer/model-connection-errors.ts b/apps/desktop/src/renderer/model-connection-errors.ts index 79b607c18b..1be9fc1e6c 100644 --- a/apps/desktop/src/renderer/model-connection-errors.ts +++ b/apps/desktop/src/renderer/model-connection-errors.ts @@ -68,11 +68,7 @@ export function sessionEventErrorMessage( // status fragments in the presentation layer. The bounded marker is the only // proof that `message` is safe to render verbatim: a code alone can be a Node // transport code whose message is unbounded internal text. - if ( - event.boundedProviderMessage === true && - event.code !== undefined && - event.message.length > 0 - ) { + if (event.boundedProviderMessage === true && event.message.length > 0) { return event.message; } diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index 13195f7b9b..282ade550e 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -271,6 +271,46 @@ describe('Maka Pi TUI transcript', () => { ); }); + test('renders a Runtime-owned reason before unmarked provider text', () => { + const state = createMakaPiTranscriptState(); + + applyMakaSessionEventToTranscript( + state, + event({ + type: 'error', + recoverable: false, + reason: 'context_overflow', + message: 'unbounded provider response', + }), + ); + + assert.deepEqual(state.entries.at(-1), { + kind: 'notice', + level: 'error', + text: 'Context window exceeded', + }); + }); + + test('renders a marked provider summary without inventing a code requirement', () => { + const state = createMakaPiTranscriptState(); + + applyMakaSessionEventToTranscript( + state, + event({ + type: 'error', + recoverable: false, + message: 'Provider request failed safely.', + boundedProviderMessage: true, + }), + ); + + assert.deepEqual(state.entries.at(-1), { + kind: 'notice', + level: 'error', + text: 'Provider request failed safely.', + }); + }); + test('hides an unmarked Runtime error behind the safe fallback', () => { const state = createMakaPiTranscriptState(); diff --git a/packages/cli/src/pi-transcript.ts b/packages/cli/src/pi-transcript.ts index 421a96186f..a14f9bca57 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -742,10 +742,7 @@ export function applyMakaSessionEventToTranscript( state.entries.push({ kind: 'notice', level: 'error', - text: - event.boundedProviderMessage === true - ? event.message - : 'The task run failed. Try again later.', + text: transcriptErrorMessage(event), }); break; @@ -777,6 +774,46 @@ export function applyMakaSessionEventToTranscript( } } +function chatItemToTranscriptEntries(item: ChatItem): MakaPiTranscriptEntry[] { + switch (item.kind) { + case 'user': + return [ + { + kind: + item.message.origin?.kind === 'legacy_automation' + ? 'legacy_automation' + : item.message.origin?.kind === 'goal' + ? 'goal_continuation' + : 'user', + text: item.message.displayText ?? item.message.text, + }, + ]; + case 'assistant': { + const entries: MakaPiTranscriptEntry[] = []; + // Stored thinking happened before the reply text, so it resumes above it. + const thinking = item.message.thinking?.text; + if (thinking?.trim()) { + // Replay resets the expansion defaults to collapsed, so replayed + // entries start collapsed too. + entries.push({ + kind: 'thinking', + messageId: item.message.id, + text: thinking, + expanded: false, + }); + } + entries.push({ kind: 'assistant', messageId: item.message.id, text: item.message.text }); + return entries; + } + case 'tool': + return [toolActivityToTranscriptEntry(item.item)]; + case 'system_note': { + const entry = systemNoteToTranscriptEntry(item.message); + return entry ? [entry] : []; + } + } +} + function storedMessagesToTranscriptEntries( messages: readonly StoredMessage[], ): MakaPiTranscriptEntry[] { diff --git a/packages/runtime/src/__tests__/provider-error-classification.test.ts b/packages/runtime/src/__tests__/provider-error-classification.test.ts index 72cec528a9..01a5866340 100644 --- a/packages/runtime/src/__tests__/provider-error-classification.test.ts +++ b/packages/runtime/src/__tests__/provider-error-classification.test.ts @@ -151,6 +151,29 @@ describe('Provider error classification', () => { assert.equal(diagnostic.retryable, false); }); + test('ranks structured cause semantics above an outer HTTP status', () => { + const wrapped = (cause: unknown) => + Object.assign(new Error('transport wrapper rejected the request'), { + status: 403, + code: 'FETCH_FAILED', + cause, + }); + const cases: Array<[Record, string, string]> = [ + [{ error: { code: 'usage_limit_reached' } }, 'UsageLimit', 'usage_limit_reached'], + [{ error: { type: 'permission_denied' } }, 'ProviderPermission', 'permission_denied'], + [{ error: { code: 'context_length_exceeded' } }, 'ContextLength', 'context_length_exceeded'], + ]; + + for (const [cause, expectedClass, expectedCode] of cases) { + const result = providerFailureResult(wrapped(cause)); + assert.equal(result.errorClass, expectedClass); + assert.equal(result.httpStatus, 403); + assert.equal(result.providerCode, expectedCode); + assert.equal(result.retryable, false); + assert.deepEqual(providerRetryMetadata(wrapped(cause)), { retryable: false }); + } + }); + test('durable diagnostics distinguish the provider failure classes used by fail-open handling', () => { const cases: Array<[unknown, string]> = [ [Object.assign(new Error('bad request'), { statusCode: 400 }), 'RequestRejected'], diff --git a/packages/runtime/src/provider-error-classification.ts b/packages/runtime/src/provider-error-classification.ts index 050d8619cb..740560b088 100644 --- a/packages/runtime/src/provider-error-classification.ts +++ b/packages/runtime/src/provider-error-classification.ts @@ -123,6 +123,8 @@ interface ProviderErrorFacts { target: unknown; evidence: ProviderErrorEvidence; summarySources: ProviderFailureSources; + messageSources?: ProviderFailureSources; + boundedProviderMessageSource?: boolean; bareMessage?: string; responseHeaders?: Record; } @@ -195,7 +197,7 @@ function parseRetryAfterMs(headers: Record): number | null | und * response headers across the ModelAdapter boundary. */ export function providerRetryMetadata(error: unknown): ProviderRetryMetadata { - const facts = normalizeProviderError(error); + const facts = providerFailureDiagnosticFacts(error); if (!facts) return { retryable: false }; return providerRetryMetadataFromFacts(facts); } @@ -367,7 +369,7 @@ function normalizeProviderError(error: unknown): ProviderErrorFacts | undefined * or serialized as diagnostic output wholesale. */ export function providerFailureSummary(error: unknown): ProviderFailureSummary | undefined { - const facts = normalizeProviderError(error); + const facts = providerFailureDiagnosticFacts(error); if (!facts) return undefined; const summary = providerFailureSummaryFromFacts(facts); if (!summary) return undefined; @@ -382,7 +384,7 @@ function providerFailureSummaryFromFacts( ): ProviderFailureSummaryEvidence | undefined { const sources = facts.summarySources; const message = firstProviderMessage(facts); - const code = firstProviderField(sources, ['code']) ?? firstProviderField(sources, ['type']); + const code = strongestProviderCode(facts); const statusCode = firstProviderField(sources, ['statusCode', 'status']); const requestId = firstProviderField(sources, ['requestId', 'request_id']) ?? @@ -453,8 +455,7 @@ export function providerFailureResult(error: unknown): ProviderFailureResult { : classified, httpStatus, ); - const providerCode = - firstProviderField(sources, ['code']) ?? firstProviderField(sources, ['type']); + const providerCode = strongestProviderCode(facts); const providerRequestId = firstProviderField(sources, ['requestId', 'request_id']) ?? boundedProviderField(facts.responseHeaders?.['x-request-id']); @@ -478,6 +479,24 @@ export function providerFailureResult(error: unknown): ProviderFailureResult { }; } +function strongestProviderCode(facts: ProviderErrorFacts): string | undefined { + const semantic = facts.evidence.structuredCodes.find( + (value) => + PROVIDER_AUTH_CODES.has(value) || + PROVIDER_BILLING_CODES.has(value) || + PROVIDER_PERMISSION_CODES.has(value) || + PROVIDER_USAGE_LIMIT_CODES.has(value) || + PROVIDER_RATE_LIMIT_CODES.has(value) || + PROVIDER_UNAVAILABLE_CODES.has(value) || + CONTEXT_OVERFLOW_PROVIDER_CODES.has(value), + ); + return ( + boundedProviderField(semantic) ?? + firstProviderField(facts.summarySources, ['code']) ?? + firstProviderField(facts.summarySources, ['type']) + ); +} + function normalizedProviderFailureClass( classified: string, httpStatus: number | undefined, @@ -503,28 +522,50 @@ function normalizedProviderFailureClass( function providerFailureDiagnosticFacts(error: unknown): ProviderErrorFacts | undefined { let current = providerErrorTarget(error); - let fallback: ProviderErrorFacts | undefined; - let structuredFallback: ProviderErrorFacts | undefined; - let codedFallback: ProviderErrorFacts | undefined; + const chain: ProviderErrorFacts[] = []; const seen = new Set(); for (let depth = 0; depth < 4 && current !== undefined && !seen.has(current); depth += 1) { seen.add(current); const facts = normalizeProviderError(current); - fallback ??= facts; - // HTTP status is the strongest durable evidence: an SDK wrapper can carry - // its own transport `code` (for example `FETCH_FAILED`) while the real - // provider status sits on the wrapped cause. Prefer the status-bearing - // fact, and only fall back to wrapper-level structured codes when no fact - // in the chain carries one. - if (facts?.evidence.statusCode) return facts; - structuredFallback ??= facts && facts.evidence.structuredCodes.length > 0 ? facts : undefined; - if (facts?.evidence.code) codedFallback ??= facts; + if (facts) chain.push(facts); current = current && typeof current === 'object' ? safeField(current as Record, 'cause') : undefined; } - return structuredFallback ?? codedFallback ?? fallback; + if (chain.length === 0) return undefined; + + // Provider semantics can sit below an SDK/transport wrapper that also owns + // the HTTP status. Aggregate the bounded cause chain instead of letting the + // first status-bearing object discard stronger structured codes. Provider + // sources are ordered inner-first for message/code projection; transport + // status and retry hints remain available as fallback evidence. + const providerFirst = [...chain].reverse(); + const messageFacts = providerFirst.filter((facts) => hasProviderMessageSource(facts)); + const responseHeaders = Object.assign({}, ...chain.map((facts) => facts.responseHeaders ?? {})) as + | Record + | undefined; + const bareMessage = messageFacts.find((facts) => facts.bareMessage)?.bareMessage; + return { + target: chain[0]!.target, + evidence: { + text: chain.map((facts) => facts.evidence.text).join(' '), + statusCode: chain.find((facts) => facts.evidence.statusCode)?.evidence.statusCode ?? '', + code: chain.find((facts) => facts.evidence.code)?.evidence.code ?? '', + structuredCodes: [...new Set(chain.flatMap((facts) => facts.evidence.structuredCodes))], + }, + summarySources: { + records: providerFirst.flatMap((facts) => facts.summarySources.records), + stringErrors: providerFirst.flatMap((facts) => facts.summarySources.stringErrors), + }, + messageSources: { + records: messageFacts.flatMap((facts) => facts.summarySources.records), + stringErrors: messageFacts.flatMap((facts) => facts.summarySources.stringErrors), + }, + boundedProviderMessageSource: messageFacts.length > 0, + ...(bareMessage ? { bareMessage } : {}), + ...(responseHeaders && Object.keys(responseHeaders).length > 0 ? { responseHeaders } : {}), + }; } interface ProviderFailureSources { @@ -558,7 +599,7 @@ function providerRecord(value: unknown): Record | undefined { function firstProviderMessage(facts: ProviderErrorFacts): string | undefined { if (facts.bareMessage !== undefined) return boundedProviderMessage(facts.bareMessage); - const sources = facts.summarySources; + const sources = facts.messageSources ?? facts.summarySources; const candidates = [ ...sources.records.map((source) => safeField(source, 'message')), ...sources.stringErrors, @@ -569,6 +610,9 @@ function firstProviderMessage(facts: ProviderErrorFacts): string | undefined { } function hasProviderMessageSource(facts: ProviderErrorFacts): boolean { + if (facts.boundedProviderMessageSource !== undefined) { + return facts.boundedProviderMessageSource; + } if (!(facts.target instanceof Error)) return true; const targetRecord = objectRecord(facts.target); return ( @@ -762,7 +806,7 @@ export function isContextOverflowErrorText(text: string): boolean { */ export function classifyError(error: unknown): string { if (RetryError.isInstance(error) && error.reason === 'abort') return 'Abort'; - const facts = normalizeProviderError(error); + const facts = providerFailureDiagnosticFacts(error); return facts ? classifyProviderFacts(facts) : 'Other'; } From c222c93ef7db64012b2a1de0a56e724617590270 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Tue, 18 Aug 2026 23:53:11 +0800 Subject: [PATCH 10/20] fix(cli): localize runtime error notices Generated-by: Maka --- .../cli/src/__tests__/pi-transcript.test.ts | 18 +++- packages/cli/src/pi-transcript.ts | 82 ++++++++++++++++--- 2 files changed, 86 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index 282ade550e..b4314ca0f1 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -264,6 +264,7 @@ describe('Maka Pi TUI transcript', () => { kind: 'notice', level: 'error', text: message, + runtimeError: {}, }); assert.match( renderMakaPiTranscript(state, meta(), 100).map(stripAnsi).join('\n'), @@ -287,8 +288,14 @@ describe('Maka Pi TUI transcript', () => { assert.deepEqual(state.entries.at(-1), { kind: 'notice', level: 'error', - text: 'Context window exceeded', + text: '', + runtimeError: { reason: 'context_overflow' }, }); + const chinese = renderMakaPiTranscript(state, { ...meta(), uiLocale: 'zh' }, 100) + .map(stripAnsi) + .join('\n'); + assert.match(chinese, /上下文窗口已超出限制/); + assert.doesNotMatch(chinese, /Context window exceeded/); }); test('renders a marked provider summary without inventing a code requirement', () => { @@ -308,6 +315,7 @@ describe('Maka Pi TUI transcript', () => { kind: 'notice', level: 'error', text: 'Provider request failed safely.', + runtimeError: {}, }); }); @@ -327,8 +335,14 @@ describe('Maka Pi TUI transcript', () => { assert.deepEqual(state.entries.at(-1), { kind: 'notice', level: 'error', - text: 'The task run failed. Try again later.', + text: '', + runtimeError: {}, }); + const chinese = renderMakaPiTranscript(state, { ...meta(), uiLocale: 'zh' }, 100) + .map(stripAnsi) + .join('\n'); + assert.match(chinese, /任务运行失败,请稍后重试/); + assert.doesNotMatch(chinese, /The task run failed/); }); test('keeps assistant text after a tool call visible after the tool block', () => { diff --git a/packages/cli/src/pi-transcript.ts b/packages/cli/src/pi-transcript.ts index a14f9bca57..33acc432eb 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -178,7 +178,14 @@ export type MakaPiTranscriptEntry = /** An internal shell-run poll retained for correlation but not displayed. */ suppressed?: boolean; } - | { kind: 'notice'; level: 'info' | 'error'; text: string }; + | { + kind: 'notice'; + level: 'info' | 'error'; + /** Already-safe display text. Runtime errors leave this empty unless the Host bounded it. */ + text: string; + /** Stable Runtime reason retained until the locale-aware render boundary. */ + runtimeError?: { reason?: string }; + }; export interface MakaPiTranscriptMetadata { title: string; @@ -742,7 +749,9 @@ export function applyMakaSessionEventToTranscript( state.entries.push({ kind: 'notice', level: 'error', - text: transcriptErrorMessage(event), + text: + event.boundedProviderMessage === true && event.message.length > 0 ? event.message : '', + runtimeError: event.reason ? { reason: event.reason.toLowerCase() } : {}, }); break; @@ -1162,8 +1171,7 @@ export function renderMakaPiTranscript( lines.length < viewportTop && (entryHeight === 0 || lines.length + entryHeight <= viewportTop); lines.push(...renderTranscriptEntryMemoized(entry, safeWidth, fullyOffScreen)); - previousVisibleEntry = entry; - } + previousVisibleEntry = entry; } state.renderGeometry.entryFirstLine = entryFirstLine; if (state.pendingInteraction?.type === 'sandbox_boundary_request') { @@ -1255,6 +1263,7 @@ function renderTranscriptEntryMemoized( entry: MakaPiTranscriptEntry, width: number, offScreen: boolean, + locale: UiLocale, ): string[] { // Off-screen entries live in terminal scrollback, which is immutable: any // change to their rendered lines forces pi-tui's differential renderer into a @@ -1267,15 +1276,19 @@ function renderTranscriptEntryMemoized( const cached = transcriptEntryRenderCache.get(entry); if (cached && cached.width === width) return cached.lines; } - const signature = transcriptEntrySignature(entry, width); + const signature = transcriptEntrySignature(entry, width, locale); const cached = transcriptEntryRenderCache.get(entry); if (cached && cached.signature === signature) return cached.lines; - const lines = renderTranscriptEntryBlock(entry, width); + const lines = renderTranscriptEntryBlock(entry, width, locale); transcriptEntryRenderCache.set(entry, { signature, lines, width }); return lines; } -function renderTranscriptEntryBlock(entry: MakaPiTranscriptEntry, width: number): string[] { +function renderTranscriptEntryBlock( + entry: MakaPiTranscriptEntry, + width: number, + locale: UiLocale, +): string[] { // Keep the conversation stream inside a one-cell gutter. The editor owns // the full terminal width, so this makes the two surfaces align without // changing any of the individual block renderers' internal prefixes. @@ -1295,7 +1308,11 @@ function renderTranscriptEntryBlock(entry: MakaPiTranscriptEntry, width: number) case 'tool': return renderToolBlock(entry, contentWidth, entry.expanded); case 'notice': - return renderNotice(entry, contentWidth); + return renderNotice( + entry, + contentWidth, + entry.runtimeError ? transcriptErrorMessage(entry, locale) : entry.text, + ); } })(); @@ -1314,7 +1331,11 @@ function isBlankTranscriptLine(line: string): boolean { return line.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '').trim().length === 0; } -function transcriptEntrySignature(entry: MakaPiTranscriptEntry, width: number): string { +function transcriptEntrySignature( + entry: MakaPiTranscriptEntry, + width: number, + locale: UiLocale, +): string { switch (entry.kind) { // User text is immutable, so length is a safe change key. case 'user': @@ -1333,7 +1354,9 @@ function transcriptEntrySignature(entry: MakaPiTranscriptEntry, width: number): // then serve stale reasoning from the cache. Key on the full text. return `thinking|${width}|${entry.expanded ? 1 : 0}|${entry.text}`; case 'notice': - return `notice|${width}|${entry.level}|${entry.text.length}`; + return `notice|${width}|${entry.level}|${ + entry.runtimeError ? transcriptErrorMessage(entry, locale) : entry.text + }`; case 'tool': // A tool entry mutates in place as it runs: its derived presentation and // duration change, progress/output deltas append, and resultVersion @@ -1798,9 +1821,44 @@ function renderAssistantBlock(text: string, width: number): string[] { .map((line) => fitLine(line, width)); } -function renderNotice(entry: MakaPiNoticeEntry, width: number): string[] { +function transcriptErrorMessage(entry: MakaPiNoticeEntry, locale: UiLocale): string { + const copy = + locale === 'zh' + ? { + context_overflow: '上下文窗口已超出限制', + timeout: '请求超时', + auth: '鉴权失败', + provider_billing: '模型服务计费受限', + provider_permission: '模型服务拒绝访问', + provider_unavailable: '模型服务返回错误', + rate_limit: '触发模型速率限制', + usage_limit: '模型使用额度已用完', + network: '网络错误', + fallback: '任务运行失败,请稍后重试。', + } + : { + context_overflow: 'Context window exceeded', + timeout: 'Request timed out', + auth: 'Authentication failed', + provider_billing: 'Provider billing required', + provider_permission: 'Provider access denied', + provider_unavailable: 'Provider returned an error', + rate_limit: 'Rate limit exceeded', + usage_limit: 'Usage limit reached', + network: 'Network error', + fallback: 'The task run failed. Try again later.', + }; + const reason = entry.runtimeError?.reason; + if (reason && reason in copy && reason !== 'fallback') { + return copy[reason as Exclude]; + } + if (entry.text.length > 0) return entry.text; + return copy.fallback; +} + +function renderNotice(entry: MakaPiNoticeEntry, width: number, text: string): string[] { const label = entry.level === 'error' ? ansi.red('Error') : ansi.dim('Note'); - return renderIndented(`${label}: ${entry.text}`, width, 0).map((line) => fitLine(line, width)); + return renderIndented(`${label}: ${text}`, width, 0).map((line) => fitLine(line, width)); } // Shown on a fresh, empty session. Greets with the branded maka wordmark and a From c45aef2bc5835eab0c8457bc3bb42b53f0ae4b79 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Wed, 19 Aug 2026 08:46:21 +0800 Subject: [PATCH 11/20] fix(runtime): preserve structured provider failures Generated-by: Maka --- .../provider-failure-presentation.test.ts | 20 +++++++++++++++++++ apps/desktop/src/renderer/app-shell-copy.ts | 3 +++ .../renderer/session-error-presentation.ts | 17 ++++++++++++++++ .../settings/provider-panel-shared.ts | 3 +++ .../__tests__/handshake-compatibility.test.ts | 4 ++-- .../__tests__/provider-conformance.test.ts | 1 + .../provider-error-classification.test.ts | 6 +++++- .../__tests__/scoped-fetch-transport.test.ts | 1 - .../src/provider-error-classification.ts | 7 +++---- 9 files changed, 54 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/main/__tests__/provider-failure-presentation.test.ts b/apps/desktop/src/main/__tests__/provider-failure-presentation.test.ts index bd2bce52d6..f814e7921a 100644 --- a/apps/desktop/src/main/__tests__/provider-failure-presentation.test.ts +++ b/apps/desktop/src/main/__tests__/provider-failure-presentation.test.ts @@ -147,4 +147,24 @@ describe('provider failure presentation', () => { ); assert.equal(commandPaletteConnectionTestFailureMessage(result, 'en'), message); }); + + test('preserves structured account meaning without provider message text', () => { + const result = { + ok: false, + statusCode: 429, + errorClass: 'provider_unavailable' as const, + providerFailure: { + errorClass: 'UsageLimit' as const, + httpStatus: 429, + providerCode: 'usage_limit_reached', + retryable: false, + }, + }; + + assert.equal( + connectionTestFailureMessage(result, { auth: 'AUTH', recheck: 'RECHECK' }, 'en'), + 'Model usage limit reached', + ); + assert.equal(commandPaletteConnectionTestFailureMessage(result, 'en'), 'Model usage limit reached'); + }); }); diff --git a/apps/desktop/src/renderer/app-shell-copy.ts b/apps/desktop/src/renderer/app-shell-copy.ts index c31e974cf9..4d97f7ea5a 100644 --- a/apps/desktop/src/renderer/app-shell-copy.ts +++ b/apps/desktop/src/renderer/app-shell-copy.ts @@ -22,6 +22,7 @@ import type { TextFileImportPreflightFailureReason } from '@maka/core/text-file- import type { UiLocale } from '@maka/core/ui-locale'; import { generalizedErrorMessage, generalizedErrorMessageChinese } from '@maka/core/redaction'; import { getShellCopy } from './locales/shell-copy.js'; +import { describeProviderAccountFailure } from './session-error-presentation.js'; const SESSION_READ_MESSAGES_ERROR_MARKER = 'MAKA_SESSION_READ_MESSAGES_ERROR:'; @@ -67,6 +68,8 @@ export function commandPaletteConnectionTestFailureMessage(result: ConnectionTes } function commandPaletteConnectionTestFailureFallback(result: ConnectionTestResult, locale: UiLocale): string { + const accountFailure = describeProviderAccountFailure(result.providerFailure?.errorClass, locale); + if (accountFailure) return accountFailure; const copy = getShellCopy(locale).commandActions.connectionFailures; switch (result.providerFailure?.errorClass) { case 'Auth': diff --git a/apps/desktop/src/renderer/session-error-presentation.ts b/apps/desktop/src/renderer/session-error-presentation.ts index ecc2500f21..8e4306738a 100644 --- a/apps/desktop/src/renderer/session-error-presentation.ts +++ b/apps/desktop/src/renderer/session-error-presentation.ts @@ -52,3 +52,20 @@ export function describeSessionErrorReason(reason: string | undefined, locale: U return undefined; } } + +/** Shared safe copy for structured provider account failures without displayable provider text. */ +export function describeProviderAccountFailure( + errorClass: string | undefined, + locale: UiLocale = 'zh', +): string | undefined { + switch (errorClass) { + case 'ProviderBilling': + return describeSessionErrorReason('provider_billing', locale); + case 'ProviderPermission': + return describeSessionErrorReason('provider_permission', locale); + case 'UsageLimit': + return describeSessionErrorReason('usage_limit', locale); + default: + return undefined; + } +} diff --git a/apps/desktop/src/renderer/settings/provider-panel-shared.ts b/apps/desktop/src/renderer/settings/provider-panel-shared.ts index 35b4c7bf47..d68a56a4dd 100644 --- a/apps/desktop/src/renderer/settings/provider-panel-shared.ts +++ b/apps/desktop/src/renderer/settings/provider-panel-shared.ts @@ -32,6 +32,7 @@ import { import { type UiLocale } from '@maka/core/ui-locale'; import { getProviderSettingsCopy } from '../locales/settings-provider-copy.js'; import { cleanErrorMessage } from '../model-connection-errors.js'; +import { describeProviderAccountFailure } from '../session-error-presentation.js'; import type { DesktopConnectionSnapshot } from '../../shared/desktop-connection-snapshot.js'; export interface ConnectionsBridge { @@ -91,6 +92,8 @@ export function connectionTestFailureFallback( copy: ConnectionTestTroubleshootingCopy, locale: UiLocale = 'zh', ): string { + const accountFailure = describeProviderAccountFailure(result.providerFailure?.errorClass, locale); + if (accountFailure) return accountFailure; const shared = getProviderSettingsCopy(locale).shared; switch (result.providerFailure?.errorClass) { case 'Auth': diff --git a/packages/runtime-host/src/__tests__/handshake-compatibility.test.ts b/packages/runtime-host/src/__tests__/handshake-compatibility.test.ts index 69e9dddd9d..f07a7014ec 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-39 Host before any domain command', async () => { +test('rejects the previous compatibility epoch before any domain command', async () => { let admittedRequest: RequestFrame | undefined; await withForgedHandshakePeer( async (transport, hostEpoch, rootId) => { @@ -120,7 +120,7 @@ test('rejects an epoch-39 Host before any domain command', async () => { hostEpoch, connectionId: 'forged-epoch-connection', selectedProtocol: RUNTIME_HOST_PROTOCOL_VERSION, - compatibilityEpoch: 39, + compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH - 1, compositionId: 'maka.interactive', compositionRevision: '1', state: 'ready', diff --git a/packages/runtime/src/__tests__/provider-conformance.test.ts b/packages/runtime/src/__tests__/provider-conformance.test.ts index d96c9c68ba..a802d1f0fb 100644 --- a/packages/runtime/src/__tests__/provider-conformance.test.ts +++ b/packages/runtime/src/__tests__/provider-conformance.test.ts @@ -674,6 +674,7 @@ describe('models.dev provider conformance', () => { assert.equal(result.statusCode, 401); assert.equal(result.errorClass, 'auth'); assert.equal(result.errorMessage, undefined); + assert.equal(result.providerFailure?.message, undefined); assert.equal(result.providerFailure?.boundedProviderMessage, undefined); }); diff --git a/packages/runtime/src/__tests__/provider-error-classification.test.ts b/packages/runtime/src/__tests__/provider-error-classification.test.ts index 01a5866340..256a0f79dd 100644 --- a/packages/runtime/src/__tests__/provider-error-classification.test.ts +++ b/packages/runtime/src/__tests__/provider-error-classification.test.ts @@ -623,6 +623,11 @@ describe('Provider error classification', () => { assert.equal(classifyError(planUsageLimit), 'UsageLimit'); assert.deepEqual(providerRetryMetadata(planUsageLimit), { retryable: false }); + const abortedUsageLimit = providerError(429, 'The request was aborted at the account limit', { + type: 'usage_limit_reached', + }); + assert.equal(classifyError(abortedUsageLimit), 'UsageLimit'); + const permission = providerError(403, 'This key cannot access the requested model', { type: 'permission_denied', }); @@ -706,7 +711,6 @@ describe('Provider error classification', () => { errorClass: 'RequestRejected', httpStatus: 403, retryable: false, - message: 'Provider request failed (status=403)', }); }); diff --git a/packages/runtime/src/network/__tests__/scoped-fetch-transport.test.ts b/packages/runtime/src/network/__tests__/scoped-fetch-transport.test.ts index 0f3c1c337a..b623baf59a 100644 --- a/packages/runtime/src/network/__tests__/scoped-fetch-transport.test.ts +++ b/packages/runtime/src/network/__tests__/scoped-fetch-transport.test.ts @@ -386,7 +386,6 @@ describe('connection effect network transport', () => { errorClass: 'Auth', httpStatus: 401, retryable: false, - message: 'Provider request failed (status=401)', }, }); assert.equal(outcome.modelId, undefined); diff --git a/packages/runtime/src/provider-error-classification.ts b/packages/runtime/src/provider-error-classification.ts index 740560b088..4d550d8d10 100644 --- a/packages/runtime/src/provider-error-classification.ts +++ b/packages/runtime/src/provider-error-classification.ts @@ -468,12 +468,10 @@ export function providerFailureResult(error: unknown): ProviderFailureResult { ...(providerRequestId !== undefined ? { providerRequestId } : {}), retryable: retry.retryable, ...(retry.retryAfterMs !== undefined ? { retryAfterMs: retry.retryAfterMs } : {}), - ...(summary !== undefined + ...(summary?.boundedProviderMessage === true ? { message: summary.message, - ...(summary.boundedProviderMessage === true - ? { boundedProviderMessage: true as const } - : {}), + boundedProviderMessage: true as const, } : {}), }; @@ -850,6 +848,7 @@ function classifyProviderFacts(facts: ProviderErrorFacts): string { if (structuredCodes.some((value) => PROVIDER_RATE_LIMIT_CODES.has(value))) return 'RateLimit'; if (structuredCodes.some((value) => PROVIDER_UNAVAILABLE_CODES.has(value))) return 'ProviderUnavailable'; + if (text.includes('abort')) return 'Abort'; if (statusCode === '402' || code === '402') return 'ProviderBilling'; if (statusCode === '429' || code === '429') return 'RateLimit'; if (statusCode === '401' || code === '401') return 'Auth'; From fffab8f57d2766367412c549d94a23adee0c76b5 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Wed, 19 Aug 2026 18:31:22 +0800 Subject: [PATCH 12/20] fix(runtime): prioritize structured context overflow Generated-by: Maka --- .../provider-error-classification.test.ts | 17 ++++++++++++ .../src/provider-error-classification.ts | 26 +++++++++---------- 2 files changed, 30 insertions(+), 13 deletions(-) diff --git a/packages/runtime/src/__tests__/provider-error-classification.test.ts b/packages/runtime/src/__tests__/provider-error-classification.test.ts index 256a0f79dd..cdac504e31 100644 --- a/packages/runtime/src/__tests__/provider-error-classification.test.ts +++ b/packages/runtime/src/__tests__/provider-error-classification.test.ts @@ -174,6 +174,23 @@ describe('Provider error classification', () => { } }); + test('ranks structured context overflow above numeric and text fallbacks', () => { + const cases = [ + Object.assign(new Error('provider rejected the request'), { + statusCode: 429, + data: { error: { code: 'context_length_exceeded' } }, + }), + Object.assign(new Error('request aborted because the prompt is too long'), { + data: { error: { code: 'context_length_exceeded' } }, + }), + ]; + + for (const error of cases) { + assert.equal(classifyError(error), 'ContextLength'); + assert.deepEqual(providerRetryMetadata(error), { retryable: false }); + } + }); + test('durable diagnostics distinguish the provider failure classes used by fail-open handling', () => { const cases: Array<[unknown, string]> = [ [Object.assign(new Error('bad request'), { statusCode: 400 }), 'RequestRejected'], diff --git a/packages/runtime/src/provider-error-classification.ts b/packages/runtime/src/provider-error-classification.ts index 4d550d8d10..05ba4018fa 100644 --- a/packages/runtime/src/provider-error-classification.ts +++ b/packages/runtime/src/provider-error-classification.ts @@ -791,16 +791,16 @@ 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 → structured account state → 402 → 429 → 401 (numeric fields, - * never substrings) → the provider's structured capacity and overflow codes → - * bare 413 (HTTP: request entity too large — itself input-side evidence, - * Cerebras sends it with no body) → 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; 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. + * abort wrapper → structured account state (capacity, overflow, auth, + * billing, permission, usage/rate limits) → numeric HTTP fallbacks (402, 429, + * 401; numeric fields, never substrings) → bare 413 + * (HTTP: request entity too large — itself input-side evidence, Cerebras sends + * it with no body) → 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; 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. */ export function classifyError(error: unknown): string { if (RetryError.isInstance(error) && error.reason === 'abort') return 'Abort'; @@ -848,6 +848,9 @@ function classifyProviderFacts(facts: ProviderErrorFacts): string { if (structuredCodes.some((value) => PROVIDER_RATE_LIMIT_CODES.has(value))) return 'RateLimit'; if (structuredCodes.some((value) => PROVIDER_UNAVAILABLE_CODES.has(value))) return 'ProviderUnavailable'; + // The provider's structured context code is unconditional input-overflow + // evidence. It outranks outer transport status and message fallbacks. + 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'; @@ -855,9 +858,6 @@ function classifyProviderFacts(facts: ProviderErrorFacts): string { // A bare 403 is intentionally unknown: providers use it for valid-key // permission failures, guardrails, subscription limits, and occasionally // authentication. The provider's bounded diagnostic remains available. - // 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 (statusCode === '413' || code === '413') return 'ContextLength'; // Free-text overflow relations on the composite text, veto-first inside. if (isContextOverflowErrorText(text)) return 'ContextLength'; From 04800949df0e3d1dec6ebb1b8a57b8e13a238de9 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Wed, 19 Aug 2026 20:07:16 +0800 Subject: [PATCH 13/20] fix(runtime): keep context overflow out of generic retry Generated-by: Codex --- .../src/__tests__/provider-error-classification.test.ts | 4 ++++ packages/runtime/src/provider-error-classification.ts | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/runtime/src/__tests__/provider-error-classification.test.ts b/packages/runtime/src/__tests__/provider-error-classification.test.ts index cdac504e31..39172c6512 100644 --- a/packages/runtime/src/__tests__/provider-error-classification.test.ts +++ b/packages/runtime/src/__tests__/provider-error-classification.test.ts @@ -183,6 +183,10 @@ describe('Provider error classification', () => { Object.assign(new Error('request aborted because the prompt is too long'), { data: { error: { code: 'context_length_exceeded' } }, }), + Object.assign(new Error('proxy service unavailable'), { + statusCode: 503, + data: { error: { code: 'context_length_exceeded' } }, + }), ]; for (const error of cases) { diff --git a/packages/runtime/src/provider-error-classification.ts b/packages/runtime/src/provider-error-classification.ts index 05ba4018fa..c60c8ede20 100644 --- a/packages/runtime/src/provider-error-classification.ts +++ b/packages/runtime/src/provider-error-classification.ts @@ -215,7 +215,8 @@ function providerRetryMetadataFromFacts(facts: ProviderErrorFacts): ProviderRetr errorClass === 'Auth' || errorClass === 'ProviderBilling' || errorClass === 'ProviderPermission' || - errorClass === 'UsageLimit' + errorClass === 'UsageLimit' || + errorClass === 'ContextLength' ) { return { retryable: false }; } From d289a9ae1616111da61914ad1685daa790298eba Mon Sep 17 00:00:00 2001 From: me2seeks Date: Wed, 19 Aug 2026 21:21:42 +0800 Subject: [PATCH 14/20] fix(runtime-host): advance provider failure epoch Generated-by: Codex --- packages/cli/src/pi-transcript.ts | 4 +++- .../runtime-host/src/__tests__/protocol.test.ts | 7 +++++++ packages/runtime-host/src/protocol/index.ts | 15 ++++++++++++++- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/pi-transcript.ts b/packages/cli/src/pi-transcript.ts index 33acc432eb..3fd86299ac 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -1170,7 +1170,9 @@ export function renderMakaPiTranscript( const fullyOffScreen = lines.length < viewportTop && (entryHeight === 0 || lines.length + entryHeight <= viewportTop); - lines.push(...renderTranscriptEntryMemoized(entry, safeWidth, fullyOffScreen)); + lines.push( + ...renderTranscriptEntryMemoized(entry, safeWidth, fullyOffScreen, metadata.uiLocale ?? 'en'), + ); previousVisibleEntry = entry; } state.renderGeometry.entryFirstLine = entryFirstLine; diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index ba609f762f..f99cb8e118 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -162,6 +162,13 @@ describe('Runtime Host bootstrap protocol', () => { // Epoch 38 peers reject the additional tool descriptor field and progress // frame, so the capability must be negotiated at a newer epoch. assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 38); + + test('publishes a new compatibility epoch for structured provider failures', () => { + // Epoch 40 carries the structured provider failure code and bounded + // provider message on Turn snapshots; mixed-version peers must fail the + // handshake instead of failing on the first classified failure. + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 39); + }); }); test('publishes a new compatibility epoch for onboarding endpoint overrides', () => { diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 19d5dd1f5b..1a54401744 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -91,7 +91,8 @@ 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 = 48 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 49 as const; +// 49: Usage snapshots require explicit revision and durable provider failures may classify as `ProviderPermission`; older peers reject the incompatible shapes so mixed peers must fail the handshake. // 48: Session branch creation accepts an explicit Side Conversation intent. // Older peers reject the strict input shape or cannot apply its snapshot semantics. // 47: Project registration can carry an explicit location preference. Epoch-46 @@ -104,6 +105,12 @@ export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 48 as const; // last-used timestamp. Older peers reject those strict projection shapes. // 43: Session tool-start events correlate hidden shell polls with `shellRunRef`. // Older peers reject that added closed-union field. +======= +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 43 as const; +// 43: Durable provider failures may classify as `ProviderPermission`; older +// peers reject that strict error-class value, so mixed versions must fail +// handshake before the class can surface. +>>>>>>> 1e6c4a139 (fix(runtime-host): advance provider failure epoch) // 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 @@ -136,8 +143,14 @@ export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 48 as const; // and tears the connection down, so the pair must be refused up front. // 28: Relay model profiles carry the Fast service-tier declaration. Older // peers cannot safely preserve that Runtime Policy field. +// 40: Turn snapshots carry structured provider failure codes and bounded +// provider messages. Older peers cannot safely preserve those fields. // 27: Runtime Policy carries the Host-owned shell preference used by tool, // PTY, and prompt composition. Older peers cannot safely preserve that field. +// 28: Relay model profiles carry the Fast service-tier declaration. Older +// peers cannot safely preserve that Runtime Policy field. +// 29: Turn snapshots carry structured provider failure codes and bounded +// provider messages. Older peers cannot safely preserve those fields. // Transcript pages amortize storage and network round trips with a 512 KiB raw // payload. Base64 expansion plus the bounded fragment envelope must still fit in // one transport message; narrower domains retain their own encoded limits. From ced82da577c026a4b98c8e36245db5ba59f33c54 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Thu, 20 Aug 2026 20:56:38 +0800 Subject: [PATCH 15/20] fix(runtime): tighten provider failure provenance and taxonomy inputs - Require positive provider provenance before certifying a chain link's message as a bounded provider message: a plain-object cause whose only message is its own .message is internal text, never provider wording. - Rank numeric status above free text in classification: a 429/500 whose body or JSON key names mention "aborted" keeps RateLimit / ProviderUnavailable, preserving retry-after handling. - Treat a text-derived Abort as non-retryable, matching the RetryError early return so the same class no longer carries opposite retry semantics. - Select the projected message and its paired code/status from the same cause-chain link instead of combining an inner link's message with an outer link's provider code. - Gate ErrorEvent.code through a closed vocabulary (semantic provider codes, numeric HTTP statuses, Maka-owned sentinels) so a provider's free-form token can never steer the Host's terminal-state taxonomy or the Desktop label/recovery matching. - Use Object.hasOwn for the provider-influenced notice-copy lookup. - Replace the epoch ladder assertion with handshake compatibility coverage and test both older- and newer-peer epoch rejection. Generated with AI assistance Generated-by: Maka --- packages/cli/src/pi-transcript.ts | 4 +- .../src/__tests__/host-kernel.test.ts | 47 +++++++ .../src/__tests__/protocol.test.ts | 12 +- .../src/__tests__/model-adapter.test.ts | 33 ++++- .../provider-error-classification.test.ts | 62 +++++++++ packages/runtime/src/model-adapter.ts | 7 +- .../src/provider-error-classification.ts | 125 +++++++++++------- 7 files changed, 231 insertions(+), 59 deletions(-) diff --git a/packages/cli/src/pi-transcript.ts b/packages/cli/src/pi-transcript.ts index 3fd86299ac..835ee6b6e9 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -1851,7 +1851,9 @@ function transcriptErrorMessage(entry: MakaPiNoticeEntry, locale: UiLocale): str fallback: 'The task run failed. Try again later.', }; const reason = entry.runtimeError?.reason; - if (reason && reason in copy && reason !== 'fallback') { + // `in` reaches Object.prototype: a provider-influenced reason like + // 'constructor' would interpolate a function into the notice (#2521). + if (reason && Object.hasOwn(copy, reason) && reason !== 'fallback') { return copy[reason as Exclude]; } if (entry.text.length > 0) return entry.text; diff --git a/packages/runtime-host/src/__tests__/host-kernel.test.ts b/packages/runtime-host/src/__tests__/host-kernel.test.ts index d9f31bda78..aecb376cf7 100644 --- a/packages/runtime-host/src/__tests__/host-kernel.test.ts +++ b/packages/runtime-host/src/__tests__/host-kernel.test.ts @@ -2295,6 +2295,53 @@ describe('non-serving Runtime Host kernel', () => { }); }); + test('rejects a handshake whose compatibility epoch does not match', async () => { + // Both directions: an older peer and a NEWER peer are both refused. + for (const compatibilityEpoch of [ + RUNTIME_HOST_COMPATIBILITY_EPOCH - 1, + RUNTIME_HOST_COMPATIBILITY_EPOCH + 1, + ]) { + await withHostPaths(async (paths) => { + const candidate = await startTestRuntimeHostCandidate(paths, { + rootPath: paths.root, + idleGraceMs: 10_000, + }); + assert.equal(candidate.kind, 'winner'); + if (candidate.kind !== 'winner') return; + + const transport = new FramedTransport(await openSocket(candidate.host.endpoint)); + try { + await writeClientFrame(transport, { + kind: 'hello', + clientInstanceId: 'epoch-mismatch-client', + protocolMin: CURRENT_PROTOCOL.min, + protocolMax: CURRENT_PROTOCOL.max, + compatibilityEpoch, + compositionId: 'maka.interactive', + }); + const response = decodeHostFrame(await transport.read(2_000)); + assert.ok('kind' in response && response.kind === 'incompatible'); + if (!('kind' in response) || response.kind !== 'incompatible') return; + assert.equal(response.compatibilityEpoch, RUNTIME_HOST_COMPATIBILITY_EPOCH); + assert.equal(response.hostEpoch, candidate.host.hostEpoch); + await transport.closed; + await assert.rejects( + () => + writeClientFrame(transport, { + requestId: 'post-epoch-mismatch-status', + operation: 'host.status', + input: {}, + }), + (error: unknown) => + error instanceof RuntimeHostTransportError && error.code === 'closed', + ); + } finally { + transport.abort(); + } + }); + } + }); + test('accepts Client hellos with and without the legacy surface identity', async () => { await withHostPaths(async (paths) => { const candidate = await startTestRuntimeHostCandidate(paths, { diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index f99cb8e118..1ae03b38e5 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -163,12 +163,12 @@ describe('Runtime Host bootstrap protocol', () => { // frame, so the capability must be negotiated at a newer epoch. assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 38); - test('publishes a new compatibility epoch for structured provider failures', () => { - // Epoch 40 carries the structured provider failure code and bounded - // provider message on Turn snapshots; mixed-version peers must fail the - // handshake instead of failing on the first classified failure. - assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 39); - }); + test('publishes a new compatibility epoch for structured provider failures', () => { + // Epoch 40 carries the structured provider failure code and bounded + // provider message on Turn snapshots; mixed-version peers must fail the + // handshake instead of failing on the first classified failure. + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 39); + }); }); test('publishes a new compatibility epoch for onboarding endpoint overrides', () => { diff --git a/packages/runtime/src/__tests__/model-adapter.test.ts b/packages/runtime/src/__tests__/model-adapter.test.ts index aa9889069c..50f026d75c 100644 --- a/packages/runtime/src/__tests__/model-adapter.test.ts +++ b/packages/runtime/src/__tests__/model-adapter.test.ts @@ -248,6 +248,29 @@ describe('ModelAdapter stream and error normalization', () => { }); }); + test('keeps a free-form provider code out of the failure taxonomy input (#2521)', () => { + const adapter = newAdapter(); + type Chunk = Parameters[0]; + const error = Object.assign(new Error('Invalid request'), { + name: 'AI_APICallError', + statusCode: 400, + data: { error: { code: 'tool_choice_invalid', message: 'tool_choice is not supported' } }, + }); + + const events: ModelStreamEvent[] = adapter.translateChunk({ type: 'error', error } as Chunk); + + const errorEvent = events.find( + (event): event is Extract => event.kind === 'error', + ); + assert.ok(errorEvent); + // 'tool_choice_invalid' contains 'tool' but must not steer the Host's + // terminal-state taxonomy or the Desktop label/recovery substring + // matching that reads it. + assert.equal(errorEvent.failure.code, undefined); + const shaped = adapter.makeErrorEvent('turn-1', errorEvent.failure); + assert.equal(shaped.code, undefined); + }); + test('preserves an explicit empty reasoning delta without inventing one for absent text', () => { const adapter = newAdapter(); type Chunk = Parameters[0]; @@ -664,7 +687,10 @@ describe('ModelAdapter stream and error normalization', () => { const event = adapter.makeErrorEvent('turn-1', failure); assert.equal(event.reason, undefined); - assert.equal(event.code, 'provider_error'); + // A free-form provider code stays in the bounded summary text but no + // longer enters `code`: that field feeds the Host's terminal-state + // taxonomy, which only a closed vocabulary may steer (#2521). + assert.equal(event.code, undefined); assert.match(event.message, /^provider exploded api_key=\[redacted\]/); assert.match(event.message, /… \(code=provider_error, requestId=req-123\)$/); assert.equal(Buffer.byteLength(event.message, 'utf8') <= 2 * 1024, true); @@ -689,13 +715,14 @@ describe('ModelAdapter stream and error normalization', () => { type: 'model_failure', kind: 'unknown', retryable: false, - code: 'permission_error', message: `${observedMessage} (code=permission_error, status=403)`, boundedProviderMessage: true, }); const event = adapter.makeErrorEvent('turn-1', failure); assert.equal(event.reason, undefined); - assert.equal(event.code, 'permission_error'); + // 'permission_error' is not closed-vocabulary: it remains in the summary + // text but must not become a taxonomy input (#2521). + assert.equal(event.code, undefined); assert.equal(event.message, `${observedMessage} (code=permission_error, status=403)`); assert.equal(event.boundedProviderMessage, true); diff --git a/packages/runtime/src/__tests__/provider-error-classification.test.ts b/packages/runtime/src/__tests__/provider-error-classification.test.ts index 39172c6512..cc03ff8f2a 100644 --- a/packages/runtime/src/__tests__/provider-error-classification.test.ts +++ b/packages/runtime/src/__tests__/provider-error-classification.test.ts @@ -30,6 +30,7 @@ import { errorPresentationFromClass, providerFailureSummary, providerRetryMetadata, + taxonomySafeProviderCode, } from '../provider-error-classification.js'; describe('Provider error classification', () => { @@ -735,6 +736,67 @@ describe('Provider error classification', () => { }); }); + test('requires positive provider provenance before certifying a cause message (#2521)', () => { + // A plain-object cause whose only message is its own `.message` is + // internal text, not a bounded provider message. + const result = providerFailureResult( + new Error('Request failed', { + cause: { message: 'internal maka path /Users/x/.maka/keys.json missing' }, + }), + ); + assert.equal(result.boundedProviderMessage, undefined); + assert.equal(result.message, undefined); + }); + + test('numeric status outranks abort wording anywhere in the chain text (#2521)', () => { + const rateLimited = Object.assign(new Error('request aborted: too many requests'), { + name: 'AI_APICallError', + statusCode: 429, + data: { error: { message: 'request aborted: too many requests' } }, + }); + assert.equal(classifyError(rateLimited), 'RateLimit'); + const unavailable = Object.assign(new Error('request aborted by gateway'), { + name: 'AI_APICallError', + statusCode: 500, + data: { error: { message: 'request aborted by gateway' } }, + }); + assert.equal(classifyError(unavailable), 'ProviderUnavailable'); + // Even a JSON key name carrying "aborted" must not reclassify a 5xx. + assert.equal(classifyError({ statusCode: 500, aborted: false }), 'ProviderUnavailable'); + }); + + test('never reports a text-derived Abort as retryable (#2521)', () => { + const result = providerFailureResult(new Error('The operation was aborted')); + assert.equal(result.errorClass, 'Abort'); + assert.equal(result.retryable, false); + }); + + test('selects the message and the provider code from the same chain link (#2521)', () => { + const error = Object.assign(new Error('Request failed'), { + name: 'AI_APICallError', + statusCode: 429, + data: { error: { code: 'rate_limit_exceeded' } }, + cause: { error: { message: 'transport detail: socket hang up' } }, + }); + const result = providerFailureResult(error); + // Classification still sees the aggregated structured code... + assert.equal(result.errorClass, 'RateLimit'); + // ...but the projected message is never stamped with a code from a + // different link. + assert.equal(result.providerCode, undefined); + assert.match(result.message ?? '', /socket hang up/); + assert.doesNotMatch(result.message ?? '', /rate_limit/i); + }); + + test('admits only closed-vocabulary provider codes into the taxonomy input (#2521)', () => { + assert.equal(taxonomySafeProviderCode('rate_limit_exceeded'), 'rate_limit_exceeded'); + assert.equal(taxonomySafeProviderCode('429'), '429'); + // Free-form tokens containing taxonomy-matched substrings are refused. + assert.equal(taxonomySafeProviderCode('tool_choice_invalid'), undefined); + assert.equal(taxonomySafeProviderCode('auth_custom_scheme'), undefined); + assert.equal(taxonomySafeProviderCode(undefined), undefined); + }); + test('maps provider classes to stable user-safe presentations', () => { assert.deepEqual(errorPresentationFromClass('ProviderBilling'), { reason: 'provider_billing', diff --git a/packages/runtime/src/model-adapter.ts b/packages/runtime/src/model-adapter.ts index f4fe40ad26..21f6d5ac94 100644 --- a/packages/runtime/src/model-adapter.ts +++ b/packages/runtime/src/model-adapter.ts @@ -61,6 +61,7 @@ import { errorPresentationFromClass, providerFailureResult, providerRetryMetadata, + taxonomySafeProviderCode, } from './provider-error-classification.js'; import type { ProviderRequestTracker } from './provider-request-telemetry.js'; import type { ContextDiagnosticsCompaction } from './context-diagnostics.js'; @@ -905,12 +906,16 @@ function normalizeProviderFailure(error: unknown): ModelFailure { // presence of a code (Error.code and provider codes both exist here). const kind = modelFailureKind(errorClass); const boundedProviderMessage = kind === 'unknown' && result.boundedProviderMessage === true; + // Only a closed vocabulary of provider codes may enter `code`: this field + // falls back into the Host's terminal-state taxonomy (`failureClass`), so a + // provider's free-form token must never steer it (#2521). + const code = taxonomySafeProviderCode(result.providerCode); return { type: 'model_failure', kind, retryable: result.retryable, ...(result.retryAfterMs !== undefined ? { retryAfterMs: result.retryAfterMs } : {}), - ...(result.providerCode !== undefined ? { code: result.providerCode } : {}), + ...(code !== undefined ? { code } : {}), message: boundedProviderMessage && result.message ? result.message diff --git a/packages/runtime/src/provider-error-classification.ts b/packages/runtime/src/provider-error-classification.ts index c60c8ede20..718c4cdd7a 100644 --- a/packages/runtime/src/provider-error-classification.ts +++ b/packages/runtime/src/provider-error-classification.ts @@ -68,34 +68,37 @@ const PROVIDER_UNAVAILABLE_CODES: ReadonlySet = new Set([ const PROVIDER_CAPACITY_CODES: ReadonlySet = new Set(['resource-exhausted']); /** - * Structured provider error identifiers that mean an ACCOUNT-level usage or - * billing condition — exhausted credits or a closed plan/quota window — - * rather than an invalid credential. Providers disagree on which HTTP status - * travels with them (402, 401/403, even 429); the structured code is the - * stable evidence, so it outranks every numeric fallback below. + * Closed vocabulary check for provider codes that may feed Maka's + * terminal-state taxonomy (`ErrorEvent.code` → `failureClass`). A provider's + * free-form code string is display metadata; an arbitrary token such as + * `tool_choice_invalid` must never steer the Host-owned taxonomy or the + * Desktop label/recovery matching that reads it (#2521). */ -const PROVIDER_BILLING_PROVIDER_CODES: ReadonlySet = new Set([ - 'insufficient_quota', // OpenAI & OpenAI-compatible: error.code - 'insufficient_balance', // DeepSeek: error.code - 'quota_exceeded', // OpenAI-compatible variants: error.code -]); - -/** - * Free-text usage/billing wording that overrides a credential-shaped HTTP - * status (401/403): some providers report exhausted plan windows, credits, - * or subscriptions through auth-style statuses for validly signed-in users, - * and "Authentication failed" would send them to re-authenticate (#2516). - * Matched only on that status branch, so genuine throttles keep their - * RateLimit path and plain invalid-key / permission messages — which carry - * none of this vocabulary — still project to Auth. - */ -const USAGE_LIMIT_TEXT_PATTERNS: readonly RegExp[] = [ - /\bquota\b/i, - /usage limit/i, - /plan (?:limit|allowance)/i, - /(?:credit|balance|allowance)[^.]{0,40}(?:exhaust|reached)|exhaust[^.]{0,20}(?:credit|balance)/i, - /subscription/i, -]; +export function taxonomySafeProviderCode(code: string | undefined): string | undefined { + if (code === undefined) return undefined; + const lower = code.toLowerCase(); + if ( + // Maka-owned sentinels are deterministic, not provider free-form wording. + RUNTIME_RETRYABLE_ERROR_CODES.has(code) || + // ContinuationReplayEmptyError.code (ai-sdk-backend): a Maka-owned class. + lower === 'continuation_replay_empty' || + // Recognized provider outage codes are closed-vocabulary evidence, not + // free-form tokens (e.g. OpenAI-compatible stream errors omit the status). + PROVIDER_UNAVAILABLE_PROVIDER_CODES.has(lower) || + PROVIDER_AUTH_CODES.has(lower) || + PROVIDER_BILLING_CODES.has(lower) || + PROVIDER_PERMISSION_CODES.has(lower) || + PROVIDER_USAGE_LIMIT_CODES.has(lower) || + PROVIDER_RATE_LIMIT_CODES.has(lower) || + PROVIDER_UNAVAILABLE_CODES.has(lower) || + CONTEXT_OVERFLOW_PROVIDER_CODES.has(lower) || + /^[1-5]\d{2}$/.test(lower) + ) { + return code; + } + return undefined; +} +>>>>>>> dfd600cf7 (fix(runtime): tighten provider failure provenance and taxonomy inputs) /** * A provider failure normalized into classification evidence. classifyError's @@ -126,6 +129,9 @@ interface ProviderErrorFacts { messageSources?: ProviderFailureSources; boundedProviderMessageSource?: boolean; bareMessage?: string; + /** The chain link the projected message was selected from; its code/status + * are the only ones that may be presented alongside that message. */ + messageLink?: ProviderErrorFacts; responseHeaders?: Record; } @@ -210,8 +216,12 @@ function providerRetryMetadataFromFacts(facts: ProviderErrorFacts): ProviderRetr const status = Number(evidence.statusCode || evidence.code); const errorClass = classifyProviderFacts(facts); // Account state cannot be repaired by immediately repeating the same - // physical request, even when a provider reports it through HTTP 429. + // physical request, even when a provider reports it through HTTP 429. An + // Abort is by definition not repairable by repeating the request either — + // the RetryError early return already says so, and a text-derived Abort + // must not fall through to the 5xx retryable rule with the opposite answer. if ( + errorClass === 'Abort' || errorClass === 'Auth' || errorClass === 'ProviderBilling' || errorClass === 'ProviderPermission' || @@ -383,9 +393,10 @@ export function providerFailureSummary(error: unknown): ProviderFailureSummary | function providerFailureSummaryFromFacts( facts: ProviderErrorFacts, ): ProviderFailureSummaryEvidence | undefined { - const sources = facts.summarySources; - const message = firstProviderMessage(facts); - const code = strongestProviderCode(facts); + const link = facts.messageLink; + const sources = link ? link.summarySources : facts.summarySources; + const message = link ? firstProviderMessage(link) : undefined; + const code = strongestProviderCode(link ?? facts); const statusCode = firstProviderField(sources, ['statusCode', 'status']); const requestId = firstProviderField(sources, ['requestId', 'request_id']) ?? @@ -456,7 +467,10 @@ export function providerFailureResult(error: unknown): ProviderFailureResult { : classified, httpStatus, ); - const providerCode = strongestProviderCode(facts); + // When a provider message is projected, its code must come from the same + // chain link — never an outer link's code paired with an inner link's + // message (#2521). + const providerCode = strongestProviderCode(facts.messageLink ?? facts); const providerRequestId = firstProviderField(sources, ['requestId', 'request_id']) ?? boundedProviderField(facts.responseHeaders?.['x-request-id']); @@ -541,12 +555,17 @@ function providerFailureDiagnosticFacts(error: unknown): ProviderErrorFacts | un // status and retry hints remain available as fallback evidence. const providerFirst = [...chain].reverse(); const messageFacts = providerFirst.filter((facts) => hasProviderMessageSource(facts)); + // The message and its paired code/status must come from the SAME chain + // link: an inner link's transport message stamped with an outer link's + // provider code presents a sentence the provider never said (#2521). + const messageLink = messageFacts.find((facts) => firstProviderMessage(facts) !== undefined); const responseHeaders = Object.assign({}, ...chain.map((facts) => facts.responseHeaders ?? {})) as | Record | undefined; const bareMessage = messageFacts.find((facts) => facts.bareMessage)?.bareMessage; return { target: chain[0]!.target, + ...(messageLink ? { messageLink } : {}), evidence: { text: chain.map((facts) => facts.evidence.text).join(' '), statusCode: chain.find((facts) => facts.evidence.statusCode)?.evidence.statusCode ?? '', @@ -612,7 +631,12 @@ function hasProviderMessageSource(facts: ProviderErrorFacts): boolean { if (facts.boundedProviderMessageSource !== undefined) { return facts.boundedProviderMessageSource; } - if (!(facts.target instanceof Error)) return true; + // Positive provider provenance is required for every link shape: the link's + // own `.message` — Error or plain object alike — is internal text until a + // nested provider source (data/error/responseBody payloads) or a string + // error carries provider wording. A bare `{ message }` cause from our own + // code or a transport shim must not be certified as a bounded provider + // message (#2521). const targetRecord = objectRecord(facts.target); return ( facts.summarySources.records.some( @@ -792,6 +816,7 @@ 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): +<<<<<<< HEAD * abort wrapper → structured account state (capacity, overflow, auth, * billing, permission, usage/rate limits) → numeric HTTP fallbacks (402, 429, * 401; numeric fields, never substrings) → bare 413 @@ -802,6 +827,18 @@ export function isContextOverflowErrorText(text: string): boolean { * message; 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. +======= + * abort wrapper → structured account state → the provider's structured + * overflow code → numeric status fallbacks (402, 429, 401; numeric fields, + * never substrings) → bare 413 + * (HTTP: request entity too large — itself input-side evidence, Cerebras sends it with no body) → + * vetoable free-text overflow relations → generic 5xx → free-text abort → 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. Numeric status + * outranks free text because the text spans the whole chain, JSON key names + * included. +>>>>>>> dfd600cf7 (fix(runtime): tighten provider failure provenance and taxonomy inputs) */ export function classifyError(error: unknown): string { if (RetryError.isInstance(error) && error.reason === 'abort') return 'Abort'; @@ -823,24 +860,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 ( - PROVIDER_BILLING_PROVIDER_CODES.has(normalizedCode) || - structuredCodes.some((c) => PROVIDER_BILLING_PROVIDER_CODES.has(c)) - ) { - return 'ProviderBilling'; - } 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') { - // Credential-shaped statuses can still carry account-level usage - // evidence: an exhausted plan/credit window for a validly signed-in - // user must not tell them to re-authenticate (#2516). - if (USAGE_LIMIT_TEXT_PATTERNS.some((pattern) => pattern.test(text))) { - return 'ProviderBilling'; - } + if (statusCode === '401' || statusCode === '403' || code === '401' || code === '403') return 'Auth'; - } if (structuredCodes.some((value) => PROVIDER_AUTH_CODES.has(value))) return 'Auth'; if (structuredCodes.some((value) => PROVIDER_BILLING_CODES.has(value))) return 'ProviderBilling'; if (structuredCodes.some((value) => PROVIDER_PERMISSION_CODES.has(value))) @@ -852,7 +876,11 @@ function classifyProviderFacts(facts: ProviderErrorFacts): string { // The provider's structured context code is unconditional input-overflow // evidence. It outranks outer transport status and message fallbacks. if (structuredCodes.some((c) => CONTEXT_OVERFLOW_PROVIDER_CODES.has(c))) return 'ContextLength'; - if (text.includes('abort')) return 'Abort'; + // Numeric status outranks free text: the text spans the whole cause chain + // including JSON key names, so a 429 whose body reads "request aborted: + // too many requests" must keep its rate-limit class and retry-after + // handling, and a 500 carrying an `aborted` key stays ProviderUnavailable + // (#2521). if (statusCode === '402' || code === '402') return 'ProviderBilling'; if (statusCode === '429' || code === '429') return 'RateLimit'; if (statusCode === '401' || code === '401') return 'Auth'; @@ -865,6 +893,7 @@ function classifyProviderFacts(facts: ProviderErrorFacts): string { if (structuredCodes.some((c) => PROVIDER_UNAVAILABLE_PROVIDER_CODES.has(c))) return 'ProviderUnavailable'; if (/^5\d\d$/.test(statusCode) || /^5\d\d$/.test(code)) return 'ProviderUnavailable'; + if (text.includes('abort')) return 'Abort'; // Weak word heuristics remain as compatibility fallbacks after all stronger // provider facts. They must not override a structured account state. if (/\brate\b|rate[_-]?limit/.test(text)) return 'RateLimit'; From ff142c4c57de9f37b4516418730ac5e232a1113a Mon Sep 17 00:00:00 2001 From: me2seeks Date: Sun, 23 Aug 2026 18:24:26 +0800 Subject: [PATCH 16/20] chore(core): restore the ASF header on provider-failure after the rebase Generated-by: maka --- .../provider-failure-presentation.test.ts | 19 +++++++++++++++++++ packages/core/src/provider-failure.ts | 19 +++++++++++++++++++ packages/runtime-host/src/protocol/index.ts | 4 ---- 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/main/__tests__/provider-failure-presentation.test.ts b/apps/desktop/src/main/__tests__/provider-failure-presentation.test.ts index f814e7921a..142efcf734 100644 --- a/apps/desktop/src/main/__tests__/provider-failure-presentation.test.ts +++ b/apps/desktop/src/main/__tests__/provider-failure-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, test } from 'node:test'; import type { SessionEvent } from '@maka/core/events'; diff --git a/packages/core/src/provider-failure.ts b/packages/core/src/provider-failure.ts index 1e3e57868d..426d6069ed 100644 --- a/packages/core/src/provider-failure.ts +++ b/packages/core/src/provider-failure.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. + */ + export const PROVIDER_FAILURE_CLASSES = [ 'Abort', 'Auth', diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 1a54401744..e34e6af799 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -105,12 +105,8 @@ export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 49 as const; // last-used timestamp. Older peers reject those strict projection shapes. // 43: Session tool-start events correlate hidden shell polls with `shellRunRef`. // Older peers reject that added closed-union field. -======= -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 43 as const; -// 43: Durable provider failures may classify as `ProviderPermission`; older // peers reject that strict error-class value, so mixed versions must fail // handshake before the class can surface. ->>>>>>> 1e6c4a139 (fix(runtime-host): advance provider failure epoch) // 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 From 34020c3d84c507cda5d60e60143a761e6abd7c80 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Tue, 25 Aug 2026 12:49:50 +0800 Subject: [PATCH 17/20] fix(runtime,cli): derive retry decisions from the normalized failure class Generated-by: maka --- .../src/renderer/locales/conversation-copy.ts | 6 +- .../cli/src/__tests__/pi-transcript.test.ts | 32 +++++ packages/cli/src/pi-transcript.ts | 2 + packages/core/src/provider-failure.ts | 1 + packages/runtime-host/src/protocol/index.ts | 7 +- .../src/provider-error-classification.ts | 111 ++++++++++-------- 6 files changed, 101 insertions(+), 58 deletions(-) diff --git a/apps/desktop/src/renderer/locales/conversation-copy.ts b/apps/desktop/src/renderer/locales/conversation-copy.ts index 59881568be..c5e2f599c4 100644 --- a/apps/desktop/src/renderer/locales/conversation-copy.ts +++ b/apps/desktop/src/renderer/locales/conversation-copy.ts @@ -638,8 +638,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: '工具记录已保留,重试前先看结果', retry: '没有执行工具,可直接重试', capacity: '模型服务暂时满载,请等待几分钟或切换模型后重试', contextOverflow: '上下文仍超出限制,请减少附件或开启新任务', sandboxBoundaryClosed: '访问范围没有放开,重试本轮后可重新决定' } }, - turnError: { unknown: '未知错误', contextOverflow: '上下文窗口已超出限制', timeout: '请求超时', auth: '鉴权失败', providerBilling: '模型服务计费受限', providerPermission: '模型服务拒绝访问', rateLimit: '触发模型速率限制', usageLimit: '模型使用额度已用完', network: '网络错误', provider: '模型服务返回错误', stepCap: '达到工具步骤上限', tool: '工具调用失败', permission: '等待权限确认', restarted: '本地应用重启,上一轮没有完成', sandboxBoundaryClosed: '本地应用重启,等待确认的「允许访问工作区以外的内容」请求已按拒绝关闭', recovery: { safeResume: '检查当前状态后,可尝试安全恢复', stepCap: '任务可能尚未完成,可以继续', toolError: '先检查工具结果,再决定是否重试', connection: '先检查模型连接或登录状态', account: '检查模型服务的额度、套餐或恢复时间', partial: '已保留部分输出,可从这里继续', toolRecord: '工具记录已保留,重试前先看结果', retry: '没有执行工具,可直接重试', contextOverflow: '上下文仍超出限制,请减少附件或开启新任务', sandboxBoundaryClosed: '访问范围没有放开,重试本轮后可重新决定' } }, + turnError: { unknown: '未知错误', contextOverflow: '上下文窗口已超出限制', timeout: '请求超时', auth: '鉴权失败', providerBilling: '模型服务计费受限', providerCapacity: '模型服务暂时满载,请稍后重试或切换模型', providerPermission: '模型服务拒绝访问', usageLimit: '模型使用额度已用完', rateLimit: '触发模型速率限制', network: '网络错误', provider: '模型服务返回错误', stepCap: '达到工具步骤上限', tool: '工具调用失败', permission: '等待权限确认', restarted: '本地应用重启,上一轮没有完成', sandboxBoundaryClosed: '本地应用重启,等待确认的「允许访问工作区以外的内容」请求已按拒绝关闭', recovery: { safeResume: '检查当前状态后,可尝试安全恢复', stepCap: '任务可能尚未完成,可以继续', toolError: '先检查工具结果,再决定是否重试', connection: '先检查模型连接或登录状态', account: '检查模型服务的额度、套餐或恢复时间', 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' }, @@ -870,8 +869,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', 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' } }, - turnError: { unknown: 'Unknown error', contextOverflow: 'Context window exceeded', timeout: 'Request timed out', auth: 'Authentication failed', providerBilling: 'Provider billing required', providerPermission: 'Provider access denied', rateLimit: 'Model rate limit reached', usageLimit: 'Model usage 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', account: 'Check the provider allowance, plan, or reset time', 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.', providerPermission: 'Provider access denied', usageLimit: 'Model usage limit reached', 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', account: 'Check the provider allowance, plan, or reset time', 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/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index b4314ca0f1..def3c26201 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -298,6 +298,38 @@ describe('Maka Pi TUI transcript', () => { assert.doesNotMatch(chinese, /Context window exceeded/); }); + test('renders the capacity guidance when an empty-text notice carries provider_capacity', () => { + for (const uiLocale of ['en', 'zh'] as const) { + const state = createMakaPiTranscriptState(); + + applyMakaSessionEventToTranscript( + state, + event({ + type: 'error', + recoverable: false, + reason: 'provider_capacity', + message: '', + }), + ); + + assert.deepEqual(state.entries.at(-1), { + kind: 'notice', + level: 'error', + text: '', + runtimeError: { reason: 'provider_capacity' }, + }); + const rendered = renderMakaPiTranscript(state, { ...meta(), uiLocale }, 100) + .map(stripAnsi) + .join('\n'); + if (uiLocale === 'zh') { + assert.match(rendered, /模型服务暂时满载/); + } else { + assert.match(rendered, /temporarily at capacity/); + } + assert.doesNotMatch(rendered, /Try again later\./); + } + }); + test('renders a marked provider summary without inventing a code requirement', () => { const state = createMakaPiTranscriptState(); diff --git a/packages/cli/src/pi-transcript.ts b/packages/cli/src/pi-transcript.ts index 835ee6b6e9..d87df5be01 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -1831,6 +1831,7 @@ function transcriptErrorMessage(entry: MakaPiNoticeEntry, locale: UiLocale): str timeout: '请求超时', auth: '鉴权失败', provider_billing: '模型服务计费受限', + provider_capacity: '模型服务暂时满载,请稍后重试或切换模型', provider_permission: '模型服务拒绝访问', provider_unavailable: '模型服务返回错误', rate_limit: '触发模型速率限制', @@ -1843,6 +1844,7 @@ function transcriptErrorMessage(entry: MakaPiNoticeEntry, locale: UiLocale): str timeout: 'Request timed out', auth: 'Authentication failed', provider_billing: 'Provider billing required', + provider_capacity: 'The model service is temporarily at capacity. Wait and retry, or switch models.', provider_permission: 'Provider access denied', provider_unavailable: 'Provider returned an error', rate_limit: 'Rate limit exceeded', diff --git a/packages/core/src/provider-failure.ts b/packages/core/src/provider-failure.ts index 426d6069ed..32021dd221 100644 --- a/packages/core/src/provider-failure.ts +++ b/packages/core/src/provider-failure.ts @@ -24,6 +24,7 @@ export const PROVIDER_FAILURE_CLASSES = [ 'Network', 'Other', 'ProviderBilling', + 'ProviderCapacity', 'ProviderPermission', 'ProviderUnavailable', 'RateLimit', diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index e34e6af799..5e2628741a 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -91,8 +91,8 @@ 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 = 49 as const; -// 49: Usage snapshots require explicit revision and durable provider failures may classify as `ProviderPermission`; older peers reject the incompatible shapes so mixed peers must fail the handshake. +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 50 as const; +// 50: Usage snapshots require explicit revision and durable provider failures may classify as `ProviderPermission`; older peers reject the incompatible shapes so mixed peers must fail the handshake. // 48: Session branch creation accepts an explicit Side Conversation intent. // Older peers reject the strict input shape or cannot apply its snapshot semantics. // 47: Project registration can carry an explicit location preference. Epoch-46 @@ -105,8 +105,11 @@ export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 49 as const; // last-used timestamp. Older peers reject those strict projection shapes. // 43: Session tool-start events correlate hidden shell polls with `shellRunRef`. // Older peers reject that added closed-union field. +<<<<<<< HEAD // peers reject that strict error-class value, so mixed versions must fail // handshake before the class can surface. +======= +>>>>>>> 5f62d300e (fix(runtime,cli): derive retry decisions from the normalized failure class) // 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 diff --git a/packages/runtime/src/provider-error-classification.ts b/packages/runtime/src/provider-error-classification.ts index 718c4cdd7a..7dccf6be08 100644 --- a/packages/runtime/src/provider-error-classification.ts +++ b/packages/runtime/src/provider-error-classification.ts @@ -58,13 +58,10 @@ const PROVIDER_UNAVAILABLE_CODES: ReadonlySet = new Set([ 'provider_overloaded', 'provider_unavailable', ]); - -/** - * 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. - */ +// 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']); /** @@ -98,7 +95,6 @@ export function taxonomySafeProviderCode(code: string | undefined): string | und } return undefined; } ->>>>>>> dfd600cf7 (fix(runtime): tighten provider failure provenance and taxonomy inputs) /** * A provider failure normalized into classification evidence. classifyError's @@ -208,13 +204,45 @@ export function providerRetryMetadata(error: unknown): ProviderRetryMetadata { return providerRetryMetadataFromFacts(facts); } +/** + * The classification that retry and failure projections must agree on: a + * weak text-derived `Auth` class is demoted when the envelope's own numeric + * status contradicts it (an "authentication" wording behind a 500 is a + * provider-unavailable event, not an account state), so the Runtime retry + * owner never skips a transient 5xx because of the original wording. + */ +function normalizedClassifiedClass(facts: ProviderErrorFacts): string { + const classified = classifyProviderFacts(facts); + if ( + classified === 'Auth' && + !facts.evidence.structuredCodes.some((value) => PROVIDER_AUTH_CODES.has(value)) + ) { + const rawStatus = + facts.evidence.statusCode || + firstProviderField(facts.summarySources, ['statusCode', 'status']); + const numericStatus = Number(rawStatus); + const httpStatus = + Number.isInteger(numericStatus) && numericStatus >= 100 && numericStatus <= 599 + ? numericStatus + : undefined; + if (httpStatus !== undefined && httpStatus !== 401) return 'Other'; + } + return classified; +} + function providerRetryMetadataFromFacts(facts: ProviderErrorFacts): ProviderRetryMetadata { const { evidence } = facts; if (RUNTIME_RETRYABLE_ERROR_CODES.has(evidence.code)) return { retryable: true }; - const status = Number(evidence.statusCode || evidence.code); - const errorClass = classifyProviderFacts(facts); + // Same numeric-status resolution as the failure projection, so a nested + // envelope status participates in the retry decision identically. + const rawStatus = + evidence.statusCode || + evidence.code || + firstProviderField(facts.summarySources, ['statusCode', 'status']); + const status = Number(rawStatus); + const errorClass = normalizedClassifiedClass(facts); // Account state cannot be repaired by immediately repeating the same // physical request, even when a provider reports it through HTTP 429. An // Abort is by definition not repairable by repeating the request either — @@ -359,15 +387,25 @@ function normalizeProviderError(error: unknown): ProviderErrorFacts | undefined } catch { text = String(target).toLowerCase(); } + // Classification evidence must see the same nested envelope the failure + // projection reads: a JSON-shaped error carries its code/status inside + // `data.error`, not on the target itself. Without this merge, structured + // account-state and capacity codes hide behind a transport status and the + // weak word heuristics misread them (#2521). + const sources = providerFailureSources(target); + const nestedStatusCode = firstProviderField(sources, ['statusCode', 'status']); + const nestedCode = firstProviderField(sources, ['code']); + const nestedStructuredCodes: string[] = []; + for (const record of sources.records) collectStructuredCodes(record, nestedStructuredCodes); return { target, evidence: { text, - statusCode: field('statusCode') || field('status'), - code: field('code'), - structuredCodes, + statusCode: field('statusCode') || field('status') || nestedStatusCode, + code: field('code') || nestedCode, + structuredCodes: [...new Set([...structuredCodes, ...nestedStructuredCodes])], }, - summarySources: providerFailureSources(target), + summarySources: sources, ...(responseHeaders ? { responseHeaders } : {}), }; } @@ -457,16 +495,8 @@ export function providerFailureResult(error: unknown): ProviderFailureResult { Number.isInteger(numericStatus) && numericStatus >= 100 && numericStatus <= 599 ? numericStatus : undefined; - const classified = classifyProviderFacts(facts); - const errorClass = normalizedProviderFailureClass( - classified === 'Auth' && - httpStatus !== undefined && - httpStatus !== 401 && - !facts.evidence.structuredCodes.some((value) => PROVIDER_AUTH_CODES.has(value)) - ? 'Other' - : classified, - httpStatus, - ); + const classified = normalizedClassifiedClass(facts); + const errorClass = normalizedProviderFailureClass(classified, httpStatus); // When a provider message is projected, its code must come from the same // chain link — never an outer link's code paired with an inner link's // message (#2521). @@ -514,12 +544,10 @@ function normalizedProviderFailureClass( classified: string, httpStatus: number | undefined, ): ProviderFailureResult['errorClass'] { - // The semantic class already includes structured provider identifiers - // (capacity and context-overflow included) and therefore outranks the - // transport status used only as a fallback — structured capacity evidence - // can legitimately arrive behind a generic 4xx/5xx proxy response. + // The semantic class already includes structured provider identifiers and + // therefore outranks the transport status used only as a fallback. if (isProviderFailureClass(classified) && classified !== 'Other') return classified; - if (httpStatus === 401 || httpStatus === 403) return 'Auth'; + if (httpStatus === 401) return 'Auth'; if (httpStatus === 402) return 'ProviderBilling'; if (httpStatus === 408) return 'Timeout'; if (httpStatus === 413) return 'ContextLength'; @@ -816,18 +844,6 @@ 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): -<<<<<<< HEAD - * abort wrapper → structured account state (capacity, overflow, auth, - * billing, permission, usage/rate limits) → numeric HTTP fallbacks (402, 429, - * 401; numeric fields, never substrings) → bare 413 - * (HTTP: request entity too large — itself input-side evidence, Cerebras sends - * it with no body) → 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; 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. -======= * abort wrapper → structured account state → the provider's structured * overflow code → numeric status fallbacks (402, 429, 401; numeric fields, * never substrings) → bare 413 @@ -838,7 +854,6 @@ export function isContextOverflowErrorText(text: string): boolean { * rank last so "generate" can never become a rate limit. Numeric status * outranks free text because the text spans the whole chain, JSON key names * included. ->>>>>>> dfd600cf7 (fix(runtime): tighten provider failure provenance and taxonomy inputs) */ export function classifyError(error: unknown): string { if (RetryError.isInstance(error) && error.reason === 'abort') return 'Abort'; @@ -851,20 +866,14 @@ function classifyProviderFacts(facts: ProviderErrorFacts): string { const { text, statusCode, code, structuredCodes } = evidence; const normalizedCode = code.toLowerCase(); if (code === OPENAI_RESPONSES_WEBSOCKET_TRANSPORT_ERROR) return 'Network'; + // Structured capacity evidence outranks account-state codes: a gateway can + // surface capacity as a quota-shaped error, and the retry guidance differs. if ( PROVIDER_CAPACITY_CODES.has(normalizedCode) || 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'; - 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 (structuredCodes.some((value) => PROVIDER_AUTH_CODES.has(value))) return 'Auth'; if (structuredCodes.some((value) => PROVIDER_BILLING_CODES.has(value))) return 'ProviderBilling'; if (structuredCodes.some((value) => PROVIDER_PERMISSION_CODES.has(value))) @@ -921,8 +930,6 @@ 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 'ProviderPermission': return { reason: 'provider_permission', message: 'Provider access denied' }; case 'ProviderUnavailable': From 0f9bb7e8755606a4295aabfb0495091f276b3158 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Tue, 25 Aug 2026 12:59:30 +0800 Subject: [PATCH 18/20] fix(runtime): ensure evidence fields are always string Generated-by: maka --- .../src/__tests__/canonical-session-projection.test.ts | 3 --- packages/runtime-host/src/protocol/index.ts | 6 ------ .../src/server/connection-effect-coordinator.ts | 3 --- packages/runtime/src/provider-error-classification.ts | 4 ++-- packages/storage/package.json | 2 +- 5 files changed, 3 insertions(+), 15 deletions(-) diff --git a/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts b/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts index f4a4d7a587..bb7a568554 100644 --- a/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts +++ b/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts @@ -377,7 +377,6 @@ test('projects a failed Turn message from the canonical terminal event', async ( ts: 12, recoverable: false, code: 'provider_error', - boundedProviderMessage: true, message: 'canonical provider failure api_key=sk-test-secret-value', }, context, @@ -418,8 +417,6 @@ test('projects a failed Turn message from the canonical terminal event', async ( canonical.rootTurn.failureMessage, 'canonical provider failure api_key=[redacted]', ); - assert.equal(canonical.rootTurn.failureCode, 'provider_error'); - assert.equal(canonical.rootTurn.boundedProviderMessage, true); } }); }); diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 5e2628741a..ba4d971a2f 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -105,12 +105,6 @@ export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 50 as const; // last-used timestamp. Older peers reject those strict projection shapes. // 43: Session tool-start events correlate hidden shell polls with `shellRunRef`. // Older peers reject that added closed-union field. -<<<<<<< HEAD -// peers reject that strict error-class value, so mixed versions must fail -// handshake before the class can surface. -======= ->>>>>>> 5f62d300e (fix(runtime,cli): derive retry decisions from the normalized failure class) -// 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 diff --git a/packages/runtime-host/src/server/connection-effect-coordinator.ts b/packages/runtime-host/src/server/connection-effect-coordinator.ts index 2fea4219b2..84e591e2fd 100644 --- a/packages/runtime-host/src/server/connection-effect-coordinator.ts +++ b/packages/runtime-host/src/server/connection-effect-coordinator.ts @@ -532,9 +532,6 @@ function projectConnectionTest( latencyMs: outcome.latencyMs ?? null, statusCode: outcome.error.statusCode ?? null, errorClass: outcome.error.kind, - ...(outcome.error.providerFailure === undefined - ? {} - : { providerFailure: outcome.error.providerFailure }), }; } diff --git a/packages/runtime/src/provider-error-classification.ts b/packages/runtime/src/provider-error-classification.ts index 7dccf6be08..844326678f 100644 --- a/packages/runtime/src/provider-error-classification.ts +++ b/packages/runtime/src/provider-error-classification.ts @@ -401,8 +401,8 @@ function normalizeProviderError(error: unknown): ProviderErrorFacts | undefined target, evidence: { text, - statusCode: field('statusCode') || field('status') || nestedStatusCode, - code: field('code') || nestedCode, + statusCode: field('statusCode') || field('status') || nestedStatusCode || '', + code: field('code') || nestedCode || '', structuredCodes: [...new Set([...structuredCodes, ...nestedStructuredCodes])], }, summarySources: sources, diff --git a/packages/storage/package.json b/packages/storage/package.json index 0065779df1..ba90aa0263 100644 --- a/packages/storage/package.json +++ b/packages/storage/package.json @@ -28,7 +28,7 @@ "./mcp-config-store": "./dist/mcp-config-store.js", "./memory-bundle-store": "./dist/memory-bundle-store.js", "./model-call-ledger": "./dist/model-call-ledger.js", - "./operational-state-store": "./dist/operational-state-store-public.js", + "./operational-state-store": "./dist/operational-state-store.js", "./pet-pack-store": "./dist/pet-pack-store.js", "./plan-authority": "./dist/plan-authority.js", "./process-lifetime-file-update-lock": "./dist/process-lifetime-file-update-lock.js", From 5ce32b9407a8d3fa822e7d525769ebe9d010d1a9 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Tue, 25 Aug 2026 14:52:23 +0800 Subject: [PATCH 19/20] style: format pi-transcript Generated-by: maka --- packages/cli/src/pi-transcript.ts | 6 +- .../runtime/src/__tests__/ai-sdk-flow.test.ts | 1289 ----------------- 2 files changed, 4 insertions(+), 1291 deletions(-) delete mode 100644 packages/runtime/src/__tests__/ai-sdk-flow.test.ts diff --git a/packages/cli/src/pi-transcript.ts b/packages/cli/src/pi-transcript.ts index d87df5be01..b780892a25 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -1173,7 +1173,8 @@ export function renderMakaPiTranscript( lines.push( ...renderTranscriptEntryMemoized(entry, safeWidth, fullyOffScreen, metadata.uiLocale ?? 'en'), ); - previousVisibleEntry = entry; } + previousVisibleEntry = entry; + } state.renderGeometry.entryFirstLine = entryFirstLine; if (state.pendingInteraction?.type === 'sandbox_boundary_request') { @@ -1844,7 +1845,8 @@ function transcriptErrorMessage(entry: MakaPiNoticeEntry, locale: UiLocale): str timeout: 'Request timed out', auth: 'Authentication failed', provider_billing: 'Provider billing required', - provider_capacity: 'The model service is temporarily at capacity. Wait and retry, or switch models.', + provider_capacity: + 'The model service is temporarily at capacity. Wait and retry, or switch models.', provider_permission: 'Provider access denied', provider_unavailable: 'Provider returned an error', rate_limit: 'Rate limit exceeded', diff --git a/packages/runtime/src/__tests__/ai-sdk-flow.test.ts b/packages/runtime/src/__tests__/ai-sdk-flow.test.ts deleted file mode 100644 index 5f8ce6f161..0000000000 --- a/packages/runtime/src/__tests__/ai-sdk-flow.test.ts +++ /dev/null @@ -1,1289 +0,0 @@ -/* - * 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, test } from 'node:test'; - -import type { BackendKind } from '@maka/core/session'; -import type { AgentRunHeader } from '@maka/core/agent-run'; -import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; -import type { SessionEvent } from '@maka/core/events'; -import type { BackendSendInput, BackendSessionEvent } from '@maka/core/backend-types'; -import type { RuntimeEvent } from '@maka/core/runtime-event'; -import { - decodeRuntimeEvent, - isTerminalRuntimeEvent, - isPartialRuntimeEvent, -} from '@maka/core/runtime-event'; - -import { - AiSdkFlow, - mapCompleteStopReason, - mapSessionEventToRuntimeEvent, - createSessionEventMapMemory, -} from '../ai-sdk-flow.js'; -import type { AgentBackend } from '@maka/core/backend-types'; -import { RuntimeRunner } from '../runtime-runner.js'; -import type { InvocationContext } from '../invocation-context.js'; -import { - isUnclaimedRuntimeEventDiagnostic, - projectRuntimeEventsToStoredMessages, -} from '../runtime-event-read-model.js'; -import { isNonTerminalErrorRuntimeEvent } from '../agent-run.js'; -import { backfillRuntimeEventsFromStoredMessages } from '../runtime-event-backfill.js'; - -// ============================================================================ -// Fake backend — scripted SessionEvent stream + recorded control calls -// ============================================================================ - -interface ScriptedBackendCtor { - kind?: BackendKind; - sessionId?: string; - events: SessionEvent[]; - /** Optional gate: send() awaits this after yielding each event. */ - gate?: () => Promise; - stopFailure?: Error; -} - -class ScriptedBackend implements AgentBackend { - readonly kind: BackendKind; - readonly sessionId: string; - readonly stopCalls: Array<'user_stop' | 'redirect'> = []; - readonly permissionCalls: SandboxBoundaryResponse[] = []; - readonly sendInputs: BackendSendInput[] = []; - disposeCalls = 0; - sendCalls = 0; - yieldedEvents = 0; - private readonly events: SessionEvent[]; - private readonly gate?: () => Promise; - private readonly stopFailure?: Error; - - constructor(c: ScriptedBackendCtor) { - this.kind = c.kind ?? 'ai-sdk'; - this.sessionId = c.sessionId ?? 'session-1'; - this.events = c.events; - this.gate = c.gate; - this.stopFailure = c.stopFailure; - } - - async *send(input: BackendSendInput): AsyncIterable { - this.sendCalls += 1; - this.sendInputs.push(input); - for (const e of this.events) { - this.yieldedEvents += 1; - yield e; - if (this.gate) await this.gate(); - } - } - - async stop(reason: 'user_stop' | 'redirect'): Promise { - this.stopCalls.push(reason); - if (this.stopFailure) throw this.stopFailure; - } - - async respondToSandboxBoundary(decision: SandboxBoundaryResponse): Promise { - this.permissionCalls.push(decision); - } - - async dispose(): Promise { - this.disposeCalls += 1; - } -} - -// ============================================================================ -// Event builders -// ============================================================================ - -let __seq = 0; -type DistributiveOmit = T extends any ? Omit : never; -function ev( - e: DistributiveOmit & Partial>, -): SessionEvent { - __seq += 1; - return { id: `evt-${__seq}`, turnId: 'turn-1', ts: e.ts ?? __seq, ...e } as SessionEvent; -} - -const ctx = { - sessionId: 'session-1', - invocationId: 'inv-1', - runId: 'run-1', - turnId: 'turn-1', - source: 'test', - startedAt: 999, - request: { - sessionId: 'session-1', - invocationId: 'inv-1', - runId: 'run-1', - turnId: 'turn-1', - text: 'hi', - source: 'test', - }, - newId: () => 'rt-id', - now: () => 1000, -} satisfies InvocationContext; - -function collect(stream: AsyncIterable): Promise { - const out: RuntimeEvent[] = []; - return (async () => { - for await (const e of stream) out.push(e); - return out; - })(); -} - -// ============================================================================ -// Tests -// ============================================================================ - -describe('AiSdkFlow seam', () => { - test('maps the original steering content digest into the durable Runtime event', () => { - const digest = `sha256:${'a'.repeat(64)}` as const; - const runtimeEvent = mapSessionEventToRuntimeEvent( - { - id: 'steering-event', - turnId: 'turn-1', - ts: 1, - type: 'steering_message', - messageId: 'steering-message', - content: { text: 'Prepared' }, - submittedContentDigest: digest, - }, - ctx, - ); - - assert.equal(runtimeEvent.refs?.sourceMessageDigest, digest); - }); - - test('single-flights a pending backend stop and retries after it settles', async () => { - const stopFailure = new Error('backend stop failed'); - const backend = new ScriptedBackend({ events: [], stopFailure }); - const flow = new AiSdkFlow({ backend }); - - const results = await Promise.allSettled([flow.stop('user_stop'), flow.stop('redirect')]); - - assert.deepEqual( - results.map((result) => result.status), - ['rejected', 'rejected'], - ); - assert.equal(results[0]?.status === 'rejected' && results[0].reason, stopFailure); - assert.equal(results[1]?.status === 'rejected' && results[1].reason, stopFailure); - assert.deepEqual(backend.stopCalls, ['user_stop']); - - await assert.rejects(flow.stop('redirect'), stopFailure); - assert.deepEqual(backend.stopCalls, ['user_stop', 'redirect']); - }); - - test('maps a normal turn preserving event order and terminal guarantee', async () => { - const backend = new ScriptedBackend({ - events: [ - ev({ type: 'text_delta', messageId: 'm1', text: 'Hel' }), - ev({ type: 'text_delta', messageId: 'm1', text: 'lo' }), - ev({ type: 'text_complete', messageId: 'm1', text: 'Hello' }), - ev({ - type: 'token_usage', - input: 10, - output: 5, - costUsd: 0.001, - systemPromptHash: 'sys-hash', - providerRequestTraceId: 'provider-trace-1', - }), - ev({ type: 'complete', stopReason: 'end_turn' }), - ], - }); - const flow = new AiSdkFlow({ backend }); - - const out = await collect(flow.run(ctx, { text: 'hi', context: [] })); - - assert.equal(out.length, 5); - // Order preserved. - assert.deepEqual( - out.map((e) => e.content?.kind ?? null), - ['text', 'text', 'text', null, null], - ); - // Deltas are partial; complete is not. - assert.equal(isPartialRuntimeEvent(out[0]), true); - assert.equal(isPartialRuntimeEvent(out[2]), false); - // Identity spine propagated. - assert.equal(out[0].invocationId, 'inv-1'); - assert.equal(out[0].runId, 'run-1'); - assert.equal(out[0].sessionId, 'session-1'); - assert.equal(out[0].turnId, 'turn-1'); - // id reused from source for 1:1 dedup linkage. - assert.equal(out[0].id, 'evt-1'); - // Token usage carried as an action. - assert.deepEqual(out[3].actions?.tokenUsage, { - input: 10, - output: 5, - costUsd: 0.001, - systemPromptHash: 'sys-hash', - }); - assert.deepEqual(out[3].refs, { providerRequestTraceId: 'provider-trace-1' }); - // Stream closes with a terminal event. - assert.equal(isTerminalRuntimeEvent(out[out.length - 1]), true); - assert.equal(out[out.length - 1].status, 'completed'); - assert.equal(out[out.length - 1].actions?.endInvocation, true); - // send was invoked exactly once with the turn id. - assert.equal(backend.sendCalls, 1); - }); - - test('RuntimeRunner dispatches AiSdkFlow with defined context and preserved attachments', async () => { - const attachment = { - kind: 'image' as const, - name: 'chart.png', - mimeType: 'image/png', - bytes: 123, - ref: { - kind: 'session_file' as const, - sessionId: 'session-1', - relativePath: 'attachments/chart.png', - }, - }; - const history = [ - { - type: 'user' as const, - id: 'u-prev', - turnId: 'turn-prev', - ts: 1, - text: 'previous', - }, - ]; - const runtimeContext: RuntimeEvent[] = [ - { - id: 'rt-prev', - invocationId: 'inv-prev', - runId: 'run-prev', - sessionId: 'session-1', - turnId: 'turn-prev', - ts: 1, - partial: false, - role: 'user', - author: 'user', - content: { kind: 'text', text: 'previous' }, - }, - ]; - const backend = new ScriptedBackend({ - events: [ - ev({ type: 'text_complete', messageId: 'm1', text: 'ok' }), - ev({ type: 'complete', stopReason: 'end_turn' }), - ], - }); - const flow = new AiSdkFlow({ backend }); - let idSeq = 0; - const runner = new RuntimeRunner({ - flow, - providers: { - newId: () => `rt-${(idSeq += 1)}`, - now: () => 1000, - }, - }); - - const result = await runner.run({ - sessionId: 'session-1', - turnId: 'turn-1', - text: 'hi', - attachments: [attachment], - context: history, - runtimeContext, - source: 'test', - }); - - assert.equal(result.status, 'completed'); - assert.equal(result.finalOutput, 'ok'); - assert.equal(backend.sendInputs.length, 1); - assert.deepEqual(backend.sendInputs[0], { - invocationId: 'rt-1', - runId: 'rt-2', - turnId: 'turn-1', - text: 'hi', - attachments: [attachment], - context: history, - runtimeContext, - }); - }); - - test('maps thinking deltas/signature onto model thinking content', async () => { - const backend = new ScriptedBackend({ - events: [ - ev({ type: 'thinking_delta', messageId: 'm1', text: 'hm' }), - ev({ type: 'thinking_complete', messageId: 'm1', text: 'hmm', signature: 'sig' }), - ev({ type: 'complete', stopReason: 'end_turn' }), - ], - }); - const flow = new AiSdkFlow({ backend }); - const out = await collect(flow.run(ctx, { text: 'hi', context: [] })); - - assert.equal(isPartialRuntimeEvent(out[0]), true); - assert.equal(out[1].content?.kind, 'thinking'); - assert.equal((out[1].content as { signature?: string }).signature, 'sig'); - assert.equal(isPartialRuntimeEvent(out[1]), false); - }); - - test('preserves toolName linkage between tool_start and tool_result', async () => { - const backend = new ScriptedBackend({ - events: [ - ev({ type: 'tool_start', toolUseId: 'tu-1', toolName: 'read', args: { path: '/a' } }), - ev({ - type: 'tool_result', - toolUseId: 'tu-1', - isError: false, - content: { kind: 'text', text: 'body' }, - durationMs: 42, - }), - ev({ type: 'complete', stopReason: 'end_turn' }), - ], - }); - const flow = new AiSdkFlow({ backend }); - const out = await collect(flow.run(ctx, { text: 'read it', context: [] })); - - // tool_start -> function_call - const call = out[0]; - assert.equal(call.role, 'model'); - assert.equal(call.author, 'agent'); - assert.equal(call.content?.kind, 'function_call'); - const fnCall = call.content as { id: string; name: string; args: unknown }; - assert.equal(fnCall.name, 'read'); - assert.equal(fnCall.id, 'tu-1'); - assert.equal(call.refs?.toolCallId, 'tu-1'); - - // tool_result -> function_response with the remembered name - const result = out[1]; - assert.equal(result.role, 'tool'); - assert.equal(result.author, 'tool'); - assert.equal(result.content?.kind, 'function_response'); - const fnResp = result.content as { - id: string; - name: string; - result: unknown; - isError?: boolean; - }; - assert.equal(fnResp.name, 'read', 'tool_result recovers toolName from the prior tool_start'); - assert.equal(fnResp.isError, undefined); - assert.equal(result.refs?.toolCallId, 'tu-1'); - assert.deepEqual(result.actions?.stateDelta, { durationMs: 42 }); - }); - - test('maps sandbox boundary requests and decisions as first-class runtime actions', async () => { - const backend = new ScriptedBackend({ - events: [ - ev({ - type: 'sandbox_boundary_request', - requestId: 'boundary-1', - toolUseId: 'tu-boundary', - justification: 'Write the requested export.', - expansion: { - filesystem: { - entries: [{ path: '/tmp/export.txt', access: 'write', scope: 'exact' }], - }, - }, - }), - ev({ - type: 'sandbox_boundary_decision_ack', - requestId: 'boundary-1', - toolUseId: 'tu-boundary', - decision: 'allow', - status: 'approved', - revision: 2, - }), - ev({ type: 'complete', stopReason: 'end_turn' }), - ], - }); - const flow = new AiSdkFlow({ backend }); - const out = await collect(flow.run(ctx, { text: 'do it', context: [] })); - - const req = out[0]; - assert.equal(req.author, 'system'); - assert.deepEqual(req.actions?.stateDelta?.sandboxBoundaryRequest, { - requestId: 'boundary-1', - toolUseId: 'tu-boundary', - justification: 'Write the requested export.', - expansion: { - filesystem: { - entries: [{ path: '/tmp/export.txt', access: 'write', scope: 'exact' }], - }, - }, - }); - - const ack = out[1]; - assert.equal(ack.author, 'user'); - assert.deepEqual(ack.actions?.stateDelta?.sandboxBoundaryDecision, { - requestId: 'boundary-1', - decision: 'allow', - status: 'approved', - revision: 2, - }); - assert.equal(out[2].status, 'completed'); - }); - - test('maps the error path preserving error content + terminal failed', async () => { - const backend = new ScriptedBackend({ - events: [ - ev({ - type: 'error', - recoverable: false, - code: 'AUTH', - reason: 'auth_failed', - message: 'no token', - boundedProviderMessage: true, - }), - ev({ type: 'complete', stopReason: 'error' }), - ], - }); - const flow = new AiSdkFlow({ backend }); - const out = await collect(flow.run(ctx, { text: 'hi', context: [] })); - - const err = out[0]; - assert.equal(err.content?.kind, 'error'); - const errContent = err.content as { - code?: string; - reason?: string; - message: string; - boundedProviderMessage?: boolean; - }; - assert.equal(errContent.message, 'no token'); - assert.equal(errContent.code, 'AUTH'); - assert.equal(errContent.reason, 'auth_failed'); - assert.equal(errContent.boundedProviderMessage, true); - // error event itself is non-terminal; the trailing complete carries failed. - assert.equal(isTerminalRuntimeEvent(err), false); - - assert.equal(out[1].status, 'failed'); - assert.equal(isTerminalRuntimeEvent(out[1]), true); - assert.deepEqual(out[1].content, err.content); - }); - - test('synthesizes a failed terminal event when the backend exhausts without one', async () => { - const seen: SessionEvent[] = []; - let idSeq = 0; - const backend = new ScriptedBackend({ - events: [ev({ type: 'text_delta', messageId: 'm1', text: 'partial answer' })], - }); - const flow = new AiSdkFlow({ - backend, - onSessionEvent: (sessionEvent) => { - seen.push(sessionEvent); - }, - }); - const out = await collect( - flow.run( - { ...ctx, newId: () => `synthetic-${(idSeq += 1)}`, now: () => 2000 }, - { text: 'hi', context: [] }, - ), - ); - - assert.deepEqual( - seen.map((event) => event.type), - ['text_delta', 'error', 'complete'], - ); - assert.equal(seen[1]?.type, 'error'); - assert.equal( - (seen[1] as Extract).reason, - 'missing_terminal_event', - ); - assert.equal(seen[2]?.type, 'complete'); - assert.equal((seen[2] as Extract).stopReason, 'error'); - assert.equal(out.at(-2)?.content?.kind, 'error'); - assert.equal( - (out.at(-2)?.content as { reason?: string } | undefined)?.reason, - 'missing_terminal_event', - ); - assert.equal(out.at(-1)?.status, 'failed'); - assert.equal(out.filter(isTerminalRuntimeEvent).length, 1); - }); - - test('maps the abort path to exactly one terminal event', async () => { - const backend = new ScriptedBackend({ - events: [ - ev({ type: 'text_delta', messageId: 'm1', text: 'par' }), - ev({ type: 'abort', reason: 'user_stop' }), - ev({ type: 'complete', stopReason: 'user_stop' }), - ], - }); - const flow = new AiSdkFlow({ backend }); - const out = await collect(flow.run(ctx, { text: 'hi', context: [] })); - - // AgentFlow guarantees exactly one terminal event, so the trailing - // complete(user_stop) from the legacy backend is coalesced away. - assert.equal(out.length, 2); - assert.equal(out[1].status, 'aborted'); - assert.equal(out[1].actions?.endInvocation, true); - assert.equal(isTerminalRuntimeEvent(out[1]), true); - assert.equal(out.filter(isTerminalRuntimeEvent).length, 1); - }); - - test('stops yielding after the first terminal event', async () => { - const backend = new ScriptedBackend({ - events: [ - ev({ type: 'abort', reason: 'user_stop' }), - ev({ type: 'text_delta', messageId: 'm1', text: 'after-terminal' }), - ev({ type: 'complete', stopReason: 'user_stop' }), - ], - }); - const flow = new AiSdkFlow({ backend }); - const out = await collect(flow.run(ctx, { text: 'hi', context: [] })); - - assert.equal(out.length, 1); - assert.equal(out[0]?.status, 'aborted'); - assert.equal(isTerminalRuntimeEvent(out[0]), true); - }); - - test('can silently drain backend events after a terminal while coalescing duplicate terminals', async () => { - const seen: SessionEvent[] = []; - const backend = new ScriptedBackend({ - events: [ - ev({ type: 'abort', reason: 'user_stop' }), - ev({ type: 'text_delta', messageId: 'm1', text: 'cleanup-after-terminal' }), - ev({ type: 'complete', stopReason: 'user_stop' }), - ], - }); - const flow = new AiSdkFlow({ - backend, - drainAfterTerminal: true, - onSessionEvent: (sessionEvent) => { - seen.push(sessionEvent); - }, - }); - const out = await collect(flow.run(ctx, { text: 'hi', context: [] })); - - assert.equal(backend.yieldedEvents, 3); - assert.deepEqual( - seen.map((event) => event.type), - ['abort'], - ); - assert.deepEqual( - out.map((event) => event.content?.kind ?? event.status ?? null), - ['aborted'], - ); - assert.equal(out.filter(isTerminalRuntimeEvent).length, 1); - }); - - test('reports terminal onSessionEvent failures before accepting the terminal event', async () => { - const seenErrors: string[] = []; - const backend = new ScriptedBackend({ - events: [ - ev({ type: 'complete', stopReason: 'end_turn' }), - ev({ type: 'text_delta', messageId: 'm1', text: 'after-terminal' }), - ], - }); - const flow = new AiSdkFlow({ - backend, - drainAfterTerminal: true, - onSessionEvent: () => { - throw new Error('terminal write failed'); - }, - onError: (error) => { - seenErrors.push(error instanceof Error ? error.message : String(error)); - }, - }); - - await assert.rejects( - collect(flow.run(ctx, { text: 'hi', context: [] })), - /terminal write failed/, - ); - assert.deepEqual(seenErrors, ['terminal write failed']); - assert.equal(backend.yieldedEvents, 1); - }); - - test('RuntimeRunner consumes AiSdkFlow abort as one coherent failed outcome', async () => { - const backend = new ScriptedBackend({ - events: [ - ev({ type: 'text_delta', messageId: 'm1', text: 'par' }), - ev({ type: 'abort', reason: 'user_stop' }), - ev({ type: 'complete', stopReason: 'user_stop' }), - ], - }); - const flow = new AiSdkFlow({ backend }); - let idSeq = 0; - const runner = new RuntimeRunner({ - flow, - providers: { - newId: () => `id-${(idSeq += 1)}`, - now: () => 1000, - }, - }); - - const result = await runner.run({ - sessionId: 'session-1', - turnId: 'turn-1', - text: 'hi', - source: 'test', - }); - - assert.equal(result.status, 'failed'); - assert.equal(result.failure?.class, 'aborted'); - assert.equal(result.events.filter(isTerminalRuntimeEvent).length, 1); - }); - - test('delegates stop / respondToSandboxBoundary / dispose to the wrapped backend', async () => { - const backend = new ScriptedBackend({ events: [] }); - const flow = new AiSdkFlow({ backend }); - - await flow.stop('redirect'); - await flow.respondToSandboxBoundary({ requestId: 'r', decision: 'allow' }); - await flow.dispose(); - - assert.deepEqual(backend.stopCalls, ['redirect']); - assert.deepEqual(backend.permissionCalls, [{ requestId: 'r', decision: 'allow' }]); - assert.equal(backend.disposeCalls, 1); - }); - - test('throws on session id mismatch between ctx and backend', async () => { - const backend = new ScriptedBackend({ sessionId: 'session-1', events: [] }); - const flow = new AiSdkFlow({ backend }); - - await assert.rejects( - collect(flow.run({ ...ctx, sessionId: 'other' }, { text: 'hi', context: [] })), - /AiSdkFlow session mismatch/, - ); - }); - - test('bridges FlowInput.abortSignal onto backend.stop("user_stop")', async () => { - let releaseGate: () => void = () => {}; - const gate = new Promise((resolve) => { - releaseGate = resolve; - }); - - const backend = new ScriptedBackend({ - events: [ - ev({ type: 'text_delta', messageId: 'm1', text: 'x' }), - ev({ type: 'complete', stopReason: 'end_turn' }), - ], - gate: () => gate, - }); - // stop releases the gate so send() can advance to the terminal event. - const realStop = backend.stop.bind(backend); - backend.stop = async (reason) => { - await realStop(reason); - releaseGate(); - }; - - const flow = new AiSdkFlow({ backend }); - const ctrl = new AbortController(); - const runPromise = collect( - flow.run(ctx, { text: 'hi', context: [], abortSignal: ctrl.signal }), - ); - - // Let the generator yield the first event and park on the gate. - await new Promise((r) => setTimeout(r, 0)); - ctrl.abort(); - const out = await runPromise; - - assert.deepEqual(backend.stopCalls, ['user_stop']); - assert.equal(out.length, 2); - assert.equal(isTerminalRuntimeEvent(out[out.length - 1]), true); - }); - - test('maps provider retry progress as a partial non-terminal runtime fact', () => { - const retry = ev({ - type: 'provider_retry', - phase: 'scheduled', - attempt: 2, - maxAttempts: 10, - delayMs: 4_000, - reason: 'rate_limit', - }); - - const mapped = mapSessionEventToRuntimeEvent(retry, ctx); - - assert.equal(mapped.partial, true); - assert.equal(isTerminalRuntimeEvent(mapped), false); - assert.deepEqual(mapped.actions?.stateDelta, { - providerRetry: { - phase: 'scheduled', - attempt: 2, - maxAttempts: 10, - delayMs: 4_000, - reason: 'rate_limit', - }, - }); - }); - - 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', - }, - }); - }); -}); - -// ============================================================================ -// Pure mapping unit tests -// ============================================================================ - -describe('mapSessionEventToRuntimeEvent (pure)', () => { - test('mapCompleteStopReason covers all stop reasons', () => { - assert.equal(mapCompleteStopReason('end_turn'), 'completed'); - assert.equal(mapCompleteStopReason('max_tokens'), 'completed'); - assert.equal(mapCompleteStopReason('plan_handoff'), 'completed'); - assert.equal(mapCompleteStopReason('graph_yield'), 'completed'); - assert.equal(mapCompleteStopReason('permission_handoff'), 'completed'); - assert.equal(mapCompleteStopReason('user_stop'), 'aborted'); - assert.equal(mapCompleteStopReason('error'), 'failed'); - assert.equal(mapCompleteStopReason('step_limit'), 'failed'); - }); - - test('step_limit uses the established tool-step-cap failure class', () => { - const mapped = mapSessionEventToRuntimeEvent( - ev({ type: 'complete', stopReason: 'step_limit' }), - ctx, - createSessionEventMapMemory(), - ); - - assert.deepEqual(mapped.actions?.stateDelta, { - stopReason: 'step_limit', - failureClass: 'tool_step_cap_reached', - }); - }); - - test('context_budget_exhausted keeps its detail in the durable terminal state', () => { - const mapped = mapSessionEventToRuntimeEvent( - ev({ - type: 'complete', - stopReason: 'context_budget_exhausted', - contextBudgetExhaustedDetail: 'head_anchor_exceeds_capacity', - }), - ctx, - createSessionEventMapMemory(), - ); - - assert.equal(mapped.status, 'failed'); - assert.deepEqual(mapped.actions?.stateDelta, { - stopReason: 'context_budget_exhausted', - failureClass: 'context_budget_exhausted', - contextBudgetExhaustedDetail: 'head_anchor_exceeds_capacity', - }); - }); - - test('tool_output_delta and tool_progress map to partial tool-role heartbeats', () => { - const mem = createSessionEventMapMemory(); - const a = mapSessionEventToRuntimeEvent( - ev({ - type: 'tool_output_delta', - sessionId: 'session-1', - toolCallId: 'tu-1', - toolUseId: 'tu-1', - seq: 1, - stream: 'stdout', - chunk: 'c', - redacted: false, - createdAt: 1, - }), - ctx, - mem, - ); - assert.equal(a.partial, true); - assert.equal(a.role, 'tool'); - assert.equal(a.author, 'tool'); - assert.equal(a.refs?.toolCallId, 'tu-1'); - - const b = mapSessionEventToRuntimeEvent( - ev({ type: 'tool_progress', toolUseId: 'tu-1', chunk: 'c' }), - ctx, - mem, - ); - assert.equal(b.partial, true); - assert.equal(b.role, 'tool'); - }); - - test('tool activity mapping retains nested CodeMode replay and parent identity', () => { - const memory = createSessionEventMapMemory(); - const start = mapSessionEventToRuntimeEvent( - ev({ - type: 'tool_start', - toolUseId: 'nested-1', - toolName: 'Read', - operationId: 'nested-op-1', - args: {}, - origin: 'code_mode', - modelVisibility: 'hidden', - parentToolCallId: 'exec-1', - parentOperationId: 'exec-op-1', - }), - ctx, - memory, - ); - const result = mapSessionEventToRuntimeEvent( - ev({ - type: 'tool_result', - toolUseId: 'nested-1', - operationId: 'nested-op-1', - isError: false, - content: { kind: 'text', text: 'ok' }, - origin: 'code_mode', - modelVisibility: 'hidden', - parentToolCallId: 'exec-1', - parentOperationId: 'exec-op-1', - }), - ctx, - memory, - ); - - for (const event of [start, result]) { - assert.equal(event.origin, 'code_mode'); - assert.equal(event.modelVisibility, 'hidden'); - assert.equal(event.refs?.parentToolCallId, 'exec-1'); - assert.equal(event.refs?.parentOperationId, 'exec-op-1'); - } - }); - - test('owns independent tool args across SessionEvent to RuntimeEvent mappings', () => { - const sourceArgs = { content: 'approved', layout: { cols: 120 } }; - const sourceEvent = ev({ - type: 'tool_start', - toolUseId: 'tu-owned', - toolName: 'Write', - args: sourceArgs, - }); - const mapped = mapSessionEventToRuntimeEvent(sourceEvent, ctx, createSessionEventMapMemory()); - const mappedArgs = ( - mapped.content?.kind === 'function_call' ? mapped.content.args : undefined - ) as typeof sourceArgs; - - assert.notStrictEqual(mappedArgs, sourceArgs); - assert.notStrictEqual(mappedArgs.layout, sourceArgs.layout); - sourceArgs.layout.cols = 80; - assert.equal(mappedArgs.layout.cols, 120); - mappedArgs.content = 'runtime'; - assert.equal(sourceArgs.content, 'approved'); - }); - - test('plan_submitted maps to an agent-authored state delta', () => { - const a = mapSessionEventToRuntimeEvent( - ev({ type: 'plan_submitted', planId: 'p1', title: 'T', markdownPath: '/p.md' }), - ctx, - ); - assert.equal(a.role, 'system'); - assert.equal(a.author, 'agent'); - assert.deepEqual(a.actions?.stateDelta, { planId: 'p1', title: 'T', markdownPath: '/p.md' }); - }); - - test('user_question_request maps to one system-authored runtime action', () => { - const mapped = mapSessionEventToRuntimeEvent( - ev({ - type: 'user_question_request', - requestId: 'question-1', - toolUseId: 'tool-1', - questions: [ - { - question: 'Choose an approach', - options: [ - { label: 'Extend', description: 'Reuse the runtime seam' }, - { label: 'Separate' }, - ], - }, - ], - }), - ctx, - ); - - assert.equal(mapped.role, 'system'); - assert.equal(mapped.author, 'system'); - assert.deepEqual(mapped.actions?.userQuestionRequest, { - requestId: 'question-1', - toolUseId: 'tool-1', - questions: [ - { - question: 'Choose an approach', - options: [ - { label: 'Extend', description: 'Reuse the runtime seam' }, - { label: 'Separate' }, - ], - }, - ], - }); - }); - - test('user_question_answer_ack maps without duplicating the canonical answer', () => { - const mapped = mapSessionEventToRuntimeEvent( - ev({ - type: 'user_question_answer_ack', - requestId: 'question-1', - toolUseId: 'tool-1', - }), - ctx, - ); - - assert.equal(mapped.role, 'system'); - assert.equal(mapped.author, 'user'); - assert.deepEqual(mapped.actions?.userQuestionAnswerAccepted, { - requestId: 'question-1', - }); - assert.equal(mapped.refs?.toolCallId, 'tool-1'); - }); - - test('tool_result without a prior tool_start still maps (name falls back to empty)', () => { - const a = mapSessionEventToRuntimeEvent( - ev({ - type: 'tool_result', - toolUseId: 'orphan', - isError: true, - content: { kind: 'text', text: 'boom' }, - }), - ctx, - ); - const fnResp = a.content as { name: string; isError?: boolean }; - assert.equal(fnResp.name, ''); - assert.equal(fnResp.isError, true); - }); - - test('branch is propagated when present on the context', () => { - const a = mapSessionEventToRuntimeEvent(ev({ type: 'complete', stopReason: 'end_turn' }), { - ...ctx, - branch: 'agent-b', - }); - assert.equal(a.branch, 'agent-b'); - }); -}); - -// ============================================================================ -// Projection coverage contract -// ============================================================================ - -/** - * One sample per backend-mappable SessionEvent variant. `subject` is typed to - * its own key, so a new variant cannot be satisfied by an empty list or by - * some other event that happens to project cleanly; `before` and `after` carry - * only the companions that variant's projection needs. - */ -type ProjectionSamples = { - [K in BackendSessionEvent['type']]: { - subject: Extract; - before?: SessionEvent[]; - after?: SessionEvent[]; - }; -}; - -const PROJECTION_SAMPLES: ProjectionSamples = { - text_delta: { - subject: { type: 'text_delta', id: 'e', turnId: 'turn-1', ts: 1, messageId: 'm1', text: 'h' }, - }, - text_complete: { - subject: { - type: 'text_complete', - id: 'e', - turnId: 'turn-1', - ts: 1, - messageId: 'm1', - text: 'hi', - }, - }, - thinking_delta: { - subject: { - type: 'thinking_delta', - id: 'e', - turnId: 'turn-1', - ts: 1, - messageId: 'm1', - text: 'h', - }, - }, - thinking_complete: { - subject: { - type: 'thinking_complete', - id: 'e1', - turnId: 'turn-1', - ts: 1, - messageId: 'm1', - text: 'why', - }, - // Thinking is held until the assistant text row that shares its message id. - after: [ - { type: 'text_complete', id: 'e2', turnId: 'turn-1', ts: 2, messageId: 'm1', text: 'hi' }, - ], - }, - tool_start: { - subject: { - type: 'tool_start', - id: 'e', - turnId: 'turn-1', - ts: 1, - toolUseId: 'tool-1', - toolName: 'Read', - args: { path: '/tmp/a' }, - }, - }, - tool_output_delta: { - subject: { - type: 'tool_output_delta', - id: 'e', - turnId: 'turn-1', - ts: 1, - sessionId: 'session-1', - toolCallId: 'tool-1', - toolUseId: 'tool-1', - seq: 1, - stream: 'stdout', - chunk: 'out', - redacted: false, - createdAt: 1, - }, - }, - tool_progress: { - subject: { - type: 'tool_progress', - id: 'e', - turnId: 'turn-1', - ts: 1, - toolUseId: 'tool-1', - chunk: 'x', - }, - }, - tool_result_preview: { - subject: { - type: 'tool_result_preview', - id: 'e', - turnId: 'turn-1', - ts: 1, - toolUseId: 'tool-1', - isError: false, - content: { - kind: 'subagent', - childSessionId: 'child-1', - agentName: 'Local Read', - turnId: 'child-turn', - status: 'running', - permissionMode: 'explore', - }, - }, - }, - tool_result: { - subject: { - type: 'tool_result', - id: 'e2', - turnId: 'turn-1', - ts: 2, - toolUseId: 'tool-1', - isError: false, - content: { kind: 'text', text: 'ok' }, - }, - // A result carries no tool name of its own; the mapper reads it from the call. - before: [ - { - type: 'tool_start', - id: 'e1', - turnId: 'turn-1', - ts: 1, - toolUseId: 'tool-1', - toolName: 'Read', - args: { path: '/tmp/a' }, - }, - ], - }, - sandbox_boundary_request: { - subject: { - type: 'sandbox_boundary_request', - id: 'e', - turnId: 'turn-1', - ts: 1, - requestId: 'boundary-1', - toolUseId: 'tool-1', - justification: 'read a file outside the workspace', - expansion: { - filesystem: { entries: [{ path: '/tmp/outside.txt', access: 'read', scope: 'exact' }] }, - }, - }, - }, - sandbox_boundary_decision_ack: { - subject: { - type: 'sandbox_boundary_decision_ack', - id: 'e', - turnId: 'turn-1', - ts: 1, - requestId: 'boundary-1', - toolUseId: 'tool-1', - decision: 'allow', - status: 'approved', - revision: 2, - }, - }, - user_question_request: { - subject: { - type: 'user_question_request', - id: 'e', - turnId: 'turn-1', - ts: 1, - requestId: 'q-1', - toolUseId: 'tool-1', - questions: [{ question: 'Which one?', options: [{ label: 'A', description: 'a' }] }], - }, - }, - user_question_answer_ack: { - subject: { - type: 'user_question_answer_ack', - id: 'e', - turnId: 'turn-1', - ts: 1, - requestId: 'q-1', - toolUseId: 'tool-1', - }, - }, - plan_submitted: { - subject: { - type: 'plan_submitted', - id: 'e', - turnId: 'turn-1', - ts: 1, - planId: 'plan-1', - title: 'Plan', - }, - }, - token_usage: { - subject: { - type: 'token_usage', - id: 'e', - turnId: 'turn-1', - ts: 1, - input: 10, - output: 5, - total: 15, - }, - }, - steering_message: { - subject: { - type: 'steering_message', - id: 'e', - turnId: 'turn-1', - ts: 1, - messageId: 'm2', - content: { text: 'steer' }, - }, - }, - provider_retry: { - subject: { - type: 'provider_retry', - id: 'e', - turnId: 'turn-1', - ts: 1, - phase: 'started', - attempt: 2, - maxAttempts: 3, - reason: 'rate_limit', - }, - }, - error: { - subject: { - type: 'error', - id: 'e1', - turnId: 'turn-1', - ts: 1, - recoverable: false, - message: 'boom', - }, - // An error is always followed by a terminal complete carrying the failure. - after: [{ type: 'complete', id: 'e2', turnId: 'turn-1', ts: 2, stopReason: 'error' }], - }, - complete: { - subject: { type: 'complete', id: 'e', turnId: 'turn-1', ts: 1, stopReason: 'end_turn' }, - }, - abort: { subject: { type: 'abort', id: 'e', turnId: 'turn-1', ts: 1, reason: 'user_stop' } }, -}; - -const projectionRunHeader: AgentRunHeader = { - runId: 'run-1', - sessionId: 'session-1', - turnId: 'turn-1', - status: 'completed', - backendKind: 'ai-sdk', - llmConnectionSlug: 'anthropic', - modelId: 'model-1', - cwd: '/tmp', - permissionMode: 'ask', - createdAt: 1, - updatedAt: 2, - completedAt: 2, -}; - -describe('SessionEvent projection coverage', () => { - // The contract is over what a reader can actually meet: every mapped event - // AgentRun admits to the ledger has to project. It asserts on the unclaimed - // codes at either severity, not on the hard one alone — a control fact whose - // gap only degrades the view is still a gap, and must be found here rather - // than by a user opening the session. - for (const [type, sample] of Object.entries(PROJECTION_SAMPLES)) { - test(`${type} projects without an unclaimed-event diagnostic`, () => { - let seq = 0; - const memory = createSessionEventMapMemory(); - const runtimeEvents = [...(sample.before ?? []), sample.subject, ...(sample.after ?? [])] - .map((event) => - mapSessionEventToRuntimeEvent( - event, - { - ...ctx, - newId: () => { - seq += 1; - return `rt-${seq}`; - }, - }, - memory, - ), - ) - .filter((event) => !isNonTerminalErrorRuntimeEvent(event)); - - const projected = projectRuntimeEventsToStoredMessages(runtimeEvents, { - runHeaders: [projectionRunHeader], - }); - - assert.deepEqual(projected.diagnostics.filter(isUnclaimedRuntimeEventDiagnostic), []); - }); - } - - // The guard's fallback is what a variant added without a claim actually - // becomes. It has to stay on the degradable side of the line: control-only, - // so the session it lands in still opens, and still reported so the gap the - // coverage contract would have caught is not invisible at runtime. - test('an unmapped SessionEvent maps to a reported control-only fact', () => { - const unmapped = { type: 'not_yet_mapped', id: 'e', turnId: 'turn-1', ts: 1 }; - const memory = createSessionEventMapMemory(); - const runtimeEvent = mapSessionEventToRuntimeEvent( - unmapped as unknown as SessionEvent, - ctx, - memory, - ); - - assert.equal(runtimeEvent.content, undefined); - assert.equal(runtimeEvent.actions?.stateDelta?.unmappedSessionEventType, 'not_yet_mapped'); - - const projected = projectRuntimeEventsToStoredMessages([runtimeEvent], { - runHeaders: [projectionRunHeader], - }); - assert.deepEqual(projected.messages, []); - // Filtered through the predicate the contract above uses, not just compared - // to the code string: dropping the soft code from the predicate would - // otherwise loosen the contract to `unsupported_event` only, silently. - assert.deepEqual( - projected.diagnostics.filter(isUnclaimedRuntimeEventDiagnostic).map((d) => d.code), - ['unclaimed_control_fact'], - ); - assert.equal(projected.diagnostics.length, 1); - }); -}); From e5247828a7cfce0e504ad2fb1b5607cf8bdae581 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Tue, 25 Aug 2026 15:51:25 +0800 Subject: [PATCH 20/20] chore: retrigger CI Generated-by: maka