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
@@ -1188,3 +1188,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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").
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<Response> {
|
||||
}
|
||||
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<Response> {
|
||||
question: parsed.data.question,
|
||||
pageContext: parsed.data.context,
|
||||
tier: parsed.data.tier,
|
||||
history,
|
||||
})
|
||||
|
||||
// answerAssistantQuestion throws EmptyModelAnswerError on an empty answer,
|
||||
|
||||
@@ -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<string, unknown>
|
||||
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<LoadedConversation | null>(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<string | null>(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 (
|
||||
|
||||
@@ -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 && (
|
||||
<AgentSheet
|
||||
key={`${activeArgs.intentId}:${activeArgs.contextRef ?? ''}:${stableArgsKey(activeArgs.intentArgs)}:${activeArgs.seedUserMessage ?? ''}:${restartNonce}`}
|
||||
key={`${activeArgs.intentId}:${activeArgs.contextRef ?? ''}:${stableArgsKey(activeArgs.intentArgs)}:${activeArgs.seedUserMessage ?? ''}:${activeArgs.resumeConversationId ?? ''}:${restartNonce}`}
|
||||
intentId={activeArgs.intentId}
|
||||
intentArgs={activeArgs.intentArgs}
|
||||
contextRef={activeArgs.contextRef}
|
||||
seedUserMessage={activeArgs.seedUserMessage}
|
||||
resumeConversationId={activeArgs.resumeConversationId}
|
||||
collapsed={collapsed}
|
||||
onStatus={publishAgentStatus}
|
||||
onDockWidthChange={setDockWidth}
|
||||
|
||||
@@ -53,8 +53,13 @@ export function DeployReloadPrompt() {
|
||||
if (!stale) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-x-0 bottom-4 z-[60] flex justify-center px-4">
|
||||
<div className="flex items-center gap-3 rounded-lg border border-border bg-popover px-4 py-3 text-sm shadow-md">
|
||||
// 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.
|
||||
<div className="pointer-events-none fixed inset-x-0 bottom-4 z-[60] flex justify-center px-4">
|
||||
<div className="pointer-events-auto flex items-center gap-3 rounded-lg border border-border bg-popover px-4 py-3 text-sm shadow-md">
|
||||
<span className="text-foreground">{t('update_available')}</span>
|
||||
<Button size="sm" onClick={() => window.location.reload()}>
|
||||
{t('reload')}
|
||||
|
||||
@@ -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