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
10 changes: 10 additions & 0 deletions .changeset/7587-quota-reset-promise-pin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
---

Pin what a free-plan 429 actually renders after cloud PR #1852 inverted
`error.details.resetsTonight` and added a sibling `resetsAt` (objectui#7587).
Measured: `resetsTonight` has no reader past `parseAiQuotaError`, so the
inversion changes nothing on screen and the banner is the server's own sentence
verbatim — pinned as an exact accounting of the rendered text, and as the flag
being inert. Tests plus one field comment; no package is released by this
change.
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* objectui#7587 — what a free-plan 429 actually puts on screen, now that the
* producer moved under this consumer: cloud PR #1852 (the rolling 7-day free
* quota) left `error.details.resetsTonight` a boolean but inverted its value
* for the free plan (`true` -> `false`) and started sending an ISO `resetsAt`
* beside it. `tool-display.ts` reads the flag and not the timestamp.
*
* #6385 already pinned that field — about its TYPE ("leaves `resetsTonight`
* undefined unless a producer sends an actual boolean", `tool-display.test.ts`).
* The type did not move. A value inversion and a new sibling key are exactly
* what a type pin cannot see, so this file pins the CONSEQUENCE: the rendered
* result.
*
* The measurement, taken here rather than assumed: after the parser populates
* `resetsTonight`, NOTHING reads it. `useAiQuotaCopy` — the only consumer of
* the parsed shape's fields — builds the banner from `message` / `messageEn` /
* `topUp`; the two other `parseAiQuotaError` call sites in `ChatbotEnhanced`
* use it as a yes/no predicate. So the free plan's inversion is invisible here,
* and the answer to "does this render nothing, or does it render a false
* promise?" is RENDERS NOTHING: every word the user gets about when the
* allowance returns is the server's own sentence, passed through verbatim.
*
* That is worth pinning in both directions. The banner is pinned by exact
* accounting (title + server sentence + CTA, and nothing else), so a reset line
* of our own cannot appear unnoticed; and the flag is pinned as inert, so the
* day someone wires "resets tonight" copy to a boolean that is now `false` for
* exactly the plan that needs the answer, this file says so.
*/
import '@testing-library/jest-dom/vitest';
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { ChatbotEnhanced } from '../ChatbotEnhanced';
import { parseAiQuotaError } from '../tool-display';

/** The server-owned refusal prose, as the cloud guardrail sends it. */
const FREE_PLAN_ZH = 'AI 额度已用完,9 月 13 日恢复;升级后可继续使用。';
const FREE_PLAN_EN =
'Your free AI allowance is used up. It comes back on 13 Sep — upgrade to keep going.';
/** The ISO instant cloud PR #1852 added; no reader in this package. */
const RESETS_AT = '2026-09-13T00:00:00Z';

/**
* The free plan's post-#1852 wire shape: the declared envelope (ADR-0112) with
* the ledger code, `resetsTonight` now `false`, and `resetsAt` beside it —
* tagged the way `sendAwareFetch` tags a POST refused before any tokens
* streamed.
*/
function freePlanRefusal(resetsTonight: boolean): Error {
const e = new Error(
JSON.stringify({
success: false,
error: {
code: 'AI_ALLOWANCE_EXHAUSTED',
message: FREE_PLAN_ZH,
category: 'rate_limit',
details: {
messageEn: FREE_PLAN_EN,
upgrade: true,
topUp: false,
resetsTonight,
resetsAt: RESETS_AT,
},
},
}),
) as Error & { notSent?: boolean; status?: number };
e.notSent = true;
e.status = 429;
return e;
}

function renderRefusal(resetsTonight: boolean) {
return render(
<ChatbotEnhanced
placeholder="Ask…"
messages={[]}
onSendMessage={vi.fn()}
onUpgrade={vi.fn()}
error={freePlanRefusal(resetsTonight)}
/>,
);
}

describe('free-plan 429 banner — the reset promise nobody makes (objectui#7587)', () => {
it('renders the server sentence and nothing of our own about the reset', () => {
renderRefusal(false);
const banner = screen.getByRole('alert');

// Exact accounting: three parts, each with a named source — our own title,
// the server's sentence verbatim, and the CTA `topUp` picks. Anything this
// package starts deriving from the 429's reset fields has to land inside
// this string, so it cannot arrive unnoticed.
expect(banner.textContent).toBe(`Upgrade needed${FREE_PLAN_EN}Upgrade plan`);

// The two halves of "renders nothing": the instant the producer sent never
// reaches the DOM, and no reset copy of our own stands in for it. (The
// package HAS such copy for the separate `GET /api/v1/ai/usage` surface —
// "Resets tonight" / "Resets in N days" in `@object-ui/i18n` — which is a
// different envelope on a different screen, and none of it is here.)
expect(banner.textContent).not.toContain(RESETS_AT);
expect(banner.textContent).not.toMatch(/resets?\s+(?:tonight|tomorrow|in\b)/i);
});

it('renders identically whether the producer says resetsTonight true or false', () => {
// The control first: the two envelopes really do differ where it counts,
// so a green invariance below is a measurement and not a fixture that
// forgot to vary its input.
expect(parseAiQuotaError(freePlanRefusal(false))?.resetsTonight).toBe(false);
expect(parseAiQuotaError(freePlanRefusal(true))?.resetsTonight).toBe(true);

const first = renderRefusal(false);
const withFalse = screen.getByRole('alert').innerHTML;
first.unmount();

const second = renderRefusal(true);
const withTrue = screen.getByRole('alert').innerHTML;
second.unmount();

// The inversion cloud PR #1852 shipped changes not one byte of what the
// free-plan user sees. That is the state #7587 measured this file into
// existence to record — and the assertion that turns red the day the flag
// alone starts driving copy, which is the failure the card feared.
expect(withTrue).toBe(withFalse);
});
});
40 changes: 40 additions & 0 deletions packages/plugin-chatbot/src/tool-display.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,46 @@ describe('parseAiQuotaError', () => {
).toBe(false);
});

// objectui#7587 — the free plan's shape AFTER cloud PR #1852 (the rolling
// 7-day window): same field, same type, inverted VALUE, plus a sibling
// key the parser has no read for. The pin above is about the type and
// stays true; this one is about what the parse hands downstream, which is
// where the change actually landed.
it('parses the free-plan envelope and carries no reset instant out of it', () => {
const parsed = parseAiQuotaError(
err({
success: false,
error: {
code: 'AI_ALLOWANCE_EXHAUSTED',
message: 'zh',
details: {
messageEn: 'Your free AI allowance is used up.',
upgrade: true,
topUp: false,
resetsTonight: false,
resetsAt: '2026-09-13T00:00:00Z',
},
},
}),
);
// The whole shape, so an added field is a deliberate edit to this line.
expect(parsed).toEqual({
code: 'AI_ALLOWANCE_EXHAUSTED',
message: 'zh',
messageEn: 'Your free AI allowance is used up.',
upgrade: true,
topUp: false,
resetsTonight: false,
});
// Stated separately because `toEqual` treats an explicit `undefined`
// property as absent: the KEY is not on the parsed object at all, so no
// consumer downstream can render the instant the producer sent. Reading
// it is a real option (objectui#7587 weighs it) — it just needs a
// reader and a measured wire position, and this line is where that
// decision has to be made out loud.
expect(parsed && 'resetsAt' in parsed).toBe(false);
});

it('ignores a non-object `details` instead of throwing', () => {
expect(
parseAiQuotaError(
Expand Down
16 changes: 16 additions & 0 deletions packages/plugin-chatbot/src/tool-display.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,22 @@ export interface AiQuotaError {
* (cloud#1238) and that POSITION is measured, but an absent or
* otherwise-typed value stays `undefined` rather than being coerced to a
* `false` no producer declared.
*
* ⚠️ objectui#7587 — measured on this tree: NOTHING reads this field once the
* parse fills it in. `useAiQuotaCopy` (ChatbotEnhanced), the only consumer of
* the parsed shape's fields, builds the banner from `message` / `messageEn` /
* `topUp`; the other two `parseAiQuotaError` call sites use the parse as a
* yes/no predicate. So cloud PR #1852's free-plan inversion (`true` ->
* `false`, the rolling 7-day window) changes nothing on screen, and the ISO
* `resetsAt` that producer now sends beside this flag is deliberately NOT
* read here: with no renderer for it, a read would pin a wire position this
* repo cannot measure (`objectstack-ai/cloud` is out of scope) and would add
* a second unread field rather than answer anyone's question. Both halves are
* pinned — the flag's inertness by
* `__tests__/ChatbotEnhanced.quotaResetPromise-7587.test.tsx`, the parse's
* silence about the instant by `tool-display.test.ts`. Wiring reset copy to
* this BOOLEAN is the one move to avoid: it is `false` for exactly the plan
* that needs the answer.
*/
resetsTonight?: boolean;
}
Expand Down
Loading