fix(assistant): keep proposals, selections and picks intact across a resume (#1212)

* fix(assistant): keep proposals, selections and picks intact across a resume

PR3 of the assistant UI makeover (dev_docs/assistant_redesign_plan.md section 7):
resume fidelity. Four ways the chat lost state that the user had every reason to
think was still there.

Approval cards ride on streamed staged_operation events, which are never
persisted, so reopening a conversation rendered the tool trace and the answer
but silently dropped the card. The proposal then sat in Granskning for its full
30-day expiry with nothing in the thread pointing at it. run-turn already stamps
agent_metadata.conversation_id on every staged row, so both resume paths (the
sheet's history and the /chat page) now re-attach the still-pending ones to the
last assistant turn.

Regenerate abandoned whatever the discarded turn had staged: the card left the
screen, the operation stayed pending, and the regenerated turn usually staged a
second proposal for the same booking, leaving two live proposals for one action.
It now withdraws them through the same reject path the Avslå button uses, so the
audit trail records why they went away.

The sheet's remount key ignored intentArgs while some callers pass a CONSTANT
contextRef with varying args: bulk-book always uses 'inbox:bulk' and carries the
selected ids. Selecting A+B, collapsing, then selecting C+D reopened the A+B
conversation while the user believed C+D were being booked. The key now includes
a stable serialization of the args.

Picking conversation A (slow) then B (fast) let A's late response overwrite B,
leaving the user typing into a thread they did not choose. A sequence token now
means only the newest pick may write state.

Verified: 9540 unit tests pass (11 new), lint and tsc clean on every touched
file, guards pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: record the resume-fidelity decisions

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(assistant): render hydrated proposals with the same preview as live ones

pending_operations.operation_type stores the bare action name
('categorize_transaction'), while the streamed card carries the MCP tool name
('gnubok_categorize_transaction') and ApprovalCard's PreviewBlock dispatches on
that. Hydrated cards therefore fell through to the flat generic preview instead
of the journal-line one, so a resumed proposal looked materially worse than the
same proposal did live: the opposite of what this PR is for.

Found by checking the query against prod rather than trusting the mock, which is
also how the stored value space was confirmed: categorize_transaction,
create_voucher and approve_supplier_invoice are what exist in the wild, and the
four operation types that have a specialized renderer all stage unprefixed.

The test fixture now uses the real stored shape so the mapping is actually
covered rather than assumed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(assistant): await proposal withdrawals, surface staged-query errors, share the type

Review follow-ups on the resume-fidelity batch. All four findings were valid.

The withdrawals were fire-and-forget and raced the replacement turn, so the new
turn could stage a second proposal before the old one was rejected: the exact
double-staging this change exists to prevent. They are now awaited, a 409 counts
as withdrawn (someone else resolved it, which is all we need), and if any
withdrawal genuinely fails the turn stays on screen with an error rather than
hiding a card whose operation is still pending.

Both staged-operation loaders ignored their error result, so a database or
policy failure rendered the conversation as successful with the proposals
silently missing: again the failure this query exists to prevent, reintroduced
through the error path. Both now propagate, matching the sibling message query.

StoredStagedOperation now lives in @/types: it is a persisted API contract that
crosses a server page and three components, not an AgentChat detail.

The unserializable-args fallback used a timestamp, which collides for two
objects created in the same millisecond and changes on every render tick for the
same object, remounting the sheet mid-session. A WeakMap gives each object one
stable id for its lifetime.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-26 21:56:50 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent f0f3050f54
commit 4de648fb5d
10 changed files with 486 additions and 11 deletions
+2
View File
@@ -387,3 +387,5 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-07-26] Fixed cross-user attachment access (Odin Aero support case) at the call sites with service-role clients after company-scoped authorization, instead of rewriting the documents bucket storage policy to be company-scoped like sie-files got in 20260416120000: the documents path layout (documents/{userId}/...) carries no company_id, so a company-scoped policy needs a per-object join against document_attachments on every storage op, and the authorize-then-service-client pattern was already the established model (inline proxy route, v1 download route, MCP tools). Sweep found and fixed the same defect in the metadata/sign route, the integrity probe, verifyIntegrity, invoice-inbox retry-extraction, and cloud-backup archive generation. Known leftover, deliberately unfixed: deleteDocument and the upload-failure cleanups call storage remove() with a user-bound client, which silently no-ops (no DELETE policy, WORM), orphaning storage objects; harmless for compliance, needs a separate decision on whether files should ever be hard-deleted.
[2026-07-26] reverseEntry now blocks only source_type='storno', no longer 'correction': BFL 5 kap 5 § requires traceability, not immunity for rättelseverifikat, and blocking corrections left users with no sanctioned exit when a rättelse duplicated an affärshändelse booked elsewhere (support case 2026-07-26); it also broke uncategorize-after-rättelse since transactions are relinked to the correction entry. Storno-of-storno stays blocked (chain ambiguity).
[2026-07-26] Supplier-invoice DELETE allows 'approved' (not only 'registered'/'overdue'): the overdue cron flips BOTH registered and approved invoices to 'overdue', so excluding 'approved' would make deletability depend on whether the cron ran yet; the orphan-safety checks (no registration verifikat, no payments, no accrual schedule) are the real guard, and an attested but unbooked, unpaid invoice deletes nothing from the books.
[2026-07-26] Hydrating approval cards on resume re-links pending_operations via agent_metadata->>conversation_id rather than persisting the staged_operation stream events: the metadata stamp already exists for the BFL trail, so no migration and no second source of truth for a proposal. Cards render from the operation row, which is also what commit/reject act on, so a hydrated card and a live one cannot disagree.
[2026-07-26] Regenerate now rejects the discarded turn's staged operations through the normal reject endpoint instead of leaving them pending: the alternative (silently deleting) would break the append-only audit trail, and leaving them (previous behaviour) produced two live proposals for one booking, each with its own 30-day expiry.
+17 -1
View File
@@ -1,5 +1,6 @@
import { notFound, redirect } from 'next/navigation'
import ChatConversationView from '@/components/agent/ChatConversationView'
import type { StoredStagedOperation } from '@/types'
import { getDashboardAuthContext, getDashboardCompanyId } from '../../request-context'
export const dynamic = 'force-dynamic'
@@ -24,7 +25,7 @@ export default async function ChatConversationPage({ params }: PageProps) {
// Both queries key on the route id, so they run in parallel. The tenant
// check on the conversation row still gates rendering — when it fails,
// notFound() throws and the messages result is discarded unrendered.
const [{ data: conversation }, { data: messages }] = await Promise.all([
const [{ data: conversation }, { data: messages }, staged] = await Promise.all([
supabase
.from('agent_conversations')
.select('id, intent_id, context_ref, title, pinned, archived, last_message_at')
@@ -36,9 +37,23 @@ export default async function ChatConversationPage({ params }: PageProps) {
.select('role, content, hidden, created_at')
.eq('conversation_id', id)
.order('created_at', { ascending: true }),
// Proposals this thread staged that are still unanswered, so the approval
// card comes back on resume rather than the proposal quietly waiting out
// its expiry in Granskning with nothing here pointing at it.
supabase
.from('pending_operations')
.select('id, operation_type, title, risk_level, preview_data, created_at')
.eq('company_id', companyId)
.eq('status', 'pending')
.eq('agent_metadata->>conversation_id', id)
.order('created_at', { ascending: true }),
])
if (!conversation) notFound()
// An empty list here would silently hide still-open proposals, which is the
// failure this query exists to prevent: fail loudly instead of quietly
// rendering a thread that looks like it never staged anything.
if (staged.error) throw staged.error
return (
<ChatConversationView
@@ -47,6 +62,7 @@ export default async function ChatConversationPage({ params }: PageProps) {
contextRef={conversation.context_ref}
title={conversation.title ?? intentLabel(conversation.intent_id)}
rawMessages={(messages ?? []) as { role: string; content: unknown; hidden?: boolean }[]}
stagedOperations={(staged.data ?? []) as StoredStagedOperation[]}
/>
)
}
+27 -1
View File
@@ -68,7 +68,33 @@ export const GET = withRouteContext(
.order('created_at', { ascending: true })
if (msgErr) throw msgErr
return NextResponse.json({ data: { conversation: conv, messages: messages ?? [] } })
// Proposals this conversation staged that nobody has answered yet.
//
// Approval cards ride on the streamed `staged_operation` events, which are
// not persisted, so a resumed thread rendered the tool trace and the answer
// but silently dropped the card: the proposal then sat in Granskning for
// its full 30 days with nothing in the conversation pointing at it.
// run-turn stamps agent_metadata.conversation_id on every staged row, so
// the still-open ones can be re-attached here.
const { data: staged, error: stagedErr } = await supabase
.from('pending_operations')
.select('id, operation_type, title, risk_level, preview_data, created_at')
.eq('company_id', conv.company_id)
.eq('status', 'pending')
.eq('agent_metadata->>conversation_id', id)
.order('created_at', { ascending: true })
// Do not swallow this: silently returning an empty list would drop the very
// proposals this query exists to restore, and the thread would look like it
// never staged anything. Same handling as the message query above.
if (stagedErr) throw stagedErr
return NextResponse.json({
data: {
conversation: conv,
messages: messages ?? [],
staged_operations: staged ?? [],
},
})
},
)
@@ -0,0 +1,121 @@
/**
* GET /api/agent/conversations/[id] returns the conversation's still-open
* proposals alongside its messages.
*
* Approval cards live in streamed events that are never persisted, so without
* these rows a resumed thread renders the tool trace and the answer but drops
* the card, and the proposal waits out its 30-day expiry in Granskning with
* nothing in the conversation pointing at it.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import {
createMockRequest,
createMockRouteParams,
parseJsonResponse,
createQueuedMockSupabase,
} from '@/tests/helpers'
const { supabase: mockSupabase, enqueue, enqueueMany, reset } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
import { GET } from '../[id]/route'
const CONV = { id: 'conv-1', company_id: 'company-1', user_id: 'user-1', intent_id: 'general.help' }
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase: mockSupabase, error: null })
})
describe('GET /api/agent/conversations/[id]', () => {
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase: mockSupabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const res = await GET(
createMockRequest('/api/agent/conversations/conv-1'),
createMockRouteParams({ id: 'conv-1' }),
)
expect((await parseJsonResponse(res)).status).toBe(401)
})
it('returns 404 for a conversation the caller does not own', async () => {
enqueue({ data: null }) // conversation lookup filters on user_id
const res = await GET(
createMockRequest('/api/agent/conversations/conv-1'),
createMockRouteParams({ id: 'conv-1' }),
)
expect((await parseJsonResponse(res)).status).toBe(404)
})
it('returns unanswered proposals alongside the messages', async () => {
enqueueMany([
{ data: CONV }, // conversation
{ data: { role: 'owner' } }, // company_members
{ data: [{ role: 'assistant', content: [] }] }, // agent_messages
{
data: [
{
id: 'op-1',
operation_type: 'gnubok_categorize_transaction',
title: 'Kontering: Circle K',
risk_level: 'low',
preview_data: {},
},
],
}, // pending_operations
])
const res = await GET(
createMockRequest('/api/agent/conversations/conv-1'),
createMockRouteParams({ id: 'conv-1' }),
)
const { status, body } = await parseJsonResponse<{
data: { staged_operations: { id: string }[]; messages: unknown[] }
}>(res)
expect(status).toBe(200)
expect(body.data.messages).toHaveLength(1)
expect(body.data.staged_operations).toHaveLength(1)
expect(body.data.staged_operations[0]!.id).toBe('op-1')
})
it('returns an empty list rather than failing when nothing is staged', async () => {
enqueueMany([
{ data: CONV },
{ data: { role: 'owner' } },
{ data: [] },
{ data: null }, // no rows at all
])
const res = await GET(
createMockRequest('/api/agent/conversations/conv-1'),
createMockRouteParams({ id: 'conv-1' }),
)
const { status, body } = await parseJsonResponse<{
data: { staged_operations: unknown[] }
}>(res)
expect(status).toBe(200)
expect(body.data.staged_operations).toEqual([])
})
})
+96 -2
View File
@@ -19,6 +19,7 @@ import { CAPABILITY } from '@/lib/entitlements/keys'
import { UpgradeNote } from '@/components/billing/UpgradeNote'
import ApprovalCard from './ApprovalCard'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import type { StoredStagedOperation } from '@/types'
// Markdown parser loads separately from the chat surface: react-markdown +
// remark-gfm pull in the whole unified/remark tree.
@@ -418,8 +419,53 @@ export default function AgentChat({
}
if (lastUserIdx === -1) return
const userMsg = messages[lastUserIdx]
setMessages(messages.slice(0, lastUserIdx + 1))
void startTurn({ conversationId, userMessage: userMsg.text })
// Anything the discarded turn staged has to be withdrawn BEFORE the
// replacement runs. Otherwise the operation stays pending server-side while
// the regenerated turn stages a second proposal for the same booking: two
// live proposals for one action, each with its own 30-day expiry. Rejecting
// is the same path the Avslå button uses, so the audit trail records why it
// went away.
const abandoned = messages
.slice(lastUserIdx + 1)
.flatMap((m) => m.staged ?? [])
.map((s) => s.operation_id)
.filter((id): id is string => typeof id === 'string')
void (async () => {
const withdrawn = await Promise.all(
abandoned.map(async (operationId) => {
try {
const res = await fetch(`/api/pending-operations/${operationId}/reject`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
rejection_category: 'other',
rejection_reason: 'Ersatt: användaren begärde ett nytt svar.',
}),
})
// 409 means someone already resolved it (approved in Granskning, or
// a parallel client): it is no longer pending either way, which is
// all we need.
return res.ok || res.status === 409
} catch {
return false
}
}),
)
if (withdrawn.some((ok) => !ok)) {
// Leave the turn on screen: hiding a card whose operation is still
// pending is the failure mode this whole change exists to remove.
setErrorMessage(
'Kunde inte dra tillbaka det tidigare förslaget, så svaret behölls. Försök igen.',
)
return
}
setMessages((prev) => prev.slice(0, lastUserIdx + 1))
void startTurn({ conversationId, userMessage: userMsg.text })
})()
}
// Fired after the user rejects a proposal with a reason. The rejection is
@@ -994,6 +1040,54 @@ function prettyToolName(name: string): string {
// Helper used by /chat/[id] server component to normalize agent_messages
// rows into the ChatMessage shape this component expects. Exported here so
// both the sheet (for future "resume" support) and the page can use it.
/**
* Re-attach unanswered proposals to a hydrated thread.
*
* Approval cards ride on streamed events that are never persisted, so without
* this a resumed conversation shows the tool trace and the answer but no card,
* and the proposal quietly waits out its 30-day expiry in Granskning. They land
* on the last assistant message so they read as that turn's proposal, which is
* where they were when the turn streamed.
*/
/**
* `pending_operations.operation_type` stores the bare action name
* ('categorize_transaction'), while the live streamed card carries the MCP tool
* name ('gnubok_categorize_transaction') and ApprovalCard's PreviewBlock
* dispatches on that. Without this, every hydrated card fell through to the
* flat generic preview instead of the journal-line one, so a resumed proposal
* looked materially worse than the same proposal did live.
*/
export function toolNameFor(operationType: string): string {
return operationType.startsWith('gnubok_') ? operationType : `gnubok_${operationType}`
}
export function attachStagedOperations(
messages: ChatMessage[],
staged: StoredStagedOperation[],
): ChatMessage[] {
if (staged.length === 0) return messages
const cards: StagedOperation[] = staged.map((op) => ({
// Hydrated cards have no tool_use_id (it lived only in the stream); the
// operation id is the stable key and the only thing commit/reject need.
tool_use_id: `hydrated:${op.id}`,
operation_id: op.id,
risk_level:
op.risk_level === 'high' || op.risk_level === 'medium' ? op.risk_level : 'low',
message: op.title ?? 'Förslag väntar på granskning.',
tool_name: toolNameFor(op.operation_type),
preview: op.preview_data,
}))
const lastAssistantIdx = messages.map((m) => m.role).lastIndexOf('assistant')
if (lastAssistantIdx === -1) {
return [...messages, { role: 'assistant', text: '', staged: cards }]
}
return messages.map((m, i) =>
i === lastAssistantIdx ? { ...m, staged: [...(m.staged ?? []), ...cards] } : m,
)
}
export function normalizeStoredMessages(
rows: { role: string; content: unknown; hidden?: boolean | null }[],
): ChatMessage[] {
+20 -4
View File
@@ -2,7 +2,12 @@
import { useEffect, useRef, useState } from 'react'
import { X, Expand, Shrink, PanelRightClose, Eraser, History, ChevronLeft, Loader2 } from 'lucide-react'
import AgentChat, { normalizeStoredMessages, type ChatMessage } from './AgentChat'
import AgentChat, {
attachStagedOperations,
normalizeStoredMessages,
type ChatMessage,
} from './AgentChat'
import type { StoredStagedOperation } from '@/types'
import AgentAvatar from './AgentAvatar'
import AgentSessionList from './AgentSessionList'
import SandboxAgentPreview from './SandboxAgentPreview'
@@ -69,6 +74,8 @@ export default function AgentSheet({
const displayTitle = loaded ? (loaded.title ?? intentToTitle(loaded.intentId, agentName)) : sheetTitle
const activeConversationId = loaded?.id ?? conversationId
const sheetRef = useRef<HTMLDivElement | null>(null)
// Monotonic counter so a slow conversation fetch can't clobber a newer pick.
const selectSeqRef = useRef(0)
// Esc: back out of the session list first, otherwise close. Never while
// collapsed (the sheet is hidden off-screen, so Esc belongs elsewhere).
@@ -129,6 +136,10 @@ export default function AgentSheet({
setLoaded(null)
setLoadingConversation(true)
setLoadError(null)
// Sequence token: picking A (slow) then B (fast) used to end with A's
// response overwriting B, leaving the user typing into a conversation they
// did not choose. Only the newest selection may write state.
const seq = ++selectSeqRef.current
try {
const res = await fetch(`/api/agent/conversations/${id}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
@@ -141,22 +152,27 @@ export default function AgentSheet({
title: string | null
}
messages: { role: string; content: unknown; hidden?: boolean | null }[]
staged_operations?: StoredStagedOperation[]
}
}
const data = json.data
if (!data) throw new Error('missing data')
if (seq !== selectSeqRef.current) return
setLoaded({
id: data.conversation.id,
intentId: data.conversation.intent_id,
contextRef: data.conversation.context_ref,
title: data.conversation.title,
messages: normalizeStoredMessages(data.messages),
messages: attachStagedOperations(
normalizeStoredMessages(data.messages),
data.staged_operations ?? [],
),
})
setConversationId(data.conversation.id)
} catch {
setLoadError('Kunde inte öppna konversationen.')
if (seq === selectSeqRef.current) setLoadError('Kunde inte öppna konversationen.')
} finally {
setLoadingConversation(false)
if (seq === selectSeqRef.current) setLoadingConversation(false)
}
}
+35 -1
View File
@@ -27,6 +27,40 @@ function AgentSheetSkeleton() {
)
}
/**
* Serialize intent args into the sheet's remount key.
*
* The key used to be intent + contextRef + seed only, but some callers pass a
* CONSTANT contextRef with varying args: bulk-book always uses 'inbox:bulk' and
* carries the selected item ids. Selecting A+B, collapsing, then selecting C+D
* produced the same key, so the sheet did not remount and the earlier
* conversation reopened while the user believed C+D were being booked.
*
* Key order so the same selection reached by different routes stays one session.
*/
// Fallback identity for args that cannot be serialized (cycles, non-JSON
// values). A timestamp would be wrong twice over: two different objects created
// in the same millisecond would collide, and the same object would get a new
// key on every render tick, remounting the sheet under the user mid-session.
// A WeakMap gives each object one stable id for as long as it exists.
const argsFallbackIds = new WeakMap<object, string>()
let argsFallbackSeq = 0
function stableArgsKey(args?: Record<string, unknown>): string {
if (!args) return ''
try {
const keys = Object.keys(args).sort()
return JSON.stringify(keys.map((k) => [k, args[k]]))
} catch {
let id = argsFallbackIds.get(args)
if (!id) {
id = `unserializable:${++argsFallbackSeq}`
argsFallbackIds.set(args, id)
}
return id
}
}
/** Warm the sheet chunk when the browser is idle, never on the critical path. */
function useSheetPrefetch() {
useEffect(() => {
@@ -174,7 +208,7 @@ export function AgentSheetProvider({ children, identity }: AgentSheetProviderPro
{children}
{activeArgs && (
<AgentSheet
key={`${activeArgs.intentId}:${activeArgs.contextRef ?? ''}:${activeArgs.seedUserMessage ?? ''}:${restartNonce}`}
key={`${activeArgs.intentId}:${activeArgs.contextRef ?? ''}:${stableArgsKey(activeArgs.intentArgs)}:${activeArgs.seedUserMessage ?? ''}:${restartNonce}`}
intentId={activeArgs.intentId}
intentArgs={activeArgs.intentArgs}
contextRef={activeArgs.contextRef}
+11 -2
View File
@@ -3,7 +3,8 @@
import { useMemo } from 'react'
import Link from 'next/link'
import { ArrowLeft } from 'lucide-react'
import AgentChat, { normalizeStoredMessages } from './AgentChat'
import AgentChat, { attachStagedOperations, normalizeStoredMessages } from './AgentChat'
import type { StoredStagedOperation } from '@/types'
import AgentAvatar from './AgentAvatar'
import SandboxAgentPreview from './SandboxAgentPreview'
import { useAgentSheet } from './AgentSheetProvider'
@@ -15,6 +16,10 @@ interface Props {
contextRef: string | null
title: string
rawMessages: { role: string; content: unknown; hidden?: boolean }[]
// Proposals this conversation staged that are still awaiting an answer, so
// the approval card reappears on resume instead of the proposal silently
// waiting out its expiry in Granskning.
stagedOperations?: StoredStagedOperation[]
}
// Full-page conversation view. Wraps AgentChat with a header that shows the
@@ -25,8 +30,12 @@ export default function ChatConversationView({
contextRef,
title,
rawMessages,
stagedOperations,
}: Props) {
const initialMessages = useMemo(() => normalizeStoredMessages(rawMessages), [rawMessages])
const initialMessages = useMemo(
() => attachStagedOperations(normalizeStoredMessages(rawMessages), stagedOperations ?? []),
[rawMessages, stagedOperations],
)
const { identity } = useAgentSheet()
const companyCtx = useCompanyOptional()
const isSandbox = companyCtx?.isSandbox ?? false
@@ -0,0 +1,139 @@
import { describe, it, expect } from 'vitest'
import { attachStagedOperations, toolNameFor } from '../AgentChat'
import type { StoredStagedOperation } from '@/types'
/**
* Approval cards ride on streamed `staged_operation` events, which are never
* persisted. Without re-attaching them, reopening a conversation showed the
* tool trace and the answer but no card, so an unapproved proposal waited out
* its full 30-day expiry in Granskning with nothing in the thread pointing at
* it. This is the reattachment.
*/
const op = (overrides: Partial<StoredStagedOperation> = {}): StoredStagedOperation => ({
id: 'op-1',
// As actually stored: pending_operations.operation_type is the bare action
// name, verified against prod (categorize_transaction / create_voucher /
// approve_supplier_invoice are the values in the wild).
operation_type: 'categorize_transaction',
title: 'Kontering: Circle K, 689 kr',
risk_level: 'low',
preview_data: { lines: [] },
...overrides,
})
describe('attachStagedOperations', () => {
it('returns the thread untouched when nothing is pending', () => {
const messages = [
{ role: 'user' as const, text: 'boka om' },
{ role: 'assistant' as const, text: 'klart' },
]
expect(attachStagedOperations(messages, [])).toBe(messages)
})
it('attaches a pending proposal to the last assistant turn', () => {
const messages = [
{ role: 'user' as const, text: 'boka om Circle K' },
{ role: 'assistant' as const, text: 'Klart att godkänna.' },
]
const [, assistant] = attachStagedOperations(messages, [op()])
expect(assistant!.staged).toHaveLength(1)
expect(assistant!.staged![0]).toMatchObject({
operation_id: 'op-1',
risk_level: 'low',
tool_name: 'gnubok_categorize_transaction',
message: 'Kontering: Circle K, 689 kr',
})
})
it('maps the stored operation_type onto the tool name the preview dispatches on', () => {
// pending_operations stores the bare action name; the live card carries the
// MCP tool name, and ApprovalCard's PreviewBlock keys off that. Getting this
// wrong is invisible in a mock but drops every hydrated card to the flat
// generic preview instead of the journal-line one. These four are the
// operation types that have a specialized renderer.
for (const t of [
'categorize_transaction',
'create_invoice',
'create_voucher',
'correct_entry',
]) {
expect(toolNameFor(t)).toBe(`gnubok_${t}`)
}
})
it('leaves an already-prefixed operation type alone', () => {
expect(toolNameFor('gnubok_create_voucher')).toBe('gnubok_create_voucher')
})
it('keeps risk level for medium and high, and floors anything unknown to low', () => {
const messages = [{ role: 'assistant' as const, text: 'svar' }]
const risks = (level: string | null) =>
attachStagedOperations(messages, [op({ risk_level: level })])[0]!.staged![0]!.risk_level
expect(risks('high')).toBe('high')
expect(risks('medium')).toBe('medium')
// An unrecognized value must not silently render as a one-click low-risk
// approval... but it also must not crash: low is the safe render, and the
// server re-checks risk on commit regardless.
expect(risks('nonsense')).toBe('low')
expect(risks(null)).toBe('low')
})
it('preserves staged cards already on the message', () => {
const messages = [
{
role: 'assistant' as const,
text: 'svar',
staged: [
{
tool_use_id: 'tu_live',
operation_id: 'op-live',
risk_level: 'low' as const,
message: 'redan här',
},
],
},
]
const out = attachStagedOperations(messages, [op({ id: 'op-2' })])
expect(out[0]!.staged!.map((s) => s.operation_id)).toEqual(['op-live', 'op-2'])
})
it('appends a carrier turn when the thread has no assistant message', () => {
// Defensive: a conversation whose assistant turn was never persisted still
// has to show its proposal rather than swallow it.
const out = attachStagedOperations([{ role: 'user', text: 'boka' }], [op()])
expect(out).toHaveLength(2)
expect(out[1]!.role).toBe('assistant')
expect(out[1]!.staged).toHaveLength(1)
})
it('attaches to the LAST assistant turn, not the first', () => {
const messages = [
{ role: 'assistant' as const, text: 'första' },
{ role: 'user' as const, text: 'och sen?' },
{ role: 'assistant' as const, text: 'andra' },
]
const out = attachStagedOperations(messages, [op()])
expect(out[0]!.staged).toBeUndefined()
expect(out[2]!.staged).toHaveLength(1)
})
it('gives every hydrated card a distinct tool_use_id key', () => {
const out = attachStagedOperations(
[{ role: 'assistant', text: 'svar' }],
[op({ id: 'op-a' }), op({ id: 'op-b' })],
)
const keys = out[0]!.staged!.map((s) => s.tool_use_id)
expect(new Set(keys).size).toBe(2)
})
})
+18
View File
@@ -3839,3 +3839,21 @@ export interface AGIDeclaration {
created_at: string
updated_at: string
}
/**
* A `pending_operations` row a chat conversation staged and nobody has answered
* yet, as returned by GET /api/agent/conversations/[id] and by the /chat/[id]
* server page.
*
* Approval cards ride on streamed events that are never persisted, so this is
* what lets a resumed thread show its still-open proposal instead of silently
* dropping it. `operation_type` is the bare action name as stored
* ('categorize_transaction'), not the prefixed MCP tool name.
*/
export interface StoredStagedOperation {
id: string
operation_type: string
title?: string | null
risk_level?: string | null
preview_data?: unknown
}