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:
co-authored by
Claude Opus 5
parent
f0f3050f54
commit
4de648fb5d
@@ -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.
|
||||
|
||||
@@ -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[]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user