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
31 changes: 31 additions & 0 deletions .changeset/max-tool-roundtrips-deprecate-5605.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions content/docs/plugins/plugin-chatbot.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ const schema = {
streamingEnabled?: boolean,
headers?: Record<string, string>,
body?: Record<string, unknown>,
maxToolRoundtrips?: number,
maxToolRoundtrips?: number, // deprecated - inert, see below
onError?: (error: Error) => void,
}
```
Expand Down Expand Up @@ -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 |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
/**
* 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');
});

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: [],
api: '/api/v1/ai/chat',
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);
});
});
73 changes: 71 additions & 2 deletions packages/plugin-chatbot/src/useObjectChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();

/** 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<Record<string, unknown>>;
reasoning?: string;
Expand Down Expand Up @@ -237,7 +290,14 @@ export interface UseObjectChatOptions {
body?: Record<string, unknown>;
/**
* 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;
/**
Expand Down Expand Up @@ -360,7 +420,7 @@ export function useObjectChat(options: UseObjectChatOptions = {}): UseObjectChat
streamingEnabled = true,
headers,
body,
maxToolRoundtrips = 5,
maxToolRoundtrips,
onError,
showTimestamp,
autoResponse,
Expand All @@ -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');
Expand Down
16 changes: 15 additions & 1 deletion packages/types/src/complex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -619,7 +619,21 @@ export interface ChatbotSchema extends BaseSchema {
requestBody?: Record<string, unknown>;
/**
* 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;
/**
Expand Down
4 changes: 3 additions & 1 deletion packages/types/src/zod/complex.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
});

Expand Down
Loading