Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/ai-quota-ledger-vocabulary-3804.md
Original file line numberDiff line numberDiff line change
@@ -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.
221 changes: 215 additions & 6 deletions packages/plugin-chatbot/src/tool-display.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) => {
Expand All@@ -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();
});
Expand All@@ -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();
Expand All@@ -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(
Expand All@@ -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();
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/ai-quota-ledger-vocabulary-3804.md
Original file line numberDiff line numberDiff line change
@@ -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.
221 changes: 215 additions & 6 deletions packages/plugin-chatbot/src/tool-display.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) => {
Expand All@@ -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();
});
Expand All@@ -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();
Expand All@@ -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(
Expand All@@ -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();
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/ai-quota-ledger-vocabulary-3804.md
Original file line numberDiff line numberDiff line change
@@ -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.
221 changes: 215 additions & 6 deletions packages/plugin-chatbot/src/tool-display.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) => {
Expand All@@ -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();
});
Expand All@@ -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();
Expand All@@ -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(
Expand All@@ -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();
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/ai-quota-ledger-vocabulary-3804.md
Original file line numberDiff line numberDiff line change
@@ -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.
221 changes: 215 additions & 6 deletions packages/plugin-chatbot/src/tool-display.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) => {
Expand All@@ -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();
});
Expand All@@ -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();
Expand All@@ -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(
Expand All@@ -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();
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/ai-quota-ledger-vocabulary-3804.md
Original file line numberDiff line numberDiff line change
@@ -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.
221 changes: 215 additions & 6 deletions packages/plugin-chatbot/src/tool-display.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) => {
Expand All@@ -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();
});
Expand All@@ -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();
Expand All@@ -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(
Expand All@@ -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();
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/ai-quota-ledger-vocabulary-3804.md
Original file line numberDiff line numberDiff line change
@@ -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.
221 changes: 215 additions & 6 deletions packages/plugin-chatbot/src/tool-display.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) => {
Expand All@@ -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();
});
Expand All@@ -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();
Expand All@@ -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(
Expand All@@ -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();
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/ai-quota-ledger-vocabulary-3804.md
Original file line numberDiff line numberDiff line change
@@ -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.
221 changes: 215 additions & 6 deletions packages/plugin-chatbot/src/tool-display.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) => {
Expand All@@ -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();
});
Expand All@@ -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();
Expand All@@ -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(
Expand All@@ -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();
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/ai-quota-ledger-vocabulary-3804.md
Original file line numberDiff line numberDiff line change
@@ -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.
221 changes: 215 additions & 6 deletions packages/plugin-chatbot/src/tool-display.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) => {
Expand All@@ -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();
});
Expand All@@ -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();
Expand All@@ -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(
Expand All@@ -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();
Expand Down
Loading
Loading