diff --git a/apps/shared/src/json-rpc-gateway.ts b/apps/shared/src/json-rpc-gateway.ts
index b083d8e0e1a0..f296856d16be 100644
--- a/apps/shared/src/json-rpc-gateway.ts
+++ b/apps/shared/src/json-rpc-gateway.ts
@@ -53,6 +53,8 @@ export interface GatewayClientOptions {
connectErrorMessage?: string
connectTimeoutMs?: number
createRequestId?: (nextId: number) => GatewayRequestId
+ /** Return true to intercept the default closed-state transition. */
+ onSocketClose?: (event: CloseEvent) => boolean | void
requestIdPrefix?: string
requestTimeoutMs?: number
socketFactory?: (url: string) => WebSocketLike
@@ -83,6 +85,7 @@ export class JsonRpcGatewayClient {
connectTimeoutMs: options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS,
createRequestId: options.createRequestId ?? ((nextId: number) => `${options.requestIdPrefix ?? 'r'}${nextId}`),
notConnectedErrorMessage: options.notConnectedErrorMessage ?? 'gateway not connected',
+ onSocketClose: options.onSocketClose ?? (() => false),
requestIdPrefix: options.requestIdPrefix ?? 'r',
requestTimeoutMs: options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS,
socketFactory: options.socketFactory
@@ -111,11 +114,15 @@ export class JsonRpcGatewayClient {
this.handleMessage(message.data)
})
- socket.addEventListener('close', () => {
+ socket.addEventListener('close', event => {
if (this.socket !== socket) {
return
}
+ if (this.options.onSocketClose(event)) {
+ return
+ }
+
this.socket = null
this.setState('closed')
this.rejectAllPending(new Error(this.options.closedErrorMessage))
diff --git a/web/src/components/ChatSidebar.test.tsx b/web/src/components/ChatSidebar.test.tsx
new file mode 100644
index 000000000000..8a05ee6a6a90
--- /dev/null
+++ b/web/src/components/ChatSidebar.test.tsx
@@ -0,0 +1,133 @@
+// @vitest-environment jsdom
+import { act, type ReactNode } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const apiMocks = vi.hoisted(() => ({
+ buildWsUrl: vi.fn(async () => "ws://localhost/api/events?channel=chat-1"),
+ getModelInfo: vi.fn(async () => ({
+ capabilities: { supports_reasoning: false },
+ model: "test/model",
+ })),
+}));
+
+const gatewayMocks = vi.hoisted(() => ({
+ close: vi.fn(),
+ connect: vi.fn(async () => undefined),
+ on: vi.fn(() => () => undefined),
+ onState: vi.fn((handler: (state: string) => void) => {
+ handler("open");
+ return () => undefined;
+ }),
+ request: vi.fn(async () => ({ session_id: "sidecar-1" })),
+}));
+
+const reloadMocks = vi.hoisted(() => ({
+ maybeReloadForLoopbackWsAuthFailure: vi.fn(() => true),
+}));
+
+vi.mock("@/lib/api", () => ({
+ api: { getModelInfo: apiMocks.getModelInfo },
+ buildWsUrl: apiMocks.buildWsUrl,
+}));
+vi.mock("@/lib/dashboard-auth-reload", () => ({
+ maybeReloadForLoopbackWsAuthFailure:
+ reloadMocks.maybeReloadForLoopbackWsAuthFailure,
+}));
+vi.mock("@/lib/gatewayClient", () => ({
+ GatewayClient: class {
+ close = gatewayMocks.close;
+ connect = gatewayMocks.connect;
+ on = gatewayMocks.on;
+ onState = gatewayMocks.onState;
+ request = gatewayMocks.request;
+ },
+}));
+vi.mock("@/components/ModelPickerDialog", () => ({
+ ModelPickerDialog: () => null,
+}));
+vi.mock("@/components/ModelReloadConfirm", () => ({
+ ModelReloadConfirm: () => null,
+}));
+vi.mock("@/components/ReasoningPicker", () => ({
+ ReasoningPicker: () => null,
+}));
+vi.mock("@nous-research/ui/ui/components/button", () => ({
+ Button: ({ children }: { children?: ReactNode }) => ,
+}));
+vi.mock("@nous-research/ui/ui/components/badge", () => ({
+ Badge: ({ children }: { children?: ReactNode }) => {children},
+}));
+vi.mock("@nous-research/ui/ui/components/card", () => ({
+ Card: ({ children }: { children?: ReactNode }) =>
{children}
,
+}));
+
+type EventLike = { code?: number; data?: string };
+
+class FakeWebSocket {
+ static instances: FakeWebSocket[] = [];
+
+ private listeners = new Map void>>();
+ readonly url: string;
+
+ constructor(url: string) {
+ this.url = url;
+ FakeWebSocket.instances.push(this);
+ }
+
+ addEventListener(type: string, listener: (event: EventLike) => void) {
+ const listeners = this.listeners.get(type) ?? [];
+ listeners.push(listener);
+ this.listeners.set(type, listeners);
+ }
+
+ close() {}
+
+ emit(type: string, event: EventLike) {
+ for (const listener of this.listeners.get(type) ?? []) {
+ listener(event);
+ }
+ }
+}
+
+let container: HTMLDivElement;
+let root: Root;
+
+async function render(ui: ReactNode) {
+ container = document.createElement("div");
+ document.body.append(container);
+ root = createRoot(container);
+ await act(async () => root.render(ui));
+}
+
+beforeEach(() => {
+ FakeWebSocket.instances = [];
+ vi.clearAllMocks();
+ reloadMocks.maybeReloadForLoopbackWsAuthFailure.mockReturnValue(true);
+ vi.stubGlobal("WebSocket", FakeWebSocket);
+});
+
+afterEach(async () => {
+ await act(async () => root?.unmount());
+ container?.remove();
+ vi.unstubAllGlobals();
+});
+
+describe("ChatSidebar event socket", () => {
+ it("routes loopback 4401 closes through stale-token recovery", async () => {
+ const { ChatSidebar } = await import("./ChatSidebar");
+
+ await render();
+
+ await vi.waitFor(() => expect(FakeWebSocket.instances).toHaveLength(1));
+ expect(apiMocks.buildWsUrl).toHaveBeenCalledWith("/api/events", {
+ channel: "chat-1",
+ });
+
+ FakeWebSocket.instances[0].emit("close", { code: 4401 });
+
+ expect(
+ reloadMocks.maybeReloadForLoopbackWsAuthFailure,
+ ).toHaveBeenCalledWith(4401);
+ });
+});
diff --git a/web/src/components/ChatSidebar.tsx b/web/src/components/ChatSidebar.tsx
index 57236e782bc8..fd4df2edcded 100644
--- a/web/src/components/ChatSidebar.tsx
+++ b/web/src/components/ChatSidebar.tsx
@@ -32,6 +32,7 @@ import { ModelReloadConfirm } from "@/components/ModelReloadConfirm";
import { ReasoningPicker } from "@/components/ReasoningPicker";
import { GatewayClient, type ConnectionState } from "@/lib/gatewayClient";
import { api, buildWsUrl } from "@/lib/api";
+import { maybeReloadForLoopbackWsAuthFailure } from "@/lib/dashboard-auth-reload";
import { titleFromSessionInfoPayload } from "@/lib/chat-title";
import { cn } from "@/lib/utils";
@@ -258,6 +259,9 @@ export function ChatSidebar({
ws.addEventListener("error", () => surface(DISCONNECTED));
ws.addEventListener("close", (ev) => {
+ if (maybeReloadForLoopbackWsAuthFailure(ev.code)) {
+ return;
+ }
if (ev.code === 4401 || ev.code === 4403) {
surface(`events feed rejected (${ev.code}) — reload the page`);
} else if (ev.code !== 1000) {
diff --git a/web/src/lib/api.test.ts b/web/src/lib/api.test.ts
index 4d63d51a01a3..5868f2bd5d31 100644
--- a/web/src/lib/api.test.ts
+++ b/web/src/lib/api.test.ts
@@ -1,9 +1,37 @@
-import { afterEach, describe, expect, it, vi } from "vitest";
+// @vitest-environment jsdom
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { api } from "./api";
+import { api, fetchJSON } from "./api";
+
+const reloadMocks = vi.hoisted(() => ({
+ attemptDashboardTokenReloadOnce: vi.fn(() => false),
+ clearDashboardTokenReloadAttempt: vi.fn(),
+}));
+
+vi.mock("./dashboard-auth-reload", () => ({
+ attemptDashboardTokenReloadOnce: reloadMocks.attemptDashboardTokenReloadOnce,
+ clearDashboardTokenReloadAttempt: reloadMocks.clearDashboardTokenReloadAttempt,
+}));
const SESSION_HEADER = "X-Hermes-Session-Token";
+beforeEach(() => {
+ reloadMocks.attemptDashboardTokenReloadOnce.mockReset();
+ reloadMocks.attemptDashboardTokenReloadOnce.mockReturnValue(false);
+ reloadMocks.clearDashboardTokenReloadAttempt.mockReset();
+
+ Object.defineProperty(window, "__HERMES_SESSION_TOKEN__", {
+ configurable: true,
+ value: "stale-token",
+ writable: true,
+ });
+ Object.defineProperty(window, "__HERMES_AUTH_REQUIRED__", {
+ configurable: true,
+ value: false,
+ writable: true,
+ });
+});
+
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
@@ -19,6 +47,47 @@ function jsonFetchMock(body: unknown = { ok: true }) {
);
}
+describe("fetchJSON", () => {
+ it("tries the one-shot reload path for loopback 401s", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async () => ({
+ clone: () => ({
+ json: async () => ({}),
+ }),
+ ok: false,
+ status: 401,
+ statusText: "Unauthorized",
+ text: async () => "Unauthorized",
+ })),
+ );
+ reloadMocks.attemptDashboardTokenReloadOnce.mockReturnValue(true);
+
+ const pending = fetchJSON("/api/status");
+ await expect(Promise.race([pending, Promise.resolve("pending")])).resolves.toBe(
+ "pending",
+ );
+
+ expect(reloadMocks.attemptDashboardTokenReloadOnce).toHaveBeenCalledTimes(1);
+ expect(reloadMocks.clearDashboardTokenReloadAttempt).not.toHaveBeenCalled();
+ });
+
+ it("clears the reload latch after a successful response", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async () => ({
+ json: async () => ({ ok: true }),
+ ok: true,
+ status: 200,
+ })),
+ );
+
+ await expect(fetchJSON("/api/status")).resolves.toEqual({ ok: true });
+
+ expect(reloadMocks.clearDashboardTokenReloadAttempt).toHaveBeenCalledTimes(1);
+ });
+});
+
describe("api.getModelOptions", () => {
it("requests a live model refresh when asked", async () => {
vi.stubGlobal("window", {});
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts
index 114b2c89576d..bcc2ed5e2463 100644
--- a/web/src/lib/api.ts
+++ b/web/src/lib/api.ts
@@ -20,6 +20,10 @@ export const HERMES_BASE_PATH = readBasePath();
const BASE = HERMES_BASE_PATH;
import type { DashboardTheme } from "@/themes/types";
+import {
+ attemptDashboardTokenReloadOnce,
+ clearDashboardTokenReloadAttempt,
+} from "@/lib/dashboard-auth-reload";
// Ephemeral session token for protected endpoints.
// Injected into index.html by the server — never fetched via API.
@@ -156,20 +160,7 @@ export async function fetchJSON(
// handled above, so reaching here in gated mode means a real
// middleware failure that should not reload-loop.
if (!window.__HERMES_AUTH_REQUIRED__ && !options?.allowUnauthorized) {
- let alreadyReloaded = false;
- try {
- alreadyReloaded =
- sessionStorage.getItem("hermes.tokenReloadAttempted") === "1";
- } catch {
- /* SSR / privacy mode — fall through to throw */
- }
- if (!alreadyReloaded) {
- try {
- sessionStorage.setItem("hermes.tokenReloadAttempted", "1");
- } catch {
- /* SSR / privacy mode — best effort */
- }
- window.location.reload();
+ if (attemptDashboardTokenReloadOnce()) {
return new Promise(() => {});
}
}
@@ -178,11 +169,7 @@ export async function fetchJSON(
// Clear the stale-token reload guard: a successful 2xx proves the
// current ``window.__HERMES_SESSION_TOKEN__`` is valid, so the next
// 401 — if any — should be allowed to trigger its own reload cycle.
- try {
- sessionStorage.removeItem("hermes.tokenReloadAttempted");
- } catch {
- /* SSR / privacy mode — ignore */
- }
+ clearDashboardTokenReloadAttempt();
}
if (!res.ok) {
const text = await res.text().catch(() => res.statusText);
diff --git a/web/src/lib/dashboard-auth-reload.test.ts b/web/src/lib/dashboard-auth-reload.test.ts
new file mode 100644
index 000000000000..3bce0d979692
--- /dev/null
+++ b/web/src/lib/dashboard-auth-reload.test.ts
@@ -0,0 +1,70 @@
+import { describe, expect, it, vi } from "vitest";
+
+import {
+ attemptDashboardTokenReloadOnce,
+ clearDashboardTokenReloadAttempt,
+ maybeReloadForLoopbackWsAuthFailure,
+} from "./dashboard-auth-reload";
+
+function makeStorage() {
+ const values = new Map();
+ return {
+ getItem(key: string) {
+ return values.get(key) ?? null;
+ },
+ removeItem(key: string) {
+ values.delete(key);
+ },
+ setItem(key: string, value: string) {
+ values.set(key, value);
+ },
+ };
+}
+
+describe("attemptDashboardTokenReloadOnce", () => {
+ it("reloads once and latches the attempt", () => {
+ const storage = makeStorage();
+ const reload = vi.fn();
+
+ expect(attemptDashboardTokenReloadOnce(storage, reload)).toBe(true);
+ expect(reload).toHaveBeenCalledTimes(1);
+
+ expect(attemptDashboardTokenReloadOnce(storage, reload)).toBe(false);
+ expect(reload).toHaveBeenCalledTimes(1);
+ });
+
+ it("clears the latch when asked", () => {
+ const storage = makeStorage();
+ const reload = vi.fn();
+
+ expect(attemptDashboardTokenReloadOnce(storage, reload)).toBe(true);
+ clearDashboardTokenReloadAttempt(storage);
+ expect(attemptDashboardTokenReloadOnce(storage, reload)).toBe(true);
+ expect(reload).toHaveBeenCalledTimes(2);
+ });
+});
+
+describe("maybeReloadForLoopbackWsAuthFailure", () => {
+ it("reloads once for loopback 4401 closes", () => {
+ const storage = makeStorage();
+ const reload = vi.fn();
+
+ expect(
+ maybeReloadForLoopbackWsAuthFailure(4401, false, storage, reload),
+ ).toBe(true);
+ expect(reload).toHaveBeenCalledTimes(1);
+ });
+
+ it("does not reload in gated mode or for other close codes", () => {
+ const storage = makeStorage();
+ const reload = vi.fn();
+
+ expect(
+ maybeReloadForLoopbackWsAuthFailure(4401, true, storage, reload),
+ ).toBe(false);
+ expect(
+ maybeReloadForLoopbackWsAuthFailure(4403, false, storage, reload),
+ ).toBe(false);
+ expect(reload).not.toHaveBeenCalled();
+ });
+});
diff --git a/web/src/lib/dashboard-auth-reload.ts b/web/src/lib/dashboard-auth-reload.ts
new file mode 100644
index 000000000000..69c50836cd31
--- /dev/null
+++ b/web/src/lib/dashboard-auth-reload.ts
@@ -0,0 +1,69 @@
+type StorageLike = Pick;
+
+const TOKEN_RELOAD_STORAGE_KEY = "hermes.tokenReloadAttempted";
+
+function dashboardAuthRequired(): boolean {
+ return typeof window !== "undefined" && !!window.__HERMES_AUTH_REQUIRED__;
+}
+
+function reloadDashboardWindow(): void {
+ if (typeof window !== "undefined") {
+ window.location.reload();
+ }
+}
+
+function dashboardSessionStorage(): StorageLike | null {
+ if (typeof window === "undefined") return null;
+ try {
+ return window.sessionStorage;
+ } catch {
+ return null;
+ }
+}
+
+export function clearDashboardTokenReloadAttempt(
+ storage: StorageLike | null = dashboardSessionStorage(),
+): void {
+ try {
+ storage?.removeItem(TOKEN_RELOAD_STORAGE_KEY);
+ } catch {
+ /* privacy mode / blocked storage — ignore */
+ }
+}
+
+export function attemptDashboardTokenReloadOnce(
+ storage: StorageLike | null = dashboardSessionStorage(),
+ reload: () => void = reloadDashboardWindow,
+): boolean {
+ let alreadyReloaded = false;
+ try {
+ alreadyReloaded =
+ storage?.getItem(TOKEN_RELOAD_STORAGE_KEY) === "1";
+ } catch {
+ /* privacy mode / blocked storage — fall through */
+ }
+ if (alreadyReloaded) {
+ return false;
+ }
+
+ try {
+ storage?.setItem(TOKEN_RELOAD_STORAGE_KEY, "1");
+ } catch {
+ /* privacy mode / blocked storage — best effort */
+ }
+
+ reload();
+ return true;
+}
+
+export function maybeReloadForLoopbackWsAuthFailure(
+ code: number,
+ authRequired = dashboardAuthRequired(),
+ storage: StorageLike | null = dashboardSessionStorage(),
+ reload: () => void = reloadDashboardWindow,
+): boolean {
+ if (authRequired || code !== 4401) {
+ return false;
+ }
+ return attemptDashboardTokenReloadOnce(storage, reload);
+}
diff --git a/web/src/lib/gatewayClient.test.ts b/web/src/lib/gatewayClient.test.ts
new file mode 100644
index 000000000000..5ed55cac5848
--- /dev/null
+++ b/web/src/lib/gatewayClient.test.ts
@@ -0,0 +1,96 @@
+// @vitest-environment jsdom
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { GatewayClient } from "./gatewayClient";
+
+const reloadMocks = vi.hoisted(() => ({
+ maybeReloadForLoopbackWsAuthFailure: vi.fn(() => false),
+}));
+
+vi.mock("./dashboard-auth-reload", () => ({
+ maybeReloadForLoopbackWsAuthFailure:
+ reloadMocks.maybeReloadForLoopbackWsAuthFailure,
+}));
+
+class FakeWebSocket {
+ static instances: FakeWebSocket[] = [];
+ static OPEN = 1;
+
+ listeners = new Map void>>();
+ readyState = 0;
+ url: string;
+
+ constructor(url: string) {
+ this.url = url;
+ FakeWebSocket.instances.push(this);
+ }
+
+ addEventListener(type: string, cb: (event: EventLike) => void) {
+ const list = this.listeners.get(type) ?? [];
+ list.push(cb);
+ this.listeners.set(type, list);
+ }
+
+ close() {}
+
+ emit(type: string, event: EventLike) {
+ for (const cb of this.listeners.get(type) ?? []) {
+ cb(event);
+ }
+ }
+
+ removeEventListener(type: string, cb: (event: EventLike) => void) {
+ const list = this.listeners.get(type) ?? [];
+ this.listeners.set(
+ type,
+ list.filter((item) => item !== cb),
+ );
+ }
+
+ send() {}
+}
+
+type EventLike = {
+ code?: number;
+};
+
+beforeEach(() => {
+ FakeWebSocket.instances = [];
+ reloadMocks.maybeReloadForLoopbackWsAuthFailure.mockClear();
+ vi.stubGlobal("WebSocket", FakeWebSocket);
+ Object.defineProperty(window, "__HERMES_SESSION_TOKEN__", {
+ configurable: true,
+ value: "stale-token",
+ writable: true,
+ });
+ Object.defineProperty(window, "__HERMES_AUTH_REQUIRED__", {
+ configurable: true,
+ value: false,
+ writable: true,
+ });
+});
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+describe("GatewayClient", () => {
+ it("treats loopback 4401 closes as stale-token reload candidates", async () => {
+ reloadMocks.maybeReloadForLoopbackWsAuthFailure.mockReturnValue(true);
+ const gw = new GatewayClient();
+ const connectPromise = gw.connect();
+
+ await vi.waitFor(() => expect(FakeWebSocket.instances).toHaveLength(1));
+ const socket = FakeWebSocket.instances[0];
+ socket.readyState = 1;
+ socket.emit("open", {});
+ await connectPromise;
+
+ socket.emit("close", { code: 4401 });
+
+ expect(
+ reloadMocks.maybeReloadForLoopbackWsAuthFailure,
+ ).toHaveBeenCalledWith(4401);
+ expect(gw.connectionState).toBe("open");
+ });
+});
diff --git a/web/src/lib/gatewayClient.ts b/web/src/lib/gatewayClient.ts
index d5ef547ab774..29fd891a27bc 100644
--- a/web/src/lib/gatewayClient.ts
+++ b/web/src/lib/gatewayClient.ts
@@ -22,6 +22,7 @@ import {
} from "@hermes/shared";
import { HERMES_BASE_PATH, buildWsAuthParam } from "@/lib/api";
+import { maybeReloadForLoopbackWsAuthFailure } from "@/lib/dashboard-auth-reload";
export type { ConnectionState, GatewayEvent, GatewayEventName };
@@ -31,6 +32,7 @@ export class GatewayClient extends JsonRpcGatewayClient {
closedErrorMessage: "WebSocket closed",
connectErrorMessage: "WebSocket connection failed",
notConnectedErrorMessage: "gateway not connected",
+ onSocketClose: (event) => maybeReloadForLoopbackWsAuthFailure(event.code),
requestIdPrefix: "w",
});
}
diff --git a/web/src/pages/ChatPage.test.tsx b/web/src/pages/ChatPage.test.tsx
new file mode 100644
index 000000000000..912debe62b4b
--- /dev/null
+++ b/web/src/pages/ChatPage.test.tsx
@@ -0,0 +1,228 @@
+// @vitest-environment jsdom
+import { act, type ReactNode } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { MemoryRouter } from "react-router-dom";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+class FakeFitAddon {
+ fit() {}
+}
+
+class FakeWebglAddon {
+ onContextLoss() {
+ return { dispose() {} };
+ }
+}
+
+class FakeTerminal {
+ options: Record;
+ rows = 24;
+ cols = 80;
+ parser = {
+ registerOscHandler: vi.fn(),
+ };
+ unicode = { activeVersion: "" };
+
+ constructor(options: Record) {
+ this.options = options;
+ }
+
+ attachCustomKeyEventHandler() {
+ return true;
+ }
+
+ attachCustomWheelEventHandler() {
+ return true;
+ }
+
+ clearSelection() {}
+
+ dispose() {}
+
+ focus() {}
+
+ getSelection() {
+ return "";
+ }
+
+ loadAddon() {}
+
+ onData() {
+ return { dispose() {} };
+ }
+
+ onResize() {
+ return { dispose() {} };
+ }
+
+ open() {}
+
+ paste() {}
+
+ refresh() {}
+
+ write() {}
+}
+
+const maybeReloadForLoopbackWsAuthFailure = vi.fn(() => false);
+
+vi.mock("@xterm/addon-fit", () => ({ FitAddon: FakeFitAddon }));
+vi.mock("@xterm/addon-unicode11", () => ({ Unicode11Addon: class {} }));
+vi.mock("@xterm/addon-web-links", () => ({ WebLinksAddon: class {} }));
+vi.mock("@xterm/addon-webgl", () => ({ WebglAddon: FakeWebglAddon }));
+vi.mock("@xterm/xterm", () => ({ Terminal: FakeTerminal }));
+vi.mock("@/components/ChatSidebar", () => ({
+ ChatSidebar: () => null,
+}));
+vi.mock("@/components/ChatSessionList", () => ({
+ ChatSessionList: () => null,
+}));
+vi.mock("@/components/Backdrop", () => ({ Backdrop: () => null }));
+vi.mock("@/plugins", () => ({
+ PluginSlot: () => null,
+}));
+vi.mock("@/contexts/usePageHeader", () => ({
+ usePageHeader: () => ({ setEnd: vi.fn(), setTitle: vi.fn() }),
+}));
+vi.mock("@/contexts/useProfileScope", () => ({
+ useProfileScope: () => ({ profile: "" }),
+}));
+vi.mock("@/themes", () => ({
+ useTheme: () => ({ theme: { terminalBackground: "#000000" } }),
+}));
+vi.mock("@/i18n", () => ({
+ useI18n: () => ({
+ t: {
+ app: {
+ closeModelTools: "Close model tools",
+ modelToolsSheetSubtitle: "Tools",
+ modelToolsSheetTitle: "Model",
+ },
+ },
+ }),
+}));
+vi.mock("@/lib/dashboard-auth-reload", () => ({
+ maybeReloadForLoopbackWsAuthFailure,
+}));
+
+class FakeWebSocket {
+ static instances: FakeWebSocket[] = [];
+ static OPEN = 1;
+
+ binaryType = "blob";
+ onclose: ((event: CloseEventLike) => void) | null = null;
+ onmessage: ((event: { data: ArrayBuffer | string }) => void) | null = null;
+ onopen: (() => void) | null = null;
+ readyState = FakeWebSocket.OPEN;
+ url: string;
+
+ constructor(url: string) {
+ this.url = url;
+ FakeWebSocket.instances.push(this);
+ }
+
+ close() {
+ this.readyState = 3;
+ }
+
+ send() {}
+}
+
+type CloseEventLike = {
+ code: number;
+ reason: string;
+ wasClean: boolean;
+};
+
+let container: HTMLDivElement;
+let root: Root;
+
+async function render(ui: ReactNode) {
+ container = document.createElement("div");
+ document.body.append(container);
+ root = createRoot(container);
+ await act(async () => root.render(ui));
+}
+
+beforeEach(() => {
+ FakeWebSocket.instances = [];
+ maybeReloadForLoopbackWsAuthFailure.mockClear();
+ vi.stubGlobal("WebSocket", FakeWebSocket);
+ vi.stubGlobal(
+ "ResizeObserver",
+ class {
+ disconnect() {}
+ observe() {}
+ unobserve() {}
+ },
+ );
+ vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
+ cb(0);
+ return 1;
+ });
+ vi.stubGlobal("cancelAnimationFrame", () => {});
+ vi.stubGlobal("matchMedia", () => ({
+ addEventListener() {},
+ matches: false,
+ media: "",
+ removeEventListener() {},
+ }));
+ vi.stubGlobal("crypto", {
+ getRandomValues: (values: Uint8Array) => {
+ values.fill(7);
+ return values;
+ },
+ randomUUID: () => "chat-test-id",
+ });
+
+ Object.defineProperty(window, "visualViewport", {
+ configurable: true,
+ value: { addEventListener() {}, removeEventListener() {}, width: 1280 },
+ });
+ Object.defineProperty(window, "__HERMES_SESSION_TOKEN__", {
+ configurable: true,
+ value: "stale-token",
+ writable: true,
+ });
+ Object.defineProperty(window, "__HERMES_AUTH_REQUIRED__", {
+ configurable: true,
+ value: false,
+ writable: true,
+ });
+ Object.defineProperty(window.navigator, "clipboard", {
+ configurable: true,
+ value: {
+ readText: vi.fn(async () => ""),
+ writeText: vi.fn(async () => {}),
+ },
+ });
+ sessionStorage.clear();
+});
+
+afterEach(async () => {
+ await act(async () => root?.unmount());
+ container?.remove();
+ vi.unstubAllGlobals();
+});
+
+describe("ChatPage", () => {
+ it("treats loopback 4401 closes as stale-token reload candidates", async () => {
+ const { default: ChatPage } = await import("./ChatPage");
+
+ await render(
+
+
+ ,
+ );
+
+ await vi.waitFor(() => expect(FakeWebSocket.instances).toHaveLength(1));
+
+ FakeWebSocket.instances[0].onclose?.({
+ code: 4401,
+ reason: "auth: token_mismatch",
+ wasClean: true,
+ });
+
+ expect(maybeReloadForLoopbackWsAuthFailure).toHaveBeenCalledWith(4401);
+ });
+});
diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx
index f7432d267cc8..fabd8d6339c5 100644
--- a/web/src/pages/ChatPage.tsx
+++ b/web/src/pages/ChatPage.tsx
@@ -55,6 +55,7 @@ import {
transferMayContainImage,
uploadChatImage,
} from "@/lib/chatImagePaste";
+import { maybeReloadForLoopbackWsAuthFailure } from "@/lib/dashboard-auth-reload";
import { PluginSlot } from "@/plugins";
import { useTheme } from "@/themes";
import { useProfileScope } from "@/contexts/useProfileScope";
@@ -1013,6 +1014,9 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
console.warn(`[chat] PTY WebSocket closed code=${ev.code}${why}`);
setLastCloseCode(ev.code);
if (ev.code === 4401) {
+ if (maybeReloadForLoopbackWsAuthFailure(ev.code)) {
+ return;
+ }
setPtyState("closed");
setBanner(
ev.reason