diff --git a/apps/leaf/src/agent/runMessage/engines/claudeManagedEngine.ts b/apps/leaf/src/agent/runMessage/engines/claudeManagedEngine.ts index 185edeccb..81471f2fb 100644 --- a/apps/leaf/src/agent/runMessage/engines/claudeManagedEngine.ts +++ b/apps/leaf/src/agent/runMessage/engines/claudeManagedEngine.ts @@ -20,6 +20,16 @@ import { createBraintrustLogger } from "../../../providers/braintrust/index.js"; import type { AgentEngine } 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); 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/ui/blocks.ts b/apps/leaf/src/ui/blocks.ts index 1a3ad6905..9325661a8 100644 --- a/apps/leaf/src/ui/blocks.ts +++ b/apps/leaf/src/ui/blocks.ts @@ -824,6 +824,7 @@ export const approvalStatusCard = ({ ), ] : []), + ...(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/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"); + }); +});