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:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
f75ea2384d
commit
3edbf0a2e3
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import {
|
||||
AGENT_SHEET_SESSION_KEY,
|
||||
clearAgentSheetSession,
|
||||
parseAgentSheetSession,
|
||||
readAgentSheetSession,
|
||||
writeAgentSheetSession,
|
||||
} from '../session-restore'
|
||||
|
||||
// A minimal sessionStorage double on a fake window; the helpers must also be
|
||||
// inert when neither exists (server render) or when access throws.
|
||||
function installStorage(opts: { throwOnAccess?: boolean } = {}) {
|
||||
const map = new Map<string, string>()
|
||||
const store = {
|
||||
getItem: (k: string) => map.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => {
|
||||
map.set(k, v)
|
||||
},
|
||||
removeItem: (k: string) => {
|
||||
map.delete(k)
|
||||
},
|
||||
}
|
||||
const win: Record<string, unknown> = {}
|
||||
if (opts.throwOnAccess) {
|
||||
Object.defineProperty(win, 'sessionStorage', {
|
||||
get() {
|
||||
throw new Error('SecurityError')
|
||||
},
|
||||
})
|
||||
} else {
|
||||
win.sessionStorage = store
|
||||
}
|
||||
;(globalThis as { window?: unknown }).window = win
|
||||
return map
|
||||
}
|
||||
|
||||
const saved = (globalThis as { window?: unknown }).window
|
||||
beforeEach(() => {
|
||||
delete (globalThis as { window?: unknown }).window
|
||||
})
|
||||
afterEach(() => {
|
||||
if (saved === undefined) delete (globalThis as { window?: unknown }).window
|
||||
else (globalThis as { window?: unknown }).window = saved
|
||||
})
|
||||
|
||||
const session = {
|
||||
conversationId: 'conv-1',
|
||||
intentId: 'general.help',
|
||||
contextRef: 'report:vat:2026-07',
|
||||
collapsed: false,
|
||||
}
|
||||
|
||||
describe('session-restore', () => {
|
||||
it('round-trips the open thread through sessionStorage', () => {
|
||||
const map = installStorage()
|
||||
writeAgentSheetSession(session)
|
||||
expect(map.has(AGENT_SHEET_SESSION_KEY)).toBe(true)
|
||||
expect(readAgentSheetSession()).toEqual(session)
|
||||
clearAgentSheetSession()
|
||||
expect(readAgentSheetSession()).toBeNull()
|
||||
})
|
||||
|
||||
it('reads nothing without a window (server) and never throws', () => {
|
||||
expect(readAgentSheetSession()).toBeNull()
|
||||
expect(() => writeAgentSheetSession(session)).not.toThrow()
|
||||
expect(() => clearAgentSheetSession()).not.toThrow()
|
||||
})
|
||||
|
||||
it('treats a storage that throws on access as empty', () => {
|
||||
installStorage({ throwOnAccess: true })
|
||||
expect(readAgentSheetSession()).toBeNull()
|
||||
expect(() => writeAgentSheetSession(session)).not.toThrow()
|
||||
})
|
||||
|
||||
it('ignores malformed stored values', () => {
|
||||
const map = installStorage()
|
||||
map.set(AGENT_SHEET_SESSION_KEY, 'not json')
|
||||
expect(readAgentSheetSession()).toBeNull()
|
||||
map.set(AGENT_SHEET_SESSION_KEY, JSON.stringify({ intentId: 'general.help' }))
|
||||
expect(readAgentSheetSession()).toBeNull()
|
||||
})
|
||||
|
||||
it('parses defensively: contextRef defaults to null, collapsed to false', () => {
|
||||
expect(parseAgentSheetSession({ conversationId: 'c', intentId: 'i' })).toEqual({
|
||||
conversationId: 'c',
|
||||
intentId: 'i',
|
||||
contextRef: null,
|
||||
collapsed: false,
|
||||
})
|
||||
expect(parseAgentSheetSession({ conversationId: 'c', intentId: 'i', collapsed: true, contextRef: 7 })).toEqual({
|
||||
conversationId: 'c',
|
||||
intentId: 'i',
|
||||
contextRef: null,
|
||||
collapsed: true,
|
||||
})
|
||||
expect(parseAgentSheetSession(null)).toBeNull()
|
||||
expect(parseAgentSheetSession({ conversationId: '', intentId: 'i' })).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Per-tab memory of which thread the docked assistant panel had open.
|
||||
*
|
||||
* The panel's state lives in React only, so a full page reload (the "Ladda om"
|
||||
* deploy prompt, a browser refresh, a crash recovery) closed it and dropped the
|
||||
* thread from view; the user had to dig it out of "Tidigare konversationer" by
|
||||
* hand. This remembers just enough to reopen the same thread after a reload:
|
||||
* the conversation id (the thread itself is already persisted server-side),
|
||||
* the intent that shaped it, and whether the panel was collapsed.
|
||||
*
|
||||
* sessionStorage on purpose, not user_preferences: this is "this tab, this
|
||||
* session" state. It survives a reload of the tab and nothing else, so a
|
||||
* thread never follows the user to another device or another tab, and a closed
|
||||
* tab forgets it. Nothing here is authoritative: the id is re-validated by the
|
||||
* conversations API on restore, and a thread that no longer opens is dropped.
|
||||
*/
|
||||
|
||||
export const AGENT_SHEET_SESSION_KEY = 'accounted-agent-sheet'
|
||||
|
||||
export interface AgentSheetSession {
|
||||
conversationId: string
|
||||
intentId: string
|
||||
contextRef: string | null
|
||||
collapsed: boolean
|
||||
}
|
||||
|
||||
function storage(): Storage | null {
|
||||
try {
|
||||
if (typeof window === 'undefined') return null
|
||||
return window.sessionStorage
|
||||
} catch {
|
||||
// Storage access can throw (privacy modes, sandboxed frames): then there
|
||||
// is simply nothing to remember.
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function readAgentSheetSession(): AgentSheetSession | null {
|
||||
const store = storage()
|
||||
if (!store) return null
|
||||
try {
|
||||
const raw = store.getItem(AGENT_SHEET_SESSION_KEY)
|
||||
if (!raw) return null
|
||||
return parseAgentSheetSession(JSON.parse(raw))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate an untrusted parsed value; anything malformed reads as "nothing stored". */
|
||||
export function parseAgentSheetSession(value: unknown): AgentSheetSession | null {
|
||||
if (!value || typeof value !== 'object') return null
|
||||
const v = value as Record<string, unknown>
|
||||
if (typeof v.conversationId !== 'string' || v.conversationId.length === 0) return null
|
||||
if (typeof v.intentId !== 'string' || v.intentId.length === 0) return null
|
||||
return {
|
||||
conversationId: v.conversationId,
|
||||
intentId: v.intentId,
|
||||
contextRef: typeof v.contextRef === 'string' ? v.contextRef : null,
|
||||
collapsed: v.collapsed === true,
|
||||
}
|
||||
}
|
||||
|
||||
export function writeAgentSheetSession(session: AgentSheetSession): void {
|
||||
const store = storage()
|
||||
if (!store) return
|
||||
try {
|
||||
store.setItem(AGENT_SHEET_SESSION_KEY, JSON.stringify(session))
|
||||
} catch {
|
||||
// Quota or privacy mode: a lost write only costs the reload convenience.
|
||||
}
|
||||
}
|
||||
|
||||
export function clearAgentSheetSession(): void {
|
||||
const store = storage()
|
||||
if (!store) return
|
||||
try {
|
||||
store.removeItem(AGENT_SHEET_SESSION_KEY)
|
||||
} catch {
|
||||
// ignored: see writeAgentSheetSession
|
||||
}
|
||||
}
|
||||
@@ -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() },
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 } : {}),
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -146,6 +146,36 @@ describe('generateText / generateStructured', () => {
|
||||
expect(result.text).toBe('{"ok":true}')
|
||||
})
|
||||
|
||||
it('generateText sends earlier turns as real message turns before the prompt', async () => {
|
||||
const svc = createAnthropicFamilyService(readAiConfig())
|
||||
await svc.generateText({
|
||||
tier: 'assistant',
|
||||
system: 'S',
|
||||
prompt: 'Och förra månaden?',
|
||||
maxTokens: 50,
|
||||
history: [
|
||||
{ role: 'user', text: 'Vad är min största utgift?' },
|
||||
{ role: 'assistant', text: '12 345 kr på 5010.' },
|
||||
],
|
||||
})
|
||||
expect(mockCreate.mock.calls[0][0].messages).toEqual([
|
||||
{ role: 'user', content: 'Vad är min största utgift?' },
|
||||
{ role: 'assistant', content: '12 345 kr på 5010.' },
|
||||
{ role: 'user', content: 'Och förra månaden?' },
|
||||
])
|
||||
})
|
||||
|
||||
it('generateText with an empty history is byte-identical to the single-turn call', async () => {
|
||||
const svc = createAnthropicFamilyService(readAiConfig())
|
||||
await svc.generateText({ tier: 'assistant', system: 'S', prompt: 'Hej', maxTokens: 50, history: [] })
|
||||
expect(mockCreate.mock.calls[0][0]).toEqual({
|
||||
model: 'eu.anthropic.claude-sonnet-5',
|
||||
max_tokens: 50,
|
||||
system: 'S',
|
||||
messages: [{ role: 'user', content: 'Hej' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('generateStructured forces one named tool and returns its input', async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
content: [{ type: 'tool_use', id: 't1', name: 'verdict', input: { paired: true } }],
|
||||
@@ -237,6 +267,29 @@ describe('generateText read-only tool loop', () => {
|
||||
expect(result.usage.outputTokens).toBe(13)
|
||||
})
|
||||
|
||||
it('keeps the earlier turns in front of the tool loop', async () => {
|
||||
const svc = createAnthropicFamilyService(readAiConfig())
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
stop_reason: 'end_turn',
|
||||
content: [{ type: 'text', text: 'Svar.' }],
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
})
|
||||
await svc.generateText({
|
||||
tier: 'assistant',
|
||||
prompt: 'Och förra månaden?',
|
||||
maxTokens: 50,
|
||||
history: [
|
||||
{ role: 'user', text: 'Vad är min största utgift?' },
|
||||
{ role: 'assistant', text: '12 345 kr på 5010.' },
|
||||
],
|
||||
tools: [{ name: 't', description: 'd', jsonSchema: { type: 'object' }, execute: vi.fn() }],
|
||||
})
|
||||
const messages = mockCreate.mock.calls[0][0].messages
|
||||
expect(messages).toHaveLength(3)
|
||||
expect(messages[0]).toEqual({ role: 'user', content: 'Vad är min största utgift?' })
|
||||
expect(messages[2]).toEqual({ role: 'user', content: 'Och förra månaden?' })
|
||||
})
|
||||
|
||||
it('surfaces a tool failure as an is_error result rather than throwing', async () => {
|
||||
const execute = vi.fn().mockRejectedValue(new Error('report timeout'))
|
||||
const svc = createAnthropicFamilyService(readAiConfig())
|
||||
|
||||
@@ -102,6 +102,25 @@ describe('createOpenAICompatibleService', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('generateText sends earlier turns as message turns before the prompt', async () => {
|
||||
const svc = createOpenAICompatibleService(readAiConfig())
|
||||
await svc.generateText({
|
||||
tier: 'assistant',
|
||||
system: 'S',
|
||||
prompt: 'Och förra månaden?',
|
||||
maxTokens: 50,
|
||||
history: [
|
||||
{ role: 'user', text: 'Vad är min största utgift?' },
|
||||
{ role: 'assistant', text: '12 345 kr på 5010.' },
|
||||
],
|
||||
})
|
||||
const prompt = promptOf()
|
||||
expect(prompt.map((m) => m.role)).toEqual(['system', 'user', 'assistant', 'user'])
|
||||
expect(prompt[1].content).toEqual([{ type: 'text', text: 'Vad är min största utgift?' }])
|
||||
expect(prompt[2].content).toEqual([{ type: 'text', text: '12 345 kr på 5010.' }])
|
||||
expect(prompt[3].content).toEqual([{ type: 'text', text: 'Och förra månaden?' }])
|
||||
})
|
||||
|
||||
it('generateText forwards read-only tools to the model when provided', async () => {
|
||||
const svc = createOpenAICompatibleService(readAiConfig())
|
||||
const execute = vi.fn().mockResolvedValue({ ok: true })
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { AiService } from './types'
|
||||
|
||||
export type {
|
||||
AiCapabilities,
|
||||
AiChatTurn,
|
||||
AiDocumentInput,
|
||||
AiImageMediaType,
|
||||
AiPdfMode,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createAiClient, toProviderModelId, type AiClient } from '../provider'
|
||||
import type { ResolvedAiConfig } from '../config'
|
||||
import { capabilitiesFor } from '../config'
|
||||
import type {
|
||||
AiChatTurn,
|
||||
AiDocumentInput,
|
||||
AiService,
|
||||
AiTier,
|
||||
@@ -39,6 +40,22 @@ const EMPTY_USAGE: AiUsage = {
|
||||
cacheReadInputTokens: 0,
|
||||
}
|
||||
|
||||
/**
|
||||
* Earlier turns as real message turns, then the prompt as the final user
|
||||
* message. With no history this is exactly the single-message array every
|
||||
* non-chat caller sent before (hosted stays byte-identical for them).
|
||||
*/
|
||||
function messagesFor(
|
||||
prompt: string,
|
||||
history: AiChatTurn[] | undefined,
|
||||
): Anthropic.Messages.MessageParam[] {
|
||||
const prior: Anthropic.Messages.MessageParam[] = (history ?? []).map((t) => ({
|
||||
role: t.role,
|
||||
content: t.text,
|
||||
}))
|
||||
return [...prior, { role: 'user', content: prompt }]
|
||||
}
|
||||
|
||||
/** Sum usage across the turns of a tool loop so the reported cost is truthful. */
|
||||
function addUsage(a: AiUsage, b: AiUsage): AiUsage {
|
||||
const s = (x: number | null, y: number | null) => (x ?? 0) + (y ?? 0)
|
||||
@@ -136,7 +153,7 @@ export function createAnthropicFamilyService(cfg: ResolvedAiConfig): AiService {
|
||||
model,
|
||||
max_tokens: req.maxTokens,
|
||||
...(req.system ? { system: req.system } : {}),
|
||||
messages: [{ role: 'user', content: req.prompt }],
|
||||
messages: messagesFor(req.prompt, req.history),
|
||||
}
|
||||
const resp = await getClient().messages.create(params)
|
||||
return { text: textOf(resp), model, usage: usageOf(resp) }
|
||||
@@ -151,7 +168,7 @@ export function createAnthropicFamilyService(cfg: ResolvedAiConfig): AiService {
|
||||
input_schema: t.jsonSchema as Anthropic.Messages.Tool['input_schema'],
|
||||
}))
|
||||
const byName = new Map<string, AiToolDef>(req.tools.map((t) => [t.name, t]))
|
||||
const messages: Anthropic.Messages.MessageParam[] = [{ role: 'user', content: req.prompt }]
|
||||
const messages: Anthropic.Messages.MessageParam[] = messagesFor(req.prompt, req.history)
|
||||
const maxSteps = req.maxSteps ?? DEFAULT_MAX_STEPS
|
||||
let usage = EMPTY_USAGE
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { capabilitiesFor, type ResolvedAiConfig } from '../config'
|
||||
import { extractJsonObject } from '../json'
|
||||
import { rasterizePdf } from '../rasterize-pdf'
|
||||
import type {
|
||||
AiChatTurn,
|
||||
AiDocumentInput,
|
||||
AiService,
|
||||
AiTier,
|
||||
@@ -29,6 +30,16 @@ import type {
|
||||
|
||||
const DEFAULT_MAX_STEPS = 4
|
||||
|
||||
/** Earlier turns as message turns, then the prompt as the final user message. */
|
||||
function messagesWithHistory(prompt: string, history: AiChatTurn[]): ModelMessage[] {
|
||||
const prior: ModelMessage[] = history.map((t) =>
|
||||
t.role === 'assistant'
|
||||
? { role: 'assistant', content: t.text }
|
||||
: { role: 'user', content: t.text },
|
||||
)
|
||||
return [...prior, { role: 'user', content: prompt }]
|
||||
}
|
||||
|
||||
/**
|
||||
* Map our provider-agnostic tool defs onto the AI SDK's tool() shape. The SDK
|
||||
* runs the loop itself (calls execute, feeds the result back) up to the
|
||||
@@ -163,7 +174,12 @@ export function createOpenAICompatibleService(cfg: ResolvedAiConfig): AiService
|
||||
const result = await generateText({
|
||||
model: provider(model),
|
||||
...(req.system ? { system: req.system } : {}),
|
||||
prompt: req.prompt,
|
||||
// The SDK takes either `prompt` or `messages`, never both: a plain
|
||||
// single-turn call keeps `prompt`; a conversation sends the earlier
|
||||
// turns as real messages with the prompt as the final user turn.
|
||||
...(req.history && req.history.length > 0
|
||||
? { messages: messagesWithHistory(req.prompt, req.history) }
|
||||
: { prompt: req.prompt }),
|
||||
maxOutputTokens: req.maxTokens,
|
||||
...(tools ? { tools, stopWhen: stepCountIs(req.maxSteps ?? DEFAULT_MAX_STEPS) } : {}),
|
||||
})
|
||||
|
||||
@@ -66,11 +66,29 @@ export interface AiToolDef {
|
||||
execute: (args: Record<string, unknown>) => Promise<unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* One earlier turn of a conversation, text only. Sent to the model as a real
|
||||
* message turn (not inlined into the prompt), so the answer can refer back
|
||||
* to what was said without the caller re-describing it.
|
||||
*/
|
||||
export interface AiChatTurn {
|
||||
role: 'user' | 'assistant'
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface GenerateTextRequest {
|
||||
tier: AiTier
|
||||
system?: string
|
||||
prompt: string
|
||||
maxTokens: number
|
||||
/**
|
||||
* Earlier turns of the same conversation, oldest first, placed before
|
||||
* `prompt` (which stays the final user message). Callers must hand over a
|
||||
* clean alternation that starts with a user turn (see
|
||||
* lib/agent/ask/persist.ts loadChatHistory); the services do not repair it.
|
||||
* Absent or empty leaves the request exactly as a single-turn call.
|
||||
*/
|
||||
history?: AiChatTurn[]
|
||||
/**
|
||||
* Read-only tools the model may call to gather data before answering. When
|
||||
* present AND the backend supports tool use (capabilities.toolUse), the
|
||||
|
||||
Reference in New Issue
Block a user