feat(chat): single-call console for general.help, persisted, runs on a local model (#1762)

RIP-3 cutover. The free-form /chat panel (general.help) now answers through a
page-scoped single-call console (AskConsole → POST /api/agent/ask) instead of
the streaming Anthropic runtime, so the in-app assistant runs on ANY configured
backend, including a local OpenAI-compatible model (Qwen behind
llama.cpp/Ollama/vLLM). No tool loop, no NDJSON stream, no Anthropic wire format.

Threads still persist: the ask route gains an opt-in persist branch that writes
both turns to agent_conversations/agent_messages as canonical Anthropic text
blocks, so the /chat sidebar and "resume a thread" keep working across old
streaming threads and new single-call ones. Page-scoped one-off asks (a report
page) omit persist and stay stateless.

Scope: only general.help is wired to the console. The tool-loop intents
(transaction.categorization, invoice.draft, supplier_invoice.review) and the
docked AgentSheet still use AgentChat + run-turn.ts because they stage
operations and need the tool loop, so run-turn.ts is intentionally NOT deleted
here (the plan gates its deletion on "once nothing calls them"; RIP-4 migrates
the rest).

- lib/agent/ask/persist.ts: resolveChatConversation (create/resume, ownership),
  persistUserTurn, persistAssistantTurn (append + roll last_message_* forward)
- app/api/agent/ask/route.ts: persist branch (resolve → user turn → answer →
  assistant turn), returns conversation_id; 404 on a foreign conversation
- components/agent/AskConsole.tsx: the console UI (approved sign-off design):
  user bubble + bare-prose answer, thinking indicator, empty/503/paywall states
- ChatConversationView / ChatNewStarter: branch general.help → AskConsole,
  every other intent keeps AgentChat unchanged

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-20 20:35:16 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Opus 4.8
parent a071a0220b
commit 3f6f1ab06e
8 changed files with 872 additions and 4 deletions
+181
View File
@@ -0,0 +1,181 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import {
resolveChatConversation,
persistUserTurn,
persistAssistantTurn,
CHAT_INTENT_ID,
} from '../persist'
interface Recorded {
inserts: { table: string; payload: Record<string, unknown> }[]
updates: { table: string; payload: Record<string, unknown>; id?: string }[]
}
/**
* Hand-rolled supabase double. agent_messages.insert() is awaited directly
* ({ error }); agent_conversations.insert() chains .select('id').single().
* agent_conversations.update() chains .eq('id', ...). The mock branches on the
* table so both shapes resolve.
*/
function makeSupabase(opts: {
conv?: { id: string; user_id: string; company_id: string; intent_id: string } | null
newId?: string
msgInsertError?: unknown
} = {}) {
const rec: Recorded = { inserts: [], updates: [] }
const api = {
from(table: string) {
const chain = {
select: () => chain,
eq: (_col: string, _val: string) => chain,
maybeSingle: async () => ({ data: opts.conv ?? null, error: null }),
insert(payload: Record<string, unknown>) {
rec.inserts.push({ table, payload })
if (table === 'agent_messages') {
return Promise.resolve({ error: opts.msgInsertError ?? null })
}
return {
select: () => ({
single: async () => ({ data: { id: opts.newId ?? 'new-conv' }, error: null }),
}),
}
},
update(payload: Record<string, unknown>) {
return {
eq: async (_col: string, id: string) => {
rec.updates.push({ table, payload, id })
return { error: null }
},
}
},
}
return chain
},
}
return { supabase: api as unknown as SupabaseClient, rec }
}
beforeEach(() => vi.clearAllMocks())
describe('resolveChatConversation', () => {
it('creates a general.help conversation titled from the first question', async () => {
const { supabase, rec } = makeSupabase({ newId: 'conv-new' })
const res = await resolveChatConversation(
supabase,
'user-1',
'company-1',
null,
' Hur bokför jag en lunch med en kund? ',
'report:vat:2026-07',
)
expect(res).toEqual({ ok: true, conversationId: 'conv-new', created: true })
const insert = rec.inserts.find((i) => i.table === 'agent_conversations')!
expect(insert.payload).toMatchObject({
company_id: 'company-1',
user_id: 'user-1',
intent_id: CHAT_INTENT_ID,
context_ref: 'report:vat:2026-07',
})
expect(insert.payload.title).toBe('Hur bokför jag en lunch med en kund?')
})
it('resumes an owned general.help thread', async () => {
const { supabase } = makeSupabase({
conv: {
id: 'conv-9',
user_id: 'user-1',
company_id: 'company-1',
intent_id: CHAT_INTENT_ID,
},
})
const res = await resolveChatConversation(supabase, 'user-1', 'company-1', 'conv-9', 'q')
expect(res).toEqual({ ok: true, conversationId: 'conv-9', created: false })
})
it("refuses a colleague's thread (not_found, not 403)", async () => {
const { supabase } = makeSupabase({
conv: {
id: 'conv-9',
user_id: 'other-user',
company_id: 'company-1',
intent_id: CHAT_INTENT_ID,
},
})
const res = await resolveChatConversation(supabase, 'user-1', 'company-1', 'conv-9', 'q')
expect(res).toEqual({ ok: false, reason: 'not_found' })
})
it('refuses a thread from another company', async () => {
const { supabase } = makeSupabase({
conv: { id: 'conv-9', user_id: 'user-1', company_id: 'company-2', intent_id: CHAT_INTENT_ID },
})
const res = await resolveChatConversation(supabase, 'user-1', 'company-1', 'conv-9', 'q')
expect(res).toEqual({ ok: false, reason: 'not_found' })
})
it('refuses a tool-loop thread (wrong intent)', async () => {
const { supabase } = makeSupabase({
conv: {
id: 'conv-9',
user_id: 'user-1',
company_id: 'company-1',
intent_id: 'transaction.categorization',
},
})
const res = await resolveChatConversation(supabase, 'user-1', 'company-1', 'conv-9', 'q')
expect(res).toEqual({ ok: false, reason: 'not_found' })
})
it('falls back to a default title when the first question is blank', async () => {
const { supabase, rec } = makeSupabase({ newId: 'conv-new' })
await resolveChatConversation(supabase, 'user-1', 'company-1', null, ' ')
const insert = rec.inserts.find((i) => i.table === 'agent_conversations')!
expect(insert.payload.title).toBe('Fråga din assistent')
expect(insert.payload.context_ref).toBeNull()
})
})
describe('persistUserTurn', () => {
it('appends the question as a text-block user message', async () => {
const { supabase, rec } = makeSupabase()
await persistUserTurn(supabase, 'conv-9', 'Stämmer momsen?')
const insert = rec.inserts.find((i) => i.table === 'agent_messages')!
expect(insert.payload).toEqual({
conversation_id: 'conv-9',
role: 'user',
content: [{ type: 'text', text: 'Stämmer momsen?' }],
})
})
it('throws if the insert fails (append-only audit must not silently drop turns)', async () => {
const { supabase } = makeSupabase({ msgInsertError: new Error('rls') })
await expect(persistUserTurn(supabase, 'conv-9', 'q')).rejects.toThrow('rls')
})
})
describe('persistAssistantTurn', () => {
it('appends the answer and rolls the conversation row forward with a preview', async () => {
const { supabase, rec } = makeSupabase()
await persistAssistantTurn(supabase, 'conv-9', 'Ja, momsen stämmer.')
const insert = rec.inserts.find((i) => i.table === 'agent_messages')!
expect(insert.payload).toEqual({
conversation_id: 'conv-9',
role: 'assistant',
content: [{ type: 'text', text: 'Ja, momsen stämmer.' }],
})
const update = rec.updates.find((u) => u.table === 'agent_conversations')!
expect(update.id).toBe('conv-9')
expect(update.payload.last_message_preview).toBe('Ja, momsen stämmer.')
expect(update.payload.last_message_at).toEqual(expect.any(String))
})
it('truncates a long preview to 200 chars with an ellipsis', async () => {
const { supabase, rec } = makeSupabase()
await persistAssistantTurn(supabase, 'conv-9', 'x'.repeat(500))
const preview = rec.updates.find((u) => u.table === 'agent_conversations')!.payload
.last_message_preview as string
expect(preview.length).toBe(200)
expect(preview.endsWith('…')).toBe(true)
})
})
+137
View File
@@ -0,0 +1,137 @@
import type { SupabaseClient } from '@supabase/supabase-js'
/**
* Thread persistence for the single-call chat console (/chat).
*
* The console answers in one call via answerAssistantQuestion (no tool loop,
* no streaming: runs on a local model). But the founder wants the /chat
* sidebar and "resume a thread" to keep working, so each console turn is
* written to the SAME durable tables the streaming runtime uses:
* agent_conversations + agent_messages. That way old and new threads live in
* one list and one schema.
*
* Content is stored as the canonical Anthropic content array
* ([{ type: 'text', text }]) so normalizeStoredMessages() renders these rows
* identically to run-turn's, and the BFL append-only audit invariant on
* agent_messages holds (no UPDATE/DELETE policy exists on that table).
*
* Only the general.help intent uses this path. Tool-loop intents
* (categorization, invoice draft, supplier review) still go through
* run-turn.ts because they stage operations and need the tool loop.
*/
/** The only intent the single-call console persists under. */
export const CHAT_INTENT_ID = 'general.help'
// 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
const TITLE_MAX = 80
/** Truncate on a whole-grapheme boundary is overkill here; a hard slice with an ellipsis is fine for a preview. */
function clamp(text: string, max: number): string {
const t = text.trim().replace(/\s+/g, ' ')
if (t.length <= max) return t
return `${t.slice(0, max - 1).trimEnd()}…`
}
function textBlocks(text: string): { type: 'text'; text: string }[] {
return [{ type: 'text', text }]
}
export type ResolveConversationResult =
| { ok: true; conversationId: string; created: boolean }
// The client passed a conversation id that isn't this user's general.help
// thread in this company. Same outcome as "doesn't exist": a 404, never a
// 403 that would confirm someone else's id is real.
| { ok: false; reason: 'not_found' }
/**
* Resolve the conversation to append this turn to.
*
* With no id, create a fresh general.help conversation titled from the first
* question. With an id, verify ownership the same way /api/agent/invoke does:
* RLS on agent_conversations is COMPANY-scoped, so a colleague's thread would
* otherwise load; the user_id + company_id + intent_id checks close that.
*/
export async function resolveChatConversation(
supabase: SupabaseClient,
userId: string,
companyId: string,
conversationId: string | null | undefined,
firstQuestion: string,
contextRef?: string | null,
): Promise<ResolveConversationResult> {
if (conversationId) {
const { data: conv } = await supabase
.from('agent_conversations')
.select('id, user_id, company_id, intent_id')
.eq('id', conversationId)
.maybeSingle()
if (
!conv ||
conv.user_id !== userId ||
conv.company_id !== companyId ||
conv.intent_id !== CHAT_INTENT_ID
) {
return { ok: false, reason: 'not_found' }
}
return { ok: true, conversationId: conv.id as string, created: false }
}
const { data: created, error } = await supabase
.from('agent_conversations')
.insert({
company_id: companyId,
user_id: userId,
intent_id: CHAT_INTENT_ID,
context_ref: contextRef ?? null,
title: clamp(firstQuestion, TITLE_MAX) || 'Fråga din assistent',
})
.select('id')
.single()
if (error || !created) throw error ?? new Error('Failed to create conversation')
return { ok: true, conversationId: created.id as string, created: true }
}
/** Append the user's question as a persisted turn (append-only). */
export async function persistUserTurn(
supabase: SupabaseClient,
conversationId: string,
question: string,
): Promise<void> {
const { error } = await supabase.from('agent_messages').insert({
conversation_id: conversationId,
role: 'user',
content: textBlocks(question),
})
if (error) throw error
}
/**
* Append the assistant's answer and roll the conversation row forward so the
* sidebar shows this thread at the top with a fresh preview. run-turn updates
* the same two columns on every assistant turn; we mirror that exactly.
*/
export async function persistAssistantTurn(
supabase: SupabaseClient,
conversationId: string,
answer: string,
): Promise<void> {
const { error: msgErr } = await supabase.from('agent_messages').insert({
conversation_id: conversationId,
role: 'assistant',
content: textBlocks(answer),
})
if (msgErr) throw msgErr
// Best-effort: a failed roll-forward only mis-sorts the sidebar row, it does
// not lose the answer (already persisted above). Do not fail the request on it.
await supabase
.from('agent_conversations')
.update({
last_message_at: new Date().toISOString(),
last_message_preview: clamp(answer, PREVIEW_MAX),
})
.eq('id', conversationId)
}