@@ -4,28 +4,39 @@ import { describe, it, expect } from 'vitest';
44import type { TextStreamPart , ToolSet } from '@objectstack/spec/contracts' ;
55import { encodeStreamPart , encodeVercelDataStream } from '../stream/vercel-stream-encoder.js' ;
66
7+ // Helper to parse SSE frame payload
8+ function parseSSE ( frame : string ) : Record < string , unknown > | null {
9+ if ( ! frame . startsWith ( 'data: ' ) || ! frame . endsWith ( '\n\n' ) ) return null ;
10+ const json = frame . slice ( 6 , - 2 ) ;
11+ if ( json === '[DONE]' ) return null ;
12+ return JSON . parse ( json ) ;
13+ }
14+
715// ─────────────────────────────────────────────────────────────────
8- // encodeStreamPart — individual frame encoding
16+ // encodeStreamPart — individual frame encoding (v6 SSE format)
917// ─────────────────────────────────────────────────────────────────
1018
1119describe ( 'encodeStreamPart' , ( ) => {
12- it ( 'should encode text-delta as "0:" frame' , ( ) => {
20+ it ( 'should encode text-delta as SSE frame' , ( ) => {
1321 const part = { type : 'text-delta' , text : 'Hello world' } as TextStreamPart < ToolSet > ;
14- expect ( encodeStreamPart ( part ) ) . toBe ( '0:"Hello world"\n' ) ;
22+ const frame = encodeStreamPart ( part ) ;
23+ const payload = parseSSE ( frame ) ;
24+ expect ( payload ) . toEqual ( { type : 'text-delta' , id : '0' , delta : 'Hello world' } ) ;
1525 } ) ;
1626
1727 it ( 'should JSON-escape text-delta content' , ( ) => {
1828 const part = { type : 'text-delta' , text : 'say "hi"\nnewline' } as TextStreamPart < ToolSet > ;
1929 const frame = encodeStreamPart ( part ) ;
20- expect ( frame ) . toBe ( `0: ${ JSON . stringify ( 'say "hi"\nnewline' ) } \n` ) ;
21- expect ( frame . startsWith ( '0: ') ) . toBe ( true ) ;
30+ expect ( frame . startsWith ( 'data: ' ) ) . toBe ( true ) ;
31+ expect ( frame . endsWith ( '\n\n ') ) . toBe ( true ) ;
2232
2333 // Verify round-trip: decode the frame payload back to the original text
24- const decoded = JSON . parse ( frame . slice ( 2 ) . trim ( ) ) ;
25- expect ( decoded ) . toBe ( 'say "hi"\nnewline' ) ;
34+ const payload = parseSSE ( frame ) ;
35+ expect ( payload ) . not . toBeNull ( ) ;
36+ expect ( ( payload as Record < string , unknown > ) . delta ) . toBe ( 'say "hi"\nnewline' ) ;
2637 } ) ;
2738
28- it ( 'should encode tool-call as "9:" frame' , ( ) => {
39+ it ( 'should encode tool-call as tool-input-available SSE frame' , ( ) => {
2940 const part = {
3041 type : 'tool-call' ,
3142 toolCallId : 'call_1' ,
@@ -34,51 +45,48 @@ describe('encodeStreamPart', () => {
3445 } as TextStreamPart < ToolSet > ;
3546
3647 const frame = encodeStreamPart ( part ) ;
37- expect ( frame . startsWith ( '9:' ) ) . toBe ( true ) ;
38-
39- const payload = JSON . parse ( frame . slice ( 2 ) ) ;
48+ const payload = parseSSE ( frame ) ;
4049 expect ( payload ) . toEqual ( {
50+ type : 'tool-input-available' ,
4151 toolCallId : 'call_1' ,
4252 toolName : 'get_weather' ,
43- args : { location : 'San Francisco' } ,
53+ input : { location : 'San Francisco' } ,
4454 } ) ;
4555 } ) ;
4656
47- it ( 'should encode tool-input-start as "b:" frame' , ( ) => {
57+ it ( 'should encode tool-input-start as SSE frame' , ( ) => {
4858 const part = {
4959 type : 'tool-input-start' ,
5060 id : 'call_2' ,
5161 toolName : 'search' ,
5262 } as TextStreamPart < ToolSet > ;
5363
5464 const frame = encodeStreamPart ( part ) ;
55- expect ( frame . startsWith ( 'b:' ) ) . toBe ( true ) ;
56-
57- const payload = JSON . parse ( frame . slice ( 2 ) ) ;
65+ const payload = parseSSE ( frame ) ;
5866 expect ( payload ) . toEqual ( {
67+ type : 'tool-input-start' ,
5968 toolCallId : 'call_2' ,
6069 toolName : 'search' ,
6170 } ) ;
6271 } ) ;
6372
64- it ( 'should encode tool-input-delta as "c:" frame' , ( ) => {
73+ it ( 'should encode tool-input-delta as SSE frame' , ( ) => {
6574 const part = {
6675 type : 'tool-input-delta' ,
6776 id : 'call_2' ,
6877 delta : '{"query":' ,
6978 } as TextStreamPart < ToolSet > ;
7079
7180 const frame = encodeStreamPart ( part ) ;
72- expect ( frame . startsWith ( 'c:' ) ) . toBe ( true ) ;
73-
74- const payload = JSON . parse ( frame . slice ( 2 ) ) ;
81+ const payload = parseSSE ( frame ) ;
7582 expect ( payload ) . toEqual ( {
83+ type : 'tool-input-delta' ,
7684 toolCallId : 'call_2' ,
77- argsTextDelta : '{"query":' ,
85+ inputTextDelta : '{"query":' ,
7886 } ) ;
7987 } ) ;
8088
81- it ( 'should encode tool-result as "a:" frame' , ( ) => {
89+ it ( 'should encode tool-result as tool-output-available SSE frame' , ( ) => {
8290 const part = {
8391 type : 'tool-result' ,
8492 toolCallId : 'call_1' ,
@@ -87,43 +95,33 @@ describe('encodeStreamPart', () => {
8795 } as TextStreamPart < ToolSet > ;
8896
8997 const frame = encodeStreamPart ( part ) ;
90- expect ( frame . startsWith ( 'a:' ) ) . toBe ( true ) ;
91-
92- const payload = JSON . parse ( frame . slice ( 2 ) ) ;
98+ const payload = parseSSE ( frame ) ;
9399 expect ( payload ) . toEqual ( {
100+ type : 'tool-output-available' ,
94101 toolCallId : 'call_1' ,
95- result : { temperature : 72 } ,
102+ output : { temperature : 72 } ,
96103 } ) ;
97104 } ) ;
98105
99- it ( 'should encode finish as "d:" frame ' , ( ) => {
106+ it ( 'should return empty string for finish (handled by generator) ' , ( ) => {
100107 const part = {
101108 type : 'finish' ,
102109 finishReason : 'stop' ,
103110 totalUsage : { promptTokens : 10 , completionTokens : 20 , totalTokens : 30 } ,
104111 rawFinishReason : 'stop' ,
105112 } as unknown as TextStreamPart < ToolSet > ;
106113
107- const frame = encodeStreamPart ( part ) ;
108- expect ( frame . startsWith ( 'd:' ) ) . toBe ( true ) ;
109-
110- const payload = JSON . parse ( frame . slice ( 2 ) ) ;
111- expect ( payload . finishReason ) . toBe ( 'stop' ) ;
112- expect ( payload . usage ) . toEqual ( { promptTokens : 10 , completionTokens : 20 , totalTokens : 30 } ) ;
114+ expect ( encodeStreamPart ( part ) ) . toBe ( '' ) ;
113115 } ) ;
114116
115- it ( 'should encode finish-step as "e:" frame ' , ( ) => {
117+ it ( 'should return empty string for finish-step (handled by generator) ' , ( ) => {
116118 const part = {
117119 type : 'finish-step' ,
118120 finishReason : 'tool-calls' ,
119121 usage : { promptTokens : 5 , completionTokens : 10 , totalTokens : 15 } ,
120122 } as unknown as TextStreamPart < ToolSet > ;
121123
122- const frame = encodeStreamPart ( part ) ;
123- expect ( frame . startsWith ( 'e:' ) ) . toBe ( true ) ;
124-
125- const payload = JSON . parse ( frame . slice ( 2 ) ) ;
126- expect ( payload . finishReason ) . toBe ( 'tool-calls' ) ;
124+ expect ( encodeStreamPart ( part ) ) . toBe ( '' ) ;
127125 } ) ;
128126
129127 it ( 'should return empty string for unknown event types' , ( ) => {
@@ -133,11 +131,13 @@ describe('encodeStreamPart', () => {
133131} ) ;
134132
135133// ─────────────────────────────────────────────────────────────────
136- // encodeVercelDataStream — async iterable transformation
134+ // encodeVercelDataStream — async iterable transformation (v6 SSE)
135+ //
136+ // Lifecycle: start → start-step → text-start → ...events... → text-end → finish-step → finish → [DONE]
137137// ─────────────────────────────────────────────────────────────────
138138
139139describe ( 'encodeVercelDataStream' , ( ) => {
140- it ( 'should transform stream events into Vercel Data Stream frames' , async ( ) => {
140+ it ( 'should transform stream events into v6 UI Message Stream frames' , async ( ) => {
141141 async function * source ( ) : AsyncIterable < TextStreamPart < ToolSet > > {
142142 yield { type : 'text-delta' , text : 'Hello' } as TextStreamPart < ToolSet > ;
143143 yield { type : 'text-delta' , text : ' world' } as TextStreamPart < ToolSet > ;
@@ -154,10 +154,25 @@ describe('encodeVercelDataStream', () => {
154154 frames . push ( frame ) ;
155155 }
156156
157- expect ( frames ) . toHaveLength ( 3 ) ;
158- expect ( frames [ 0 ] ) . toBe ( '0:"Hello"\n' ) ;
159- expect ( frames [ 1 ] ) . toBe ( '0:" world"\n' ) ;
160- expect ( frames [ 2 ] ) . toMatch ( / ^ d : / ) ;
157+ // Preamble: start, start-step, text-start
158+ // Content: 2 text-deltas
159+ // Postamble: text-end, finish-step, finish, [DONE]
160+ expect ( frames ) . toHaveLength ( 9 ) ;
161+
162+ // Preamble
163+ expect ( parseSSE ( frames [ 0 ] ) ) . toEqual ( { type : 'start' } ) ;
164+ expect ( parseSSE ( frames [ 1 ] ) ) . toEqual ( { type : 'start-step' } ) ;
165+ expect ( parseSSE ( frames [ 2 ] ) ) . toEqual ( { type : 'text-start' , id : '0' } ) ;
166+
167+ // Content
168+ expect ( parseSSE ( frames [ 3 ] ) ) . toMatchObject ( { type : 'text-delta' , delta : 'Hello' } ) ;
169+ expect ( parseSSE ( frames [ 4 ] ) ) . toMatchObject ( { type : 'text-delta' , delta : ' world' } ) ;
170+
171+ // Postamble
172+ expect ( parseSSE ( frames [ 5 ] ) ) . toEqual ( { type : 'text-end' , id : '0' } ) ;
173+ expect ( parseSSE ( frames [ 6 ] ) ) . toEqual ( { type : 'finish-step' } ) ;
174+ expect ( parseSSE ( frames [ 7 ] ) ) . toMatchObject ( { type : 'finish' , finishReason : 'stop' } ) ;
175+ expect ( frames [ 8 ] ) . toBe ( 'data: [DONE]\n\n' ) ;
161176 } ) ;
162177
163178 it ( 'should skip events with no wire format mapping' , async ( ) => {
@@ -177,10 +192,9 @@ describe('encodeVercelDataStream', () => {
177192 frames . push ( frame ) ;
178193 }
179194
180- // 'unknown-internal' is silently dropped
181- expect ( frames ) . toHaveLength ( 2 ) ;
182- expect ( frames [ 0 ] ) . toBe ( '0:"Hi"\n' ) ;
183- expect ( frames [ 1 ] ) . toMatch ( / ^ d : / ) ;
195+ // Preamble(3) + 1 text-delta + Postamble(4) = 8 ('unknown-internal' dropped)
196+ expect ( frames ) . toHaveLength ( 8 ) ;
197+ expect ( parseSSE ( frames [ 3 ] ) ) . toMatchObject ( { type : 'text-delta' , delta : 'Hi' } ) ;
184198 } ) ;
185199
186200 it ( 'should handle empty stream' , async ( ) => {
@@ -193,7 +207,11 @@ describe('encodeVercelDataStream', () => {
193207 frames . push ( frame ) ;
194208 }
195209
196- expect ( frames ) . toHaveLength ( 0 ) ;
210+ // Preamble(3) + text-end + finish-step + finish + [DONE] = 7
211+ expect ( frames ) . toHaveLength ( 7 ) ;
212+ expect ( parseSSE ( frames [ 0 ] ) ) . toEqual ( { type : 'start' } ) ;
213+ expect ( parseSSE ( frames [ 3 ] ) ) . toEqual ( { type : 'text-end' , id : '0' } ) ;
214+ expect ( frames [ 6 ] ) . toBe ( 'data: [DONE]\n\n' ) ;
197215 } ) ;
198216
199217 it ( 'should handle tool-call events in stream' , async ( ) => {
@@ -223,14 +241,23 @@ describe('encodeVercelDataStream', () => {
223241 frames . push ( frame ) ;
224242 }
225243
226- expect ( frames ) . toHaveLength ( 3 ) ;
227- expect ( frames [ 0 ] ) . toMatch ( / ^ 9 : / ) ;
228- expect ( frames [ 1 ] ) . toMatch ( / ^ a : / ) ;
229- expect ( frames [ 2 ] ) . toMatch ( / ^ d : / ) ;
244+ // Preamble(3) + tool-input-available + tool-output-available + Postamble(4) = 9
245+ expect ( frames ) . toHaveLength ( 9 ) ;
230246
231247 // Verify tool-call frame content
232- const toolCallPayload = JSON . parse ( frames [ 0 ] . slice ( 2 ) ) ;
233- expect ( toolCallPayload . toolCallId ) . toBe ( 'call_1' ) ;
234- expect ( toolCallPayload . args ) . toEqual ( { query : 'test' } ) ;
248+ const toolCallPayload = parseSSE ( frames [ 3 ] ) ;
249+ expect ( toolCallPayload ) . toMatchObject ( {
250+ type : 'tool-input-available' ,
251+ toolCallId : 'call_1' ,
252+ toolName : 'search' ,
253+ input : { query : 'test' } ,
254+ } ) ;
255+
256+ const toolResultPayload = parseSSE ( frames [ 4 ] ) ;
257+ expect ( toolResultPayload ) . toMatchObject ( {
258+ type : 'tool-output-available' ,
259+ toolCallId : 'call_1' ,
260+ output : { hits : 42 } ,
261+ } ) ;
235262 } ) ;
236263} ) ;
0 commit comments