diff --git a/src/renderer/src/screens/Chat/Chat.tsx b/src/renderer/src/screens/Chat/Chat.tsx index eed0a00b4..6a2695f97 100644 --- a/src/renderer/src/screens/Chat/Chat.tsx +++ b/src/renderer/src/screens/Chat/Chat.tsx @@ -20,6 +20,11 @@ import { } from "./hooks/useDashboardChatTransport"; import { useI18n } from "../../components/useI18n"; import { buildChatTranscript } from "./transcriptUtils"; +import { dbItemsToChatMessages, type DbHistoryItem } from "./sessionHistory"; +import { + deleteChatRunTranscript, + saveChatRunTranscript, +} from "../Layout/chatRunPersistence"; import { ConfigHealthBanner } from "../../components/ConfigHealthBanner"; import type { Attachment } from "../../../../shared/attachments"; import type { ActiveTurn, ChatMessage, UsageState } from "./types"; @@ -265,6 +270,31 @@ function Chat({ activeTurnRef, }); + const hydratedInitialSessionRef = useRef(false); + useEffect(() => { + if (hydratedInitialSessionRef.current) return; + if (!initialSessionId || messages.length > 0) return; + hydratedInitialSessionRef.current = true; + let cancelled = false; + window.hermesAPI + .getSessionMessages(initialSessionId) + .then((items) => { + if (cancelled) return; + const restored = dbItemsToChatMessages(items as DbHistoryItem[]); + if (restored.length > 0) setMessages(restored); + }) + .catch(() => { + /* best-effort restore; sending can still resume by session id */ + }); + return () => { + cancelled = true; + }; + }, [initialSessionId, messages.length]); + + useEffect(() => { + saveChatRunTranscript(runId, messages); + }, [runId, messages]); + // No parent-driven reset effects: each run is its own // instance. A new chat is a fresh mount, and switching sessions just flips // which mounted instance is shown — local state (session id, context folder, @@ -368,6 +398,7 @@ function Chat({ void window.hermesAPI.clearStagedAttachments(idToDelete); } setMessages([]); + deleteChatRunTranscript(runId); setHermesSessionId(null); setContextFolder(null); activeTurnRef.current = null; @@ -375,7 +406,9 @@ function Chat({ setToolProgress(null); queueRef.current = []; setQueuedMessages([]); - }, [isLoading, runId, hermesSessionId, setMessages]); + reportedTitleRef.current = false; + onTitleChange?.(runId, ""); + }, [isLoading, runId, hermesSessionId, setMessages, onTitleChange]); const localCommands = useLocalCommands({ profile, diff --git a/src/renderer/src/screens/Chat/dashboardGatewayClient.ts b/src/renderer/src/screens/Chat/dashboardGatewayClient.ts index e1e51be02..503b76fef 100644 --- a/src/renderer/src/screens/Chat/dashboardGatewayClient.ts +++ b/src/renderer/src/screens/Chat/dashboardGatewayClient.ts @@ -132,6 +132,7 @@ export class DashboardGatewayClient { request( method: string, params: Record = {}, + timeoutMs: number = this.requestTimeoutMs, ): Promise { const socket = this.socket; if (!socket || socket.readyState !== WebSocket.OPEN) { @@ -147,7 +148,7 @@ export class DashboardGatewayClient { const timeout = window.setTimeout(() => { this.pending.delete(id); reject(new Error(`Hermes dashboard request timed out: ${method}`)); - }, this.requestTimeoutMs); + }, timeoutMs); this.pending.set(id, { resolve: (value: unknown) => resolve(value as T), reject, diff --git a/src/renderer/src/screens/Chat/hooks/useDashboardChatTransport.test.tsx b/src/renderer/src/screens/Chat/hooks/useDashboardChatTransport.test.tsx index 2c01d3e0f..981d4959c 100644 --- a/src/renderer/src/screens/Chat/hooks/useDashboardChatTransport.test.tsx +++ b/src/renderer/src/screens/Chat/hooks/useDashboardChatTransport.test.tsx @@ -9,7 +9,10 @@ import { } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { DashboardRpcEvent } from "../dashboardGatewayClient"; -import { useDashboardChatTransport } from "./useDashboardChatTransport"; +import { + dashboardSeedMessagesFromTranscript, + useDashboardChatTransport, +} from "./useDashboardChatTransport"; import type { ActiveTurn, ChatMessage } from "../types"; const dashboardMock = vi.hoisted(() => ({ @@ -32,7 +35,9 @@ vi.mock("../dashboardGatewayClient", () => ({ connected = true; request = dashboardMock.request; - constructor(options: { onEvent?: (event: DashboardRpcEvent) => void } = {}) { + constructor( + options: { onEvent?: (event: DashboardRpcEvent) => void } = {}, + ) { dashboardMock.onEvent = options.onEvent ?? null; dashboardMock.instances.push(this); } @@ -342,4 +347,24 @@ describe("useDashboardChatTransport recovery", () => { "prompt.submit", ); }); + + it("keeps pending assistant text when recreating context from the visible transcript", () => { + expect( + dashboardSeedMessagesFromTranscript([ + { id: "user-1", role: "user", content: "original prompt" }, + { + id: "agent-1", + role: "agent", + content: "partial work that streamed before reconnect", + pending: true, + }, + ]), + ).toEqual([ + { role: "user", content: "original prompt" }, + { + role: "assistant", + content: "partial work that streamed before reconnect", + }, + ]); + }); }); diff --git a/src/renderer/src/screens/Chat/hooks/useDashboardChatTransport.ts b/src/renderer/src/screens/Chat/hooks/useDashboardChatTransport.ts index 7e3bd3265..6cd4893be 100644 --- a/src/renderer/src/screens/Chat/hooks/useDashboardChatTransport.ts +++ b/src/renderer/src/screens/Chat/hooks/useDashboardChatTransport.ts @@ -57,9 +57,15 @@ interface FileAttachResponse { } interface DashboardPromptClient { - request(method: string, params?: unknown): Promise; + request( + method: string, + params?: unknown, + timeoutMs?: number, + ): Promise; } +export const DASHBOARD_SESSION_RESUME_TIMEOUT_MS = 120_000; + interface EnsureDashboardRuntimeSessionParams { client: DashboardPromptClient; contextFolder?: string | null; @@ -167,9 +173,13 @@ export async function submitDashboardPromptWithRecovery( throw err; } - const resumed = await client.request("session.resume", { - session_id: params.storedSessionId, - }); + const resumed = (await client.request( + "session.resume", + { + session_id: params.storedSessionId, + }, + DASHBOARD_SESSION_RESUME_TIMEOUT_MS, + )) as SessionResponse; const recoveredSessionId = resumed?.session_id; if (!recoveredSessionId) { throw err; @@ -192,14 +202,15 @@ export async function ensureDashboardRuntimeSession( if (stored) { try { - const resumed = await params.client.request( + const resumed = (await params.client.request( "session.resume", { session_id: stored, cols, ...(params.profile ? { profile: params.profile } : {}), }, - ); + DASHBOARD_SESSION_RESUME_TIMEOUT_MS, + )) as SessionResponse; if (!resumed.session_id) { throw new Error("session.resume returned no session_id"); } @@ -218,12 +229,12 @@ export async function ensureDashboardRuntimeSession( const seedMessages = dashboardSeedMessagesFromTranscript(params.messages, { excludeUserId: params.excludeSeedUserId ?? null, }); - const created = await params.client.request("session.create", { + const created = (await params.client.request("session.create", { cols, ...(seedMessages.length > 0 ? { messages: seedMessages } : {}), ...(params.contextFolder ? { cwd: params.contextFolder } : {}), ...(params.profile ? { profile: params.profile } : {}), - }); + })) as SessionResponse; return { created: true, @@ -393,14 +404,14 @@ export async function syncDashboardAttachmentsForSubmit( if (!contentBase64) return { handled: false, refs: [] }; try { - const result = await client.request( + const result = (await client.request( "image.attach_bytes", { session_id: sessionId, content_base64: contentBase64, filename: safeAttachmentFilename(image.name, index), }, - ); + )) as ImageAttachBytesResponse; if (!result?.attached) { throw new Error(result?.message || `Could not attach ${image.name}`); } @@ -433,10 +444,10 @@ export async function syncDashboardAttachmentsForSubmit( } try { - const result = await client.request( + const result = (await client.request( "file.attach", params, - ); + )) as FileAttachResponse; if (!result?.attached || !result.ref_text) { throw new Error(result?.message || `Could not attach ${name}`); } @@ -660,7 +671,11 @@ export function dashboardSeedMessagesFromTranscript( for (const message of messages) { if (!isBubbleMessage(message)) continue; if (message.role === "user" && message.id === options.excludeUserId) continue; - if (message.localOnly || message.error || message.pending) continue; + // Pending is renderer-local, but if a connection/runtime session has to be + // recreated from the visible transcript, that partially streamed assistant + // text is still the only context the user saw. Error/local-only rows stay + // excluded; pending-but-readable assistant content is preserved. + if (message.localOnly || message.error) continue; if (failedUserIds.has(message.id)) continue; const content = normalizeMessageText(message.content); if (!content) continue; diff --git a/src/renderer/src/screens/Layout/Layout.tsx b/src/renderer/src/screens/Layout/Layout.tsx index 05c4f5579..83dc3fa94 100644 --- a/src/renderer/src/screens/Layout/Layout.tsx +++ b/src/renderer/src/screens/Layout/Layout.tsx @@ -11,6 +11,11 @@ import { findRunBySession, loadingSessionIds as deriveLoadingSessionIds, } from "./chatRuns"; +import { + deleteChatRunTranscript, + persistChatRunsState, + restoreChatRunsState, +} from "./chatRunPersistence"; import { ActiveSessionsBar } from "./ActiveSessionsBar"; import Sessions from "../Sessions/Sessions"; import Agents from "../Agents/Agents"; @@ -103,12 +108,24 @@ function Layout({ }: LayoutProps = {}): React.JSX.Element { const { t } = useI18n(); const [view, setView] = useState("chat"); + const restoredRunsRef = useRef<{ + value: ReturnType; + } | null>(null); + if (restoredRunsRef.current === null) { + restoredRunsRef.current = { value: restoreChatRunsState() }; + } // Multiple conversations coexist (background sessions + multi-agent). Each is // a ChatRun; all are mounted, only the active one is shown. `activeProfile` // tracks the selected profile and always equals the active run's profile. - const [activeProfile, setActiveProfile] = useState("default"); - const [runs, setRuns] = useState(() => [mintRun("default")]); - const [activeRunId, setActiveRunId] = useState(() => runs[0].runId); + const [activeProfile, setActiveProfile] = useState( + () => restoredRunsRef.current?.value?.activeProfile ?? "default", + ); + const [runs, setRuns] = useState( + () => restoredRunsRef.current?.value?.runs ?? [mintRun("default")], + ); + const [activeRunId, setActiveRunId] = useState( + () => restoredRunsRef.current?.value?.activeRunId ?? runs[0].runId, + ); // While a resume's history is loading, show its spinner immediately. const [resumingSessionId, setResumingSessionId] = useState( null, @@ -121,6 +138,10 @@ function Layout({ const currentSessionId = runs.find((r) => r.runId === activeRunId)?.sessionId ?? null; + useEffect(() => { + persistChatRunsState(runs, activeRunId); + }, [runs, activeRunId]); + const loadingSessionIds = useMemo( () => deriveLoadingSessionIds(runs), [runs], @@ -399,6 +420,7 @@ function Layout({ const handleCloseRun = useCallback( (runId: string) => { window.hermesAPI.abortChat(runId); + deleteChatRunTranscript(runId); const idx = runs.findIndex((r) => r.runId === runId); const remaining = runs.filter((r) => r.runId !== runId); if (remaining.length === 0) { diff --git a/src/renderer/src/screens/Layout/chatRunPersistence.test.ts b/src/renderer/src/screens/Layout/chatRunPersistence.test.ts new file mode 100644 index 000000000..96457dc23 --- /dev/null +++ b/src/renderer/src/screens/Layout/chatRunPersistence.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import type { ChatRun } from "./chatRuns"; +import { + deleteChatRunTranscript, + loadChatRunTranscript, + persistChatRunsState, + restoreChatRunsState, + saveChatRunTranscript, +} from "./chatRunPersistence"; +import type { ChatMessage } from "../Chat/types"; + +const run: ChatRun = { + runId: "run-saved", + profile: "research-agent", + sessionId: "session-saved", + loading: true, + title: "Long investigation", +}; + +const transcript: ChatMessage[] = [ + { + id: "user-1", + role: "user", + content: "trace the context flow", + }, + { + id: "agent-1", + role: "agent", + content: "partial analysis that streamed before reload", + pending: true, + }, +]; + +describe("chat run persistence", () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + it("restores open runs with their visible transcript snapshots", () => { + saveChatRunTranscript(run.runId, transcript); + persistChatRunsState([run], run.runId); + + const restored = restoreChatRunsState(); + + expect(restored?.activeRunId).toBe(run.runId); + expect(restored?.activeProfile).toBe(run.profile); + expect(restored?.runs).toHaveLength(1); + expect(restored?.runs[0]).toMatchObject({ + runId: run.runId, + profile: run.profile, + sessionId: run.sessionId, + loading: false, + title: run.title, + }); + expect(restored?.runs[0].seed?.[0]).toMatchObject(transcript[0]); + expect(restored?.runs[0].seed?.[1]).toMatchObject({ + id: "agent-1", + role: "agent", + content: "partial analysis that streamed before reload", + pending: false, + }); + }); + + it("deletes transcript snapshots for discarded runs", () => { + saveChatRunTranscript(run.runId, transcript); + expect(loadChatRunTranscript(run.runId)).toHaveLength(2); + + deleteChatRunTranscript(run.runId); + + expect(loadChatRunTranscript(run.runId)).toEqual([]); + }); +}); diff --git a/src/renderer/src/screens/Layout/chatRunPersistence.ts b/src/renderer/src/screens/Layout/chatRunPersistence.ts new file mode 100644 index 000000000..7bb34a74f --- /dev/null +++ b/src/renderer/src/screens/Layout/chatRunPersistence.ts @@ -0,0 +1,286 @@ +import type { ChatRun } from "./chatRuns"; +import type { + ChatBubbleMessage, + ChatMessage, + ClarifyMessage, + ReasoningMessage, + ToolCallMessage, + ToolResultMessage, +} from "../Chat/types"; + +const RUNS_STORAGE_KEY = "hermes.chat.runs.v1"; +const TRANSCRIPT_STORAGE_PREFIX = "hermes.chat.transcript."; +const MAX_RESTORED_RUNS = 12; + +interface PersistedRunsState { + activeRunId?: string; + runs?: PersistedRunMeta[]; +} + +interface PersistedRunMeta { + runId?: string; + profile?: string; + sessionId?: string | null; + title?: string; + updatedAt?: number; +} + +interface RestoredRunsState { + activeProfile: string; + activeRunId: string; + runs: ChatRun[]; +} + +function transcriptStorageKey(runId: string): string { + return `${TRANSCRIPT_STORAGE_PREFIX}${runId}`; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function optionalString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function optionalBoolean(value: unknown): boolean | undefined { + return typeof value === "boolean" ? value : undefined; +} + +function sanitizeAttachments(value: unknown): ChatBubbleMessage["attachments"] { + if (!Array.isArray(value)) return undefined; + const attachments = value.filter(isRecord) as unknown as NonNullable< + ChatBubbleMessage["attachments"] + >; + return attachments.length > 0 ? attachments : undefined; +} + +function sanitizeBubbleMessage( + row: Record, +): ChatBubbleMessage | null { + const role = row.role === "agent" || row.role === "user" ? row.role : null; + if (!role) return null; + const content = optionalString(row.content) ?? ""; + const error = optionalString(row.error); + const attachments = sanitizeAttachments(row.attachments); + if (!content.trim() && !error?.trim() && !attachments?.length) return null; + + return { + id: optionalString(row.id) || `restored-${role}-${Date.now()}`, + role, + content, + ...(row.kind === "user" || row.kind === "assistant" + ? { kind: row.kind } + : {}), + ...(attachments ? { attachments } : {}), + ...(error ? { error } : {}), + ...(optionalBoolean(row.localOnly) ? { localOnly: true } : {}), + ...(optionalString(row.turnId) + ? { turnId: optionalString(row.turnId) } + : {}), + // A restored renderer snapshot cannot still be actively streaming. Dropping + // the pending flag keeps recovered assistant text eligible as context when + // the dashboard has to recreate a runtime session from the visible transcript. + pending: false, + }; +} + +function sanitizeReasoningMessage( + row: Record, +): ReasoningMessage | null { + const text = optionalString(row.text) ?? ""; + if (!text.trim()) return null; + return { + id: optionalString(row.id) || `restored-reasoning-${Date.now()}`, + kind: "reasoning", + role: "agent", + text, + }; +} + +function sanitizeToolCallMessage( + row: Record, +): ToolCallMessage | null { + const name = optionalString(row.name) ?? ""; + const args = optionalString(row.args) ?? ""; + const callId = optionalString(row.callId) ?? ""; + if (!name && !args && !callId) return null; + return { + id: optionalString(row.id) || `restored-tool-call-${Date.now()}`, + kind: "tool_call", + role: "agent", + callId, + name, + args, + ...(row.status === "running" || + row.status === "completed" || + row.status === "failed" + ? { status: row.status } + : {}), + }; +} + +function sanitizeToolResultMessage( + row: Record, +): ToolResultMessage | null { + const content = optionalString(row.content) ?? ""; + const attachments = sanitizeAttachments(row.attachments); + if (!content.trim() && !attachments?.length) return null; + return { + id: optionalString(row.id) || `restored-tool-result-${Date.now()}`, + kind: "tool_result", + role: "agent", + callId: optionalString(row.callId) ?? "", + name: optionalString(row.name) ?? "", + content, + ...(attachments ? { attachments } : {}), + }; +} + +function sanitizeClarifyMessage( + row: Record, +): ClarifyMessage | null { + const requestId = optionalString(row.requestId) ?? ""; + const question = optionalString(row.question) ?? ""; + if (!requestId || !question) return null; + return { + id: optionalString(row.id) || `restored-clarify-${requestId}`, + kind: "clarify", + role: "agent", + requestId, + question, + choices: Array.isArray(row.choices) + ? row.choices.filter( + (choice): choice is string => typeof choice === "string", + ) + : [], + ...(optionalString(row.answer) + ? { answer: optionalString(row.answer) } + : {}), + ...(optionalBoolean(row.resolved) ? { resolved: true } : {}), + }; +} + +function sanitizeChatMessage(value: unknown): ChatMessage | null { + if (!isRecord(value)) return null; + switch (value.kind) { + case "reasoning": + return sanitizeReasoningMessage(value); + case "tool_call": + return sanitizeToolCallMessage(value); + case "tool_result": + return sanitizeToolResultMessage(value); + case "clarify": + return sanitizeClarifyMessage(value); + default: + return sanitizeBubbleMessage(value); + } +} + +export function loadChatRunTranscript(runId: string): ChatMessage[] { + if (!runId) return []; + try { + const raw = window.localStorage.getItem(transcriptStorageKey(runId)); + if (!raw) return []; + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) return []; + return parsed + .map(sanitizeChatMessage) + .filter((message): message is ChatMessage => message !== null); + } catch { + return []; + } +} + +export function saveChatRunTranscript( + runId: string, + messages: ReadonlyArray, +): void { + if (!runId) return; + try { + if (messages.length === 0) { + window.localStorage.removeItem(transcriptStorageKey(runId)); + return; + } + window.localStorage.setItem( + transcriptStorageKey(runId), + JSON.stringify(messages), + ); + } catch { + // localStorage can be unavailable or full. The canonical Hermes session DB + // still remains the primary source for completed turns; this snapshot is a + // best-effort crash/reload safety net for renderer-visible state. + } +} + +export function deleteChatRunTranscript(runId: string): void { + if (!runId) return; + try { + window.localStorage.removeItem(transcriptStorageKey(runId)); + } catch { + /* ignore */ + } +} + +export function persistChatRunsState( + runs: ReadonlyArray, + activeRunId: string, +): void { + try { + const persisted: PersistedRunsState = { + activeRunId, + runs: runs.slice(-MAX_RESTORED_RUNS).map((run) => ({ + runId: run.runId, + profile: run.profile, + sessionId: run.sessionId, + title: run.title, + updatedAt: Date.now(), + })), + }; + window.localStorage.setItem(RUNS_STORAGE_KEY, JSON.stringify(persisted)); + } catch { + /* ignore */ + } +} + +export function restoreChatRunsState(): RestoredRunsState | null { + try { + const raw = window.localStorage.getItem(RUNS_STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as unknown; + if (!isRecord(parsed) || !Array.isArray(parsed.runs)) return null; + + const runs: ChatRun[] = []; + const seen = new Set(); + for (const item of parsed.runs) { + if (!isRecord(item)) continue; + const runId = optionalString(item.runId); + const profile = optionalString(item.profile) || "default"; + if (!runId || seen.has(runId)) continue; + seen.add(runId); + runs.push({ + runId, + profile, + sessionId: optionalString(item.sessionId) ?? null, + loading: false, + ...(optionalString(item.title) + ? { title: optionalString(item.title) } + : {}), + seed: loadChatRunTranscript(runId), + }); + } + + if (runs.length === 0) return null; + const storedActiveRunId = optionalString(parsed.activeRunId); + const activeRunId = + storedActiveRunId && runs.some((run) => run.runId === storedActiveRunId) + ? storedActiveRunId + : runs[0].runId; + const activeProfile = + runs.find((run) => run.runId === activeRunId)?.profile || runs[0].profile; + + return { activeRunId, activeProfile, runs }; + } catch { + return null; + } +} diff --git a/tests/dashboard-chat-transport.test.ts b/tests/dashboard-chat-transport.test.ts index 9db2d8789..26a000ad7 100644 --- a/tests/dashboard-chat-transport.test.ts +++ b/tests/dashboard-chat-transport.test.ts @@ -7,6 +7,7 @@ import { completionFailed, dashboardPromptTextWithAttachmentRefs, dashboardShouldPersistLocalOverlays, + DASHBOARD_SESSION_RESUME_TIMEOUT_MS, ensureDashboardRuntimeSession, isDashboardSlashWorkerExitError, dashboardSeedMessagesFromTranscript, @@ -393,10 +394,18 @@ describe("dashboard attachment sync", () => { describe("submitDashboardPromptWithRecovery", () => { it("resumes the stored session and retries once when the live session is gone", async () => { - const calls: Array<{ method: string; params: unknown }> = []; + const calls: Array<{ method: string; params: unknown; timeoutMs?: number }> = []; const client = { - async request(method: string, params?: unknown): Promise { - calls.push({ method, params }); + async request( + method: string, + params?: unknown, + timeoutMs?: number, + ): Promise { + calls.push({ + method, + params, + ...(timeoutMs ? { timeoutMs } : {}), + }); if (calls.length === 1) { throw new Error("session not found"); } @@ -427,6 +436,7 @@ describe("submitDashboardPromptWithRecovery", () => { { method: "session.resume", params: { session_id: "stored-1" }, + timeoutMs: DASHBOARD_SESSION_RESUME_TIMEOUT_MS, }, { method: "prompt.submit", @@ -454,10 +464,18 @@ describe("submitDashboardPromptWithRecovery", () => { describe("ensureDashboardRuntimeSession", () => { it("resumes an existing stored session and preserves the durable id", async () => { - const calls: Array<{ method: string; params: unknown }> = []; + const calls: Array<{ method: string; params: unknown; timeoutMs?: number }> = []; const client = { - async request(method: string, params?: unknown): Promise { - calls.push({ method, params }); + async request( + method: string, + params?: unknown, + timeoutMs?: number, + ): Promise { + calls.push({ + method, + params, + ...(timeoutMs ? { timeoutMs } : {}), + }); return { session_id: "live-resumed", resumed: "stored-1" }; }, }; @@ -478,15 +496,24 @@ describe("ensureDashboardRuntimeSession", () => { { method: "session.resume", params: { session_id: "stored-1", cols: 96, profile: "work" }, + timeoutMs: DASHBOARD_SESSION_RESUME_TIMEOUT_MS, }, ]); }); it("creates a seeded session when a stale stored id cannot be resumed", async () => { - const calls: Array<{ method: string; params: unknown }> = []; + const calls: Array<{ method: string; params: unknown; timeoutMs?: number }> = []; const client = { - async request(method: string, params?: unknown): Promise { - calls.push({ method, params }); + async request( + method: string, + params?: unknown, + timeoutMs?: number, + ): Promise { + calls.push({ + method, + params, + ...(timeoutMs ? { timeoutMs } : {}), + }); if (method === "session.resume") { throw new Error("session not found"); } @@ -515,6 +542,7 @@ describe("ensureDashboardRuntimeSession", () => { { method: "session.resume", params: { session_id: "missing-stored", cols: 96 }, + timeoutMs: DASHBOARD_SESSION_RESUME_TIMEOUT_MS, }, { method: "session.create", diff --git a/tests/dashboard-gateway-client.test.ts b/tests/dashboard-gateway-client.test.ts index 6d5af94b9..c797b96ee 100644 --- a/tests/dashboard-gateway-client.test.ts +++ b/tests/dashboard-gateway-client.test.ts @@ -1,5 +1,12 @@ -import { describe, expect, it } from "vitest"; -import { normalizeDashboardNotification } from "../src/renderer/src/screens/Chat/dashboardGatewayClient"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + DashboardGatewayClient, + normalizeDashboardNotification, +} from "../src/renderer/src/screens/Chat/dashboardGatewayClient"; + +afterEach(() => { + vi.useRealTimers(); +}); describe("normalizeDashboardNotification", () => { it("normalizes upstream JSON-RPC event envelopes", () => { @@ -34,3 +41,42 @@ describe("normalizeDashboardNotification", () => { }); }); }); + +describe("DashboardGatewayClient", () => { + it("honors per-request timeout overrides", async () => { + vi.useFakeTimers(); + const client = new DashboardGatewayClient({ requestTimeoutMs: 30_000 }); + const socket = { + close: vi.fn(), + readyState: WebSocket.OPEN, + send: vi.fn(), + }; + ( + client as unknown as { + socket: typeof socket; + } + ).socket = socket; + + const request = client.request("session.resume", {}, 120_000); + let rejected = false; + request.catch(() => { + rejected = true; + }); + + await vi.advanceTimersByTimeAsync(30_000); + expect(rejected).toBe(false); + + await vi.advanceTimersByTimeAsync(90_000); + await expect(request).rejects.toThrow( + "Hermes dashboard request timed out: session.resume", + ); + expect(socket.send).toHaveBeenCalledWith( + JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "session.resume", + params: {}, + }), + ); + }); +});