Skip to content

Commit 603cd6b

Browse files
committed
test: cover useChat({outputSchema}) runtime + runStreamingStructuredOutput orchestrator
Two runtime test files closing the highest-value gaps in the PR's test coverage: packages/typescript/ai-react/tests/use-chat-structured-output.test.ts (4 tests) - partial updates progressively from TEXT_MESSAGE_CONTENT deltas, final snaps on the terminal CUSTOM structured-output.complete event - state resets between runs via the stateful mock adapter (RUN_STARTED clears partial/final before the second run's deltas land) - user-supplied onChunk callback fires after internal tracking, with full visibility of the same chunks - useChat() without outputSchema doesn't track structured state — the internal handler's outputSchema-gate is a no-op packages/typescript/ai/tests/chat-structured-output-stream.test.ts (6 tests) - native adapter.structuredOutputStream path: validated structured- output.complete event forwarded with parsed object, schema validation failure → RUN_ERROR { code: 'schema-validation' } and NO complete event is emitted, reasoning carries through validation onto the terminal event, TEXT_MESSAGE_CONTENT deltas pass through - fallbackStructuredOutputStream path (adapter lacks native streaming): synthesizes RUN_STARTED → TEXT_MESSAGE_* → structured-output.complete → RUN_FINISHED around the non-streaming structuredOutput call; schema validation failure on the fallback path also emits RUN_ERROR Together: ai package 769 tests, ai-react 110, ai-vue 93, ai-solid 103, ai-svelte 56 — all green.
1 parent 1097b54 commit 603cd6b

2 files changed

Lines changed: 628 additions & 0 deletions

File tree

Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
/**
2+
* Runtime tests for `useChat({ outputSchema })`:
3+
*
4+
* - `partial` updates per `TEXT_MESSAGE_CONTENT` delta (progressive JSON parse)
5+
* - `final` snaps on the terminal `CUSTOM structured-output.complete` event
6+
* - State resets between `sendMessage` calls (on `RUN_STARTED`)
7+
* - User's own `onChunk` callback fires after internal tracking
8+
* - Without `outputSchema`, no partial/final tracking runs
9+
*/
10+
11+
import { act, renderHook, waitFor } from '@testing-library/react'
12+
import { describe, expect, it, vi } from 'vitest'
13+
import type { StandardJSONSchemaV1 } from '@standard-schema/spec'
14+
import type { StreamChunk } from '@tanstack/ai'
15+
import { createMockConnectionAdapter } from '../../ai-client/tests/test-utils'
16+
import { useChat } from '../src/use-chat'
17+
18+
type Person = { name: string; age: number; email: string }
19+
type PersonSchema = StandardJSONSchemaV1<Person, Person>
20+
const personSchema = {} as PersonSchema
21+
22+
/**
23+
* Build a chunk sequence simulating a streaming structured-output run:
24+
* RUN_STARTED → TEXT_MESSAGE_CONTENT deltas (each delta moves the buffer
25+
* one character closer to `fullJson`) → CUSTOM structured-output.complete
26+
* → RUN_FINISHED.
27+
*/
28+
function buildStructuredStream(
29+
fullJson: string,
30+
finalObject: Person,
31+
runId = 'run-1',
32+
): Array<StreamChunk> {
33+
const chunks: Array<StreamChunk> = [
34+
{
35+
type: 'RUN_STARTED',
36+
runId,
37+
threadId: `thread-${runId}`,
38+
model: 'test',
39+
timestamp: Date.now(),
40+
} as StreamChunk,
41+
]
42+
// Split fullJson into a few large-ish slices so we test progressive parsing
43+
// without producing a flood of one-char chunks.
44+
const sliceSize = Math.max(4, Math.floor(fullJson.length / 4))
45+
for (let i = 0; i < fullJson.length; i += sliceSize) {
46+
chunks.push({
47+
type: 'TEXT_MESSAGE_CONTENT',
48+
messageId: `msg-${runId}`,
49+
delta: fullJson.slice(i, i + sliceSize),
50+
content: fullJson.slice(0, i + sliceSize),
51+
model: 'test',
52+
timestamp: Date.now(),
53+
} as StreamChunk)
54+
}
55+
chunks.push({
56+
type: 'CUSTOM',
57+
name: 'structured-output.complete',
58+
value: { object: finalObject, raw: fullJson },
59+
model: 'test',
60+
timestamp: Date.now(),
61+
} as StreamChunk)
62+
chunks.push({
63+
type: 'RUN_FINISHED',
64+
runId,
65+
threadId: `thread-${runId}`,
66+
model: 'test',
67+
timestamp: Date.now(),
68+
finishReason: 'stop',
69+
} as StreamChunk)
70+
return chunks
71+
}
72+
73+
describe('useChat({ outputSchema }) — runtime', () => {
74+
const person: Person = {
75+
name: 'John Doe',
76+
age: 30,
77+
email: 'john@example.com',
78+
}
79+
const json = JSON.stringify(person)
80+
81+
it('updates `partial` progressively and snaps `final` on the terminal event', async () => {
82+
const chunks = buildStructuredStream(json, person)
83+
const adapter = createMockConnectionAdapter({ chunks })
84+
85+
const { result } = renderHook(() =>
86+
useChat({ connection: adapter, outputSchema: personSchema }),
87+
)
88+
89+
// Initial state.
90+
expect(result.current.partial).toEqual({})
91+
expect(result.current.final).toBeNull()
92+
93+
await act(async () => {
94+
await result.current.sendMessage('Extract')
95+
})
96+
97+
// The schema-validated `final` lands once the terminal event fires.
98+
await waitFor(() => {
99+
expect(result.current.final).toEqual(person)
100+
})
101+
102+
// `partial` should end with the same shape (parsePartialJSON on the
103+
// complete buffer returns the fully-formed object).
104+
expect(result.current.partial).toEqual(person)
105+
})
106+
107+
it('resets `partial` and `final` between runs', async () => {
108+
const personA: Person = {
109+
name: 'Alice',
110+
age: 25,
111+
email: 'alice@example.com',
112+
}
113+
const personB: Person = { name: 'Bob', age: 40, email: 'bob@example.com' }
114+
115+
// Stateful adapter that yields a different stream per connect() call.
116+
// Without this, createMockConnectionAdapter would yield the same array
117+
// on every sendMessage — the "reset" couldn't be observed between runs
118+
// because final would race past personA straight to personB on call #1.
119+
let call = 0
120+
const adapter = {
121+
async *connect() {
122+
const chunks =
123+
call === 0
124+
? buildStructuredStream(
125+
JSON.stringify(personA),
126+
personA,
127+
'run-a',
128+
)
129+
: buildStructuredStream(
130+
JSON.stringify(personB),
131+
personB,
132+
'run-b',
133+
)
134+
call++
135+
for (const chunk of chunks) yield chunk
136+
},
137+
}
138+
139+
const { result } = renderHook(() =>
140+
useChat({ connection: adapter, outputSchema: personSchema }),
141+
)
142+
143+
await act(async () => {
144+
await result.current.sendMessage('A')
145+
})
146+
await waitFor(() => {
147+
expect(result.current.final).toEqual(personA)
148+
})
149+
expect(result.current.partial).toEqual(personA)
150+
151+
// Second run — RUN_STARTED at the head must clear partial/final before
152+
// run-b's deltas land. If the reset didn't happen, run-b's progressive
153+
// partial would be shadowed by leftover state from run-a (since
154+
// parsePartialJSON would parse run-b's accumulated buffer cleanly, but
155+
// the spread-onto-stale-state class of bug would still surface in `final`).
156+
await act(async () => {
157+
await result.current.sendMessage('B')
158+
})
159+
await waitFor(() => {
160+
expect(result.current.final).toEqual(personB)
161+
})
162+
expect(result.current.partial).toEqual(personB)
163+
})
164+
165+
it("invokes the user's onChunk callback alongside internal tracking", async () => {
166+
const chunks = buildStructuredStream(json, person)
167+
const adapter = createMockConnectionAdapter({ chunks })
168+
const onChunk = vi.fn()
169+
170+
const { result } = renderHook(() =>
171+
useChat({
172+
connection: adapter,
173+
outputSchema: personSchema,
174+
onChunk,
175+
}),
176+
)
177+
178+
await act(async () => {
179+
await result.current.sendMessage('Extract')
180+
})
181+
await waitFor(() => {
182+
expect(result.current.final).toEqual(person)
183+
})
184+
185+
// User callback fires for every chunk the hook sees, including the
186+
// terminal structured-output.complete event.
187+
const completeCalls = onChunk.mock.calls.filter(
188+
([c]) => c.type === 'CUSTOM' && c.name === 'structured-output.complete',
189+
)
190+
expect(completeCalls.length).toBe(1)
191+
expect(completeCalls[0][0].value).toEqual({ object: person, raw: json })
192+
193+
const deltaCalls = onChunk.mock.calls.filter(
194+
([c]) => c.type === 'TEXT_MESSAGE_CONTENT',
195+
)
196+
expect(deltaCalls.length).toBeGreaterThan(0)
197+
})
198+
})
199+
200+
describe('useChat() without outputSchema — runtime', () => {
201+
it('does not break or track structured state when no schema is supplied', async () => {
202+
const adapter = createMockConnectionAdapter({
203+
chunks: [
204+
{
205+
type: 'RUN_STARTED',
206+
runId: 'r',
207+
threadId: 't',
208+
model: 'test',
209+
timestamp: Date.now(),
210+
} as StreamChunk,
211+
{
212+
type: 'TEXT_MESSAGE_CONTENT',
213+
messageId: 'm',
214+
delta: 'Hello',
215+
content: 'Hello',
216+
model: 'test',
217+
timestamp: Date.now(),
218+
} as StreamChunk,
219+
{
220+
type: 'RUN_FINISHED',
221+
runId: 'r',
222+
threadId: 't',
223+
model: 'test',
224+
timestamp: Date.now(),
225+
finishReason: 'stop',
226+
} as StreamChunk,
227+
],
228+
})
229+
230+
const { result } = renderHook(() => useChat({ connection: adapter }))
231+
232+
await act(async () => {
233+
await result.current.sendMessage('hi')
234+
})
235+
await waitFor(() => {
236+
expect(result.current.messages.length).toBeGreaterThan(0)
237+
})
238+
// The return object doesn't expose partial/final at the type level — and
239+
// the runtime branch in onChunk is gated on `outputSchema !== undefined`
240+
// so the internal state never updates. (Runtime access is the only way
241+
// to verify the no-op branch.)
242+
expect(
243+
(result.current as unknown as { partial?: unknown }).partial,
244+
).toEqual({})
245+
expect(
246+
(result.current as unknown as { final?: unknown }).final,
247+
).toBeNull()
248+
})
249+
})

0 commit comments

Comments
 (0)