fix(chatbot): deprecate the inert maxToolRoundtrips key (stage 1 of 2) - #5918
Merged
Conversation
`maxToolRoundtrips` is declared authorable in `@object-ui/types` (interface +
zod), threaded through the chatbot renderer at three call sites, accepted by
`useObjectChat`, given a default — and then dropped on the floor. ADR-0049 says
enforce-or-remove; measuring the installed runtime says it cannot be enforced
from here.
`@ai-sdk/react`'s `useChat` takes `ChatInit` + `{throttle, resume}`, and
`ChatInit` declares exactly one loop control — the boolean predicate
`sendAutomaticallyWhen` — and no numeric cap under any spelling. The numeric
knob was removed from `useChat` in a major; its successor `maxSteps` became
`stopWhen`/`stepCountIs`, declared only on `generateText`, `streamText` and
`ToolLoopAgentSettings` — all server-side. This hook never passes
`sendAutomaticallyWhen`, so there is no client loop to cap; and ObjectUI is
backend-agnostic, so there is no server loop we own either.
Stage 1 of a two-stage retirement: the key still parses and keeps its declared
shape, but it is marked `@deprecated` in the interface, the zod description and
the docs, and authoring it now logs a one-time notice pointing at the knob that
does work (`planning.maxIterations` on the agent). The unused-var lint warning
resolves as a consequence of the value finally being read — not by silencing it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EuPCi56cnGyykygi3z9w4m
The first version asserted that an authored `maxToolRoundtrips` survives `ChatbotSchema.safeParse`, on the assumption that `z.object` strips undeclared keys. It does not here: `BaseSchema` is `.passthrough()` (packages/types/src/zod/base.zod.ts:197), so the key round-trips whether or not it is declared — the assertion could not fail. Measured by ablating the zod declaration and watching the parse-based assertion stay green. Membership in `ChatbotSchema.shape` is the observable that actually separates "declared authorable" from "gone", so the pin now reads the shape (and the description's deprecation marker). Re-ablated: the rewritten assertion fails with `expected [ Array(37) ] to include 'maxToolRoundtrips'`. The parse case is kept as an explicitly labelled regression floor. Its non-discrimination is itself worth recording for stage 2: deleting the declaration will not make an authoring document fail, it will keep passing silently — which is why the runtime notice is the part that reaches an author. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EuPCi56cnGyykygi3z9w4m
This was referenced Aug 23, 2026
Contributor
✅ Console Performance Budget
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
Size Limits
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part of #5605
Part of, not a closing keyword, and that is the finding: the card is an ADR-0049 enforce-or-remove call whose arm was to be chosen by measurement, and the measurement lands on the fallback arm the maintainer's 2026-08-22 ruling attached to Option A. Retirement is two-stage (maintainer ruling, 2026-08-22 item 13), so this PR ships stage 1 only and the card stays open for the deletion.The measurement: the cap cannot be honoured from here
The ruling was "enforce, unless the AI SDK offers no corresponding round-trip-cap knob (or the semantics do not line up)". Read out of the installed packages, not their docs:
1.
useChathas no numeric cap.@ai-sdk/react@4.0.68declaresUseChatOptionsas({ chat } | ChatInit) & { throttle, experimental_throttle, resume }. ItsChatInit(fromai@7.0.65) declares, in full:id,messageMetadataSchema,dataPartSchemas,messages,generateId,transport,onError,onToolCall,onFinish,onData, and one loop control —A boolean predicate, not a count.
maxToolRoundtripsandmaxStepshave zero occurrences in either installed.d.ts.2. That is deliberate upstream history, not an oversight.
@ai-sdk/react's own CHANGELOG records the removal as a major:The installed version is four majors past that. The numeric successor migrated server-side:
332167b: chore (ai): move maxSteps into UseChatOptions→64f6d64/d306260: feat (ai): replace maxSteps with continueUntil→9315076: chore (ai): rename continueUntil to stopWhen. Rename maxSteps stop condition to isStepCount. In the installedaipackagestopWhenis declared at exactly three sites —generateText,streamText,ToolLoopAgentSettings— all server-side call functions.3. There is no client loop here to cap.
useObjectChatnever passessendAutomaticallyWhen, so the client performs no automatic tool round-trips at all.4. And no server loop we own. ObjectUI is backend-agnostic —
apiis whatever endpoint the author names. Putting the number in the request body would move the same dead key one hop further out, onto a wire contract no backend reads, while widening a public request shape. The platform's real cap is a different key with a different default:@objectstack/specdeclaresChatWithToolsOptions.maxIterations(default 10) and agentplanning.maxIterations(min(1).max(100).default(10), aliasesmaxSteps/maxLoops/iterations/maxTurns—maxToolRoundtripsis not among them). The docs promised a default of 5.So the semantics do not line up in either direction, and the fallback condition is met.
What stage 1 does
Nothing an author already wrote breaks. The key still parses, keeps its declared shape, and the renderer still threads it. What changes is that it stops lying:
@object-ui/types—@deprecatedon the interface field (the false@default 5is gone) and aDEPRECATED (inert, slated for removal)zod description.@object-ui/plugin-chatbot— authoring the key logs a one-time notice naming the knob that does work (planning.maxIterations). The@typescript-eslint/no-unused-varswarning the issue quoted resolves as a consequence of the value finally being read, not by deleting the destructure.content/docs/plugins/plugin-chatbot.mdx:111/:155as part of the same defect, and the ruling's remove arm names "both docs locations" explicitly. Leaving the docs promising a working cap would have left stage 1 half-done.The runtime notice is load-bearing rather than decorative:
BaseSchemais.passthrough(), so a JSON/AI metadata author gets no schema signal —@deprecatedreaches TypeScript consumers only.Verification — all at
be40354d4Targeted, path-filtered, heavy legs through the shared verify lock.
vitest run packages/plugin-chatbotTest Files 26 passed (26)·Tests 354 passed (354)vitest run packages/typesTest Files 50 passed (50)·Tests 559 passed (559)type-check(both packages)packages/types type-check: Done·packages/plugin-chatbot type-check: Done(script names echoed, so not a zero-match no-op)check-changeset-presence✅ 4 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)check-changeset-no-major✅ No changeset declares amajorbump.check-control-bytes✅ check-control-bytes: OK (scanned 4918 tracked text file(s); skipped 85 binary).check-phantom-dependencies✅ Every in-scope import is declared by the package that publishes it.check-doc-component-types✅ Every documented component type is registered.check-doc-linksLinks are valid across 13 scan roots.check-spec-symbol-derivation✅ spec symbol derivation: 1300 files scanned against 4959 spec export namescheck-lint-coverage✅ lint coverage: 46/46 packages linted, 0 with outstanding errors (0 total).check-type-check-coverage✅ type-check coverage: 45/46 viatype-check… ✅ test type-check coverage: 41/41Not run locally —
check-doc-snippet-types. It refused to run on an unbuilt-workspace precondition unrelated to this diff:@object-ui/plugin-view declares types at packages/plugin-view/dist/index.d.ts and it is not on disk, 10 such packages, 0 of them touched here, and the edited block is a```plaintextfence this gate does not typecheck. CI builds first.Reverse verification
Each leg confirmed on disk by grepping injected and removed text (never an editor's exit code), each with a restore
trap, direction predicted before running, tree restored after.!== undefined)Leg C is the one that matters most: "the value is absent from the request body" is precisely the assertion shape that passes when the harness is dead, so it was made to fail on demand. Its control asserts
conversationIdandmodeldo arrive in that same POST.Leg D first ran GREEN against a RED prediction — the green-direction failure. Cause was not
dist/staleness (packages/types/distdoes not exist, and the root vitest config aliases@object-ui/types→packages/types/src, so this leg reads source): the original assertion was a phantom. It asserted an authored key survivessafeParse, assumingz.objectstrips undeclared keys — butBaseSchemais.passthrough()(packages/types/src/zod/base.zod.ts:197), so it passes for any key, declared or not. The pin was rewritten ontoChatbotSchema.shapemembership, which is the only observable separating "declared" from "gone", and re-ablated to RED. The parse case is kept, explicitly labelled non-discriminating, as a regression floor.Legs that do not discriminate, named rather than implied: the docs edit and the interface
@deprecatedJSDoc are observed by no test — JSDoc is type-level only, and no gate reads the prop table's prose.Declared lint narrowing
Repo-scale
turbo run lintis CI's run. Narrowed to the two touched packages, with the three pieces of evidence:eslint .run inside each package, resolving the root flateslint.config.jsitself.--format json: 58 files (plugin-chatbot) + 111 (types) = 169, 0 errors in both.eslint.config.js(251 lines) sets noprojectService, noparserOptions.project, norecommendedTypeChecked— type-aware linting is not enabled, so every rule is single-file and this diff cannot move the verdict on a file it did not touch.Corroborated independently by
check-lint-coverage:46/46 packages linted, 0 with outstanding errors.The measured consequence:
useObjectChat.tswent from 12 warnings (including363:5 'maxToolRoundtrips' is assigned a value but never used) to 11 — the remaining 11 are pre-existingreact-hooks/refsandno-explicit-any, untouched here. The new test file lints clean.Follow-up
Stage 2 — deleting the key from the interface, the zod schema, the three renderer call sites and the docs — is filed as #5919, gated on this deprecation shipping in a release. It is deliberately not done here.
That card carries one measured warning for whoever takes it: because
BaseSchemais.passthrough(), deleting the declaration will not make an authoring document fail — it keeps parsing green, silently.Generated by Claude Code
Generated by Claude Code