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 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 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 |
-
|
| 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 |
}
|