From 845a5918b57d319dc28e24c8c1b7738a63f15d78 Mon Sep 17 00:00:00 2001 From: qer Date: Wed, 15 Jul 2026 22:54:39 +0800 Subject: [PATCH] feat(web): show detailed diagnostics for model request failures 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. --- .changeset/web-model-error-details.md | 5 ++ .../src/api/daemon/agentEventProjector.ts | 14 +++- apps/kimi-web/src/api/daemon/eventReducer.ts | 78 +++++++++++++++++-- apps/kimi-web/src/i18n/locales/en/warnings.ts | 10 +++ apps/kimi-web/src/i18n/locales/zh/warnings.ts | 10 +++ .../test/agent-event-projector.test.ts | 12 ++- apps/kimi-web/test/event-reducer.test.ts | 75 ++++++++++++++++++ 7 files changed, 193 insertions(+), 11 deletions(-) create mode 100644 .changeset/web-model-error-details.md diff --git a/.changeset/web-model-error-details.md b/.changeset/web-model-error-details.md new file mode 100644 index 0000000000..ce19d82407 --- /dev/null +++ b/.changeset/web-model-error-details.md @@ -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. diff --git a/apps/kimi-web/src/api/daemon/agentEventProjector.ts b/apps/kimi-web/src/api/daemon/agentEventProjector.ts index bcfefbb5d3..4c332bbb84 100644 --- a/apps/kimi-web/src/api/daemon/agentEventProjector.ts +++ b/apps/kimi-web/src/api/daemon/agentEventProjector.ts @@ -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; } diff --git a/apps/kimi-web/src/api/daemon/eventReducer.ts b/apps/kimi-web/src/api/daemon/eventReducer.ts index 667b70f7d8..b6e4b8a814 100644 --- a/apps/kimi-web/src/api/daemon/eventReducer.ts +++ b/apps/kimi-web/src/api/daemon/eventReducer.ts @@ -15,6 +15,8 @@ import type { AppGoal, AppMessage, AppMessageContent, + AppNotice, + AppNoticeDetail, AppWarning, AppQuestionRequest, AppSession, @@ -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> = { + '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; +} + +/** + * 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 @@ -673,18 +733,20 @@ export function reduceAppEvent( _agentWarning?: boolean; code?: string; message?: string; + name?: string; + details?: Record; 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)'; diff --git a/apps/kimi-web/src/i18n/locales/en/warnings.ts b/apps/kimi-web/src/i18n/locales/en/warnings.ts index 5afac2ef4d..467e0ad70d 100644 --- a/apps/kimi-web/src/i18n/locales/en/warnings.ts +++ b/apps/kimi-web/src/i18n/locales/en/warnings.ts @@ -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', diff --git a/apps/kimi-web/src/i18n/locales/zh/warnings.ts b/apps/kimi-web/src/i18n/locales/zh/warnings.ts index 0e3be56c18..9163648871 100644 --- a/apps/kimi-web/src/i18n/locales/zh/warnings.ts +++ b/apps/kimi-web/src/i18n/locales/zh/warnings.ts @@ -2,6 +2,16 @@ export default { dismiss: '关闭', errorLabel: '错误', noteLabel: '提示', + agentError: { + title: '模型请求失败', + connection: '无法连接模型服务', + auth: '模型认证失败', + rateLimit: '模型请求被限流', + overloaded: '模型服务过载', + filtered: '响应被提供方过滤', + api: '模型接口返回错误', + contextOverflow: '上下文超出模型限制', + }, details: { cause: '底层原因', code: '错误码', diff --git a/apps/kimi-web/test/agent-event-projector.test.ts b/apps/kimi-web/test/agent-event-projector.test.ts index 36bf5ec581..cdddf34132 100644 --- a/apps/kimi-web/test/agent-event-projector.test.ts +++ b/apps/kimi-web/test/agent-event-projector.test.ts @@ -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([ @@ -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, }, }, ]); diff --git a/apps/kimi-web/test/event-reducer.test.ts b/apps/kimi-web/test/event-reducer.test.ts index 3aa471c2aa..2b4134d586 100644 --- a/apps/kimi-web/test/event-reducer.test.ts +++ b/apps/kimi-web/test/event-reducer.test.ts @@ -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 { @@ -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 { + 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`); + }); +});