Skip to content

test(plugin-chatbot): pin what a free-plan 429 renders after the quota envelope moved - #8131

Merged
os-justin merged 1 commit into
mainfrom
claude/issue-7587-429-resetsat-free-plan
Sep 6, 2026
Merged

test(plugin-chatbot): pin what a free-plan 429 renders after the quota envelope moved#8131
os-justin merged 1 commit into
mainfrom
claude/issue-7587-429-resetsat-free-plan

Conversation

@os-justin

Copy link
Copy Markdown
Collaborator

Fixes #7587

The card asked for a repair and said, in its own words, that it had not measured whether anything renders the field — and that the difference between renders nothing and renders a false promise changes the priority. So that was measured first, and it decided the shape of this PR.

The measurement: renders nothing

packages/plugin-chatbot/src/tool-display.ts still populates resetsTonight (:221 declared, :373 read — both line numbers re-taken on cf1d29e2f and still exact). After that, nothing reads it:

reader of parseAiQuotaError what it uses
useAiQuotaCopy (ChatbotEnhanced.tsx:4322) message / messageEn / topUp — the only consumer of the parsed fields
ChatbotEnhanced.tsx:1939 yes/no predicate (park the confirm card in a blocked state)
ChatbotEnhanced.tsx:3313 yes/no predicate (banner vs. send-error notice)

parseAiQuotaError / AiQuotaError are not in the package barrel either, so there is no external consumer to inherit the field. resetsAt is still 0 occurrences repo-wide in plugin-chatbot.

What a free-plan user actually sees, captured from the rendered DOM:

Upgrade needed | <the server's own messageEn, verbatim> | Upgrade plan

Three parts, each with a named source. Nothing in it is derived from resetsTonight, and nothing from resetsAt. The true -> false inversion changes not one rendered byte, and the only sentence about when the allowance returns is the server's, passed straight through. The false-promise risk, if it exists, lives in that server-owned prose — in a repo that is not in this session's scope.

Why this is a pin and not a feature

Reading resetsAt and rendering a reset time was the other candidate repair. Against it, measured here:

  • There is no renderer to feed. Adding the read gives the parsed shape a second unread field rather than answering anyone's question.
  • The wire position is unverifiable from here. objectstack-ai/cloud is out of scope; that resetsAt sits in error.details rather than top-level is an inference from the cloud seat's prose, not a measurement. A reader built on a guessed position fails silently — it never fires, and a fixture written to match the guess pins the guess as if it were fact.
  • A second sentence is the hazard useAiQuotaCopy exists to prevent (objectui#7253): "one 429 can never be described by two different sentences on the same screen." Our own reset line would sit beside the server's.

So the field stays unread, deliberately and in writing, and what gets pinned is the consequence — which is what the existing pin could not do. #6385 left a pin about this field's type; the type did not move. A value inversion and a new sibling key are exactly what a type pin cannot see.

What is pinned

packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.quotaResetPromise-7587.test.tsx

  1. Exact accounting of the bannertextContent equals title + server sentence + CTA, plus explicit assertions that the ISO instant never reaches the DOM and no reset copy of ours stands in for it. Reset copy of our own cannot appear unnoticed.
  2. The flag is inert — the same envelope with resetsTonight true and false renders byte-identical HTML, guarded by a control that asserts the two parsed inputs really differ (so a green here is a measurement, not a fixture that forgot to vary its input).

packages/plugin-chatbot/src/tool-display.test.ts

  1. The parse is silent about the instant — the free-plan envelope parses to the whole expected shape and 'resetsAt' in parsed is false. Stated separately from the toEqual because toEqual treats an explicit undefined property as absent.

Plus the measurement itself, recorded at the field in tool-display.ts where the next reader lands, and an empty-frontmatter changeset (test + comment only; nothing released).

Reverse verification — each pin proven able to fail

Three ablations, each committed-then-mutated, each with the on-disk change proven by marker count and the restore proven by git diff HEAD empty plus a blob-hash match against HEAD.

mutation result
useAiQuotaCopy appends ' Resets tonight.' driven by the flag invariance pin RED, accounting pin green
useAiQuotaCopy appends ' Resets tonight.' unconditionally accounting pin RED, invariance pin green
parser gains resetsAt: asText(details?.resetsAt) parser pin RED — 1 failed, 49 passed

The first two are complementary on purpose: each catches precisely what the other cannot, and the flag-driven one is the regression the card actually feared — copy promising a reset, driven by a boolean that is now false for exactly the plan that needs the answer.

The third is the card's lesson landing twice. Under it, the suite's pre-existing whole-shape toEqual assertions stay green: their fixtures send no resetsAt, so an exact-equality assertion still cannot see a sibling key that is not in the fixture. Only a fixture that carries the field can.

Gates

pnpm exec vitest run packages/plugin-chatbot/ — 39 files / 461 tests passed. type-check (both tsconfigs) exit 0 after building the dependency closure. check:control-bytes, check:published-dist, check:published-tsconfig-exclude, check:handler-key-reads, check:sdui-registration-pins, check-changeset-presence — all exit 0. Package lint: 0 errors, 90 pre-existing warnings, none in the changed files.

Scope

packages/app-shell is untouched. Its useAiUsage / AiUsageIndicator read a resetsAt belonging to GET /api/v1/ai/usage — a different envelope on a different surface, and no evidence at all about the 429's field.

The follow-on decision this PR deliberately does not take — wire resetsAt to a renderer, or retire resetsTonight as declared-but-unread — is named in the dev report on #7587.

🤖 Generated with Claude Code

https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S


Generated by Claude Code

…a envelope moved

Cloud PR #1852 (rolling 7-day free quota) left `error.details.resetsTonight`
a boolean and changed what it means: the free plan now sends `false` where it
sent `true`, with an ISO `resetsAt` beside it that `tool-display.ts` has no
read for.

Measured before choosing a repair, which is the half the card had not taken:
nothing reads `resetsTonight` once `parseAiQuotaError` populates it.
`useAiQuotaCopy` builds the banner from `message` / `messageEn` / `topUp`, and
the other two `parseAiQuotaError` call sites use the parse as a predicate. So
the answer is "renders nothing", not "renders a false promise" -- the banner is
the server's own sentence passed through verbatim, and the inversion changes
no rendered byte.

The existing pin is about the field's TYPE, and the type did not move. These
pin the consequence instead:

- the banner's text by exact accounting (our title + the server sentence + the
  CTA, and nothing else), so reset copy of our own cannot appear unnoticed;
- the flag as inert: the same envelope with `resetsTonight` true and false
  renders identical HTML, with a control asserting the two inputs really
  differ;
- the parse as silent about the instant: the free-plan envelope's `resetsAt`
  is absent from the parsed shape, so no consumer can render it today.

`resetsAt` is deliberately still unread: with no renderer for it, a read would
pin a wire position this repo cannot measure and add a second unread field.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 50 chunks) 3186.6 KB 3191.4 KB
Main entry chunk (gzip) 143.6 KB 350 KB
Entry file index-Cs9mOUgK.js
Status PASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 15.67KB 5.75KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 5.13KB 2.35KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 498.06KB 113.97KB
core (index.js) 6.96KB 2.79KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 182.08KB 50.62KB
fields (index.js) 242.43KB 61.25KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 6.57KB 2.76KB
i18n (index.js) 3.65KB 1.47KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.84KB 10.94KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 4.39KB 1.66KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 11.71KB 4.29KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 5.12KB 1.74KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 15.16KB 3.68KB
plugin-calendar (index.js) 47.29KB 13.18KB
plugin-charts (index.js) 70.35KB 19.68KB
plugin-chatbot (index.js) 193.54KB 46.04KB
plugin-dashboard (index.js) 131.41KB 34.43KB
plugin-designer (index.js) 211.51KB 43.01KB
plugin-detail (index.js) 247.75KB 63.50KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 131.01KB 32.32KB
plugin-gantt (index.js) 167.16KB 40.99KB
plugin-grid (index.js) 208.58KB 56.63KB
plugin-kanban (index.js) 52.46KB 14.57KB
plugin-list (index.js) 113.34KB 27.72KB
plugin-map (index.js) 20.49KB 6.83KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.42KB 11.92KB
plugin-timeline (index.js) 30.10KB 8.74KB
plugin-tree (index.js) 9.33KB 3.25KB
plugin-view (index.js) 84.48KB 20.81KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 81.07KB 26.86KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.55KB 2.45KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 13.64KB 4.59KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.74KB 1.41KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.73KB 2.28KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Copy link
Copy Markdown
Collaborator Author

Landing — ACCEPT

By the dispatching seat (session_01YBWFb5YgMU5dw8p2VKj16S, os-justin), 18:1xZ.

CI: 32 of 32 check runs enumerated (returned count equals totalCount), 31 green — all four Test shards, Build & E2E, Type Check, Lint, Bundle Analysis, Changeset Bump Policy, Governed Surface Queue Guard. ⭐ Lint read explicitly rather than inferred: a sibling PR in the previous round lost a round to an error-severity unused import that a merits-only review had waved through.

Standing down on Live E2E (informational) — advisory, and red on main itself (run 34017174769 job 101442890465, step 7 Start ObjectStack backend (published packages), against the 09-05 control that passed the same step in 76s). ⚠️ That run reports conclusion: success at the run level while its job fails — ⛔ never read a run conclusion as evidence here. No fix to port; carded objectui#8084. ⛔ No re-run spent: a reproducible base-red is not a flake.

What lands, and what it deliberately does not do

+193 / −0, and the production diff is comment-only — one new pin file, tool-display.test.ts (+38), tool-display.ts (+18 of recorded measurement at the field), and an empty-frontmatter changeset that releases nothing. Clause-② stays no: no interface field, symbol, or barrel entry was added.

⭐ It pins the absence rather than inventing a behaviour. resetsTonight is a declared, populated, now twice-pinned field with zero readers — I re-verified that independently with a control that fires (the same grep for messageEn returns consumers in ChatbotEnhanced.tsx, so the zero is a reading, not a broken query), plus parseAiQuotaError / AiQuotaError genuinely absent from the barrel. ⇒ The card's truefalse flip changes not one rendered byte, and the reasoning for leaving it that way is recorded at the field rather than in a commit message nobody will find.

⛔ The obvious repair — read resetsAt and render a reset time — was correctly not taken, on two independent grounds: the 429's wire position for resetsAt has never been measured (it is an inference from prose, and "a reader built on a guessed position fails silently"), and objectui#7253 already ruled that "one 429 can never be described by two different sentences on the same screen."

⚠️ #7587 carries pm:retriage requesting a re-grade — its priority:p2 rested on a user-visible string being wrong, and none is.

Flipping ready and enqueuing.


Generated by Claude Code

@os-justin
os-justin marked this pull request as ready for review September 6, 2026 18:03
@os-justin
os-justin added this pull request to the merge queue Sep 6, 2026
Merged via the queue into main with commit 24b8a89 Sep 6, 2026
33 of 34 checks passed
@os-justin
os-justin deleted the claude/issue-7587-429-resetsat-free-plan branch September 6, 2026 18:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants