PR #3631
Sections
Review

fix: recover delayed session entry

main ← feature/investigate-slow-cha-7bd 47 files +1240 −310 PR #3631 ↗

Delayed session entry now recovers with bounded retries for subscription, history, and status, and shows clear loading and retry feedback instead of an empty chat.

Why this change

Opening an existing task can delay subscription and status responses beyond the 5 second deadline. The client discards the late responses, history never loads, and the UI shows an empty invitation or a generic startup banner.

What it does

Architecture, end to end

The client gates history on subscription readiness. Each entry request has a bounded retry. History and status drive separate feedback in the chat region.

flowchart LR
  UI[Chat panel] --> Client[WebSocketClient]
  Client --> Sub[session.subscribe readiness]
  Sub --> Hist[message.list fetch]
  Client --> Status[task.session.status]
  Hist --> State[MessageHistoryStatus]
  Status --> Recovery[SessionRecoveryFailure]
  State --> Feedback[SessionHistoryFeedback]
  Recovery --> Banner[Status unavailable notice]
  Feedback --> List[MessageListStatus]

Key code changes

Drag to pan. Use the + and − buttons to zoom. Click a node to open the full code. The arrows show how the parts interact.

drag to pan · +/− to zoom · click a node for details

Transport
Session hooks
Chat UI
class WebSocketRequestTimeoutError extends Error
Click for details →

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 retryapps/web/lib/ws/client.ts ↗
startSessionSubscription(sessionId: string, readiness: SessionSubscriptionReadiness)
Click for details →

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);
    }
  );
}
fetchAndStoreMessages(sessionId: string, store: StoreApi<AppState>): Promise<Message[]>
Click for details →

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);
  }
}
requestSessionStatusWithRetry(params): Promise<SessionStatus | null>
Click for details →

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"),
  };
}
function SessionHistoryFeedback(props: { status: MessageHistoryStatus }): JSX.Element
Click for details →

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>}
function useSessionData(resolvedSessionId: string | null): SessionData
Click for details →

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} />
Read the changes as a list

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} />

Data and storage

History state is per session and generation. Only a successful snapshot marks history as initialized.

FieldTypeNotes
historyStatusenumloading, retrying, ready, or unavailable
historyErrorunknownlast timeout or request error for Details
historyInitializedbooleantrue only after a successful snapshot, even if empty
retryHistory() => voidstarts a new bounded episode; disabled while active

Risk

5 / 10 Medium
1 low5 medium10 high

Why this score

  • Retries are bounded to one extra attempt per entry request, which limits extra traffic.
  • History and status share readiness but keep separate budgets, so a bug could still delay feedback.
  • UI touches the main transcript path; a regression would affect every chat open.

Trade-offs and review notes

Where to look first

  1. Check that only WebSocketRequestTimeoutError triggers retry and that backend rejections stay permanent.
  2. Confirm history waits for readiness and that cached messages stay visible while retrying.
  3. Verify the empty invitation appears only when historyStatus is ready and that status-only failures do not hide the transcript.
  4. Review generation guards for session switch and disconnect so stale responses cannot overwrite newer state.