From 680bd5f015589a1809c2427e7a9e915d0cfb0498 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 20:05:47 +0000 Subject: [PATCH 1/2] fix(chatbot): deprecate the inert `maxToolRoundtrips` key (#5605) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 Claude-Session: https://claude.ai/code/session_01EuPCi56cnGyykygi3z9w4m --- .../max-tool-roundtrips-deprecate-5605.md | 31 ++++ content/docs/plugins/plugin-chatbot.mdx | 4 +- .../useObjectChat.maxToolRoundtrips.test.tsx | 162 ++++++++++++++++++ packages/plugin-chatbot/src/useObjectChat.ts | 73 +++++++- packages/types/src/complex.ts | 16 +- packages/types/src/zod/complex.zod.ts | 4 +- 6 files changed, 284 insertions(+), 6 deletions(-) create mode 100644 .changeset/max-tool-roundtrips-deprecate-5605.md create mode 100644 packages/plugin-chatbot/src/__tests__/useObjectChat.maxToolRoundtrips.test.tsx diff --git a/.changeset/max-tool-roundtrips-deprecate-5605.md b/.changeset/max-tool-roundtrips-deprecate-5605.md new file mode 100644 index 0000000000..895bfcddfe --- /dev/null +++ b/.changeset/max-tool-roundtrips-deprecate-5605.md @@ -0,0 +1,31 @@ +--- +'@object-ui/plugin-chatbot': patch +'@object-ui/types': patch +--- + +`maxToolRoundtrips` on `ChatbotSchema` is deprecated: it is inert, and an author +who sets it is now told so instead of being left believing the documented cap +applies (objectui#5605). + +The key was declared authorable in `@object-ui/types` (interface and zod, with a +description), threaded from the authored document through the chatbot renderer at +three call sites, accepted by `useObjectChat`, given a default — and then dropped. +Measuring the installed chat runtime says it cannot be honoured from here rather +than that someone forgot to wire it: `@ai-sdk/react`'s `useChat` takes `ChatInit` +plus 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, and its +successor was renamed through `continueUntil` to `stopWhen` / `stepCountIs`, +which the installed `ai` package declares only on `generateText`, `streamText` +and the tool-loop agent settings — all server-side. ObjectUI is backend-agnostic, +so it owns no server loop to cap either, and putting the number in the request +body would only move the same dead key one hop onto a wire contract no backend +reads. + +This is stage one of a two-stage retirement, so nothing an author already wrote +breaks: the key still parses, still carries its declared shape, and the renderer +still threads it. What changes is that it is now marked `@deprecated` in the +interface, the zod description and the docs, and that authoring it logs a +one-time notice naming the knob that does work — `planning.maxIterations` on the +agent. A follow-up removes the declaration once this deprecation has shipped in a +release. diff --git a/content/docs/plugins/plugin-chatbot.mdx b/content/docs/plugins/plugin-chatbot.mdx index 15087f2741..df73dd0236 100644 --- a/content/docs/plugins/plugin-chatbot.mdx +++ b/content/docs/plugins/plugin-chatbot.mdx @@ -108,7 +108,7 @@ const schema = { streamingEnabled?: boolean, headers?: Record, body?: Record, - maxToolRoundtrips?: number, + maxToolRoundtrips?: number, // deprecated - inert, see below onError?: (error: Error) => void, } ``` @@ -152,7 +152,7 @@ const schema = { | `streamingEnabled` | boolean | `true` | Enable SSE streaming for AI responses | | `headers` | object | - | Additional headers for API requests | | `body` | object | - | Additional body params for API requests | -| `maxToolRoundtrips` | number | `5` | Max tool-calling round-trips per message | +| `maxToolRoundtrips` | number | - | **Deprecated - has no effect.** Nothing reads this value, so it never capped anything. Cap tool-calling loops on the agent instead (`planning.maxIterations`). Still accepted so existing documents keep parsing; slated for removal in a future major | | `surface` | `'card' \| 'plain'` | `'card'` | Controls whether the chat renders as a bordered panel or a frameless full-page workspace | | `processVisibility` | `'hidden' \| 'summary' \| 'debug'` | `'summary'` | Controls how much agent reasoning and tool detail is shown | | `onError` | function | - | Error callback for streaming/API errors | diff --git a/packages/plugin-chatbot/src/__tests__/useObjectChat.maxToolRoundtrips.test.tsx b/packages/plugin-chatbot/src/__tests__/useObjectChat.maxToolRoundtrips.test.tsx new file mode 100644 index 0000000000..7a82b12e9f --- /dev/null +++ b/packages/plugin-chatbot/src/__tests__/useObjectChat.maxToolRoundtrips.test.tsx @@ -0,0 +1,162 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * objectui#5605 — `maxToolRoundtrips` is an authorable, documented chatbot key + * that reaches nothing. ADR-0049 says enforce-or-remove, and the MEASUREMENT + * decides which arm: it cannot be enforced from here. + * + * The installed chat runtime is `@ai-sdk/react`'s `useChat`, whose options are + * `ChatInit` plus `{ throttle, experimental_throttle, resume }`. `ChatInit` + * declares exactly one loop control — `sendAutomaticallyWhen`, a boolean + * predicate — and no numeric cap under any spelling. The numeric knob was + * removed from `useChat` in a MAJOR ("remove deprecated useChat roundtrip + * options"), and its successor `maxSteps` was renamed through `continueUntil` + * to `stopWhen`/`stepCountIs`, which the installed `ai` package declares only + * on `generateText`, `streamText` and `ToolLoopAgentSettings` — all + * server-side. ObjectUI is backend-agnostic, so it owns no server loop either. + * + * So this is stage 1 of a two-stage retirement: the key still parses and still + * carries its declared shape, but authoring it now says out loud that it does + * nothing. These tests pin all three halves of that claim, each with a control + * that fails in the opposite direction: + * + * 1. AUTHORED → one notice naming the key. Control: UNAUTHORED → silence. + * 2. AUTHORED → the value still never reaches the runtime boundary (the chat + * POST body). Control: `model`/`conversationId` DO reach that same body in + * the same request, so a dead harness cannot make this pass vacuously. + * 3. The key still round-trips through `ChatbotSchema` — deprecating it is + * not a breaking change for documents that already author it. + * + * Resolution note (why no `dist/` rebuild is needed for the cross-package leg): + * the root vitest config aliases `@object-ui/types` → `packages/types/src` and + * `@object-ui/types/zod` → `packages/types/src/zod/index.zod.ts`, so test 3 + * reads SOURCE and never resolves through the package's `dist/`. + */ +import { renderHook, act, waitFor } from '@testing-library/react'; +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { ChatbotSchema } from '@object-ui/types/zod'; +import { useObjectChat, resetMaxToolRoundtripsWarning } from '../useObjectChat'; + +const API = 'https://example.test/api/v1/ai/chat'; + +type AnyRecord = { [key: string]: unknown }; +type FetchMock = { mock: { calls: unknown[][] } }; + +/** A minimal, well-formed Vercel AI UI-message data stream so a send completes. */ +function dataStreamResponse(): Response { + const body = + 'data: {"type":"start"}\n\n' + 'data: {"type":"finish"}\n\n' + 'data: [DONE]\n\n'; + return new Response(body, { + status: 200, + headers: { + 'Content-Type': 'text/event-stream', + 'x-vercel-ai-ui-message-stream': 'v1', + }, + }); +} + +/** The JSON body of a captured chat POST. */ +function bodyOf(fetchMock: FetchMock, callIndex: number): AnyRecord { + const init = fetchMock.mock.calls[callIndex]?.[1] as { body?: string } | undefined; + return JSON.parse(init?.body ?? '{}') as AnyRecord; +} + +/** Every string that appears anywhere in a captured body, keys included. */ +function serialize(body: AnyRecord): string { + return JSON.stringify(body); +} + +beforeEach(() => { + resetMaxToolRoundtripsWarning(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe('useObjectChat — maxToolRoundtrips is inert and now says so (#5605)', () => { + it('warns ONCE, naming the key, when an author actually sets it', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const { rerender } = renderHook(() => useObjectChat({ api: API, maxToolRoundtrips: 3 })); + rerender(); + rerender(); + + expect(warn).toHaveBeenCalledTimes(1); + const message = String(warn.mock.calls[0]?.[0]); + expect(message).toContain('maxToolRoundtrips'); + // The notice has to be actionable, not just a scold: it names the real knob. + expect(message).toContain('planning.maxIterations'); + }); + + it('CONTROL: stays silent when the key is not authored', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + renderHook(() => useObjectChat({ api: API })); + + expect(warn).not.toHaveBeenCalled(); + }); + + it('reports an authored `0` too — the cap that most looks like it should bite', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + renderHook(() => useObjectChat({ api: API, maxToolRoundtrips: 0 })); + + // A truthiness check would swallow this one. + expect(warn).toHaveBeenCalledTimes(1); + }); + + it('does NOT reach the runtime boundary — the value is absent from the chat POST', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const fetchMock = vi.fn(async () => dataStreamResponse()); + vi.stubGlobal('fetch', fetchMock); + + const { result } = renderHook(() => + useObjectChat({ + api: API, + conversationId: 'conv_1', + model: 'gpt-4o', + maxToolRoundtrips: 3, + }), + ); + + await act(async () => { + result.current.sendMessage('hello'); + }); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + + const body = bodyOf(fetchMock as unknown as FetchMock, 0); + + // CONTROL FIRST — without this, "the key is absent" would also pass if the + // request never happened or the body were empty. + expect(body.conversationId).toBe('conv_1'); + expect(body.model).toBe('gpt-4o'); + + // The measurement this whole card rests on: the authored cap travels the + // renderer → hook path and then stops. It is on no wire. + expect(body.maxToolRoundtrips).toBeUndefined(); + expect(serialize(body)).not.toContain('maxToolRoundtrips'); + expect(serialize(body)).not.toContain('"3"'); + }); + + it('still parses: deprecating the key does not break documents that author it', () => { + const parsed = ChatbotSchema.safeParse({ + type: 'chatbot', + messages: [], + api: '/api/v1/ai/chat', + maxToolRoundtrips: 3, + }); + + expect(parsed.success).toBe(true); + // `z.object` STRIPS unknown keys rather than rejecting them, so retention in + // the parsed output — not the absence of an `unrecognized_keys` issue — is + // what actually discriminates "declared" from "gone". This is the assertion + // stage 2 is expected to flip. + expect(parsed.success && parsed.data.maxToolRoundtrips).toBe(3); + }); +}); diff --git a/packages/plugin-chatbot/src/useObjectChat.ts b/packages/plugin-chatbot/src/useObjectChat.ts index 1343a8d7d5..2ae7a62844 100644 --- a/packages/plugin-chatbot/src/useObjectChat.ts +++ b/packages/plugin-chatbot/src/useObjectChat.ts @@ -183,6 +183,59 @@ export function withHandoffContext( return { ...body, context: { ...ctx, parentConversationId } }; } +/** + * objectui#5605 — `maxToolRoundtrips` is an authorable, documented key that + * reaches nothing, and the measurement says it cannot be made to reach anything + * from here. + * + * The installed chat runtime is `@ai-sdk/react`'s `useChat`, whose options are + * `ChatInit` plus `{ throttle, experimental_throttle, resume }`. `ChatInit` + * carries exactly one loop control — `sendAutomaticallyWhen`, a boolean + * predicate — and no numeric cap of any spelling: `@ai-sdk/react@1.0.0` shipped + * "remove deprecated useChat roundtrip options" as a MAJOR, and the successor + * `maxSteps` was renamed through `continueUntil` to `stopWhen`/`stepCountIs`, + * which the installed `ai` package declares ONLY on `generateText`, + * `streamText` and `ToolLoopAgentSettings` — all server-side. This hook also + * never passes `sendAutomaticallyWhen`, so the client performs no automatic + * tool round-trips at all: there is no client loop here to cap. + * + * Nor is there a server loop we own. ObjectUI is backend-agnostic — `api` is + * whatever endpoint the author names — so shipping the number in the request + * body would only move the same dead key one hop further out, onto a wire + * contract no backend reads. The platform's own cap is `maxIterations` on the + * agent (`planning.maxIterations`), a different key with a different default. + * + * So the honest state is retirement, and retirement is two-stage (maintainer + * ruling, 2026-08-22 item 13). This is STAGE 1: the key keeps parsing and keeps + * its declared shape, so nothing an author already wrote breaks — but an author + * who actually writes it is now TOLD it is inert, instead of being left + * believing the documented cap applies. Stage 2 deletes it. + * + * Warned once per process: the hook re-runs on every render, and three renderer + * call sites feed it. Reset seam for tests, same shape as `plugin-detail`'s + * `recordActivityFeed` warnings. + */ +const warnedInertMaxToolRoundtrips = new Set(); + +/** Test seam: forget that the inert-`maxToolRoundtrips` notice has been given. */ +export function resetMaxToolRoundtripsWarning(): void { + warnedInertMaxToolRoundtrips.clear(); +} + +/** Tell an author once that their authored cap does nothing. See above. */ +function warnMaxToolRoundtripsInert(): void { + if (warnedInertMaxToolRoundtrips.has('maxToolRoundtrips')) return; + warnedInertMaxToolRoundtrips.add('maxToolRoundtrips'); + console.warn( + '[@object-ui/plugin-chatbot] `maxToolRoundtrips` is deprecated and has no ' + + 'effect: the installed chat runtime exposes no client-side round-trip cap ' + + '(`useChat` dropped the numeric knob, and the surviving `stopWhen` / ' + + '`stepCountIs` step cap is server-side only). Cap tool-calling loops on the ' + + 'agent instead — `planning.maxIterations`. This key is inert and is slated ' + + 'for removal in a future major (objectui#5605).', + ); +} + type InitialMessage = OuiChatMessage & { parts?: Array>; reasoning?: string; @@ -237,7 +290,14 @@ export interface UseObjectChatOptions { body?: Record; /** * Maximum tool-calling round-trips per message. - * @default 5 + * + * @deprecated objectui#5605 — INERT. Nothing reads this value: the installed + * chat runtime exposes no client-side round-trip cap, and ObjectUI does not + * own the server loop. Setting it has never had an effect, and it does not + * acquire one by being set. Cap tool-calling loops on the agent instead + * (`planning.maxIterations`). Still accepted so existing documents keep + * parsing; authoring it now logs a one-time notice, and it is slated for + * removal in a future major. See {@link warnMaxToolRoundtripsInert}. */ maxToolRoundtrips?: number; /** @@ -360,7 +420,7 @@ export function useObjectChat(options: UseObjectChatOptions = {}): UseObjectChat streamingEnabled = true, headers, body, - maxToolRoundtrips = 5, + maxToolRoundtrips, onError, showTimestamp, autoResponse, @@ -369,6 +429,15 @@ export function useObjectChat(options: UseObjectChatOptions = {}): UseObjectChat onSend, } = options; + // objectui#5605 — an AUTHORED `maxToolRoundtrips` is inert; say so once. The + // check is `!== undefined`, not truthiness, so an authored `0` is reported + // too (a cap of zero is exactly the author who most needs telling). Declared + // here, at the top of the hook, so it runs before the local-mode early return + // and stays unconditional under the Rules of Hooks. + useEffect(() => { + if (maxToolRoundtrips !== undefined) warnMaxToolRoundtripsInert(); + }, [maxToolRoundtrips]); + // Lock the mode on first render to satisfy the Rules of Hooks. // Conditional hook calls would crash if `api` toggled between renders. const modeRef = useRef<'api' | 'local'>(api ? 'api' : 'local'); diff --git a/packages/types/src/complex.ts b/packages/types/src/complex.ts index 25e25ebae2..7347ba3890 100644 --- a/packages/types/src/complex.ts +++ b/packages/types/src/complex.ts @@ -619,7 +619,21 @@ export interface ChatbotSchema extends BaseSchema { requestBody?: Record; /** * Maximum number of tool-calling round-trips per user message. - * @default 5 + * + * @deprecated objectui#5605 — INERT, and not fixable from here. The renderer + * really does thread this value into `useObjectChat`, which then drops it: + * the installed chat runtime (`@ai-sdk/react`'s `useChat`) exposes no + * client-side round-trip cap — the numeric knob was removed from `useChat`, + * and its successor step cap (`stopWhen` / `stepCountIs`) exists only on the + * server-side call functions. ObjectUI is backend-agnostic, so it does not + * own a server loop to cap either. Setting this has never limited anything. + * + * Cap tool-calling loops on the agent instead — `planning.maxIterations`, + * which the platform spec declares and enforces. + * + * Still declared and still accepted so documents that already author it keep + * parsing; authoring it now logs a one-time notice from the chatbot plugin. + * Slated for removal in a future major (ADR-0049 enforce-or-remove, staged). */ maxToolRoundtrips?: number; /** diff --git a/packages/types/src/zod/complex.zod.ts b/packages/types/src/zod/complex.zod.ts index de3d5f874d..9580fe9398 100644 --- a/packages/types/src/zod/complex.zod.ts +++ b/packages/types/src/zod/complex.zod.ts @@ -301,7 +301,9 @@ export const ChatbotSchema = BaseSchema.extend({ streamingEnabled: z.boolean().optional().describe('Enable streaming responses'), headers: z.record(z.string(), z.string()).optional().describe('Additional API headers'), body: z.record(z.string(), z.unknown()).optional().describe('Additional API body params'), - maxToolRoundtrips: z.number().optional().describe('Max tool-calling round-trips'), + /** @deprecated objectui#5605 — inert; nothing reads it. Cap loops on the agent (`planning.maxIterations`). Slated for removal. */ + maxToolRoundtrips: z.number().optional() + .describe('DEPRECATED (inert, slated for removal) — Max tool-calling round-trips. Nothing reads this; cap tool loops on the agent via planning.maxIterations'), onError: z.function().optional().describe('Error callback'), }); From be40354d43def4067cf6b42410dc19cb0b6ac51a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 20:10:10 +0000 Subject: [PATCH 2/2] test(chatbot): make the declaration pin discriminating (#5605) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01EuPCi56cnGyykygi3z9w4m --- .../useObjectChat.maxToolRoundtrips.test.tsx | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/packages/plugin-chatbot/src/__tests__/useObjectChat.maxToolRoundtrips.test.tsx b/packages/plugin-chatbot/src/__tests__/useObjectChat.maxToolRoundtrips.test.tsx index 7a82b12e9f..e9d6998457 100644 --- a/packages/plugin-chatbot/src/__tests__/useObjectChat.maxToolRoundtrips.test.tsx +++ b/packages/plugin-chatbot/src/__tests__/useObjectChat.maxToolRoundtrips.test.tsx @@ -141,10 +141,23 @@ describe('useObjectChat — maxToolRoundtrips is inert and now says so (#5605)', // renderer → hook path and then stops. It is on no wire. expect(body.maxToolRoundtrips).toBeUndefined(); expect(serialize(body)).not.toContain('maxToolRoundtrips'); - expect(serialize(body)).not.toContain('"3"'); }); - it('still parses: deprecating the key does not break documents that author it', () => { + it('is still DECLARED, and the declaration now announces the deprecation', () => { + // This is the assertion stage 2 flips. It has to read the SHAPE, not a parse + // result: `BaseSchema` is `.passthrough()` (packages/types/src/zod/base.zod.ts), + // so an authored `maxToolRoundtrips` survives `safeParse` whether or not it + // is declared — measured, by ablating the declaration and watching a + // parse-based assertion stay green. Membership in `.shape` is the only + // observable that separates "declared authorable" from "gone". + expect(Object.keys(ChatbotSchema.shape)).toContain('maxToolRoundtrips'); + + const description = ChatbotSchema.shape.maxToolRoundtrips.description ?? ''; + expect(description).toContain('DEPRECATED'); + expect(description).toContain('planning.maxIterations'); + }); + + it('does not break documents that already author it (non-discriminating by design)', () => { const parsed = ChatbotSchema.safeParse({ type: 'chatbot', messages: [], @@ -152,11 +165,12 @@ describe('useObjectChat — maxToolRoundtrips is inert and now says so (#5605)', maxToolRoundtrips: 3, }); + // Recorded as a REGRESSION FLOOR, not as evidence the key is declared: under + // `.passthrough()` this passes for any key at all, declared or not. It pins + // only that stage 1 broke nothing — and it is worth knowing for stage 2 that + // deleting the declaration will NOT make such a document fail; it will keep + // passing silently, which is why the runtime notice above is the part that + // actually reaches an author. expect(parsed.success).toBe(true); - // `z.object` STRIPS unknown keys rather than rejecting them, so retention in - // the parsed output — not the absence of an `unrecognized_keys` issue — is - // what actually discriminates "declared" from "gone". This is the assertion - // stage 2 is expected to flip. - expect(parsed.success && parsed.data.maxToolRoundtrips).toBe(3); }); });