Skip to content

Commit ca844df

Browse files
boring91claude
authored andcommitted
feat(ai-client): accept a per-call body via SendMessageOptions
ChatClient.sendMessage already took a per-call body as its positional second argument, but every framework hook (useChat, injectChat, createChat, ...) exposes sendMessage(content, options) and forwards undefined for it — leaving no race-free way to send per-message data (e.g. attachment ids) through a hook: updating a reactive chat-level body/forwardedProps option right before sending can flush after the send. SendMessageOptions gains an optional body that ChatClient resolves as a fallback to the positional argument (positional wins), so every wrapper inherits the capability with no wrapper code changes. Queued sends preserve it exactly like the positional form. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 888e8b7 commit ca844df

15 files changed

Lines changed: 275 additions & 8 deletions

File tree

.changeset/spotty-donuts-repeat.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@tanstack/ai-client': minor
3+
---
4+
5+
Add `body` to `SendMessageOptions`: a per-call body shallow-merged into the request's `forwardedProps` with the highest priority.
6+
7+
`ChatClient.sendMessage` already accepted a per-call body as its positional second argument, but the framework hooks (`useChat`, `injectChat`, `createChat`, …) expose `sendMessage(content, options)` and forwarded `undefined` for it — leaving no race-free way to send per-message data (e.g. attachment ids) through a hook. Updating a reactive chat-level `body`/`forwardedProps` option right before sending is racy because reactive option changes can flush after the send.
8+
9+
`sendMessage(content, { body: { ... } })` now works through every framework hook and on `ChatClient` directly. The positional argument wins if both are provided. Queued sends preserve their per-call `body` exactly like the positional form.

docs/chat/connection-adapters.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,24 @@ const { messages } = useChat({
8080

8181
> **Tip:** `body` and `forwardedProps` populate the same wire field. Use `body` for static defaults, the `forwardedProps` constructor option (or per-`sendMessage` `data`) for dynamic values. Runtime values always win.
8282
83+
**Per-call body.** For data that belongs to one specific message (attachment ids, a one-off flag), pass `body` in `sendMessage`'s options — it is shallow-merged into `forwardedProps` with the highest priority, for that request only:
84+
85+
```typescript
86+
import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
87+
88+
const { sendMessage } = useChat({
89+
connection: fetchServerSentEvents("/api/chat"),
90+
body: { provider: "openai" },
91+
});
92+
93+
// forwardedProps for this request: { provider: "openai", attachmentIds: [...] }
94+
await sendMessage("Summarize the attached files", {
95+
body: { attachmentIds: ["att_1", "att_2"] },
96+
});
97+
```
98+
99+
This is race-free where updating a reactive chat-level `body`/`forwardedProps` option right before sending is not (reactive option changes can flush after the send). On `ChatClient` directly, the positional `body` argument does the same thing and wins if both are provided.
100+
83101
### Resumable SSE
84102

85103
`fetchServerSentEvents` watches SSE `id:` values. If a connection drops after

packages/ai-angular/tests/inject-chat.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,29 @@ describe('injectChat — streaming', () => {
123123
expect(result.isLoading()).toBe(false)
124124
})
125125

126+
it('merges sendMessage options.body into the request', async () => {
127+
let capturedData: Record<string, any> | undefined
128+
const adapter = createMockConnectionAdapter({
129+
chunks: createTextChunks('Hello there'),
130+
onConnect: (_messages, data) => {
131+
capturedData = data
132+
},
133+
})
134+
const { result, flush } = renderInjectChat({
135+
connection: adapter,
136+
body: { provider: 'openai' },
137+
})
138+
139+
// injectChat has no positional body arg — options.body is the per-call
140+
// channel, merged over the chat-level `body` option.
141+
await result.sendMessage('Hi', { body: { attachmentIds: ['a1', 'a2'] } })
142+
await tick()
143+
flush()
144+
145+
expect(capturedData?.['provider']).toBe('openai')
146+
expect(capturedData?.['attachmentIds']).toEqual(['a1', 'a2'])
147+
})
148+
126149
it('initializes with provided messages', () => {
127150
const adapter = createMockConnectionAdapter()
128151
const initialMessages = [

packages/ai-client/src/chat-client.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1773,7 +1773,9 @@ export class ChatClient<
17731773
* @param body - Optional body parameters to merge with the client's base body for this request.
17741774
* Uses shallow merge with per-message body taking priority.
17751775
* @param sendOptions - Per-call overrides, e.g. `{ whenBusy: 'interrupt' }` to
1776-
* override the configured queue policy for this one send.
1776+
* override the configured queue policy for this one send,
1777+
* or `{ body }` as an alternative to the positional `body`
1778+
* argument (the positional argument wins if both are set).
17771779
*
17781780
* @example
17791781
* ```ts
@@ -1783,9 +1785,13 @@ export class ChatClient<
17831785
* // Text message with custom body params
17841786
* await client.sendMessage('Hello!', { temperature: 0.7 })
17851787
*
1786-
* // Per-call whenBusy override (body must still be the 2nd arg on ChatClient)
1788+
* // Per-call whenBusy override
17871789
* await client.sendMessage('Urgent', undefined, { whenBusy: 'interrupt' })
17881790
*
1791+
* // Per-call body via options — same effect as the positional arg. This is
1792+
* // the shape the framework hooks (`useChat`, `injectChat`, …) forward.
1793+
* await client.sendMessage('Hello!', undefined, { body: { temperature: 0.7 } })
1794+
*
17891795
* // Multimodal message with image
17901796
* await client.sendMessage({
17911797
* content: [
@@ -1823,13 +1829,17 @@ export class ChatClient<
18231829
)
18241830
}
18251831

1832+
// Positional `body` wins over `sendOptions.body` — the positional arg
1833+
// predates the option and existing callers may pass both.
1834+
const resolvedBody = body ?? sendOptions?.body
1835+
18261836
if (this.isSendBusy()) {
18271837
const { action, id } = this.decideWhenBusy(content, sendOptions)
18281838
if (action === 'drop') {
18291839
return
18301840
}
18311841
if (action === 'queue') {
1832-
this.enqueueMessage(content, body, id)
1842+
this.enqueueMessage(content, resolvedBody, id)
18331843
return
18341844
}
18351845
// 'interrupt': abort the current stream, then send now.
@@ -1846,7 +1856,7 @@ export class ChatClient<
18461856
}
18471857

18481858
try {
1849-
await this.deliverMessage(content, body)
1859+
await this.deliverMessage(content, resolvedBody)
18501860
} finally {
18511861
this.sendInFlight = false
18521862
}

packages/ai-client/src/types.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,20 @@ export type QueueOption = WhenBusy | QueueConfig | QueueStrategy
411411
export interface SendMessageOptions {
412412
/** Overrides the configured `whenBusy` for this one send. */
413413
whenBusy?: WhenBusy
414+
/**
415+
* Body parameters merged into this request's wire `forwardedProps`
416+
* (shallow merge, highest priority — wins over the chat-level `body` and
417+
* `forwardedProps` options on key collisions).
418+
*
419+
* Equivalent to the positional `body` argument of `ChatClient.sendMessage`;
420+
* if both are provided, the positional argument wins. The framework hooks
421+
* (`useChat`, `injectChat`, …) expose `sendMessage(content, options)`
422+
* without the positional argument, so this option is the way to pass a
423+
* per-call body through them — chat-level `forwardedProps` set via
424+
* reactive options can flush asynchronously, which makes "set option,
425+
* then send" racy for data that belongs to one specific message.
426+
*/
427+
body?: Record<string, any>
414428
}
415429

416430
/**

packages/ai-client/tests/chat-client-queue.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -685,6 +685,42 @@ describe('ChatClient queue policy branches', () => {
685685
expect(seenBodies.map((b) => b?.tag)).toEqual(['seed', 'y'])
686686
})
687687

688+
it('preserves sendOptions.body for queued sends', async () => {
689+
const seenBodies: Array<Record<string, unknown> | undefined> = []
690+
const deferred = createDeferred<void>()
691+
let call = 0
692+
const connection: ConnectConnectionAdapter = {
693+
async *connect(_messages, data) {
694+
call += 1
695+
seenBodies.push(
696+
data && typeof data === 'object'
697+
? (data as Record<string, unknown>)
698+
: undefined,
699+
)
700+
if (call === 1) {
701+
await deferred.promise
702+
}
703+
yield* createTextChunks('done', `msg-${call}`)
704+
},
705+
}
706+
707+
// Hook-style calls: body rides in sendOptions, positional arg unused.
708+
const client = new ChatClient({ connection })
709+
const firstSend = client.sendMessage('first', undefined, {
710+
body: { tag: 'seed' },
711+
})
712+
await vi.waitFor(() => {
713+
expect(client.getIsLoading()).toBe(true)
714+
})
715+
await client.sendMessage('a', undefined, { body: { tag: 'a' } })
716+
deferred.resolve()
717+
await firstSend
718+
await vi.waitFor(() => {
719+
expect(client.getQueue()).toEqual([])
720+
})
721+
expect(seenBodies.map((b) => b?.tag)).toEqual(['seed', 'a'])
722+
})
723+
688724
it('batch drain does not strand messages enqueued during the batch stream', async () => {
689725
const deferred1 = createDeferred<void>()
690726
const deferred2 = createDeferred<void>()

packages/ai-client/tests/chat-client.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3397,6 +3397,55 @@ describe('ChatClient', () => {
33973397
expect(capturedData?.['maxTokens']).toBe(100) // From per-message body
33983398
})
33993399

3400+
it('should merge sendOptions.body into the request (hook-style per-call body)', async () => {
3401+
const chunks = createTextChunks('Response')
3402+
let capturedData: Record<string, any> | undefined
3403+
const adapter = createMockConnectionAdapter({
3404+
chunks,
3405+
onConnect: (_messages, data) => {
3406+
capturedData = data
3407+
},
3408+
})
3409+
3410+
const client = new ChatClient({
3411+
connection: adapter,
3412+
body: { model: 'gpt-5.5', temperature: 0.7 },
3413+
})
3414+
3415+
// The framework hooks call sendMessage(content, undefined, sendOptions),
3416+
// so `sendOptions.body` is their only per-call body channel.
3417+
await client.sendMessage('Hello', undefined, {
3418+
body: { model: 'gpt-6', maxTokens: 100 },
3419+
})
3420+
3421+
expect(capturedData?.['model']).toBe('gpt-6') // From sendOptions.body
3422+
expect(capturedData?.['temperature']).toBe(0.7) // From base body
3423+
expect(capturedData?.['maxTokens']).toBe(100) // From sendOptions.body
3424+
})
3425+
3426+
it('positional body wins over sendOptions.body', async () => {
3427+
const chunks = createTextChunks('Response')
3428+
let capturedData: Record<string, any> | undefined
3429+
const adapter = createMockConnectionAdapter({
3430+
chunks,
3431+
onConnect: (_messages, data) => {
3432+
capturedData = data
3433+
},
3434+
})
3435+
3436+
const client = new ChatClient({ connection: adapter })
3437+
3438+
await client.sendMessage(
3439+
'Hello',
3440+
{ tag: 'positional' },
3441+
{ body: { tag: 'options', extra: true } },
3442+
)
3443+
3444+
// The positional arg replaces (not merges with) sendOptions.body.
3445+
expect(capturedData?.['tag']).toBe('positional')
3446+
expect(capturedData?.['extra']).toBeUndefined()
3447+
})
3448+
34003449
it('should accept forwardedProps option and merge into request body', async () => {
34013450
const chunks = createTextChunks('Response')
34023451
let capturedData: Record<string, any> | undefined

packages/ai-preact/src/use-mcp-app-bridge.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export type UseMcpAppBridgeOptions = CreateMcpAppBridgeOptions
1919
* const bridge = useMcpAppBridge({
2020
* threadId,
2121
* callEndpoint: '/api/mcp-apps-call',
22-
* chat: { sendMessage: async (content) => void sendMessage(content) },
22+
* chat: { sendMessage: (content, body) => sendMessage(content, { body }) },
2323
* onLink: (url) => window.open(url, '_blank', 'noopener,noreferrer'),
2424
* })
2525
* // pass `bridge` to <MCPAppResource bridge={bridge} … />

packages/ai-react/src/types.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,9 @@ interface BaseUseChatReturn<
164164
* Can be a simple string or multimodal content with images, audio, etc.
165165
* By default, sends while busy are queued until the run settles successfully
166166
* (`queue: 'drop'` restores the old drop-while-busy behavior).
167-
* Pass `{ whenBusy }` to override the policy for a single send.
167+
* Pass `{ whenBusy }` to override the policy for a single send, or
168+
* `{ body }` to merge per-call body params into this request's
169+
* `forwardedProps`.
168170
*/
169171
sendMessage: (
170172
content: string | MultimodalContent,

packages/ai-react/src/use-mcp-app-bridge.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export type UseMcpAppBridgeOptions = CreateMcpAppBridgeOptions
1919
* const bridge = useMcpAppBridge({
2020
* threadId,
2121
* callEndpoint: '/api/mcp-apps-call',
22-
* chat: { sendMessage: async (content) => void sendMessage(content) },
22+
* chat: { sendMessage: (content, body) => sendMessage(content, { body }) },
2323
* onLink: (url) => window.open(url, '_blank', 'noopener,noreferrer'),
2424
* })
2525
* // pass `bridge` to <MCPAppResource bridge={bridge} … />

0 commit comments

Comments
 (0)