From 85fd045957c4ae5f1d1836103445099a161cee41 Mon Sep 17 00:00:00 2001 From: jan-kubica Date: Fri, 12 Jun 2026 17:59:30 +0200 Subject: [PATCH 1/2] fix(ai-client): add missing methods to the no-op chat devtools bridge The default NoOpChatDevtoolsBridge was missing mountWithTools, notifyToolsChanged, and recordStreamId, so the first ChatClient.sendMessage() threw a TypeError before appending the user message whenever no devtoolsBridgeFactory was supplied. Subsequent sends appeared to work because mountDevtools() marks itself done before calling the missing method. Rework the compile-time parity check so drift between the real and no-op bridge surfaces fails the build: the previous form assigned 'undefined as never' to the missing-keys type, which always typechecks because never is assignable to every type. Add a unit test that unmocks the suite-wide real-bridge shim so the shipping default path is exercised. --- .../noop-chat-devtools-bridge-parity.md | 5 +++ packages/ai-client/src/devtools-noop.ts | 24 ++++++++----- .../tests/chat-client-noop-devtools.test.ts | 34 +++++++++++++++++++ 3 files changed, 54 insertions(+), 9 deletions(-) create mode 100644 .changeset/noop-chat-devtools-bridge-parity.md create mode 100644 packages/ai-client/tests/chat-client-noop-devtools.test.ts diff --git a/.changeset/noop-chat-devtools-bridge-parity.md b/.changeset/noop-chat-devtools-bridge-parity.md new file mode 100644 index 000000000..d2533f9cf --- /dev/null +++ b/.changeset/noop-chat-devtools-bridge-parity.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-client': patch +--- + +Fix `ChatClient` throwing `TypeError: this.devtoolsBridge.mountWithTools is not a function` on the first `sendMessage()` (and on `updateOptions({ tools })`) when no devtools bridge factory is supplied. The default `NoOpChatDevtoolsBridge` was missing the `mountWithTools`, `notifyToolsChanged`, and `recordStreamId` methods of the real bridge; the throw happened before the user message was appended, so the first message was silently lost. The compile-time parity check between the real and no-op bridges now fails the build when the surfaces drift. diff --git a/packages/ai-client/src/devtools-noop.ts b/packages/ai-client/src/devtools-noop.ts index 7d02c8a26..e83a168c5 100644 --- a/packages/ai-client/src/devtools-noop.ts +++ b/packages/ai-client/src/devtools-noop.ts @@ -72,7 +72,10 @@ export class NoOpChatDevtoolsBridge { dispose(): void {} // chat-specific surface + mountWithTools(_initialMessageCount: number): void {} + notifyToolsChanged(): void {} setCurrentStreamId(_streamId: string | null): void {} + recordStreamId(_streamId: string): void {} getCurrentStreamId(): string | null { return null } @@ -150,9 +153,10 @@ export class NoOpVideoDevtoolsBridge< // Compile-time parity checks. If a public method is added to the real // bridge class without a matching stub on the no-op, the corresponding -// `Exclude<...>` will resolve to a non-`never` union and the `as never` -// assignment below will fail to typecheck — surfacing the drift at build -// time instead of as a runtime TypeError later. +// `Exclude<...>` resolves to a non-`never` union, which violates the +// `extends never` constraint below and fails the build — surfacing the +// drift at build time instead of as a runtime TypeError later. +type AssertBridgeParity = TMissing type _ChatBridgeMissing = Exclude< keyof ChatDevtoolsBridge, keyof NoOpChatDevtoolsBridge @@ -165,12 +169,14 @@ type _VideoBridgeMissing = Exclude< keyof VideoDevtoolsBridge, keyof NoOpVideoDevtoolsBridge > -const _chatBridgeParity: _ChatBridgeMissing = undefined as never -const _generationBridgeParity: _GenerationBridgeMissing = undefined as never -const _videoBridgeParity: _VideoBridgeMissing = undefined as never -void _chatBridgeParity -void _generationBridgeParity -void _videoBridgeParity +const _bridgeParity: + | [ + AssertBridgeParity<_ChatBridgeMissing>, + AssertBridgeParity<_GenerationBridgeMissing>, + AssertBridgeParity<_VideoBridgeMissing>, + ] + | undefined = undefined +void _bridgeParity // =========================================================================== // Factories — these are what the clients call when no real factory was diff --git a/packages/ai-client/tests/chat-client-noop-devtools.test.ts b/packages/ai-client/tests/chat-client-noop-devtools.test.ts new file mode 100644 index 000000000..95c9e961b --- /dev/null +++ b/packages/ai-client/tests/chat-client-noop-devtools.test.ts @@ -0,0 +1,34 @@ +// Regression coverage for the shipping default. The suite-wide setup file +// (`use-real-devtools-bridges.ts`) re-routes the no-op devtools factories to +// the real bridges, so no other test exercises the bridge production +// consumers actually get when they don't opt into devtools. Unmock here so +// `ChatClient` runs against the actual no-op bridge that ships as the +// default, instead of the real-bridge substitute the setup file installs. +import { describe, expect, it, vi } from 'vitest' +import { ChatClient } from '../src/chat-client' +import { createMockConnectionAdapter, createTextChunks } from './test-utils' + +vi.unmock('../src/devtools-noop') + +describe('ChatClient with default no-op devtools bridge', () => { + it('sends the first message and appends it', async () => { + const adapter = createMockConnectionAdapter({ + chunks: createTextChunks('Hi there'), + }) + const client = new ChatClient({ connection: adapter }) + + await client.sendMessage('hello') + + const messages = client.getMessages() + expect(messages.at(0)?.role).toBe('user') + expect(messages.at(0)?.parts).toEqual([{ type: 'text', content: 'hello' }]) + expect(messages.at(1)?.role).toBe('assistant') + }) + + it('updates tools without throwing', () => { + const adapter = createMockConnectionAdapter() + const client = new ChatClient({ connection: adapter }) + + expect(() => client.updateOptions({ tools: [] })).not.toThrow() + }) +}) From 0f95b999334bc3b9ab70acb8a6aa27bab4c3db20 Mon Sep 17 00:00:00 2001 From: jan-kubica Date: Fri, 12 Jun 2026 17:59:30 +0200 Subject: [PATCH 2/2] test(e2e): cover vanilla ChatClient with the default no-op devtools bridge The framework hooks always inject the real devtools bridge factory, so no existing E2E route exercised the no-op bridge that direct ChatClient consumers get by default. --- testing/e2e/src/routeTree.gen.ts | 21 +++++ .../src/routes/chat-client-default-bridge.tsx | 79 +++++++++++++++++++ .../tests/chat-client-default-bridge.spec.ts | 22 ++++++ 3 files changed, 122 insertions(+) create mode 100644 testing/e2e/src/routes/chat-client-default-bridge.tsx create mode 100644 testing/e2e/tests/chat-client-default-bridge.spec.ts diff --git a/testing/e2e/src/routeTree.gen.ts b/testing/e2e/src/routeTree.gen.ts index 23f0cc4ff..c35d84fa3 100644 --- a/testing/e2e/src/routeTree.gen.ts +++ b/testing/e2e/src/routeTree.gen.ts @@ -18,6 +18,7 @@ import { Route as DevtoolsRouteBRouteImport } from './routes/devtools-route-b' import { Route as DevtoolsRouteARouteImport } from './routes/devtools-route-a' import { Route as DevtoolsGenerationHooksRouteImport } from './routes/devtools-generation-hooks' import { Route as DevtoolsChatRouteImport } from './routes/devtools-chat' +import { Route as ChatClientDefaultBridgeRouteImport } from './routes/chat-client-default-bridge' import { Route as IndexRouteImport } from './routes/index' import { Route as ProviderIndexRouteImport } from './routes/$provider/index' import { Route as ApiVideoRouteImport } from './routes/api.video' @@ -95,6 +96,11 @@ const DevtoolsChatRoute = DevtoolsChatRouteImport.update({ path: '/devtools-chat', getParentRoute: () => rootRouteImport, } as any) +const ChatClientDefaultBridgeRoute = ChatClientDefaultBridgeRouteImport.update({ + id: '/chat-client-default-bridge', + path: '/chat-client-default-bridge', + getParentRoute: () => rootRouteImport, +} as any) const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', @@ -257,6 +263,7 @@ const ApiAudioStreamRoute = ApiAudioStreamRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute + '/chat-client-default-bridge': typeof ChatClientDefaultBridgeRoute '/devtools-chat': typeof DevtoolsChatRoute '/devtools-generation-hooks': typeof DevtoolsGenerationHooksRoute '/devtools-route-a': typeof DevtoolsRouteARoute @@ -299,6 +306,7 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/': typeof IndexRoute + '/chat-client-default-bridge': typeof ChatClientDefaultBridgeRoute '/devtools-chat': typeof DevtoolsChatRoute '/devtools-generation-hooks': typeof DevtoolsGenerationHooksRoute '/devtools-route-a': typeof DevtoolsRouteARoute @@ -342,6 +350,7 @@ export interface FileRoutesByTo { export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute + '/chat-client-default-bridge': typeof ChatClientDefaultBridgeRoute '/devtools-chat': typeof DevtoolsChatRoute '/devtools-generation-hooks': typeof DevtoolsGenerationHooksRoute '/devtools-route-a': typeof DevtoolsRouteARoute @@ -386,6 +395,7 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' + | '/chat-client-default-bridge' | '/devtools-chat' | '/devtools-generation-hooks' | '/devtools-route-a' @@ -428,6 +438,7 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/' + | '/chat-client-default-bridge' | '/devtools-chat' | '/devtools-generation-hooks' | '/devtools-route-a' @@ -470,6 +481,7 @@ export interface FileRouteTypes { id: | '__root__' | '/' + | '/chat-client-default-bridge' | '/devtools-chat' | '/devtools-generation-hooks' | '/devtools-route-a' @@ -513,6 +525,7 @@ export interface FileRouteTypes { } export interface RootRouteChildren { IndexRoute: typeof IndexRoute + ChatClientDefaultBridgeRoute: typeof ChatClientDefaultBridgeRoute DevtoolsChatRoute: typeof DevtoolsChatRoute DevtoolsGenerationHooksRoute: typeof DevtoolsGenerationHooksRoute DevtoolsRouteARoute: typeof DevtoolsRouteARoute @@ -614,6 +627,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DevtoolsChatRouteImport parentRoute: typeof rootRouteImport } + '/chat-client-default-bridge': { + id: '/chat-client-default-bridge' + path: '/chat-client-default-bridge' + fullPath: '/chat-client-default-bridge' + preLoaderRoute: typeof ChatClientDefaultBridgeRouteImport + parentRoute: typeof rootRouteImport + } '/': { id: '/' path: '/' @@ -894,6 +914,7 @@ const ApiVideoRouteWithChildren = ApiVideoRoute._addFileChildren( const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, + ChatClientDefaultBridgeRoute: ChatClientDefaultBridgeRoute, DevtoolsChatRoute: DevtoolsChatRoute, DevtoolsGenerationHooksRoute: DevtoolsGenerationHooksRoute, DevtoolsRouteARoute: DevtoolsRouteARoute, diff --git a/testing/e2e/src/routes/chat-client-default-bridge.tsx b/testing/e2e/src/routes/chat-client-default-bridge.tsx new file mode 100644 index 000000000..7718517fb --- /dev/null +++ b/testing/e2e/src/routes/chat-client-default-bridge.tsx @@ -0,0 +1,79 @@ +import { useEffect, useState } from 'react' +import { createFileRoute } from '@tanstack/react-router' +import { ChatClient } from '@tanstack/ai-client' +import type { UIMessage } from '@tanstack/ai-client' + +export const Route = createFileRoute('/chat-client-default-bridge')({ + component: ChatClientDefaultBridgePage, +}) + +// Covers the vanilla `ChatClient` shipping default: no `devtoolsBridgeFactory`, +// so the client falls back to the no-op devtools bridge. The framework hooks +// (`useChat` etc.) always inject the real bridge, so every other route in this +// suite bypasses the no-op path entirely. A static SSE body keeps the scenario +// deterministic; the transport is not what is under test here. +const SSE_BODY = [ + 'data: {"type":"RUN_STARTED","threadId":"thread-default-bridge","runId":"run-default-bridge"}\n\n', + 'data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"msg-default-bridge","model":"test","timestamp":0,"delta":"Hi from the assistant","content":"Hi from the assistant"}\n\n', + 'data: {"type":"RUN_FINISHED","threadId":"thread-default-bridge","runId":"run-default-bridge","model":"test","timestamp":0,"finishReason":"stop"}\n\n', +].join('') + +function ChatClientDefaultBridgePage() { + const [messages, setMessages] = useState>([]) + const [error, setError] = useState(null) + const [client] = useState( + () => + new ChatClient({ + fetcher: () => + new Response(SSE_BODY, { + headers: { 'Content-Type': 'text/event-stream' }, + }), + onMessagesChange: setMessages, + }), + ) + + // The button starts disabled in the server-rendered HTML and enables on + // hydration, so Playwright's actionability check cannot click before the + // onClick handler is attached. + const [hydrated, setHydrated] = useState(false) + useEffect(() => { + setHydrated(true) + }, []) + + const handleSend = () => { + client + .sendMessage('hello from the vanilla client') + .catch((sendError: unknown) => setError(String(sendError))) + } + + return ( +
+

+ Vanilla ChatClient (default no-op devtools bridge) +

+ + {error !== null &&
{error}
} +
+ {messages.map((message) => ( +
+ {message.parts + .map((part) => (part.type === 'text' ? part.content : '')) + .join('')} +
+ ))} +
+
+ ) +} diff --git a/testing/e2e/tests/chat-client-default-bridge.spec.ts b/testing/e2e/tests/chat-client-default-bridge.spec.ts new file mode 100644 index 000000000..adcd63524 --- /dev/null +++ b/testing/e2e/tests/chat-client-default-bridge.spec.ts @@ -0,0 +1,22 @@ +import { expect, test } from '@playwright/test' + +// The framework hooks always pass the real devtools bridge factory, so this +// is the only scenario in the suite that exercises the no-op bridge that +// vanilla `ChatClient` consumers get by default. +test.describe('vanilla ChatClient with default no-op devtools bridge', () => { + test('first sendMessage appends the user message and streams a reply', async ({ + page, + }) => { + await page.goto('/chat-client-default-bridge') + + await page.getByTestId('send-button').click() + + await expect(page.getByTestId('user-message')).toHaveText( + 'hello from the vanilla client', + ) + await expect(page.getByTestId('assistant-message')).toContainText( + 'Hi from the assistant', + ) + await expect(page.getByTestId('send-error')).toHaveCount(0) + }) +})