feat: enhance chat approval flow for pending operations with pre-fill… (#566)

* feat: enhance chat approval flow for pending operations with pre-filled commit details

* fix: update message validation to be case-insensitive and simplify approve schema

* fix: enhance approval guidance for high-risk operations and improve message clarity
This commit is contained in:
Mattsson
2026-05-25 13:11:27 +02:00
committed by GitHub
parent 50e5520b6d
commit aeadc8bb1d
2 changed files with 34 additions and 8 deletions
@@ -322,7 +322,7 @@ describe('MCP Receipt Matcher', () => {
expect(parsed.staged).toBe(true) expect(parsed.staged).toBe(true)
expect(parsed.operation_id).toBe('op-1') expect(parsed.operation_id).toBe('op-1')
expect(parsed.message).toContain('staged') expect(parsed.message).toMatch(/staged/i)
expect(parsed.preview).toBeDefined() expect(parsed.preview).toBeDefined()
expect(parsed.preview.debit_account).toBeDefined() expect(parsed.preview.debit_account).toBeDefined()
}) })
+33 -7
View File
@@ -167,6 +167,13 @@ interface StageOptions {
dateForPeriodCheck?: string dateForPeriodCheck?: string
} }
function buildApprovalGuidance(operationId: string, riskLevel: 'low' | 'medium' | 'high'): string {
if (riskLevel === 'high') {
return `This is an irreversible posting under BFL 5 kap 5§ — surface the irreversibility implications to the user and obtain an explicit acknowledgment before committing. Once the user has acknowledged, call gnubok_approve_pending_operation with operation_id="${operationId}" and confirmed=true.`
}
return `When the user authorises, call gnubok_approve_pending_operation with operation_id="${operationId}".`
}
async function stagePendingOperation( async function stagePendingOperation(
supabase: SupabaseClient, supabase: SupabaseClient,
companyId: string, companyId: string,
@@ -186,6 +193,7 @@ async function stagePendingOperation(
risk_level: 'low' | 'medium' | 'high' risk_level: 'low' | 'medium' | 'high'
actor: ActorContext actor: ActorContext
message: string message: string
approve?: { tool: string; args: Record<string, unknown> }
preview: Record<string, unknown> preview: Record<string, unknown>
period_status?: PeriodStatusForDate period_status?: PeriodStatusForDate
next?: StageNextHint next?: StageNextHint
@@ -238,12 +246,19 @@ async function stagePendingOperation(
if (options.idempotencyKey && requestHash) { if (options.idempotencyKey && requestHash) {
const cached = await checkIdempotencyKey(supabase, userId, companyId, options.idempotencyKey, requestHash) const cached = await checkIdempotencyKey(supabase, userId, companyId, options.idempotencyKey, requestHash)
if (cached) { if (cached) {
const cachedBody = cached.body as Record<string, unknown>
const cachedOpId = typeof cachedBody.operation_id === 'string' ? cachedBody.operation_id : undefined
return { return {
...(cached.body as Record<string, unknown>), ...cachedBody,
idempotency_replay: true, idempotency_replay: true,
risk_level: riskLevel, risk_level: riskLevel,
actor, actor,
message: `Replayed cached response for idempotency_key "${options.idempotencyKey}". No new side-effects.`, message: cachedOpId
? `Replayed cached response for idempotency_key "${options.idempotencyKey}" — already staged as pending_operation ${cachedOpId}. No new side-effects. ${buildApprovalGuidance(cachedOpId, riskLevel)}`
: `Replayed cached response for idempotency_key "${options.idempotencyKey}". No new side-effects.`,
...(cachedOpId
? { approve: { tool: 'gnubok_approve_pending_operation', args: { operation_id: cachedOpId } } }
: {}),
preview: periodStatus ? { ...previewData, period_status: periodStatus } : previewData, preview: periodStatus ? { ...previewData, period_status: periodStatus } : previewData,
...(periodStatus ? { period_status: periodStatus } : {}), ...(periodStatus ? { period_status: periodStatus } : {}),
} as Awaited<ReturnType<typeof stagePendingOperation>> } as Awaited<ReturnType<typeof stagePendingOperation>>
@@ -269,12 +284,22 @@ async function stagePendingOperation(
if (error) throw new Error(`Failed to stage operation: ${error.message}`) if (error) throw new Error(`Failed to stage operation: ${error.message}`)
// approve.args carries only the operation_id. For high-risk operations the
// LLM must supply confirmed=true itself after surfacing the BFL 5 kap 5§
// irreversibility implications to the user — pre-filling it server-side
// would collapse the explicit-acknowledgment gate (mirrors the web UI's
// warning dialog). The server-side check in gnubok_approve_pending_operation
// remains authoritative.
const response = { const response = {
staged: true, staged: true,
operation_id: data.id, operation_id: data.id,
risk_level: riskLevel, risk_level: riskLevel,
actor, actor,
message: `Operation staged for review (risk: ${riskLevel}). Open the ${branding} web app to approve or reject it.`, message: `Staged as pending_operation ${data.id} (risk: ${riskLevel}). ${buildApprovalGuidance(data.id, riskLevel)} The user can also approve at /pending in the ${branding} web app.`,
approve: {
tool: 'gnubok_approve_pending_operation',
args: { operation_id: data.id } as Record<string, unknown>,
},
preview: periodStatus ? { ...previewData, period_status: periodStatus } : previewData, preview: periodStatus ? { ...previewData, period_status: periodStatus } : previewData,
...(periodStatus ? { period_status: periodStatus } : {}), ...(periodStatus ? { period_status: periodStatus } : {}),
...(next ? { next } : {}), ...(next ? { next } : {}),
@@ -591,6 +616,7 @@ const STAGED_OPERATION_SCHEMA = {
dry_run: { type: 'boolean' }, dry_run: { type: 'boolean' },
idempotency_replay: { type: 'boolean' }, idempotency_replay: { type: 'boolean' },
message: { type: 'string' }, message: { type: 'string' },
approve: { type: 'object' },
preview: { type: 'object' }, preview: { type: 'object' },
period_status: { period_status: {
type: 'object', type: 'object',
@@ -1435,7 +1461,7 @@ export const tools: McpTool[] = [
{ {
name: 'gnubok_create_transactions', name: 'gnubok_create_transactions',
description: 'Stage one or more transactions for the user to approve. Each item creates a separate pending operation that the user confirms or rejects in the web app. Useful for ingesting rows from external sources (Airtable, CSVs, etc.). Max 10 per call.', description: 'Stage one or more transactions for the user to approve. Each item creates a separate pending operation; commit each via gnubok_approve_pending_operation when the user authorises. Useful for ingesting rows from external sources (Airtable, CSVs, etc.). Max 10 per call.',
outputSchema: { outputSchema: {
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
@@ -1696,7 +1722,7 @@ export const tools: McpTool[] = [
{ {
name: 'gnubok_categorize_transaction', name: 'gnubok_categorize_transaction',
description: 'Categorize a bank transaction. Stages the journal entry for the user to approve in the web app — no DB write until approval.', description: 'Categorize a bank transaction. Stages the journal entry; commit via gnubok_approve_pending_operation when the user authorises — no DB write until approval.',
inputSchema: { inputSchema: {
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
@@ -1723,7 +1749,7 @@ export const tools: McpTool[] = [
userId, userId,
companyId, companyId,
supabase, supabase,
false // preview mode — execution happens via web UI commit false // preview mode — execution happens at approval time via gnubok_approve_pending_operation
) )
// If already has a journal entry, pass through as-is // If already has a journal entry, pass through as-is
@@ -6535,7 +6561,7 @@ export const tools: McpTool[] = [
{ {
name: 'gnubok_approve_pending_operation', name: 'gnubok_approve_pending_operation',
description: "Commit a staged pending_operation. This IS the chat-approval action — call when the user authorises an operation_id in chat ('approve', 'book it', 'go ahead'). Staging already gated review; do not redirect to the web UI. risk_level=high needs confirmed=true (BFL 5 kap 5§).", description: "Commit a staged pending_operation when the user has explicitly authorised the operation_id. risk_level=high requires confirmed=true — surface the BFL 5 kap 5§ irreversibility to the user first. The /pending web UI offers an equivalent commit path.",
inputSchema: { inputSchema: {
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,