Skip to content
Merged
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
2 changes: 1 addition & 1 deletion apps/desktop/renderer-architecture.json
Original file line number Diff line number Diff line change
Expand Up @@ -715,7 +715,7 @@
"react": 1
},
"importSpecifiers": 11,
"nonTriviaTokens": 1399
"nonTriviaTokens": 1395
},
"src/renderer/app-shell.tsx": {
"importDeclarations": 79,
Expand Down
12 changes: 2 additions & 10 deletions apps/desktop/src/main/__tests__/session-error-presentation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,21 +21,13 @@ import assert from 'node:assert/strict';
import { describe, it } from 'node:test';

import { describeSessionErrorReason } from '../../renderer/session-error-presentation.js';
import { sessionEventErrorMessage } from '../../renderer/model-connection-errors.js';
import { describeTurnErrorClass } from '../../renderer/session-status-presentation.js';

describe('provider capacity presentation', () => {
it('uses capacity-specific copy instead of the unknown error fallback', () => {
assert.match(describeSessionErrorReason('provider_capacity', 'zh-CN') ?? '', /满载/);
assert.match(describeSessionErrorReason('provider_capacity', 'en') ?? '', /at capacity/);
assert.match(describeTurnErrorClass('provider_capacity', 'zh-CN'), /满载/);
assert.match(describeTurnErrorClass('provider_capacity', 'en'), /at capacity/);
});

it('does not recommend an immediate direct retry', () => {
const label = describeTurnErrorClass('provider_capacity', 'zh-CN');
assert.match(label, /等几分钟|换一个模型/);
assert.doesNotMatch(label, /直接重试/);
assert.equal(describeTurnErrorClass('provider_capacity', 'zh-CN'), '模型服务暂时满载。');
assert.equal(describeTurnErrorClass('provider_capacity', 'en'), 'The model service is temporarily at capacity.');
});
});

Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@

import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import type { TurnViewModel } from '@maka/ui';
import { deriveAppShellTurnPresentation } from '../../renderer/app-shell-turn-view-model.js';
import {
describeFailedTurnExecutionState,
describeTurnErrorClass,
Expand All @@ -39,13 +41,20 @@ describe('failed turn presentation', () => {
assert.equal(describeTurnErrorClass('ECONNRESET', 'en'), describeTurnErrorClass('network', 'en'));
});

it('states what to do without promising a resume the UI cannot offer', () => {
for (const errorClass of ['rate_limit', 'network', 'timeout']) {
assert.match(describeTurnErrorClass(errorClass, 'zh-CN'), /重新发消息|再发消息|发消息/);
}
assert.doesNotMatch(describeTurnErrorClass('unknown_failure', 'zh-CN'), /重试|重发/);
assert.match(describeTurnErrorClass('stream_truncated', 'zh-CN'), /中途断开/);
assert.match(describeFailedTurnExecutionState({ ...NOTHING_RAN, retry: { decision: 'declined', because: 'side_effects' } }, 'zh-CN')!, /未自动重试/);
it('shows the failure cause alongside the recorded retry refusal', () => {
const turn: TurnViewModel = {
turnId: 't1', status: 'failed', errorClass: 'network',
retry: { decision: 'declined', because: 'side_effects' },
tools: [], timeline: [], notes: [], startedAt: 1,
};
const presentation = deriveAppShellTurnPresentation([turn], {
activeId: 'session-1', pendingTurnActions: new Set<string>(), uiLocale: 'zh-CN',
});
assert.equal(presentation.failedReasonLabels.t1, '网络连接失败,请检查网络。');
assert.equal(presentation.failedExecutionStateLabels.t1,
'本次已有工具活动,为避免重复操作,未自动重试。请先检查工具结果。');
assert.equal(describeTurnErrorClass('rate_limit', 'zh-CN'), '模型请求太频繁被限流了。');
assert.equal(describeTurnErrorClass('timeout', 'zh-CN'), '模型请求超时。');
});

it('grades continuable outcomes below outcomes the user must act on', () => {
Expand Down Expand Up @@ -93,3 +102,10 @@ describe('failed turn execution state', () => {
});

});

it('does not hide a terminal diagnostic behind a sandbox tool failure or promote a tool failure to a failed turn', () => {
const turn: TurnViewModel = { turnId: 't1', status: 'failed', errorClass: 'unknown', failureMessage: 'Provider request failed after the tool result', tools: [{ toolUseId: 'tool-1', toolName: 'Bash', status: 'errored', args: {}, result: { kind: 'text', text: 'Operation not permitted', sandboxDenial: { likely: true } } }], timeline: [], notes: [], startedAt: 1 };
const context = { activeId: 'session-1', pendingTurnActions: new Set<string>(), uiLocale: 'en' as const };
assert.ok(deriveAppShellTurnPresentation([turn], context).failedReasonLabels.t1);
assert.equal(deriveAppShellTurnPresentation([{ ...turn, status: 'completed' }], context).failedReasonLabels.t1, undefined);
});
11 changes: 2 additions & 9 deletions apps/desktop/src/renderer/app-shell-turn-view-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,14 +66,7 @@ function isSandboxOnlyToolFailure(turn: TurnViewModel): boolean {
const erroredTools = turn.tools.filter((tool) => tool.status === 'errored');
if (erroredTools.length === 0 || !erroredTools.every(isSandboxDeniedTool)) return false;

const errorClass = turn.errorClass?.toLowerCase();
return (
errorClass === undefined
|| errorClass === 'unknown'
|| errorClass === 'tool_failed'
|| errorClass === 'sandbox_denial'
|| errorClass === 'sandbox_denied'
);
return [undefined, 'unknown', 'tool_failed', 'sandbox_denial', 'sandbox_denied'].includes(turn.errorClass?.toLowerCase());
}

/**
Expand Down Expand Up @@ -219,7 +212,7 @@ function deriveTurnPresentationEntry(input: {

const entry: TurnPresentationEntry = { footerActions };

if (turn.status === 'failed' && !isSandboxOnlyToolFailure(turn)) {
if (turn.status === 'failed' && (turn.failureMessage || !isSandboxOnlyToolFailure(turn))) {
entry.failedReasonLabel = describeTurnErrorClass(turn.errorClass, uiLocale);
entry.failedSeverity = deriveFailedTurnSeverity(turn.errorClass);
entry.failedExecutionStateLabel = describeFailedTurnExecutionState({
Expand Down
6 changes: 3 additions & 3 deletions apps/desktop/src/renderer/locales/conversation-copy.ts

Large diffs are not rendered by default.

41 changes: 39 additions & 2 deletions apps/desktop/stories/app-shell.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -799,7 +799,7 @@ export const ProviderStreamTruncated: Story = {
chat={{ messages: [
user('msg-st-1', 'turn-st', 4, '检查项目的构建结果。'),
{ type: 'assistant', id: 'msg-st-answer', turnId: 'turn-st', ts: NOW - 199_000, text: '构建已完成,我继续检查输出。', modelId: 'claude-sonnet-4-5' },
{ type: 'turn_state', id: 'state-st-failed', turnId: 'turn-st', ts: NOW - 198_000, status: 'failed', errorClass: 'stream_truncated', retry: { decision: 'declined', because: 'side_effects' } },
{ type: 'turn_state', id: 'state-st-failed', turnId: 'turn-st', ts: NOW - 198_000, status: 'failed', errorClass: 'stream_truncated', failureMessage: 'Response stream ended without a finish reason. (status=502, requestId=req-stream-4502)', retry: { decision: 'declined', because: 'side_effects' } },
] }}
/>
),
Expand All @@ -820,7 +820,7 @@ export const ProviderRateLimited: Story = {
messages: [
user('msg-r-1', 'turn-r', 4, '再生成三个对照方案,越详细越好。'),
{ type: 'turn_state', id: 'state-r-running', turnId: 'turn-r', ts: NOW - 200_000, status: 'running' },
{ type: 'turn_state', id: 'state-r-failed', turnId: 'turn-r', ts: NOW - 198_000, status: 'failed', errorClass: 'rate_limit' },
{ type: 'turn_state', id: 'state-r-failed', turnId: 'turn-r', ts: NOW - 198_000, status: 'failed', errorClass: 'rate_limit', failureMessage: 'Quota exceeded for this account. Check the provider quota before submitting another request. (code=insufficient_quota, status=429, requestId=req-4502)' },
],
}}
/>
Expand All @@ -834,6 +834,43 @@ export const ProviderRateLimited: Story = {
},
};

// The same turn moves from running to its durable terminal contribution.
function FailureArrival() {
const [failed, fail] = useReducer(() => true, false);
useEffect(() => {
window.addEventListener('storybook:turn-failed', fail);
return () => window.removeEventListener('storybook:turn-failed', fail);
}, []);
return <ComposedShell chat={{ messages: [
user('msg-arrival', 'turn-arrival', 4, '检查结果。'),
{ type: 'assistant', id: 'answer-arrival', turnId: 'turn-arrival', ts: NOW - 200_000, text: '已经得到部分结果。', modelId: 'claude-sonnet-4-5' },
{ type: 'turn_state', id: 'state-arrival', turnId: 'turn-arrival', ts: NOW - 198_000, status: failed ? 'failed' : 'running', ...(failed ? { errorClass: 'stream_truncated', failureMessage: 'Response stream ended without a finish reason.', retry: { decision: 'declined' as const, because: 'observable_output' as const } } : {}) },
] }} />;
}

export const FailureArrivesLive: Story = {
render: () => <FailureArrival />,
play: async ({ canvasElement }) => {
expect(canvasElement.querySelector('.maka-turn-failed-banner')).toBeNull();
window.dispatchEvent(new Event('storybook:turn-failed'));
await waitFor(() => expect(canvasElement.querySelector('.maka-turn-failed-banner')?.textContent).toContain('本次已有部分输出'));
const toggle = canvasElement.querySelector<HTMLButtonElement>('.maka-turn-failed-banner button[aria-expanded]')!;
expect(toggle.getAttribute('aria-expanded')).toBe('false');
toggle.focus();
toggle.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await waitFor(() => expect(toggle.getAttribute('aria-expanded')).toBe('true'));
expect(document.activeElement).toBe(toggle);
expect(canvasElement.querySelector('.maka-turn-failure-detail')?.textContent).toBe('Response stream ended without a finish reason.');
},
};

export const LongFailureDiagnostic: Story = {
render: () => <ComposedShell chat={{ messages: [
user('msg-long-error', 'turn-long-error', 4, '检查模型配置。'),
{ type: 'turn_state', id: 'state-long-error', turnId: 'turn-long-error', ts: NOW - 198_000, status: 'failed', errorClass: 'request_rejected', failureMessage: 'The provider rejected this request.\n' + 'configuration-'.repeat(145) + '\n(status=400, requestId=req-long-4502)' },
] }} />,
};

// Real path: the provider throttles a live request and Runtime schedules a
// retry. The running turn swaps its working phrase for the retry Banner
// (`ModelProviderRetryIndicator`) — the "retrying" state no story reached.
Expand Down
14 changes: 13 additions & 1 deletion packages/core/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@
* under the License.
*/

import { isModelRetryDecision, type ModelRetryDecision } from './model-failure.js';
import {
MODEL_FAILURE_MESSAGE_MAX_BYTES,
isModelRetryDecision,
type ModelRetryDecision,
} from './model-failure.js';

import {
decodeMessageContent,
Expand Down Expand Up @@ -912,6 +916,7 @@ export interface TurnStateMessage {
/** Diagnostic source for user/renderer-triggered aborts, e.g. renderer.stop_button. */
abortSource?: string;
errorClass?: string;
failureMessage?: string;
retry?: ModelRetryDecision;
}

Expand Down Expand Up @@ -1121,6 +1126,7 @@ export interface TurnRecord {
abortedAt?: number;
abortSource?: string;
errorClass?: string;
failureMessage?: string;
retry?: ModelRetryDecision;
}

Expand Down Expand Up @@ -1261,6 +1267,7 @@ const TURN_STATE_MESSAGE_SHAPE = defineObjectShape<TurnStateMessage>()(
'abortedAt',
'abortSource',
'errorClass',
'failureMessage',
'retry',
],
['partialOutputRetained'],
Expand Down Expand Up @@ -1542,6 +1549,10 @@ function decodeMessage(
(message.abortedAt === undefined || isFiniteNumber(message.abortedAt)) &&
isOptionalString(message.abortSource) &&
isOptionalString(message.errorClass) &&
(message.failureMessage === undefined ||
(typeof message.failureMessage === 'string' &&
new TextEncoder().encode(message.failureMessage).byteLength <=
MODEL_FAILURE_MESSAGE_MAX_BYTES)) &&
(message.retry === undefined || isModelRetryDecision(message.retry))
)
return pickShape(message as unknown as TurnStateMessage, TURN_STATE_MESSAGE_SHAPE);
Expand Down Expand Up @@ -1831,6 +1842,7 @@ export function deriveTurnRecords(messages: readonly StoredMessage[]): TurnRecor
...(latestState.abortedAt !== undefined ? { abortedAt: latestState.abortedAt } : {}),
...(latestState.abortSource ? { abortSource: latestState.abortSource } : {}),
...(latestState.errorClass ? { errorClass: latestState.errorClass } : {}),
...(latestState.failureMessage ? { failureMessage: latestState.failureMessage } : {}),
...(latestState.retry ? { retry: latestState.retry } : {}),
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ test('preserves the canonical retry decision in shared bootstrap and later pages
status: 'failed',
errorClass: 'stream_truncated',
retry: { decision: 'declined', because: 'side_effects' },
failureMessage: 'Private provider diagnostic',
},
];
const reader = transcriptReader(durable);
Expand All @@ -101,6 +102,7 @@ test('preserves the canonical retry decision in shared bootstrap and later pages
maxBytes: 1024,
projection: 'shared',
});
assert.equal(decodeBootstrap(bootstrap.durable)[0]?.failureMessage, undefined);
assert.deepEqual(decodeBootstrap(bootstrap.durable)[0]?.retry, {
decision: 'declined',
because: 'side_effects',
Expand All @@ -113,6 +115,7 @@ test('preserves the canonical retry decision in shared bootstrap and later pages
status: 'failed',
errorClass: 'stream_truncated',
retry: { decision: 'exhausted', attempts: 2 },
failureMessage: 'Another private diagnostic',
});
updateSubscriberTranscriptHighWater(state, 1);
const page = await readSessionTranscriptPage({
Expand All @@ -128,6 +131,7 @@ test('preserves the canonical retry decision in shared bootstrap and later pages
maxBytes: 1024,
},
});
assert.equal(decodeBootstrap(page)[0]?.failureMessage, undefined);
assert.deepEqual(decodeBootstrap(page)[0]?.retry, { decision: 'exhausted', attempts: 2 });
});

Expand Down
8 changes: 7 additions & 1 deletion packages/runtime-host/src/__tests__/session-turns.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
*/

import assert from 'node:assert/strict';
import { MODEL_FAILURE_MESSAGE_MAX_BYTES } from '@maka/core/model-failure';
import test from 'node:test';
import {
decodeSessionTurnsQueryResult,
Expand Down Expand Up @@ -100,6 +101,7 @@ test('bounds turn diagnostics before publishing a contribution', () => {
ts: 1,
status: 'failed',
errorClass: '失败'.repeat(100_000),
failureMessage: '失败'.repeat(100_000),
retry: { decision: 'declined', because: 'side_effects' },
},
},
Expand All @@ -119,7 +121,11 @@ test('bounds turn diagnostics before publishing a contribution', () => {
}),
);
const turn = projectSessionTurnContribution(contribution);
assert.deepEqual(turn?.retry, { decision: 'declined', because: 'side_effects' });
assert.ok(turn);
assert.ok(turn.failureMessage);
assert.ok(Buffer.byteLength(turn.failureMessage) <= MODEL_FAILURE_MESSAGE_MAX_BYTES);
assert.equal(turn.failureMessage, contribution.latestState!.message.failureMessage);
assert.deepEqual(turn.retry, { decision: 'declined', because: 'side_effects' });
});

test('rejects invalid turn-state references before publishing a contribution', () => {
Expand Down
4 changes: 3 additions & 1 deletion packages/runtime-host/src/protocol/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,9 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const;
export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const;
// Increment when the same protocol version no longer guarantees safe Client-Host
// interoperability. Mismatches are rejected before domain commands are admitted.
export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 129 as const;
export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 130 as const;
// 130: Turn contributions carry the optional bounded `failureMessage` diagnostic.
// Epoch-129 peers reject this added field on the strict contribution shape.
// 129: Turn states and Turn records drop `partialOutputRetained`. The fact was
// derived twice — once from the Turn's output rows, once off the state message
// — and read by nothing; older peers require the field on both.
Expand Down
11 changes: 11 additions & 0 deletions packages/runtime-host/src/protocol/session-turns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
* under the License.
*/

import { MODEL_FAILURE_MESSAGE_MAX_BYTES } from '@maka/core/model-failure';
import { decodeCanonicalMessage, type TurnRecord, type TurnStateMessage } from '@maka/core/session';
import { truncateUtf8 } from '@maka/core/diagnostic-log';
import {
Expand Down Expand Up @@ -164,6 +165,15 @@ function projectTurnStateMessageForWire(message: TurnStateMessage): TurnStateMes
...(message.errorClass
? { errorClass: truncateUtf8(message.errorClass, SESSION_TURN_DIAGNOSTIC_MAX_BYTES) }
: {}),
...(message.failureMessage
? {
failureMessage: truncateUtf8(
message.failureMessage,
MODEL_FAILURE_MESSAGE_MAX_BYTES,
'…',
),
}
: {}),
...(message.retry ? { retry: message.retry } : {}),
};
}
Expand Down Expand Up @@ -197,6 +207,7 @@ export function projectSessionTurnContribution(
...(state.abortedAt !== undefined ? { abortedAt: state.abortedAt } : {}),
...(state.abortSource ? { abortSource: state.abortSource } : {}),
...(state.errorClass ? { errorClass: state.errorClass } : {}),
...(state.failureMessage ? { failureMessage: state.failureMessage } : {}),
...(state.retry ? { retry: state.retry } : {}),
};
}
Expand Down
5 changes: 2 additions & 3 deletions packages/runtime/src/__tests__/ai-sdk-backend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7280,7 +7280,7 @@ describe('AiSdkBackend model history', () => {
});

describe('AiSdkBackend error surfaces', () => {
test('generalizes model setup errors before emitting renderer events', async () => {
test('preserves model setup diagnostics in renderer events', async () => {
const backend = createBackend({
connection: connection(),
apiKey: 'sk-live-secret-token-value',
Expand All @@ -7300,8 +7300,7 @@ describe('AiSdkBackend error surfaces', () => {
const error = events.find(
(event): event is Extract<SessionEvent, { type: 'error' }> => event.type === 'error',
);
assert.equal(error?.message, '401 Authorization: Bearer [redacted]');
assert.equal(JSON.stringify(events).includes('sk-live-secret-token-value'), false);
assert.equal(error?.message, '401 Authorization: Bearer sk-live-secret-token-value');
});

test('stops after a T1 rejection only after sibling tool calls settle', async () => {
Expand Down
7 changes: 3 additions & 4 deletions packages/runtime/src/__tests__/model-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -917,24 +917,23 @@ describe('ModelAdapter stream and error normalization', () => {
assert.equal(event.message, 'fetch failed');
});

test('retains a safe bounded summary from an unknown structured provider error', () => {
test('retains an unredacted bounded summary from an unknown structured provider error', () => {
const adapter = newAdapter();
const failure = adapter.normalizeFailure({
type: 'error',
error: {
code: 'provider_error',
message: `provider exploded api_key=sk-live-secret-token-value ${'x'.repeat(4_000)}`,
message: `provider exploded api_key=sk-test-diagnostic-value ${'x'.repeat(4_000)}`,
},
request_id: 'req-123',
});
const event = adapter.makeErrorEvent('turn-1', failure);

assert.equal(event.reason, 'unknown');
assert.equal(event.code, 'provider_error');
assert.match(event.message, /^provider exploded api_key=\[redacted\]/);
assert.ok(event.message.startsWith('provider exploded api_key=sk-test-diagnostic-value '));
assert.match(event.message, /… \(code=provider_error, requestId=req-123\)$/);
assert.equal(Buffer.byteLength(event.message, 'utf8') <= 2 * 1024, true);
assert.equal(event.message.includes('sk-live-secret-token-value'), false);
});

test('normalizes cache and reasoning usage variants in the adapter module', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -210,15 +210,15 @@ describe('Provider error classification', () => {
test('extracts allowlisted fields from JSON string failures without copying the payload', () => {
const summary = providerModelFailure(
JSON.stringify({
error: { message: 'provider rejected request', code: 'bad_request' },
error: { message: 'Invalid api_key=sk-test-diagnostic-value', code: 'bad_request' },
request_id: 'req-123',
prompt: 'private customer text',
headers: { 'x-debug': 'internal' },
}),
);

assert.partialDeepStrictEqual(summary, {
message: 'provider rejected request (code=bad_request, requestId=req-123)',
message: 'Invalid api_key=sk-test-diagnostic-value (code=bad_request, requestId=req-123)',
code: 'bad_request',
});
assert.equal(JSON.stringify(summary).includes('private customer text'), false);
Expand Down
Loading