Skip to content

Commit 6757f7a

Browse files
committed
fix: address PR review follow-ups — rebase, parity, tests, safety
- Rebase onto main (resolve solid-chat + e2e merge conflicts) - Extend `const TTools` to Preact `useChat` and Angular `injectChat` - Add message-updaters tests for `input` carry-forward through approval - Guard `JSON.stringify(chunk.input)` in stream processor - Strengthen approval-gating changeset migration notes; include angular/preact in the const-tools inference changeset
1 parent d57343b commit 6757f7a

6 files changed

Lines changed: 96 additions & 12 deletions

File tree

.changeset/tool-call-approval-gating.md

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,36 @@ for (const part of message.parts) {
2020
}
2121
```
2222

23-
**Breaking (types only):** when you pass typed `tools`, reading `part.approval`
24-
on a mixed tool-call union without first narrowing by `part.name` no longer
25-
compiles — narrow to a `needsApproval: true` tool first. Untyped `useChat()`
26-
(no `tools` generic) and the base `ToolCallPart` type are unaffected: `approval`
27-
stays available on every tool-call part there. Runtime behavior is unchanged.
23+
## ⚠️ Breaking change (types only)
24+
25+
**This is the primary migration surface for this release.** When you pass a typed
26+
`tools` array to `useChat` / `createChat` / `injectChat`, reading `part.approval`
27+
on a mixed tool-call union **without first narrowing by `part.name`** no longer
28+
compiles. Code that previously did `part.approval?.id` in a generic handler over
29+
all tool-call parts must be updated:
30+
31+
```ts
32+
// ❌ No longer compiles on a typed mixed union
33+
part.approval?.id
34+
35+
// ✅ Narrow to an approval-required tool first
36+
if (part.name === 'deleteAccount') part.approval?.id
37+
38+
// ✅ Or guard with `in`
39+
if ('approval' in part) part.approval?.id
40+
41+
// ✅ Or type the handler against the base (untyped) ToolCallPart
42+
function handleApproval(part: ToolCallPart) {
43+
return part.approval?.id
44+
}
45+
```
46+
47+
Untyped `useChat()` (no inferred `tools` generic) and the base `ToolCallPart`
48+
type are unaffected: `approval` stays available on every tool-call part there.
49+
**Runtime behavior is unchanged** — only TypeScript narrowing is stricter.
2850

2951
Adds a `TNeedsApproval extends boolean` type parameter (defaulting to `false`)
3052
to the client tool types; existing explicit type arguments keep working via the
31-
default.
53+
default. Literal capture requires `toolDefinition({ needsApproval: true })` at
54+
the call site — a dynamic `needsApproval: boolean` variable will not gate the
55+
type.
Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
11
---
2+
'@tanstack/ai-angular': patch
3+
'@tanstack/ai-preact': patch
24
'@tanstack/ai-react': patch
35
'@tanstack/ai-solid': patch
46
'@tanstack/ai-svelte': patch
57
'@tanstack/ai-vue': patch
68
---
79

810
Add the `const` modifier to the `TTools` type parameter of `useChat`
9-
(`createChat` in Svelte) so a plain inline `tools` array now yields full
10-
type-safe message chunks. Previously the array widened to
11+
(`createChat` in Svelte, `injectChat` in Angular) so a plain inline `tools` array
12+
now yields full type-safe message chunks. Previously the array widened to
1113
`Array<Union>` and lost the literal tool `name`s that drive the
1214
discriminated `tool-call` part union, so callers had to wrap their tools in
1315
`clientTools(...)` (or add `as const`) to get narrowing. That wrapper is now
1416
optional — `tools: [toolA, toolB]` narrows `part.name`, `part.input`, and
1517
`part.output` on its own. `clientTools(...)` still works and remains useful
16-
for defining a shared tuple outside the hook call.
18+
for defining a shared tuple outside the hook call.

packages/ai-angular/src/inject-chat.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ import type {
3535
let nextId = 0
3636

3737
export function injectChat<
38-
TTools extends ReadonlyArray<AnyClientTool> = any,
38+
const TTools extends ReadonlyArray<AnyClientTool> = any,
3939
TSchema extends SchemaInput | undefined = undefined,
4040
TContext = InferredClientContext<TTools>,
4141
>(

packages/ai-preact/src/use-chat.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import type {
2323
} from './types'
2424

2525
export function useChat<
26-
TTools extends ReadonlyArray<AnyClientTool> = any,
26+
const TTools extends ReadonlyArray<AnyClientTool> = any,
2727
TContext = InferredClientContext<TTools>,
2828
>(options: UseChatOptions<TTools, TContext>): UseChatReturn<TTools> {
2929
const hookId = useId()

packages/ai/src/activities/chat/stream/processor.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1298,7 +1298,12 @@ export class StreamProcessor {
12981298
// received, back-fill the arguments string so the UIMessage ToolCallPart
12991299
// carries the correct value (defensive against adapters that skip ARGS).
13001300
if (chunk.input !== undefined && !existingToolCall.arguments) {
1301-
existingToolCall.arguments = JSON.stringify(chunk.input)
1301+
try {
1302+
existingToolCall.arguments = JSON.stringify(chunk.input)
1303+
} catch {
1304+
// circular refs, BigInt, etc. — leave arguments empty rather than
1305+
// aborting stream processing
1306+
}
13021307
}
13031308

13041309
const index = msgState.toolCallOrder.indexOf(chunk.toolCallId)

packages/ai/tests/message-updaters.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,59 @@ describe('message-updaters', () => {
244244
})
245245
})
246246

247+
it('should preserve existing input when updating a tool call without input in payload', () => {
248+
const parsedInput = { path: '/tmp/file' }
249+
const messages = [
250+
createMessage('msg-1', 'assistant', [
251+
{
252+
type: 'tool-call',
253+
id: 'call-1',
254+
name: 'deleteFile',
255+
arguments: '{"path":"/tmp/file"}',
256+
state: 'input-complete',
257+
input: parsedInput,
258+
},
259+
]),
260+
]
261+
262+
const result = updateToolCallPart(messages, 'msg-1', {
263+
id: 'call-1',
264+
name: 'deleteFile',
265+
arguments: '{"path":"/tmp/file"}',
266+
state: 'approval-requested',
267+
})
268+
269+
const part = result[0]?.parts[0] as ToolCallPart | undefined
270+
expect(part?.input).toEqual(parsedInput)
271+
})
272+
273+
it('should preserve existing input through approval request updates', () => {
274+
const parsedInput = { path: '/tmp/file' }
275+
const messages = [
276+
createMessage('msg-1', 'assistant', [
277+
{
278+
type: 'tool-call',
279+
id: 'call-1',
280+
name: 'deleteFile',
281+
arguments: '{"path":"/tmp/file"}',
282+
state: 'input-complete',
283+
input: parsedInput,
284+
},
285+
]),
286+
]
287+
288+
const result = updateToolCallApproval(
289+
messages,
290+
'msg-1',
291+
'call-1',
292+
'approval-123',
293+
)
294+
295+
const part = result[0]?.parts[0] as ToolCallPart | undefined
296+
expect(part?.input).toEqual(parsedInput)
297+
expect(part?.state).toBe('approval-requested')
298+
})
299+
247300
it('should preserve existing output when updating a tool call', () => {
248301
const toolOutput = { temperature: 20, conditions: 'sunny' }
249302
const messages = [

0 commit comments

Comments
 (0)