(null)
@@ -394,6 +469,7 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
chatState,
messages,
resolvedSessionId,
+ retry,
scheduleAutoScroll,
streamingText,
turnChangeCards,
@@ -709,6 +785,8 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
)}
+ {retry && }
+
{!isLoadingTurnChangeCards && turnChangeCards.length === 0 && turnChangeLoadError && (
{turnChangeLoadError}
diff --git a/desktop/src/components/workspace/WorkspacePanel.test.tsx b/desktop/src/components/workspace/WorkspacePanel.test.tsx
index 80ab9d755..3cb737b3e 100644
--- a/desktop/src/components/workspace/WorkspacePanel.test.tsx
+++ b/desktop/src/components/workspace/WorkspacePanel.test.tsx
@@ -806,7 +806,7 @@ describe('WorkspacePanel', () => {
expect(view.getByTestId('workspace-code').textContent).toContain('const line2300 = 2300')
})
expect(view.getByRole('button', { name: 'Collapse preview' })).toBeTruthy()
- })
+ }, 30_000)
it('renders image previews from workspace files', async () => {
await setWorkspaceState((state) => ({
diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts
index b30e32784..dafc389ea 100644
--- a/desktop/src/stores/chatStore.test.ts
+++ b/desktop/src/stores/chatStore.test.ts
@@ -1282,10 +1282,20 @@ describe('chatStore history mapping', () => {
data: retry,
})
- expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.retry).toEqual(retry)
- expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([
- { type: 'system', content: 'Model request failed. Retrying in 120 seconds (retry #1).' },
- ])
+ expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.retry).toMatchObject({
+ ...retry,
+ status: 'scheduled',
+ statusMessage: 'Model request failed. Retrying in 120 seconds (retry #1).',
+ })
+ expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toEqual([])
+
+ useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
+ type: 'error',
+ message: 'API Error: overloaded',
+ code: 'CLI_ERROR',
+ })
+
+ expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toEqual([])
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'system_notification',
@@ -1297,7 +1307,9 @@ describe('chatStore history mapping', () => {
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.retry).toMatchObject({
paused: true,
nextRetryAt: null,
+ status: 'paused',
})
+ expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toEqual([])
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'system_notification',
@@ -1307,7 +1319,82 @@ describe('chatStore history mapping', () => {
})
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.retry).toBeNull()
- expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toHaveLength(3)
+ expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([
+ { type: 'system', content: 'Automatic retry state cleared.' },
+ ])
+ })
+
+ it('coalesces automatic retry errors into retry state across attempts', () => {
+ useChatStore.setState({
+ sessions: {
+ [TEST_SESSION_ID]: makeSession({ chatState: 'idle' }),
+ },
+ })
+
+ const retryError = 'API Error: Input is too long.'
+ const retry1 = {
+ paused: false,
+ failureCount: 1,
+ nextAttempt: 1,
+ intervalMs: 120_000,
+ nextRetryAt: 1760000000000,
+ errorMessage: retryError,
+ errorCode: 'CLI_ERROR',
+ }
+ const retry2 = {
+ ...retry1,
+ failureCount: 2,
+ nextAttempt: 2,
+ nextRetryAt: 1760000120000,
+ }
+
+ useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
+ type: 'error',
+ message: retryError,
+ code: 'CLI_ERROR',
+ })
+
+ expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([
+ { type: 'error', message: retryError, code: 'CLI_ERROR' },
+ ])
+
+ useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
+ type: 'system_notification',
+ subtype: 'retry_scheduled',
+ message: 'Model request failed. Retrying in 120 seconds (retry #1).',
+ data: retry1,
+ })
+
+ expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toEqual([])
+ expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.retry).toMatchObject({
+ failureCount: 1,
+ status: 'scheduled',
+ })
+
+ useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
+ type: 'system_notification',
+ subtype: 'retry_attempting',
+ message: 'Retrying model request (retry #1).',
+ data: { ...retry1, nextRetryAt: null },
+ })
+ useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
+ type: 'error',
+ message: retryError,
+ code: 'CLI_ERROR',
+ })
+ useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
+ type: 'system_notification',
+ subtype: 'retry_scheduled',
+ message: 'Model request failed. Retrying in 120 seconds (retry #2).',
+ data: retry2,
+ })
+
+ expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toEqual([])
+ expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.retry).toMatchObject({
+ failureCount: 2,
+ status: 'scheduled',
+ statusMessage: 'Model request failed. Retrying in 120 seconds (retry #2).',
+ })
})
it('clears local message state for only the requested session', () => {
diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts
index dd866308d..8b25443b4 100644
--- a/desktop/src/stores/chatStore.ts
+++ b/desktop/src/stores/chatStore.ts
@@ -182,6 +182,55 @@ function isAutoRetryStateData(value: unknown): value is AutoRetryState {
)
}
+function getRetryStatusFromSubtype(
+ subtype: string,
+ retry: AutoRetryState,
+): AutoRetryState['status'] {
+ if (subtype === 'retry_attempting' || subtype === 'retry_resumed') return 'attempting'
+ if (subtype === 'retry_paused' || retry.paused) return 'paused'
+ if (subtype === 'retry_scheduled') return 'scheduled'
+ return 'status'
+}
+
+function normalizeRetryState(
+ retry: AutoRetryState,
+ subtype: string,
+ message: string | null,
+): AutoRetryState {
+ return {
+ ...retry,
+ status: getRetryStatusFromSubtype(subtype, retry),
+ statusMessage: message ?? retry.statusMessage,
+ }
+}
+
+function removeTrailingRetryErrorMessages(
+ messages: UIMessage[],
+ retry: AutoRetryState | null,
+): UIMessage[] {
+ if (!retry) return messages
+
+ let end = messages.length
+ while (end > 0) {
+ const message = messages[end - 1]
+ if (
+ message?.type === 'error' &&
+ message.message === retry.errorMessage &&
+ message.code === retry.errorCode
+ ) {
+ end -= 1
+ continue
+ }
+ break
+ }
+
+ return end === messages.length ? messages : messages.slice(0, end)
+}
+
+function shouldAppendRetrySystemMessage(subtype: string): boolean {
+ return subtype === 'retry_status' || subtype === 'retry_cleared'
+}
+
// Streaming throttle for content_delta. Buffers must be per-session because
// multiple desktop tabs can stream at the same time.
const pendingDeltaBySession = new Map()
@@ -570,6 +619,7 @@ export const useChatStore = create((set, get) => ({
statusVerb: isMemberSession ? '' : randomSpinnerVerb(),
elapsedTimer: timer,
connectionState: isMemberSession ? 'connected' : session.connectionState,
+ retry: null,
},
},
}
@@ -1053,7 +1103,9 @@ export const useChatStore = create((set, get) => ({
if (pendingText.trim()) {
newMessages = appendAssistantTextMessage(newMessages, pendingText, Date.now())
}
- newMessages = [...newMessages, { id: nextId(), type: 'error', message: msg.message, code: msg.code, timestamp: Date.now() }]
+ if (!s.retry) {
+ newMessages = [...newMessages, { id: nextId(), type: 'error', message: msg.message, code: msg.code, timestamp: Date.now() }]
+ }
return {
messages: newMessages,
chatState: 'idle',
@@ -1152,25 +1204,37 @@ export const useChatStore = create((set, get) => ({
typeof msg.message === 'string' && msg.message.trim()
? msg.message
: null
- update((session) => ({
- retry:
- msg.subtype === 'retry_cleared' || msg.subtype === 'retry_succeeded'
- ? null
- : retry ?? session.retry ?? null,
- ...(message
- ? {
- messages: [
- ...session.messages,
+ const nextRetry =
+ msg.subtype === 'retry_cleared' || msg.subtype === 'retry_succeeded'
+ ? null
+ : retry
+ ? normalizeRetryState(retry, msg.subtype, message)
+ : null
+ update((session) => {
+ const retryForErrorCleanup = nextRetry ?? session.retry ?? null
+ const messages = removeTrailingRetryErrorMessages(
+ session.messages,
+ retryForErrorCleanup,
+ )
+ return {
+ retry: nextRetry ?? (
+ msg.subtype === 'retry_cleared' || msg.subtype === 'retry_succeeded'
+ ? null
+ : session.retry ?? null
+ ),
+ messages: message && shouldAppendRetrySystemMessage(msg.subtype)
+ ? [
+ ...messages,
{
id: nextId(),
type: 'system',
content: message,
timestamp: Date.now(),
},
- ],
- }
- : {}),
- }))
+ ]
+ : messages,
+ }
+ })
}
if (msg.subtype === 'compact_boundary') {
update((session) => ({
diff --git a/desktop/src/types/chat.ts b/desktop/src/types/chat.ts
index 5f4dd83a3..eecf0cb56 100644
--- a/desktop/src/types/chat.ts
+++ b/desktop/src/types/chat.ts
@@ -111,6 +111,10 @@ export type AutoRetryState = {
nextRetryAt: number | null
errorMessage: string
errorCode: string
+ source?: 'user' | 'goal' | 'synthetic'
+ synthetic?: boolean
+ status?: 'scheduled' | 'attempting' | 'paused' | 'status' | 'resumed'
+ statusMessage?: string
}
export type ChatState = 'idle' | 'thinking' | 'tool_executing' | 'streaming' | 'permission_pending'