Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/noop-chat-devtools-bridge-parity.md
Original file line number Diff line number Diff line change
@@ -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.
24 changes: 15 additions & 9 deletions packages/ai-client/src/devtools-noop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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 extends never> = TMissing
type _ChatBridgeMissing = Exclude<
keyof ChatDevtoolsBridge,
keyof NoOpChatDevtoolsBridge
Expand All @@ -165,12 +169,14 @@ type _VideoBridgeMissing = Exclude<
keyof VideoDevtoolsBridge<unknown>,
keyof NoOpVideoDevtoolsBridge<unknown>
>
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
Expand Down
34 changes: 34 additions & 0 deletions packages/ai-client/tests/chat-client-noop-devtools.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
21 changes: 21 additions & 0 deletions testing/e2e/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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: '/',
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -386,6 +395,7 @@ export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/chat-client-default-bridge'
| '/devtools-chat'
| '/devtools-generation-hooks'
| '/devtools-route-a'
Expand Down Expand Up @@ -428,6 +438,7 @@ export interface FileRouteTypes {
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/chat-client-default-bridge'
| '/devtools-chat'
| '/devtools-generation-hooks'
| '/devtools-route-a'
Expand Down Expand Up @@ -470,6 +481,7 @@ export interface FileRouteTypes {
id:
| '__root__'
| '/'
| '/chat-client-default-bridge'
| '/devtools-chat'
| '/devtools-generation-hooks'
| '/devtools-route-a'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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: '/'
Expand Down Expand Up @@ -894,6 +914,7 @@ const ApiVideoRouteWithChildren = ApiVideoRoute._addFileChildren(

const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
ChatClientDefaultBridgeRoute: ChatClientDefaultBridgeRoute,
DevtoolsChatRoute: DevtoolsChatRoute,
DevtoolsGenerationHooksRoute: DevtoolsGenerationHooksRoute,
DevtoolsRouteARoute: DevtoolsRouteARoute,
Expand Down
79 changes: 79 additions & 0 deletions testing/e2e/src/routes/chat-client-default-bridge.tsx
Original file line number Diff line number Diff line change
@@ -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<Array<UIMessage>>([])
const [error, setError] = useState<string | null>(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 (
<div className="p-6 max-w-2xl mx-auto space-y-4">
<h1 className="text-xl font-semibold">
Vanilla ChatClient (default no-op devtools bridge)
</h1>
<button
data-testid="send-button"
type="button"
disabled={!hydrated}
onClick={handleSend}
>
Send
</button>
{error !== null && <div data-testid="send-error">{error}</div>}
<div data-testid="messages">
{messages.map((message) => (
<div
key={message.id}
data-testid={
message.role === 'user' ? 'user-message' : 'assistant-message'
}
>
{message.parts
.map((part) => (part.type === 'text' ? part.content : ''))
.join('')}
</div>
))}
</div>
</div>
)
}
22 changes: 22 additions & 0 deletions testing/e2e/tests/chat-client-default-bridge.spec.ts
Original file line number Diff line number Diff line change
@@ -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.
Comment on lines +3 to +5

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟑 Minor | ⚑ Quick win

Document the aimock policy exception in the header comment.

Based on learnings, E2E specs that don't reach the provider HTTP layer should document why aimock is not used. This test uses a mock fetcher (static SSE Response in the route file) that never reaches the provider HTTP layer, qualifying as an exception.

πŸ“ Suggested header comment expansion
+// Policy exception: This test uses a mock fetcher (static SSE Response) that
+// never reaches the provider HTTP layer, so aimock is not needed. The route
+// component's inline fetcher returns deterministic SSE events to test the
+// default no-op bridge behavior without external HTTP dependencies.
+
 // 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.
πŸ“ Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 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.
// Policy exception: This test uses a mock fetcher (static SSE Response) that
// never reaches the provider HTTP layer, so aimock is not needed. The route
// component's inline fetcher returns deterministic SSE events to test the
// default no-op bridge behavior without external HTTP dependencies.
// 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.
πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@testing/e2e/tests/chat-client-default-bridge.spec.ts` around lines 3 - 5,
Update the header comment above the test that exercises the no-op bridge
(referenced by ChatClient and the framework hooks that pass the real devtools
bridge factory) to explicitly document an aimock policy exception: state that
this spec uses a mock fetcher (a static SSE Response served from the route file)
and therefore never reaches the provider HTTP layer, so aimock is intentionally
not used; include brief rationale and link the exception to the mock fetcher/SSE
Response and the no-op bridge behavior so future readers understand why aimock
is skipped.

Source: Learnings

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)
})
})