mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-31 16:33:34 +08:00
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.
This commit is contained in:
parent
9612fae4c2
commit
e437199b81
@ -37,6 +37,7 @@ function makeSessionState(overrides: Partial<PerSessionState> = {}): PerSessionS
|
|||||||
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||||
elapsedSeconds: 0,
|
elapsedSeconds: 0,
|
||||||
statusVerb: '',
|
statusVerb: '',
|
||||||
|
apiRetry: null,
|
||||||
slashCommands: [],
|
slashCommands: [],
|
||||||
agentTaskNotifications: {},
|
agentTaskNotifications: {},
|
||||||
elapsedTimer: null,
|
elapsedTimer: null,
|
||||||
@ -495,6 +496,32 @@ describe('MessageList nested tool calls', () => {
|
|||||||
expect(screen.queryByText('Compacting context...')).toBeNull()
|
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(<MessageList />)
|
||||||
|
|
||||||
|
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', () => {
|
it('renders compact completion as an expandable timeline divider', () => {
|
||||||
useChatStore.setState({
|
useChatStore.setState({
|
||||||
sessions: {
|
sessions: {
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { RefreshCw } from 'lucide-react'
|
||||||
import { useChatStore } from '../../stores/chatStore'
|
import { useChatStore } from '../../stores/chatStore'
|
||||||
import { useTabStore } from '../../stores/tabStore'
|
import { useTabStore } from '../../stores/tabStore'
|
||||||
import { useTranslation, type TranslationKey } from '../../i18n'
|
import { useTranslation, type TranslationKey } from '../../i18n'
|
||||||
@ -18,14 +20,70 @@ function translateServerVerb(
|
|||||||
return translated === key ? verb : translated
|
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() {
|
export function StreamingIndicator() {
|
||||||
const t = useTranslation()
|
const t = useTranslation()
|
||||||
|
const [now, setNow] = useState(() => Date.now())
|
||||||
const activeTabId = useTabStore((s) => s.activeTabId)
|
const activeTabId = useTabStore((s) => s.activeTabId)
|
||||||
const sessionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId] : undefined)
|
const sessionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId] : undefined)
|
||||||
const chatState = sessionState?.chatState ?? 'idle'
|
const chatState = sessionState?.chatState ?? 'idle'
|
||||||
const statusVerb = sessionState?.statusVerb ?? ''
|
const statusVerb = sessionState?.statusVerb ?? ''
|
||||||
|
const apiRetry = sessionState?.apiRetry ?? null
|
||||||
const elapsedSeconds = sessionState?.elapsedSeconds ?? 0
|
const elapsedSeconds = sessionState?.elapsedSeconds ?? 0
|
||||||
const tokenUsage = sessionState?.tokenUsage ?? { input_tokens: 0, output_tokens: 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 (
|
||||||
|
<div
|
||||||
|
data-testid="api-retry-indicator"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
className="mb-2 flex w-full max-w-[min(720px,100%)] flex-wrap items-center gap-2 rounded-md border border-amber-500/35 bg-amber-50/80 px-3 py-2 text-xs text-amber-950 shadow-sm dark:border-amber-400/25 dark:bg-amber-950/30 dark:text-amber-100"
|
||||||
|
>
|
||||||
|
<RefreshCw size={14} strokeWidth={2.2} className="shrink-0 animate-spin text-amber-700 dark:text-amber-300" aria-hidden="true" />
|
||||||
|
<span className="font-medium">{t('chat.retry.title')}</span>
|
||||||
|
<span className="rounded-[4px] border border-amber-700/20 bg-white/70 px-1.5 py-0.5 font-mono text-[11px] leading-none text-amber-900 dark:border-amber-300/20 dark:bg-black/15 dark:text-amber-100">
|
||||||
|
{t('chat.retry.attempt', { attempt: apiRetry.attempt, max: apiRetry.maxRetries })}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-[4px] border border-amber-700/20 bg-white/70 px-1.5 py-0.5 font-mono text-[11px] leading-none text-amber-900 dark:border-amber-300/20 dark:bg-black/15 dark:text-amber-100">
|
||||||
|
{statusText}
|
||||||
|
</span>
|
||||||
|
<span className="text-amber-800 dark:text-amber-200">
|
||||||
|
{t('chat.retry.waiting', { seconds: formatRetrySeconds(remainingMs) })}
|
||||||
|
</span>
|
||||||
|
{detailText && (
|
||||||
|
<span className="min-w-0 max-w-full truncate text-amber-700 dark:text-amber-200" title={detailText}>
|
||||||
|
{detailText}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
let verb: string
|
let verb: string
|
||||||
if (statusVerb) {
|
if (statusVerb) {
|
||||||
verb = translateServerVerb(t, statusVerb)
|
verb = translateServerVerb(t, statusVerb)
|
||||||
|
|||||||
@ -1541,6 +1541,11 @@ export const en = {
|
|||||||
'serverVerb.Creating worktree': 'Creating worktree',
|
'serverVerb.Creating worktree': 'Creating worktree',
|
||||||
'serverVerb.Task started': 'Task started',
|
'serverVerb.Task started': 'Task started',
|
||||||
'serverVerb.Task in progress': 'Task in progress',
|
'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 ──────────────────────────────────────
|
||||||
'tabs.close': 'Close',
|
'tabs.close': 'Close',
|
||||||
|
|||||||
@ -1543,6 +1543,11 @@ export const zh: Record<TranslationKey, string> = {
|
|||||||
'serverVerb.Creating worktree': '正在创建工作树',
|
'serverVerb.Creating worktree': '正在创建工作树',
|
||||||
'serverVerb.Task started': '任务已启动',
|
'serverVerb.Task started': '任务已启动',
|
||||||
'serverVerb.Task in progress': '任务进行中',
|
'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 ──────────────────────────────────────
|
||||||
'tabs.close': '关闭',
|
'tabs.close': '关闭',
|
||||||
|
|||||||
@ -148,6 +148,7 @@ function makeSession(overrides: Partial<PerSessionState> = {}): PerSessionState
|
|||||||
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||||
elapsedSeconds: 0,
|
elapsedSeconds: 0,
|
||||||
statusVerb: '',
|
statusVerb: '',
|
||||||
|
apiRetry: null,
|
||||||
slashCommands: [],
|
slashCommands: [],
|
||||||
agentTaskNotifications: {},
|
agentTaskNotifications: {},
|
||||||
backgroundAgentTasks: {},
|
backgroundAgentTasks: {},
|
||||||
@ -2023,6 +2024,46 @@ describe('chatStore history mapping', () => {
|
|||||||
expect(updateTabStatusMock).toHaveBeenLastCalledWith(TEST_SESSION_ID, 'running')
|
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', () => {
|
it('renders memory saved notifications as chat memory events', () => {
|
||||||
useChatStore.setState({
|
useChatStore.setState({
|
||||||
sessions: {
|
sessions: {
|
||||||
|
|||||||
@ -17,6 +17,7 @@ import type { RuntimeSelection } from '../types/runtime'
|
|||||||
import type {
|
import type {
|
||||||
ActiveGoalState,
|
ActiveGoalState,
|
||||||
AgentTaskNotification,
|
AgentTaskNotification,
|
||||||
|
ApiRetryState,
|
||||||
AttachmentRef,
|
AttachmentRef,
|
||||||
BackgroundAgentTask,
|
BackgroundAgentTask,
|
||||||
BackgroundAgentTaskUsage,
|
BackgroundAgentTaskUsage,
|
||||||
@ -63,6 +64,7 @@ export type PerSessionState = {
|
|||||||
tokenUsage: TokenUsage
|
tokenUsage: TokenUsage
|
||||||
elapsedSeconds: number
|
elapsedSeconds: number
|
||||||
statusVerb: string
|
statusVerb: string
|
||||||
|
apiRetry?: ApiRetryState | null
|
||||||
slashCommands: Array<{ name: string; description: string; argumentHint?: string }>
|
slashCommands: Array<{ name: string; description: string; argumentHint?: string }>
|
||||||
agentTaskNotifications: Record<string, AgentTaskNotification>
|
agentTaskNotifications: Record<string, AgentTaskNotification>
|
||||||
backgroundAgentTasks?: Record<string, BackgroundAgentTask>
|
backgroundAgentTasks?: Record<string, BackgroundAgentTask>
|
||||||
@ -90,6 +92,7 @@ const DEFAULT_SESSION_STATE: PerSessionState = {
|
|||||||
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||||
elapsedSeconds: 0,
|
elapsedSeconds: 0,
|
||||||
statusVerb: '',
|
statusVerb: '',
|
||||||
|
apiRetry: null,
|
||||||
slashCommands: [],
|
slashCommands: [],
|
||||||
agentTaskNotifications: {},
|
agentTaskNotifications: {},
|
||||||
backgroundAgentTasks: {},
|
backgroundAgentTasks: {},
|
||||||
@ -784,6 +787,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
|||||||
elapsedSeconds: 0,
|
elapsedSeconds: 0,
|
||||||
streamingText: '',
|
streamingText: '',
|
||||||
statusVerb: isMemberSession ? '' : randomSpinnerVerb(),
|
statusVerb: isMemberSession ? '' : randomSpinnerVerb(),
|
||||||
|
apiRetry: null,
|
||||||
elapsedTimer: timer,
|
elapsedTimer: timer,
|
||||||
connectionState: isMemberSession ? 'connected' : session.connectionState,
|
connectionState: isMemberSession ? 'connected' : session.connectionState,
|
||||||
},
|
},
|
||||||
@ -871,6 +875,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
|||||||
chatState: 'idle',
|
chatState: 'idle',
|
||||||
pendingPermission: null,
|
pendingPermission: null,
|
||||||
pendingComputerUsePermission: null,
|
pendingComputerUsePermission: null,
|
||||||
|
apiRetry: null,
|
||||||
elapsedTimer: null,
|
elapsedTimer: null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -960,6 +965,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
|||||||
pendingComputerUsePermission: null,
|
pendingComputerUsePermission: null,
|
||||||
elapsedTimer: null,
|
elapsedTimer: null,
|
||||||
statusVerb: '',
|
statusVerb: '',
|
||||||
|
apiRetry: null,
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -1015,7 +1021,13 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
|||||||
clearMessages: (sessionId) => {
|
clearMessages: (sessionId) => {
|
||||||
clearPendingTaskToolUseIds(sessionId)
|
clearPendingTaskToolUseIds(sessionId)
|
||||||
clearPendingToolParentUseIds(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) => {
|
handleServerMessage: (sessionId, msg) => {
|
||||||
@ -1061,6 +1073,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
|||||||
: '',
|
: '',
|
||||||
...(msg.tokens ? { tokenUsage: { ...session.tokenUsage, output_tokens: msg.tokens } } : {}),
|
...(msg.tokens ? { tokenUsage: { ...session.tokenUsage, output_tokens: msg.tokens } } : {}),
|
||||||
...(msg.state === 'idle' ? { activeThinkingId: null } : {}),
|
...(msg.state === 'idle' ? { activeThinkingId: null } : {}),
|
||||||
|
...(msg.state === 'idle' ? { apiRetry: null } : {}),
|
||||||
...((shouldFlush || msg.state === 'compacting') ? { messages: nextMessages } : {}),
|
...((shouldFlush || msg.state === 'compacting') ? { messages: nextMessages } : {}),
|
||||||
...(shouldFlush ? {
|
...(shouldFlush ? {
|
||||||
streamingText: '',
|
streamingText: '',
|
||||||
@ -1093,6 +1106,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
|||||||
...(pendingText !== s.streamingText ? { streamingText: pendingText } : {}),
|
...(pendingText !== s.streamingText ? { streamingText: pendingText } : {}),
|
||||||
chatState: 'streaming',
|
chatState: 'streaming',
|
||||||
activeThinkingId: null,
|
activeThinkingId: null,
|
||||||
|
apiRetry: null,
|
||||||
}))
|
}))
|
||||||
} else if (msg.blockType === 'tool_use') {
|
} else if (msg.blockType === 'tool_use') {
|
||||||
rememberPendingToolParentUseId(sessionId, msg.toolUseId, msg.parentToolUseId)
|
rememberPendingToolParentUseId(sessionId, msg.toolUseId, msg.parentToolUseId)
|
||||||
@ -1102,11 +1116,34 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
|||||||
streamingToolInput: '',
|
streamingToolInput: '',
|
||||||
chatState: 'tool_executing',
|
chatState: 'tool_executing',
|
||||||
activeThinkingId: null,
|
activeThinkingId: null,
|
||||||
|
apiRetry: null,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
break
|
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':
|
case 'content_delta':
|
||||||
if (msg.text !== undefined) {
|
if (msg.text !== undefined) {
|
||||||
if (!get().sessions[sessionId]) break
|
if (!get().sessions[sessionId]) break
|
||||||
@ -1224,6 +1261,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
|||||||
pendingComputerUsePermission: null,
|
pendingComputerUsePermission: null,
|
||||||
chatState: 'permission_pending',
|
chatState: 'permission_pending',
|
||||||
activeThinkingId: null,
|
activeThinkingId: null,
|
||||||
|
apiRetry: null,
|
||||||
messages:
|
messages:
|
||||||
msg.toolName === 'AskUserQuestion'
|
msg.toolName === 'AskUserQuestion'
|
||||||
? s.messages
|
? s.messages
|
||||||
@ -1257,6 +1295,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
|||||||
pendingPermission: null,
|
pendingPermission: null,
|
||||||
chatState: 'permission_pending',
|
chatState: 'permission_pending',
|
||||||
activeThinkingId: null,
|
activeThinkingId: null,
|
||||||
|
apiRetry: null,
|
||||||
}))
|
}))
|
||||||
break
|
break
|
||||||
|
|
||||||
@ -1283,6 +1322,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
|||||||
pendingPermission: null,
|
pendingPermission: null,
|
||||||
pendingComputerUsePermission: null,
|
pendingComputerUsePermission: null,
|
||||||
elapsedTimer: null,
|
elapsedTimer: null,
|
||||||
|
apiRetry: null,
|
||||||
}))
|
}))
|
||||||
useTabStore.getState().updateTabStatus(sessionId, 'idle')
|
useTabStore.getState().updateTabStatus(sessionId, 'idle')
|
||||||
const notification = wasAgentRunning
|
const notification = wasAgentRunning
|
||||||
@ -1316,6 +1356,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
|||||||
streamingText: '',
|
streamingText: '',
|
||||||
pendingPermission: null,
|
pendingPermission: null,
|
||||||
pendingComputerUsePermission: null,
|
pendingComputerUsePermission: null,
|
||||||
|
apiRetry: null,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
useTabStore.getState().updateTabStatus(sessionId, 'error')
|
useTabStore.getState().updateTabStatus(sessionId, 'error')
|
||||||
@ -1378,6 +1419,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
|||||||
elapsedTimer: null,
|
elapsedTimer: null,
|
||||||
elapsedSeconds: 0,
|
elapsedSeconds: 0,
|
||||||
statusVerb: '',
|
statusVerb: '',
|
||||||
|
apiRetry: null,
|
||||||
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||||
slashCommands: [],
|
slashCommands: [],
|
||||||
activeGoal: null,
|
activeGoal: null,
|
||||||
|
|||||||
@ -75,6 +75,15 @@ export type ServerMessage =
|
|||||||
| { type: 'message_complete'; usage: TokenUsage }
|
| { type: 'message_complete'; usage: TokenUsage }
|
||||||
| { type: 'thinking'; text: string }
|
| { type: 'thinking'; text: string }
|
||||||
| { type: 'status'; state: ChatState; verb?: string; elapsed?: number; tokens?: number }
|
| { 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: 'error'; message: string; code: string; retryable?: boolean }
|
||||||
| { type: 'system_notification'; subtype: string; message?: string; data?: unknown }
|
| { type: 'system_notification'; subtype: string; message?: string; data?: unknown }
|
||||||
| { type: 'pong' }
|
| { type: 'pong' }
|
||||||
@ -93,6 +102,16 @@ export type TokenUsage = {
|
|||||||
|
|
||||||
export type ChatState = 'idle' | 'thinking' | 'compacting' | 'tool_executing' | 'streaming' | 'permission_pending'
|
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 = {
|
export type TeamMemberStatus = {
|
||||||
agentId: string
|
agentId: string
|
||||||
role: string
|
role: string
|
||||||
|
|||||||
@ -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', () => {
|
describe('WebSocket background task events', () => {
|
||||||
it('forwards task start and progress as structured desktop notifications', () => {
|
it('forwards task start and progress as structured desktop notifications', () => {
|
||||||
const started = {
|
const started = {
|
||||||
|
|||||||
@ -63,6 +63,15 @@ export type ServerMessage =
|
|||||||
| { type: 'message_complete'; usage: TokenUsage }
|
| { type: 'message_complete'; usage: TokenUsage }
|
||||||
| { type: 'thinking'; text: string }
|
| { type: 'thinking'; text: string }
|
||||||
| { type: 'status'; state: ChatState; verb?: string; elapsed?: number; tokens?: number }
|
| { 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: 'error'; message: string; code: string; retryable?: boolean }
|
||||||
| { type: 'system_notification'; subtype: string; message?: string; data?: unknown }
|
| { type: 'system_notification'; subtype: string; message?: string; data?: unknown }
|
||||||
| { type: 'pong' }
|
| { type: 'pong' }
|
||||||
|
|||||||
@ -1239,6 +1239,10 @@ export function translateCliMessage(cliMsg: any, sessionId: string): ServerMessa
|
|||||||
case 'system': {
|
case 'system': {
|
||||||
// 区分不同的 system 子类型
|
// 区分不同的 system 子类型
|
||||||
const subtype = cliMsg.subtype
|
const subtype = cliMsg.subtype
|
||||||
|
if (subtype === 'api_retry') {
|
||||||
|
const apiRetryMessage = toApiRetryServerMessage(cliMsg)
|
||||||
|
return apiRetryMessage ? [apiRetryMessage] : []
|
||||||
|
}
|
||||||
if (subtype === 'init') {
|
if (subtype === 'init') {
|
||||||
// CLI 初始化完成 — 缓存 slash commands 并发送模型信息
|
// CLI 初始化完成 — 缓存 slash commands 并发送模型信息
|
||||||
// NOTE: Do NOT send status:idle here — the CLI init fires while
|
// 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
|
// 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<string, unknown> | null {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
|
||||||
|
return value as Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
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<WebSocketData>, message: ServerMessage) {
|
function sendMessage(ws: ServerWebSocket<WebSocketData>, message: ServerMessage) {
|
||||||
ws.send(JSON.stringify(message))
|
ws.send(JSON.stringify(message))
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user