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 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-24 16:37:30 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent f75ea2384d
commit 3edbf0a2e3
18 changed files with 680 additions and 20 deletions
@@ -133,6 +133,30 @@ describe('answerAssistantQuestion', () => {
await expect(promise).rejects.toMatchObject({ code: 'empty_model_answer' })
})
it('forwards the earlier turns as history so a follow-up can refer back', async () => {
const history = [
{ role: 'user' as const, text: 'Vad är min största utgift?' },
{ role: 'assistant' as const, text: '12 345 kr på 5010.' },
]
await answerAssistantQuestion({
supabase: supabaseWith(null),
companyId: 'c1',
userId: 'u1',
conversationId: 'conv-1',
question: 'Och förra månaden?',
history,
})
const call = generateText.mock.calls[0][0]
expect(call.history).toEqual(history)
// The question itself stays the final prompt, not folded into history.
expect(call.prompt).toContain('Fråga: Och förra månaden?')
})
it('sends no history key at all for a fresh thread or a one-off ask', async () => {
await answerAssistantQuestion({ supabase: supabaseWith(null), companyId: 'c1', question: 'Hej?', history: [] })
expect('history' in generateText.mock.calls[0][0]).toBe(false)
})
it('honours a custom maxSteps', async () => {
buildLedgerTools.mockReturnValue([
{ name: 'gnubok_get_vat_report', description: 'd', jsonSchema: {}, execute: vi.fn() },
+107
View File
@@ -2,9 +2,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import {
resolveChatConversation,
loadChatHistory,
persistUserTurn,
persistAssistantTurn,
CHAT_INTENT_ID,
HISTORY_MAX_CHARS,
HISTORY_MAX_MESSAGES,
} from '../persist'
interface Recorded {
@@ -179,3 +182,107 @@ describe('persistAssistantTurn', () => {
expect(preview.endsWith('…')).toBe(true)
})
})
describe('loadChatHistory', () => {
type Row = { role: string; content: unknown; hidden?: boolean | null }
// agent_messages read: select → eq → order → order → limit resolves rows.
// Rows are handed over newest-first, exactly as the query returns them.
function supabaseWithRows(newestFirst: Row[], opts: { error?: unknown; limitSeen?: number[] } = {}) {
const chain = {
select: () => chain,
eq: () => chain,
order: () => chain,
limit: async (n: number) => {
opts.limitSeen?.push(n)
return { data: opts.error ? null : newestFirst, error: opts.error ?? null }
},
}
return { from: () => chain } as unknown as SupabaseClient
}
const text = (t: string) => [{ type: 'text', text: t }]
it('returns the thread oldest-first as user/assistant text turns', async () => {
const supabase = supabaseWithRows([
{ role: 'assistant', content: text('12 345 kr på 5010.') },
{ role: 'user', content: text('Vad är min största utgift?') },
])
expect(await loadChatHistory(supabase, 'conv-1')).toEqual([
{ role: 'user', text: 'Vad är min största utgift?' },
{ role: 'assistant', text: '12 345 kr på 5010.' },
])
})
it('drops hidden scaffolding, tool rows and empty tool-only turns from the old streaming runtime', async () => {
const supabase = supabaseWithRows([
{ role: 'assistant', content: text('Klart.') },
{ role: 'tool', content: [{ type: 'tool_result', tool_use_id: 'tu_1', content: '{}' }] },
{ role: 'assistant', content: [{ type: 'tool_use', id: 'tu_1', name: 'x', input: {} }] },
{ role: 'user', content: text('Kolla momsen.') },
{ role: 'user', content: text('[prompt template]'), hidden: true },
])
expect(await loadChatHistory(supabase, 'conv-1')).toEqual([
{ role: 'user', text: 'Kolla momsen.' },
{ role: 'assistant', text: 'Klart.' },
])
})
it('merges consecutive same-role turns (a question whose answer failed, then its retry)', async () => {
const supabase = supabaseWithRows([
{ role: 'assistant', content: text('Svar.') },
{ role: 'user', content: text('Igen?') },
{ role: 'user', content: text('Hur gick juli?') },
])
expect(await loadChatHistory(supabase, 'conv-1')).toEqual([
{ role: 'user', text: 'Hur gick juli?\n\nIgen?' },
{ role: 'assistant', text: 'Svar.' },
])
})
it('never opens with an assistant turn whose question fell off the window', async () => {
const supabase = supabaseWithRows([
{ role: 'assistant', content: text('B') },
{ role: 'user', content: text('b?') },
{ role: 'assistant', content: text('A (its question is outside the window)') },
])
expect(await loadChatHistory(supabase, 'conv-1')).toEqual([
{ role: 'user', text: 'b?' },
{ role: 'assistant', text: 'B' },
])
})
it('reads only the newest HISTORY_MAX_MESSAGES rows and trims the oldest until the text fits', async () => {
const limitSeen: number[] = []
const big = 'x'.repeat(2_500)
// 6 turns of 2 500 chars = 15 000 > HISTORY_MAX_CHARS: the oldest go.
const rows: Row[] = []
for (let i = 0; i < 6; i++) rows.push({ role: i % 2 === 0 ? 'assistant' : 'user', content: text(`${i}${big}`) })
const supabase = supabaseWithRows(rows, { limitSeen })
const turns = await loadChatHistory(supabase, 'conv-1')
expect(limitSeen).toEqual([HISTORY_MAX_MESSAGES])
const total = turns.reduce((n, t) => n + t.text.length, 0)
expect(total).toBeLessThanOrEqual(HISTORY_MAX_CHARS)
expect(turns[0].role).toBe('user')
// Newest turn (index 0 in the newest-first rows) is the last one kept.
expect(turns[turns.length - 1].text.startsWith('0')).toBe(true)
})
it('clamps a single oversized turn instead of letting it eat the whole budget', async () => {
const supabase = supabaseWithRows([
{ role: 'assistant', content: text('Svar.') },
{ role: 'user', content: text('y'.repeat(9_000)) },
])
const turns = await loadChatHistory(supabase, 'conv-1')
expect(turns).toHaveLength(2)
expect(turns[0].text.length).toBeLessThan(3_100)
expect(turns[0].text.endsWith('…')).toBe(true)
})
it('throws on a read error rather than silently answering without context', async () => {
const supabase = supabaseWithRows([], { error: new Error('rls') })
await expect(loadChatHistory(supabase, 'conv-1')).rejects.toThrow('rls')
})
it('is empty for a thread with nothing readable', async () => {
expect(await loadChatHistory(supabaseWithRows([]), 'conv-1')).toEqual([])
})
})
+9 -2
View File
@@ -1,5 +1,5 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { getAiService, type AiTier, type AiToolDef } from '@/lib/ai'
import { getAiService, type AiChatTurn, type AiTier, type AiToolDef } from '@/lib/ai'
import { createLogger } from '@/lib/logger'
import { EmptyModelAnswerError } from './errors'
import { buildLedgerTools } from './ledger-tools'
@@ -50,8 +50,14 @@ export interface AskRequest {
* with this user's identity for audit). Omitted → no tools, snapshot-only.
*/
userId?: string
/** Conversation id, used only as the tool actor id for BFL audit. */
/** Conversation id, used as the tool actor id for BFL audit. */
conversationId?: string
/**
* Earlier turns of the thread (see loadChatHistory), oldest first. Sent to
* the model as real message turns before the question, so a follow-up can
* refer back to what was said. Omitted for a fresh thread or a one-off ask.
*/
history?: AiChatTurn[]
/** Max model turns in the tool loop (default 5). */
maxSteps?: number
}
@@ -144,6 +150,7 @@ export async function answerAssistantQuestion(req: AskRequest): Promise<AskResul
tier: req.tier ?? 'assistant',
system: systemPrompt(tools.length > 0),
prompt: promptParts.join('\n'),
...(req.history && req.history.length > 0 ? { history: req.history } : {}),
maxTokens: req.maxTokens ?? DEFAULT_MAX_TOKENS,
...(tools.length > 0 ? { tools, maxSteps: req.maxSteps ?? DEFAULT_MAX_STEPS } : {}),
})
+90
View File
@@ -1,4 +1,5 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import type { AiChatTurn } from '@/lib/ai'
/**
* Thread persistence for the single-call chat console (/chat).
@@ -23,6 +24,16 @@ import type { SupabaseClient } from '@supabase/supabase-js'
/** The only intent the single-call console persists under. */
export const CHAT_INTENT_ID = 'general.help'
// How much of a thread the model sees on each turn. Bounded because every
// resumed turn replays it: a long-lived thread must not grow the prompt
// without limit, and a local model may have a small context window. The
// newest turns win; the tail that fits is what the model gets.
export const HISTORY_MAX_MESSAGES = 16
export const HISTORY_MAX_CHARS = 10_000
// One turn is clamped too so a single pasted wall of text cannot eat the
// whole budget on its own.
const HISTORY_MAX_TURN_CHARS = 3_000
// The sidebar caches a one-line preview per row; the title is the sidebar
// label. Both are bounded so a long first question can't bloat the row.
const PREVIEW_MAX = 200
@@ -135,3 +146,82 @@ export async function persistAssistantTurn(
})
.eq('id', conversationId)
}
/** Plain text of a stored Anthropic-shaped content array; empty for tool-only rows. */
function textOfContent(content: unknown): string {
if (typeof content === 'string') return content.trim()
if (!Array.isArray(content)) return ''
return content
.filter(
(b): b is { type: 'text'; text: string } =>
!!b && typeof b === 'object' && (b as { type?: unknown }).type === 'text' && typeof (b as { text?: unknown }).text === 'string',
)
.map((b) => b.text)
.join('\n')
.trim()
}
function clampTurn(text: string): string {
if (text.length <= HISTORY_MAX_TURN_CHARS) return text
return `${text.slice(0, HISTORY_MAX_TURN_CHARS).trimEnd()}…`
}
/**
* The earlier turns of a thread, as the model should see them.
*
* This is what makes a resumed thread a conversation rather than a series of
* unrelated questions: without it every turn was answered blind, so a
* follow-up ("och förra månaden?") got "vad syftar du på?". Only the
* conversation's own rows are read (the caller has already proven ownership
* via resolveChatConversation).
*
* Shape rules, so both AI adapters can send the result as-is:
* - text only: tool_use / tool_result rows from the old streaming runtime
* carry no prose, and hidden rows are prompt-template scaffolding, never
* something the user said; both are dropped.
* - alternating, starting with a user turn: consecutive same-role turns are
* merged (a question whose answer failed sits next to its retry), and a
* leading assistant turn (its question fell off the window) is dropped.
* - bounded: newest HISTORY_MAX_MESSAGES rows, then trimmed from the oldest
* end until the text fits HISTORY_MAX_CHARS.
*/
export async function loadChatHistory(
supabase: SupabaseClient,
conversationId: string,
): Promise<AiChatTurn[]> {
const { data, error } = await supabase
.from('agent_messages')
.select('role, content, hidden, created_at')
.eq('conversation_id', conversationId)
.order('created_at', { ascending: false })
.order('id', { ascending: false })
.limit(HISTORY_MAX_MESSAGES)
if (error) throw error
const rows = (data ?? []) as { role: string; content: unknown; hidden?: boolean | null }[]
const turns: AiChatTurn[] = []
// Oldest first from here on.
for (const row of rows.slice().reverse()) {
if (row.hidden) continue
if (row.role !== 'user' && row.role !== 'assistant') continue
const text = textOfContent(row.content)
if (!text) continue
const clamped = clampTurn(text)
const last = turns[turns.length - 1]
if (last && last.role === row.role) {
last.text = clampTurn(`${last.text}\n\n${clamped}`)
} else {
turns.push({ role: row.role, text: clamped })
}
}
// Trim from the oldest end until the total fits, then make sure what is
// left opens with the user.
let total = turns.reduce((n, t) => n + t.text.length, 0)
while (turns.length > 0 && total > HISTORY_MAX_CHARS) {
total -= turns[0].text.length
turns.shift()
}
while (turns.length > 0 && turns[0].role !== 'user') turns.shift()
return turns
}