From e437199b81b386a364babfb46084921c8be71a8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Wed, 20 May 2026 18:48:36 +0800 Subject: [PATCH] fix: Surface API retry progress in desktop chat CLI retry events already include retry attempts, delay, and upstream status, but the desktop WebSocket translation dropped api_retry system messages. The desktop chat now keeps the current retry state in session state and renders a compact active-turn indicator with attempt count, HTTP status, and countdown. Constraint: Desktop users need visible feedback during provider retry waits after auth or network failures. Rejected: Add retry rows to the transcript | retry heartbeats would clutter chat history instead of describing the active turn. Confidence: high Scope-risk: moderate Directive: Keep api_retry as active-turn state; do not persist retry heartbeats into transcript history without a product decision. Tested: bun test src/server/__tests__/ws-memory-events.test.ts Tested: cd desktop && bun run test src/stores/chatStore.test.ts src/components/chat/MessageList.test.tsx Tested: cd desktop && bun run lint Tested: bun run check:server Tested: Browser smoke with temp CLAUDE_CONFIG_DIR and mock OpenAI-compatible 503 endpoint, screenshot /tmp/cc-haha-retry-ui.png Not-tested: Live ChatGPT logout retry path against OpenAI production auth. --- .../src/components/chat/MessageList.test.tsx | 27 +++++++++ .../components/chat/StreamingIndicator.tsx | 58 +++++++++++++++++++ desktop/src/i18n/locales/en.ts | 5 ++ desktop/src/i18n/locales/zh.ts | 5 ++ desktop/src/stores/chatStore.test.ts | 41 +++++++++++++ desktop/src/stores/chatStore.ts | 44 +++++++++++++- desktop/src/types/chat.ts | 19 ++++++ src/server/__tests__/ws-memory-events.test.ts | 23 ++++++++ src/server/ws/events.ts | 9 +++ src/server/ws/handler.ts | 56 ++++++++++++++++++ 10 files changed, 286 insertions(+), 1 deletion(-) diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx index 6e75ff91..12b66416 100644 --- a/desktop/src/components/chat/MessageList.test.tsx +++ b/desktop/src/components/chat/MessageList.test.tsx @@ -37,6 +37,7 @@ function makeSessionState(overrides: Partial = {}): PerSessionS tokenUsage: { input_tokens: 0, output_tokens: 0 }, elapsedSeconds: 0, statusVerb: '', + apiRetry: null, slashCommands: [], agentTaskNotifications: {}, elapsedTimer: null, @@ -495,6 +496,32 @@ describe('MessageList nested tool calls', () => { expect(screen.queryByText('Compacting context...')).toBeNull() }) + it('shows API retry metadata in the active turn indicator', () => { + useChatStore.setState({ + sessions: { + [ACTIVE_TAB]: makeSessionState({ + chatState: 'thinking', + apiRetry: { + attempt: 2, + maxRetries: 10, + retryDelayMs: 3000, + errorStatus: 503, + errorType: 'server_error', + receivedAt: Date.now(), + }, + }), + }, + }) + + render() + + expect(screen.getByTestId('api-retry-indicator')).toBeTruthy() + expect(screen.getByText('Request failed, retrying')).toBeTruthy() + expect(screen.getByText('retry 2/10')).toBeTruthy() + expect(screen.getByText('HTTP 503')).toBeTruthy() + expect(screen.getByText(/waiting \d+s/)).toBeTruthy() + }) + it('renders compact completion as an expandable timeline divider', () => { useChatStore.setState({ sessions: { diff --git a/desktop/src/components/chat/StreamingIndicator.tsx b/desktop/src/components/chat/StreamingIndicator.tsx index 2449fb17..a2e35d70 100644 --- a/desktop/src/components/chat/StreamingIndicator.tsx +++ b/desktop/src/components/chat/StreamingIndicator.tsx @@ -1,3 +1,5 @@ +import { useEffect, useState } from 'react' +import { RefreshCw } from 'lucide-react' import { useChatStore } from '../../stores/chatStore' import { useTabStore } from '../../stores/tabStore' import { useTranslation, type TranslationKey } from '../../i18n' @@ -18,14 +20,70 @@ function translateServerVerb( return translated === key ? verb : translated } +function formatRetrySeconds(ms: number): number { + return Math.max(0, Math.ceil(ms / 1000)) +} + +function formatErrorType(errorType: string | undefined): string | null { + if (!errorType) return null + return errorType + .replace(/[_-]+/g, ' ') + .replace(/\s+/g, ' ') + .trim() +} + export function StreamingIndicator() { const t = useTranslation() + const [now, setNow] = useState(() => Date.now()) const activeTabId = useTabStore((s) => s.activeTabId) const sessionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId] : undefined) const chatState = sessionState?.chatState ?? 'idle' const statusVerb = sessionState?.statusVerb ?? '' + const apiRetry = sessionState?.apiRetry ?? null const elapsedSeconds = sessionState?.elapsedSeconds ?? 0 const tokenUsage = sessionState?.tokenUsage ?? { input_tokens: 0, output_tokens: 0 } + + useEffect(() => { + if (!apiRetry) return undefined + setNow(Date.now()) + const timer = window.setInterval(() => setNow(Date.now()), 1000) + return () => window.clearInterval(timer) + }, [apiRetry?.receivedAt, apiRetry?.retryDelayMs]) + + if (apiRetry) { + const remainingMs = Math.max(0, apiRetry.retryDelayMs - (now - apiRetry.receivedAt)) + const statusText = apiRetry.errorStatus !== null + ? t('chat.retry.httpStatus', { status: apiRetry.errorStatus }) + : formatErrorType(apiRetry.errorType) ?? t('chat.retry.networkError') + const detailText = apiRetry.errorMessage?.trim() + + return ( +
+
+ ) + } + let verb: string if (statusVerb) { verb = translateServerVerb(t, statusVerb) diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index cf360766..7283b2de 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -1541,6 +1541,11 @@ export const en = { 'serverVerb.Creating worktree': 'Creating worktree', 'serverVerb.Task started': 'Task started', 'serverVerb.Task in progress': 'Task in progress', + 'chat.retry.title': 'Request failed, retrying', + 'chat.retry.attempt': 'retry {attempt}/{max}', + 'chat.retry.httpStatus': 'HTTP {status}', + 'chat.retry.networkError': 'network error', + 'chat.retry.waiting': 'waiting {seconds}s', // ─── Tabs ────────────────────────────────────── 'tabs.close': 'Close', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 141f7355..daaf39a0 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -1543,6 +1543,11 @@ export const zh: Record = { 'serverVerb.Creating worktree': '正在创建工作树', 'serverVerb.Task started': '任务已启动', 'serverVerb.Task in progress': '任务进行中', + 'chat.retry.title': '请求失败,正在重试', + 'chat.retry.attempt': '第 {attempt}/{max} 次', + 'chat.retry.httpStatus': 'HTTP {status}', + 'chat.retry.networkError': '网络错误', + 'chat.retry.waiting': '等待 {seconds}s', // ─── Tabs ────────────────────────────────────── 'tabs.close': '关闭', diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts index 85aef449..e5b87033 100644 --- a/desktop/src/stores/chatStore.test.ts +++ b/desktop/src/stores/chatStore.test.ts @@ -148,6 +148,7 @@ function makeSession(overrides: Partial = {}): PerSessionState tokenUsage: { input_tokens: 0, output_tokens: 0 }, elapsedSeconds: 0, statusVerb: '', + apiRetry: null, slashCommands: [], agentTaskNotifications: {}, backgroundAgentTasks: {}, @@ -2023,6 +2024,46 @@ describe('chatStore history mapping', () => { expect(updateTabStatusMock).toHaveBeenLastCalledWith(TEST_SESSION_ID, 'running') }) + it('tracks API retry status until the request finishes', () => { + useChatStore.setState({ + sessions: { + [TEST_SESSION_ID]: makeSession({ + messages: [], + chatState: 'thinking', + statusVerb: 'Thinking', + }), + }, + }) + + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'api_retry', + attempt: 1, + maxRetries: 10, + retryDelayMs: 2500, + errorStatus: 503, + errorType: 'server_error', + }) + + const retryingSession = useChatStore.getState().sessions[TEST_SESSION_ID] + expect(retryingSession?.chatState).toBe('thinking') + expect(retryingSession?.statusVerb).toBe('') + expect(retryingSession?.apiRetry).toMatchObject({ + attempt: 1, + maxRetries: 10, + retryDelayMs: 2500, + errorStatus: 503, + errorType: 'server_error', + }) + expect(updateTabStatusMock).toHaveBeenLastCalledWith(TEST_SESSION_ID, 'running') + + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'message_complete', + usage: { input_tokens: 1, output_tokens: 0 }, + }) + + expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.apiRetry).toBeNull() + }) + it('renders memory saved notifications as chat memory events', () => { useChatStore.setState({ sessions: { diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts index 6ab4086b..9ffd5ccc 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -17,6 +17,7 @@ import type { RuntimeSelection } from '../types/runtime' import type { ActiveGoalState, AgentTaskNotification, + ApiRetryState, AttachmentRef, BackgroundAgentTask, BackgroundAgentTaskUsage, @@ -63,6 +64,7 @@ export type PerSessionState = { tokenUsage: TokenUsage elapsedSeconds: number statusVerb: string + apiRetry?: ApiRetryState | null slashCommands: Array<{ name: string; description: string; argumentHint?: string }> agentTaskNotifications: Record backgroundAgentTasks?: Record @@ -90,6 +92,7 @@ const DEFAULT_SESSION_STATE: PerSessionState = { tokenUsage: { input_tokens: 0, output_tokens: 0 }, elapsedSeconds: 0, statusVerb: '', + apiRetry: null, slashCommands: [], agentTaskNotifications: {}, backgroundAgentTasks: {}, @@ -784,6 +787,7 @@ export const useChatStore = create((set, get) => ({ elapsedSeconds: 0, streamingText: '', statusVerb: isMemberSession ? '' : randomSpinnerVerb(), + apiRetry: null, elapsedTimer: timer, connectionState: isMemberSession ? 'connected' : session.connectionState, }, @@ -871,6 +875,7 @@ export const useChatStore = create((set, get) => ({ chatState: 'idle', pendingPermission: null, pendingComputerUsePermission: null, + apiRetry: null, elapsedTimer: null, }, }, @@ -960,6 +965,7 @@ export const useChatStore = create((set, get) => ({ pendingComputerUsePermission: null, elapsedTimer: null, statusVerb: '', + apiRetry: null, })), } }) @@ -1015,7 +1021,13 @@ export const useChatStore = create((set, get) => ({ clearMessages: (sessionId) => { clearPendingTaskToolUseIds(sessionId) clearPendingToolParentUseIds(sessionId) - set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, () => ({ messages: [], activeGoal: null, streamingText: '', chatState: 'idle' })) })) + set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, () => ({ + messages: [], + activeGoal: null, + streamingText: '', + chatState: 'idle', + apiRetry: null, + })) })) }, handleServerMessage: (sessionId, msg) => { @@ -1061,6 +1073,7 @@ export const useChatStore = create((set, get) => ({ : '', ...(msg.tokens ? { tokenUsage: { ...session.tokenUsage, output_tokens: msg.tokens } } : {}), ...(msg.state === 'idle' ? { activeThinkingId: null } : {}), + ...(msg.state === 'idle' ? { apiRetry: null } : {}), ...((shouldFlush || msg.state === 'compacting') ? { messages: nextMessages } : {}), ...(shouldFlush ? { streamingText: '', @@ -1093,6 +1106,7 @@ export const useChatStore = create((set, get) => ({ ...(pendingText !== s.streamingText ? { streamingText: pendingText } : {}), chatState: 'streaming', activeThinkingId: null, + apiRetry: null, })) } else if (msg.blockType === 'tool_use') { rememberPendingToolParentUseId(sessionId, msg.toolUseId, msg.parentToolUseId) @@ -1102,11 +1116,34 @@ export const useChatStore = create((set, get) => ({ streamingToolInput: '', chatState: 'tool_executing', activeThinkingId: null, + apiRetry: null, })) } break } + case 'api_retry': { + const attempt = Math.max(1, Math.trunc(msg.attempt)) + const maxRetries = Math.max(attempt, Math.trunc(msg.maxRetries)) + const retryDelayMs = Math.max(0, Math.trunc(msg.retryDelayMs)) + update((session) => ({ + apiRetry: { + attempt, + maxRetries, + retryDelayMs, + errorStatus: msg.errorStatus ?? null, + errorType: msg.errorType, + errorMessage: msg.errorMessage, + receivedAt: Date.now(), + }, + chatState: session.chatState === 'idle' ? 'thinking' : session.chatState, + activeThinkingId: null, + statusVerb: '', + })) + useTabStore.getState().updateTabStatus(sessionId, 'running') + break + } + case 'content_delta': if (msg.text !== undefined) { if (!get().sessions[sessionId]) break @@ -1224,6 +1261,7 @@ export const useChatStore = create((set, get) => ({ pendingComputerUsePermission: null, chatState: 'permission_pending', activeThinkingId: null, + apiRetry: null, messages: msg.toolName === 'AskUserQuestion' ? s.messages @@ -1257,6 +1295,7 @@ export const useChatStore = create((set, get) => ({ pendingPermission: null, chatState: 'permission_pending', activeThinkingId: null, + apiRetry: null, })) break @@ -1283,6 +1322,7 @@ export const useChatStore = create((set, get) => ({ pendingPermission: null, pendingComputerUsePermission: null, elapsedTimer: null, + apiRetry: null, })) useTabStore.getState().updateTabStatus(sessionId, 'idle') const notification = wasAgentRunning @@ -1316,6 +1356,7 @@ export const useChatStore = create((set, get) => ({ streamingText: '', pendingPermission: null, pendingComputerUsePermission: null, + apiRetry: null, } }) useTabStore.getState().updateTabStatus(sessionId, 'error') @@ -1378,6 +1419,7 @@ export const useChatStore = create((set, get) => ({ elapsedTimer: null, elapsedSeconds: 0, statusVerb: '', + apiRetry: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, slashCommands: [], activeGoal: null, diff --git a/desktop/src/types/chat.ts b/desktop/src/types/chat.ts index a9c30aa5..aa1316f4 100644 --- a/desktop/src/types/chat.ts +++ b/desktop/src/types/chat.ts @@ -75,6 +75,15 @@ export type ServerMessage = | { type: 'message_complete'; usage: TokenUsage } | { type: 'thinking'; text: string } | { type: 'status'; state: ChatState; verb?: string; elapsed?: number; tokens?: number } + | { + type: 'api_retry' + attempt: number + maxRetries: number + retryDelayMs: number + errorStatus: number | null + errorType?: string + errorMessage?: string + } | { type: 'error'; message: string; code: string; retryable?: boolean } | { type: 'system_notification'; subtype: string; message?: string; data?: unknown } | { type: 'pong' } @@ -93,6 +102,16 @@ export type TokenUsage = { export type ChatState = 'idle' | 'thinking' | 'compacting' | 'tool_executing' | 'streaming' | 'permission_pending' +export type ApiRetryState = { + attempt: number + maxRetries: number + retryDelayMs: number + errorStatus: number | null + errorType?: string + errorMessage?: string + receivedAt: number +} + export type TeamMemberStatus = { agentId: string role: string diff --git a/src/server/__tests__/ws-memory-events.test.ts b/src/server/__tests__/ws-memory-events.test.ts index 692a9332..4d13f70f 100644 --- a/src/server/__tests__/ws-memory-events.test.ts +++ b/src/server/__tests__/ws-memory-events.test.ts @@ -95,6 +95,29 @@ describe('WebSocket compact events', () => { }) }) +describe('WebSocket API retry events', () => { + it('forwards CLI api_retry messages as structured retry status', () => { + expect(translateCliMessage({ + type: 'system', + subtype: 'api_retry', + attempt: 2, + max_retries: 10, + retry_delay_ms: 1500, + error_status: 503, + error: 'server_error', + }, 'session-1')).toEqual([ + { + type: 'api_retry', + attempt: 2, + maxRetries: 10, + retryDelayMs: 1500, + errorStatus: 503, + errorType: 'server_error', + }, + ]) + }) +}) + describe('WebSocket background task events', () => { it('forwards task start and progress as structured desktop notifications', () => { const started = { diff --git a/src/server/ws/events.ts b/src/server/ws/events.ts index e808f78a..ea9b6ea7 100644 --- a/src/server/ws/events.ts +++ b/src/server/ws/events.ts @@ -63,6 +63,15 @@ export type ServerMessage = | { type: 'message_complete'; usage: TokenUsage } | { type: 'thinking'; text: string } | { type: 'status'; state: ChatState; verb?: string; elapsed?: number; tokens?: number } + | { + type: 'api_retry' + attempt: number + maxRetries: number + retryDelayMs: number + errorStatus: number | null + errorType?: string + errorMessage?: string + } | { type: 'error'; message: string; code: string; retryable?: boolean } | { type: 'system_notification'; subtype: string; message?: string; data?: unknown } | { type: 'pong' } diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index f674620a..7737b1c9 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -1239,6 +1239,10 @@ export function translateCliMessage(cliMsg: any, sessionId: string): ServerMessa case 'system': { // 区分不同的 system 子类型 const subtype = cliMsg.subtype + if (subtype === 'api_retry') { + const apiRetryMessage = toApiRetryServerMessage(cliMsg) + return apiRetryMessage ? [apiRetryMessage] : [] + } if (subtype === 'init') { // CLI 初始化完成 — 缓存 slash commands 并发送模型信息 // NOTE: Do NOT send status:idle here — the CLI init fires while @@ -1386,6 +1390,58 @@ export function translateCliMessage(cliMsg: any, sessionId: string): ServerMessa // Helpers // ============================================================================ +function finiteNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + +function normalizeRetryCount(value: unknown): number | null { + const numeric = finiteNumber(value) + if (numeric === null) return null + return Math.max(0, Math.trunc(numeric)) +} + +function readRetryErrorRecord(value: unknown): Record | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null + return value as Record +} + +function readRetryErrorString(value: unknown, keys: string[]): string | undefined { + const record = readRetryErrorRecord(value) + if (!record) return undefined + for (const key of keys) { + const candidate = record[key] + if (typeof candidate === 'string' && candidate.trim()) return candidate.trim() + } + return undefined +} + +function toApiRetryServerMessage(cliMsg: any): ServerMessage | null { + const attempt = normalizeRetryCount(cliMsg.attempt) + const maxRetries = normalizeRetryCount(cliMsg.max_retries) + const retryDelayMs = normalizeRetryCount(cliMsg.retry_delay_ms) + if (attempt === null || maxRetries === null || retryDelayMs === null) return null + + const embeddedError = readRetryErrorRecord(cliMsg.error) + const embeddedStatus = embeddedError ? finiteNumber(embeddedError.status) : null + const rawStatus = cliMsg.error_status === null + ? null + : finiteNumber(cliMsg.error_status) ?? embeddedStatus + const errorType = typeof cliMsg.error === 'string' && cliMsg.error.trim() + ? cliMsg.error.trim() + : readRetryErrorString(cliMsg.error, ['type', 'code', 'name']) + const errorMessage = readRetryErrorString(cliMsg.error, ['message', 'error']) + + return { + type: 'api_retry', + attempt, + maxRetries, + retryDelayMs, + errorStatus: rawStatus === null ? null : Math.trunc(rawStatus), + ...(errorType ? { errorType } : {}), + ...(errorMessage ? { errorMessage } : {}), + } +} + function sendMessage(ws: ServerWebSocket, message: ServerMessage) { ws.send(JSON.stringify(message)) }