diff --git a/apps/web/components/task/chat/message-list-native.tsx b/apps/web/components/task/chat/message-list-native.tsx
index 30506104f4..55b932b77d 100644
--- a/apps/web/components/task/chat/message-list-native.tsx
+++ b/apps/web/components/task/chat/message-list-native.tsx
@@ -1,5 +1,7 @@
"use client";
+/* eslint-disable max-lines -- native transcript composition owns scrolling. */
+
import { useEffect, useMemo, useRef, memo, forwardRef, useImperativeHandle } from "react";
import { SessionPanelContent } from "@kandev/ui/pannel-session";
import type { Message, TaskSessionState } from "@/lib/types/http";
@@ -264,6 +266,9 @@ type NativeMessageListBodyProps = {
isLoadingMore: boolean;
isInitialLoading: boolean;
showLoadingState: boolean;
+ historyStatus: MessageListProps["historyStatus"];
+ historyError: MessageListProps["historyError"];
+ onRetryHistory: MessageListProps["onRetryHistory"];
retryLoadMore: () => void;
showRecovery: boolean;
sentinelRef: (node: HTMLDivElement | null) => void;
@@ -470,6 +475,9 @@ function NativeMessageListBody({
isLoadingMore,
isInitialLoading,
showLoadingState,
+ historyStatus,
+ historyError,
+ onRetryHistory,
retryLoadMore,
showRecovery,
sentinelRef,
@@ -495,6 +503,11 @@ function NativeMessageListBody({
messagesLoading={messagesLoading}
isInitialLoading={isInitialLoading}
messagesCount={messages.length}
+ sessionId={sessionId}
+ sessionState={sessionState}
+ historyStatus={historyStatus}
+ historyError={historyError}
+ onRetryHistory={onRetryHistory}
onLoadMore={retryLoadMore}
showRecovery={showRecovery}
/>
@@ -557,6 +570,9 @@ export const NativeMessageList = memo(
sessionId,
messagesLoading,
historyRefreshPending = false,
+ historyStatus = "ready",
+ historyError = null,
+ onRetryHistory,
isWorking,
sessionState,
worktreePath,
@@ -673,6 +689,9 @@ export const NativeMessageList = memo(
isLoadingMore={isLoadingMore}
isInitialLoading={isInitialLoading}
showLoadingState={showLoadingState}
+ historyStatus={historyStatus}
+ historyError={historyError}
+ onRetryHistory={onRetryHistory}
retryLoadMore={retryLoadMore}
showRecovery={showRecovery}
sentinelRef={sentinelRef}
diff --git a/apps/web/components/task/chat/message-list-shared.test.tsx b/apps/web/components/task/chat/message-list-shared.test.tsx
index 3fc4ddb76d..2e71fad32f 100644
--- a/apps/web/components/task/chat/message-list-shared.test.tsx
+++ b/apps/web/components/task/chat/message-list-shared.test.tsx
@@ -825,6 +825,43 @@ describe("MessageListStatus", () => {
expect(screen.queryByTestId("conversation-loading-state")).not.toBeNull();
expect(screen.queryByText("Loading conversation...")).not.toBeNull();
});
+
+ it("does not show the empty invitation before history is ready", () => {
+ render(
+ ,
+ );
+
+ expect(screen.queryByText("No messages yet. Start the conversation!")).toBeNull();
+ expect(screen.getByTestId("session-history-loading")).toBeTruthy();
+ });
+
+ it("shows the empty invitation after a successful empty history snapshot", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText("No messages yet. Start the conversation!")).toBeTruthy();
+ });
});
describe("UnreadDivider", () => {
diff --git a/apps/web/components/task/chat/message-list-shared.tsx b/apps/web/components/task/chat/message-list-shared.tsx
index 8b18e206af..c45807962a 100644
--- a/apps/web/components/task/chat/message-list-shared.tsx
+++ b/apps/web/components/task/chat/message-list-shared.tsx
@@ -20,6 +20,8 @@ import {
import { isLaunchErrorSurfaceMessage } from "./types";
import { useTranslation } from "react-i18next";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
+import type { MessageHistoryStatus } from "@/hooks/domains/session/use-message-fetch-state";
+import { SessionHistoryFeedback } from "./session-entry-feedback";
export type MessageListProps = {
items: RenderItem[];
@@ -33,6 +35,9 @@ export type MessageListProps = {
messagesLoading: boolean;
/** Latest-session history is still reconciling while cached rows remain visible. */
historyRefreshPending?: boolean;
+ historyStatus?: MessageHistoryStatus;
+ historyError?: unknown;
+ onRetryHistory?: () => void;
isWorking: boolean;
sessionState?: TaskSessionState;
worktreePath?: string;
@@ -470,9 +475,31 @@ export function UnreadDivider() {
);
}
+function SessionHistoryStatus({
+ sessionId,
+ sessionState,
+ historyStatus,
+ historyError,
+ onRetryHistory,
+}: {
+ sessionId: string | null;
+ sessionState?: TaskSessionState;
+ historyStatus: MessageHistoryStatus;
+ historyError: unknown;
+ onRetryHistory?: () => void;
+}) {
+ if (!sessionId || sessionState === "CREATED" || historyStatus === "ready" || !onRetryHistory) {
+ return null;
+ }
+ return (
+
+ );
+}
+
/** Transcript status footer: the loading-older indicator, an explicit
* load-older button, the conversation loading spinner, and the empty-state
* message when there are no messages. */
+// eslint-disable-next-line complexity -- transcript status owns independent loading, pagination, and recovery states.
export function MessageListStatus({
isLoadingMore,
hasMore,
@@ -482,6 +509,11 @@ export function MessageListStatus({
messagesCount,
onLoadMore,
showRecovery = false,
+ sessionId = null,
+ sessionState,
+ historyStatus = "ready",
+ historyError = null,
+ onRetryHistory,
}: {
isLoadingMore: boolean;
hasMore: boolean;
@@ -497,6 +529,11 @@ export function MessageListStatus({
onLoadMore?: () => void;
/** Shows the explicit control only after a recoverable pagination failure. */
showRecovery?: boolean;
+ sessionId?: string | null;
+ sessionState?: TaskSessionState;
+ historyStatus?: MessageHistoryStatus;
+ historyError?: unknown;
+ onRetryHistory?: () => void;
}) {
const { t } = useTranslation();
const { isFinePointer } = useResponsiveBreakpoint();
@@ -521,7 +558,14 @@ export function MessageListStatus({
)}
- {showLoadingState && (
+
+ {showLoadingState && historyStatus === "ready" && (
{t("task:loadingConversation")}
)}
- {!messagesLoading && !isInitialLoading && messagesCount === 0 && (
-
- {t("task:noMessagesYetStartTheConversation")}
-
- )}
+ {!messagesLoading &&
+ !isInitialLoading &&
+ messagesCount === 0 &&
+ historyStatus === "ready" && (
+
+ {t("task:noMessagesYetStartTheConversation")}
+
+ )}
>
);
}
diff --git a/apps/web/components/task/chat/session-entry-feedback.test.tsx b/apps/web/components/task/chat/session-entry-feedback.test.tsx
new file mode 100644
index 0000000000..72a64c1f4b
--- /dev/null
+++ b/apps/web/components/task/chat/session-entry-feedback.test.tsx
@@ -0,0 +1,55 @@
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+const breakpoint = vi.hoisted(() => ({ isFinePointer: true }));
+
+vi.mock("@/hooks/use-responsive-breakpoint", () => ({
+ useResponsiveBreakpoint: () => breakpoint,
+}));
+
+import { SessionHistoryFeedback } from "./session-entry-feedback";
+
+afterEach(() => {
+ cleanup();
+ breakpoint.isFinePointer = true;
+});
+
+describe("SessionHistoryFeedback", () => {
+ it("announces loading without showing the empty conversation invitation", () => {
+ render();
+
+ expect(screen.getByTestId("session-history-loading").getAttribute("role")).toBe("status");
+ expect(
+ screen.getByTestId("session-history-loading").querySelector('[aria-hidden="true"]'),
+ ).toBeTruthy();
+ expect(screen.getByText("Loading conversation...")).toBeTruthy();
+ expect(screen.queryByText("No messages yet. Start the conversation!")).toBeNull();
+ });
+
+ it("shows a retry and collapsed technical detail after history fails", () => {
+ const onRetry = vi.fn();
+ render(
+ ,
+ );
+
+ fireEvent.click(screen.getByTestId("session-history-retry"));
+ expect(onRetry).toHaveBeenCalledTimes(1);
+ const details = screen.getByTestId("session-history-details") as HTMLDetailsElement;
+ expect(details.open).toBe(false);
+
+ fireEvent.click(screen.getByTestId("session-history-details-summary"));
+ expect(details.open).toBe(true);
+ expect(screen.getByText("WebSocket request timed out: message.list")).toBeTruthy();
+ });
+
+ it("uses a touch-sized retry control for coarse pointers", () => {
+ breakpoint.isFinePointer = false;
+ render();
+
+ expect(screen.getByTestId("session-history-retry").className).toContain("min-h-11");
+ });
+});
diff --git a/apps/web/components/task/chat/session-entry-feedback.tsx b/apps/web/components/task/chat/session-entry-feedback.tsx
new file mode 100644
index 0000000000..2783580437
--- /dev/null
+++ b/apps/web/components/task/chat/session-entry-feedback.tsx
@@ -0,0 +1,99 @@
+"use client";
+
+import { IconInfoCircle, IconRefresh } from "@tabler/icons-react";
+import { Button } from "@kandev/ui/button";
+import { useTranslation } from "react-i18next";
+import { GridSpinner } from "@/components/grid-spinner";
+import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
+import type { MessageHistoryStatus } from "@/hooks/domains/session/use-message-fetch-state";
+import { cn } from "@/lib/utils";
+
+function errorDetail(error: unknown, fallback: string): string {
+ if (error instanceof Error && error.message) return error.message;
+ if (typeof error === "string" && error) return error;
+ return fallback;
+}
+
+export function SessionHistoryFeedback({
+ status,
+ error,
+ onRetry,
+}: {
+ status: MessageHistoryStatus;
+ error: unknown;
+ onRetry: () => void;
+}) {
+ const { t } = useTranslation();
+ const { isFinePointer } = useResponsiveBreakpoint();
+
+ if (status === "ready") return null;
+
+ if (status === "loading" || status === "retrying") {
+ return (
+
+
+
+
+
+ {status === "retrying" ? t("task:sessionHistoryRetrying") : t("task:loadingConversation")}
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
{t("task:sessionHistoryUnavailable")}
+
+
+
+
+ {t("task:details")}
+
+
+ {errorDetail(error, t("task:sessionHistoryUnknownError"))}
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/components/task/chat/use-chat-panel-state.ts b/apps/web/components/task/chat/use-chat-panel-state.ts
index e195b76b5f..5430fec090 100644
--- a/apps/web/components/task/chat/use-chat-panel-state.ts
+++ b/apps/web/components/task/chat/use-chat-panel-state.ts
@@ -454,6 +454,9 @@ function useSessionData(
historyRefreshPending,
historyInitialized,
hasMore: hasOlderMessages,
+ historyStatus,
+ historyError,
+ retryHistory,
} = useSessionMessages(resolvedSessionId);
const turns = useAppStore((state) =>
resolvedSessionId ? state.turns.bySession[resolvedSessionId] : undefined,
@@ -490,6 +493,9 @@ function useSessionData(
messagesLoading,
isInitialMessagesLoading,
historyRefreshPending,
+ historyStatus,
+ historyError,
+ retryHistory,
...processed,
sessionModel,
activeModel,
diff --git a/apps/web/components/task/ensure-session-error.test.tsx b/apps/web/components/task/ensure-session-error.test.tsx
index a3ffd0dfce..ce36b5ee8c 100644
--- a/apps/web/components/task/ensure-session-error.test.tsx
+++ b/apps/web/components/task/ensure-session-error.test.tsx
@@ -175,6 +175,31 @@ describe("EnsureSessionErrorBanner", () => {
expect(screen.getByText(RESUME_FAILURE_DETAIL)).toBeTruthy();
});
+ it("renders status failures as compact retryable feedback with collapsed details", () => {
+ const onRetry = vi.fn();
+ render(
+ ,
+ );
+
+ expect(screen.getByText("Session status is unavailable.")).toBeTruthy();
+ expect(screen.queryByText("Couldn't start a session")).toBeNull();
+ const details = screen.getByTestId("session-status-details") as HTMLDetailsElement;
+ expect(details.open).toBe(false);
+ fireEvent.click(screen.getByTestId("session-status-details-summary"));
+ expect(screen.getByText("WebSocket request timed out: task.session.status")).toBeTruthy();
+ fireEvent.click(screen.getByTestId("session-status-retry"));
+ expect(onRetry).toHaveBeenCalledTimes(1);
+ });
+
it("renders page-wide feedback when automatic recovery and workspace restoration fail", () => {
render(
Promise;
+ retrySessionStatus: () => Promise;
+}): () => void {
+ if (source.recoveryFailure?.outcome === "status_unavailable") {
+ return () => {
+ void source.retrySessionStatus();
+ };
+ }
+ return () => {
+ void source.resumeSession();
+ };
+}
+
type BannerProps = {
error: Error | null;
onRetry: () => void;
@@ -70,6 +86,7 @@ type BannerProps = {
function RecoveryFailureDetails({ failure }: { failure: SessionRecoveryFailure }) {
const { t } = useTranslation();
+ if (failure.outcome === "status_unavailable") return null;
return (
;
+ onRetry: () => void;
+ retryDisabled?: boolean;
+}) {
+ const { t } = useTranslation();
+ const { isFinePointer } = useResponsiveBreakpoint();
+ return (
+
+
+
+ {t("task:sessionStatusUnavailable")}
+
+ {t("task:sessionStatusUnavailableDetail")}
+
+
+ {t("task:details")}
+
+ {failure.statusError}
+
+
+
+
+
+ );
+}
+
/** Slim banner for the task page, rendered above the layout. */
export function EnsureSessionErrorBanner({
error,
@@ -215,8 +279,16 @@ export function SessionRecoveryFeedback({
}) {
const readOnlyRecovery =
recoveryFailure?.outcome === "workspace_read_only" ? recoveryFailure : null;
+ const statusFailure = recoveryFailure?.outcome === "status_unavailable" ? recoveryFailure : null;
return (
<>
+ {statusFailure ? (
+
+ ) : null}
void resumption.resumeSession()}
+ onRetry={getSessionRecoveryRetry(resumption)}
retryDisabled={
resumption.resumptionState === "checking" || resumption.resumptionState === "resuming"
}
@@ -678,7 +682,7 @@ function PreviewNoSessionsState({
error={resumption.error}
notice={resumption.notice}
recoveryFailure={resumption.recoveryFailure}
- onRetry={() => void resumption.resumeSession()}
+ onRetry={getSessionRecoveryRetry(resumption)}
retryDisabled={
resumption.resumptionState === "checking" || resumption.resumptionState === "resuming"
}
diff --git a/apps/web/components/task/task-chat-panel.tsx b/apps/web/components/task/task-chat-panel.tsx
index 91a13a24b2..28ce28fa95 100644
--- a/apps/web/components/task/task-chat-panel.tsx
+++ b/apps/web/components/task/task-chat-panel.tsx
@@ -1017,6 +1017,9 @@ export const TaskChatPanel = memo(function TaskChatPanel({
messagesLoading,
historyRefreshPending,
isInitialMessagesLoading,
+ historyStatus,
+ historyError,
+ retryHistory,
groupedItems,
allMessages,
footerActionMessages,
@@ -1194,6 +1197,9 @@ export const TaskChatPanel = memo(function TaskChatPanel({
sessionId={resolvedSessionId}
messagesLoading={messagesLoading}
historyRefreshPending={historyRefreshPending}
+ historyStatus={historyStatus}
+ historyError={historyError}
+ onRetryHistory={retryHistory}
isWorking={isWorking}
sessionState={session?.state}
worktreePath={getSessionWorkspacePath(session)}
diff --git a/apps/web/components/task/task-page-inner.tsx b/apps/web/components/task/task-page-inner.tsx
index 71542de135..839e7552f7 100644
--- a/apps/web/components/task/task-page-inner.tsx
+++ b/apps/web/components/task/task-page-inner.tsx
@@ -12,6 +12,7 @@ import { useAppStore } from "@/components/state-provider";
import type { UseEnsureTaskSessionResult } from "@/hooks/domains/session/use-ensure-task-session";
import {
EnsureSessionErrorBanner,
+ getSessionRecoveryRetry,
SessionRecoveryFeedback,
} from "@/components/task/ensure-session-error";
import { TaskMoveErrorBanner } from "@/components/task/task-move-error-banner";
@@ -271,7 +272,7 @@ function TaskPageRecoveryFeedback({
error={resumption.error}
notice={resumption.notice}
recoveryFailure={resumption.recoveryFailure}
- onRetry={() => void resumption.resumeSession()}
+ onRetry={getSessionRecoveryRetry(resumption)}
retryDisabled={
resumption.resumptionState === "checking" || resumption.resumptionState === "resuming"
}
diff --git a/apps/web/e2e/helpers/session-entry-recovery.ts b/apps/web/e2e/helpers/session-entry-recovery.ts
new file mode 100644
index 0000000000..14a50ec46f
--- /dev/null
+++ b/apps/web/e2e/helpers/session-entry-recovery.ts
@@ -0,0 +1,113 @@
+import type { Page } from "@playwright/test";
+import { injectLatency } from "./causal-waits";
+
+type WireFrame = {
+ id?: unknown;
+ type?: unknown;
+ action?: unknown;
+};
+
+type DelayRule = {
+ remaining: number;
+ delayMs: number;
+ reason: string;
+};
+
+export type SessionEntryRecoveryProxy = {
+ delayNextResponses: (action: string, count: number, delayMs: number, reason: string) => void;
+ requestCount: (action: string) => number;
+ delayedResponseCount: (action: string) => number;
+};
+
+function parseFrame(value: string): WireFrame | null {
+ try {
+ const parsed = JSON.parse(value) as unknown;
+ return typeof parsed === "object" && parsed !== null ? (parsed as WireFrame) : null;
+ } catch {
+ return null;
+ }
+}
+
+function isResponseFrame(frame: WireFrame | null): boolean {
+ return frame?.type === "response" || frame?.type === "error";
+}
+
+function responseAction(
+ frame: WireFrame | null,
+ requestActions: Map,
+): string | undefined {
+ if (typeof frame?.action === "string") return frame.action;
+ if (typeof frame?.id === "string") return requestActions.get(frame.id);
+ return undefined;
+}
+
+/**
+ * Delay selected gateway responses while forwarding every other frame. Rules
+ * correlate replies by request id, so the test never relies on action-only or
+ * payload timing and does not inspect message contents.
+ */
+export async function routeSessionEntryRecovery(page: Page): Promise {
+ const requestActions = new Map();
+ const requestCounts = new Map();
+ const delayedCounts = new Map();
+ const rules = new Map();
+
+ await page.routeWebSocket(/\/ws$/, (ws) => {
+ const server = ws.connectToServer();
+
+ ws.onMessage((message) => {
+ if (typeof message === "string") {
+ for (const part of message.split("\n")) {
+ const frame = parseFrame(part.trim());
+ if (
+ frame?.type === "request" &&
+ typeof frame.id === "string" &&
+ typeof frame.action === "string"
+ ) {
+ requestActions.set(frame.id, frame.action);
+ requestCounts.set(frame.action, (requestCounts.get(frame.action) ?? 0) + 1);
+ }
+ }
+ }
+ server.send(message);
+ });
+
+ server.onMessage((message) => {
+ if (typeof message !== "string") {
+ ws.send(message);
+ return;
+ }
+
+ for (const part of message.split("\n")) {
+ const trimmed = part.trim();
+ if (!trimmed) continue;
+ const frame = parseFrame(trimmed);
+ const action = responseAction(frame, requestActions);
+ if (typeof frame?.id === "string") requestActions.delete(frame.id);
+ const rule = isResponseFrame(frame) && action ? rules.get(action) : null;
+
+ if (rule && rule.remaining > 0 && action) {
+ rule.remaining -= 1;
+ delayedCounts.set(action, (delayedCounts.get(action) ?? 0) + 1);
+ void (async () => {
+ await injectLatency(rule.delayMs, rule.reason);
+ ws.send(trimmed);
+ })();
+ continue;
+ }
+
+ ws.send(trimmed);
+ }
+ });
+ });
+
+ return {
+ delayNextResponses: (action, count, delayMs, reason) => {
+ if (count < 1) throw new Error("delayNextResponses requires a positive response count");
+ if (delayMs < 0) throw new Error("delayNextResponses requires a non-negative delay");
+ rules.set(action, { remaining: count, delayMs, reason });
+ },
+ requestCount: (action) => requestCounts.get(action) ?? 0,
+ delayedResponseCount: (action) => delayedCounts.get(action) ?? 0,
+ };
+}
diff --git a/apps/web/e2e/tests/session/mobile-session-entry-recovery.spec.ts b/apps/web/e2e/tests/session/mobile-session-entry-recovery.spec.ts
new file mode 100644
index 0000000000..3061198d3a
--- /dev/null
+++ b/apps/web/e2e/tests/session/mobile-session-entry-recovery.spec.ts
@@ -0,0 +1,58 @@
+import { expect, test } from "../../fixtures/test-base";
+import type { SeedData } from "../../fixtures/test-base";
+import type { ApiClient } from "../../helpers/api-client";
+import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions";
+import { openTaskSession } from "../../helpers/session";
+import { routeSessionEntryRecovery } from "../../helpers/session-entry-recovery";
+
+async function createEntryTask(apiClient: ApiClient, seedData: SeedData, title: string) {
+ return apiClient.createTaskWithAgent(seedData.workspaceId, title, seedData.agentProfileId, {
+ description: "/e2e:simple-message",
+ workflow_id: seedData.workflowId,
+ workflow_step_id: seedData.startStepId,
+ repository_ids: [seedData.repositoryId],
+ });
+}
+
+test.describe("mobile session entry recovery", () => {
+ test.describe.configure({ retries: 1 });
+
+ test("keeps cached access while history recovers with phone-sized controls", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ test.setTimeout(150_000);
+ const proxy = await routeSessionEntryRecovery(testPage);
+ const task = await createEntryTask(
+ apiClient,
+ seedData,
+ `Mobile history recovery ${Date.now()}`,
+ );
+
+ proxy.delayNextResponses(
+ "message.list",
+ 2,
+ 11_000,
+ "force both bounded history attempts to expose the mobile unavailable state",
+ );
+
+ const session = await openTaskSession(testPage, task.id);
+ const chat = session.activeChat();
+ const historyNotice = chat.getByTestId("session-history-unavailable");
+ await expect(historyNotice).toBeVisible({ timeout: 45_000 });
+
+ const retry = historyNotice.getByTestId("session-history-retry");
+ const retryBox = await retry.boundingBox();
+ expect(retryBox?.height).toBeGreaterThanOrEqual(44);
+ const detailsSummary = historyNotice.getByTestId("session-history-details-summary");
+ const detailsBox = await detailsSummary.boundingBox();
+ expect(detailsBox?.height).toBeGreaterThanOrEqual(44);
+ await assertNoDocumentHorizontalOverflow(testPage, "mobile session history recovery");
+
+ await retry.click();
+ await expect(historyNotice).toHaveCount(0);
+ await expect(chat).toContainText("simple mock response", { timeout: 30_000 });
+ expect(proxy.delayedResponseCount("message.list")).toBe(2);
+ });
+});
diff --git a/apps/web/e2e/tests/session/session-entry-recovery.spec.ts b/apps/web/e2e/tests/session/session-entry-recovery.spec.ts
new file mode 100644
index 0000000000..2c8647dbc1
--- /dev/null
+++ b/apps/web/e2e/tests/session/session-entry-recovery.spec.ts
@@ -0,0 +1,145 @@
+import { expect, test } from "../../fixtures/test-base";
+import type { SeedData } from "../../fixtures/test-base";
+import type { ApiClient } from "../../helpers/api-client";
+import { openTaskSession } from "../../helpers/session";
+import { routeSessionEntryRecovery } from "../../helpers/session-entry-recovery";
+
+async function createEntryTask(apiClient: ApiClient, seedData: SeedData, title: string) {
+ return apiClient.createTaskWithAgent(seedData.workspaceId, title, seedData.agentProfileId, {
+ description: "/e2e:simple-message",
+ workflow_id: seedData.workflowId,
+ workflow_step_id: seedData.startStepId,
+ repository_ids: [seedData.repositoryId],
+ });
+}
+
+test.describe("session entry recovery", () => {
+ test.describe.configure({ retries: 1 });
+
+ test("completes entry when status and subscription acknowledgements take seven seconds", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ test.setTimeout(90_000);
+ const proxy = await routeSessionEntryRecovery(testPage);
+ const task = await createEntryTask(apiClient, seedData, `Delayed entry ${Date.now()}`);
+
+ proxy.delayNextResponses(
+ "task.session.status",
+ 1,
+ 7_000,
+ "expose a delayed status acknowledgement without crossing the entry deadline",
+ );
+ proxy.delayNextResponses(
+ "session.subscribe",
+ 1,
+ 7_000,
+ "expose a delayed subscription acknowledgement without a reload",
+ );
+
+ const session = await openTaskSession(testPage, task.id);
+ await expect(session.activeChat()).toContainText("simple mock response", { timeout: 45_000 });
+ await expect(testPage.getByTestId("ensure-session-error-banner")).toHaveCount(0);
+ expect(proxy.delayedResponseCount("task.session.status")).toBe(1);
+ expect(proxy.delayedResponseCount("session.subscribe")).toBe(1);
+ });
+
+ test("retries a timed out subscription automatically while the page remains open", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ test.setTimeout(120_000);
+ const proxy = await routeSessionEntryRecovery(testPage);
+ const task = await createEntryTask(apiClient, seedData, `Retry entry ${Date.now()}`);
+
+ proxy.delayNextResponses(
+ "session.subscribe",
+ 1,
+ 11_000,
+ "force the first subscription acknowledgement past the ten-second deadline",
+ );
+
+ const session = await openTaskSession(testPage, task.id);
+ await expect
+ .poll(() => proxy.requestCount("session.subscribe"), {
+ timeout: 30_000,
+ message: "Waiting for the automatic second session.subscribe request",
+ })
+ .toBeGreaterThan(1);
+ await expect(session.activeChat()).toContainText("simple mock response", { timeout: 60_000 });
+ await expect(testPage.getByTestId("ensure-session-error-banner")).toHaveCount(0);
+ expect(proxy.delayedResponseCount("session.subscribe")).toBe(1);
+ });
+
+ test("shows one recoverable history notice and restores it with Retry", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ test.setTimeout(150_000);
+ const proxy = await routeSessionEntryRecovery(testPage);
+ const task = await createEntryTask(apiClient, seedData, `History recovery ${Date.now()}`);
+
+ proxy.delayNextResponses(
+ "message.list",
+ 2,
+ 11_000,
+ "force both bounded history attempts to expose the unavailable state",
+ );
+
+ const session = await openTaskSession(testPage, task.id);
+ const historyNotice = session.activeChat().getByTestId("session-history-unavailable");
+ await expect(historyNotice).toBeVisible({ timeout: 45_000 });
+ await expect(
+ session.activeChat().getByText("No messages yet. Start the conversation!", { exact: true }),
+ ).toHaveCount(0);
+
+ const details = session.activeChat().getByTestId("session-history-details");
+ await expect(details).toBeVisible();
+ await expect(details).not.toHaveAttribute("open", "");
+ await details.getByTestId("session-history-details-summary").click();
+ await expect(details).toHaveAttribute("open", "");
+ await expect(details).toContainText("WebSocket request timed out: message.list");
+
+ await historyNotice.getByTestId("session-history-retry").click();
+ await expect(historyNotice).toHaveCount(0);
+ await expect(session.activeChat()).toContainText("simple mock response", { timeout: 30_000 });
+ expect(proxy.delayedResponseCount("message.list")).toBe(2);
+ });
+
+ test("labels exhausted status checks accurately and retries only the status read", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ test.setTimeout(150_000);
+ const proxy = await routeSessionEntryRecovery(testPage);
+ const task = await createEntryTask(apiClient, seedData, `Status recovery ${Date.now()}`);
+
+ proxy.delayNextResponses(
+ "task.session.status",
+ 2,
+ 11_000,
+ "force both bounded status attempts to expose the compact status notice",
+ );
+
+ await openTaskSession(testPage, task.id);
+ const statusNotice = testPage.getByTestId("session-status-unavailable");
+ await expect(statusNotice).toBeVisible({ timeout: 45_000 });
+ await expect(statusNotice).not.toContainText("Couldn't start a session");
+
+ const details = statusNotice.getByTestId("session-status-details");
+ await expect(details).not.toHaveAttribute("open", "");
+ await details.getByTestId("session-status-details-summary").click();
+ await expect(details).toContainText("WebSocket request timed out: task.session.status");
+
+ const launchRequestCountBeforeRetry = proxy.requestCount("session.launch");
+ await statusNotice.getByTestId("session-status-retry").click();
+ await expect(statusNotice).toHaveCount(0);
+ expect(proxy.requestCount("task.session.status")).toBeGreaterThanOrEqual(3);
+ expect(proxy.requestCount("session.launch")).toBe(launchRequestCountBeforeRetry);
+ expect(proxy.delayedResponseCount("task.session.status")).toBe(2);
+ });
+});
diff --git a/apps/web/hooks/domains/session/use-message-fetch-state.ts b/apps/web/hooks/domains/session/use-message-fetch-state.ts
index 1a56785ddb..cecb5a32e2 100644
--- a/apps/web/hooks/domains/session/use-message-fetch-state.ts
+++ b/apps/web/hooks/domains/session/use-message-fetch-state.ts
@@ -1,10 +1,14 @@
import { useMemo, useRef, useState } from "react";
import type { useAppStoreApi } from "@/components/state-provider";
+export type MessageHistoryStatus = "loading" | "retrying" | "ready" | "unavailable";
+
export function useMessageFetchState(store: ReturnType) {
const [isLoading, setIsLoading] = useState(false);
const [isWaitingForInitialMessages, setIsWaitingForInitialMessages] = useState(false);
const [isCachedHistoryRefreshPending, setIsCachedHistoryRefreshPending] = useState(false);
+ const [historyStatus, setHistoryStatus] = useState("loading");
+ const [historyError, setHistoryError] = useState(null);
const cachedRefreshGenerationRef = useRef(0);
const initialFetchStartRef = useRef(null);
const lastFetchedSessionIdRef = useRef(null);
@@ -15,6 +19,8 @@ export function useMessageFetchState(store: ReturnType) {
setIsWaitingForInitialMessages,
initialFetchStartRef,
lastFetchedSessionIdRef,
+ setHistoryStatus,
+ setHistoryError,
}),
[store],
);
@@ -23,6 +29,10 @@ export function useMessageFetchState(store: ReturnType) {
isWaitingForInitialMessages,
isCachedHistoryRefreshPending,
setIsCachedHistoryRefreshPending,
+ historyStatus,
+ setHistoryStatus,
+ historyError,
+ setHistoryError,
setIsWaitingForInitialMessages,
initialFetchStartRef,
lastFetchedSessionIdRef,
diff --git a/apps/web/hooks/domains/session/use-session-message-fetch.test.ts b/apps/web/hooks/domains/session/use-session-message-fetch.test.ts
index d983d38585..ae37d6c085 100644
--- a/apps/web/hooks/domains/session/use-session-message-fetch.test.ts
+++ b/apps/web/hooks/domains/session/use-session-message-fetch.test.ts
@@ -27,6 +27,8 @@ function makeParams(
} as never,
setIsLoading: vi.fn(),
setIsWaitingForInitialMessages: vi.fn(),
+ setHistoryStatus: vi.fn(),
+ setHistoryError: vi.fn(),
initialFetchStartRef: { current: null },
lastFetchedSessionIdRef: { current: null },
fetchAndStoreMessages,
@@ -80,7 +82,7 @@ describe("doFetchMessages", () => {
expect(setMessagesLoading).toHaveBeenLastCalledWith(SESSION_ID, false);
});
- it("clears hook gates when an active fetch becomes stale before settling", async () => {
+ it("releases store and local loading bookkeeping when a stale fetch settles", async () => {
const result = deferred();
const setMessagesLoading = vi.fn();
const params = makeParams(vi.fn().mockReturnValue(result.promise), setMessagesLoading);
@@ -92,7 +94,52 @@ describe("doFetchMessages", () => {
await fetch;
expect(params.setIsLoading).toHaveBeenLastCalledWith(false);
- expect(params.setIsWaitingForInitialMessages).toHaveBeenLastCalledWith(false);
+ expect(params.setIsWaitingForInitialMessages).toHaveBeenLastCalledWith(true);
expect(setMessagesLoading).toHaveBeenLastCalledWith(SESSION_ID, false);
});
+
+ it("does not finalize a newer hook generation from a stale fetch", async () => {
+ const result = deferred();
+ const setMessagesLoading = vi.fn();
+ const params = makeParams(vi.fn().mockReturnValue(result.promise), setMessagesLoading);
+ const isActive = { value: true };
+ const fetch = doFetchMessages({
+ ...params,
+ isActive: () => isActive.value,
+ canFinalizeLoading: () => isActive.value,
+ } as never);
+
+ isActive.value = false;
+ result.resolve([]);
+ await fetch;
+
+ expect(params.setIsLoading).toHaveBeenLastCalledWith(true);
+ expect(setMessagesLoading).toHaveBeenLastCalledWith(SESSION_ID, false);
+ });
+
+ it("preserves cached messages when the history request fails", async () => {
+ const setMessages = vi.fn();
+ const setMessagesLoading = vi.fn();
+ const store = {
+ getState: () => ({ setMessages, setMessagesLoading }),
+ } as unknown as Parameters[0]["store"];
+ const lastFetchedSessionIdRef = { current: null as string | null };
+ const initialFetchStartRef = { current: null as number | null };
+
+ await doFetchMessages({
+ taskSessionId: SESSION_ID,
+ store,
+ setIsLoading: vi.fn(),
+ setIsWaitingForInitialMessages: vi.fn(),
+ setHistoryStatus: vi.fn(),
+ setHistoryError: vi.fn(),
+ initialFetchStartRef,
+ lastFetchedSessionIdRef,
+ fetchAndStoreMessages: vi.fn().mockRejectedValue(new Error("history unavailable")),
+ onError: vi.fn(),
+ });
+
+ expect(setMessages).not.toHaveBeenCalled();
+ expect(lastFetchedSessionIdRef.current).toBeNull();
+ });
});
diff --git a/apps/web/hooks/domains/session/use-session-message-fetch.ts b/apps/web/hooks/domains/session/use-session-message-fetch.ts
index 5732040aed..a1849deef4 100644
--- a/apps/web/hooks/domains/session/use-session-message-fetch.ts
+++ b/apps/web/hooks/domains/session/use-session-message-fetch.ts
@@ -2,6 +2,7 @@ import { useEffect, type MutableRefObject } from "react";
import type { useAppStoreApi } from "@/components/state-provider";
import type { Message } from "@/lib/types/http";
+import type { MessageHistoryStatus } from "./use-message-fetch-state";
type SessionMessageStore = ReturnType;
@@ -103,17 +104,23 @@ type DoFetchMessagesParams = {
store: SessionMessageStore;
setIsLoading: (value: boolean) => void;
setIsWaitingForInitialMessages: (value: boolean) => void;
+ setHistoryStatus: (value: MessageHistoryStatus) => void;
+ setHistoryError: (value: unknown) => void;
initialFetchStartRef: MutableRefObject;
lastFetchedSessionIdRef: MutableRefObject;
+ // eslint-disable-next-line max-params -- hydration guards and retry feedback share one fetch contract.
fetchAndStoreMessages: (
sessionId: string,
store: SessionMessageStore,
isActive?: () => boolean,
hydrationRef?: SessionHydrationRef,
hydrationKey?: string,
+ onRetry?: () => void,
) => Promise;
onError?: (error: unknown) => void;
isActive?: () => boolean;
+ /** Allows the owning hook generation to fence local loading finalization. */
+ canFinalizeLoading?: () => boolean;
hydrationRef?: SessionHydrationRef;
hydrationKey?: string;
};
@@ -123,44 +130,51 @@ export async function doFetchMessages({
store,
setIsLoading,
setIsWaitingForInitialMessages,
+ setHistoryStatus,
+ setHistoryError,
initialFetchStartRef,
lastFetchedSessionIdRef,
fetchAndStoreMessages,
onError,
isActive,
+ canFinalizeLoading,
hydrationRef,
hydrationKey,
}: DoFetchMessagesParams): Promise {
if (isInactive(isActive)) return;
beginSessionFetch(taskSessionId);
setIsLoading(true);
+ const isInitialHistoryFetch = lastFetchedSessionIdRef.current !== taskSessionId;
+ if (isInitialHistoryFetch) {
+ setHistoryStatus("loading");
+ setHistoryError(null);
+ }
store.getState().setMessagesLoading(taskSessionId, true);
if (initialFetchStartRef.current === null) {
initialFetchStartRef.current = Date.now();
setIsWaitingForInitialMessages(true);
}
try {
- const fetched = await fetchAndStoreMessages(
- taskSessionId,
- store,
- isActive,
- hydrationRef,
- hydrationKey,
+ await fetchAndStoreMessages(taskSessionId, store, isActive, hydrationRef, hydrationKey, () =>
+ setHistoryStatus("retrying"),
);
if (isInactive(isActive)) return;
lastFetchedSessionIdRef.current = taskSessionId;
- if (fetched.length > 0) setIsWaitingForInitialMessages(false);
+ setHistoryStatus("ready");
+ setHistoryError(null);
+ setIsWaitingForInitialMessages(false);
} catch (error) {
if (isInactive(isActive)) return;
if (onError) onError(error);
else console.error("Failed to fetch messages:", error);
- store.getState().setMessages(taskSessionId, []);
- lastFetchedSessionIdRef.current = taskSessionId;
+ setHistoryStatus("unavailable");
+ setHistoryError(error);
} finally {
+ const active = !isInactive(isActive);
if (endSessionFetch(taskSessionId)) {
store.getState().setMessagesLoading(taskSessionId, false);
- setIsLoading(false);
+ if (!canFinalizeLoading || canFinalizeLoading()) setIsLoading(false);
}
- setIsWaitingForInitialMessages(false);
+ if (active) setIsWaitingForInitialMessages(false);
}
}
diff --git a/apps/web/hooks/domains/session/use-session-messages.test.ts b/apps/web/hooks/domains/session/use-session-messages.test.ts
index 5de3b087b7..47a729f07a 100644
--- a/apps/web/hooks/domains/session/use-session-messages.test.ts
+++ b/apps/web/hooks/domains/session/use-session-messages.test.ts
@@ -1,4 +1,6 @@
-import { act, renderHook } from "@testing-library/react";
+/* eslint-disable max-lines -- session history race regressions share one lifecycle test harness. */
+
+import { act, renderHook, waitFor } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { Message } from "@/lib/types/http";
@@ -60,6 +62,7 @@ vi.mock("@/components/state-provider", () => ({
}));
import { taskId, sessionId } from "@/lib/types/ids";
+import { WebSocketRequestTimeoutError } from "@/lib/ws/client";
beforeEach(() => {
vi.clearAllMocks();
@@ -90,6 +93,7 @@ beforeEach(() => {
});
afterEach(() => {
+ vi.useRealTimers();
vi.restoreAllMocks();
});
import {
@@ -289,10 +293,45 @@ describe("stale concurrent fetch guard", () => {
function deferred() {
let resolve!: (value: T | PromiseLike) => void;
- const promise = new Promise((resolvePromise) => {
+ let reject!: (reason?: unknown) => void;
+ const promise = new Promise((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
+ reject = rejectPromise;
});
- return { promise, resolve };
+ return { promise, resolve, reject };
+}
+
+function configureSession(
+ id: string,
+ state: TaskSessionState,
+ messages: Message[],
+ historyInitialized = true,
+) {
+ const messagesBySession = mockState.messages.bySession as Record;
+ const metaBySession = mockState.messages.metaBySession as Record<
+ string,
+ {
+ historyInitialized: boolean;
+ hasMore: boolean;
+ oldestCursor: string | null;
+ isLoading: boolean;
+ isLoadingMore: boolean;
+ }
+ >;
+ const sessions = mockState.taskSessions.items as Record;
+ const turnsBySession = mockState.turns.bySession as Record;
+ const activeTurns = mockState.turns.activeBySession as Record;
+ messagesBySession[id] = messages;
+ metaBySession[id] = {
+ historyInitialized,
+ hasMore: false,
+ oldestCursor: null,
+ isLoading: false,
+ isLoadingMore: false,
+ };
+ sessions[id] = { state };
+ turnsBySession[id] = [];
+ activeTurns[id] = null;
}
describe("cached session entry history readiness", () => {
@@ -308,7 +347,7 @@ describe("cached session entry history readiness", () => {
mockWebSocketClient.request.mockReturnValue(response.promise);
mockState.messages.bySession["sess-1"] = [makeMessage({ id: "cached" })];
- const { result, unmount } = renderHook(() => useSessionMessages("sess-1"));
+ const { result, rerender, unmount } = renderHook(() => useSessionMessages("sess-1"));
expect(result.current.historyRefreshPending).toBe(true);
@@ -318,6 +357,15 @@ describe("cached session entry history readiness", () => {
});
expect(result.current.historyRefreshPending).toBe(true);
+ // A live row can arrive while the cached refresh is still in flight.
+ // That changes the message count and reruns the entry effect before the
+ // refresh response settles.
+ mockState.messages.bySession["sess-1"] = [
+ makeMessage({ id: "cached" }),
+ makeMessage({ id: "live" }),
+ ];
+ rerender();
+
await act(async () => {
response.resolve({ messages: [], has_more: false });
await response.promise;
@@ -442,6 +490,301 @@ describe("session subscription hydration ordering", () => {
});
});
+describe("history retry state", () => {
+ it("retries a timed out history request and marks the snapshot ready", async () => {
+ vi.useFakeTimers();
+ mockWebSocketClient.getSessionSubscriptionReadiness.mockReturnValue(Promise.resolve());
+ mockWebSocketClient.subscribeSessionWithReady.mockReturnValue({
+ ready: Promise.resolve(),
+ unsubscribe: vi.fn(),
+ });
+ mockWebSocketClient.request
+ .mockRejectedValueOnce(new WebSocketRequestTimeoutError("message.list"))
+ .mockResolvedValueOnce({ messages: [], has_more: false });
+
+ const { result, unmount } = renderHook(() => useSessionMessages("sess-1"));
+ await act(async () => {
+ await Promise.resolve();
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(1000);
+ });
+
+ expect(mockWebSocketClient.request).toHaveBeenCalledTimes(2);
+ expect(result.current.historyStatus).toBe("ready");
+ unmount();
+ });
+
+ it("keeps a failed history snapshot unavailable without retrying non-timeout errors", async () => {
+ mockWebSocketClient.getSessionSubscriptionReadiness.mockReturnValue(Promise.resolve());
+ mockWebSocketClient.subscribeSessionWithReady.mockReturnValue({
+ ready: Promise.resolve(),
+ unsubscribe: vi.fn(),
+ });
+ mockWebSocketClient.request.mockRejectedValue(new Error("permission denied"));
+ vi.spyOn(console, "error").mockImplementation(() => {});
+
+ const { result, unmount } = renderHook(() => useSessionMessages("sess-1"));
+ await act(async () => {
+ await Promise.resolve();
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ expect(mockWebSocketClient.request).toHaveBeenCalledTimes(1);
+ expect(result.current.historyStatus).toBe("unavailable");
+ expect(result.current.historyError).toEqual(new Error("permission denied"));
+ unmount();
+ });
+});
+
+// eslint-disable-next-line max-lines-per-function -- deferred session transitions are easiest to audit as complete scenarios.
+describe("history feedback generation", () => {
+ it("ignores a terminal fetch rejection from session A after switching to session B", async () => {
+ const aReadiness = deferred();
+ const bReadiness = deferred();
+ const aInitial = deferred<{ messages: Message[]; has_more: boolean }>();
+ const aTerminal = deferred<{ messages: Message[]; has_more: boolean }>();
+ const bInitial = deferred<{ messages: Message[]; has_more: boolean }>();
+ const readinessBySession = new Map([
+ ["sess-a", aReadiness.promise],
+ ["sess-b", bReadiness.promise],
+ ]);
+ const aUser = makeMessage({ id: "a-user", session_id: sessionId("sess-a") });
+ const bUser = makeMessage({ id: "b-user", session_id: sessionId("sess-b") });
+ configureSession("sess-a", "RUNNING", [aUser]);
+ configureSession("sess-b", "RUNNING", [bUser]);
+ mockState.mergeMessages.mockImplementation((id, messages, metadata) => {
+ (mockState.messages.bySession as Record)[id] = messages;
+ (
+ mockState.messages.metaBySession as Record<
+ string,
+ (typeof mockState.messages.metaBySession)["sess-1"]
+ >
+ )[id] = {
+ ...mockState.messages.metaBySession["sess-1"],
+ ...metadata,
+ };
+ });
+ mockWebSocketClient.getSessionSubscriptionReadiness.mockImplementation(
+ (id: string) => readinessBySession.get(id) ?? Promise.resolve(),
+ );
+ mockWebSocketClient.subscribeSessionWithReady.mockImplementation((id: string) => ({
+ ready: readinessBySession.get(id) ?? Promise.resolve(),
+ unsubscribe: vi.fn(),
+ }));
+ mockWebSocketClient.request
+ .mockImplementationOnce(() => aInitial.promise)
+ .mockImplementationOnce(() => aTerminal.promise)
+ .mockImplementationOnce(() => bInitial.promise);
+
+ const { result, rerender, unmount } = renderHook(
+ ({ activeSessionId }: { activeSessionId: string }) => useSessionMessages(activeSessionId),
+ { initialProps: { activeSessionId: "sess-a" } },
+ );
+
+ await act(async () => {
+ aReadiness.resolve();
+ await aReadiness.promise;
+ });
+ await waitFor(() => expect(mockWebSocketClient.request).toHaveBeenCalledTimes(1));
+ await act(async () => {
+ aInitial.resolve({ messages: [aUser], has_more: false });
+ await aInitial.promise;
+ });
+
+ (mockState.taskSessions.items as Record)["sess-a"].state =
+ "WAITING_FOR_INPUT";
+ rerender({ activeSessionId: "sess-a" });
+ await waitFor(() => expect(mockWebSocketClient.request).toHaveBeenCalledTimes(2));
+
+ rerender({ activeSessionId: "sess-b" });
+ await act(async () => {
+ bReadiness.resolve();
+ await bReadiness.promise;
+ });
+ await waitFor(() => expect(mockWebSocketClient.request).toHaveBeenCalledTimes(3));
+ await act(async () => {
+ bInitial.resolve({ messages: [bUser], has_more: false });
+ await bInitial.promise;
+ });
+ await waitFor(() => expect(result.current.historyStatus).toBe("ready"));
+
+ await act(async () => {
+ aTerminal.reject(new Error("session A history failed"));
+ await aTerminal.promise.catch(() => undefined);
+ });
+
+ expect(result.current.historyStatus).toBe("ready");
+ expect(result.current.historyError).toBeNull();
+ unmount();
+ });
+
+ it("ignores an obsolete manual retry after leaving and re-entering session A", async () => {
+ const aReadiness = deferred();
+ const bReadiness = deferred();
+ const aReentryReadiness = deferred();
+ const aInitial = deferred<{ messages: Message[]; has_more: boolean }>();
+ const aRetry = deferred<{ messages: Message[]; has_more: boolean }>();
+ const bInitial = deferred<{ messages: Message[]; has_more: boolean }>();
+ const aReentry = deferred<{ messages: Message[]; has_more: boolean }>();
+ const readinessBySession = new Map>([["sess-a", aReadiness.promise]]);
+ const aUser = makeMessage({ id: "a-user", session_id: sessionId("sess-a") });
+ const bUser = makeMessage({ id: "b-user", session_id: sessionId("sess-b") });
+ const aNewUser = makeMessage({ id: "a-new-user", session_id: sessionId("sess-a") });
+ configureSession("sess-a", "RUNNING", [aUser]);
+ configureSession("sess-b", "RUNNING", [bUser]);
+ mockState.mergeMessages.mockImplementation((id, messages, metadata) => {
+ (mockState.messages.bySession as Record)[id] = messages;
+ (
+ mockState.messages.metaBySession as Record<
+ string,
+ (typeof mockState.messages.metaBySession)["sess-1"]
+ >
+ )[id] = {
+ ...mockState.messages.metaBySession["sess-1"],
+ ...metadata,
+ };
+ });
+ mockWebSocketClient.getSessionSubscriptionReadiness.mockImplementation(
+ (id: string) => readinessBySession.get(id) ?? Promise.resolve(),
+ );
+ mockWebSocketClient.subscribeSessionWithReady.mockImplementation((id: string) => ({
+ ready: readinessBySession.get(id) ?? Promise.resolve(),
+ unsubscribe: vi.fn(),
+ }));
+ mockWebSocketClient.request
+ .mockImplementationOnce(() => aInitial.promise)
+ .mockImplementationOnce(() => aRetry.promise)
+ .mockImplementationOnce(() => bInitial.promise)
+ .mockImplementationOnce(() => aReentry.promise);
+
+ const { result, rerender, unmount } = renderHook(
+ ({ activeSessionId }: { activeSessionId: string }) => useSessionMessages(activeSessionId),
+ { initialProps: { activeSessionId: "sess-a" } },
+ );
+ await act(async () => {
+ aReadiness.resolve();
+ await aReadiness.promise;
+ });
+ await waitFor(() => expect(mockWebSocketClient.request).toHaveBeenCalledTimes(1));
+ await act(async () => {
+ aInitial.resolve({ messages: [aUser], has_more: false });
+ await aInitial.promise;
+ });
+
+ await act(async () => {
+ result.current.retryHistory();
+ await Promise.resolve();
+ });
+ await waitFor(() => expect(mockWebSocketClient.request).toHaveBeenCalledTimes(2));
+
+ readinessBySession.set("sess-b", bReadiness.promise);
+ rerender({ activeSessionId: "sess-b" });
+ await act(async () => {
+ bReadiness.resolve();
+ await bReadiness.promise;
+ });
+ await waitFor(() => expect(mockWebSocketClient.request).toHaveBeenCalledTimes(3));
+ await act(async () => {
+ bInitial.resolve({ messages: [bUser], has_more: false });
+ await bInitial.promise;
+ });
+
+ readinessBySession.set("sess-a", aReentryReadiness.promise);
+ rerender({ activeSessionId: "sess-a" });
+ await act(async () => {
+ aReentryReadiness.resolve();
+ await aReentryReadiness.promise;
+ });
+ await waitFor(() => expect(mockWebSocketClient.request).toHaveBeenCalledTimes(4));
+ await act(async () => {
+ aReentry.resolve({ messages: [aNewUser], has_more: false });
+ await aReentry.promise;
+ });
+ await waitFor(() => expect(result.current.historyStatus).toBe("ready"));
+
+ await act(async () => {
+ aRetry.reject(new Error("obsolete retry failed"));
+ await aRetry.promise.catch(() => undefined);
+ });
+
+ expect(result.current.historyStatus).toBe("ready");
+ expect(result.current.historyError).toBeNull();
+ expect((mockState.messages.bySession as Record)["sess-a"]).toEqual([
+ aNewUser,
+ ]);
+ unmount();
+ });
+
+ it("forces a fresh message snapshot when Retry follows a failed refresh", async () => {
+ const readiness = Promise.resolve();
+ const initial = deferred<{ messages: Message[]; has_more: boolean }>();
+ const failedRefresh = deferred<{ messages: Message[]; has_more: boolean }>();
+ const retried = deferred<{ messages: Message[]; has_more: boolean }>();
+ const oldUser = makeMessage({ id: "old-user", session_id: sessionId("sess-1") });
+ const newUser = makeMessage({ id: "new-user", session_id: sessionId("sess-1") });
+ configureSession("sess-1", "RUNNING", [oldUser]);
+ mockState.mergeMessages.mockImplementation((id, messages, metadata) => {
+ (mockState.messages.bySession as Record)[id] = messages;
+ (
+ mockState.messages.metaBySession as Record<
+ string,
+ (typeof mockState.messages.metaBySession)["sess-1"]
+ >
+ )[id] = {
+ ...mockState.messages.metaBySession["sess-1"],
+ ...metadata,
+ };
+ });
+ mockWebSocketClient.getSessionSubscriptionReadiness.mockReturnValue(readiness);
+ mockWebSocketClient.subscribeSessionWithReady.mockReturnValue({
+ ready: readiness,
+ unsubscribe: vi.fn(),
+ });
+ mockWebSocketClient.request
+ .mockImplementationOnce(() => initial.promise)
+ .mockImplementationOnce(() => failedRefresh.promise)
+ .mockImplementationOnce(() => retried.promise);
+
+ const { result, rerender, unmount } = renderHook(() => useSessionMessages("sess-1"));
+ await waitFor(() => expect(mockWebSocketClient.request).toHaveBeenCalledTimes(1));
+ await act(async () => {
+ initial.resolve({ messages: [oldUser], has_more: false });
+ await initial.promise;
+ });
+ await act(async () => {
+ await new Promise((resolve) => window.setTimeout(resolve, 0));
+ });
+
+ (mockState.taskSessions.items as Record)["sess-1"].state =
+ "WAITING_FOR_INPUT";
+ rerender();
+ await waitFor(() => expect(mockWebSocketClient.request).toHaveBeenCalledTimes(2));
+ await act(async () => {
+ failedRefresh.reject(new Error("refresh failed"));
+ await failedRefresh.promise.catch(() => undefined);
+ });
+ await waitFor(() => expect(result.current.historyStatus).toBe("unavailable"));
+
+ await act(async () => {
+ result.current.retryHistory();
+ await Promise.resolve();
+ });
+ await waitFor(() => expect(mockWebSocketClient.request).toHaveBeenCalledTimes(3));
+ await act(async () => {
+ retried.resolve({ messages: [newUser], has_more: false });
+ await retried.promise;
+ });
+
+ await waitFor(() => expect(result.current.historyStatus).toBe("ready"));
+ expect(mockState.messages.bySession["sess-1"]).toEqual([newUser]);
+ unmount();
+ });
+});
+
describe("turn loading for sessions without hydrated turns", () => {
it("fetches and merges turns when the session has none in the store", async () => {
const readiness = deferred();
diff --git a/apps/web/hooks/domains/session/use-session-messages.ts b/apps/web/hooks/domains/session/use-session-messages.ts
index b3c7b4e3ad..bbe85a1c65 100644
--- a/apps/web/hooks/domains/session/use-session-messages.ts
+++ b/apps/web/hooks/domains/session/use-session-messages.ts
@@ -1,7 +1,12 @@
/* eslint-disable max-lines -- session hydration and lifecycle hooks share one transcript contract. */
-import { useEffect, useRef, type MutableRefObject } from "react";
+import { useCallback, useEffect, useLayoutEffect, useRef, type MutableRefObject } from "react";
import { getWebSocketClient } from "@/lib/ws/connection";
+import {
+ isWebSocketRequestTimeoutError,
+ SESSION_ENTRY_REQUEST_TIMEOUT_MS,
+ SESSION_ENTRY_RETRY_DELAY_MS,
+} from "@/lib/ws/client";
import { useForegroundRefresh } from "@/hooks/use-foreground-refresh";
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
import type { TaskSessionState, Message } from "@/lib/types/http";
@@ -18,6 +23,7 @@ import {
type SessionHydrationRef,
} from "./use-session-message-fetch";
import { useMessageFetchState } from "./use-message-fetch-state";
+import type { MessageHistoryStatus } from "./use-message-fetch-state";
import { reconcileLatestMessageWindow } from "./message-window-reconciliation";
import { t } from "@/lib/i18n";
@@ -28,6 +34,7 @@ export { ensureSessionTurnsLoaded } from "./use-session-turns-hydration";
const INITIAL_FETCH_LIMIT = 100;
const RUNNING_BACKFILL_INITIAL_DELAY_MS = 1200;
const RUNNING_BACKFILL_INTERVAL_MS = 5000;
+const MAX_HISTORY_FETCH_ATTEMPTS = 2;
// Monotonic guard against stale concurrent fetches. Each fetch claims an
// increasing sequence number before awaiting; a completion only merges if no
@@ -143,6 +150,9 @@ interface UseSessionMessagesReturn {
historyInitialized: boolean;
hasMore: boolean;
oldestCursor: string | null;
+ historyStatus: MessageHistoryStatus;
+ historyError: unknown;
+ retryHistory: () => void;
}
type MessageListResponse = { messages: Message[]; has_more?: boolean; cursor?: string };
@@ -150,6 +160,7 @@ type InFlightMessageRequest = {
readiness: Promise;
promise: Promise;
cachedAtRequest: Message[];
+ settled: boolean;
};
const EMPTY_MESSAGES: Message[] = [];
@@ -195,18 +206,23 @@ function requestSessionMessages(
cachedAtRequest: Message[],
): InFlightMessageRequest {
const existing = inFlightMessageRequests.get(sessionId);
- if (existing?.readiness === readiness) return existing;
+ if (existing?.readiness === readiness && !existing.settled) return existing;
const requestParams = {
session_id: sessionId,
limit: INITIAL_FETCH_LIMIT,
sort: "desc" as const,
};
- const promise = client.request("message.list", requestParams, 10000);
- const entry = { readiness, promise, cachedAtRequest: [...cachedAtRequest] };
+ const promise = client.request(
+ "message.list",
+ requestParams,
+ SESSION_ENTRY_REQUEST_TIMEOUT_MS,
+ );
+ const entry = { readiness, promise, cachedAtRequest: [...cachedAtRequest], settled: false };
inFlightMessageRequests.set(sessionId, entry);
void promise.then(
() => {
+ entry.settled = true;
window.setTimeout(() => {
if (inFlightMessageRequests.get(sessionId) === entry) {
inFlightMessageRequests.delete(sessionId);
@@ -214,6 +230,7 @@ function requestSessionMessages(
}, 0);
},
() => {
+ entry.settled = true;
window.setTimeout(() => {
if (inFlightMessageRequests.get(sessionId) === entry) {
inFlightMessageRequests.delete(sessionId);
@@ -225,7 +242,7 @@ function requestSessionMessages(
}
/** Fetch latest messages via WS and merge with any that arrived via live notifications. */
-async function fetchAndStoreMessages(
+async function fetchAndStoreMessagesAttempt(
sessionId: string,
store: ReturnType,
isActive?: () => boolean,
@@ -291,6 +308,50 @@ async function fetchAndStoreMessages(
// and message content from the return, so `merged` is equivalent.
return merged;
}
+
+function waitForHistoryRetry(isActive?: () => boolean): Promise {
+ return new Promise((resolve) => {
+ const timer = window.setTimeout(
+ () => resolve(!isActive || isActive()),
+ SESSION_ENTRY_RETRY_DELAY_MS,
+ );
+ if (isActive && !isActive()) {
+ window.clearTimeout(timer);
+ resolve(false);
+ }
+ });
+}
+
+/** Fetch history with one bounded retry for a timed-out message.list request. */
+// eslint-disable-next-line max-params -- session hydration needs its active and generation guards.
+async function fetchAndStoreMessages(
+ sessionId: string,
+ store: ReturnType,
+ isActive?: () => boolean,
+ hydrationRef?: SessionHydrationRef,
+ hydrationKey?: string,
+ onRetry?: () => void,
+): Promise {
+ 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 [];
+}
/**
* When the initial fetch window contains no user/agent message rows (common
* when the latest turn produced hundreds of tool calls), the chat would render
@@ -304,10 +365,13 @@ function useTerminalStateFetch(
taskSessionId: string | null,
taskSessionState: TaskSessionState | null,
hasAgentMessage: boolean,
+ sessionFetchGenerationRef: MutableRefObject,
refs: {
store: ReturnType;
setIsLoading: (v: boolean) => void;
setIsWaitingForInitialMessages: (v: boolean) => void;
+ setHistoryStatus: (v: MessageHistoryStatus) => void;
+ setHistoryError: (v: unknown) => void;
initialFetchStartRef: MutableRefObject;
lastFetchedSessionIdRef: MutableRefObject;
},
@@ -315,20 +379,36 @@ function useTerminalStateFetch(
const lastFetchStateKeyRef = useRef(null);
const connectionStatus = useAppStore((state) => state.connection.status);
useEffect(() => {
- if (!taskSessionId || connectionStatus !== "connected") return;
- if (!taskSessionState || hasAgentMessage) return;
+ let active = true;
+ const generation = sessionFetchGenerationRef.current;
+ const isActive = () => active && sessionFetchGenerationRef.current === generation;
+ const deactivate = () => {
+ active = false;
+ };
+ if (!taskSessionId || connectionStatus !== "connected") return deactivate;
+ if (!taskSessionState || hasAgentMessage) return deactivate;
const terminalStates = new Set(["WAITING_FOR_INPUT", "COMPLETED", "FAILED"]);
- if (!terminalStates.has(taskSessionState)) return;
+ if (!terminalStates.has(taskSessionState)) return deactivate;
const key = `${taskSessionId}:${taskSessionState}`;
- if (lastFetchStateKeyRef.current === key) return;
+ if (lastFetchStateKeyRef.current === key) return deactivate;
lastFetchStateKeyRef.current = key;
void doFetchMessages({
taskSessionId,
...refs,
fetchAndStoreMessages,
+ isActive,
+ canFinalizeLoading: isActive,
onError: (error) => console.error("Failed to fetch messages after state change:", error),
});
- }, [taskSessionId, taskSessionState, hasAgentMessage, connectionStatus, refs]);
+ return deactivate;
+ }, [
+ taskSessionId,
+ taskSessionState,
+ hasAgentMessage,
+ connectionStatus,
+ refs,
+ sessionFetchGenerationRef,
+ ]);
}
// Silent WS disconnects (NAT timeout, laptop sleep, suspended tab) leave
@@ -338,10 +418,14 @@ function useTerminalStateFetch(
export function useVisibilityBackfill(
taskSessionId: string | null,
store: ReturnType,
+ sessionFetchGenerationRef?: MutableRefObject,
) {
useForegroundRefresh(
() => {
if (!taskSessionId) return;
+ const generation = sessionFetchGenerationRef?.current;
+ const isActive = () =>
+ generation === undefined || sessionFetchGenerationRef?.current === generation;
const visibilityState = document.visibilityState;
const state = store.getState();
const existingCount = state.messages.bySession[taskSessionId]?.length ?? 0;
@@ -354,7 +438,7 @@ export function useVisibilityBackfill(
existingCount,
newestBefore,
});
- fetchAndStoreMessages(taskSessionId, store)
+ fetchAndStoreMessages(taskSessionId, store, isActive)
.then(() => {
const afterCount = store.getState().messages.bySession[taskSessionId]?.length ?? 0;
const newestAfter =
@@ -379,8 +463,10 @@ type SessionSubscriptionParams = {
taskSessionId: string | null;
connectionStatus: string;
store: ReturnType;
+ fetchRefs: ReturnType["refs"];
hydrationRef: SessionHydrationRef;
hydrationKey: string;
+ sessionFetchGenerationRef: MutableRefObject;
};
function useSessionSubscription({
@@ -388,8 +474,10 @@ function useSessionSubscription({
connectionStatus,
isSessionStartingOrUnknown,
store,
+ fetchRefs,
hydrationRef,
hydrationKey,
+ sessionFetchGenerationRef,
}: SessionSubscriptionParams & { isSessionStartingOrUnknown: boolean }) {
useEffect(() => {
debug("subscription: effect ran", {
@@ -412,6 +500,7 @@ function useSessionSubscription({
debug("subscription: subscribing", { sessionId: taskSessionId });
const subscription = client.subscribeSessionWithReady(taskSessionId);
let active = true;
+ const generation = sessionFetchGenerationRef.current;
// Re-fetch messages after the server acknowledges the subscription to
// close the gap between SSR (which may have run before the agent
@@ -419,13 +508,17 @@ function useSessionSubscription({
void subscription.ready
.then(() => {
if (!active) return;
- return fetchAndStoreMessages(
+ const isCurrentGeneration = () =>
+ active && sessionFetchGenerationRef.current === generation;
+ return doFetchMessages({
taskSessionId,
- store,
- () => active,
+ ...fetchRefs,
+ fetchAndStoreMessages,
+ isActive: isCurrentGeneration,
+ canFinalizeLoading: isCurrentGeneration,
hydrationRef,
hydrationKey,
- );
+ });
})
.catch(() => {});
@@ -438,9 +531,11 @@ function useSessionSubscription({
taskSessionId,
connectionStatus,
store,
+ fetchRefs,
isSessionStartingOrUnknown,
hydrationRef,
hydrationKey,
+ sessionFetchGenerationRef,
]);
}
@@ -458,6 +553,7 @@ function useResyncOnTurnSettle(
taskSessionState: TaskSessionState | null,
connectionStatus: string,
store: ReturnType,
+ sessionFetchGenerationRef: MutableRefObject,
) {
const prevRef = useRef<{ sessionId: string | null; state: TaskSessionState | null }>({
sessionId: null,
@@ -474,24 +570,35 @@ function useResyncOnTurnSettle(
prev: prevState,
next: taskSessionState,
});
- fetchAndStoreMessages(taskSessionId, store).catch(() => {});
- }, [taskSessionId, taskSessionState, connectionStatus, store]);
+ const generation = sessionFetchGenerationRef.current;
+ fetchAndStoreMessages(
+ taskSessionId,
+ store,
+ () => sessionFetchGenerationRef.current === generation,
+ ).catch(() => {});
+ }, [taskSessionId, taskSessionState, connectionStatus, store, sessionFetchGenerationRef]);
}
function useRunningMessageBackfill(
taskSessionId: string | null,
shouldBackfill: boolean,
store: ReturnType,
+ sessionFetchGenerationRef: MutableRefObject,
) {
useEffect(() => {
if (!taskSessionId || !shouldBackfill) return;
+ const generation = sessionFetchGenerationRef.current;
let inFlight = false;
const sync = () => {
if (inFlight) return;
inFlight = true;
debug("running backfill", { sessionId: taskSessionId });
- fetchAndStoreMessages(taskSessionId, store)
+ fetchAndStoreMessages(
+ taskSessionId,
+ store,
+ () => sessionFetchGenerationRef.current === generation,
+ )
.catch((err) => {
debug("running backfill failed", { sessionId: taskSessionId, err });
})
@@ -505,7 +612,7 @@ function useRunningMessageBackfill(
window.clearTimeout(initial);
window.clearInterval(interval);
};
- }, [taskSessionId, shouldBackfill, store]);
+ }, [taskSessionId, shouldBackfill, store, sessionFetchGenerationRef]);
}
function useSessionMessageInputs(taskSessionId: string | null) {
@@ -538,8 +645,10 @@ function useSessionLifecycleSubscriptions(
activeTurnId,
messages,
store,
+ fetchRefs,
hydrationRef,
hydrationKey,
+ sessionFetchGenerationRef,
} = params;
// Bool flips exactly once when a freshly-adopted session leaves STARTING,
// so the subscription effect re-runs then (covering the backend race where
@@ -556,15 +665,23 @@ function useSessionLifecycleSubscriptions(
connectionStatus,
isSessionStartingOrUnknown,
store,
+ fetchRefs,
hydrationRef,
hydrationKey,
+ sessionFetchGenerationRef,
});
useUnknownSessionSubscriptionRetryEffect({
taskSessionId,
connectionStatus,
retryToken: unknownSessionRetryToken,
});
- useResyncOnTurnSettle(taskSessionId, taskSessionState, connectionStatus, store);
+ useResyncOnTurnSettle(
+ taskSessionId,
+ taskSessionState,
+ connectionStatus,
+ store,
+ sessionFetchGenerationRef,
+ );
useRunningMessageBackfill(
taskSessionId,
shouldRunMessageBackfill({
@@ -574,25 +691,30 @@ function useSessionLifecycleSubscriptions(
messages,
}),
store,
+ sessionFetchGenerationRef,
);
}
type SessionEntryFetchParams = {
taskSessionId: string | null;
connectionStatus: string;
messagesLength: number;
+ historyInitialized: boolean;
store: ReturnType;
prevSessionIdRef: MutableRefObject;
fetchState: ReturnType;
hydrationRef: SessionHydrationRef;
hydrationKey: string;
+ sessionFetchGenerationRef: MutableRefObject;
};
function useInitialMessagesWait(params: SessionEntryFetchParams): void {
- const { taskSessionId, messagesLength, fetchState } = params;
+ const { taskSessionId, messagesLength, historyInitialized, fetchState } = params;
const {
initialFetchStartRef,
lastFetchedSessionIdRef,
setIsWaitingForInitialMessages,
setIsCachedHistoryRefreshPending,
+ setHistoryStatus,
+ setHistoryError,
} = fetchState;
useEffect(() => {
if (!taskSessionId) {
@@ -600,10 +722,16 @@ function useInitialMessagesWait(params: SessionEntryFetchParams): void {
lastFetchedSessionIdRef.current = null;
setIsWaitingForInitialMessages(false);
setIsCachedHistoryRefreshPending(false);
+ setHistoryStatus("ready");
+ setHistoryError(null);
return;
}
if (messagesLength > 0) {
setIsWaitingForInitialMessages(false);
+ if (historyInitialized) {
+ setHistoryStatus("ready");
+ setHistoryError(null);
+ }
return;
}
if (initialFetchStartRef.current === null) {
@@ -617,6 +745,9 @@ function useInitialMessagesWait(params: SessionEntryFetchParams): void {
lastFetchedSessionIdRef,
setIsWaitingForInitialMessages,
setIsCachedHistoryRefreshPending,
+ setHistoryStatus,
+ setHistoryError,
+ historyInitialized,
]);
}
function useSessionEntryMessageFetch(params: SessionEntryFetchParams): void {
@@ -624,11 +755,13 @@ function useSessionEntryMessageFetch(params: SessionEntryFetchParams): void {
taskSessionId,
connectionStatus,
messagesLength,
+ historyInitialized,
store,
prevSessionIdRef,
fetchState,
hydrationRef,
hydrationKey,
+ sessionFetchGenerationRef,
} = params;
const {
lastFetchedSessionIdRef,
@@ -639,6 +772,9 @@ function useSessionEntryMessageFetch(params: SessionEntryFetchParams): void {
} = fetchState;
useEffect(() => {
let active = true;
+ const generation = sessionFetchGenerationRef.current;
+ const isCurrentGeneration = () => sessionFetchGenerationRef.current === generation;
+ const isActive = () => active && isCurrentGeneration();
const deactivate = () => {
active = false;
};
@@ -655,6 +791,7 @@ function useSessionEntryMessageFetch(params: SessionEntryFetchParams): void {
if (messagesLength > 0 && !sessionChanged && !isFreshMount) {
lastFetchedSessionIdRef.current = taskSessionId;
setIsWaitingForInitialMessages(false);
+ if (historyInitialized) fetchRefs.setHistoryStatus("ready");
return deactivate;
}
if (isFreshMount && messagesLength > 0) {
@@ -662,10 +799,19 @@ function useSessionEntryMessageFetch(params: SessionEntryFetchParams): void {
setIsWaitingForInitialMessages(false);
setIsCachedHistoryRefreshPending(true);
const refreshGeneration = ++cachedRefreshGenerationRef.current;
- void fetchAndStoreMessages(taskSessionId, store, () => active, hydrationRef, hydrationKey)
+ void doFetchMessages({
+ taskSessionId,
+ ...fetchRefs,
+ setIsWaitingForInitialMessages: () => {},
+ fetchAndStoreMessages,
+ isActive: isCurrentGeneration,
+ canFinalizeLoading: isCurrentGeneration,
+ hydrationRef,
+ hydrationKey,
+ })
.catch(() => {})
.finally(() => {
- if (cachedRefreshGenerationRef.current === refreshGeneration) {
+ if (isCurrentGeneration() && cachedRefreshGenerationRef.current === refreshGeneration) {
setIsCachedHistoryRefreshPending(false);
}
});
@@ -676,7 +822,8 @@ function useSessionEntryMessageFetch(params: SessionEntryFetchParams): void {
taskSessionId,
...fetchRefs,
fetchAndStoreMessages,
- isActive: () => active,
+ isActive,
+ canFinalizeLoading: isActive,
hydrationRef,
hydrationKey,
});
@@ -685,6 +832,7 @@ function useSessionEntryMessageFetch(params: SessionEntryFetchParams): void {
taskSessionId,
connectionStatus,
messagesLength,
+ historyInitialized,
store,
prevSessionIdRef,
lastFetchedSessionIdRef,
@@ -693,8 +841,98 @@ function useSessionEntryMessageFetch(params: SessionEntryFetchParams): void {
fetchRefs,
hydrationRef,
hydrationKey,
+ sessionFetchGenerationRef,
]);
}
+
+function useSessionHistoryRecoveryState({
+ taskSessionId,
+ connectionStatus,
+ messages,
+ messagesLoading,
+ isWaitingForInitialMessages,
+ messagesMetaLoading,
+ fetchState,
+ hydrationRef,
+ hydrationKey,
+}: {
+ taskSessionId: string | null;
+ connectionStatus: string;
+ messages: Message[];
+ messagesLoading: boolean;
+ isWaitingForInitialMessages: boolean;
+ messagesMetaLoading: boolean;
+ fetchState: ReturnType;
+ hydrationRef: SessionHydrationRef;
+ hydrationKey: string;
+}) {
+ const activeSessionIdRef = useRef(taskSessionId);
+ const historySessionIdRef = useRef(taskSessionId);
+ const previousConnectionStatusRef = useRef(connectionStatus);
+ const sessionFetchGenerationRef = useRef(0);
+ const { refs: fetchRefs } = fetchState;
+
+ useLayoutEffect(() => {
+ sessionFetchGenerationRef.current += 1;
+ activeSessionIdRef.current = taskSessionId;
+ const sessionChanged = historySessionIdRef.current !== taskSessionId;
+ const connectionChanged = previousConnectionStatusRef.current !== connectionStatus;
+ previousConnectionStatusRef.current = connectionStatus;
+ if (sessionChanged || connectionChanged) fetchRefs.setIsLoading(false);
+ if (sessionChanged) {
+ historySessionIdRef.current = taskSessionId;
+ fetchRefs.setHistoryStatus(taskSessionId ? "loading" : "ready");
+ fetchRefs.setHistoryError(null);
+ }
+ return () => {
+ sessionFetchGenerationRef.current += 1;
+ };
+ }, [connectionStatus, fetchRefs, taskSessionId]);
+
+ const retryHistory = useCallback(() => {
+ const retrySessionId = taskSessionId;
+ if (
+ !retrySessionId ||
+ connectionStatus !== "connected" ||
+ messagesLoading ||
+ isWaitingForInitialMessages ||
+ messagesMetaLoading
+ ) {
+ return;
+ }
+ const generation = sessionFetchGenerationRef.current;
+ fetchState.lastFetchedSessionIdRef.current = null;
+ fetchState.cachedRefreshGenerationRef.current += 1;
+ hydrationRef.current = null;
+ const isCurrentGeneration = () =>
+ activeSessionIdRef.current === retrySessionId &&
+ sessionFetchGenerationRef.current === generation;
+ void doFetchMessages({
+ taskSessionId: retrySessionId,
+ ...fetchRefs,
+ setIsWaitingForInitialMessages:
+ messages.length > 0 ? () => {} : fetchRefs.setIsWaitingForInitialMessages,
+ fetchAndStoreMessages,
+ isActive: isCurrentGeneration,
+ canFinalizeLoading: isCurrentGeneration,
+ hydrationRef,
+ hydrationKey,
+ });
+ }, [
+ connectionStatus,
+ fetchRefs,
+ hydrationKey,
+ hydrationRef,
+ isWaitingForInitialMessages,
+ messages.length,
+ messagesLoading,
+ messagesMetaLoading,
+ taskSessionId,
+ ]);
+
+ return { retryHistory, sessionFetchGenerationRef };
+}
+
export function useSessionMessages(taskSessionId: string | null): UseSessionMessagesReturn {
const store = useAppStoreApi();
const { messages, messagesMeta, taskSessionState, activeTurnId, connectionStatus } =
@@ -714,8 +952,21 @@ export function useSessionMessages(taskSessionId: string | null): UseSessionMess
isLoading,
isWaitingForInitialMessages,
isCachedHistoryRefreshPending,
+ historyStatus,
+ historyError,
refs: fetchRefs,
} = fetchState;
+ const { retryHistory, sessionFetchGenerationRef } = useSessionHistoryRecoveryState({
+ taskSessionId,
+ connectionStatus,
+ messages,
+ messagesLoading: isLoading,
+ isWaitingForInitialMessages,
+ messagesMetaLoading: messagesMeta.isLoading,
+ fetchState,
+ hydrationRef,
+ hydrationKey,
+ });
useSessionLifecycleSubscriptions({
taskSessionId,
taskSessionState,
@@ -723,23 +974,33 @@ export function useSessionMessages(taskSessionId: string | null): UseSessionMess
activeTurnId,
messages,
store,
+ fetchRefs,
hydrationRef,
hydrationKey,
+ sessionFetchGenerationRef,
});
const entryFetchParams = {
taskSessionId,
connectionStatus,
messagesLength: messages.length,
+ historyInitialized: messagesMeta.historyInitialized,
store,
prevSessionIdRef,
fetchState,
hydrationRef,
hydrationKey,
+ sessionFetchGenerationRef,
};
useInitialMessagesWait(entryFetchParams);
useSessionEntryMessageFetch(entryFetchParams);
- useVisibilityBackfill(taskSessionId, store);
- useTerminalStateFetch(taskSessionId, taskSessionState, hasAgentMessage, fetchRefs);
+ useVisibilityBackfill(taskSessionId, store, sessionFetchGenerationRef);
+ useTerminalStateFetch(
+ taskSessionId,
+ taskSessionState,
+ hasAgentMessage,
+ sessionFetchGenerationRef,
+ fetchRefs,
+ );
return {
isLoading: isLoading || isWaitingForInitialMessages || messagesMeta.isLoading,
@@ -754,5 +1015,8 @@ export function useSessionMessages(taskSessionId: string | null): UseSessionMess
historyInitialized: messagesMeta.historyInitialized,
hasMore: messagesMeta.hasMore,
oldestCursor: messagesMeta.oldestCursor,
+ historyStatus,
+ historyError,
+ retryHistory,
};
}
diff --git a/apps/web/hooks/domains/session/use-session-recovery-feedback.ts b/apps/web/hooks/domains/session/use-session-recovery-feedback.ts
index 4b57cf1364..6d20e2b5de 100644
--- a/apps/web/hooks/domains/session/use-session-recovery-feedback.ts
+++ b/apps/web/hooks/domains/session/use-session-recovery-feedback.ts
@@ -5,6 +5,7 @@ type RecoveryResumptionState = "resumed" | "running";
type RecoveryFeedbackSetters = {
setError: (value: string | null) => void;
setNotice?: (value: string | null) => void;
+ setRecoveryFailure?: (value: null) => void;
setResumptionState: (value: RecoveryResumptionState) => void;
};
@@ -20,7 +21,7 @@ export function useSessionRecoveryFeedback(
notice: string | null,
setters: RecoveryFeedbackSetters,
): void {
- const { setError, setNotice, setResumptionState } = setters;
+ const { setError, setNotice, setRecoveryFailure, setResumptionState } = setters;
const lastObservedSessionRef = useRef<{ id: string | null; state?: string }>({
id: sessionId,
state: sessionState,
@@ -33,6 +34,16 @@ export function useSessionRecoveryFeedback(
if (!stateChanged || !isActiveSessionState(sessionState)) return;
if (error !== null) setError(null);
if (notice !== null) setNotice?.(null);
+ setRecoveryFailure?.(null);
setResumptionState(sessionState === "RUNNING" ? "running" : "resumed");
- }, [error, notice, sessionId, sessionState, setError, setNotice, setResumptionState]);
+ }, [
+ error,
+ notice,
+ sessionId,
+ sessionState,
+ setError,
+ setNotice,
+ setRecoveryFailure,
+ setResumptionState,
+ ]);
}
diff --git a/apps/web/hooks/domains/session/use-session-resumption-operations.ts b/apps/web/hooks/domains/session/use-session-resumption-operations.ts
index 3d81928a83..9dc25cc8c1 100644
--- a/apps/web/hooks/domains/session/use-session-resumption-operations.ts
+++ b/apps/web/hooks/domains/session/use-session-resumption-operations.ts
@@ -62,6 +62,11 @@ export type SessionRecoveryFailure =
outcome: "recovery_failed";
resumeError: string;
restoreError: string;
+ }
+ | {
+ outcome: "status_unavailable";
+ kind: "timeout" | "request";
+ statusError: string;
};
export type ResumeStateSetter = {
diff --git a/apps/web/hooks/domains/session/use-session-resumption.archive.test.ts b/apps/web/hooks/domains/session/use-session-resumption.archive.test.ts
index 8e6b9b9b10..42c7b4ab93 100644
--- a/apps/web/hooks/domains/session/use-session-resumption.archive.test.ts
+++ b/apps/web/hooks/domains/session/use-session-resumption.archive.test.ts
@@ -108,10 +108,14 @@ describe("useSessionResumption archive lifecycle", () => {
await Promise.resolve();
});
- expect(mockRequest).toHaveBeenCalledWith("task.session.status", {
- task_id: TASK_ID,
- session_id: SESSION_ID,
- });
+ expect(mockRequest).toHaveBeenCalledWith(
+ "task.session.status",
+ {
+ task_id: TASK_ID,
+ session_id: SESSION_ID,
+ },
+ 10000,
+ );
});
it("does not start an agent after unarchive when automatic start is disabled", async () => {
diff --git a/apps/web/hooks/domains/session/use-session-resumption.test.ts b/apps/web/hooks/domains/session/use-session-resumption.test.ts
index 7a1206ea03..bee21c34ca 100644
--- a/apps/web/hooks/domains/session/use-session-resumption.test.ts
+++ b/apps/web/hooks/domains/session/use-session-resumption.test.ts
@@ -1,7 +1,7 @@
/* eslint-disable max-lines -- session resumption cases share one lifecycle harness. */
import { act, renderHook, waitFor } from "@testing-library/react";
-import { describe, it, expect, vi, beforeEach } from "vitest";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { buildRestoreWorkspaceRequest } from "@/lib/services/session-launch-helpers";
import { sanitizeWorkspaceRestorationDetails } from "@/lib/state/slices/session-runtime/workspace-restoration";
import { createAppStore } from "@/lib/state/store";
@@ -14,24 +14,42 @@ let mockConnectionStatus = "connected";
let mockPreventAutoStart = false;
let mockSessionItems: Record =
{};
+let mockWorkspaceRestorationEnabled = false;
+let mockWorkspaceStore = createAppStore();
+
+function getMockState() {
+ const workspaceState = mockWorkspaceStore.getState();
+ return {
+ connection: { status: mockConnectionStatus },
+ taskSessions: { items: mockSessionItems },
+ setTaskSession: mockSetTaskSession,
+ setSessionAgentctlStatus: mockSetSessionAgentctlStatus,
+ setResumeSkipped: mockSetResumeSkipped,
+ userSettings: { preventAutoStartAgentOnOpen: mockPreventAutoStart },
+ tasks: { resumeSkippedSessionIds: {} },
+ sessionAgentctl: workspaceState.sessionAgentctl,
+ ...(mockWorkspaceRestorationEnabled
+ ? {
+ environmentIdBySessionId: { [SESSION_ID]: "environment-1" },
+ workspaceRestoration: workspaceState.workspaceRestoration,
+ beginWorkspaceRestoration: workspaceState.beginWorkspaceRestoration,
+ completeWorkspaceRestoration: workspaceState.completeWorkspaceRestoration,
+ failWorkspaceRestoration: workspaceState.failWorkspaceRestoration,
+ clearWorkspaceRestoration: workspaceState.clearWorkspaceRestoration,
+ bumpWorkspaceFilesRefresh: workspaceState.bumpWorkspaceFilesRefresh,
+ }
+ : {}),
+ };
+}
vi.mock("@/lib/ws/connection", () => ({
getWebSocketClient: () => ({ request: mockRequest }),
}));
vi.mock("@/components/state-provider", () => ({
- useAppStore: (selector: (state: Record) => unknown) =>
- selector({
- connection: { status: mockConnectionStatus },
- taskSessions: { items: mockSessionItems },
- setTaskSession: mockSetTaskSession,
- setSessionAgentctlStatus: mockSetSessionAgentctlStatus,
- setResumeSkipped: mockSetResumeSkipped,
- userSettings: { preventAutoStartAgentOnOpen: mockPreventAutoStart },
- tasks: { resumeSkippedSessionIds: {} },
- }),
+ useAppStore: (selector: (state: Record) => unknown) => selector(getMockState()),
useAppStoreApi: () => ({
- getState: () => ({ taskSessions: { items: mockSessionItems } }),
+ getState: getMockState,
}),
}));
@@ -45,6 +63,13 @@ const LATER_AT = "2026-01-02T00:00:00.000Z";
const RESUME_TRANSPORT_ERROR = "Resume transport failed";
const WORKSPACE_RESTORE_ERROR = "Workspace restore failed";
+afterEach(() => {
+ vi.useRealTimers();
+ mockRequest.mockReset();
+ mockWorkspaceRestorationEnabled = false;
+ mockWorkspaceStore = createAppStore();
+});
+
import {
decideResumeAction,
resumeWithSilentFallback,
@@ -53,6 +78,7 @@ import {
type ResumptionState,
type SessionRecoveryFailure,
} from "./use-session-resumption";
+import { WebSocketRequestTimeoutError } from "@/lib/ws/client";
import { resumeViaLaunch } from "./use-session-resumption-operations";
import { buildGuardedSetters } from "./use-session-resumption-request-guard";
@@ -451,6 +477,7 @@ describe("resumeWithSilentFallback", () => {
});
});
+// eslint-disable-next-line max-lines-per-function -- status and recovery cases share one lifecycle harness.
describe("useSessionResumption", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -479,6 +506,169 @@ describe("useSessionResumption", () => {
expect(mockSetTaskSession).toHaveBeenCalledWith(expect.objectContaining({ updated_at: "" }));
});
+ it("retries a timed out status check before processing the response", async () => {
+ vi.useFakeTimers();
+ mockRequest
+ .mockRejectedValueOnce(new WebSocketRequestTimeoutError(STATUS_ACTION))
+ .mockResolvedValueOnce({
+ session_id: SESSION_ID,
+ task_id: TASK_ID,
+ state: "WAITING_FOR_INPUT",
+ is_agent_running: false,
+ is_resumable: false,
+ needs_resume: false,
+ });
+
+ renderHook(() => useSessionResumption(TASK_ID, SESSION_ID));
+ await act(async () => {
+ await Promise.resolve();
+ await Promise.resolve();
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(1000);
+ });
+
+ expect(mockRequest).toHaveBeenCalledTimes(2);
+ expect(mockRequest).toHaveBeenNthCalledWith(
+ 1,
+ STATUS_ACTION,
+ { task_id: TASK_ID, session_id: SESSION_ID },
+ 10000,
+ );
+ expect(mockSetTaskSession).toHaveBeenCalledWith(
+ expect.objectContaining({ state: "WAITING_FOR_INPUT" }),
+ );
+ });
+
+ it("surfaces a status timeout as status-unavailable feedback after the retry budget", async () => {
+ vi.useFakeTimers();
+ mockRequest
+ .mockRejectedValueOnce(new WebSocketRequestTimeoutError(STATUS_ACTION))
+ .mockRejectedValueOnce(new WebSocketRequestTimeoutError(STATUS_ACTION));
+
+ const { result } = renderHook(() => useSessionResumption(TASK_ID, SESSION_ID));
+ await act(async () => {
+ await Promise.resolve();
+ await Promise.resolve();
+ await vi.advanceTimersByTimeAsync(1000);
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ expect(result.current.resumptionState).toBe("error");
+ expect(result.current.error).toBeNull();
+ expect(result.current.recoveryFailure).toEqual({
+ outcome: "status_unavailable",
+ kind: "timeout",
+ statusError: "WebSocket request timed out: task.session.status",
+ });
+ expect(mockRequest).toHaveBeenCalledTimes(2);
+
+ mockRequest.mockResolvedValueOnce({
+ session_id: SESSION_ID,
+ task_id: TASK_ID,
+ state: "RUNNING",
+ is_agent_running: true,
+ is_resumable: false,
+ needs_resume: false,
+ });
+ await act(async () => {
+ await result.current.retrySessionStatus();
+ });
+
+ expect(mockRequest).toHaveBeenCalledTimes(3);
+ expect(mockRequest).not.toHaveBeenCalledWith(
+ LAUNCH_ACTION,
+ expect.anything(),
+ expect.anything(),
+ );
+ expect(result.current.recoveryFailure).toBeNull();
+ expect(result.current.resumptionState).toBe("running");
+ });
+
+ it("keeps a workspace restore failure as launch feedback with a launch retry", async () => {
+ mockWorkspaceRestorationEnabled = true;
+ mockRequest
+ .mockResolvedValueOnce({
+ session_id: SESSION_ID,
+ task_id: TASK_ID,
+ state: "WAITING_FOR_INPUT",
+ is_agent_running: false,
+ is_resumable: false,
+ needs_resume: false,
+ needs_workspace_restore: true,
+ })
+ .mockRejectedValueOnce(new Error(WORKSPACE_RESTORE_ERROR));
+
+ const { result } = renderHook(() => useSessionResumption(TASK_ID, SESSION_ID));
+
+ await waitFor(() => expect(result.current.workspaceRestoration.status).toBe("error"));
+ expect(result.current.workspaceRestoration.attempt?.details).toBe(WORKSPACE_RESTORE_ERROR);
+ expect(result.current.resumptionState).toBe("error");
+ expect(result.current.error).toBeNull();
+ expect(result.current.recoveryFailure).toBeNull();
+ expect(mockRequest).toHaveBeenNthCalledWith(
+ 2,
+ LAUNCH_ACTION,
+ expect.objectContaining({ intent: "restore_workspace" }),
+ expect.any(Number),
+ );
+
+ mockRequest.mockResolvedValueOnce({
+ success: true,
+ task_id: TASK_ID,
+ session_id: SESSION_ID,
+ state: "STARTING",
+ });
+ await act(async () => {
+ await result.current.resumeSession();
+ });
+
+ expect(mockRequest).toHaveBeenNthCalledWith(
+ 3,
+ LAUNCH_ACTION,
+ expect.objectContaining({ intent: "resume" }),
+ expect.any(Number),
+ );
+ });
+
+ it("keeps a backend status error visible after a timeout retry without launching", async () => {
+ vi.useFakeTimers();
+ mockRequest
+ .mockRejectedValueOnce(new WebSocketRequestTimeoutError(STATUS_ACTION))
+ .mockRejectedValueOnce(new WebSocketRequestTimeoutError(STATUS_ACTION));
+
+ const { result } = renderHook(() => useSessionResumption(TASK_ID, SESSION_ID));
+ await act(async () => {
+ await Promise.resolve();
+ await Promise.resolve();
+ await vi.advanceTimersByTimeAsync(1000);
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ mockRequest.mockResolvedValueOnce({
+ session_id: SESSION_ID,
+ task_id: TASK_ID,
+ state: "WAITING_FOR_INPUT",
+ is_agent_running: false,
+ is_resumable: false,
+ needs_resume: false,
+ error: "session not found",
+ });
+ await act(async () => {
+ await result.current.retrySessionStatus();
+ });
+
+ expect(result.current.resumptionState).toBe("error");
+ expect(result.current.error).toBe("session not found");
+ expect(result.current.recoveryFailure).toBeNull();
+ expect(mockRequest.mock.calls.filter(([action]) => action === LAUNCH_ACTION)).toHaveLength(0);
+ });
+
it("refreshes the status and embedded editor capability after a successful resume", async () => {
mockRequest
.mockResolvedValueOnce({
@@ -511,10 +701,14 @@ describe("useSessionResumption", () => {
await waitFor(() => {
expect(result.current.sessionStatus?.capabilities?.embedded_vscode).toBe(true);
});
- expect(mockRequest).toHaveBeenLastCalledWith(STATUS_ACTION, {
- task_id: TASK_ID,
- session_id: SESSION_ID,
- });
+ expect(mockRequest).toHaveBeenLastCalledWith(
+ STATUS_ACTION,
+ {
+ task_id: TASK_ID,
+ session_id: SESSION_ID,
+ },
+ 10000,
+ );
});
it("clears stale feedback when an external recovery makes the live session active", async () => {
@@ -650,10 +844,14 @@ describe("useSessionResumption prevent-auto-start gate", () => {
renderHook(() => useSessionResumption(TASK_ID, SESSION_ID));
await waitFor(() => {
- expect(mockRequest).toHaveBeenCalledWith(STATUS_ACTION, {
- task_id: TASK_ID,
- session_id: SESSION_ID,
- });
+ expect(mockRequest).toHaveBeenCalledWith(
+ STATUS_ACTION,
+ {
+ task_id: TASK_ID,
+ session_id: SESSION_ID,
+ },
+ 10000,
+ );
});
// The skip branch must consult the live row (RUNNING) and refuse.
expect(mockSetResumeSkipped).not.toHaveBeenCalled();
@@ -690,10 +888,14 @@ describe("useSessionResumption monotonic terminal hydration", () => {
renderHook(() => useSessionResumption("t1", "s1"));
await waitFor(() => {
- expect(mockRequest).toHaveBeenCalledWith(STATUS_ACTION, {
- task_id: "t1",
- session_id: "s1",
- });
+ expect(mockRequest).toHaveBeenCalledWith(
+ STATUS_ACTION,
+ {
+ task_id: "t1",
+ session_id: "s1",
+ },
+ 10000,
+ );
});
// The stale COMPLETED must not overwrite the newer live FAILED.
expect(mockSetTaskSession).not.toHaveBeenCalledWith(
@@ -722,10 +924,14 @@ describe("useSessionResumption monotonic terminal hydration", () => {
renderHook(() => useSessionResumption("t1", "s1"));
await waitFor(() => {
- expect(mockRequest).toHaveBeenCalledWith(STATUS_ACTION, {
- task_id: "t1",
- session_id: "s1",
- });
+ expect(mockRequest).toHaveBeenCalledWith(
+ STATUS_ACTION,
+ {
+ task_id: "t1",
+ session_id: "s1",
+ },
+ 10000,
+ );
});
// The stale STARTING must not overwrite the newer live FAILED.
expect(mockSetTaskSession).not.toHaveBeenCalledWith(
@@ -781,10 +987,14 @@ describe("useSessionResumption monotonic terminal hydration", () => {
renderHook(() => useSessionResumption("t1", "s1"));
await waitFor(() => {
- expect(mockRequest).toHaveBeenCalledWith(STATUS_ACTION, {
- task_id: "t1",
- session_id: "s1",
- });
+ expect(mockRequest).toHaveBeenCalledWith(
+ STATUS_ACTION,
+ {
+ task_id: "t1",
+ session_id: "s1",
+ },
+ 10000,
+ );
});
// WAITING_FOR_INPUT means the agent is alive; a stale older response must
// not downgrade it to a stopped-looking state.
@@ -836,10 +1046,14 @@ describe("useSessionResumption completed-session admission", () => {
renderHook(() => useSessionResumption(TASK_ID, SESSION_ID));
await waitFor(() => {
- expect(mockRequest).toHaveBeenCalledWith(STATUS_ACTION, {
- task_id: TASK_ID,
- session_id: SESSION_ID,
- });
+ expect(mockRequest).toHaveBeenCalledWith(
+ STATUS_ACTION,
+ {
+ task_id: TASK_ID,
+ session_id: SESSION_ID,
+ },
+ 10000,
+ );
});
expect(mockRequest).not.toHaveBeenCalledWith(
LAUNCH_ACTION,
@@ -908,10 +1122,14 @@ describe("useSessionResumption stale-callback guard after navigation", () => {
);
await waitFor(() => {
- expect(mockRequest).toHaveBeenCalledWith(STATUS_ACTION, {
- task_id: TASK_ID,
- session_id: SESSION_ID,
- });
+ expect(mockRequest).toHaveBeenCalledWith(
+ STATUS_ACTION,
+ {
+ task_id: TASK_ID,
+ session_id: SESSION_ID,
+ },
+ 10000,
+ );
});
// Navigate to another session before the first status response resolves.
diff --git a/apps/web/hooks/domains/session/use-session-resumption.ts b/apps/web/hooks/domains/session/use-session-resumption.ts
index 6bbf3f690d..e68ee33aae 100644
--- a/apps/web/hooks/domains/session/use-session-resumption.ts
+++ b/apps/web/hooks/domains/session/use-session-resumption.ts
@@ -1,4 +1,11 @@
+/* eslint-disable max-lines -- session resumption coordinates one guarded lifecycle. */
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
+import {
+ isWebSocketRequestTimeoutError,
+ SESSION_ENTRY_REQUEST_TIMEOUT_MS,
+ SESSION_ENTRY_RETRY_DELAY_MS,
+} from "@/lib/ws/client";
import { getWebSocketClient } from "@/lib/ws/connection";
import { launchSession } from "@/lib/services/session-launch-service";
import {
@@ -67,6 +74,33 @@ type CheckAndResumeParams = {
};
const TERMINAL_STATES = new Set(["FAILED", "CANCELLED", "COMPLETED"]);
+const MAX_SESSION_STATUS_ATTEMPTS = 2;
+
+function sessionStatusFailure(
+ error: unknown,
+): Extract {
+ return {
+ outcome: "status_unavailable",
+ kind: isWebSocketRequestTimeoutError(error) ? "timeout" : "request",
+ statusError: error instanceof Error && error.message ? error.message : t("common:unknownError"),
+ };
+}
+
+/** Apply permanent outcomes returned inside an otherwise successful status response. */
+function applyStatusResponseOutcome(status: SessionStatus, setters: ResumeStateSetter): boolean {
+ if (status.error) {
+ setters.setRecoveryFailure?.(null);
+ setters.setResumptionState("error");
+ setters.setError(status.error);
+ setters.setNotice?.(null);
+ return true;
+ }
+ if (status.resume_reason === TASK_ARCHIVED_KIND) {
+ clearArchiveRecovery(setters);
+ return true;
+ }
+ return false;
+}
type LiveSessionLike = (SessionLike & { state?: string }) | null;
@@ -137,6 +171,42 @@ type RefreshSessionStatusParams = {
canContinue: () => boolean;
};
+function waitForSessionStatusRetry(canContinue: () => boolean): Promise {
+ return new Promise((resolve) => {
+ const timer = window.setTimeout(() => resolve(canContinue()), SESSION_ENTRY_RETRY_DELAY_MS);
+ if (!canContinue()) {
+ window.clearTimeout(timer);
+ resolve(false);
+ }
+ });
+}
+
+async function requestSessionStatusWithRetry({
+ client,
+ taskId,
+ sessionId,
+ canContinue,
+}: Omit<
+ RefreshSessionStatusParams,
+ "session" | "setSessionStatus" | "setters"
+>): Promise {
+ for (let attempt = 0; attempt < MAX_SESSION_STATUS_ATTEMPTS; attempt += 1) {
+ if (!canContinue()) return null;
+ try {
+ return await client.request(
+ "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;
+}
+
async function refreshSessionStatus({
client,
taskId,
@@ -145,14 +215,17 @@ async function refreshSessionStatus({
setSessionStatus,
setters,
canContinue,
-}: RefreshSessionStatusParams): Promise {
- if (!canContinue()) return;
+}: RefreshSessionStatusParams): Promise {
+ if (!canContinue()) return null;
try {
- const status = await client.request("task.session.status", {
- task_id: taskId,
- session_id: sessionId,
+ const status = await requestSessionStatusWithRetry({
+ client,
+ taskId,
+ sessionId,
+ canContinue,
});
- if (!canContinue()) return;
+ if (!status || !canContinue()) return null;
+ if (applyStatusResponseOutcome(status, setters)) return null;
setSessionStatus(status);
applyStatusToState(status, taskId, sessionId, session, setters);
// A status response confirming the agent is running must clear any
@@ -164,8 +237,17 @@ async function refreshSessionStatus({
if (status.is_agent_running && setters.setAgentctlReady) {
setters.setAgentctlReady(sessionId);
}
+ return status;
} catch (err) {
- console.error("[refreshSessionStatus] failed to refresh session status", { sessionId, err });
+ if (isTaskArchivedConflict(err) || !canContinue()) {
+ clearArchiveRecovery(setters);
+ return null;
+ }
+ setters.setResumptionState("error");
+ setters.setError(null);
+ setters.setNotice?.(null);
+ setters.setRecoveryFailure?.(sessionStatusFailure(err));
+ return null;
}
}
@@ -244,16 +326,7 @@ async function processResumeStatus({
preventAutoStart,
canContinue,
}: ProcessResumeStatusParams): Promise {
- if (status.error) {
- setters.setRecoveryFailure?.(null);
- setters.setResumptionState("error");
- setters.setError(status.error);
- return false;
- }
- if (status.resume_reason === TASK_ARCHIVED_KIND) {
- clearArchiveRecovery(setters);
- return false;
- }
+ if (applyStatusResponseOutcome(status, setters)) return false;
applyStatusToState(status, taskId, sessionId, session, setters);
if (status.is_agent_running && setters.setAgentctlReady) {
setters.setAgentctlReady(sessionId);
@@ -269,6 +342,7 @@ async function processResumeStatus({
});
}
+// eslint-disable-next-line complexity -- status, restore, archive, and stale-request outcomes share one guarded transition.
async function checkAndResume({
taskId,
sessionId,
@@ -286,12 +360,28 @@ async function checkAndResume({
setters.setError(null);
setters.setNotice?.(null);
setters.setRecoveryFailure?.(null);
+ let status: SessionStatus | null;
try {
- const status = await client.request("task.session.status", {
- task_id: taskId,
- session_id: sessionId,
+ status = await requestSessionStatusWithRetry({
+ client,
+ taskId,
+ sessionId,
+ canContinue,
});
- if (!canContinue()) return;
+ } catch (err) {
+ if (isTaskArchivedConflict(err) || !canContinue()) {
+ clearArchiveRecovery(setters);
+ return;
+ }
+ setters.setResumptionState("error");
+ setters.setError(null);
+ setters.setNotice?.(null);
+ setters.setRecoveryFailure?.(sessionStatusFailure(err));
+ return;
+ }
+ if (!status || !canContinue()) return;
+ if (applyStatusResponseOutcome(status, setters)) return;
+ try {
setSessionStatus(status);
const resumed = await processResumeStatus({
status,
@@ -319,9 +409,9 @@ async function checkAndResume({
return;
}
setters.setResumptionState("error");
+ setters.setRecoveryFailure?.(null);
setters.setError(err instanceof Error ? err.message : t("common:unknownError"));
setters.setNotice?.(null);
- setters.setRecoveryFailure?.(null);
}
}
@@ -335,6 +425,7 @@ interface UseSessionResumptionReturn {
worktreePath: string | null;
worktreeBranch: string | null;
resumeSession: () => Promise;
+ retrySessionStatus: () => Promise;
workspaceRestoration: WorkspaceRestorationResult;
}
@@ -347,6 +438,7 @@ type SessionResetAndCheckResult = {
sessionStatus: SessionStatus | null;
captureRequest: () => SessionRequestIdentity;
buildGuardedSettersFor: (capturedRequest: SessionRequestIdentity) => ResumeStateSetter;
+ retryStatus: () => Promise;
};
type ResetAndCheckParams = {
@@ -366,6 +458,7 @@ const getSessionRequestKey = (
) => JSON.stringify([taskId, sessionId, taskArchiveState]);
/** Extracted effects: reset state on session/task change, auto-check/resume, and remote retry. */
+// eslint-disable-next-line max-lines-per-function -- one hook owns the request identity and lifecycle effects.
function useSessionResetAndCheck({
taskId,
sessionId,
@@ -456,10 +549,14 @@ function useSessionResetAndCheck({
if (!client) return;
remoteStatusRetryCount.current += 1;
try {
- const nextStatus = await client.request("task.session.status", {
- task_id: taskId,
- session_id: sessionId,
- });
+ const nextStatus = await client.request(
+ "task.session.status",
+ {
+ task_id: taskId,
+ session_id: sessionId,
+ },
+ SESSION_ENTRY_REQUEST_TIMEOUT_MS,
+ );
if (isCurrentRequest(activeRequestRef.current, capturedRequest)) {
setSessionStatus({ requestKey: capturedRequest.key, status: nextStatus });
}
@@ -471,11 +568,47 @@ function useSessionResetAndCheck({
return () => window.clearTimeout(timer);
}, [taskId, sessionId, connectionStatus, sessionStatus, taskArchiveState]);
+ const retryStatus = useCallback(async () => {
+ if (!taskId || !sessionId || connectionStatus !== "connected" || taskArchiveState !== false) {
+ return;
+ }
+ const capturedRequest = activeRequestRef.current;
+ const guardedSetters = buildGuardedSetters(activeRequestRef, capturedRequest, setters);
+ const canContinue = () => isCurrentRequest(activeRequestRef.current, capturedRequest);
+ guardedSetters.setResumptionState("checking");
+ guardedSetters.setError(null);
+ guardedSetters.setNotice?.(null);
+ guardedSetters.setRecoveryFailure?.(null);
+ const client = getWebSocketClient();
+ if (!client) return;
+ const status = await refreshSessionStatus({
+ client,
+ taskId,
+ sessionId,
+ session,
+ setSessionStatus: (nextStatus) => {
+ if (canContinue()) {
+ setSessionStatus({ requestKey: capturedRequest.key, status: nextStatus });
+ }
+ },
+ setters: guardedSetters,
+ canContinue,
+ });
+ if (!status || !canContinue()) return;
+ guardedSetters.setError(null);
+ guardedSetters.setNotice?.(null);
+ guardedSetters.setRecoveryFailure?.(null);
+ guardedSetters.setResumptionState(
+ status.is_agent_running || status.state === "RUNNING" ? "running" : "idle",
+ );
+ }, [connectionStatus, session, sessionId, setters, taskArchiveState, taskId]);
+
return {
sessionStatus,
captureRequest: () => activeRequestRef.current,
buildGuardedSettersFor: (capturedRequest) =>
buildGuardedSetters(activeRequestRef, capturedRequest, setters),
+ retryStatus,
};
}
@@ -627,15 +760,16 @@ export function useSessionResumption(
useSessionRecoveryFeedback(sessionId, session?.state, error, notice, setters);
- const { sessionStatus, captureRequest, buildGuardedSettersFor } = useSessionResetAndCheck({
- taskId,
- sessionId,
- connectionStatus,
- session,
- setters,
- preventAutoStart: preventAutoStartAgentOnOpen,
- taskArchiveState,
- });
+ const { sessionStatus, captureRequest, buildGuardedSettersFor, retryStatus } =
+ useSessionResetAndCheck({
+ taskId,
+ sessionId,
+ connectionStatus,
+ session,
+ setters,
+ preventAutoStart: preventAutoStartAgentOnOpen,
+ taskArchiveState,
+ });
const resumeSession = useManualResumeSession({
taskId,
@@ -656,6 +790,7 @@ export function useSessionResumption(
worktreePath,
worktreeBranch,
resumeSession,
+ retrySessionStatus: retryStatus,
workspaceRestoration,
};
}
diff --git a/apps/web/lib/ws/client.test.ts b/apps/web/lib/ws/client.test.ts
index e4c30b981a..f70b8ab708 100644
--- a/apps/web/lib/ws/client.test.ts
+++ b/apps/web/lib/ws/client.test.ts
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { WebSocketClient, WebSocketRequestError } from "./client";
+import { WebSocketClient, WebSocketRequestError, WebSocketRequestTimeoutError } from "./client";
type SentRequest = {
id: string;
@@ -9,6 +9,9 @@ type SentRequest = {
payload: unknown;
};
+const SESSION_ID = "sess-1";
+const SESSION_SUBSCRIBE_ACTION = "session.subscribe";
+
class FakeWebSocket {
static readonly OPEN = 1;
static readonly CLOSED = 3;
@@ -66,7 +69,9 @@ function connectClient(options?: ConstructorParameters[2
}
function sessionSubscribeRequest(socket: FakeWebSocket, index = 0) {
- const request = socket.sent.filter((message) => message.action === "session.subscribe")[index];
+ const request = socket.sent.filter((message) => message.action === SESSION_SUBSCRIBE_ACTION)[
+ index
+ ];
if (!request) throw new Error("No session.subscribe request was sent");
return request;
}
@@ -89,10 +94,70 @@ afterEach(() => {
vi.useRealTimers();
});
+// eslint-disable-next-line max-lines-per-function -- the readiness suite covers the shared lifecycle contract.
describe("session subscription readiness", () => {
+ it("accepts a registration acknowledgement that arrives after seven seconds", async () => {
+ vi.useFakeTimers();
+ const { client, socket } = connectClient();
+ const subscription = client.subscribeSessionWithReady(SESSION_ID);
+
+ await vi.advanceTimersByTimeAsync(7000);
+ acknowledge(socket, sessionSubscribeRequest(socket));
+
+ await expect(subscription.ready).resolves.toBeUndefined();
+ subscription.unsubscribe();
+ });
+
+ it("recovers timed out registration without a visibility change", async () => {
+ vi.useFakeTimers();
+ const { client, socket } = connectClient();
+ const subscription = client.subscribeSessionWithReady(SESSION_ID);
+
+ await vi.advanceTimersByTimeAsync(10000);
+ expect(
+ socket.sent.filter((message) => message.action === SESSION_SUBSCRIBE_ACTION),
+ ).toHaveLength(1);
+
+ await vi.advanceTimersByTimeAsync(1000);
+ const retryRequest = sessionSubscribeRequest(socket, 1);
+ acknowledge(socket, retryRequest);
+
+ await expect(subscription.ready).resolves.toBeUndefined();
+ subscription.unsubscribe();
+ });
+
+ it("stops after the bounded registration retry budget", async () => {
+ vi.useFakeTimers();
+ const { client, socket } = connectClient();
+ const subscription = client.subscribeSessionWithReady(SESSION_ID);
+
+ await vi.advanceTimersByTimeAsync(21000);
+
+ await expect(subscription.ready).rejects.toBeInstanceOf(WebSocketRequestTimeoutError);
+ expect(
+ socket.sent.filter((message) => message.action === SESSION_SUBSCRIBE_ACTION),
+ ).toHaveLength(2);
+ subscription.unsubscribe();
+ });
+
+ it("cancels a scheduled registration retry when the last consumer unsubscribes", async () => {
+ vi.useFakeTimers();
+ const { client, socket } = connectClient();
+ const subscription = client.subscribeSessionWithReady(SESSION_ID);
+
+ await vi.advanceTimersByTimeAsync(10000);
+ subscription.unsubscribe();
+ await expect(subscription.ready).rejects.toThrow("Session subscription released");
+
+ await vi.advanceTimersByTimeAsync(1000);
+ expect(
+ socket.sent.filter((message) => message.action === SESSION_SUBSCRIBE_ACTION),
+ ).toHaveLength(1);
+ });
+
it("resolves only after the server acknowledges the registration", async () => {
const { client, socket } = connectClient();
- const subscription = client.subscribeSessionWithReady("sess-1");
+ const subscription = client.subscribeSessionWithReady(SESSION_ID);
const request = sessionSubscribeRequest(socket);
let ready = false;
@@ -111,11 +176,13 @@ describe("session subscription readiness", () => {
it("shares one in-flight acknowledgement between ref-counted consumers", async () => {
const { client, socket } = connectClient();
- const first = client.subscribeSessionWithReady("sess-1");
- const second = client.subscribeSessionWithReady("sess-1");
+ const first = client.subscribeSessionWithReady(SESSION_ID);
+ const second = client.subscribeSessionWithReady(SESSION_ID);
expect(second.ready).toBe(first.ready);
- expect(socket.sent.filter((message) => message.action === "session.subscribe")).toHaveLength(1);
+ expect(
+ socket.sent.filter((message) => message.action === SESSION_SUBSCRIBE_ACTION),
+ ).toHaveLength(1);
acknowledge(socket, sessionSubscribeRequest(socket));
await expect(second.ready).resolves.toBeUndefined();
@@ -126,7 +193,7 @@ describe("session subscription readiness", () => {
it("allows a failed registration to be retried with fresh readiness", async () => {
const { client, socket } = connectClient();
- const subscription = client.subscribeSessionWithReady("sess-1");
+ const subscription = client.subscribeSessionWithReady(SESSION_ID);
const firstRequest = sessionSubscribeRequest(socket);
socket.receive({
@@ -136,7 +203,7 @@ describe("session subscription readiness", () => {
});
await expect(subscription.ready).rejects.toThrow("session is not ready");
- const retry = client.resubscribeSession("sess-1");
+ const retry = client.resubscribeSession(SESSION_ID);
const retryRequest = sessionSubscribeRequest(socket, 1);
expect(retry).not.toBe(subscription.ready);
@@ -148,7 +215,7 @@ describe("session subscription readiness", () => {
it("tracks the re-registration after reconnect", async () => {
vi.useFakeTimers();
const { client, socket } = connectClient({ enabled: true, initialDelay: 0, maxAttempts: 1 });
- const initial = client.subscribeSessionWithReady("sess-1");
+ const initial = client.subscribeSessionWithReady(SESSION_ID);
acknowledge(socket, sessionSubscribeRequest(socket));
await expect(initial.ready).resolves.toBeUndefined();
@@ -157,9 +224,9 @@ describe("session subscription readiness", () => {
const reconnectedSocket = FakeWebSocket.latest();
reconnectedSocket.open();
- const reconnected = client.subscribeSessionWithReady("sess-1");
+ const reconnected = client.subscribeSessionWithReady(SESSION_ID);
const reconnectRequest = sessionSubscribeRequest(reconnectedSocket, 0);
- expect(reconnectRequest.payload).toEqual({ session_id: "sess-1" });
+ expect(reconnectRequest.payload).toEqual({ session_id: SESSION_ID });
expect(reconnected.ready).not.toBe(initial.ready);
acknowledge(reconnectedSocket, reconnectRequest);
@@ -255,12 +322,12 @@ describe("session subscription reconnect recovery", () => {
it("keeps queued hydration behind the reconnect subscription acknowledgement", async () => {
vi.useFakeTimers();
const { client, socket } = connectClient({ enabled: true, initialDelay: 0, maxAttempts: 1 });
- const initial = client.subscribeSessionWithReady("sess-1");
+ const initial = client.subscribeSessionWithReady(SESSION_ID);
acknowledge(socket, sessionSubscribeRequest(socket));
await expect(initial.ready).resolves.toBeUndefined();
socket.close();
- const reconnectReadiness = client.getSessionSubscriptionReadiness("sess-1");
+ const reconnectReadiness = client.getSessionSubscriptionReadiness(SESSION_ID);
let readinessResolved = false;
void reconnectReadiness.then(() => {
readinessResolved = true;
@@ -269,7 +336,7 @@ describe("session subscription reconnect recovery", () => {
id: "hydration-1",
type: "request",
action: "message.list",
- payload: { session_id: "sess-1" },
+ payload: { session_id: SESSION_ID },
});
await Promise.resolve();
expect(readinessResolved).toBe(false);
@@ -279,7 +346,7 @@ describe("session subscription reconnect recovery", () => {
reconnectedSocket.open();
expect(reconnectedSocket.sent.map((message) => message.action)).toEqual([
- "session.subscribe",
+ SESSION_SUBSCRIBE_ACTION,
"message.list",
]);
const reconnectRequest = sessionSubscribeRequest(reconnectedSocket);
@@ -296,7 +363,7 @@ describe("session subscription reconnect recovery", () => {
it("rejects an active readiness when reconnect recovery is disabled", async () => {
const { client, socket } = connectClient({ enabled: false });
- const subscription = client.subscribeSessionWithReady("sess-1");
+ const subscription = client.subscribeSessionWithReady(SESSION_ID);
socket.close();
await expect(subscription.ready).rejects.toThrow("WebSocket connection closed");
diff --git a/apps/web/lib/ws/client.ts b/apps/web/lib/ws/client.ts
index 56eb9b7589..edac279cb6 100644
--- a/apps/web/lib/ws/client.ts
+++ b/apps/web/lib/ws/client.ts
@@ -1,10 +1,21 @@
+/* eslint-disable max-lines -- the shared WebSocket client owns all transport lifecycle paths. */
+
import type { BackendMessageMap, BackendMessageType } from "@/lib/types/backend";
import type { ConnectionStatus } from "@/lib/types/connection";
import { generateUUID } from "@/lib/utils";
import { createDebugLogger, isDebug } from "@/lib/debug/log";
import { dispatchToPluginWsHandlers } from "@/lib/ws/plugin-bridge";
-import { toWebSocketRequestError } from "./request-error";
-export { WebSocketRequestError, type WebSocketRequestErrorDetails } from "./request-error";
+import {
+ isWebSocketRequestTimeoutError,
+ toWebSocketRequestError,
+ WebSocketRequestTimeoutError,
+} from "./request-error";
+export {
+ isWebSocketRequestTimeoutError,
+ WebSocketRequestError,
+ WebSocketRequestTimeoutError,
+ type WebSocketRequestErrorDetails,
+} from "./request-error";
const debugDispatch = createDebugLogger("ws:dispatch");
@@ -43,8 +54,14 @@ type SessionSubscriptionReadiness = {
reject: (reason: unknown) => void;
requestStarted: boolean;
settled: boolean;
+ attempt: number;
+ retryTimer: ReturnType | null;
};
+export const SESSION_ENTRY_REQUEST_TIMEOUT_MS = 10000;
+export const SESSION_ENTRY_RETRY_DELAY_MS = 1000;
+const MAX_SESSION_SUBSCRIPTION_ATTEMPTS = 2;
+
const DEFAULT_RECONNECT_OPTIONS: Required = {
enabled: true,
maxAttempts: 10,
@@ -178,7 +195,7 @@ export class WebSocketClient {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
this.pendingRequests.delete(id);
- reject(new Error(`WebSocket request timed out: ${action}`));
+ reject(new WebSocketRequestTimeoutError(action));
}, timeoutMs);
this.pendingRequests.set(id, {
resolve: resolve as (payload: unknown) => void,
@@ -587,6 +604,8 @@ export class WebSocketClient {
reject,
requestStarted: false,
settled: false,
+ attempt: 0,
+ retryTimer: null,
};
// subscribeSession() consumers do not await readiness, so handle failures
// while returning the original promise to readiness-aware consumers.
@@ -596,18 +615,37 @@ export class WebSocketClient {
}
private startSessionSubscription(sessionId: string, readiness: SessionSubscriptionReadiness) {
+ if (this.sessionSubscriptionReadiness.get(sessionId) !== readiness) return;
if (readiness.requestStarted) return;
readiness.requestStarted = true;
- void this.request("session.subscribe", { session_id: sessionId }).then(
+ 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.retryTimer = null;
readiness.settled = true;
readiness.resolve();
},
(error: unknown) => {
- if (this.sessionSubscriptionReadiness.get(sessionId) === readiness) {
- this.sessionSubscriptionReadiness.delete(sessionId);
+ 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);
},
@@ -618,6 +656,10 @@ export class WebSocketClient {
const readiness = this.sessionSubscriptionReadiness.get(sessionId);
if (!readiness) return;
this.sessionSubscriptionReadiness.delete(sessionId);
+ if (readiness.retryTimer) {
+ clearTimeout(readiness.retryTimer);
+ readiness.retryTimer = null;
+ }
if (!readiness.settled) {
readiness.settled = true;
readiness.reject(new Error("Session subscription released"));
@@ -628,6 +670,10 @@ export class WebSocketClient {
const readinessEntries = [...this.sessionSubscriptionReadiness.entries()];
this.sessionSubscriptionReadiness.clear();
for (const [, readiness] of readinessEntries) {
+ if (readiness.retryTimer) {
+ clearTimeout(readiness.retryTimer);
+ readiness.retryTimer = null;
+ }
if (readiness.settled) continue;
readiness.settled = true;
readiness.reject(error);
diff --git a/apps/web/lib/ws/request-error.ts b/apps/web/lib/ws/request-error.ts
index 12dcb11db3..ce744d36cb 100644
--- a/apps/web/lib/ws/request-error.ts
+++ b/apps/web/lib/ws/request-error.ts
@@ -1,5 +1,23 @@
export type WebSocketRequestErrorDetails = Record;
+/** Error returned when a WebSocket request does not receive a response in time. */
+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;
+}
+
/** Error returned when the backend rejects a request with a typed payload. */
export class WebSocketRequestError extends Error {
readonly code?: string;
diff --git a/apps/web/src/locales/en/task.json b/apps/web/src/locales/en/task.json
index 49c9ffc395..83b07e08d2 100644
--- a/apps/web/src/locales/en/task.json
+++ b/apps/web/src/locales/en/task.json
@@ -1973,6 +1973,11 @@
"sessionRecoveryDetails": "Recovery details",
"sessionRecoveryResumeAttempt": "Resume attempt",
"sessionRecoveryRestoreAttempt": "Workspace restore attempt",
+ "sessionHistoryRetrying": "Taking longer than usual. Retrying...",
+ "sessionHistoryUnavailable": "Conversation could not load.",
+ "sessionHistoryUnknownError": "The history request failed.",
+ "sessionStatusUnavailable": "Session status is unavailable.",
+ "sessionStatusUnavailableDetail": "The session remains available. Retry the status check.",
"sessionBootstrapRecoveryTitle": "Session startup needs attention",
"sessionBootstrapRecoverySummary": "The agent could not start. Retry resume or restore the workspace as read-only.",
"sessionBootstrapCauseAuthenticationRequired": "Agent authentication is required.",
diff --git a/apps/web/src/locales/pseudo/task.json b/apps/web/src/locales/pseudo/task.json
index fd34a21181..79f85e59f6 100644
--- a/apps/web/src/locales/pseudo/task.json
+++ b/apps/web/src/locales/pseudo/task.json
@@ -1973,6 +1973,11 @@
"sessionRecoveryDetails": "Ŕēćōvēŕŷ ďēţàĩĺś",
"sessionRecoveryResumeAttempt": "Ŕēśũḿē àţţēḿƥţ",
"sessionRecoveryRestoreAttempt": "Ŵōŕķśƥàćē ŕēśţōŕē àţţēḿƥţ",
+ "sessionHistoryRetrying": "Ţàķĩńĝ ĺōńĝēŕ ţĥàń ũśũàĺ. Ŕēţŕŷĩńĝ...",
+ "sessionHistoryUnavailable": "Ćōńvēŕśàţĩōń ćōũĺď ńōţ ĺōàď.",
+ "sessionHistoryUnknownError": "Ţĥē ĥĩśţōŕŷ ŕēqũēśţ ƒàĩĺēď.",
+ "sessionStatusUnavailable": "Śēśśĩōń śţàţũś ĩś ũńàvàĩĺàƀĺē.",
+ "sessionStatusUnavailableDetail": "Ţĥē śēśśĩōń ŕēḿàĩńś àvàĩĺàƀĺē. Ŕēţŕŷ ţĥē śţàţũś ćĥēćķ.",
"sessionBootstrapRecoveryTitle": "Śēśśĩōń śţàŕţũƥ ńēēďś àţţēńţĩōń",
"sessionBootstrapRecoverySummary": "Ţĥē àĝēńţ ćōũĺď ńōţ śţàŕţ. Ŕēţŕŷ ŕēśũḿē ōŕ ŕēśţōŕē ţĥē ŵōŕķśƥàćē àś ŕēàď-ōńĺŷ.",
"sessionBootstrapCauseAuthenticationRequired": "Àĝēńţ àũţĥēńţĩćàţĩōń ĩś ŕēqũĩŕēď.",
diff --git a/apps/web/src/locales/pt-pt/task.json b/apps/web/src/locales/pt-pt/task.json
index 36f51b6fb8..48e9b95330 100644
--- a/apps/web/src/locales/pt-pt/task.json
+++ b/apps/web/src/locales/pt-pt/task.json
@@ -1949,6 +1949,11 @@
"sessionRecoveryDetails": "Detalhes da recuperação",
"sessionRecoveryResumeAttempt": "Tentativa de retomar",
"sessionRecoveryRestoreAttempt": "Tentativa de restaurar o workspace",
+ "sessionHistoryRetrying": "Está a demorar mais do que o habitual. A tentar novamente...",
+ "sessionHistoryUnavailable": "Não foi possível carregar a conversa.",
+ "sessionHistoryUnknownError": "O pedido do histórico falhou.",
+ "sessionStatusUnavailable": "O estado da sessão não está disponível.",
+ "sessionStatusUnavailableDetail": "A sessão continua disponível. Tente verificar o estado novamente.",
"sessionBootstrapRecoveryTitle": "O arranque da sessão precisa de atenção",
"sessionBootstrapRecoverySummary": "Não foi possível iniciar o agente. Tente retomar ou restaurar o workspace em modo só de leitura.",
"sessionBootstrapCauseAuthenticationRequired": "É necessária autenticação do agente.",
diff --git a/apps/web/src/locales/zh-cn/task.json b/apps/web/src/locales/zh-cn/task.json
index 0c0182505a..29f70c2c79 100644
--- a/apps/web/src/locales/zh-cn/task.json
+++ b/apps/web/src/locales/zh-cn/task.json
@@ -1949,6 +1949,11 @@
"sessionRecoveryDetails": "恢复详情",
"sessionRecoveryResumeAttempt": "恢复尝试",
"sessionRecoveryRestoreAttempt": "工作区还原尝试",
+ "sessionHistoryRetrying": "耗时比平时长。正在重试…",
+ "sessionHistoryUnavailable": "无法加载对话。",
+ "sessionHistoryUnknownError": "历史记录请求失败。",
+ "sessionStatusUnavailable": "会话状态不可用。",
+ "sessionStatusUnavailableDetail": "会话仍可用。请重试状态检查。",
"sessionBootstrapRecoveryTitle": "会话启动需要处理",
"sessionBootstrapRecoverySummary": "智能体无法启动。请重试恢复,或将工作区还原为只读模式。",
"sessionBootstrapCauseAuthenticationRequired": "需要进行智能体身份验证。",
diff --git a/apps/web/src/locales/zh-hk/task.json b/apps/web/src/locales/zh-hk/task.json
index a10dd0ada0..5b7a32066f 100644
--- a/apps/web/src/locales/zh-hk/task.json
+++ b/apps/web/src/locales/zh-hk/task.json
@@ -1949,6 +1949,11 @@
"sessionRecoveryDetails": "恢復詳情",
"sessionRecoveryResumeAttempt": "恢復嘗試",
"sessionRecoveryRestoreAttempt": "工作區還原嘗試",
+ "sessionHistoryRetrying": "耗時比平時長。正在重試…",
+ "sessionHistoryUnavailable": "無法載入對話。",
+ "sessionHistoryUnknownError": "歷史記錄請求失敗。",
+ "sessionStatusUnavailable": "工作階段狀態不可用。",
+ "sessionStatusUnavailableDetail": "工作階段仍可用。請重試狀態檢查。",
"sessionBootstrapRecoveryTitle": "工作階段啓動需要處理",
"sessionBootstrapRecoverySummary": "代理程式無法啓動。請重試恢復,或將工作區還原為只讀模式。",
"sessionBootstrapCauseAuthenticationRequired": "需要進行代理程式身份驗證。",
diff --git a/apps/web/src/locales/zh-tw/task.json b/apps/web/src/locales/zh-tw/task.json
index d25367feec..1a1e0ea6f6 100644
--- a/apps/web/src/locales/zh-tw/task.json
+++ b/apps/web/src/locales/zh-tw/task.json
@@ -1949,6 +1949,11 @@
"sessionRecoveryDetails": "恢復詳情",
"sessionRecoveryResumeAttempt": "恢復嘗試",
"sessionRecoveryRestoreAttempt": "工作區還原嘗試",
+ "sessionHistoryRetrying": "耗時比平時長。正在重試…",
+ "sessionHistoryUnavailable": "無法載入對話。",
+ "sessionHistoryUnknownError": "歷史記錄請求失敗。",
+ "sessionStatusUnavailable": "工作階段狀態不可用。",
+ "sessionStatusUnavailableDetail": "工作階段仍可用。請重試狀態檢查。",
"sessionBootstrapRecoveryTitle": "工作階段啟動需要處理",
"sessionBootstrapRecoverySummary": "代理程式無法啟動。請重試恢復,或將工作區還原為只讀模式。",
"sessionBootstrapCauseAuthenticationRequired": "需要進行代理程式身分驗證。",
diff --git a/docs/plans/session-entry-recovery/plan.md b/docs/plans/session-entry-recovery/plan.md
new file mode 100644
index 0000000000..ac222e72f5
--- /dev/null
+++ b/docs/plans/session-entry-recovery/plan.md
@@ -0,0 +1,183 @@
+---
+created: 2026-09-12
+status: complete
+requirements:
+ - REQ-PLATFORM-SESSION-SUBSCRIPTION-RECOVERY-002
+ - REQ-PLATFORM-SESSION-SUBSCRIPTION-RECOVERY-001
+system_design:
+ - ../../specs/platform/system-design/session-subscription-recovery.md
+legacy_specs: []
+---
+
+# Implementation Plan: Recover delayed session entry
+
+## Overview
+
+Restore chat entry after temporary transport delays, then present accurate loading and recovery states.
+Platform owns this work because it owns subscription readiness and shared session recovery.
+The [requirement](../../specs/platform/requirements/session-subscription-recovery.md) extends the existing contract.
+The [system design](../../specs/platform/system-design/session-subscription-recovery.md) defines timing, ownership, and presentation.
+
+## Evidence and confirmed root cause
+
+On 2026-09-12, task `41227cde-bb02-4738-b9c0-1b776929d68e` received status and subscription responses after 6.9 seconds.
+The five-second client deadline had already discarded both requests.
+History waited on rejected subscription readiness and never sent its initial request.
+Foreground refresh later loaded 100 messages in 351 milliseconds.
+The status exception became a generic startup-failure banner; failed history became an empty invitation.
+The message-fetch catch also clears cached history, which compounds recovery failures.
+
+Backend registration occurred promptly. The downstream delivery bottleneck remains unconfirmed.
+This package repairs the demonstrated timeout, retry, and presentation defects.
+It does not claim a backend or network performance fix.
+
+## Scope
+
+### In scope
+
+- Bounded, shared subscription recovery and read-only status retries.
+- History retry with preserved cache and truthful initialization state.
+- Desktop, phone, and preview feedback with localized recovery controls.
+- Regression tests for timing, ownership, mutations, and rendered states.
+
+### Out of scope
+
+- Global timeout increases, automatic mutation retries, and server replay protocols.
+- Agent startup policy, archive admission, and unrelated error redesign.
+- Proxy tuning or a diagnosis of the original delivery bottleneck.
+
+## Technical approach
+
+Task 01 implements the design's client and hook state. It retains acknowledgement-before-history ordering.
+Task 02 connects those outcomes to the shared chat renderer and session feedback, then proves both viewports.
+Use typed timeout classification in `lib/ws/request-error.ts`; keep actual backend errors distinct.
+Keep registration ownership in `WebSocketClient` and mutation admission in existing session recovery code.
+New state belongs to session identity and connection generation, not a global pending boolean.
+
+## ASCII UI preview
+
+UI-01: Open existing task, current failure (desktop and phone).
+
+```text
+[!] Couldn't start a session
+ WebSocket request timed out: task.session.status
+ [Retry]
+
+ No messages yet. Start the conversation!
+```
+
+UI-02: Open existing task, proposed desktop chat region.
+
+```text
+Loading: (spinner) Loading conversation...
+Retrying: (spinner) Taking longer than usual. Retrying...
+Exhausted: Conversation could not load. [Retry] [Details v]
+Ready:
+```
+
+UI-03: Same task, proposed phone chat region.
+
+```text
++------------------------------------+
+| Conversation could not load. |
+| [ Retry ] [ Details v ] |
+| |
+| |
++------------------------------------+
+| |
++------------------------------------+
+```
+
+UI-04: Status-only exhaustion with a readable transcript.
+
+```text
+Session status is unavailable. [Retry] [Details v]
+
+```
+
+Details expands inline with wrapped technical text. Loading and retrying use a
+neutral status region. Neither uses a destructive alert or the empty invitation.
+Phone actions occupy their own row and have at least 44-pixel targets.
+Desktop actions use the normal 28-pixel size. The chat remains the scroll owner;
+existing composer placement and safe-area behavior remain unchanged.
+These hierarchy and state choices are required; wording and spacing are illustrative.
+All text is localized. Covers AC-002.4 through AC-002.6 and AC-002.8 through AC-002.10
+(where AC-002 denotes AC-PLATFORM-SESSION-SUBSCRIPTION-RECOVERY-002).
+
+## Tests
+
+- `lib/ws/client.test.ts`: seven-second acknowledgement, timeout retry, exhausted budget, shared consumers, last unsubscribe, and reconnect.
+- `hooks/domains/session/use-session-messages.test.ts`: automatic history recovery, no request before acknowledgement, preserved cache, empty success, and session-switch cancellation.
+- `hooks/domains/session/use-session-subscription-retry.test.ts`: unknown-session retry joins current registration without resetting its budget.
+- `hooks/domains/session/use-session-resumption.test.ts`: seven-second status success, bounded retry, immediate permanent rejection, and one mutation after success.
+- `components/task/chat/session-entry-feedback.test.tsx` (new): loading, unavailable, cached, confirmed-empty, and disclosure states.
+- Existing `components/task/ensure-session-error.test.tsx`: actual launch and profile errors retain their actions.
+
+Task 01 covers AC-002.1 through AC-002.7 and AC-002.10, plus AC-001.1 through AC-001.8.
+Task 02 covers all AC-002 criteria through rendering and E2E.
+Use fake timers for deterministic unit timing. Record the expected behavioral RED before changing production code.
+
+## E2E tests
+
+New files: `apps/web/e2e/tests/session/session-entry-recovery.spec.ts` (chromium)
+and `apps/web/e2e/tests/session/mobile-session-entry-recovery.spec.ts` (mobile-chrome).
+Seed an existing stopped session with known persisted messages and use a real WebSocket proxy.
+Delay selected subscription/status responses beyond five seconds with `injectLatency`.
+Verify automatic chat appearance without reload or tab visibility changes.
+Drop a first response to prove retry; drop every attempt to prove bounded exhaustion and explicit recovery.
+Assert request counts, no duplicate launch, no stale startup banner, and no false empty invitation.
+Also cover cached refresh failure, a successful empty history, and permanent authorization rejection.
+On phone, tap Retry and Details, measure targets, and check overflow at phone width and around the 768-pixel boundary.
+Reuse `e2e/helpers/session-capabilities.ts` for frame routing and `causal-waits.ts` for event waits.
+The new shared helper is `e2e/helpers/session-entry-recovery.ts`.
+
+## Work orders
+
+- [x] [Task 01: Recover entry requests](task-01-entry-recovery.md)
+- [x] [Task 02: Present conversation recovery](task-02-recovery-feedback.md)
+
+## Verification results
+
+Implementation complete. Design-package checks and implementation checks passed:
+
+- `python3 scripts/lint-spec-files.test.py`: 36 tests passed.
+- `python3 scripts/lint-spec-files.py --all`: passed.
+- `git diff --check -- docs/specs docs/plans/session-entry-recovery docs/plans/session-subscription-recovery`: passed.
+- `cd apps/web && pnpm exec vitest run lib/ws/client.test.ts hooks/domains/session/use-session-messages.test.ts hooks/domains/session/use-session-subscription-retry.test.ts hooks/domains/session/use-session-resumption.test.ts hooks/domains/session/use-session-resumption.archive.test.ts hooks/domains/session/use-session-message-fetch.test.ts components/task/chat/session-entry-feedback.test.tsx components/task/ensure-session-error.test.tsx components/task/chat/message-list-shared.test.tsx`: 170 tests passed across 9 files.
+- `cd apps/web && pnpm run typecheck`: passed.
+- The zero-warning ESLint commands listed in both work-order Results: passed.
+- `cd apps/web && pnpm run i18n:zh-hant`: passed; generated the Traditional Chinese pair.
+- `cd apps/web && pnpm run i18n:check`: passed; all five complete catalogs and pseudo locale are synchronized.
+- `cd apps/web && pnpm run i18n:ratchet`: passed; no new untranslated copy.
+- `cd apps/web && pnpm e2e:run --project chromium tests/session/session-entry-recovery.spec.ts tests/session/session-resume-recovery.spec.ts`: 5 passed, including the production web build.
+- `cd apps/web && pnpm e2e:run --project mobile-chrome tests/session/mobile-session-entry-recovery.spec.ts tests/session/mobile-session-resume-recovery.spec.ts`: 2 passed, including phone touch geometry and overflow checks.
+- `git diff --check`: passed.
+- Public documentation impact: no public docs change is required for this recovery behavior.
+
+Review remediation validation:
+
+- Status restoration and backend error-payload regressions passed, including the assertion that a
+ permanent status error does not launch a session.
+- Deferred history generation regressions passed for terminal fetch, session A to B navigation,
+ session A to B to A re-entry, unmount-safe finalization, and fresh manual Retry.
+- The focused recovery suite passed 170 tests across 9 files after review remediation. It
+ includes truthful workspace-restore failures, permanent status error payloads, terminal and
+ manual history refreshes, A-to-B and A-to-B-to-A deferred races, stale loading finalization,
+ and the single live status retry path. Typecheck, zero-warning ESLint, and formatting checks
+ passed.
+- Follow-up CI remediation passed the deferred cached-refresh rerender regression, the desktop
+ unread-divider suite (5 tests), and the mobile unread-divider suite (3 tests). Cached refresh
+ completion now remains valid across same-session message rerenders and is still invalidated by
+ navigation or unmount.
+
+## Risks
+
+- Retries can duplicate launches if they wrap `processResumeStatus` instead of its status read.
+- Per-consumer retry loops can amplify traffic or replace another consumer's readiness promise.
+- Clearing loading flags before recovery finishes can reintroduce the false empty state.
+- A longer entry deadline improves tolerance but does not remove the original delivery delay.
+
+## Related package
+
+[Original ordering repair](../session-subscription-recovery/plan.md) remains implemented.
+Its historical checks do not count as validation of this package.
diff --git a/docs/plans/session-entry-recovery/task-01-entry-recovery.md b/docs/plans/session-entry-recovery/task-01-entry-recovery.md
new file mode 100644
index 0000000000..c74d1d5273
--- /dev/null
+++ b/docs/plans/session-entry-recovery/task-01-entry-recovery.md
@@ -0,0 +1,135 @@
+---
+id: "01-entry-recovery"
+title: "Recover entry requests"
+status: done
+wave: 1
+depends_on: []
+plan: "plan.md"
+requirements:
+ - REQ-PLATFORM-SESSION-SUBSCRIPTION-RECOVERY-002
+ - REQ-PLATFORM-SESSION-SUBSCRIPTION-RECOVERY-001
+acceptance_criteria:
+ - AC-PLATFORM-SESSION-SUBSCRIPTION-RECOVERY-002.1
+ - AC-PLATFORM-SESSION-SUBSCRIPTION-RECOVERY-002.2
+ - AC-PLATFORM-SESSION-SUBSCRIPTION-RECOVERY-002.3
+ - AC-PLATFORM-SESSION-SUBSCRIPTION-RECOVERY-002.4
+ - AC-PLATFORM-SESSION-SUBSCRIPTION-RECOVERY-002.5
+ - AC-PLATFORM-SESSION-SUBSCRIPTION-RECOVERY-002.6
+ - AC-PLATFORM-SESSION-SUBSCRIPTION-RECOVERY-002.7
+ - AC-PLATFORM-SESSION-SUBSCRIPTION-RECOVERY-002.10
+system_design:
+ - ../../specs/platform/system-design/session-subscription-recovery.md
+---
+
+# Task 01: Recover entry requests
+
+## Summary
+
+Implement bounded entry recovery and truthful history state in the existing client and hooks.
+Preserve shared registration ownership, cached messages, and mutation admission.
+
+## In scope
+
+- Typed timeout errors and entry-specific deadlines.
+- Shared subscription retry and independently bounded status/history reads.
+- Hook recovery outcome and manual retry APIs for Task 02.
+- Session/connection guards, cleanup, cache preservation, and permanent-error handling.
+
+## Out of scope
+
+Rendered UI, localization, backend changes, and automatic mutation retries.
+
+## Acceptance
+
+- A seven-second response succeeds; dropped responses retry within the design's limits without a visibility event.
+- Failed history preserves cached messages; only a successful snapshot marks history initialized.
+- Concurrent consumers share recovery; obsolete requests cannot act, and a successful status runs resume processing at most once.
+
+## Verification
+
+From the repository root, install dependencies once if this worktree lacks them:
+
+```bash
+(cd apps && pnpm install --frozen-lockfile)
+```
+
+```bash
+(cd apps/web && pnpm exec vitest run lib/ws/client.test.ts hooks/domains/session/use-session-messages.test.ts hooks/domains/session/use-session-subscription-retry.test.ts hooks/domains/session/use-session-resumption.test.ts hooks/domains/session/use-session-resumption.archive.test.ts)
+(cd apps/web && pnpm run typecheck)
+(cd apps/web && pnpm exec eslint --max-warnings 0 lib/ws/client.ts lib/ws/request-error.ts hooks/domains/session/use-session-messages.ts hooks/domains/session/use-session-message-fetch.ts hooks/domains/session/use-message-fetch-state.ts hooks/domains/session/use-session-subscription-retry.ts hooks/domains/session/use-session-resumption.ts)
+git diff --check
+```
+
+Add a behavioral RED named `recovers timed out registration without a visibility change` before implementation.
+Use existing FakeWebSocket and hook test mocks; add fake-timer assertions for exhaustion and late responses.
+Run any extracted new helper tests explicitly and include their paths in Results.
+
+## Files likely touched
+
+- `apps/web/lib/ws/client.ts`, `client.test.ts`, and `request-error.ts`
+- `apps/web/hooks/domains/session/use-session-messages.ts` and its tests
+- `apps/web/hooks/domains/session/use-session-message-fetch.ts`
+- `apps/web/hooks/domains/session/use-message-fetch-state.ts`
+- `apps/web/hooks/domains/session/use-session-subscription-retry.ts` and its tests
+- `apps/web/hooks/domains/session/use-session-resumption.ts` and its tests
+
+Paths without full directories above refer to the preceding explicit directory.
+
+## Dependencies
+
+None.
+
+## Risks
+
+Ref-count churn, duplicate mutation admission, timer leaks, and stale response writes.
+
+## Parallelism
+
+`sequential`
+
+## Inputs
+
+- Requirement AC-002.1 through AC-002.7 and AC-002.10; preserve all AC-001 criteria.
+- System design: Registration ordering, Entry recovery, and History state.
+- Existing client fake socket and session hook tests.
+
+## Results
+
+Implemented typed WebSocket timeout classification, the 10-second session-entry deadline, and
+bounded shared subscription recovery. Status and history reads now retry typed timeouts within
+the design budget, while permanent errors remain distinct. History retains cached rows until a
+successful snapshot and exposes explicit recovery state for the presentation task.
+
+Verification:
+
+- `cd apps/web && pnpm exec vitest run lib/ws/client.test.ts hooks/domains/session/use-session-messages.test.ts hooks/domains/session/use-session-subscription-retry.test.ts hooks/domains/session/use-session-resumption.test.ts hooks/domains/session/use-session-resumption.archive.test.ts hooks/domains/session/use-session-message-fetch.test.ts components/task/chat/session-entry-feedback.test.tsx components/task/ensure-session-error.test.tsx components/task/chat/message-list-shared.test.tsx` — 170 tests passed across 9 files.
+- `cd apps/web && pnpm run typecheck` — passed.
+- `cd apps/web && pnpm exec eslint --max-warnings 0 lib/ws/client.ts lib/ws/request-error.ts hooks/domains/session/use-session-messages.ts hooks/domains/session/use-session-message-fetch.ts hooks/domains/session/use-message-fetch-state.ts hooks/domains/session/use-session-subscription-retry.ts hooks/domains/session/use-session-resumption.ts` — passed.
+- `git diff --check` — passed.
+
+The implementation preserves acknowledgement-before-history ordering, cancels obsolete retry
+timers, and does not retry session mutations.
+
+Review remediation (complete):
+
+- Status request failures are classified before resume processing. Workspace-restore and launch
+ failures keep their launch recovery action, while status responses with `error` or an archive
+ outcome remain visible and never start a session.
+- History feedback now carries a session and connection generation through subscription,
+ terminal-state, entry, visibility, settle, running-backfill, and manual-retry fetches. Stale
+ completions release the original session's store bookkeeping without changing the active hook
+ state.
+- Manual history Retry clears the settled hydration marker before fetching, so it always requests a
+ fresh snapshot while preserving cached rows until that snapshot succeeds.
+- Added regressions for workspace-restore failure classification, timeout-then-error status
+ responses, A-to-B and A-to-B-to-A deferred history races, terminal-state fetches, and fresh
+ manual history retry.
+- Focused review-remediation validation: 170 tests passed across 9 files. It covers
+ environment-scoped workspace-restore errors and launch retry behavior, permanent status error
+ payloads after a timeout, generation-fenced terminal and manual history fetches, settled
+ request-cache refreshes, and local loading cleanup for stale fetches. Typecheck, zero-warning
+ ESLint, and formatting checks passed.
+- Follow-up CI remediation keeps cached-entry refresh finalization on the session/connection
+ generation. A live message that rerenders the entry while its refresh is pending can no longer
+ strand the loading flag or suppress initial divider placement. The deferred regression passed,
+ as did the desktop unread-divider suite (5 tests) and mobile unread-divider suite (3 tests).
diff --git a/docs/plans/session-entry-recovery/task-02-recovery-feedback.md b/docs/plans/session-entry-recovery/task-02-recovery-feedback.md
new file mode 100644
index 0000000000..36b08a5ee5
--- /dev/null
+++ b/docs/plans/session-entry-recovery/task-02-recovery-feedback.md
@@ -0,0 +1,195 @@
+---
+id: "02-recovery-feedback"
+title: "Present conversation recovery"
+status: completed
+wave: 2
+depends_on: ["01-entry-recovery"]
+plan: "plan.md"
+requirements:
+ - REQ-PLATFORM-SESSION-SUBSCRIPTION-RECOVERY-002
+ - REQ-PLATFORM-SESSION-SUBSCRIPTION-RECOVERY-001
+acceptance_criteria:
+ - AC-PLATFORM-SESSION-SUBSCRIPTION-RECOVERY-002.1
+ - AC-PLATFORM-SESSION-SUBSCRIPTION-RECOVERY-002.2
+ - AC-PLATFORM-SESSION-SUBSCRIPTION-RECOVERY-002.3
+ - AC-PLATFORM-SESSION-SUBSCRIPTION-RECOVERY-002.4
+ - AC-PLATFORM-SESSION-SUBSCRIPTION-RECOVERY-002.5
+ - AC-PLATFORM-SESSION-SUBSCRIPTION-RECOVERY-002.6
+ - AC-PLATFORM-SESSION-SUBSCRIPTION-RECOVERY-002.7
+ - AC-PLATFORM-SESSION-SUBSCRIPTION-RECOVERY-002.8
+ - AC-PLATFORM-SESSION-SUBSCRIPTION-RECOVERY-002.9
+ - AC-PLATFORM-SESSION-SUBSCRIPTION-RECOVERY-002.10
+system_design:
+ - ../../specs/platform/system-design/session-subscription-recovery.md
+---
+
+# Task 02: Present conversation recovery
+
+## Summary
+
+Connect entry recovery to compact chat feedback on desktop, phone, and preview.
+Prove the reported delay and automatic recovery with isolated browser tests.
+
+## In scope
+
+- Explicit history loading/error/empty rendering and retry controls.
+- Typed status-check feedback with collapsed technical details.
+- Localized copy, accessible progress, phone targets, and request-aware browser tests.
+
+## Out of scope
+
+Changes to provider failures, launch policy, server transport, or global alert styling.
+
+## Acceptance
+
+- Unknown history never renders the empty invitation; cached messages remain visible after errors.
+- Status timeouts use accurate compact feedback, and successful recovery clears that feedback without changing launch-error actions.
+- Desktop and phone recover without reload; Retry and Details work through visible controls with correct touch geometry.
+
+## ASCII UI preview
+
+UI-01: Open existing task, current failure (desktop and phone).
+
+```text
+[!] Couldn't start a session
+ WebSocket request timed out: task.session.status
+ [Retry]
+
+ No messages yet. Start the conversation!
+```
+
+UI-02: Open existing task, proposed desktop chat region.
+
+```text
+Loading: (spinner) Loading conversation...
+Retrying: (spinner) Taking longer than usual. Retrying...
+Exhausted: Conversation could not load. [Retry] [Details v]
+Ready:
+```
+
+UI-03: Same task, proposed phone chat region.
+
+```text
++------------------------------------+
+| Conversation could not load. |
+| [ Retry ] [ Details v ] |
+| |
+| |
++------------------------------------+
+| |
++------------------------------------+
+```
+
+UI-04: Status-only exhaustion with a readable transcript.
+
+```text
+Session status is unavailable. [Retry] [Details v]
+
+```
+
+Details expands inline with wrapped technical text. Loading and retrying use a
+neutral status region. Neither uses a destructive alert or the empty invitation.
+Phone actions occupy their own row and have at least 44-pixel targets.
+Desktop actions use the normal 28-pixel size. The chat remains the scroll owner;
+existing composer placement and safe-area behavior remain unchanged.
+These hierarchy and state choices are required; wording and spacing are illustrative.
+All text is localized. Covers AC-002.4 through AC-002.6 and AC-002.8 through AC-002.10
+(where AC-002 denotes AC-PLATFORM-SESSION-SUBSCRIPTION-RECOVERY-002).
+
+Full combined preview: [plan](plan.md#ascii-ui-preview).
+
+## Verification
+
+Write component RED for the false empty state before rendering changes.
+Use a controlled response barrier for E2E RED where feasible; record any fixture limitation.
+
+```bash
+(cd apps/web && pnpm exec vitest run components/task/chat/session-entry-feedback.test.tsx components/task/ensure-session-error.test.tsx hooks/domains/session/use-session-messages.test.ts hooks/domains/session/use-session-resumption.test.ts)
+(cd apps/web && pnpm run typecheck)
+(cd apps/web && pnpm exec eslint --max-warnings 0 components/task/ensure-session-error.tsx components/task/task-page-inner.tsx components/task/preview-session-tabs.tsx components/task/chat components/task/task-chat-panel.tsx e2e/helpers/session-entry-recovery.ts e2e/tests/session/session-entry-recovery.spec.ts e2e/tests/session/mobile-session-entry-recovery.spec.ts)
+(cd apps/web && pnpm run i18n:zh-hant)
+(cd apps/web && pnpm run i18n:check)
+(cd apps/web && pnpm run i18n:ratchet)
+(cd apps/web && pnpm e2e:run --project chromium tests/session/session-entry-recovery.spec.ts tests/session/session-resume-recovery.spec.ts)
+(cd apps/web && pnpm e2e:run --project mobile-chrome tests/session/mobile-session-entry-recovery.spec.ts tests/session/mobile-session-resume-recovery.spec.ts)
+git diff --check
+```
+
+Managed E2E commands rebuild production assets and use isolated backends.
+Run desktop and mobile commands sequentially. Inspect their screenshots against UI-02 through UI-04.
+The desktop suite also exercises preview; phone uses the existing direct task route.
+Capture request IDs/actions without payload contents. Preserve unrelated frames in the proxy.
+
+## Files likely touched
+
+- `apps/web/components/task/ensure-session-error.tsx` and its tests
+- `apps/web/components/task/task-page-inner.tsx`
+- `apps/web/components/task/preview-session-tabs.tsx`
+- `apps/web/components/task/chat/use-chat-panel-state.ts`
+- `apps/web/components/task/chat/message-list-shared.tsx`
+- `apps/web/components/task/task-chat-panel.tsx`
+- New `apps/web/components/task/chat/session-entry-feedback.test.tsx`
+- `apps/web/src/locales/{en,pt-pt,zh-cn,zh-hk,zh-tw}/task.json`
+- New `apps/web/e2e/helpers/session-entry-recovery.ts`
+- New `apps/web/e2e/tests/session/session-entry-recovery.spec.ts`
+- New `apps/web/e2e/tests/session/mobile-session-entry-recovery.spec.ts`
+
+## Dependencies
+
+Task 01 supplies typed recovery state and bounded retry actions.
+
+## Risks
+
+Duplicate feedback from page and transcript consumers, desktop sizing leaking to phone, and untranslated diagnostic copy.
+
+## Parallelism
+
+`sequential`
+
+## Inputs
+
+- Requirement AC-002.1 through AC-002.10 and system design Presentation section.
+- Task 01 hook results; existing shared transcript loading renderer.
+- Mobile exemplar: `components/task/task-layout.tsx` dedicated phone chat composition.
+- Browser routing exemplar: `e2e/helpers/session-capabilities.ts`.
+
+## Results
+
+Connected the recovery outcomes to localized desktop, phone, and preview feedback. History now
+has loading, retrying, ready, and unavailable states; cached rows remain visible during failed
+refreshes; and the empty invitation renders only after a confirmed empty snapshot. Status-only
+failures use a compact neutral notice with collapsed technical details and a retry action that
+does not launch the agent.
+
+Verification:
+
+- `cd apps/web && pnpm exec vitest run lib/ws/client.test.ts hooks/domains/session/use-session-messages.test.ts hooks/domains/session/use-session-subscription-retry.test.ts hooks/domains/session/use-session-resumption.test.ts hooks/domains/session/use-session-resumption.archive.test.ts hooks/domains/session/use-session-message-fetch.test.ts components/task/chat/session-entry-feedback.test.tsx components/task/ensure-session-error.test.tsx components/task/chat/message-list-shared.test.tsx` — 170 tests passed across 9 files.
+- `cd apps/web && pnpm run typecheck` — passed.
+- `cd apps/web && pnpm exec eslint --max-warnings 0 components/task/ensure-session-error.tsx components/task/task-page-inner.tsx components/task/preview-session-tabs.tsx components/task/chat e2e/helpers/session-entry-recovery.ts e2e/tests/session/session-entry-recovery.spec.ts e2e/tests/session/mobile-session-entry-recovery.spec.ts` — passed.
+- `cd apps/web && pnpm run i18n:zh-hant` — passed.
+- `cd apps/web && pnpm run i18n:check` — passed; English, Portuguese, Simplified Chinese, Traditional Chinese, and pseudo catalogs are synchronized.
+- `cd apps/web && pnpm run i18n:ratchet` — passed.
+- `cd apps/web && pnpm e2e:run --project chromium tests/session/session-entry-recovery.spec.ts tests/session/session-resume-recovery.spec.ts` — 5 passed; the managed run built the backend and production web assets.
+- `cd apps/web && pnpm e2e:run --project mobile-chrome tests/session/mobile-session-entry-recovery.spec.ts tests/session/mobile-session-resume-recovery.spec.ts` — 2 passed.
+- `git diff --check` — passed.
+
+The request-aware WebSocket proxy records action and request IDs while preserving unrelated frames.
+The phone scenario verifies 44-pixel-class controls and no horizontal overflow.
+
+Review remediation (complete):
+
+- The existing recovery feedback keeps launch and workspace-restore failures on the launch retry
+ path, while status-only failures remain on the mutation-free status retry path.
+- Backend status error payloads remain visible through the shared feedback components instead of
+ being cleared as successful status refreshes.
+- Deferred history race coverage confirms that old session work cannot overwrite feedback for the
+ current desktop or phone conversation, including manual retry after re-entry.
+- History loading finalization is generation-fenced on navigation and connection changes, while
+ the original session's request bookkeeping is still released. Settled message requests cannot
+ satisfy a later terminal refresh, so explicit Retry sends a new snapshot request.
+- The loading spinner is hidden from the live status subtree so screen readers receive one
+ progress announcement. The status-retry E2E records the launch count before and after Retry
+ and verifies that status recovery stays mutation-free.
+- Follow-up CI validation confirmed that cached history refreshes release their pending state after
+ a live message rerender, so divider placement remains correct on desktop and phone. The exact
+ unread-divider regressions passed with 5 desktop and 3 mobile tests.
diff --git a/docs/plans/session-subscription-recovery/plan.md b/docs/plans/session-subscription-recovery/plan.md
index e5b371f387..07de3ee28f 100644
--- a/docs/plans/session-subscription-recovery/plan.md
+++ b/docs/plans/session-subscription-recovery/plan.md
@@ -136,3 +136,8 @@ contract, so they are intentionally one sequential task.
- The race-specific reload fallback can be removed; the general bounded
`waitForLoad` reload remains for unrelated startup/SSR hydration failures.
+
+## Follow-up package
+
+The [delayed-entry package](../session-entry-recovery/plan.md) extends recovery after a registration timeout.
+The completed ordering work and its recorded results remain unchanged.
diff --git a/docs/specs/platform/requirements/session-subscription-recovery.md b/docs/specs/platform/requirements/session-subscription-recovery.md
index 19935b1125..b243d79962 100644
--- a/docs/specs/platform/requirements/session-subscription-recovery.md
+++ b/docs/specs/platform/requirements/session-subscription-recovery.md
@@ -28,49 +28,27 @@ A fast task session can change state or persist a reply while the browser is wai
- **AC-PLATFORM-SESSION-SUBSCRIPTION-RECOVERY-001.7:** **GIVEN** one client-side consumer is already registering a session and a second consumer mounts, **WHEN** the second consumer requests hydration, **THEN** it waits for the shared registration acknowledgement and does not race an earlier `message.list` request ahead of registration.
- **AC-PLATFORM-SESSION-SUBSCRIPTION-RECOVERY-001.8:** **GIVEN** a session subscription is restored after a WebSocket reconnect, **WHEN** the restored registration is acknowledged, **THEN** message reconciliation can run against the restored subscription and later events are delivered normally.
-## Migrated source detail
+### REQ-PLATFORM-SESSION-SUBSCRIPTION-RECOVERY-002: Recover session entry from temporary delays
-## Why
+**Intent:** Users can open an existing conversation during a temporary connection delay without a false startup failure or lost history.
-A fast task session can change state or persist a reply while the browser is
-waiting for the backend to register its `session.subscribe` request. The live
-notification is then unavailable to that client, leaving the task looking busy
-or the transcript missing a reply until the user reloads the page.
+#### Acceptance criteria
-## What
+- **AC-PLATFORM-SESSION-SUBSCRIPTION-RECOVERY-002.1:** A successful subscription or status response delayed by seven seconds shall complete session entry without a startup-failure alert.
+- **AC-PLATFORM-SESSION-SUBSCRIPTION-RECOVERY-002.2:** After a temporary entry timeout, the visible session shall retry automatically without a reload, navigation, or visibility change.
+- **AC-PLATFORM-SESSION-SUBSCRIPTION-RECOVERY-002.3:** On a continuously connected socket, unsuccessful automatic recovery shall stop within 45 seconds. The user shall then have an explicit Retry action.
+- **AC-PLATFORM-SESSION-SUBSCRIPTION-RECOVERY-002.4:** Until history loads successfully, an uncached conversation shall show loading, retrying, or unavailable feedback. It shall never show the empty-conversation invitation.
+- **AC-PLATFORM-SESSION-SUBSCRIPTION-RECOVERY-002.5:** A failed refresh shall preserve cached messages, drafts, and scroll position. Successful recovery shall clear its stale feedback.
+- **AC-PLATFORM-SESSION-SUBSCRIPTION-RECOVERY-002.6:** A status timeout shall identify a status-check problem, not a failed session start. Technical details shall remain collapsed by default.
+- **AC-PLATFORM-SESSION-SUBSCRIPTION-RECOVERY-002.7:** Recovery shall not duplicate agent launches or bypass archive, permission, or manual-start restrictions. Changing sessions shall cancel obsolete recovery effects.
+- **AC-PLATFORM-SESSION-SUBSCRIPTION-RECOVERY-002.8:** Desktop, phone, and task preview shall expose equivalent recovery outcomes. Feedback shall preserve chat access and keyboard focus without horizontal overflow.
+- **AC-PLATFORM-SESSION-SUBSCRIPTION-RECOVERY-002.9:** Phone and coarse-pointer recovery controls shall have touch targets of at least 44 pixels. Progress shall have a localized status announcement.
+- **AC-PLATFORM-SESSION-SUBSCRIPTION-RECOVERY-002.10:** Permission and missing-session failures shall stop automatic retries. A successful empty history response shall still show the normal empty-conversation invitation.
-- A session subscription exposes a readiness point that resolves only after the
- backend has acknowledged registration.
-- Transcript reconciliation for a newly mounted or reconnected session starts
- after that readiness point, so the persisted message view covers the interval
- before registration and live notifications cover the interval after it.
-- Ref-counted consumers share an in-flight registration readiness point and do
- not unregister one another's subscription.
-- Reconnect and delayed-session retry paths use the same acknowledgement-aware
- registration behavior.
-- A failed or disconnected registration does not create an unhandled client
- rejection or leave a stale readiness promise that prevents the next
- connection from hydrating the session.
+## Implementation plans
-## Scenarios
-
-- **GIVEN** a browser sends `session.subscribe` while a session state change or
- reply is persisted before the backend registers the subscription, **WHEN** the
- backend acknowledges the subscription and the client reconciles messages,
- **THEN** the client displays the authoritative state and persisted reply
- without a page reload.
-- **GIVEN** one client-side consumer is already registering a session and a
- second consumer mounts, **WHEN** the second consumer requests hydration,
- **THEN** it waits for the shared registration acknowledgement and does not
- race an earlier `message.list` request ahead of registration.
-- **GIVEN** a session subscription is restored after a WebSocket reconnect,
- **WHEN** the restored registration is acknowledged, **THEN** message
- reconciliation can run against the restored subscription and later events are
- delivered normally.
-- **GIVEN** the registration fails or the socket disconnects before the
- acknowledgement, **WHEN** the client retries on the next connected socket,
- **THEN** the retry can establish a fresh readiness point and no stale promise
- or unhandled rejection blocks hydration.
+- [Acknowledgement ordering](../../../plans/session-subscription-recovery/plan.md)
+- [Delayed session entry](../../../plans/session-entry-recovery/plan.md)
## Out of scope
diff --git a/docs/specs/platform/system-design/session-subscription-recovery.md b/docs/specs/platform/system-design/session-subscription-recovery.md
new file mode 100644
index 0000000000..0e6bd4eb89
--- /dev/null
+++ b/docs/specs/platform/system-design/session-subscription-recovery.md
@@ -0,0 +1,95 @@
+---
+status: draft
+system: platform
+requirements:
+ - REQ-PLATFORM-SESSION-SUBSCRIPTION-RECOVERY-001
+ - REQ-PLATFORM-SESSION-SUBSCRIPTION-RECOVERY-002
+---
+
+# Session subscription recovery
+
+## Ownership and mapping
+
+Platform owns shared session subscription readiness and recovery. This design extends that contract across chat, task preview, and session-status feedback.
+
+| Requirement | Design |
+| --- | --- |
+| REQ-PLATFORM-SESSION-SUBSCRIPTION-RECOVERY-001 | Registration ordering and ownership |
+| REQ-PLATFORM-SESSION-SUBSCRIPTION-RECOVERY-002 | Entry recovery, history state, and presentation |
+
+## Registration ordering and ownership
+
+The gateway registers membership before acknowledging `session.subscribe` in `apps/backend/internal/gateway/websocket/client.go`.
+The frontend `WebSocketClient` owns reference counts and a shared readiness promise per session and connection generation.
+`fetchAndStoreMessages` in `use-session-messages.ts` waits for readiness before requesting history.
+This ordering remains mandatory. Recovery must not fetch history ahead of registration.
+
+## Entry recovery
+
+Use explicit 10-second deadlines for subscription registration and entry status checks. Keep the general request default unchanged.
+Add a typed timeout error in `apps/web/lib/ws/request-error.ts`, retaining its existing diagnostic message.
+Only this typed timeout permits the new automatic retry. Backend rejection codes retain their current meaning.
+
+The client owns subscription retries within one shared readiness promise: two attempts, with one second between attempts.
+Do not increase reference counts for a retry. Concurrent consumers and unknown-session retries join an active registration episode.
+Only the current connection generation can settle readiness. Last unsubscribe cancels its timer and invalidates pending results.
+A disconnect cancels the episode; existing reconnect registration starts a fresh generation after reconnection.
+Do not queue episode retries while disconnected. Existing inactive-thread subscription limits remain unchanged.
+
+The resumption hook separately retries only the `task.session.status` request with the same two-attempt policy.
+Keep `processResumeStatus` outside that retry operation. It runs once after a current successful status response.
+Never wrap resume, restore, launch, or prompt mutations in a retry loop.
+Preserve archive-state checks, request-identity guards, and existing resume eligibility policy.
+
+After registration, history uses its existing 10-second request deadline and at most one additional timeout retry after one second.
+A continuously connected initial episode therefore takes at most 42 seconds: 21 for registration and 21 for history.
+Status progresses independently and takes at most 21 seconds. Automatic failure feedback appears within the 45-second requirement.
+Use timers with cleanup and fake-timer tests. Background throttling is outside the continuously visible timing guarantee.
+Foreground refresh and reconnect remain recovery triggers, but they join an active episode instead of resetting its budget.
+After exhaustion, explicit Retry starts a new bounded episode. Disable it while that episode is active.
+
+## History state
+
+Extend the existing message hooks with explicit load outcome and retry action: loading, retrying, ready, or unavailable.
+Keep this state associated with session identity and hydration generation. Share in-flight fetches across mounted consumers.
+The hook result supplies the state to `use-chat-panel-state.ts`, `task-chat-panel.tsx`, and the shared message-list components.
+A successful snapshot alone establishes history initialization, including a successful empty snapshot.
+
+`doFetchMessages` in `use-session-message-fetch.ts` must not clear messages on rejection or mark failed hydration as successful.
+Preserve message reconciliation, prompt-history independence, draft state, and scroll anchoring.
+An obsolete response cannot overwrite another session or clear a newer episode's error.
+Partial live messages remain visible while initial reconciliation is pending.
+
+## Presentation
+
+Use the existing dedicated phone chat layout in `components/task/task-layout.tsx` as the mobile composition exemplar.
+Reuse the inline loading region in `components/task/chat/message-list-shared.tsx`.
+History feedback lives inside the chat region. Cached messages remain below a compact notice during recovery.
+The transcript remains the scroll owner. Do not add an overlay, drawer, or separate nested scroller for short feedback.
+The existing dynamic viewport and composer safe-area behavior remain intact.
+
+Use localized copy such as `Loading conversation...`, `Taking longer than usual. Retrying...`, and `Conversation could not load.`
+For exhausted status checks, use `Session status is unavailable.` with Retry and a collapsed Details disclosure.
+Route entry-status feedback through a typed failure kind in `SessionRecoveryFeedback`, including task page and preview consumers.
+Do not globally rename actual launch failures in `describeEnsureError` or suppress provider, profile, and workspace recovery actions.
+A status-only failure must not replace the transcript loading result or hide usable cached chat.
+When both fail, show one history notice in chat and retain status details without a duplicate large banner.
+
+Desktop actions use the normal 28-pixel control size. Phone or coarse-pointer actions use at least 44-pixel targets.
+Phone notices place actions below the message; desktop notices keep actions on the same row when space permits.
+Use one polite `role="status"` per visible operation and keep Retry's accessible name stable.
+Details use a keyboard-accessible disclosure with wrapped text. Do not move focus on automatic progress.
+All new copy uses existing i18n catalogs in five languages, with generated Traditional Chinese variants.
+
+## Persistence, security, and observability
+
+No database, protocol, public API, or permission changes are required.
+Existing server-side authorization and mutation admission remain authoritative.
+Retries preserve typed backend errors and expose only existing transport diagnostics in Details.
+Tests correlate request action and ID; they do not record chat payloads or credentials.
+The cause of the observed seven-second delivery delay is unconfirmed. This design does not claim to remove that underlying latency.
+
+## Implementation plans
+
+- [Original ordering repair](../../../plans/session-subscription-recovery/plan.md)
+- [Delayed session entry](../../../plans/session-entry-recovery/plan.md)