Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 170 additions & 0 deletions apps/desktop/src/main/__tests__/provider-failure-presentation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
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,
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', () => {
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: '检查模型服务的额度、套餐或恢复时间',
},
);
});

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<SessionEvent, { type: 'error' }> = {
type: 'error',
id: 'event-kimi-plan-limit',
turnId: 'turn-kimi-plan-limit',
ts: 1,
recoverable: false,
code: 'permission_error',
boundedProviderMessage: true,
message,
};

assert.equal(sessionEventErrorMessage(event), message);
assert.equal(sessionEventErrorMessage(event, 'en'), message);
});

test('preserves a bounded provider summary without a provider code', () => {
const event: Extract<SessionEvent, { type: 'error' }> = {
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<SessionEvent, { type: 'error' }> = {
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<SessionEvent, { type: 'error' }> = {
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 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.equal(
commandPaletteConnectionTestFailureMessage(result, 'en'),
'The connection test failed. Try again later.',
);
});

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);
});

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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,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 {
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/main/runtime-host-connections-ipc-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,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 }),
};
}

Expand Down
32 changes: 23 additions & 9 deletions apps/desktop/src/renderer/app-shell-copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,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:';

Expand Down Expand Up @@ -41,21 +42,34 @@ 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 accountFailure = describeProviderAccountFailure(result.providerFailure?.errorClass, locale);
if (accountFailure) return accountFailure;
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;
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
}
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;
}

Expand Down
8 changes: 5 additions & 3 deletions apps/desktop/src/renderer/locales/conversation-copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,15 +288,17 @@ export interface DesktopConversationCopy {
timeout: string;
auth: string;
providerBilling: string;
providerPermission: string;
rateLimit: string;
usageLimit: string;
network: string;
provider: string;
stepCap: string;
tool: string;
permission: string;
restarted: string;
sandboxBoundaryClosed: string;
recovery: Record<'safeResume' | 'stepCap' | 'toolError' | 'connection' | 'partial' | 'toolRecord' | 'retry' | 'sandboxBoundaryClosed', string>;
recovery: Record<'safeResume' | 'stepCap' | 'toolError' | 'connection' | 'account' | 'partial' | 'toolRecord' | 'retry' | 'sandboxBoundaryClosed', string>;
};
}

Expand Down Expand Up @@ -566,7 +568,7 @@ const COPY = {
reauth: { label: '上次连接测试鉴权失败', tooltip: '最近一次连接测试返回鉴权失败(401 / 403),密钥可能已过期或被吊销。这不会拦截发送,但若发送失败请到 设置 · 模型 重新登录。' },
testError: { label: '上次连接测试失败', tooltip: '最近一次连接测试因网络 / 超时 / 5xx 失败。这不会拦截发送,但若问题持续请到 设置 · 模型 检查 Base URL / 代理。' },
},
turnError: { unknown: '未知错误', contextOverflow: '上下文窗口已超出限制', timeout: '请求超时', auth: '鉴权失败', providerBilling: '模型服务计费受限', rateLimit: '触发模型速率限制', network: '网络错误', provider: '模型服务返回错误', stepCap: '达到工具步骤上限', tool: '工具调用失败', permission: '等待权限确认', restarted: '本地应用重启,上一轮没有完成', sandboxBoundaryClosed: '本地应用重启,等待确认的「允许访问工作区以外的内容」请求已按拒绝关闭', recovery: { safeResume: '检查当前状态后,可尝试安全恢复', stepCap: '任务可能尚未完成,可以继续', toolError: '先检查工具结果,再决定是否重试', connection: '先检查模型连接或登录状态', partial: '已保留部分输出,可从这里继续', toolRecord: '工具记录已保留,重试前先看结果', retry: '没有执行工具,可直接重试', 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: '没有执行工具,可直接重试', 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' },
Expand Down Expand Up @@ -768,7 +770,7 @@ const COPY = {
reauth: { label: 'Last connection test failed authentication', tooltip: 'The latest test returned 401 / 403. Sending is not blocked, but sign in again under Settings · Models if it fails.' },
testError: { label: 'Last connection test failed', tooltip: 'The latest test failed because of a network, timeout, or 5xx error. Sending is not blocked; check Base URL or proxy settings if it persists.' },
},
turnError: { unknown: 'Unknown error', contextOverflow: 'Context window exceeded', timeout: 'Request timed out', auth: 'Authentication failed', providerBilling: 'Provider billing required', rateLimit: 'Model rate limit reached', network: 'Network error', provider: 'Model service error', stepCap: 'Tool-step limit reached', tool: 'Tool call failed', permission: 'Waiting for permission', restarted: 'The app restarted before the previous turn completed', sandboxBoundaryClosed: 'The app restarted, so the pending request to reach outside the workspace was closed as denied', recovery: { safeResume: 'Inspect the current state, then try safe recovery', stepCap: 'The task may be incomplete; continue from here', toolError: 'Inspect the tool result before retrying', connection: 'Check the model connection or sign-in status', partial: 'Partial output was retained; continue from here', toolRecord: 'Tool history was retained; inspect it before retrying', retry: 'No tools ran; retry directly', 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', sandboxBoundaryClosed: 'Access was not widened; retry the turn to decide again' } },
},
} satisfies UiCatalog<DesktopConversationCopy>;

Expand Down
14 changes: 12 additions & 2 deletions apps/desktop/src/renderer/model-connection-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,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';
Expand Down Expand Up @@ -43,8 +42,19 @@ 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. 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.message.length > 0) {
return event.message;
}

const fallback = getDesktopConversationCopy(locale).actions.conversationErrorFallback;
return localizedShellErrorMessage(new Error(event.message), fallback, locale);
return fallback;
}

/**
Expand Down
Loading