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