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:
@@ -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()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user