Skip to content

Commit 59fd516

Browse files
fix(session): unify prompt sends on prompt_async and suppress stale network banners
Remove the long-lived synchronous POST to /session/{id}/message that produced spurious "Connection Failed" banners on transient network blips; route every send (normal, streaming-queue follow-up, message refresh) through prompt_async, whose failure genuinely means not-delivered. Suppress network-kind banners while SSE is disconnected or reconnecting (the header already shows that state); session-kind banners still appear and reconcile clears confirmed prompts after reconnect.
1 parent 80bca44 commit 59fd516

9 files changed

Lines changed: 157 additions & 168 deletions

File tree

‎frontend/src/api/opencode.ts‎

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ type SessionListParams = NonNullable<paths['/session']['get']['parameters']['que
88
}
99
type CreateSessionRequest = NonNullable<paths['/session']['post']['requestBody']>['content']['application/json']
1010
type MessageListResponse = paths['/session/{sessionID}/message']['get']['responses']['200']['content']['application/json']
11-
type SendPromptRequest = NonNullable<paths['/session/{sessionID}/message']['post']['requestBody']>['content']['application/json']
1211
type SendPromptAsyncRequest = NonNullable<paths['/session/{sessionID}/prompt_async']['post']['requestBody']>['content']['application/json']
1312
type ConfigResponse = paths['/config']['get']['responses']['200']['content']['application/json']
1413
type CommandListResponse = paths['/command']['get']['responses']['200']['content']['application/json']
@@ -18,7 +17,6 @@ type ShellRequest = NonNullable<paths['/session/{sessionID}/shell']['post']['req
1817
type AgentListResponse = paths['/agent']['get']['responses']['200']['content']['application/json']
1918
type PermissionListResponse = paths['/permission']['get']['responses']['200']['content']['application/json']
2019
type QuestionListResponse = paths['/question']['get']['responses']['200']['content']['application/json']
21-
type SendPromptResponse = paths['/session/{sessionID}/message']['post']['responses']['200']['content']['application/json']
2220
type LspStatusResponse = paths['/lsp']['get']['responses']['200']['content']['application/json']
2321
type LspStatus = LspStatusResponse[number]
2422

@@ -91,7 +89,7 @@ function toLegacySession(session: SessionV2Info, directory?: string): LegacySess
9189
} as LegacySession
9290
}
9391

94-
export type { SendPromptResponse, SendCommandResponse, LspStatus }
92+
export type { SendCommandResponse, LspStatus }
9593

9694
export class OpenCodeClient {
9795
private baseURL: string
@@ -196,19 +194,6 @@ export class OpenCodeClient {
196194
})
197195
}
198196

199-
async sendPrompt(sessionID: string, data: SendPromptRequest): Promise<SendPromptResponse> {
200-
return fetchWrapper<SendPromptResponse>(
201-
`${this.baseURL}/session/${sessionID}/message`,
202-
{
203-
method: 'POST',
204-
params: this.getParams(),
205-
headers: { 'Content-Type': 'application/json' },
206-
body: JSON.stringify(data),
207-
timeout: 0,
208-
}
209-
)
210-
}
211-
212197
async sendPromptAsync(sessionID: string, data: SendPromptAsyncRequest): Promise<void> {
213198
return fetchWrapperVoid(
214199
`${this.baseURL}/session/${sessionID}/prompt_async`,

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

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -317,10 +317,9 @@ describe('PromptInput STT Gesture Tests', () => {
317317

318318
fireEvent.click(queueButton)
319319

320-
expect(mocks.useSendPromptMutate).toHaveBeenCalledWith(
321-
expect.objectContaining({ prompt: 'follow-up', queued: true }),
322-
expect.any(Object),
323-
)
320+
const [callArgs] = mocks.useSendPromptMutate.mock.calls[0] as [{ prompt: string }, unknown]
321+
expect(callArgs.prompt).toBe('follow-up')
322+
expect('queued' in callArgs).toBe(false)
324323
})
325324

326325
it('restores a failed queued prompt when the input is empty', async () => {
@@ -335,10 +334,9 @@ describe('PromptInput STT Gesture Tests', () => {
335334
fireEvent.change(input, { target: { value: 'queued message' } })
336335
fireEvent.click(screen.getByTitle('Queue message'))
337336

338-
expect(mocks.useSendPromptMutate).toHaveBeenCalledWith(
339-
expect.objectContaining({ prompt: 'queued message', queued: true }),
340-
expect.any(Object),
341-
)
337+
const [callArgs] = mocks.useSendPromptMutate.mock.calls[0] as [{ prompt: string }, unknown]
338+
expect(callArgs.prompt).toBe('queued message')
339+
expect('queued' in callArgs).toBe(false)
342340

343341
await waitFor(() => {
344342
expect(input).toHaveValue('')

‎frontend/src/components/message/PromptInput.tsx‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -302,7 +302,6 @@ export const PromptInput = memo(forwardRef<PromptInputHandle, PromptInputProps>(
302302
model: currentModel,
303303
agent: agentUsed,
304304
variant: currentVariant,
305-
queued: true
306305
},
307306
{
308307
onSuccess: () => clearSubmittedPrompt(submittedPrompt, submittedAttachedFiles, submittedImageAttachments)

‎frontend/src/components/session/SessionSendErrorBanner.test.tsx‎

Lines changed: 78 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ vi.mock('@/lib/toast', () => ({
77
showToast: { error: vi.fn() },
88
}))
99

10+
const defaultProps = {
11+
sessionId: 'test-session',
12+
isConnected: true,
13+
isReconnecting: false,
14+
}
15+
1016
describe('SessionSendErrorBanner', () => {
1117
beforeEach(() => {
1218
useSendErrorStore.setState({ errors: {} })
@@ -20,14 +26,14 @@ describe('SessionSendErrorBanner', () => {
2026
detail: 'Stack trace here',
2127
})
2228

23-
render(<SessionSendErrorBanner sessionId="test-session" />)
29+
render(<SessionSendErrorBanner {...defaultProps} />)
2430
expect(screen.getByText('Error')).toBeInTheDocument()
2531
expect(screen.getByText('Something failed')).toBeInTheDocument()
2632
expect(screen.getByText('Stack trace here')).toBeInTheDocument()
2733
})
2834

2935
it('does not render banner when no error exists', () => {
30-
render(<SessionSendErrorBanner sessionId="test-session" />)
36+
render(<SessionSendErrorBanner {...defaultProps} />)
3137
expect(screen.queryByRole('button')).not.toBeInTheDocument()
3238
})
3339

@@ -38,7 +44,7 @@ describe('SessionSendErrorBanner', () => {
3844
message: 'Something failed',
3945
})
4046

41-
render(<SessionSendErrorBanner sessionId={undefined} />)
47+
render(<SessionSendErrorBanner sessionId={undefined} isConnected={true} isReconnecting={false} />)
4248
expect(screen.queryByRole('button')).not.toBeInTheDocument()
4349
})
4450

@@ -49,7 +55,7 @@ describe('SessionSendErrorBanner', () => {
4955
message: 'Something failed',
5056
})
5157

52-
render(<SessionSendErrorBanner sessionId="test-session" />)
58+
render(<SessionSendErrorBanner {...defaultProps} />)
5359
expect(screen.getByText('Something failed')).toBeInTheDocument()
5460

5561
fireEvent.click(screen.getByRole('button'))
@@ -65,7 +71,74 @@ describe('SessionSendErrorBanner', () => {
6571
message: 'Something failed',
6672
})
6773

68-
render(<SessionSendErrorBanner sessionId="test-session" />)
74+
render(<SessionSendErrorBanner {...defaultProps} />)
6975
expect(showToast.error).not.toHaveBeenCalled()
7076
})
77+
78+
it('does not render a network-kind error while disconnected', () => {
79+
useSendErrorStore.getState().setError({
80+
sessionID: 'test-session',
81+
title: 'Connection Failed',
82+
message: 'Could not connect.',
83+
kind: 'network',
84+
})
85+
86+
render(<SessionSendErrorBanner sessionId="test-session" isConnected={false} isReconnecting={false} />)
87+
expect(screen.queryByText('Connection Failed')).not.toBeInTheDocument()
88+
})
89+
90+
it('does not render a network-kind error while reconnecting', () => {
91+
useSendErrorStore.getState().setError({
92+
sessionID: 'test-session',
93+
title: 'Connection Failed',
94+
message: 'Could not connect.',
95+
kind: 'network',
96+
})
97+
98+
render(<SessionSendErrorBanner sessionId="test-session" isConnected={true} isReconnecting={true} />)
99+
expect(screen.queryByText('Connection Failed')).not.toBeInTheDocument()
100+
})
101+
102+
it('renders a network-kind error when connected and stable', () => {
103+
useSendErrorStore.getState().setError({
104+
sessionID: 'test-session',
105+
title: 'Connection Failed',
106+
message: 'Could not connect.',
107+
kind: 'network',
108+
})
109+
110+
render(<SessionSendErrorBanner sessionId="test-session" isConnected={true} isReconnecting={false} />)
111+
expect(screen.getByText('Connection Failed')).toBeInTheDocument()
112+
})
113+
114+
it('renders a session-kind error even while disconnected', () => {
115+
useSendErrorStore.getState().setQueuedPrompt('test-session', 'queued message')
116+
useSendErrorStore.getState().failQueuedPrompt({
117+
sessionID: 'test-session',
118+
title: 'Session error',
119+
message: 'Server reported failure',
120+
})
121+
122+
render(<SessionSendErrorBanner sessionId="test-session" isConnected={false} isReconnecting={false} />)
123+
expect(screen.getByText('Session error')).toBeInTheDocument()
124+
})
125+
126+
it('renders a previously hidden network error after reconnecting', () => {
127+
useSendErrorStore.getState().setError({
128+
sessionID: 'test-session',
129+
title: 'Connection Failed',
130+
message: 'Could not connect.',
131+
kind: 'network',
132+
})
133+
134+
const { rerender } = render(
135+
<SessionSendErrorBanner sessionId="test-session" isConnected={false} isReconnecting={false} />,
136+
)
137+
expect(screen.queryByText('Connection Failed')).not.toBeInTheDocument()
138+
139+
rerender(
140+
<SessionSendErrorBanner sessionId="test-session" isConnected={true} isReconnecting={false} />,
141+
)
142+
expect(screen.getByText('Connection Failed')).toBeInTheDocument()
143+
})
71144
})

‎frontend/src/components/session/SessionSendErrorBanner.tsx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,18 @@ import { useSendErrorStore } from '@/stores/sendErrorStore'
33

44
interface SessionSendErrorBannerProps {
55
sessionId: string | undefined
6+
isConnected: boolean
7+
isReconnecting: boolean
68
}
79

8-
export function SessionSendErrorBanner({ sessionId }: SessionSendErrorBannerProps) {
10+
export function SessionSendErrorBanner({ sessionId, isConnected, isReconnecting }: SessionSendErrorBannerProps) {
911
const sendError = useSendErrorStore((s) => sessionId ? s.errors[sessionId] : null)
1012
const clearSendError = useSendErrorStore((s) => s.clearError)
1113

1214
if (!sendError || !sessionId) return null
1315

16+
if (sendError.kind === 'network' && (!isConnected || isReconnecting)) return null
17+
1418
return (
1519
<ErrorBanner
1620
title={sendError.title}

‎frontend/src/hooks/useOpenCode.ts‎

Lines changed: 7 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import type { SessionPin } from "@opencode-manager/shared/schemas";
2323
type AssistantMessage = components["schemas"]["AssistantMessage"];
2424

2525
type SendPromptRequest = NonNullable<
26-
paths["/session/{sessionID}/message"]["post"]["requestBody"]
26+
paths["/session/{sessionID}/prompt_async"]["post"]["requestBody"]
2727
>["content"]["application/json"];
2828

2929
type SendCommandResponse = paths["/session/{sessionID}/command"]["post"]["responses"]["200"]["content"]["application/json"];
@@ -418,15 +418,13 @@ export const useSendPrompt = (opcodeUrl: string | null | undefined, directory?:
418418
model,
419419
agent,
420420
variant,
421-
queued,
422421
}: {
423422
sessionID: string;
424423
prompt?: string;
425424
parts?: ContentPart[];
426425
model?: string;
427426
agent?: string;
428427
variant?: string;
429-
queued?: boolean;
430428
}) => {
431429
if (!client) throw new Error("No client available");
432430

@@ -491,30 +489,14 @@ export const useSendPrompt = (opcodeUrl: string | null | undefined, directory?:
491489
requestData.variant = variant;
492490
}
493491

494-
if (queued) {
495-
useSendErrorStore.getState().setQueuedPrompt(sessionID, getPromptText(prompt, parts));
496-
497-
try {
498-
await client.sendPromptAsync(sessionID, requestData);
499-
} catch (error) {
500-
useSendErrorStore.getState().clearQueuedPrompt(sessionID);
501-
throw error;
502-
}
503-
504-
return { queued: true };
505-
}
506-
507-
const response = await client.sendPrompt(sessionID, requestData);
508-
509-
return { response, queued: false };
492+
useSendErrorStore.getState().setQueuedPrompt(sessionID, getPromptText(prompt, parts));
493+
await client.sendPromptAsync(sessionID, requestData);
510494
},
511495
onError: (error, variables) => {
512-
const { sessionID, queued, prompt, parts } = variables;
496+
const { sessionID, prompt, parts } = variables;
513497
const queryKey = messagesQueryKey(opcodeUrl, sessionID, directory);
514498

515-
if (queued) {
516-
useSendErrorStore.getState().clearQueuedPrompt(sessionID);
517-
}
499+
useSendErrorStore.getState().clearQueuedPrompt(sessionID);
518500

519501
queryClient.setQueryData<MessageWithParts[]>(
520502
queryKey,
@@ -538,33 +520,12 @@ export const useSendPrompt = (opcodeUrl: string | null | undefined, directory?:
538520
kind: 'network',
539521
});
540522
},
541-
onSuccess: async (data, variables) => {
523+
onSuccess: async (_data, variables) => {
542524
const { sessionID } = variables;
543-
const { response } = data;
544525
const queryKey = messagesQueryKey(opcodeUrl, sessionID, directory);
545526

546527
useSendErrorStore.getState().clearError(sessionID);
547-
548-
if (data.queued || !response) {
549-
queryClient.invalidateQueries({ queryKey });
550-
return;
551-
}
552-
553-
queryClient.setQueryData<MessageWithParts[]>(
554-
queryKey,
555-
(old) => {
556-
if (!old) return old;
557-
558-
const existingIdx = old.findIndex(m => m.info.id === response.info.id);
559-
if (existingIdx >= 0) {
560-
const updated = [...old];
561-
updated[existingIdx] = { info: response.info, parts: response.parts };
562-
return updated;
563-
}
564-
565-
return [...old, { info: response.info, parts: response.parts }];
566-
},
567-
);
528+
await queryClient.invalidateQueries({ queryKey });
568529
},
569530
});
570531
};

‎frontend/src/hooks/useRemoveMessage.ts‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -116,28 +116,28 @@ export function useRefreshMessage({ opcodeUrl, sessionId, directory }: UseRefres
116116
(old) => [...(old || []), optimisticMessageWithParts]
117117
)
118118

119-
interface SendPromptRequest {
119+
interface RefreshPromptRequest {
120120
parts: Array<{ type: 'text'; text: string }>
121121
model?: { providerID: string; modelID: string }
122122
agent?: string
123123
}
124-
125-
const requestData: SendPromptRequest = {
124+
125+
const requestData: RefreshPromptRequest = {
126126
parts: [{ type: 'text', text: userMessageContent }]
127127
}
128-
128+
129129
if (model) {
130130
const [providerID, modelID] = model.split('/')
131131
if (providerID && modelID) {
132132
requestData.model = { providerID, modelID }
133133
}
134134
}
135-
135+
136136
if (agent) {
137137
requestData.agent = agent
138138
}
139-
140-
await client.sendPrompt(sessionId, requestData)
139+
140+
await client.sendPromptAsync(sessionId, requestData)
141141

142142
return { optimisticUserID, userMessageContent }
143143
},

0 commit comments

Comments
 (0)