From 83caab8f23d89a704781071f819adca95924a1b7 Mon Sep 17 00:00:00 2001 From: Katya Date: Fri, 9 Oct 2026 18:55:52 +0200 Subject: [PATCH] feat: keep assistant conversations per user in sessionStorage --- .../components/assistant/AssistantRoot.tsx | 11 +- .../components/assistant/useAssistantChat.ts | 169 ++++++- .../assistant/AssistantRoot.test.tsx | 28 ++ .../assistant/useAssistantChat.test.ts | 430 +++++++++++++++++- 4 files changed, 612 insertions(+), 26 deletions(-) diff --git a/frontend/src/components/assistant/AssistantRoot.tsx b/frontend/src/components/assistant/AssistantRoot.tsx index ddc59b8a..2c20dd53 100644 --- a/frontend/src/components/assistant/AssistantRoot.tsx +++ b/frontend/src/components/assistant/AssistantRoot.tsx @@ -3,6 +3,7 @@ import { useAuth, registerSignOutListener } from "@/contexts/AuthContext"; import { useAssistantUI } from "@/contexts/AssistantUIContext"; import { useAssistantConfig } from "./useAssistantConfig"; import { AssistantPanel } from "./AssistantPanel"; +import { clearAssistantHistory } from "./useAssistantChat"; import "./i18n"; /** @@ -31,7 +32,15 @@ export default function AssistantRoot() { [setAvailable, closeAssistant] ); - useEffect(() => registerSignOutListener(closeAssistant), [closeAssistant]); + // One listener for both, so a later user in the same tab never sees these answers. + useEffect( + () => + registerSignOutListener(() => { + clearAssistantHistory(); + closeAssistant(); + }), + [closeAssistant] + ); if (!available) { return null; diff --git a/frontend/src/components/assistant/useAssistantChat.ts b/frontend/src/components/assistant/useAssistantChat.ts index 0bd33410..2485dc0f 100644 --- a/frontend/src/components/assistant/useAssistantChat.ts +++ b/frontend/src/components/assistant/useAssistantChat.ts @@ -6,7 +6,9 @@ import type { AnswerChecks, ChatTurn, SourceRef, TurnTiming } from "@/types/api" import { streamAssistantMessage } from "./assistant-api"; import { readAssistantSseStream, type AssistantSseEvent } from "./sse"; -export type AssistantMessageStatus = "pending" | "done" | "error" | "cut" | "cancelled"; +const MESSAGE_STATUSES = ["pending", "done", "error", "cut", "cancelled"] as const; + +export type AssistantMessageStatus = (typeof MESSAGE_STATUSES)[number]; export interface AssistantMessageError { readonly code: number; @@ -27,14 +29,102 @@ export interface AssistantMessage { readonly createdAt: number; } +/** sessionStorage keys are `${STORAGE_PREFIX}:${userId}:${projectId ?? "general"}`. */ +export const STORAGE_PREFIX = "assistant-chat"; + // AssistantChatRequest.history takes at most 20 entries (api/schemas/assistant.py). const MAX_HISTORY_ENTRIES = 20; -// The most messages kept in a conversation. +// The list kept in memory, and the same number is what is stored. const MAX_MESSAGES = 40; +// Bumped by clearAssistantHistory(). A thread remembers the value it was loaded +// under and stops writing once that value is out of date, so a sign-out cannot be +// undone by a hook that is still mounted when it happens. +let historyEpoch = 0; + interface Thread { readonly key: string; readonly messages: AssistantMessage[]; + readonly epoch: number; + // How many times an action (append, settle, clear) asked for the thread to be + // stored. It travels inside the state with the change it belongs to, so no render + // can take the request for a save without also holding that change. + readonly writes: number; +} + +function storageKeyFor(userId: string, projectId: string | null): string { + return `${STORAGE_PREFIX}:${userId}:${projectId ?? "general"}`; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isStoredMessage(value: unknown): value is AssistantMessage { + if (!isRecord(value)) return false; + const { error, sources, toolsUsed, checks, timing } = value; + return ( + typeof value.id === "string" && + (value.role === "user" || value.role === "assistant") && + typeof value.content === "string" && + typeof value.status === "string" && + (MESSAGE_STATUSES as readonly string[]).includes(value.status) && + typeof value.createdAt === "number" && + (error === undefined || + (isRecord(error) && + typeof error.code === "number" && + typeof error.detail === "string" && + (error.retryAfter === undefined || typeof error.retryAfter === "number"))) && + (sources === undefined || Array.isArray(sources)) && + (toolsUsed === undefined || Array.isArray(toolsUsed)) && + (checks === undefined || isRecord(checks)) && + (timing === undefined || isRecord(timing)) + ); +} + +// A request cannot outlive the page that made it, so a message stored as pending +// is a partial answer that was cut off, empty or not. +function loadMessages(key: string): AssistantMessage[] { + try { + const parsed: unknown = JSON.parse(window.sessionStorage.getItem(key) ?? "[]"); + if (!Array.isArray(parsed)) return []; + return parsed + .filter(isStoredMessage) + .slice(-MAX_MESSAGES) + .map((m) => (m.status === "pending" ? { ...m, status: "cut" } : m)); + } catch { + return []; + } +} + +// Storage that is blocked, full or absent must never throw out of the hook. +function saveMessages({ key, messages, epoch }: Thread): void { + if (epoch !== historyEpoch) return; + try { + if (messages.length === 0) { + window.sessionStorage.removeItem(key); + } else { + window.sessionStorage.setItem(key, JSON.stringify(messages)); + } + } catch { + // The conversation simply is not kept across a reload. + } +} + +/** Removes the conversations of every user and project, for sign-out. */ +export function clearAssistantHistory(): void { + historyEpoch += 1; + try { + const storage = window.sessionStorage; + const keys: string[] = []; + for (let i = 0; i < storage.length; i += 1) { + const key = storage.key(i); + if (key?.startsWith(`${STORAGE_PREFIX}:`)) keys.push(key); + } + keys.forEach((key) => storage.removeItem(key)); + } catch { + // Nothing was stored that could be cleared. + } } // Only a message that was sent and answered in full goes back to the server: a @@ -125,27 +215,46 @@ async function consumeStream( if (!progress.finished) throw new StreamIncompleteError(); } -/** - * `userId` is the signed-in user the conversation belongs to. The hook does not read - * it: a conversation lives in memory and is told apart by project alone. - */ +// A thread that no longer holds the message (cleared, or another conversation) is +// left as it is. +function patchThread( + thread: Thread, + id: string, + patch: (m: AssistantMessage) => AssistantMessage, + settles: boolean +): Thread { + if (!thread.messages.some((m) => m.id === id)) return thread; + return { + ...thread, + messages: patchMessage(thread.messages, id, patch), + writes: thread.writes + (settles ? 1 : 0), + }; +} + export function useAssistantChat({ projectId, + userId, }: { readonly projectId: string | null; readonly userId: string; }) { - const threadKey = projectId ?? "general"; - const [thread, setThread] = useState(() => ({ key: threadKey, messages: [] })); + const storageKey = storageKeyFor(userId, projectId); + const [thread, setThread] = useState(() => ({ + key: storageKey, + messages: loadMessages(storageKey), + epoch: historyEpoch, + writes: 0, + })); const [draft, setDraft] = useState(""); const [now, setNow] = useState(0); const abortRef = useRef(null); + const savedRef = useRef({ key: storageKey, writes: 0 }); - // A different project is a different conversation; the render that sees the - // new key swaps the thread before anything is committed. + // A different user or project is a different conversation; the render that + // sees the new key swaps the thread before anything is committed. let current = thread; - if (thread.key !== threadKey) { - current = { key: threadKey, messages: [] }; + if (thread.key !== storageKey) { + current = { key: storageKey, messages: loadMessages(storageKey), epoch: historyEpoch, writes: 0 }; setThread(current); } const { messages } = current; @@ -155,6 +264,16 @@ export function useAssistantChat({ const startedAt = pendingMessage?.createdAt ?? null; const pendingSeconds = startedAt === null ? 0 : Math.max(0, Math.floor((now - startedAt) / 1000)); + // Stored after a turn is appended (the answer still empty), settles or is cleared, + // never on mount and never while tokens are arriving: a stream of any length + // writes storage twice. + useEffect(() => { + const saved = savedRef.current; + savedRef.current = { key: current.key, writes: current.writes }; + if (saved.key !== current.key || saved.writes === current.writes) return; + saveMessages(current); + }, [current]); + useEffect(() => { if (!isPending) return; const interval = setInterval(() => setNow(Date.now()), 1000); @@ -162,19 +281,19 @@ export function useAssistantChat({ }, [isPending]); // The Sheet keeps the panel mounted while it is open, so closing it does not - // cancel; the root going away, or the conversation changing under a request, does. + // cancel; the root going away, or the conversation changing under a request, + // does. The partial answer is dropped with it and is not written to storage. useEffect( () => () => { abortRef.current?.abort(); }, - [threadKey] + [storageKey] ); const sendMessage = useCallback(async () => { const text = draft.trim(); if (!text || abortRef.current) return; - const key = current.key; const startedAtMs = Date.now(); const pendingId = crypto.randomUUID(); const history: ChatTurn[] = historyWindow(current.messages).map((index) => ({ @@ -196,8 +315,11 @@ export function useAssistantChat({ createdAt: startedAtMs, }; - const patchAnswer = (patch: (m: AssistantMessage) => AssistantMessage) => - setThread((prev) => ({ ...prev, messages: patchMessage(prev.messages, pendingId, patch) })); + const patchAnswer = (patch: (m: AssistantMessage) => AssistantMessage, settles = false) => + setThread((prev) => patchThread(prev, pendingId, patch, settles)); + // The turn ends here, so what it leaves behind is worth storing. + const settleAnswer = (patch: (m: AssistantMessage) => AssistantMessage) => + patchAnswer(patch, true); // Nothing was answered, so the question goes back into the box, unless the // user has already started typing another one. The question is marked as @@ -207,13 +329,18 @@ export function useAssistantChat({ setThread((prev) => ({ ...prev, messages: patchMessage(prev.messages, userMessage.id, markFailed), + writes: prev.writes + 1, })); }; const progress: StreamProgress = { finished: false, hasContent: false }; const controller = new AbortController(); abortRef.current = controller; - setThread({ key, messages: [...current.messages, userMessage, answer].slice(-MAX_MESSAGES) }); + setThread({ + ...current, + messages: [...current.messages, userMessage, answer].slice(-MAX_MESSAGES), + writes: current.writes + 1, + }); setDraft(""); setNow(startedAtMs); @@ -225,7 +352,7 @@ export function useAssistantChat({ if (!response.body) throw new StreamIncompleteError(); await consumeStream(response.body, controller.signal, progress, (event) => { - patchAnswer((m) => applyEvent(m, event)); + patchAnswer((m) => applyEvent(m, event), event.type !== "token"); if (event.type === "error" && !progress.hasContent) restoreDraft(); }); } catch (error) { @@ -234,7 +361,7 @@ export function useAssistantChat({ if (!aborted) { logger.error("Assistant turn failed", { error: error instanceof Error ? error.name : typeof error }); } - patchAnswer((m) => applyFailure(m, error, aborted, progress.hasContent)); + settleAnswer((m) => applyFailure(m, error, aborted, progress.hasContent)); if (!aborted && !progress.hasContent) restoreDraft(); } finally { if (abortRef.current === controller) abortRef.current = null; @@ -248,7 +375,7 @@ export function useAssistantChat({ const clear = useCallback(() => { abortRef.current?.abort(); abortRef.current = null; - setThread((prev) => ({ key: prev.key, messages: [] })); + setThread((prev) => ({ ...prev, messages: [], writes: prev.writes + 1 })); }, []); const sentIndexes = historyWindow(messages); diff --git a/frontend/tests/components/assistant/AssistantRoot.test.tsx b/frontend/tests/components/assistant/AssistantRoot.test.tsx index b6c147ae..2e8c2c32 100644 --- a/frontend/tests/components/assistant/AssistantRoot.test.tsx +++ b/frontend/tests/components/assistant/AssistantRoot.test.tsx @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { act, render, screen, waitFor } from '@testing-library/react'; import i18n from '@/i18n'; import AssistantRoot from '@/components/assistant/AssistantRoot'; +import { STORAGE_PREFIX } from '@/components/assistant/useAssistantChat'; import { AssistantUIProvider, useAssistantUI } from '@/contexts/AssistantUIContext'; const mockSetAvailable = vi.fn(); @@ -54,6 +55,7 @@ describe('AssistantRoot', () => { mockUI = { isOpen: false, projectId: null }; signOutListener = undefined; useRealUI = false; + window.sessionStorage.clear(); }); it('renders nothing and reports unavailable while the config query is loading', async () => { @@ -128,6 +130,32 @@ describe('AssistantRoot', () => { expect(mockUnsubscribe).toHaveBeenCalled(); }); + it('forgets every stored conversation on sign-out and leaves other storage alone', () => { + mockUseAssistantConfig.mockReturnValue(enabledConfig); + window.sessionStorage.setItem(`${STORAGE_PREFIX}:u1:general`, '[]'); + window.sessionStorage.setItem('unrelated', 'kept'); + render(); + + signOutListener?.(); + + expect(window.sessionStorage.getItem(`${STORAGE_PREFIX}:u1:general`)).toBeNull(); + expect(window.sessionStorage.getItem('unrelated')).toBe('kept'); + }); + + it('forgets the stored conversations before it closes the panel', () => { + mockUseAssistantConfig.mockReturnValue(enabledConfig); + window.sessionStorage.setItem(`${STORAGE_PREFIX}:u1:general`, '[]'); + let storedWhenClosed: string | null = 'not read'; + mockCloseAssistant.mockImplementation(() => { + storedWhenClosed = window.sessionStorage.getItem(`${STORAGE_PREFIX}:u1:general`); + }); + render(); + + signOutListener?.(); + + expect(storedWhenClosed).toBeNull(); + }); + it('registers the assistant i18n bundle for both locales', () => { mockUseAssistantConfig.mockReturnValue({ isSuccess: false, data: undefined }); diff --git a/frontend/tests/components/assistant/useAssistantChat.test.ts b/frontend/tests/components/assistant/useAssistantChat.test.ts index b3f24367..fc4747ce 100644 --- a/frontend/tests/components/assistant/useAssistantChat.test.ts +++ b/frontend/tests/components/assistant/useAssistantChat.test.ts @@ -1,7 +1,13 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { renderHook, act, waitFor } from '@testing-library/react'; +import { Storage } from 'happy-dom'; import i18n from '@/i18n'; -import { useAssistantChat } from '@/components/assistant/useAssistantChat'; +import { + STORAGE_PREFIX, + clearAssistantHistory, + useAssistantChat, + type AssistantMessage, +} from '@/components/assistant/useAssistantChat'; import type { AssistantSseEvent } from '@/components/assistant/sse'; import { HttpError, @@ -74,6 +80,24 @@ function openStream() { }; } +function storedMessage( + index: number, + role: 'user' | 'assistant', + status: AssistantMessage['status'] = 'done', + content = `${role} ${index}` +): AssistantMessage { + return { id: `m${index}`, role, content, status, createdAt: 1000 + index }; +} + +function seed(messages: AssistantMessage[], userId = 'u1', projectId = 'general') { + window.sessionStorage.setItem(`${STORAGE_PREFIX}:${userId}:${projectId}`, JSON.stringify(messages)); +} + +function stored(userId = 'u1', projectId = 'general'): AssistantMessage[] | null { + const raw = window.sessionStorage.getItem(`${STORAGE_PREFIX}:${userId}:${projectId}`); + return raw === null ? null : (JSON.parse(raw) as AssistantMessage[]); +} + function setup(props: { projectId?: string | null; userId?: string } = {}) { return renderHook( (p: { projectId: string | null; userId: string }) => useAssistantChat(p), @@ -103,6 +127,7 @@ describe('useAssistantChat', () => { mockReadAssistantSseStream.mockReset(); vi.spyOn(console, 'error').mockImplementation(() => undefined); mockStreamAssistantMessage.mockResolvedValue({ body: {} } as Response); + window.sessionStorage.clear(); }); afterEach(async () => { @@ -385,17 +410,18 @@ describe('useAssistantChat', () => { expect(result.current.draft).toBe(''); }); - it('empties the conversation and aborts the request in flight', async () => { + it('empties the conversation, aborts the request in flight and drops the stored copy', async () => { const stream = openStream(); const { result } = setup(); ask(result); stream.token('Partial'); - await waitFor(() => expect(result.current.messages[1].content).toBe('Partial')); + await waitFor(() => expect(stored()).toHaveLength(2)); act(() => result.current.clear()); expect(result.current.messages).toHaveLength(0); expect(mockStreamAssistantMessage.mock.calls[0][1].aborted).toBe(true); + expect(stored()).toBeNull(); await waitFor(() => expect(result.current.isPending).toBe(false)); expect(result.current.messages).toHaveLength(0); }); @@ -430,6 +456,26 @@ describe('useAssistantChat', () => { expect(mockStreamAssistantMessage.mock.calls[1][1].aborted).toBe(true); }); + it('drops the stored copy on clear even while a token render is still on its way', async () => { + // A token that has been read but not yet rendered must not cost the clear its + // write. Whether it lands before the clear's own render depends on scheduling, + // so the order is tried many times over. + for (let round = 0; round < 60; round += 1) { + const stream = openStream(); + const { result, unmount } = setup(); + ask(result); + stream.token('Partial'); + await waitFor(() => expect(stored()).toHaveLength(2)); + + act(() => result.current.clear()); + + expect(stored()).toBeNull(); + unmount(); + await nextTick(); + window.sessionStorage.clear(); + } + }); + it('lets a new question go out right after a clear', async () => { openStream(); const { result } = setup(); @@ -459,7 +505,7 @@ describe('useAssistantChat', () => { expect(result.current.messages).toHaveLength(2); }); - it('aborts the request when the hook unmounts', async () => { + it('aborts the request when the hook unmounts and does not store the partial', async () => { const stream = openStream(); const { result, unmount } = setup(); ask(result); @@ -467,8 +513,19 @@ describe('useAssistantChat', () => { await waitFor(() => expect(result.current.messages[1].content).toBe('Partial')); unmount(); + await nextTick(); expect(mockStreamAssistantMessage.mock.calls[0][1].aborted).toBe(true); + // Only what the append wrote is there: the answer still empty. + expect(stored()?.[1]).toMatchObject({ status: 'pending', content: '' }); + }); + + it('leaves storage alone when it unmounts with nothing in flight', () => { + const { unmount } = setup(); + + unmount(); + + expect(stored()).toBeNull(); }); }); @@ -776,4 +833,369 @@ describe('useAssistantChat', () => { expect(result.current.messages).toEqual([]); }); }); + + describe('persistence', () => { + it('saves the conversation under the user and project key', async () => { + const stream = openStream(); + const { result } = setup({ projectId: 'p1', userId: 'u7' }); + + ask(result, 'hi'); + await waitFor(() => expect(stored('u7', 'p1')?.[0].content).toBe('hi')); + stream.token('Part'); + stream.done(); + + await waitFor(() => expect(stored('u7', 'p1')?.[1].status).toBe('done')); + expect(stored('u7', 'p1')?.map((m) => m.role)).toEqual(['user', 'assistant']); + expect(stored('u7', 'general')).toBeNull(); + expect(stored('u1', 'p1')).toBeNull(); + }); + + it('loads the saved conversation on mount', () => { + seed([storedMessage(0, 'user'), storedMessage(1, 'assistant')], 'u1', 'p1'); + + const { result } = setup({ projectId: 'p1' }); + + expect(result.current.messages.map((m) => m.content)).toEqual(['user 0', 'assistant 1']); + }); + + it('turns a message stored as pending into a cut one on load', () => { + seed([storedMessage(0, 'user'), storedMessage(1, 'assistant', 'pending', 'Half')]); + + const { result } = setup(); + + expect(result.current.messages[1]).toMatchObject({ status: 'cut', content: 'Half' }); + expect(result.current.isPending).toBe(false); + }); + + it('keeps at most the last 40 messages, in memory after a load and in storage', async () => { + seed(Array.from({ length: 45 }, (_, i) => storedMessage(i, i % 2 === 0 ? 'user' : 'assistant'))); + openStream().done(); + const { result } = setup(); + expect(result.current.messages).toHaveLength(40); + expect(result.current.messages[0].id).toBe('m5'); + + ask(result, 'next'); + + await waitFor(() => expect(result.current.isPending).toBe(false)); + expect(stored()).toHaveLength(40); + expect(stored()?.[39]).toMatchObject({ role: 'assistant', content: RESPONSE.answer }); + }); + + it.each([ + ['not JSON', 'not json'], + ['not a list', '{"a":1}'], + ])('starts empty when the stored value is %s', (_name, raw) => { + window.sessionStorage.setItem(`${STORAGE_PREFIX}:u1:general`, raw); + + const { result } = setup(); + + expect(result.current.messages).toEqual([]); + }); + + it('drops stored entries that are not messages', () => { + seed([ + storedMessage(0, 'user'), + { id: 1 } as unknown as AssistantMessage, + null as unknown as AssistantMessage, + { ...storedMessage(2, 'assistant'), status: 'weird' as AssistantMessage['status'] }, + { ...storedMessage(3, 'assistant'), role: 'system' as AssistantMessage['role'] }, + ]); + + const { result } = setup(); + + expect(result.current.messages.map((m) => m.id)).toEqual(['m0']); + }); + + it('keeps a stored message whose optional parts have the right shape', () => { + const full = { + ...storedMessage(1, 'assistant'), + error: { code: 429, detail: 'Too many requests', retryAfter: 30 }, + sources: [SOURCE], + toolsUsed: ['search_docs'], + checks: CHECKS, + timing: { ttft_ms: 12, total_ms: 340 }, + }; + seed([storedMessage(0, 'user'), full as AssistantMessage]); + + const { result } = setup(); + + expect(result.current.messages[1]).toEqual(full); + }); + + it.each([ + ['an error that is not an object', { error: 'boom' }], + ['an error without a numeric code', { error: { code: '500', detail: 'x' } }], + ['an error without a string detail', { error: { code: 500, detail: 7 } }], + ['an error with a retryAfter that is not a number', { error: { code: 429, detail: 'x', retryAfter: '30' } }], + ['a null error', { error: null }], + ['sources that are not a list', { sources: { n: 1 } }], + ['toolsUsed that is not a list', { toolsUsed: 'search_docs' }], + ['checks that are a list', { checks: [] }], + ['checks that are a string', { checks: 'ok' }], + ['timing that is not an object', { timing: 12 }], + ])('drops a stored message with %s', (_name, bad) => { + seed([storedMessage(0, 'user'), { ...storedMessage(1, 'assistant'), ...bad } as unknown as AssistantMessage]); + + const { result } = setup(); + + expect(result.current.messages.map((m) => m.id)).toEqual(['m0']); + }); + + it('switches to the conversation of another project and back', async () => { + seed([storedMessage(0, 'user')], 'u1', 'general'); + seed([storedMessage(1, 'user'), storedMessage(2, 'assistant')], 'u1', 'p2'); + const { result, rerender } = setup(); + expect(result.current.messages).toHaveLength(1); + + rerender({ projectId: 'p2', userId: 'u1' }); + expect(result.current.messages).toHaveLength(2); + + rerender({ projectId: null, userId: 'u1' }); + expect(result.current.messages.map((m) => m.id)).toEqual(['m0']); + expect(stored('u1', 'p2')).toHaveLength(2); + }); + + it('does not show one user the conversation of another', () => { + seed([storedMessage(0, 'user')], 'u1'); + + const { result } = setup({ userId: 'u2' }); + + expect(result.current.messages).toEqual([]); + }); + + it('does not store the partial of an answer cut off by a conversation change', async () => { + const stream = openStream(); + const { result, rerender } = setup(); + ask(result); + stream.token('Partial'); + await waitFor(() => expect(result.current.messages[1].content).toBe('Partial')); + + rerender({ projectId: 'p2', userId: 'u1' }); + + expect(mockStreamAssistantMessage.mock.calls[0][1].aborted).toBe(true); + expect(result.current.messages).toEqual([]); + await waitFor(() => expect(result.current.isPending).toBe(false)); + expect(result.current.messages).toEqual([]); + expect(stored('u1', 'general')?.[1]).toMatchObject({ status: 'pending', content: '' }); + expect(stored('u1', 'p2')).toBeNull(); + }); + + it('swaps to the conversation of another user and aborts the request in flight', async () => { + seed([storedMessage(0, 'user'), storedMessage(1, 'assistant')], 'u2'); + const stream = openStream(); + const { result, rerender } = setup(); + ask(result); + stream.token('Partial'); + await waitFor(() => expect(result.current.messages[1].content).toBe('Partial')); + + rerender({ projectId: null, userId: 'u2' }); + await nextTick(); + + expect(mockStreamAssistantMessage.mock.calls[0][1].aborted).toBe(true); + expect(result.current.messages.map((m) => m.id)).toEqual(['m0', 'm1']); + expect(result.current.isPending).toBe(false); + expect(stored('u2')?.map((m) => m.id)).toEqual(['m0', 'm1']); + }); + + it('writes nothing on mount, whatever it loaded', () => { + seed([storedMessage(0, 'user'), storedMessage(1, 'assistant', 'pending', 'Half')]); + const setItem = vi.spyOn(window.sessionStorage, 'setItem'); + const removeItem = vi.spyOn(window.sessionStorage, 'removeItem'); + + setup(); + setup({ userId: 'nobody' }); + + expect(setItem).not.toHaveBeenCalled(); + expect(removeItem).not.toHaveBeenCalled(); + }); + + it('turns a stored pending answer that has no text into an empty cut one', () => { + seed([storedMessage(0, 'user'), storedMessage(1, 'assistant', 'pending', '')]); + + const { result } = setup(); + + expect(result.current.messages.map((m) => m.id)).toEqual(['m0', 'm1']); + expect(result.current.messages[1]).toMatchObject({ status: 'cut', content: '' }); + expect(result.current.isPending).toBe(false); + }); + + it('removes the stored copy when an idle conversation is cleared', () => { + seed([storedMessage(0, 'user'), storedMessage(1, 'assistant')]); + const { result } = setup(); + expect(result.current.messages).toHaveLength(2); + + act(() => result.current.clear()); + + expect(result.current.messages).toEqual([]); + expect(stored()).toBeNull(); + }); + + it('leaves storage empty when sign-out clears it and the panel then unmounts with a turn in flight', async () => { + const stream = openStream(); + const { result, unmount } = setup(); + ask(result); + stream.token('Partial'); + await waitFor(() => expect(result.current.messages[1].content).toBe('Partial')); + + clearAssistantHistory(); + unmount(); + await nextTick(); + + expect(stored()).toBeNull(); + }); + + it('leaves storage empty when sign-out clears it and the turn then settles in a mounted hook', async () => { + const stream = openStream(); + const { result } = setup(); + ask(result); + stream.token('Partial'); + await waitFor(() => expect(result.current.messages[1].content).toBe('Partial')); + + clearAssistantHistory(); + stream.done(); + + await waitFor(() => expect(result.current.messages[1].status).toBe('done')); + expect(stored()).toBeNull(); + }); + + it('leaves storage empty when the turn settles after clear()', async () => { + let release: () => void = () => undefined; + const gate = new Promise((resolve) => { + release = resolve; + }); + mockReadAssistantSseStream.mockImplementation(() => + (async function* () { + yield { type: 'token', text: 'Partial' } as const; + await gate; + yield { type: 'done', response: RESPONSE } as const; + })() + ); + const { result } = setup(); + ask(result); + await waitFor(() => expect(result.current.messages[1].content).toBe('Partial')); + + act(() => result.current.clear()); + release(); + await nextTick(); + + expect(result.current.messages).toEqual([]); + expect(stored()).toBeNull(); + }); + + it('keeps working when storage is full', async () => { + const setItem = vi.spyOn(window.sessionStorage, 'setItem').mockImplementation(() => { + throw new DOMException('The quota has been exceeded', 'QuotaExceededError'); + }); + const reported: unknown[] = []; + const onError = (event: Event) => reported.push(event); + window.addEventListener('error', onError); + openStream().done(); + const { result } = setup(); + + ask(result, 'hi'); + + await waitFor(() => expect(result.current.messages[1]?.status).toBe('done')); + window.removeEventListener('error', onError); + expect(setItem).toHaveBeenCalled(); + expect(reported).toEqual([]); + }); + + it('keeps working when storage refuses every read and write', async () => { + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new DOMException('blocked', 'SecurityError'); + }); + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new DOMException('blocked', 'SecurityError'); + }); + vi.spyOn(Storage.prototype, 'removeItem').mockImplementation(() => { + throw new DOMException('blocked', 'SecurityError'); + }); + // React reports an error thrown from an effect on the window, not to the test. + const reported: unknown[] = []; + const onError = (event: Event) => reported.push(event); + window.addEventListener('error', onError); + openStream().done(); + const { result } = setup(); + + ask(result, 'hi'); + + await waitFor(() => expect(result.current.messages[1]?.status).toBe('done')); + act(() => result.current.clear()); + window.removeEventListener('error', onError); + expect(result.current.messages).toEqual([]); + expect(reported).toEqual([]); + }); + + it('writes storage when a turn is appended and when it settles, never per token', async () => { + const setItem = vi.spyOn(window.sessionStorage, 'setItem'); + const stream = openStream(); + const { result } = setup(); + + ask(result, 'hi'); + await waitFor(() => expect(stored()).toHaveLength(2)); + expect(setItem).toHaveBeenCalledTimes(1); + for (let i = 0; i < 25; i += 1) stream.token(`t${i} `); + await waitFor(() => expect(result.current.messages[1].content).toContain('t24 ')); + expect(setItem).toHaveBeenCalledTimes(1); + stream.done(); + + await waitFor(() => expect(stored()?.[1].status).toBe('done')); + expect(setItem).toHaveBeenCalledTimes(2); + }); + + it('stores a turn that fails before any token', async () => { + mockStreamAssistantMessage.mockRejectedValueOnce(new HttpError('Not Found', 404)); + const { result } = setup(); + + ask(result, 'hi'); + + await waitFor(() => expect(stored()?.[1].status).toBe('error')); + expect(stored()?.[1].error).toEqual({ code: 404, detail: 'Not Found' }); + }); + + it('stores a cut answer with its partial text', async () => { + const stream = openStream(); + const { result } = setup(); + + ask(result); + stream.token('Half'); + stream.fail(new StreamIncompleteError()); + + await waitFor(() => expect(stored()?.[1].status).toBe('cut')); + expect(stored()?.[1].content).toBe('Half'); + }); + }); +}); + +describe('clearAssistantHistory', () => { + beforeEach(() => { + window.sessionStorage.clear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('removes every conversation of every user and project and nothing else', () => { + seed([storedMessage(0, 'user')], 'u1', 'general'); + seed([storedMessage(0, 'user')], 'u1', 'p1'); + seed([storedMessage(0, 'user')], 'u2', 'p1'); + window.sessionStorage.setItem('theme', 'dark'); + window.sessionStorage.setItem(`${STORAGE_PREFIX}x`, 'no colon, not ours'); + + clearAssistantHistory(); + + expect(window.sessionStorage.length).toBe(2); + expect(window.sessionStorage.getItem('theme')).toBe('dark'); + expect(window.sessionStorage.getItem(`${STORAGE_PREFIX}x`)).not.toBeNull(); + }); + + it('does not throw when storage is blocked', () => { + vi.spyOn(window.sessionStorage, 'key').mockImplementation(() => { + throw new DOMException('blocked', 'SecurityError'); + }); + seed([storedMessage(0, 'user')]); + + expect(() => clearAssistantHistory()).not.toThrow(); + }); });