Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
189 changes: 189 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,189 @@
/*
* 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';

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 @@ -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 {
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 @@ -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 }),
};
}

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 @@ -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:';

Expand Down Expand Up @@ -60,21 +61,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;
}
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
Loading