From 3edbf0a2e3be31955cc6b535aabdc973df31482a Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Mon, 24 Aug 2026 16:37:30 +0200 Subject: [PATCH] fix(agent): chat console keeps its thread across turns and reloads (#1859) Three user-reported failures in the assistant panel, one root cause each: 1. "The chat asks what I'm referring to" when continuing a thread. The single-call console (general.help, AskConsole -> /api/agent/ask) was stateless since the 08-20 model-agnostic cutover: conversationId was only the tool actor id, so every turn was answered blind, reload or not. The provider-agnostic GenerateTextRequest gains an optional `history` (real message turns before the prompt, in both the Anthropic-family and the OpenAI-compatible adapter; absent/empty leaves the request byte-identical to the single-turn call). The route loads the thread's earlier turns server-side (loadChatHistory: text only, hidden and tool rows dropped, alternation repaired, newest 16 rows / 10k chars) before writing the new question, and hands them to the model. 2. A full page reload (the deploy prompt's "Ladda om") closed the docked panel and dropped the thread from view. The panel now remembers its open thread per tab in sessionStorage (lib/agent-panel/session-restore) and the provider reopens it on mount; the sheet loads it exactly like a pick from "Tidigare konversationer". Close and "Ny konversation" forget it; a thread that no longer opens is dropped instead of retried on every reload. 3. "Can't type any more" once the update banner shows. DeployReloadPrompt's full-width wrapper sits at z-[60] after the panel in DOM order and swallowed clicks on the panel's composer; only the card takes input now. Claude-Session: https://claude.ai/code/session_01VjoXN3xdNZrHZeYA6qMi3g Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- DECISIONS.md | 1 + app/api/agent/ask/__tests__/route.test.ts | 34 ++++++ app/api/agent/ask/route.ts | 7 ++ components/agent/AgentSheet.tsx | 62 ++++++++-- components/agent/AgentSheetProvider.tsx | 44 ++++++- components/system/DeployReloadPrompt.tsx | 9 +- .../__tests__/session-restore.test.ts | 99 ++++++++++++++++ lib/agent-panel/session-restore.ts | 82 ++++++++++++++ lib/agent/ask/__tests__/ask-service.test.ts | 24 ++++ lib/agent/ask/__tests__/persist.test.ts | 107 ++++++++++++++++++ lib/agent/ask/ask-service.ts | 11 +- lib/agent/ask/persist.ts | 90 +++++++++++++++ lib/ai/__tests__/anthropic-family.test.ts | 53 +++++++++ lib/ai/__tests__/openai-compatible.test.ts | 19 ++++ lib/ai/index.ts | 1 + lib/ai/services/anthropic-family.ts | 21 +++- lib/ai/services/openai-compatible.ts | 18 ++- lib/ai/types.ts | 18 +++ 18 files changed, 680 insertions(+), 20 deletions(-) create mode 100644 lib/agent-panel/__tests__/session-restore.test.ts create mode 100644 lib/agent-panel/session-restore.ts diff --git a/DECISIONS.md b/DECISIONS.md index f933e7ab..f3ec41aa 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1188,3 +1188,4 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-24] Declared currency/voucher_series nullable in three MCP listing schemas on column-nullability alone (no traced null producer): loosening an output schema can only stop false validation failures, never cause one, and legacy rows predate the columns' defaults. Declined (for now) a full Ajv execute-vs-schema round-trip harness in output-schema.test.ts: right long-term answer to this bug class, but a session-sized project of its own; the audit's seven confirmed sites are pinned by a targeted declaration test instead. [2026-08-24] Manual matching (PR 6b) ships N:1 only (many outside rows -> one verifikat): bank links are independent per transaction (the engine allows it by design), skattekonto groups are all-or-nothing with the sum settling the verifikat (one guarded UPDATE, partial hit rolled back). 1:M (one row over several verifikat) and residual booking wait for a link table in 6c: the single journal_entry_id pointer on both row kinds cannot express them, and faking it (pointing the row at the residual verifikat) would break the bridge. The worksheet therefore enables Koppla only when the selection nets to zero and says so otherwise. [2026-08-24] Skattekonto payment file gets pain.001 through the supplier-payment generator (generateSupplierPain001), not the salary pain001 generator: the payment is a plain BG+OCR giro transfer (no SALA CtgyPurp), and the supplier dialect is the Validex-validated shape for exactly that; the LB path stays the default so nothing changes for banks still on LB. +[2026-08-24] Single-call chat console (general.help, AskConsole → /api/agent/ask) now carries the thread's earlier turns into every model call, via a new optional `history` on the provider-agnostic GenerateTextRequest (real message turns before the prompt in BOTH adapters: Anthropic-family messages array, OpenAI-compatible via AI SDK `messages`; an absent/empty history leaves the request byte-identical to the single-turn call, so hosted extraction and every other caller are untouched). The 08-20 RIP-3 cutover made each turn stateless (conversationId was only the tool actor id), so a follow-up in a resumed thread was answered blind (user report: "frågar vad jag refererar till"). History is loaded server-side from agent_messages (loadChatHistory: text only, hidden + tool rows dropped, alternation repaired, newest 16 rows / 10k chars) rather than sent by the client, so the client cannot forge earlier turns and old streaming threads replay cleanly. Rejected: inlining a transcript into the prompt (works everywhere but weaker turn semantics and blurs data vs instructions) and loading history in AskConsole (client-trusted history). Separately: the docked assistant panel now remembers its open thread per tab in sessionStorage (lib/agent-panel/session-restore) and reopens it after a full reload (the deploy prompt's "Ladda om" wiped it); sessionStorage, not user_preferences, because this is this-tab-this-session state that must not follow the user to other devices or tabs. And DeployReloadPrompt's full-width wrapper gets pointer-events-none: at z-[60] after the panel in DOM order it swallowed clicks on the panel's composer ("går ej att skriva"). diff --git a/app/api/agent/ask/__tests__/route.test.ts b/app/api/agent/ask/__tests__/route.test.ts index 674a4a42..c7b5a537 100644 --- a/app/api/agent/ask/__tests__/route.test.ts +++ b/app/api/agent/ask/__tests__/route.test.ts @@ -20,10 +20,12 @@ vi.mock('@/lib/ai', () => ({ getAiStatus: () => aiStatus() })) const answer = vi.fn() vi.mock('@/lib/agent/ask/ask-service', () => ({ answerAssistantQuestion: (...a: unknown[]) => answer(...a) })) const resolveConv = vi.fn() +const loadHistory = vi.fn() const persistUser = vi.fn() const persistAssistant = vi.fn() vi.mock('@/lib/agent/ask/persist', () => ({ resolveChatConversation: (...a: unknown[]) => resolveConv(...a), + loadChatHistory: (...a: unknown[]) => loadHistory(...a), persistUserTurn: (...a: unknown[]) => persistUser(...a), persistAssistantTurn: (...a: unknown[]) => persistAssistant(...a), })) @@ -42,6 +44,7 @@ beforeEach(() => { aiStatus.mockReturnValue({ configured: true, assistantAvailable: false, provider: 'openai-compatible' }) answer.mockResolvedValue({ answer: 'Svar', model: 'qwen3.8' }) resolveConv.mockResolvedValue({ ok: true, conversationId: 'conv-9', created: true }) + loadHistory.mockResolvedValue([]) persistUser.mockResolvedValue(undefined) persistAssistant.mockResolvedValue(undefined) }) @@ -130,6 +133,37 @@ describe('POST /api/agent/ask', () => { expect(persistUser).toHaveBeenCalledWith(supabase, 'conv-9', 'Hur gick juli?') expect(answer).toHaveBeenCalled() expect(persistAssistant).toHaveBeenCalledWith(supabase, 'conv-9', 'Svar') + // A thread created by this very request has no earlier turns to load. + expect(loadHistory).not.toHaveBeenCalled() + expect(answer.mock.calls[0][0].history).toEqual([]) + }) + + it('a resumed thread answers with its earlier turns, read before the new question is written', async () => { + resolveConv.mockResolvedValue({ ok: true, conversationId: 'conv-7', created: false }) + const history = [ + { role: 'user', text: 'Vad är min största utgift?' }, + { role: 'assistant', text: '12 345 kr på 5010.' }, + ] + const order: string[] = [] + loadHistory.mockImplementation(async () => { + order.push('load') + return history + }) + persistUser.mockImplementation(async () => { + order.push('persist') + }) + const res = await POST( + createMockRequest('/x', { + method: 'POST', + body: body({ persist: true, conversation_id: '11111111-1111-4111-8111-111111111111', question: 'Och förra månaden?' }), + }), + ) + expect(res.status).toBe(200) + expect(loadHistory).toHaveBeenCalledWith(supabase, 'conv-7') + expect(order).toEqual(['load', 'persist']) + expect(answer).toHaveBeenCalledWith( + expect.objectContaining({ conversationId: 'conv-7', question: 'Och förra månaden?', history }), + ) }) it('resumes with a supplied conversation_id', async () => { diff --git a/app/api/agent/ask/route.ts b/app/api/agent/ask/route.ts index 4a6e6e33..57dab54c 100644 --- a/app/api/agent/ask/route.ts +++ b/app/api/agent/ask/route.ts @@ -13,6 +13,7 @@ import { answerAssistantQuestion } from '@/lib/agent/ask/ask-service' import { EmptyModelAnswerError } from '@/lib/agent/ask/errors' import { resolveChatConversation, + loadChatHistory, persistUserTurn, persistAssistantTurn, } from '@/lib/agent/ask/persist' @@ -152,6 +153,11 @@ export async function POST(request: Request): Promise { } const { conversationId } = resolved + // A resumed thread carries its earlier turns into the model call; read + // them BEFORE the new question is written so it is not sent twice. A + // thread created just now has nothing to load. + const history = resolved.created ? [] : await loadChatHistory(supabase, conversationId) + await persistUserTurn(supabase, conversationId, parsed.data.question) const result = await answerAssistantQuestion({ @@ -162,6 +168,7 @@ export async function POST(request: Request): Promise { question: parsed.data.question, pageContext: parsed.data.context, tier: parsed.data.tier, + history, }) // answerAssistantQuestion throws EmptyModelAnswerError on an empty answer, diff --git a/components/agent/AgentSheet.tsx b/components/agent/AgentSheet.tsx index d6aecefa..af4de21a 100644 --- a/components/agent/AgentSheet.tsx +++ b/components/agent/AgentSheet.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { X, Expand, @@ -41,6 +41,10 @@ import AgentSessionList from './AgentSessionList' import SandboxAgentPreview from './SandboxAgentPreview' import { useAgentSheet } from './AgentSheetProvider' import { useCompanyOptional } from '@/contexts/CompanyContext' +import { + clearAgentSheetSession, + writeAgentSheetSession, +} from '@/lib/agent-panel/session-restore' import { cn } from '@/lib/utils' // Undimmed non-modal side sheet: sits above the page on a hairline border + @@ -55,6 +59,9 @@ interface Props { intentArgs?: Record contextRef?: string seedUserMessage?: string + // Open this existing thread on mount (a reload restore, see the provider) + // instead of starting a fresh one on the intent. + resumeConversationId?: string // Hidden (display:none) but still mounted so the conversation survives. The // provider keeps rendering this component; we just visually remove it. collapsed: boolean @@ -205,6 +212,7 @@ export default function AgentSheet({ intentArgs, contextRef, seedUserMessage, + resumeConversationId, collapsed, onStatus, onDockWidthChange, @@ -227,7 +235,10 @@ export default function AgentSheet({ // A past conversation the user picked from the list, hydrated for resume. When // set, it replaces the intent-driven fresh chat. const [loaded, setLoaded] = useState(null) - const [loadingConversation, setLoadingConversation] = useState(false) + // Starts true on a restore so the first frame is the spinner, not a fresh + // chat on the intent: a fresh tool-loop chat fires its first turn on mount + // and would create a stray conversation before the restore replaced it. + const [loadingConversation, setLoadingConversation] = useState(!!resumeConversationId) const [loadError, setLoadError] = useState(null) // Enlarge the panel IN PLACE (no navigation): the user stays on the current // page (e.g. /bookkeeping) with a wider reading/verifying surface. Transient @@ -468,15 +479,9 @@ export default function AgentSheet({ } } - // Resume a past conversation inline: fetch its messages, hydrate, and swap the - // sheet back to the chat view. Picking the one already open just closes the - // list (keeps its live in-memory state instead of re-hydrating it). - async function handleSelectConversation(id: string) { - if (id === activeConversationId) { - setView('chat') - return - } - setView('chat') + // Open a past conversation inline: fetch its messages and hydrate. Shared by + // the session list and the reload restore below. + const loadConversation = useCallback(async (id: string) => { setLoaded(null) setLoadingConversation(true) setLoadError(null) @@ -514,10 +519,43 @@ export default function AgentSheet({ }) setConversationId(data.conversation.id) } catch { - if (seq === selectSeqRef.current) setLoadError('Kunde inte öppna konversationen.') + if (seq === selectSeqRef.current) { + setLoadError('Kunde inte öppna konversationen.') + // A thread that no longer opens must not be retried on every reload. + clearAgentSheetSession() + } } finally { if (seq === selectSeqRef.current) setLoadingConversation(false) } + }, []) + + // Reload restore: the provider remounts the sheet on the thread this tab + // had open, and it is loaded here the same way a picked one is. + useEffect(() => { + if (resumeConversationId) void loadConversation(resumeConversationId) + }, [resumeConversationId, loadConversation]) + + // Remember the open thread for this tab so a reload brings it back (see + // lib/agent-panel/session-restore). Nothing to remember until the thread + // has an id: a fresh chat that never sent anything simply does not return. + const activeIntentId = loaded?.intentId ?? intentId + useEffect(() => { + if (!activeConversationId) return + writeAgentSheetSession({ + conversationId: activeConversationId, + intentId: activeIntentId, + contextRef: activeContextRef, + collapsed, + }) + }, [activeConversationId, activeIntentId, activeContextRef, collapsed]) + + // Resume a past conversation from the list and swap the sheet back to the + // chat view. Picking the one already open just closes the list (keeps its + // live in-memory state instead of re-hydrating it). + function handleSelectConversation(id: string) { + setView('chat') + if (id === activeConversationId) return + void loadConversation(id) } return ( diff --git a/components/agent/AgentSheetProvider.tsx b/components/agent/AgentSheetProvider.tsx index b5fd9500..472dd6e0 100644 --- a/components/agent/AgentSheetProvider.tsx +++ b/components/agent/AgentSheetProvider.tsx @@ -18,6 +18,10 @@ import { type ResolvedAgentPanelPrefs, } from '@/lib/agent-panel/geometry' import { persistUiState } from '@/lib/ui-state/client' +import { + clearAgentSheetSession, + readAgentSheetSession, +} from '@/lib/agent-panel/session-restore' import { INITIAL_AGENT_STATUS, reduceAgentStatus, @@ -143,6 +147,11 @@ export interface OpenAgentSheetArgs { // promptTemplate and sends this verbatim instead. Used by /chat empty-state // suggestion chips to give the user a one-click starting prompt. seedUserMessage?: string + // Reopen an existing thread instead of starting one. Set by the provider + // itself when it restores the panel after a page reload (see + // lib/agent-panel/session-restore); the sheet loads the thread on mount + // exactly as if it had been picked from "Tidigare konversationer". + resumeConversationId?: string } interface AgentSheetContextValue { @@ -277,6 +286,28 @@ export function AgentSheetProvider({ } }, [dockWidth]) + // Bring back the thread this tab had open before a full page reload. The + // panel's state is React-only, so the deploy prompt's "Ladda om" (or any + // refresh) used to close it and drop the thread from view; the user then + // had to find it again under "Tidigare konversationer". The sheet records + // the open thread per tab (sessionStorage), and this reopens it on mount. + // Only on mount: client-side navigation keeps the provider, and a session + // the user closed has already been cleared from storage. + useEffect(() => { + const stored = readAgentSheetSession() + if (!stored) return + // sessionStorage is client-only: reading it in the state initializer would + // render the panel on the client but not on the server (hydration + // mismatch), so it has to be an effect that sets state once. + // eslint-disable-next-line react-hooks/set-state-in-effect + setActiveArgs({ + intentId: stored.intentId, + contextRef: stored.contextRef ?? undefined, + resumeConversationId: stored.conversationId, + }) + setCollapsed(stored.collapsed) + }, []) + const openAgentSheet = useCallback((args: OpenAgentSheetArgs) => { setActiveArgs(args) setCollapsed(false) @@ -286,12 +317,22 @@ export function AgentSheetProvider({ setActiveArgs(null) setCollapsed(false) setDockWidth(null) + // Close ends the session: a reload after this must not bring it back. + clearAgentSheetSession() publishAgentStatus({ type: 'reset' }) }, []) const collapseAgentSheet = useCallback(() => setCollapsed(true), []) const expandAgentSheet = useCallback(() => setCollapsed(false), []) const restartAgentSheet = useCallback(() => { + // Discarding the thread: forget it for reload purposes too, so the old + // one does not reappear while the new one has no id yet, and drop any + // restore id from the args, or the remount would reopen that thread + // instead of starting a fresh one. + clearAgentSheetSession() + setActiveArgs((args) => + args?.resumeConversationId ? { ...args, resumeConversationId: undefined } : args, + ) setRestartNonce((n) => n + 1) setCollapsed(false) }, []) @@ -337,11 +378,12 @@ export function AgentSheetProvider({ {children} {activeArgs && ( -
+ // The wrapper spans the full width at the same z-index as the docked + // assistant panel and mounts after it, so without pointer-events-none it + // swallowed every click and tap in the strip behind it: the panel's + // composer sits exactly there, and "a new version is available" turned + // into "I can't type any more". Only the card itself takes input. +
+
{t('update_available')}