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
5 changes: 5 additions & 0 deletions .changeset/web-model-error-details.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

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.
14 changes: 12 additions & 2 deletions apps/kimi-web/src/api/daemon/agentEventProjector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1084,10 +1084,20 @@ export function createAgentProjector(): AgentProjector {

// -----------------------------------------------------------------------
case 'error': {
// Fold into an unknown event so the reducer pushes a warning string
// Fold into an unknown event so the reducer surfaces it as a structured
// error notice (semantic title + code/status/requestId details). The
// wire payload already carries name/details/retryable — pass them
// through untouched; the reducer decides what to display.
out.push({
type: 'unknown',
raw: { _agentError: true, code: p?.code, message: p?.message },
raw: {
_agentError: true,
code: p?.code,
message: p?.message,
name: p?.name,
details: p?.details,
retryable: p?.retryable,
},
});
break;
}
Expand Down
78 changes: 70 additions & 8 deletions apps/kimi-web/src/api/daemon/eventReducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import type {
AppGoal,
AppMessage,
AppMessageContent,
AppNotice,
AppNoticeDetail,
AppWarning,
AppQuestionRequest,
AppSession,
Expand Down Expand Up @@ -229,6 +231,64 @@ function appendToolOutputToMessages(messages: AppMessage[], toolCallId: string,
// Reducer
// ---------------------------------------------------------------------------

/** Agent error code → semantic title key under `warnings.agentError`. Codes
* come from the protocol error domain (agent-core-v2 `ProtocolErrors`);
* anything unmapped falls back to the generic `title`. */
const AGENT_ERROR_TITLE_KEYS: Readonly<Record<string, string>> = {
'provider.connection_error': 'connection',
'provider.auth_error': 'auth',
'provider.rate_limit': 'rateLimit',
'provider.overloaded': 'overloaded',
'provider.filtered': 'filtered',
'provider.api_error': 'api',
'context.overflow': 'contextOverflow',
};

interface AgentErrorRaw {
code?: string;
message?: string;
name?: string;
details?: Record<string, unknown>;
}

/**
* Build the structured error notice for a failed agent turn (typically a
* model-provider failure). The wire payload already carries the coded error —
* surface it in full so a rate-limit / auth / endpoint failure is diagnosable
* from the toast: semantic title, the provider's raw message as the body, and
* a diagnostics list (error code, HTTP status, request id, SDK error name,
* plus any extra detail fields such as finishReason).
*/
function buildAgentErrorNotice(raw: AgentErrorRaw): AppNotice {
const t = i18n.global.t;
const details: AppNoticeDetail[] = [];
const push = (label: string, value: unknown): void => {
if (typeof value === 'number' || typeof value === 'boolean') {
details.push({ label, value: String(value) });
} else if (typeof value === 'string' && value.length > 0) {
details.push({ label, value });
}
};
push(t('warnings.details.code'), raw.code);
const rawDetails = raw.details ?? {};
push(t('warnings.details.status'), rawDetails['statusCode']);
push(t('warnings.details.requestId'), rawDetails['requestId']);
push(t('warnings.details.errorName'), raw.name);
// Keep any remaining detail fields (finishReason, rawFinishReason, …) so no
// diagnostics the daemon sent are hidden.
for (const [key, value] of Object.entries(rawDetails)) {
if (key === 'statusCode' || key === 'requestId') continue;
push(key, value);
}
const titleKey = (raw.code !== undefined ? AGENT_ERROR_TITLE_KEYS[raw.code] : undefined) ?? 'title';
return {
severity: 'error',
title: t(`warnings.agentError.${titleKey}`),
message: raw.message,
details: details.length > 0 ? details : undefined,
};
}

/**
* Apply a single AppEvent to the state, returning a new state object.
* The event carries `_wireSeq` and `_wireSessionId` as hidden extras when
Expand Down Expand Up @@ -673,18 +733,20 @@ export function reduceAppEvent(
_agentWarning?: boolean;
code?: string;
message?: string;
name?: string;
details?: Record<string, unknown>;
type?: string;
} | null;
if (raw && raw._noop === true) {
// No-op streaming/tool event — seq already advanced, nothing else to do
} else if (raw && (raw._agentError || raw._agentWarning)) {
// Surface the agent's real error/warning message (e.g. a 403 from the
// model provider) instead of a useless "Unhandled event".
const label = raw._agentError
? i18n.global.t('warnings.errorLabel')
: i18n.global.t('warnings.noteLabel');
const msg = raw.message ?? raw.code ?? 'agent error';
next.warnings = [...next.warnings, `${label}: ${msg}`];
} else if (raw && raw._agentError) {
// Surface the agent's real error (e.g. a 429 from the model provider)
// as a structured notice: semantic title + raw provider message +
// diagnostics (code / HTTP status / request id) for troubleshooting.
next.warnings = [...next.warnings, buildAgentErrorNotice(raw)];
} else if (raw && raw._agentWarning) {
const msg = raw.message ?? raw.code ?? 'agent warning';
next.warnings = [...next.warnings, `${i18n.global.t('warnings.noteLabel')}: ${msg}`];
} else {
// Truly unknown — push a warning
const wireType = raw?.type ?? '(unknown)';
Expand Down
10 changes: 10 additions & 0 deletions apps/kimi-web/src/i18n/locales/en/warnings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@ export default {
dismiss: 'Close',
errorLabel: 'Error',
noteLabel: 'Note',
agentError: {
title: 'Model request failed',
connection: 'Cannot connect to the model service',
auth: 'Model authentication failed',
rateLimit: 'Model rate limit reached',
overloaded: 'Model overloaded',
filtered: 'Response filtered by the provider',
api: 'Model API error',
contextOverflow: 'Context size exceeded',
},
details: {
cause: 'Cause',
code: 'Error code',
Expand Down
10 changes: 10 additions & 0 deletions apps/kimi-web/src/i18n/locales/zh/warnings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@ export default {
dismiss: '关闭',
errorLabel: '错误',
noteLabel: '提示',
agentError: {
title: '模型请求失败',
connection: '无法连接模型服务',
auth: '模型认证失败',
rateLimit: '模型请求被限流',
overloaded: '模型服务过载',
filtered: '响应被提供方过滤',
api: '模型接口返回错误',
contextOverflow: '上下文超出模型限制',
},
details: {
cause: '底层原因',
code: '错误码',
Expand Down
12 changes: 11 additions & 1 deletion apps/kimi-web/test/agent-event-projector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,14 @@ describe('agent error projection', () => {
expect(
projector.project(
'error',
{ agentId: 'main', code: 'provider.rate_limit', message: 'Rate limited' },
{
agentId: 'main',
code: 'provider.rate_limit',
message: 'Rate limited',
name: 'RateLimitError',
details: { statusCode: 429, requestId: 'req_1' },
retryable: true,
},
's1',
),
).toEqual([
Expand All @@ -95,6 +102,9 @@ describe('agent error projection', () => {
_agentError: true,
code: 'provider.rate_limit',
message: 'Rate limited',
name: 'RateLimitError',
details: { statusCode: 429, requestId: 'req_1' },
retryable: true,
},
},
]);
Expand Down
75 changes: 75 additions & 0 deletions apps/kimi-web/test/event-reducer.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import { createInitialState, reduceAppEvent } from '../src/api/daemon/eventReducer';
import type { AppMessage, AppSession, AppTask } from '../src/api/types';
import { i18n } from '../src/i18n';

function makeSession(id: string, updatedAt: string): AppSession {
return {
Expand Down Expand Up @@ -394,3 +395,77 @@ describe('reduceAppEvent messageCreated cron origin', () => {
expect(msgs.map((m) => m.id)).toEqual(['opt_1', 'cron_1']);
});
});

describe('reduceAppEvent unknown agent error', () => {
function reduceRaw(raw: unknown): ReturnType<typeof reduceAppEvent> {
return reduceAppEvent(
createInitialState(),
{ type: 'unknown', raw },
{ sessionId: 's1', seq: 1 },
);
}

it('surfaces a rate-limit failure as a structured notice with full diagnostics', () => {
const next = reduceRaw({
_agentError: true,
code: 'provider.rate_limit',
message: 'Rate limit reached for requests. Please try again later.',
name: 'RateLimitError',
details: { statusCode: 429, requestId: 'req_1' },
retryable: true,
});
const notice = next.warnings[0];
expect(typeof notice).toBe('object');
if (typeof notice !== 'object' || notice === null) return;
expect(notice.severity).toBe('error');
expect(notice.title).toBe(i18n.global.t('warnings.agentError.rateLimit'));
expect(notice.message).toBe('Rate limit reached for requests. Please try again later.');
const byLabel = new Map(notice.details?.map((d) => [d.label, d.value]));
expect(byLabel.get(i18n.global.t('warnings.details.code'))).toBe('provider.rate_limit');
expect(byLabel.get(i18n.global.t('warnings.details.status'))).toBe('429');
expect(byLabel.get(i18n.global.t('warnings.details.requestId'))).toBe('req_1');
expect(byLabel.get(i18n.global.t('warnings.details.errorName'))).toBe('RateLimitError');
});

it('keeps extra detail fields such as finishReason visible', () => {
const next = reduceRaw({
_agentError: true,
code: 'provider.filtered',
message: 'Provider filtered the response',
details: { finishReason: 'filtered', rawFinishReason: 'content_filter' },
});
const notice = next.warnings[0];
if (typeof notice !== 'object' || notice === null) throw new Error('expected notice');
const values = notice.details?.map((d) => d.value) ?? [];
expect(values).toContain('filtered');
expect(values).toContain('content_filter');
});

it('shows a connection failure without status/requestId rows', () => {
const next = reduceRaw({
_agentError: true,
code: 'provider.connection_error',
message: 'Connection error.',
});
const notice = next.warnings[0];
if (typeof notice !== 'object' || notice === null) throw new Error('expected notice');
expect(notice.title).toBe(i18n.global.t('warnings.agentError.connection'));
expect(notice.message).toBe('Connection error.');
expect(notice.details?.map((d) => d.value)).toEqual(['provider.connection_error']);
});

it('falls back to the generic title for unmapped or missing codes', () => {
for (const code of ['internal', undefined]) {
const next = reduceRaw({ _agentError: true, code, message: 'boom' });
const notice = next.warnings[0];
if (typeof notice !== 'object' || notice === null) throw new Error('expected notice');
expect(notice.title).toBe(i18n.global.t('warnings.agentError.title'));
expect(notice.message).toBe('boom');
}
});

it('still renders agent warnings as plain strings', () => {
const next = reduceRaw({ _agentWarning: true, message: 'heads up' });
expect(next.warnings[0]).toBe(`${i18n.global.t('warnings.noteLabel')}: heads up`);
});
});
Loading