Typed timeout error
apps/web/lib/ws/request-error.ts ↗Classifies timeout so only timeout triggers retry; backend rejections stay permanent.
Timeout error
export class WebSocketRequestTimeoutError extends Error {
readonly action: string;
readonly kind = "timeout" as const;
constructor(action: string) {
super(`WebSocket request timed out: ${action}`);
this.name = "WebSocketRequestTimeoutError";
this.action = action;
}
}
export function isWebSocketRequestTimeoutError(error: unknown): error is WebSocketRequestTimeoutError {
return error instanceof WebSocketRequestTimeoutError;
}
Bounded subscription retry
apps/web/lib/ws/client.ts ↗Retries timed-out session.subscribe once after 1 second within one shared readiness promise.
Constants
export const SESSION_ENTRY_REQUEST_TIMEOUT_MS = 10000;
export const SESSION_ENTRY_RETRY_DELAY_MS = 1000;
const MAX_SESSION_SUBSCRIPTION_ATTEMPTS = 2;
Retry inside readiness
private startSessionSubscription(sessionId: string, readiness: SessionSubscriptionReadiness) {
if (this.sessionSubscriptionReadiness.get(sessionId) !== readiness) return;
if (readiness.requestStarted) return;
readiness.requestStarted = true;
readiness.attempt += 1;
void this.request("session.subscribe", { session_id: sessionId }, SESSION_ENTRY_REQUEST_TIMEOUT_MS).then(
() => {
if (this.sessionSubscriptionReadiness.get(sessionId) !== readiness) return;
readiness.settled = true;
readiness.resolve();
},
(error: unknown) => {
if (this.sessionSubscriptionReadiness.get(sessionId) !== readiness) return;
readiness.requestStarted = false;
if (isWebSocketRequestTimeoutError(error) && readiness.attempt < MAX_SESSION_SUBSCRIPTION_ATTEMPTS && this.status === "connected" && this.socket) {
readiness.retryTimer = setTimeout(() => {
readiness.retryTimer = null;
this.startSessionSubscription(sessionId, readiness);
}, SESSION_ENTRY_RETRY_DELAY_MS);
return;
}
this.sessionSubscriptionReadiness.delete(sessionId);
readiness.settled = true;
readiness.reject(error);
}
);
}
History fetch with shared state
apps/web/hooks/domains/session/use-session-messages.ts ↗Waits for subscription readiness, retries message.list once on timeout, and drives loading and retrying status.
Gate on readiness
const readiness = client.getSessionSubscriptionReadiness(sessionId);
await readiness;
if (isActive && !isActive()) return [];
void ensureSessionTurnsLoaded(sessionId, store, { readiness });
const request = requestSessionMessages(client, sessionId, readiness, cachedAtRequest);
const response = await request.promise;
Bounded history retry
async function fetchAndStoreMessages(sessionId, store, isActive, hydrationRef, hydrationKey, onRetry) {
for (let attempt = 0; attempt < MAX_HISTORY_FETCH_ATTEMPTS; attempt += 1) {
try {
return await fetchAndStoreMessagesAttempt(sessionId, store, isActive, hydrationRef, hydrationKey);
} catch (error) {
const retryable = isWebSocketRequestTimeoutError(error) && error.action === "message.list" && attempt + 1 < MAX_HISTORY_FETCH_ATTEMPTS;
if (!retryable || !(await waitForHistoryRetry(isActive))) throw error;
if (!isActive || isActive()) onRetry?.();
}
}
return [];
}
History status in doFetchMessages
export async function doFetchMessages({ taskSessionId, store, setIsLoading, setHistoryStatus, setHistoryError, fetchAndStoreMessages, onRetry }) {
beginSessionFetch(taskSessionId);
setIsLoading(true);
if (lastFetchedSessionIdRef.current !== taskSessionId) {
setHistoryStatus("loading");
setHistoryError(null);
}
try {
await fetchAndStoreMessages(taskSessionId, store, isActive, hydrationRef, hydrationKey, () => setHistoryStatus("retrying"));
setHistoryStatus("ready");
} catch (error) {
setHistoryStatus("unavailable");
setHistoryError(error);
} finally {
if (endSessionFetch(taskSessionId)) setIsLoading(false);
}
}
Status retry without mutation retry
apps/web/hooks/domains/session/use-session-resumption.ts ↗Retries task.session.status once on timeout and keeps resume and launch outside the retry loop.
Status retry
async function requestSessionStatusWithRetry({ client, taskId, sessionId, canContinue }) {
for (let attempt = 0; attempt < MAX_SESSION_STATUS_ATTEMPTS; attempt += 1) {
if (!canContinue()) return null;
try {
return await client.request<SessionStatus>("task.session.status", { task_id: taskId, session_id: sessionId }, SESSION_ENTRY_REQUEST_TIMEOUT_MS);
} catch (error) {
const canRetry = isWebSocketRequestTimeoutError(error) && attempt + 1 < MAX_SESSION_STATUS_ATTEMPTS;
if (!canRetry || !(await waitForSessionStatusRetry(canContinue))) throw error;
}
}
return null;
}
Failure kind
function sessionStatusFailure(error: unknown) {
return {
outcome: "status_unavailable",
kind: isWebSocketRequestTimeoutError(error) ? "timeout" : "request",
statusError: error instanceof Error && error.message ? error.message : t("common:unknownError"),
};
}
Chat feedback and empty-state gate
apps/web/components/task/chat/session-entry-feedback.tsx ↗Shows loading, retrying, or unavailable with Retry and Details inside the chat region and hides the empty invitation until ready.
Feedback component
export function SessionHistoryFeedback({ status, error, onRetry }: { status: MessageHistoryStatus; error: unknown; onRetry: () => void }) {
if (status === "ready") return null;
if (status === "loading" || status === "retrying") {
return (
<div role="status" aria-live="polite" data-testid={status === "retrying" ? "session-history-retrying" : "session-history-loading"}>
<GridSpinner className="text-primary" />
<span>{status === "retrying" ? t("task:sessionHistoryRetrying") : t("task:loadingConversation")}</span>
</div>
);
}
return (
<div role="status" aria-live="polite" data-testid="session-history-unavailable">
<IconInfoCircle aria-hidden="true" />
<div>{t("task:sessionHistoryUnavailable")}</div>
<Button data-testid="session-history-retry" onClick={onRetry}>{t("task:retry")}</Button>
<details data-testid="session-history-details"><summary>{t("task:details")}</summary><p>{errorDetail(error, t("task:sessionHistoryUnknownError"))}</p></details>
</div>
);
}
Gate in MessageListStatus
function SessionHistoryStatus({ sessionId, sessionState, historyStatus, historyError, onRetryHistory }) {
if (!sessionId || sessionState === "CREATED" || historyStatus === "ready" || !onRetryHistory) return null;
return <SessionHistoryFeedback status={historyStatus} error={historyError} onRetry={onRetryHistory} />;
}
{showLoadingState && historyStatus === "ready" && <div data-testid="conversation-loading-state"><GridSpinner />{t("task:loadingConversation")}</div>}
{!messagesLoading && !isInitialLoading && messagesCount === 0 && historyStatus === "ready" && <div>{t("task:noMessagesYetStartTheConversation")}</div>}
Plumb history state through panel
apps/web/components/task/chat/use-chat-panel-state.ts ↗Exposes historyStatus, historyError, and retryHistory from useSessionMessages to the chat panel and message list.
Expose from hook
const { messages, isLoading: messagesLoading, historyRefreshPending, historyInitialized, hasMore: hasOlderMessages, historyStatus, historyError, retryHistory } = useSessionMessages(resolvedSessionId);
Pass to MessageList
<MessageList historyStatus={historyStatus} historyError={historyError} onRetryHistory={retryHistory} messagesLoading={messagesLoading} historyRefreshPending={historyRefreshPending} />