diff --git a/DECISIONS.md b/DECISIONS.md index eea2d968..c11102aa 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -387,3 +387,5 @@ One line per decision: `[YYYY-MM-DD] : `. 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. diff --git a/app/(dashboard)/chat/[id]/page.tsx b/app/(dashboard)/chat/[id]/page.tsx index 0972aa1d..4559cb57 100644 --- a/app/(dashboard)/chat/[id]/page.tsx +++ b/app/(dashboard)/chat/[id]/page.tsx @@ -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 ( ) } diff --git a/app/api/agent/conversations/[id]/route.ts b/app/api/agent/conversations/[id]/route.ts index 322ca86c..0314361e 100644 --- a/app/api/agent/conversations/[id]/route.ts +++ b/app/api/agent/conversations/[id]/route.ts @@ -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 ?? [], + }, + }) }, ) diff --git a/app/api/agent/conversations/__tests__/detail-staged.test.ts b/app/api/agent/conversations/__tests__/detail-staged.test.ts new file mode 100644 index 00000000..1cbab03b --- /dev/null +++ b/app/api/agent/conversations/__tests__/detail-staged.test.ts @@ -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([]) + }) +}) diff --git a/components/agent/AgentChat.tsx b/components/agent/AgentChat.tsx index 0b54bc48..6f3ec0bf 100644 --- a/components/agent/AgentChat.tsx +++ b/components/agent/AgentChat.tsx @@ -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[] { diff --git a/components/agent/AgentSheet.tsx b/components/agent/AgentSheet.tsx index 6259c955..2af3ab2d 100644 --- a/components/agent/AgentSheet.tsx +++ b/components/agent/AgentSheet.tsx @@ -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(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) } } diff --git a/components/agent/AgentSheetProvider.tsx b/components/agent/AgentSheetProvider.tsx index 13c504bb..b9fb0182 100644 --- a/components/agent/AgentSheetProvider.tsx +++ b/components/agent/AgentSheetProvider.tsx @@ -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() +let argsFallbackSeq = 0 + +function stableArgsKey(args?: Record): 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 && ( normalizeStoredMessages(rawMessages), [rawMessages]) + const initialMessages = useMemo( + () => attachStagedOperations(normalizeStoredMessages(rawMessages), stagedOperations ?? []), + [rawMessages, stagedOperations], + ) const { identity } = useAgentSheet() const companyCtx = useCompanyOptional() const isSandbox = companyCtx?.isSandbox ?? false diff --git a/components/agent/__tests__/attach-staged-operations.test.ts b/components/agent/__tests__/attach-staged-operations.test.ts new file mode 100644 index 00000000..c4582db0 --- /dev/null +++ b/components/agent/__tests__/attach-staged-operations.test.ts @@ -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 => ({ + 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) + }) +}) diff --git a/types/index.ts b/types/index.ts index 22faa4ce..855ecabb 100644 --- a/types/index.ts +++ b/types/index.ts @@ -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 +}