From 1346b0589896572ff2196656df05b42240323b1b Mon Sep 17 00:00:00 2001 From: Charlie Lamb Date: Thu, 11 Jun 2026 20:17:58 +0100 Subject: [PATCH] chore: ui & approval cleanup --- .../runMessage/engines/claudeManagedEngine.ts | 21 ++ .../claudeManaged/repos/claudeManagedRepo.ts | 16 ++ .../approvals/utils/approvalRequest.ts | 6 +- apps/leaf/src/ui/blocks.ts | 120 +++++++----- apps/leaf/src/ui/previewContent.ts | 185 ++++++++++++++++++ apps/leaf/tests/unit/ui/blocks.test.ts | 4 +- .../leaf/tests/unit/ui/previewContent.test.ts | 157 +++++++++++++++ 7 files changed, 453 insertions(+), 56 deletions(-) create mode 100644 apps/leaf/src/ui/previewContent.ts create mode 100644 apps/leaf/tests/unit/ui/previewContent.test.ts diff --git a/apps/leaf/src/agent/runMessage/engines/claudeManagedEngine.ts b/apps/leaf/src/agent/runMessage/engines/claudeManagedEngine.ts index 4c99a8512..c57b42a44 100644 --- a/apps/leaf/src/agent/runMessage/engines/claudeManagedEngine.ts +++ b/apps/leaf/src/agent/runMessage/engines/claudeManagedEngine.ts @@ -27,6 +27,16 @@ import { import type { AgentEngine, MessageContext, MessageParams } from "../types.js"; const client = new Anthropic(); + +const AUTH_FAILURE_PATTERN = + /invalid or expired access token|request failed \(401\)/i; +const isAutumnAuthFailure = (output: unknown) => { + try { + return AUTH_FAILURE_PATTERN.test(JSON.stringify(output) ?? ""); + } catch { + return false; + } +}; // initLogger sets Braintrust's ambient logger so traced()/spans are recorded. const braintrustLogger = createBraintrustLogger(); const braintrustEnabled = Boolean(braintrustLogger); @@ -226,6 +236,7 @@ export const claudeManagedEngine: AgentEngine = { }); const previewCapture = createPreviewCapture(); + let staleVaultMarked = false; const text = buildMessageText({ env, newSession, params }); const driveTurn = ({ @@ -266,6 +277,16 @@ export const claudeManagedEngine: AgentEngine = { }, onAutumnToolResult: ({ id, name, output }) => { previewCapture.onToolResult({ name, output }); + if (isAutumnAuthFailure(output) && !staleVaultMarked) { + staleVaultMarked = true; + logger.warn("Autumn MCP auth failed — marking vault stale", { + event: "leaf.vault_marked_stale", + tool: name, + }); + void cmaRepo + .markVaultStale({ db, env, orgId: org.id }) + .catch(() => undefined); + } const toolSpan = openToolSpans.get(id); if (toolSpan) { toolSpan.log({ output }); diff --git a/apps/leaf/src/harness/claudeManaged/repos/claudeManagedRepo.ts b/apps/leaf/src/harness/claudeManaged/repos/claudeManagedRepo.ts index 77f064206..09ac76ae9 100644 --- a/apps/leaf/src/harness/claudeManaged/repos/claudeManagedRepo.ts +++ b/apps/leaf/src/harness/claudeManaged/repos/claudeManagedRepo.ts @@ -113,6 +113,22 @@ export const cmaRepo = { return row; }, + // Forces the next ensureAutumnVault to resync tokens into the vault. + markVaultStale: async ({ + db, + env, + orgId, + }: { + db: ChatDb; + env: AppEnv; + orgId: string; + }) => { + await db + .update(cmaVaults) + .set({ updated_at: 0 }) + .where(and(eq(cmaVaults.org_id, orgId), eq(cmaVaults.env, env))); + }, + upsertVault: async ({ credentialId, db, diff --git a/apps/leaf/src/internal/approvals/utils/approvalRequest.ts b/apps/leaf/src/internal/approvals/utils/approvalRequest.ts index 8dd0a8930..62e1cd555 100644 --- a/apps/leaf/src/internal/approvals/utils/approvalRequest.ts +++ b/apps/leaf/src/internal/approvals/utils/approvalRequest.ts @@ -8,9 +8,11 @@ export const approvalRequestFromOutput = (output: AgentOutput) => { toolCallId: output.suspendPayload.toolCallId, toolName: output.suspendPayload.toolName, toolArgs: output.suspendPayload.args ?? {}, + // Structured preview first: the card renders it as line items/fields, + // falling back to the model's prose only when no payload was captured. preview: - output.text || output.previewApproval?.preview || + output.text || output.suspendPayload.args, }; } @@ -22,7 +24,7 @@ export const approvalRequestFromOutput = (output: AgentOutput) => { toolCallId: undefined, toolName: output.previewApproval.toolName, toolArgs: output.previewApproval.toolArgs, - preview: output.text || output.previewApproval.preview, + preview: output.previewApproval.preview || output.text, }; } }; diff --git a/apps/leaf/src/ui/blocks.ts b/apps/leaf/src/ui/blocks.ts index 444547333..cac044db2 100644 --- a/apps/leaf/src/ui/blocks.ts +++ b/apps/leaf/src/ui/blocks.ts @@ -1,6 +1,7 @@ import type { AppEnv } from "@autumn/shared"; -import { Actions, Button, Card, CardText, Divider, Field, Fields } from "chat"; +import { Actions, Button, Card, CardText, Divider } from "chat"; import { toolLabel } from "../agent/tools/toolPolicy.js"; +import { formatEpochDate, previewElements } from "./previewContent.js"; const formatPreview = (preview: unknown) => typeof preview === "string" ? preview : ""; @@ -59,47 +60,50 @@ const formatInvoiceMode = (value: unknown) => { const envLabel = (env?: AppEnv) => env === "live" ? "Live" : env === "sandbox" ? "Sandbox" : null; -const requestFields = ({ - env, - toolName, - toolArgs, -}: { - env?: AppEnv; - toolName: string; - toolArgs?: Record; -}) => { - const request = getRequest(toolArgs); - const environment = envLabel(env); - const baseFields = [ - Field({ - label: "Action", - value: toolLabel(toolName), - }), - ...(environment - ? [Field({ label: "Environment", value: environment })] - : []), - ]; - if (!request) return baseFields; +const cardSubtitle = ({ env, hint }: { env?: AppEnv; hint: string }) => + [envLabel(env), hint].filter(Boolean).join(" · "); - return [ - ...baseFields, - ...[ - ["Customer", request.customer_id], - ["Plan", request.plan_id], - ["Entity", request.entity_id], - ["Subscription", request.subscription_id], - ["Price", formatPrice(request)], - ["Enable immediately", request.enable_plan_immediately], - ["Invoice mode", formatInvoiceMode(request.invoice_mode)], - ["Proration", request.proration_behavior], - ["Redirect", request.redirect_mode], - ].flatMap(([label, value]) => { +// One "**Label** value" line per pair — half the height of stacked fields. +const requestSummary = (toolArgs?: Record) => { + const request = getRequest(toolArgs); + if (!request) return null; + + const lines = [ + ["Customer", request.customer_id], + ["Plan", request.plan_id], + ["Feature", request.feature_id], + ["Entity", request.entity_id], + ["Subscription", request.subscription_id], + [ + "Starts", + typeof request.starts_at === "number" + ? formatEpochDate(request.starts_at) + : null, + ], + ["Price", formatPrice(request)], + ] + .flatMap(([label, value]) => { const fieldValue = getFieldValue(value); - return fieldValue - ? [Field({ label: String(label), value: fieldValue })] - : []; - }), - ].slice(0, 8); + return fieldValue ? [`**${label}** ${fieldValue}`] : []; + }) + .slice(0, 8); + return lines.length ? lines.join("\n") : null; +}; + +// Technical knobs the reviewer rarely acts on — shown as one muted line, not fields. +const configSummary = (toolArgs?: Record) => { + const request = getRequest(toolArgs); + if (!request) return null; + + const summary = [ + ["Invoice", formatInvoiceMode(request.invoice_mode)], + ["Enable immediately", getFieldValue(request.enable_plan_immediately)], + ["Proration", getFieldValue(request.proration_behavior)], + ["Redirect", getFieldValue(request.redirect_mode)], + ] + .flatMap(([label, value]) => (value ? [`${label}: ${value}`] : [])) + .join(" · "); + return summary.length ? summary : null; }; const cleanPreviewLine = (line: string) => @@ -180,17 +184,25 @@ export const approvalCard = ({ toolArgs?: Record; preview?: unknown; }) => { - const fields = requestFields({ env, toolName, toolArgs }); - const lines = preview ? previewLines(preview) : []; + const summary = requestSummary(toolArgs); + const config = configSummary(toolArgs); + const structured = preview ? previewElements(preview) : null; + const lines = !structured && preview ? previewLines(preview) : []; return Card({ title: `${toolLabel(toolName)}?`, - subtitle: "Review the preview before this runs", + subtitle: cardSubtitle({ + env, + hint: "Review the preview before this runs", + }), children: [ - ...(fields.length ? [Fields(fields)] : []), + ...(summary ? [CardText(summary)] : []), + ...(structured ?? []), ...(lines.length - ? [Divider(), CardText(lines.map((line) => `• ${line}`).join("\n"))] + ? [CardText(lines.map((line) => `• ${line}`).join("\n"))] : []), + ...(config ? [CardText(config, { style: "muted" })] : []), + Divider(), Actions([ Button({ id: "approve_billing_action", @@ -224,7 +236,8 @@ export const approvalStatusCard = ({ preview?: unknown; result?: unknown; }) => { - const fields = requestFields({ env, toolName, toolArgs }); + const summary = requestSummary(toolArgs); + const config = configSummary(toolArgs); const lines = statusLines({ status, result }); const title = status === "approved" @@ -237,20 +250,23 @@ export const approvalStatusCard = ({ return Card({ title, - subtitle: - status === "running" - ? "Applying the approved action" - : "The approval is closed", + subtitle: cardSubtitle({ + env, + hint: + status === "running" + ? "Applying the approved action" + : "The approval is closed", + }), children: [ - ...(fields.length ? [Fields(fields)] : []), + ...(summary ? [CardText(summary)] : []), ...(lines.length ? [ - Divider(), CardText( lines.map((line) => `• ${cleanPreviewLine(line)}`).join("\n"), ), ] : []), + ...(config ? [CardText(config, { style: "muted" })] : []), ], }); }; diff --git a/apps/leaf/src/ui/previewContent.ts b/apps/leaf/src/ui/previewContent.ts new file mode 100644 index 000000000..07b4a12a0 --- /dev/null +++ b/apps/leaf/src/ui/previewContent.ts @@ -0,0 +1,185 @@ +import type { CardChild } from "chat"; +import { CardText, Table } from "chat"; +import { format } from "date-fns"; + +type LooseRecord = Record; + +const MAX_LINE_ITEM_ROWS = 10; + +const asRecord = (value: unknown): LooseRecord | null => + value && typeof value === "object" && !Array.isArray(value) + ? (value as LooseRecord) + : null; + +const parseJson = (text: string): unknown => { + try { + return JSON.parse(text); + } catch { + return null; + } +}; + +export const formatEpochDate = (epochMs: number) => + format(epochMs, "MMM d, yyyy"); + +// Amounts are major currency units (the schema's "in cents" wording is stale — +// the dashboard renders these values directly). +const formatMoney = (amount: number, currency: string) => { + try { + return new Intl.NumberFormat("en-US", { + currency: currency.toUpperCase(), + currencyDisplay: "narrowSymbol", + style: "currency", + }).format(amount); + } catch { + return `$${amount.toFixed(2)}`; + } +}; + +// Unwraps MCP transport shapes around the preview payload: JSON strings, +// [{text}] content arrays, {content} results, and the {preview, pending} wrapper. +export const parsePreviewPayload = (preview: unknown): LooseRecord | null => { + if (typeof preview === "string") { + const parsed = parseJson(preview.trim()); + return parsed ? parsePreviewPayload(parsed) : null; + } + if (Array.isArray(preview)) { + for (const entry of preview) { + const record = asRecord(entry); + if (typeof record?.text !== "string") continue; + const parsed = parsePreviewPayload(record.text); + if (parsed) return parsed; + } + return null; + } + const record = asRecord(preview); + if (!record) return null; + if (Array.isArray(record.content)) return parsePreviewPayload(record.content); + if ("preview" in record) return parsePreviewPayload(record.preview); + return record; +}; + +const UPDATE_INTENT_LABELS: Record = { + cancel_end_of_cycle: "Cancel at end of cycle", + cancel_immediately: "Cancel immediately", + uncancel: "Uncancel", + update_plan: "Update plan", + update_quantity: "Update quantity", +}; + +const lineItemRows = ({ + lineItems, + currency, +}: { + lineItems: unknown[]; + currency: string; +}) => { + const items = lineItems.flatMap((item) => { + const record = asRecord(item); + return typeof record?.display_name === "string" && + typeof record.total === "number" + ? [{ name: record.display_name, total: record.total }] + : []; + }); + + const rows = items + .slice(0, MAX_LINE_ITEM_ROWS) + .map((item) => [item.name, formatMoney(item.total, currency)]); + if (items.length > MAX_LINE_ITEM_ROWS) { + rows.push([`+${items.length - MAX_LINE_ITEM_ROWS} more items`, ""]); + } + return rows; +}; + +// attach / createSchedule / updateSubscription previews all share the +// BillingPreviewResponse shape (line_items, total, currency, next_cycle). +// Rendered receipt-style: one table holding line items AND total rows. +const billingPreviewElements = (payload: LooseRecord): CardChild[] => { + const currency = + typeof payload.currency === "string" ? payload.currency : "usd"; + const rows = lineItemRows({ + lineItems: payload.line_items as unknown[], + currency, + }); + + const nextCycle = asRecord(payload.next_cycle); + const intentLabel = + typeof payload.intent === "string" + ? UPDATE_INTENT_LABELS[payload.intent] + : undefined; + + rows.push(["Due now", formatMoney(payload.total as number, currency)]); + if ( + typeof nextCycle?.total === "number" && + typeof nextCycle.starts_at === "number" + ) { + rows.push([ + `Next cycle · ${formatEpochDate(nextCycle.starts_at)}`, + formatMoney(nextCycle.total, currency), + ]); + } + + const notes = [ + intentLabel ? `Change: ${intentLabel}` : null, + payload.redirect_to_checkout === true + ? "Customer pays via checkout link" + : null, + ].filter((note): note is string => Boolean(note)); + + return [ + Table({ align: ["left", "right"], headers: ["Item", "Amount"], rows }), + ...(notes.length + ? [CardText(notes.join(" · "), { style: "muted" })] + : []), + ]; +}; + +const balancePreviewElements = (payload: LooseRecord): CardChild[] | null => { + const request = asRecord(payload.request); + if (!request) return null; + + const reset = asRecord(request.reset); + const fields = [ + ["Feature", request.feature_id], + [ + "Grant", + request.unlimited === true ? "Unlimited" : request.included_grant, + ], + [ + "Expires", + typeof request.expires_at === "number" + ? formatEpochDate(request.expires_at) + : null, + ], + [ + "Resets", + typeof reset?.interval === "string" + ? `Every ${typeof reset.interval_count === "number" && reset.interval_count > 1 ? `${reset.interval_count} ${reset.interval}s` : reset.interval}` + : null, + ], + ].flatMap(([label, value]) => + typeof value === "string" || typeof value === "number" + ? [`**${label}** ${value}`] + : [], + ); + + return [ + ...(fields.length ? [CardText(fields.join("\n"))] : []), + ...(typeof payload.impact === "string" + ? [CardText(payload.impact, { style: "muted" })] + : []), + ]; +}; + +/** Structured card body for a preview payload, or null to fall back to text. */ +export const previewElements = (preview: unknown): CardChild[] | null => { + const payload = parsePreviewPayload(preview); + if (!payload) return null; + if (Array.isArray(payload.line_items) && typeof payload.total === "number") { + return billingPreviewElements(payload); + } + if (payload.action === "createBalance") { + return balancePreviewElements(payload); + } + return null; +}; diff --git a/apps/leaf/tests/unit/ui/blocks.test.ts b/apps/leaf/tests/unit/ui/blocks.test.ts index ab2fc4561..35333cc7d 100644 --- a/apps/leaf/tests/unit/ui/blocks.test.ts +++ b/apps/leaf/tests/unit/ui/blocks.test.ts @@ -19,7 +19,7 @@ describe("approval card", () => { }); expect(card.title).toBe("Attach plan?"); - expect(card.children[0]?.type).toBe("fields"); + expect(card.children[0]?.type).toBe("text"); expect(card.children.at(-1)?.type).toBe("actions"); expect(JSON.stringify(card)).toContain("Sandbox"); expect(JSON.stringify(card)).toContain("pro_att-disc-dedup"); @@ -106,7 +106,7 @@ describe("approval card", () => { const json = JSON.stringify(card); expect(json).toContain("$178.65 due now"); - expect(json).not.toContain("**"); + expect(json).not.toContain("**Immediate"); expect(json).not.toContain("$178.\\n"); expect(json).not.toContain("Let me preview"); }); diff --git a/apps/leaf/tests/unit/ui/previewContent.test.ts b/apps/leaf/tests/unit/ui/previewContent.test.ts new file mode 100644 index 000000000..76bf9ba6a --- /dev/null +++ b/apps/leaf/tests/unit/ui/previewContent.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, test } from "bun:test"; +import { approvalCard } from "../../../src/ui/blocks.js"; +import { + parsePreviewPayload, + previewElements, +} from "../../../src/ui/previewContent.js"; + +const attachPreview = { + object: "attach_preview", + customer_id: "cus_1", + currency: "usd", + line_items: [ + { + display_name: "Pro Checkout", + description: "Pro Checkout - Base Price", + subtotal: 20, + total: 20, + discounts: [], + }, + { + display_name: "Messages", + description: "Prepaid usage", + subtotal: 5.5, + total: 5.5, + discounts: [], + }, + ], + subtotal: 25.5, + total: 25.5, + next_cycle: { starts_at: 1812731225000, subtotal: 40, total: 40 }, + redirect_to_checkout: false, +}; + +describe("parsePreviewPayload", () => { + test("unwraps MCP content arrays of JSON text", () => { + const payload = parsePreviewPayload([ + { type: "text", text: JSON.stringify(attachPreview) }, + ]); + expect(payload?.customer_id).toBe("cus_1"); + }); + + test("unwraps the agent {preview, pending} wrapper", () => { + const payload = parsePreviewPayload({ + preview: attachPreview, + pending: true, + message: "Preview ready", + }); + expect(payload?.object).toBe("attach_preview"); + }); + + test("returns null for model prose", () => { + expect(parsePreviewPayload("I'll preview this now!")).toBeNull(); + }); +}); + +describe("previewElements", () => { + test("renders billing previews as a line item table with totals", () => { + const elements = previewElements(attachPreview); + const json = JSON.stringify(elements); + + expect(elements?.[0]?.type).toBe("table"); + expect(json).toContain("Pro Checkout"); + expect(json).toContain("$20.00"); + expect(json).toContain("$5.50"); + expect(json).toContain("Due now"); + expect(json).toContain("Next cycle"); + expect(json).toContain("$40.00"); + }); + + test("labels update subscription intents", () => { + const json = JSON.stringify( + previewElements({ + ...attachPreview, + object: "update_subscription_preview", + intent: "cancel_end_of_cycle", + }), + ); + expect(json).toContain("Cancel at end of cycle"); + }); + + test("renders createBalance local previews as fields", () => { + const json = JSON.stringify( + previewElements({ + action: "createBalance", + request: { + customer_id: "cus_1", + feature_id: "credits", + included_grant: 500, + expires_at: 1812731225000, + }, + impact: "Creates a standalone balance grant.", + }), + ); + expect(json).toContain("credits"); + expect(json).toContain("500"); + expect(json).toContain("Expires"); + expect(json).toContain("standalone balance grant"); + }); +}); + +describe("approvalCard with structured previews", () => { + test("uses the structured renderer instead of text scraping", () => { + const card = approvalCard({ + id: "approval_1", + toolName: "attach", + toolArgs: { request: { customer_id: "cus_1", plan_id: "pro" } }, + preview: attachPreview, + }); + const json = JSON.stringify(card); + + expect(json).toContain("table"); + expect(json).toContain("Due now"); + expect(card.children.at(-1)?.type).toBe("actions"); + }); + + test("moves environment to subtitle and config to a muted line", () => { + const card = approvalCard({ + id: "approval_1", + env: "sandbox" as never, + toolName: "attach", + toolArgs: { + request: { + customer_id: "cus_1", + plan_id: "pro", + redirect_mode: "if_required", + invoice_mode: { enabled: true, finalize: false }, + }, + }, + preview: attachPreview, + }); + + expect(card.subtitle).toContain("Sandbox"); + const muted = card.children.filter( + (child) => child.type === "text" && child.style === "muted", + ); + const mutedJson = JSON.stringify(muted); + expect(mutedJson).toContain("Redirect: if_required"); + expect(mutedJson).toContain("Invoice: enabled, draft invoice"); + expect(JSON.stringify(card.children)).not.toContain("Environment"); + }); + + test("shows schedule start dates from tool args", () => { + const card = approvalCard({ + id: "approval_2", + toolName: "createSchedule", + toolArgs: { + request: { + customer_id: "cus_1", + plan_id: "pro", + starts_at: 1812731225000, + }, + }, + preview: attachPreview, + }); + expect(JSON.stringify(card)).toContain("Starts"); + }); +});