11import { QueryClient } from '@tanstack/react-query'
2- import { describe , it , expect , vi } from 'vitest'
3- import { createPartsBatcher } from './partsBatcher'
4- import type { Part , MessageWithParts } from '@/api/types'
2+ import { describe , it , expect , vi , afterEach } from 'vitest'
3+ import { createPartsBatcher , createTextPart , DEFERRED_OPERATION_TTL_MS } from './partsBatcher'
4+ import type { MessageWithParts } from '@/api/types'
55
66function assistantMessage ( sessionID : string , messageID : string ) : MessageWithParts {
77 return {
@@ -23,21 +23,21 @@ function assistantMessage(sessionID: string, messageID: string): MessageWithPart
2323 }
2424}
2525
26- function textPart ( sessionID : string , messageID : string , partID : string , text : string ) : Part {
27- return { id : partID , sessionID, messageID, type : 'text' , text } as Part
28- }
29-
3026function createManyCachedMessages ( count : number , sessionID : string ) : MessageWithParts [ ] {
3127 const messages : MessageWithParts [ ] = [ ]
3228 for ( let i = 0 ; i < count ; i ++ ) {
3329 const msg = assistantMessage ( sessionID , `msg-${ i } ` )
34- msg . parts = [ textPart ( sessionID , `msg-${ i } ` , `part-${ i } ` , `base text ${ i } ` ) ]
30+ msg . parts = [ createTextPart ( sessionID , `msg-${ i } ` , `part-${ i } ` , `base text ${ i } ` ) ]
3531 messages . push ( msg )
3632 }
3733 return messages
3834}
3935
4036describe ( 'createPartsBatcher' , ( ) => {
37+ afterEach ( ( ) => {
38+ vi . useRealTimers ( )
39+ } )
40+
4141 it ( 'invalidates when part deltas arrive before message cache exists and applies a later authoritative upsert' , ( ) => {
4242 const queryClient = new QueryClient ( )
4343 const invalidateSpy = vi . spyOn ( queryClient , 'invalidateQueries' )
@@ -59,7 +59,7 @@ describe('createPartsBatcher', () => {
5959 [ assistantMessage ( 'session-1' , 'message-1' ) ] ,
6060 )
6161
62- batcher . queuePartUpdate ( 'session-1' , textPart ( 'session-1' , 'message-1' , 'part-1' , 'Hello world' ) , '/repo' )
62+ batcher . queuePartUpdate ( 'session-1' , createTextPart ( 'session-1' , 'message-1' , 'part-1' , 'Hello world' ) , '/repo' )
6363 batcher . flush ( )
6464
6565 const data = queryClient . getQueryData < MessageWithParts [ ] > ( [
@@ -82,7 +82,7 @@ describe('createPartsBatcher', () => {
8282 [ assistantMessage ( 'session-1' , 'message-1' ) ] ,
8383 )
8484
85- batcher . queuePartUpdate ( 'session-1' , textPart ( 'session-1' , 'message-1' , 'part-1' , 'authoritative text' ) , '/repo' )
85+ batcher . queuePartUpdate ( 'session-1' , createTextPart ( 'session-1' , 'message-1' , 'part-1' , 'authoritative text' ) , '/repo' )
8686 batcher . flush ( )
8787
8888 const data = queryClient . getQueryData < MessageWithParts [ ] > ( [
@@ -100,7 +100,6 @@ describe('createPartsBatcher', () => {
100100
101101 it ( 'does not replay unapplied deltas onto refetched authoritative data' , ( ) => {
102102 const queryClient = new QueryClient ( )
103- const invalidateSpy = vi . spyOn ( queryClient , 'invalidateQueries' )
104103 const batcher = createPartsBatcher ( queryClient , 'http://localhost:5551' )
105104
106105 queryClient . setQueryData (
@@ -111,21 +110,91 @@ describe('createPartsBatcher', () => {
111110 batcher . queuePartDelta ( 'session-1' , 'message-1' , 'part-1' , 'text' , ' stale' , '/repo' )
112111 batcher . flush ( )
113112
113+ let data = queryClient . getQueryData < MessageWithParts [ ] > ( [
114+ 'opencode' , 'messages' , 'http://localhost:5551' , 'session-1' , '/repo' ,
115+ ] )
116+ expect ( data ! [ 0 ] . parts [ 0 ] ) . toHaveProperty ( 'text' , ' stale' )
117+
118+ queryClient . setQueryData (
119+ [ 'opencode' , 'messages' , 'http://localhost:5551' , 'session-1' , '/repo' ] ,
120+ [ { ...assistantMessage ( 'session-1' , 'message-1' ) , parts : [ createTextPart ( 'session-1' , 'message-1' , 'part-1' , 'fresh' ) ] } ] ,
121+ )
122+
123+ batcher . flush ( )
124+
125+ data = queryClient . getQueryData < MessageWithParts [ ] > ( [
126+ 'opencode' , 'messages' , 'http://localhost:5551' , 'session-1' , '/repo' ,
127+ ] )
128+ expect ( data ! [ 0 ] . parts [ 0 ] ) . toHaveProperty ( 'text' , 'fresh' )
129+ } )
130+
131+ it ( 'keeps text deltas pending until a later message update creates the assistant message' , ( ) => {
132+ const queryClient = new QueryClient ( )
133+ const invalidateSpy = vi . spyOn ( queryClient , 'invalidateQueries' )
134+ const batcher = createPartsBatcher ( queryClient , 'http://localhost:5551' )
135+
136+ queryClient . setQueryData (
137+ [ 'opencode' , 'messages' , 'http://localhost:5551' , 'session-1' , '/repo' ] ,
138+ [ assistantMessage ( 'session-1' , 'message-old' ) ] ,
139+ )
140+
141+ batcher . queuePartDelta ( 'session-1' , 'message-new' , 'part-1' , 'text' , 'streamed' , '/repo' )
142+ batcher . flush ( )
143+
114144 expect ( invalidateSpy ) . toHaveBeenCalledWith ( {
115145 queryKey : [ 'opencode' , 'messages' , 'http://localhost:5551' , 'session-1' , '/repo' ] ,
116146 } )
117147
118148 queryClient . setQueryData (
119149 [ 'opencode' , 'messages' , 'http://localhost:5551' , 'session-1' , '/repo' ] ,
120- [ { ...assistantMessage ( 'session-1' , 'message-1' ) , parts : [ textPart ( 'session-1' , 'message-1' , 'part-1' , 'fresh' ) ] } ] ,
150+ [ assistantMessage ( 'session-1' , 'message-old' ) , assistantMessage ( 'session-1' , 'message-new' ) ] ,
151+ )
152+
153+ batcher . flush ( { sessionID : 'session-1' , directory : '/repo' } )
154+
155+ const data = queryClient . getQueryData < MessageWithParts [ ] > ( [
156+ 'opencode' , 'messages' , 'http://localhost:5551' , 'session-1' , '/repo' ,
157+ ] )
158+ expect ( data ! [ 1 ] . parts ) . toHaveLength ( 1 )
159+ expect ( data ! [ 1 ] . parts [ 0 ] ) . toMatchObject ( {
160+ id : 'part-1' ,
161+ sessionID : 'session-1' ,
162+ messageID : 'message-new' ,
163+ type : 'text' ,
164+ text : 'streamed' ,
165+ } )
166+ } )
167+
168+ it ( 'invalidates once while deferring, then drops the operation after the TTL elapses for a never-arriving message' , ( ) => {
169+ vi . useFakeTimers ( )
170+ const queryClient = new QueryClient ( )
171+ const invalidateSpy = vi . spyOn ( queryClient , 'invalidateQueries' )
172+ const batcher = createPartsBatcher ( queryClient , 'http://localhost:5551' )
173+
174+ queryClient . setQueryData (
175+ [ 'opencode' , 'messages' , 'http://localhost:5551' , 'session-1' , '/repo' ] ,
176+ [ assistantMessage ( 'session-1' , 'message-old' ) ] ,
121177 )
122178
179+ batcher . queuePartDelta ( 'session-1' , 'message-missing' , 'part-1' , 'text' , 'streamed' , '/repo' )
123180 batcher . flush ( )
181+ expect ( invalidateSpy ) . toHaveBeenCalledTimes ( 1 )
182+
183+ batcher . flush ( )
184+ batcher . flush ( )
185+ expect ( invalidateSpy ) . toHaveBeenCalledTimes ( 1 )
186+
187+ vi . advanceTimersByTime ( DEFERRED_OPERATION_TTL_MS + 1 )
188+ batcher . flush ( )
189+ batcher . flush ( )
190+ expect ( invalidateSpy ) . toHaveBeenCalledTimes ( 1 )
124191
125192 const data = queryClient . getQueryData < MessageWithParts [ ] > ( [
126193 'opencode' , 'messages' , 'http://localhost:5551' , 'session-1' , '/repo' ,
127194 ] )
128- expect ( data ! [ 0 ] . parts [ 0 ] ) . toHaveProperty ( 'text' , 'fresh' )
195+ expect ( data ) . toHaveLength ( 1 )
196+ expect ( data ! [ 0 ] . info . id ) . toBe ( 'message-old' )
197+ expect ( data ! [ 0 ] . parts ) . toHaveLength ( 0 )
129198 } )
130199
131200 it ( 'applies deltas queued after an authoritative upsert in the same batch' , ( ) => {
@@ -137,7 +206,7 @@ describe('createPartsBatcher', () => {
137206 [ assistantMessage ( 'session-1' , 'message-1' ) ] ,
138207 )
139208
140- batcher . queuePartUpdate ( 'session-1' , textPart ( 'session-1' , 'message-1' , 'part-1' , 'snapshot' ) , '/repo' )
209+ batcher . queuePartUpdate ( 'session-1' , createTextPart ( 'session-1' , 'message-1' , 'part-1' , 'snapshot' ) , '/repo' )
141210 batcher . queuePartDelta ( 'session-1' , 'message-1' , 'part-1' , 'text' , ' later' , '/repo' )
142211 batcher . flush ( )
143212
@@ -202,8 +271,8 @@ describe('createPartsBatcher', () => {
202271 [ {
203272 ...assistantMessage ( 'session-1' , 'message-1' ) ,
204273 parts : [
205- textPart ( 'session-1' , 'message-1' , 'part-1' , 'first' ) ,
206- textPart ( 'session-1' , 'message-1' , 'part-2' , 'second' ) ,
274+ createTextPart ( 'session-1' , 'message-1' , 'part-1' , 'first' ) ,
275+ createTextPart ( 'session-1' , 'message-1' , 'part-2' , 'second' ) ,
207276 ] ,
208277 } ] ,
209278 )
@@ -227,11 +296,11 @@ describe('createPartsBatcher', () => {
227296
228297 queryClient . setQueryData (
229298 [ 'opencode' , 'messages' , 'http://localhost:5551' , 'session-a' , '/repo-a' ] ,
230- [ { ...assistantMessage ( 'session-a' , 'msg-1' ) , parts : [ textPart ( 'session-a' , 'msg-1' , 'part-1' , 'A1' ) ] } ] ,
299+ [ { ...assistantMessage ( 'session-a' , 'msg-1' ) , parts : [ createTextPart ( 'session-a' , 'msg-1' , 'part-1' , 'A1' ) ] } ] ,
231300 )
232301 queryClient . setQueryData (
233302 [ 'opencode' , 'messages' , 'http://localhost:5551' , 'session-b' , '/repo-b' ] ,
234- [ { ...assistantMessage ( 'session-b' , 'msg-2' ) , parts : [ textPart ( 'session-b' , 'msg-2' , 'part-2' , 'B1' ) ] } ] ,
303+ [ { ...assistantMessage ( 'session-b' , 'msg-2' ) , parts : [ createTextPart ( 'session-b' , 'msg-2' , 'part-2' , 'B1' ) ] } ] ,
235304 )
236305
237306 batcher . queuePartDelta ( 'session-a' , 'msg-1' , 'part-1' , 'text' , ' delta A' , '/repo-a' )
@@ -264,7 +333,7 @@ describe('createPartsBatcher', () => {
264333
265334 queryClient . setQueryData (
266335 [ 'opencode' , 'messages' , 'http://localhost:5551' , 'session-1' , '/repo' ] ,
267- [ { ...assistantMessage ( 'session-1' , 'msg-1' ) , parts : [ textPart ( 'session-1' , 'msg-1' , 'part-1' , 'text' ) ] } ] ,
336+ [ { ...assistantMessage ( 'session-1' , 'msg-1' ) , parts : [ createTextPart ( 'session-1' , 'msg-1' , 'part-1' , 'text' ) ] } ] ,
268337 )
269338
270339 batcher . queuePartDelta ( 'session-1' , 'msg-1' , 'part-1' , 'text' , ' updated' , '/repo' )
@@ -286,7 +355,7 @@ describe('createPartsBatcher', () => {
286355 )
287356 queryClient . setQueryData (
288357 [ 'opencode' , 'messages' , 'http://localhost:5551' , 'session-1' , '/repo-b' ] ,
289- [ { ...assistantMessage ( 'session-1' , 'message-1' ) , parts : [ textPart ( 'session-1' , 'message-1' , 'part-1' , 'B' ) ] } ] ,
358+ [ { ...assistantMessage ( 'session-1' , 'message-1' ) , parts : [ createTextPart ( 'session-1' , 'message-1' , 'part-1' , 'B' ) ] } ] ,
290359 )
291360
292361 batcher . queuePartDelta ( 'session-1' , 'message-1' , 'part-1' , 'text' , ' + chunk' , '/repo-b' )
0 commit comments