lewtun HF Staff OpenAI Codex commited on
Commit
65c14d1
·
unverified ·
1 Parent(s): 2fa5661

Recover chat sends after fetch failures (#308)

Browse files

* Recover chat sends after fetch failures

Co-authored-by: OpenAI Codex <codex@openai.com>

* Address chat send recovery review

Co-authored-by: OpenAI Codex <codex@openai.com>

* Avoid redundant recovery session fetch

Co-authored-by: OpenAI Codex <codex@openai.com>

---------

Co-authored-by: OpenAI Codex <codex@openai.com>

frontend/src/hooks/useAgentChat.ts CHANGED
@@ -32,6 +32,13 @@ interface UseAgentChatOptions {
32
  onSessionDead?: (sessionId: string) => void;
33
  }
34
 
 
 
 
 
 
 
 
35
  export function useAgentChat({ sessionId, isActive, isProcessing = false, onReady, onError, onSessionDead }: UseAgentChatOptions) {
36
  const callbacksRef = useRef({ onReady, onError, onSessionDead });
37
  callbacksRef.current = { onReady, onError, onSessionDead };
@@ -348,6 +355,97 @@ export function useAgentChat({ sessionId, isActive, isProcessing = false, onRead
348
  useUsageStore.getState().applyUsageEvent(sessionId, eventType, data);
349
  },
350
  onInterrupted: () => { /* no-op — handled by stop() caller */ },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
351
  }),
352
  // eslint-disable-next-line react-hooks/exhaustive-deps
353
  [sessionId, setProcessingState],
 
32
  onSessionDead?: (sessionId: string) => void;
33
  }
34
 
35
+ function textFromUIMessage(message: UIMessage): string {
36
+ return message.parts
37
+ .filter((p): p is Extract<typeof p, { type: 'text' }> => p.type === 'text')
38
+ .map(p => p.text)
39
+ .join('');
40
+ }
41
+
42
  export function useAgentChat({ sessionId, isActive, isProcessing = false, onReady, onError, onSessionDead }: UseAgentChatOptions) {
43
  const callbacksRef = useRef({ onReady, onError, onSessionDead });
44
  callbacksRef.current = { onReady, onError, onSessionDead };
 
355
  useUsageStore.getState().applyUsageEvent(sessionId, eventType, data);
356
  },
357
  onInterrupted: () => { /* no-op — handled by stop() caller */ },
358
+ onRecoverMessages: async ({
359
+ submittedText,
360
+ currentMessageCount,
361
+ currentUserMessageCount,
362
+ sessionInfo,
363
+ }) => {
364
+ try {
365
+ let msgsRes: Response;
366
+ let info = sessionInfo;
367
+
368
+ if (sessionInfo) {
369
+ msgsRes = await apiFetch(`/api/session/${sessionId}/messages`);
370
+ } else {
371
+ const [fetchedMsgsRes, infoRes] = await Promise.all([
372
+ apiFetch(`/api/session/${sessionId}/messages`),
373
+ apiFetch(`/api/session/${sessionId}`),
374
+ ]);
375
+ msgsRes = fetchedMsgsRes;
376
+
377
+ if (infoRes.status === 404 && msgsRes.status === 404) {
378
+ callbacksRef.current.onSessionDead?.(sessionId);
379
+ return false;
380
+ }
381
+ if (infoRes.ok) {
382
+ info = await infoRes.json();
383
+ }
384
+ }
385
+
386
+ if (sessionInfo && msgsRes.status === 404) {
387
+ callbacksRef.current.onSessionDead?.(sessionId);
388
+ return false;
389
+ }
390
+ if (!msgsRes.ok) return false;
391
+
392
+ const data = await msgsRes.json();
393
+ if (!Array.isArray(data) || data.length === 0) return false;
394
+ saveBackendMessages(sessionId, data);
395
+
396
+ let pendingIds: Set<string> | undefined;
397
+ let backendIsProcessing = false;
398
+ if (info) {
399
+ backendIsProcessing = !!info.is_processing;
400
+ if (info.pending_approval && Array.isArray(info.pending_approval)) {
401
+ pendingIds = new Set(
402
+ info.pending_approval.map((t: { tool_call_id: string }) => t.tool_call_id)
403
+ );
404
+ if (pendingIds.size > 0) setNeedsAttention(sessionId, true);
405
+ }
406
+ if (info.auto_approval) {
407
+ updateSessionYolo(sessionId, info.auto_approval);
408
+ }
409
+ }
410
+
411
+ const uiMsgs = llmMessagesToUIMessages(
412
+ data,
413
+ pendingIds,
414
+ chatActionsRef.current.messages,
415
+ );
416
+ const backendAdvanced = uiMsgs.length > currentMessageCount;
417
+ let submittedTurnAccepted = false;
418
+ if (submittedText) {
419
+ const userMessages = uiMsgs.filter((m) => m.role === 'user');
420
+ const lastUser = userMessages[userMessages.length - 1];
421
+ submittedTurnAccepted = (
422
+ userMessages.length >= currentUserMessageCount &&
423
+ !!lastUser &&
424
+ textFromUIMessage(lastUser).trim() === submittedText.trim()
425
+ );
426
+ }
427
+
428
+ const setMsgs = chatActionsRef.current.setMessages;
429
+ if (setMsgs && uiMsgs.length >= currentMessageCount) {
430
+ setMsgs(uiMsgs);
431
+ saveMessages(sessionId, uiMsgs);
432
+ }
433
+
434
+ if (backendIsProcessing) {
435
+ setProcessingState(true, { activityStatus: { type: 'thinking' } });
436
+ return false;
437
+ }
438
+ if (pendingIds && pendingIds.size > 0) {
439
+ setProcessingState(false, { activityStatus: { type: 'waiting-approval' } });
440
+ } else {
441
+ setProcessingState(false);
442
+ }
443
+
444
+ return backendAdvanced || submittedTurnAccepted;
445
+ } catch {
446
+ return false;
447
+ }
448
+ },
449
  }),
450
  // eslint-disable-next-line react-hooks/exhaustive-deps
451
  [sessionId, setProcessingState],
frontend/src/lib/sse-chat-transport.ts CHANGED
@@ -41,6 +41,25 @@ export interface SideChannelCallbacks {
41
  onToolRunning: (toolName: string, description?: string) => void;
42
  onUsageEvent: (eventType: 'llm_call' | 'hf_job_complete', data: Record<string, unknown>) => void;
43
  onInterrupted: () => void;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  }
45
 
46
  // ---------------------------------------------------------------------------
@@ -70,6 +89,34 @@ async function readErrorResponse(response: Response): Promise<string> {
70
  }
71
  }
72
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  /** Parse an SSE text stream into AgentEvent objects. */
74
  function createSSEParserStream(sessionId: string): TransformStream<string, AgentEvent> {
75
  let buffer = '';
@@ -130,6 +177,15 @@ function createSSEParserStream(sessionId: string): TransformStream<string, Agent
130
  });
131
  }
132
 
 
 
 
 
 
 
 
 
 
133
  /** Transform AgentEvent objects into UIMessageChunk objects for the Vercel AI SDK. */
134
  function createEventToChunkStream(sideChannel: SideChannelCallbacks): TransformStream<AgentEvent, UIMessageChunk> {
135
  let textPartId: string | null = null;
@@ -369,6 +425,69 @@ export class SSEChatTransport implements ChatTransport<UIMessage> {
369
  // Nothing to clean up — no persistent connections
370
  }
371
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
372
  // -- ChatTransport interface ---------------------------------------------
373
 
374
  async sendMessages(
@@ -391,6 +510,7 @@ export class SSEChatTransport implements ChatTransport<UIMessage> {
391
  ) || [];
392
 
393
  let body: Record<string, unknown>;
 
394
  if (approvedParts.length > 0) {
395
  // Approval continuation — extract approval decisions
396
  const approvals = approvedParts.map((p) => {
@@ -415,19 +535,33 @@ export class SSEChatTransport implements ChatTransport<UIMessage> {
415
  .map(p => p.text)
416
  .join('')
417
  : '';
 
418
  body = { text };
419
  }
420
 
421
  // POST to SSE endpoint
422
- const response = await apiFetch(`/api/chat/${sessionId}`, {
423
- method: 'POST',
424
- body: JSON.stringify(body),
425
- signal: options.abortSignal,
426
- headers: {
427
- 'Content-Type': 'application/json',
428
- 'Accept': 'text/event-stream',
429
- },
430
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
431
 
432
  if (response.status === 404) {
433
  // Backend lost this session (e.g. Space restart). Signal the UI so
@@ -460,20 +594,7 @@ export class SSEChatTransport implements ChatTransport<UIMessage> {
460
  const info = await infoRes.json();
461
  if (!info.is_processing) return null;
462
 
463
- // Session is mid-turn — subscribe to its event broadcast.
464
- const lastSeq = localStorage.getItem(lastEventKey(this.sessionId));
465
- const qs = lastSeq ? `?after=${encodeURIComponent(lastSeq)}` : '';
466
- const response = await apiFetch(`/api/events/${this.sessionId}${qs}`, {
467
- headers: { 'Accept': 'text/event-stream' },
468
- });
469
- if (!response.ok || !response.body) return null;
470
-
471
- this.sideChannel.onProcessing();
472
-
473
- return response.body
474
- .pipeThrough(new TextDecoderStream())
475
- .pipeThrough(createSSEParserStream(this.sessionId))
476
- .pipeThrough(createEventToChunkStream(this.sideChannel));
477
  } catch {
478
  return null;
479
  }
 
41
  onToolRunning: (toolName: string, description?: string) => void;
42
  onUsageEvent: (eventType: 'llm_call' | 'hf_job_complete', data: Record<string, unknown>) => void;
43
  onInterrupted: () => void;
44
+ onRecoverMessages: (context: MessageRecoveryContext) => Promise<boolean>;
45
+ }
46
+
47
+ export interface MessageRecoveryContext {
48
+ submittedText?: string;
49
+ currentMessageCount: number;
50
+ currentUserMessageCount: number;
51
+ sessionInfo?: RecoverySessionInfo;
52
+ }
53
+
54
+ export interface RecoverySessionInfo {
55
+ is_processing?: boolean;
56
+ pending_approval?: Array<{ tool_call_id: string }> | null;
57
+ auto_approval?: {
58
+ enabled: boolean;
59
+ cost_cap_usd?: number | null;
60
+ estimated_spend_usd?: number;
61
+ remaining_usd?: number | null;
62
+ } | null;
63
  }
64
 
65
  // ---------------------------------------------------------------------------
 
89
  }
90
  }
91
 
92
+ function isAbortError(error: unknown, signal?: AbortSignal): boolean {
93
+ if (signal?.aborted) return true;
94
+ if (!(error instanceof Error)) return false;
95
+ return error.name === 'AbortError';
96
+ }
97
+
98
+ function isRecoverableFetchError(error: unknown): boolean {
99
+ if (!(error instanceof Error)) return false;
100
+
101
+ const name = error.name.toLowerCase();
102
+ const message = error.message.toLowerCase();
103
+ const networkFailureMessages = [
104
+ 'load failed',
105
+ 'failed to fetch',
106
+ 'fetch failed',
107
+ 'networkerror',
108
+ 'network error',
109
+ 'network request failed',
110
+ 'network connection was lost',
111
+ 'internet connection appears to be offline',
112
+ ];
113
+
114
+ return (
115
+ name === 'networkerror' ||
116
+ (name === 'typeerror' && networkFailureMessages.some((pattern) => message.includes(pattern)))
117
+ );
118
+ }
119
+
120
  /** Parse an SSE text stream into AgentEvent objects. */
121
  function createSSEParserStream(sessionId: string): TransformStream<string, AgentEvent> {
122
  let buffer = '';
 
177
  });
178
  }
179
 
180
+ function createRecoveredFinishedStream(): ReadableStream<UIMessageChunk> {
181
+ return new ReadableStream<UIMessageChunk>({
182
+ start(controller) {
183
+ controller.enqueue({ type: 'finish', finishReason: 'stop' });
184
+ controller.close();
185
+ },
186
+ });
187
+ }
188
+
189
  /** Transform AgentEvent objects into UIMessageChunk objects for the Vercel AI SDK. */
190
  function createEventToChunkStream(sideChannel: SideChannelCallbacks): TransformStream<AgentEvent, UIMessageChunk> {
191
  let textPartId: string | null = null;
 
425
  // Nothing to clean up — no persistent connections
426
  }
427
 
428
+ private async connectToEventStream(): Promise<ReadableStream<UIMessageChunk> | null> {
429
+ const lastSeq = localStorage.getItem(lastEventKey(this.sessionId));
430
+ const qs = lastSeq ? `?after=${encodeURIComponent(lastSeq)}` : '';
431
+ const response = await apiFetch(`/api/events/${this.sessionId}${qs}`, {
432
+ headers: { 'Accept': 'text/event-stream' },
433
+ });
434
+ if (!response.ok || !response.body) return null;
435
+
436
+ this.sideChannel.onProcessing();
437
+
438
+ return response.body
439
+ .pipeThrough(new TextDecoderStream())
440
+ .pipeThrough(createSSEParserStream(this.sessionId))
441
+ .pipeThrough(createEventToChunkStream(this.sideChannel));
442
+ }
443
+
444
+ private async recoverFailedSend(
445
+ context: MessageRecoveryContext,
446
+ ): Promise<ReadableStream<UIMessageChunk>> {
447
+ let infoRes: Response;
448
+ try {
449
+ infoRes = await apiFetch(`/api/session/${this.sessionId}`);
450
+ } catch {
451
+ throw new Error(
452
+ 'Connection to the Space was interrupted before the message was accepted. Please retry.',
453
+ );
454
+ }
455
+
456
+ if (infoRes.status === 404) {
457
+ this.sideChannel.onSessionDead(this.sessionId);
458
+ throw new Error('Session not found or inactive');
459
+ }
460
+ if (!infoRes.ok) {
461
+ throw new Error(
462
+ 'Connection to the Space was interrupted before the message was accepted. Please retry.',
463
+ );
464
+ }
465
+
466
+ const info = await infoRes.json() as RecoverySessionInfo;
467
+ if (info.is_processing) {
468
+ try {
469
+ const stream = await this.connectToEventStream();
470
+ if (stream) return stream;
471
+ } catch {
472
+ // Fall through to message hydration; the turn may have completed
473
+ // between the status probe and the event-stream reconnect.
474
+ }
475
+ }
476
+
477
+ const recovered = await this.sideChannel.onRecoverMessages({
478
+ ...context,
479
+ sessionInfo: info.is_processing ? undefined : info,
480
+ });
481
+ if (recovered) {
482
+ this.sideChannel.onProcessingDone();
483
+ return createRecoveredFinishedStream();
484
+ }
485
+
486
+ throw new Error(
487
+ 'Connection to the Space was interrupted before the message was accepted. Please retry.',
488
+ );
489
+ }
490
+
491
  // -- ChatTransport interface ---------------------------------------------
492
 
493
  async sendMessages(
 
510
  ) || [];
511
 
512
  let body: Record<string, unknown>;
513
+ let submittedText: string | undefined;
514
  if (approvedParts.length > 0) {
515
  // Approval continuation — extract approval decisions
516
  const approvals = approvedParts.map((p) => {
 
535
  .map(p => p.text)
536
  .join('')
537
  : '';
538
+ submittedText = text;
539
  body = { text };
540
  }
541
 
542
  // POST to SSE endpoint
543
+ let response: Response;
544
+ try {
545
+ response = await apiFetch(`/api/chat/${sessionId}`, {
546
+ method: 'POST',
547
+ body: JSON.stringify(body),
548
+ signal: options.abortSignal,
549
+ headers: {
550
+ 'Content-Type': 'application/json',
551
+ 'Accept': 'text/event-stream',
552
+ },
553
+ });
554
+ } catch (error) {
555
+ if (isAbortError(error, options.abortSignal) || !isRecoverableFetchError(error)) {
556
+ throw error;
557
+ }
558
+ logger.warn('Chat POST failed; attempting session recovery:', error);
559
+ return this.recoverFailedSend({
560
+ submittedText,
561
+ currentMessageCount: options.messages.length,
562
+ currentUserMessageCount: options.messages.filter(m => m.role === 'user').length,
563
+ });
564
+ }
565
 
566
  if (response.status === 404) {
567
  // Backend lost this session (e.g. Space restart). Signal the UI so
 
594
  const info = await infoRes.json();
595
  if (!info.is_processing) return null;
596
 
597
+ return this.connectToEventStream();
 
 
 
 
 
 
 
 
 
 
 
 
 
598
  } catch {
599
  return null;
600
  }