diff --git a/.changeset/ai-quota-ledger-vocabulary-3804.md b/.changeset/ai-quota-ledger-vocabulary-3804.md new file mode 100644 index 0000000000..61ec9e5047 --- /dev/null +++ b/.changeset/ai-quota-ledger-vocabulary-3804.md @@ -0,0 +1,18 @@ +--- +'@object-ui/plugin-chatbot': patch +--- + +Recognize the landed AI quota ledger vocabulary in the chat error path + +`parseAiQuotaError` now accepts the three SCREAMING_SNAKE ledger codes the cloud +token guardrail emits (`AI_ALLOWANCE_EXHAUSTED`, `AI_DESIGN_QUOTA_EXHAUSTED`, +`AI_DATA_CHAT_TRIAL_EXHAUSTED`) alongside the legacy lowercase trio, which stays +readable for producers that have not converged yet. The companion fields +(`messageEn` / `upgrade` / `topUp` / `resetsTonight`) are now read from the +declared envelope's `error.details` as well as their legacy top-level position, +with the declared position winning. + +A quota-exhausted user gets the upgrade / top-up CTA again instead of the +generic "Response failed" banner. The per-turn message cap's generic +`QUOTA_EXCEEDED` deliberately keeps its existing rate-limit path — it has no +upgrade or top-up next step. diff --git a/packages/plugin-chatbot/src/tool-display.test.ts b/packages/plugin-chatbot/src/tool-display.test.ts index e3d56f5bf4..aaf8435054 100644 --- a/packages/plugin-chatbot/src/tool-display.test.ts +++ b/packages/plugin-chatbot/src/tool-display.test.ts @@ -82,17 +82,34 @@ describe('parseAiQuotaError', () => { expect(parseAiQuotaError('')).toBeNull(); }); - // The three-dialect matrix (objectui#3491 / cloud#944). The two live producers - // fill `error` in opposite ways and ADR-0112 declares a third shape they are - // converging on (cloud#1168); every one of them must be readable HERE before - // any producer moves, and every one must miss on a non-quota code. + // The FOUR-dialect matrix (objectui#3491 / cloud#944, widened by + // objectui#3804). The two live producers fill `error` in opposite ways, + // ADR-0112 declares the envelope shape, and cloud#1168 -> cloud PR #1238 + // landed the fourth: that envelope carrying the SCREAMING_SNAKE ledger + // vocabulary with the companions inside `error.details`. Every one of them + // must be readable HERE, and every one must miss on a non-quota code. + // + // ⚠️ DEGENERATE-CONTROL NOTE. This file already exercised the lowercase trio + // heavily, so a lowercase-only case proves nothing about this change: it + // passes against the unfixed code too. The assertions that actually pin the + // NEW behavior are exactly (a) everything driven by `LEDGER_CODES`, and + // (b) the `declared envelope + ledger vocabulary` describe below, including + // its `error.details` companion reads (which fail on the old code even with + // a lowercase code, because `error.details` was not read at all). describe('dialect matrix', () => { const err = (payload: unknown) => new Error(JSON.stringify(payload)); + // Legacy vocabulary — transition-period producers still emit it. const CODES = [ 'ai_design_quota_exhausted', 'ai_data_chat_trial_exhausted', 'ai_allowance_exhausted', ] as const; + // Ledger vocabulary landed by cloud PR #1238. NEW-BEHAVIOR assertions. + const LEDGER_CODES = [ + 'AI_DESIGN_QUOTA_EXHAUSTED', + 'AI_DATA_CHAT_TRIAL_EXHAUSTED', + 'AI_ALLOWANCE_EXHAUSTED', + ] as const; describe('flat guardrail dialect — `error` holds the code', () => { it.each(CODES)('hits on %s', (code) => { @@ -103,6 +120,17 @@ describe('parseAiQuotaError', () => { }); }); + // NEW BEHAVIOR: the guardrail's flat limb now speaks the ledger + // vocabulary too, so a producer that converged its CODE before its SHAPE + // is still parsed. + it.each(LEDGER_CODES)('hits on the ledger code %s', (code) => { + expect(parseAiQuotaError(err({ error: code, message: 'zh', upgrade: true }))).toMatchObject({ + code, + message: 'zh', + upgrade: true, + }); + }); + it('misses on a code outside the recognized set', () => { expect(parseAiQuotaError(err({ error: 'ai_quota_exhausted', message: 'zh' }))).toBeNull(); }); @@ -124,9 +152,17 @@ describe('parseAiQuotaError', () => { ).toMatchObject({ code: 'ai_allowance_exhausted', message: prose }); }); + // NEW BEHAVIOR: same limb, ledger vocabulary. + it.each(LEDGER_CODES)('hits on the ledger code %s', (code) => { + expect( + parseAiQuotaError(err({ error: '', code, resetAt: '2026-08-09T00:00:00Z' })), + ).toMatchObject({ code, message: '' }); + }); + it('misses on the code service-ai emits today, which is not in the set', () => { - // Documents a real remaining gap rather than asserting it away: the - // shape is now readable, the vocabulary is cloud#1168's to align. + // Documents a real remaining gap rather than asserting it away. Still + // a gap after cloud#1238: `ai_quota_exhausted` is in NEITHER vocabulary + // — not the legacy trio, not the ledger trio. expect( parseAiQuotaError(err({ error: '', code: 'ai_quota_exhausted', resetAt: 'x' })), ).toBeNull(); @@ -140,6 +176,14 @@ describe('parseAiQuotaError', () => { ).toMatchObject({ code, message: 'zh' }); }); + // NEW BEHAVIOR: the envelope now carries the ledger vocabulary, which is + // the only vocabulary a spec-conformant `error.code` may use. + it.each(LEDGER_CODES)('hits on the ledger code %s', (code) => { + expect( + parseAiQuotaError(err({ success: false, error: { code, message: 'zh' } })), + ).toMatchObject({ code, message: 'zh' }); + }); + it('misses on a declared non-quota code', () => { expect( parseAiQuotaError( @@ -153,6 +197,171 @@ describe('parseAiQuotaError', () => { }); }); + // ---- THE FOURTH DIALECT (objectui#3804) -------------------------------- + // What cloud PR #1238 actually shipped: the declared envelope, the ledger + // vocabulary, and the companion fields nested inside `error.details`. + // EVERY assertion in this describe is a new-behavior assertion — the old + // code never read `error.details` at all, so even the lowercase-code case + // here fails against origin/main. + describe('declared envelope + ledger vocabulary — companions in `error.details`', () => { + const landed = (code: string) => ({ + success: false, + error: { + code, + message: 'zh', + details: { + messageEn: 'Your AI allowance is used up.', + upgrade: false, + topUp: true, + resetsTonight: true, + }, + }, + }); + + it.each(LEDGER_CODES)('reads %s with its nested companion fields', (code) => { + expect(parseAiQuotaError(err(landed(code)))).toEqual({ + code, + message: 'zh', + messageEn: 'Your AI allowance is used up.', + upgrade: false, + topUp: true, + resetsTonight: true, + }); + }); + + it('prefers the declared `error.details` companions over the legacy top-level ones', () => { + // The realistic transitional producer double-emits. The declared + // position wins, matching the total order the code lookup already uses. + expect( + parseAiQuotaError( + err({ + success: false, + error: { + code: 'AI_ALLOWANCE_EXHAUSTED', + message: 'zh', + details: { messageEn: 'nested', upgrade: true, topUp: false }, + }, + messageEn: 'top-level', + upgrade: false, + topUp: true, + }), + ), + ).toMatchObject({ + messageEn: 'nested', + upgrade: true, + topUp: false, + }); + }); + + it('falls back to the top-level companions when the envelope carries no details', () => { + // The legacy limb stays reachable — this is the shape a producer that + // moved its CODE but not its COMPANIONS emits. + expect( + parseAiQuotaError( + err({ + success: false, + error: { code: 'AI_DESIGN_QUOTA_EXHAUSTED', message: 'zh' }, + messageEn: 'top-level', + upgrade: true, + }), + ), + ).toMatchObject({ + code: 'AI_DESIGN_QUOTA_EXHAUSTED', + messageEn: 'top-level', + upgrade: true, + topUp: false, + }); + }); + + it('leaves resetsTonight undefined unless a producer sends an actual boolean', () => { + // The POSITION of this field is measured (cloud#1238 puts it in + // `error.details`); its TYPE is not pinned by anything we can read from + // this repo. So a non-boolean is dropped rather than coerced into a + // `false` no producer declared. + const r = parseAiQuotaError( + err({ + success: false, + error: { + code: 'AI_ALLOWANCE_EXHAUSTED', + details: { resetsTonight: '2026-08-26T00:00:00Z' }, + }, + }), + ); + expect(r?.resetsTonight).toBeUndefined(); + expect( + parseAiQuotaError( + err({ + success: false, + error: { code: 'AI_ALLOWANCE_EXHAUSTED', details: { resetsTonight: false } }, + }), + )?.resetsTonight, + ).toBe(false); + }); + + it('ignores a non-object `details` instead of throwing', () => { + expect( + parseAiQuotaError( + err({ success: false, error: { code: 'AI_ALLOWANCE_EXHAUSTED', details: [1, 2] } }), + ), + ).toMatchObject({ code: 'AI_ALLOWANCE_EXHAUSTED', upgrade: false, topUp: false }); + expect( + parseAiQuotaError( + err({ success: false, error: { code: 'AI_ALLOWANCE_EXHAUSTED', details: 'nope' } }), + ), + ).toMatchObject({ code: 'AI_ALLOWANCE_EXHAUSTED', upgrade: false, topUp: false }); + }); + }); + + // ---- THE VOCABULARY THAT STAYS GENERIC (objectui#3804) ----------------- + // cloud PR #1238 deliberately left `POST /api/v1/ai/agents/:name/chat`'s + // per-turn message cap on the standard `QUOTA_EXCEEDED`: it has no upgrade + // / top-up / trial next step, which is the exact distinction the 2026-08-11 + // Option A ruling drew when admitting the three `AI_*` codes to the ledger. + // + // ⚠️ These assertions PASS against origin/main as well — they are + // regression pins for behavior this PR PRESERVES, not new-behavior + // assertions. They exist because the cross-seat relay asked for a pin that + // per-turn 429s keep being handled, and this is where that handling lives. + describe('generic QUOTA_EXCEEDED (per-turn cap) keeps the rate-limit path', () => { + const perTurn = { + success: false, + error: { + code: 'QUOTA_EXCEEDED', + message: 'zh', + category: 'rate_limit', + details: { resetAt: '2026-08-26T00:00:00Z' }, + }, + }; + + it('is not a quota-CTA refusal, so no upgrade / top-up CTA is offered', () => { + // Recognizing it here would render ErrorBanner's "Upgrade needed" + + // "Upgrade plan" to a user whose cap resets in a minute. + expect(parseAiQuotaError(err(perTurn))).toBeNull(); + }); + + it('routes to the unsent rate-limit notice instead', () => { + // ChatbotEnhanced renders SendErrorNotice (with the "you're sending + // too quickly" copy and the typed text restored) exactly when + // `isUnsentSendError(e) && !parseAiQuotaError(e)`. + const e = tagged(429, JSON.stringify(perTurn)); + expect(isUnsentSendError(e)).toBe(true); + expect(isRateLimitError(e)).toBe(true); + expect(isUnsentSendError(e) && !parseAiQuotaError(e)).toBe(true); + }); + + it('a non-quota 429 still falls through to the generic path', () => { + const e = tagged( + 429, + JSON.stringify({ + success: false, + error: { code: 'RATE_LIMIT_EXCEEDED', message: 'zh' }, + }), + ); + expect(parseAiQuotaError(e)).toBeNull(); + expect(isUnsentSendError(e) && !parseAiQuotaError(e)).toBe(true); + }); + }); + it('degrades to today’s behavior (null) for unknown shapes', () => { expect(parseAiQuotaError(err({ success: false, error: null }))).toBeNull(); expect(parseAiQuotaError(err({ success: false, error: [] }))).toBeNull(); diff --git a/packages/plugin-chatbot/src/tool-display.ts b/packages/plugin-chatbot/src/tool-display.ts index 4e0e36dfeb..652041b63c 100644 --- a/packages/plugin-chatbot/src/tool-display.ts +++ b/packages/plugin-chatbot/src/tool-display.ts @@ -145,8 +145,22 @@ export function summarizeChatError(err: unknown): { }; } -/** AI quota refusal codes emitted by the cloud token guardrail (HTTP 429). */ +/** + * AI quota refusal codes emitted by the cloud token guardrail (HTTP 429). + * + * TWO vocabularies, both live. cloud#1238 (the cloud#1168 convergence) landed + * the SCREAMING_SNAKE ledger codes — which is what a spec-conformant + * `error.code` must be, an `ErrorCode` ledger member. The lowercase trio is + * the legacy vocabulary that transition-period producers still emit, so it is + * KEPT rather than swapped out: dropping it would silently stop parsing every + * producer that has not converged yet (objectui#3804). + */ export type AiQuotaCode = + // Ledger vocabulary (cloud#1238) — registered `ErrorCode` members. + | 'AI_DESIGN_QUOTA_EXHAUSTED' + | 'AI_DATA_CHAT_TRIAL_EXHAUSTED' + | 'AI_ALLOWANCE_EXHAUSTED' + // Legacy vocabulary — still emitted by producers that have not converged. | 'ai_design_quota_exhausted' | 'ai_data_chat_trial_exhausted' | 'ai_allowance_exhausted'; @@ -161,9 +175,23 @@ export interface AiQuotaError { upgrade: boolean; /** Paid tier -> buy a credit top-up pack. */ topUp: boolean; + /** + * The allowance replenishes at the next daily reset, so "wait" is a real + * option alongside the CTA. Only set when the producer sends an actual + * boolean: the landed envelope carries this inside `error.details` + * (cloud#1238) and that POSITION is measured, but an absent or + * otherwise-typed value stays `undefined` rather than being coerced to a + * `false` no producer declared. + */ + resetsTonight?: boolean; } const AI_QUOTA_CODES = new Set([ + // Ledger vocabulary (cloud#1238). + 'AI_DESIGN_QUOTA_EXHAUSTED', + 'AI_DATA_CHAT_TRIAL_EXHAUSTED', + 'AI_ALLOWANCE_EXHAUSTED', + // Legacy vocabulary, still live during the transition. 'ai_design_quota_exhausted', 'ai_data_chat_trial_exhausted', 'ai_allowance_exhausted', @@ -185,6 +213,24 @@ function asText(value: unknown): string | undefined { return typeof value === 'string' && value.trim() ? value : undefined; } +/** + * The first candidate that is an actual boolean, or undefined. Lets the + * declared `error.details` position win over the legacy top-level one while a + * non-boolean (`'true'`, `1`) is ignored exactly as the old `=== true` read + * ignored it. + */ +function asOptionalFlag(...candidates: unknown[]): boolean | undefined { + for (const candidate of candidates) { + if (typeof candidate === 'boolean') return candidate; + } + return undefined; +} + +/** {@link asOptionalFlag} for the CTA flags, which are never absent. */ +function asFlag(...candidates: unknown[]): boolean { + return asOptionalFlag(...candidates) ?? false; +} + /** * Recognize the cloud AI token guardrail's 429 quota refusals so the chat UI can * show a friendly upgrade / top-up CTA instead of a generic "response failed". @@ -194,26 +240,39 @@ function asText(value: unknown): string | undefined { * strip the same retry/format prefixes summarizeChatError handles, locate the * JSON object, and find the code in it. Returns null for anything else. * - * ## Three dialects, one code set + * ## Four dialects, two vocabularies + * + * The two cloud 429 producers fill the same `error` key in OPPOSITE ways + * (objectui#3491, cloud#944), and ADR-0112's declared envelope has now LANDED + * on the producer side (cloud#1168 -> cloud PR #1238), adding a fourth + * dialect: the declared envelope carrying the SCREAMING_SNAKE ledger + * vocabulary with its companion fields inside `error.details`. All four are + * read here, because the producers converge asymmetrically and the legacy + * dialects are still live (objectui#3804): + * + * | dialect | shape | + * |-------------------------------|-------------------------------------------------| + * | declared envelope + ledger | `{ error: { code: AI_*, message, details } }` | + * | declared envelope + legacy | `{ success: false, error: { code, message } }` | + * | flat guardrail | `{ error: CODE, message, upgrade, topUp }` | + * | service-ai sibling key | `{ error: PROSE, code: CODE }` | * - * The two cloud 429 producers fill the same `error` key in OPPOSITE ways, and - * ADR-0112 declares a third shape they are converging on (objectui#3491, - * cloud#944). All three are read here so the consumer is ready BEFORE the - * producers move (cloud#1168) — the alternative is a silent misfit at the - * moment they converge: + * Both the code's LOCATION and the recognized code SET widen; an unknown shape + * still degrades to today's behavior (null). * - * | dialect | shape | - * |------------------------------|------------------------------------------------| - * | declared envelope (ADR-0112) | `{ success: false, error: { code, message } }` | - * | flat guardrail | `{ error: CODE, message, upgrade, topUp }` | - * | service-ai sibling key | `{ error: PROSE, code: CODE }` | + * ## What this deliberately does NOT recognize: generic `QUOTA_EXCEEDED` * - * Only the code's LOCATION widens; the recognized code set is unchanged, so an - * unknown shape still degrades to today's behavior (null). Note that a - * spec-conformant `error.code` must be an `ErrorCode` ledger member - * (SCREAMING_SNAKE) and none of these three codes is registered today, so the - * nested branch matches the legacy vocabulary only — aligning the vocabulary is - * cloud#1168's call and needs a follow-up here, not a guess now. + * `POST /api/v1/ai/agents/:name/chat`'s per-turn message cap keeps emitting the + * standard `QUOTA_EXCEEDED` (`error.details.resetAt`, `category: 'rate_limit'`) + * and did NOT move to the three `AI_*` codes — it has no upgrade / top-up / + * trial next step, which is exactly the distinction the 2026-08-11 Option A + * ruling drew when it admitted the three codes to the closed ledger. + * + * It is NOT dropped, it is handled one branch along: `isUnsentSendError` + + * `isRateLimitError` route it to the "you're sending too quickly" notice, with + * the user's text restored. Returning an `AiQuotaError` for it here would put + * an "Upgrade plan" CTA in front of a user whose cap resets in a minute. + * `tool-display.test.ts` pins that routing so the split cannot close silently. * * ## Parse priority (a total order, deliberately) * @@ -247,6 +306,15 @@ export function parseAiQuotaError(err: unknown): AiQuotaError | null { ? (body.error as Record) : undefined; + // The declared envelope nests the companion fields under `error.details` + // (cloud#1238); the legacy flat dialects keep them top-level. Same total + // order as the code itself — the declared position wins, and the legacy limb + // stays reachable when the declared one is absent. + const details = + nested?.details && typeof nested.details === 'object' && !Array.isArray(nested.details) + ? (nested.details as Record) + : undefined; + const flatCode = asAiQuotaCode(body.error); const code = asAiQuotaCode(nested?.code) ?? flatCode ?? asAiQuotaCode(body.code); if (!code) return null; @@ -261,13 +329,13 @@ export function parseAiQuotaError(err: unknown): AiQuotaError | null { // its own code ('ai_allowance_exhausted') to the user as the message. (flatCode ? undefined : asText(body.error)) ?? '', - // Companion fields keep their established top-level read. Their position in - // the declared envelope is NOT presumed here (`error.details.upgrade`? a - // sibling of `error.code`?) — cloud#1168 decides the real shape, and - // inventing a nested key now would fossilize a contract nobody agreed to. - messageEn: asText(body.messageEn), - upgrade: body.upgrade === true, - topUp: body.topUp === true, + // Companion fields: `error.details` is the position cloud#1238 shipped, and + // it wins; the top-level read stays for the flat/legacy dialects that still + // emit them there. No longer a presumption — the position is measured. + messageEn: asText(details?.messageEn) ?? asText(body.messageEn), + upgrade: asFlag(details?.upgrade, body.upgrade), + topUp: asFlag(details?.topUp, body.topUp), + resetsTonight: asOptionalFlag(details?.resetsTonight, body.resetsTonight), }; }