Skip to content

Commit e885aec

Browse files
authored
feat(web): show detailed diagnostics for model request failures (#1756)
Surface the coded provider error the daemon already sends: a semantic title per error code, the provider's raw message, and expandable diagnostics (error code, HTTP status, request ID, SDK error name) with copy support, instead of a bare text-only toast.
1 parent 1186686 commit e885aec

7 files changed

Lines changed: 193 additions & 11 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@moonshot-ai/kimi-code": patch
3+
---
4+
5+
web: Show full diagnostics for model request failures — a semantic title, the provider's raw message, and expandable details (error code, HTTP status, request ID) with copy support — instead of a bare "Connection error" toast.

apps/kimi-web/src/api/daemon/agentEventProjector.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1084,10 +1084,20 @@ export function createAgentProjector(): AgentProjector {
10841084

10851085
// -----------------------------------------------------------------------
10861086
case 'error': {
1087-
// Fold into an unknown event so the reducer pushes a warning string
1087+
// Fold into an unknown event so the reducer surfaces it as a structured
1088+
// error notice (semantic title + code/status/requestId details). The
1089+
// wire payload already carries name/details/retryable — pass them
1090+
// through untouched; the reducer decides what to display.
10881091
out.push({
10891092
type: 'unknown',
1090-
raw: { _agentError: true, code: p?.code, message: p?.message },
1093+
raw: {
1094+
_agentError: true,
1095+
code: p?.code,
1096+
message: p?.message,
1097+
name: p?.name,
1098+
details: p?.details,
1099+
retryable: p?.retryable,
1100+
},
10911101
});
10921102
break;
10931103
}

apps/kimi-web/src/api/daemon/eventReducer.ts

Lines changed: 70 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import type {
1515
AppGoal,
1616
AppMessage,
1717
AppMessageContent,
18+
AppNotice,
19+
AppNoticeDetail,
1820
AppWarning,
1921
AppQuestionRequest,
2022
AppSession,
@@ -229,6 +231,64 @@ function appendToolOutputToMessages(messages: AppMessage[], toolCallId: string,
229231
// Reducer
230232
// ---------------------------------------------------------------------------
231233

234+
/** Agent error code → semantic title key under `warnings.agentError`. Codes
235+
* come from the protocol error domain (agent-core-v2 `ProtocolErrors`);
236+
* anything unmapped falls back to the generic `title`. */
237+
const AGENT_ERROR_TITLE_KEYS: Readonly<Record<string, string>> = {
238+
'provider.connection_error': 'connection',
239+
'provider.auth_error': 'auth',
240+
'provider.rate_limit': 'rateLimit',
241+
'provider.overloaded': 'overloaded',
242+
'provider.filtered': 'filtered',
243+
'provider.api_error': 'api',
244+
'context.overflow': 'contextOverflow',
245+
};
246+
247+
interface AgentErrorRaw {
248+
code?: string;
249+
message?: string;
250+
name?: string;
251+
details?: Record<string, unknown>;
252+
}
253+
254+
/**
255+
* Build the structured error notice for a failed agent turn (typically a
256+
* model-provider failure). The wire payload already carries the coded error —
257+
* surface it in full so a rate-limit / auth / endpoint failure is diagnosable
258+
* from the toast: semantic title, the provider's raw message as the body, and
259+
* a diagnostics list (error code, HTTP status, request id, SDK error name,
260+
* plus any extra detail fields such as finishReason).
261+
*/
262+
function buildAgentErrorNotice(raw: AgentErrorRaw): AppNotice {
263+
const t = i18n.global.t;
264+
const details: AppNoticeDetail[] = [];
265+
const push = (label: string, value: unknown): void => {
266+
if (typeof value === 'number' || typeof value === 'boolean') {
267+
details.push({ label, value: String(value) });
268+
} else if (typeof value === 'string' && value.length > 0) {
269+
details.push({ label, value });
270+
}
271+
};
272+
push(t('warnings.details.code'), raw.code);
273+
const rawDetails = raw.details ?? {};
274+
push(t('warnings.details.status'), rawDetails['statusCode']);
275+
push(t('warnings.details.requestId'), rawDetails['requestId']);
276+
push(t('warnings.details.errorName'), raw.name);
277+
// Keep any remaining detail fields (finishReason, rawFinishReason, …) so no
278+
// diagnostics the daemon sent are hidden.
279+
for (const [key, value] of Object.entries(rawDetails)) {
280+
if (key === 'statusCode' || key === 'requestId') continue;
281+
push(key, value);
282+
}
283+
const titleKey = (raw.code !== undefined ? AGENT_ERROR_TITLE_KEYS[raw.code] : undefined) ?? 'title';
284+
return {
285+
severity: 'error',
286+
title: t(`warnings.agentError.${titleKey}`),
287+
message: raw.message,
288+
details: details.length > 0 ? details : undefined,
289+
};
290+
}
291+
232292
/**
233293
* Apply a single AppEvent to the state, returning a new state object.
234294
* The event carries `_wireSeq` and `_wireSessionId` as hidden extras when
@@ -674,18 +734,20 @@ export function reduceAppEvent(
674734
_agentWarning?: boolean;
675735
code?: string;
676736
message?: string;
737+
name?: string;
738+
details?: Record<string, unknown>;
677739
type?: string;
678740
} | null;
679741
if (raw && raw._noop === true) {
680742
// No-op streaming/tool event — seq already advanced, nothing else to do
681-
} else if (raw && (raw._agentError || raw._agentWarning)) {
682-
// Surface the agent's real error/warning message (e.g. a 403 from the
683-
// model provider) instead of a useless "Unhandled event".
684-
const label = raw._agentError
685-
? i18n.global.t('warnings.errorLabel')
686-
: i18n.global.t('warnings.noteLabel');
687-
const msg = raw.message ?? raw.code ?? 'agent error';
688-
next.warnings = [...next.warnings, `${label}: ${msg}`];
743+
} else if (raw && raw._agentError) {
744+
// Surface the agent's real error (e.g. a 429 from the model provider)
745+
// as a structured notice: semantic title + raw provider message +
746+
// diagnostics (code / HTTP status / request id) for troubleshooting.
747+
next.warnings = [...next.warnings, buildAgentErrorNotice(raw)];
748+
} else if (raw && raw._agentWarning) {
749+
const msg = raw.message ?? raw.code ?? 'agent warning';
750+
next.warnings = [...next.warnings, `${i18n.global.t('warnings.noteLabel')}: ${msg}`];
689751
} else {
690752
// Truly unknown — push a warning
691753
const wireType = raw?.type ?? '(unknown)';

apps/kimi-web/src/i18n/locales/en/warnings.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@ export default {
22
dismiss: 'Close',
33
errorLabel: 'Error',
44
noteLabel: 'Note',
5+
agentError: {
6+
title: 'Model request failed',
7+
connection: 'Cannot connect to the model service',
8+
auth: 'Model authentication failed',
9+
rateLimit: 'Model rate limit reached',
10+
overloaded: 'Model overloaded',
11+
filtered: 'Response filtered by the provider',
12+
api: 'Model API error',
13+
contextOverflow: 'Context size exceeded',
14+
},
515
details: {
616
cause: 'Cause',
717
code: 'Error code',

apps/kimi-web/src/i18n/locales/zh/warnings.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@ export default {
22
dismiss: '关闭',
33
errorLabel: '错误',
44
noteLabel: '提示',
5+
agentError: {
6+
title: '模型请求失败',
7+
connection: '无法连接模型服务',
8+
auth: '模型认证失败',
9+
rateLimit: '模型请求被限流',
10+
overloaded: '模型服务过载',
11+
filtered: '响应被提供方过滤',
12+
api: '模型接口返回错误',
13+
contextOverflow: '上下文超出模型限制',
14+
},
515
details: {
616
cause: '底层原因',
717
code: '错误码',

apps/kimi-web/test/agent-event-projector.test.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,14 @@ describe('agent error projection', () => {
8585
expect(
8686
projector.project(
8787
'error',
88-
{ agentId: 'main', code: 'provider.rate_limit', message: 'Rate limited' },
88+
{
89+
agentId: 'main',
90+
code: 'provider.rate_limit',
91+
message: 'Rate limited',
92+
name: 'RateLimitError',
93+
details: { statusCode: 429, requestId: 'req_1' },
94+
retryable: true,
95+
},
8996
's1',
9097
),
9198
).toEqual([
@@ -95,6 +102,9 @@ describe('agent error projection', () => {
95102
_agentError: true,
96103
code: 'provider.rate_limit',
97104
message: 'Rate limited',
105+
name: 'RateLimitError',
106+
details: { statusCode: 429, requestId: 'req_1' },
107+
retryable: true,
98108
},
99109
},
100110
]);

apps/kimi-web/test/event-reducer.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, expect, it } from 'vitest';
22
import { createInitialState, reduceAppEvent } from '../src/api/daemon/eventReducer';
33
import type { AppMessage, AppSession, AppTask } from '../src/api/types';
4+
import { i18n } from '../src/i18n';
45

56
function makeSession(id: string, updatedAt: string): AppSession {
67
return {
@@ -394,3 +395,77 @@ describe('reduceAppEvent messageCreated cron origin', () => {
394395
expect(msgs.map((m) => m.id)).toEqual(['opt_1', 'cron_1']);
395396
});
396397
});
398+
399+
describe('reduceAppEvent unknown agent error', () => {
400+
function reduceRaw(raw: unknown): ReturnType<typeof reduceAppEvent> {
401+
return reduceAppEvent(
402+
createInitialState(),
403+
{ type: 'unknown', raw },
404+
{ sessionId: 's1', seq: 1 },
405+
);
406+
}
407+
408+
it('surfaces a rate-limit failure as a structured notice with full diagnostics', () => {
409+
const next = reduceRaw({
410+
_agentError: true,
411+
code: 'provider.rate_limit',
412+
message: 'Rate limit reached for requests. Please try again later.',
413+
name: 'RateLimitError',
414+
details: { statusCode: 429, requestId: 'req_1' },
415+
retryable: true,
416+
});
417+
const notice = next.warnings[0];
418+
expect(typeof notice).toBe('object');
419+
if (typeof notice !== 'object' || notice === null) return;
420+
expect(notice.severity).toBe('error');
421+
expect(notice.title).toBe(i18n.global.t('warnings.agentError.rateLimit'));
422+
expect(notice.message).toBe('Rate limit reached for requests. Please try again later.');
423+
const byLabel = new Map(notice.details?.map((d) => [d.label, d.value]));
424+
expect(byLabel.get(i18n.global.t('warnings.details.code'))).toBe('provider.rate_limit');
425+
expect(byLabel.get(i18n.global.t('warnings.details.status'))).toBe('429');
426+
expect(byLabel.get(i18n.global.t('warnings.details.requestId'))).toBe('req_1');
427+
expect(byLabel.get(i18n.global.t('warnings.details.errorName'))).toBe('RateLimitError');
428+
});
429+
430+
it('keeps extra detail fields such as finishReason visible', () => {
431+
const next = reduceRaw({
432+
_agentError: true,
433+
code: 'provider.filtered',
434+
message: 'Provider filtered the response',
435+
details: { finishReason: 'filtered', rawFinishReason: 'content_filter' },
436+
});
437+
const notice = next.warnings[0];
438+
if (typeof notice !== 'object' || notice === null) throw new Error('expected notice');
439+
const values = notice.details?.map((d) => d.value) ?? [];
440+
expect(values).toContain('filtered');
441+
expect(values).toContain('content_filter');
442+
});
443+
444+
it('shows a connection failure without status/requestId rows', () => {
445+
const next = reduceRaw({
446+
_agentError: true,
447+
code: 'provider.connection_error',
448+
message: 'Connection error.',
449+
});
450+
const notice = next.warnings[0];
451+
if (typeof notice !== 'object' || notice === null) throw new Error('expected notice');
452+
expect(notice.title).toBe(i18n.global.t('warnings.agentError.connection'));
453+
expect(notice.message).toBe('Connection error.');
454+
expect(notice.details?.map((d) => d.value)).toEqual(['provider.connection_error']);
455+
});
456+
457+
it('falls back to the generic title for unmapped or missing codes', () => {
458+
for (const code of ['internal', undefined]) {
459+
const next = reduceRaw({ _agentError: true, code, message: 'boom' });
460+
const notice = next.warnings[0];
461+
if (typeof notice !== 'object' || notice === null) throw new Error('expected notice');
462+
expect(notice.title).toBe(i18n.global.t('warnings.agentError.title'));
463+
expect(notice.message).toBe('boom');
464+
}
465+
});
466+
467+
it('still renders agent warnings as plain strings', () => {
468+
const next = reduceRaw({ _agentWarning: true, message: 'heads up' });
469+
expect(next.warnings[0]).toBe(`${i18n.global.t('warnings.noteLabel')}: heads up`);
470+
});
471+
});

0 commit comments

Comments
 (0)