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:
co-authored by
Jakob Wennberg
Claude Opus 4.8
parent
a071a0220b
commit
3f6f1ab06e
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user