Skip to content

Commit 3f3670d

Browse files
fix: retract transient send error and restored prompt once server confirms send
When a long-running prompt request drops (gateway timeout or network error) while the agent is actually working, the send error banner and the restored prompt text were left in place. Tag send errors as 'network' and clear them when the server confirms session activity (message.updated / session.idle), also clearing the restored input text. Genuine server-reported session errors (kind 'session') are preserved.
1 parent 57cf327 commit 3f3670d

7 files changed

Lines changed: 194 additions & 8 deletions

File tree

frontend/src/components/message/PromptInput.stt.test.tsx

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,43 @@ describe('PromptInput STT Gesture Tests', () => {
366366
})
367367
})
368368

369+
it('clears the restored prompt when the send error resolves', async () => {
370+
const queryClient = createTestQueryClient()
371+
mocks.useSendErrorStore.mockImplementation((selector) => selector({
372+
errors: {
373+
'test-session': {
374+
sessionID: 'test-session',
375+
title: 'Connection Failed',
376+
message: 'Could not connect.',
377+
failedPrompt: 'recovered prompt',
378+
kind: 'network',
379+
},
380+
},
381+
}))
382+
383+
const { rerender } = render(
384+
<QueryClientProvider client={queryClient}>
385+
<PromptInput {...defaultProps} />
386+
</QueryClientProvider>,
387+
)
388+
389+
await waitFor(() => {
390+
expect(screen.getByPlaceholderText('Send a message...')).toHaveValue('recovered prompt')
391+
})
392+
393+
mocks.useSendErrorStore.mockImplementation((selector) => selector({ errors: {} }))
394+
395+
rerender(
396+
<QueryClientProvider client={queryClient}>
397+
<PromptInput {...defaultProps} />
398+
</QueryClientProvider>,
399+
)
400+
401+
await waitFor(() => {
402+
expect(screen.getByPlaceholderText('Send a message...')).toHaveValue('')
403+
})
404+
})
405+
369406
it('keeps stop available while active with prompt content', async () => {
370407
render(
371408
<QueryClientProvider client={createTestQueryClient()}>

frontend/src/components/message/PromptInput.tsx

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -239,11 +239,20 @@ export const PromptInput = memo(forwardRef<PromptInputHandle, PromptInputProps>(
239239
const restoredFailedPromptRef = useRef<string | null>(null)
240240

241241
useEffect(() => {
242-
if (!failedPrompt || restoredFailedPromptRef.current === failedPrompt) return
243-
restoredFailedPromptRef.current = failedPrompt
244-
if (promptRef.current) return
245-
setPrompt(failedPrompt)
246-
textareaRef.current?.focus()
242+
if (failedPrompt) {
243+
if (restoredFailedPromptRef.current === failedPrompt) return
244+
restoredFailedPromptRef.current = failedPrompt
245+
if (promptRef.current) return
246+
setPrompt(failedPrompt)
247+
textareaRef.current?.focus()
248+
return
249+
}
250+
if (restoredFailedPromptRef.current !== null) {
251+
if (promptRef.current === restoredFailedPromptRef.current) {
252+
setPrompt('')
253+
}
254+
restoredFailedPromptRef.current = null
255+
}
247256
}, [failedPrompt])
248257

249258
const mentionItems = useMemo((): MentionItem[] => {

frontend/src/hooks/useOpenCode.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -506,6 +506,7 @@ export const useSendPrompt = (opcodeUrl: string | null | undefined, directory?:
506506
message: parsed.message,
507507
detail: error instanceof FetchError ? error.detail : undefined,
508508
failedPrompt: failedPrompt || undefined,
509+
kind: 'network',
509510
});
510511
},
511512
onSuccess: async (data, variables) => {

frontend/src/hooks/useSSE.test.tsx

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,11 +396,128 @@ describe('useSSE', () => {
396396
title: 'Error',
397397
message: 'Queued send failed',
398398
failedPrompt: 'queued message',
399+
kind: 'session',
399400
})
400401

401402
unmount()
402403
})
403404

405+
it('retracts a network send error when the server confirms session activity', async () => {
406+
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
407+
const wrapper = ({ children }: { children: ReactNode }) => (
408+
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
409+
)
410+
411+
useSendErrorStore.getState().setError({
412+
sessionID: 'session-1',
413+
title: 'Connection Failed',
414+
message: 'Could not connect to the server.',
415+
failedPrompt: 'in-flight prompt',
416+
kind: 'network',
417+
})
418+
419+
const { result, unmount } = renderHook(
420+
() => useSSE('http://localhost:5551', '/repo', 'session-1'),
421+
{ wrapper },
422+
)
423+
424+
await waitFor(() => expect(MockEventSource.instances).toHaveLength(1))
425+
act(() => {
426+
MockEventSource.instances[0].emit('connected', { clientId: 'client-1' })
427+
})
428+
await waitFor(() => expect(result.current.isConnected).toBe(true))
429+
430+
act(() => {
431+
MockEventSource.instances[0].emit('message', {
432+
type: 'message.updated',
433+
properties: {
434+
info: { id: 'assistant-1', role: 'assistant', sessionID: 'session-1', time: { created: 1 } },
435+
},
436+
})
437+
})
438+
439+
expect(useSendErrorStore.getState().getError('session-1')).toBeNull()
440+
441+
unmount()
442+
})
443+
444+
it('retracts a network send error when the session goes idle', async () => {
445+
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
446+
const wrapper = ({ children }: { children: ReactNode }) => (
447+
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
448+
)
449+
450+
useSendErrorStore.getState().setError({
451+
sessionID: 'session-1',
452+
title: 'Request Timeout',
453+
message: 'The request took too long.',
454+
kind: 'network',
455+
})
456+
457+
const { result, unmount } = renderHook(
458+
() => useSSE('http://localhost:5551', '/repo', 'session-1'),
459+
{ wrapper },
460+
)
461+
462+
await waitFor(() => expect(MockEventSource.instances).toHaveLength(1))
463+
act(() => {
464+
MockEventSource.instances[0].emit('connected', { clientId: 'client-1' })
465+
})
466+
await waitFor(() => expect(result.current.isConnected).toBe(true))
467+
468+
act(() => {
469+
MockEventSource.instances[0].emit('message', {
470+
type: 'session.idle',
471+
properties: { sessionID: 'session-1' },
472+
})
473+
})
474+
475+
expect(useSendErrorStore.getState().getError('session-1')).toBeNull()
476+
477+
unmount()
478+
})
479+
480+
it('preserves a server-reported session error when the session later goes idle', async () => {
481+
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
482+
const wrapper = ({ children }: { children: ReactNode }) => (
483+
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
484+
)
485+
486+
useSendErrorStore.getState().setQueuedPrompt('session-1', 'queued message')
487+
488+
const { result, unmount } = renderHook(
489+
() => useSSE('http://localhost:5551', '/repo', 'session-1'),
490+
{ wrapper },
491+
)
492+
493+
await waitFor(() => expect(MockEventSource.instances).toHaveLength(1))
494+
act(() => {
495+
MockEventSource.instances[0].emit('connected', { clientId: 'client-1' })
496+
})
497+
await waitFor(() => expect(result.current.isConnected).toBe(true))
498+
499+
act(() => {
500+
MockEventSource.instances[0].emit('message', {
501+
type: 'session.error',
502+
properties: {
503+
sessionID: 'session-1',
504+
error: { name: 'UnknownError', data: { message: 'Queued send failed' } },
505+
},
506+
})
507+
})
508+
509+
act(() => {
510+
MockEventSource.instances[0].emit('message', {
511+
type: 'session.idle',
512+
properties: { sessionID: 'session-1' },
513+
})
514+
})
515+
516+
expect(useSendErrorStore.getState().getError('session-1')).not.toBeNull()
517+
518+
unmount()
519+
})
520+
404521
it('does not create a send error banner once the queued prompt has been cleared', async () => {
405522
const queryClient = new QueryClient({
406523
defaultOptions: {

frontend/src/hooks/useSSE.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ export const useSSE = (opcodeUrl: string | null | undefined, directory?: string
156156

157157
const { info } = event.properties
158158
const sessionID = info.sessionID
159+
useSendErrorStore.getState().clearNetworkError(sessionID)
159160
if (info.role === 'user') {
160161
useSendErrorStore.getState().clearQueuedPrompt(sessionID)
161162
}
@@ -231,6 +232,7 @@ export const useSSE = (opcodeUrl: string | null | undefined, directory?: string
231232
const { sessionID } = event.properties
232233

233234
setSessionStatus(sessionID, { type: 'idle' })
235+
useSendErrorStore.getState().clearNetworkError(sessionID)
234236

235237
batcherRef.current?.flush({ sessionID, directory: cacheDirectory })
236238

frontend/src/stores/sendErrorStore.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ describe('useSendErrorStore', () => {
3838
title: 'Error',
3939
message: 'Failed',
4040
failedPrompt: 'queued message',
41+
kind: 'session',
4142
})
4243
expect(useSendErrorStore.getState().queuedPrompts['session-1']).toBeUndefined()
4344
})
@@ -51,4 +52,17 @@ describe('useSendErrorStore', () => {
5152

5253
expect(useSendErrorStore.getState().getError('session-1')).toBeNull()
5354
})
55+
56+
it('clearNetworkError retracts a network error', () => {
57+
useSendErrorStore.getState().setError({ sessionID: 'session-1', title: 'Error', message: 'msg', kind: 'network' })
58+
useSendErrorStore.getState().clearNetworkError('session-1')
59+
expect(useSendErrorStore.getState().getError('session-1')).toBeNull()
60+
})
61+
62+
it('clearNetworkError preserves a server-reported session error', () => {
63+
useSendErrorStore.getState().setQueuedPrompt('session-1', 'queued message')
64+
useSendErrorStore.getState().failQueuedPrompt({ sessionID: 'session-1', title: 'Error', message: 'Failed' })
65+
useSendErrorStore.getState().clearNetworkError('session-1')
66+
expect(useSendErrorStore.getState().getError('session-1')).not.toBeNull()
67+
})
5468
})

frontend/src/stores/sendErrorStore.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export interface SendError {
66
message: string
77
detail?: string
88
failedPrompt?: string
9+
kind?: 'network' | 'session'
910
}
1011

1112
interface SendErrorStore {
@@ -14,8 +15,9 @@ interface SendErrorStore {
1415
setError: (err: SendError) => void
1516
setQueuedPrompt: (sessionID: string, prompt: string) => void
1617
clearQueuedPrompt: (sessionID: string) => void
17-
failQueuedPrompt: (err: Omit<SendError, 'failedPrompt'>) => void
18+
failQueuedPrompt: (err: Omit<SendError, 'failedPrompt' | 'kind'>) => void
1819
clearError: (sessionID: string) => void
20+
clearNetworkError: (sessionID: string) => void
1921
getError: (sessionID: string) => SendError | null
2022
}
2123

@@ -39,7 +41,7 @@ export const useSendErrorStore = create<SendErrorStore>((set, get) => ({
3941
return { queuedPrompts }
4042
})
4143
},
42-
failQueuedPrompt: (err: Omit<SendError, 'failedPrompt'>) => {
44+
failQueuedPrompt: (err: Omit<SendError, 'failedPrompt' | 'kind'>) => {
4345
set((state) => {
4446
const failedPrompt = state.queuedPrompts[err.sessionID]
4547
if (!failedPrompt) return state
@@ -48,7 +50,7 @@ export const useSendErrorStore = create<SendErrorStore>((set, get) => ({
4850
return {
4951
errors: {
5052
...state.errors,
51-
[err.sessionID]: { ...err, failedPrompt },
53+
[err.sessionID]: { ...err, failedPrompt, kind: 'session' },
5254
},
5355
queuedPrompts,
5456
}
@@ -61,6 +63,10 @@ export const useSendErrorStore = create<SendErrorStore>((set, get) => ({
6163
return { errors: newErrors }
6264
})
6365
},
66+
clearNetworkError: (sessionID: string) => {
67+
if (get().errors[sessionID]?.kind === 'session') return
68+
get().clearError(sessionID)
69+
},
6470
getError: (sessionID: string) => {
6571
return get().errors[sessionID] || null
6672
},

0 commit comments

Comments
 (0)