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 [ ] > ( [
@@ -117,7 +117,7 @@ describe('createPartsBatcher', () => {
117117
118118 queryClient . setQueryData (
119119 [ 'opencode' , 'messages' , 'http://localhost:5551' , 'session-1' , '/repo' ] ,
120- [ { ...assistantMessage ( 'session-1' , 'message-1' ) , parts : [ textPart ( 'session-1' , 'message-1' , 'part-1' , 'fresh' ) ] } ] ,
120+ [ { ...assistantMessage ( 'session-1' , 'message-1' ) , parts : [ createTextPart ( 'session-1' , 'message-1' , 'part-1' , 'fresh' ) ] } ] ,
121121 )
122122
123123 batcher . flush ( )
@@ -165,6 +165,38 @@ describe('createPartsBatcher', () => {
165165 } )
166166 } )
167167
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' ) ] ,
177+ )
178+
179+ batcher . queuePartDelta ( 'session-1' , 'message-missing' , 'part-1' , 'text' , 'streamed' , '/repo' )
180+ 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 )
191+
192+ const data = queryClient . getQueryData < MessageWithParts [ ] > ( [
193+ 'opencode' , 'messages' , 'http://localhost:5551' , 'session-1' , '/repo' ,
194+ ] )
195+ expect ( data ) . toHaveLength ( 1 )
196+ expect ( data ! [ 0 ] . info . id ) . toBe ( 'message-old' )
197+ expect ( data ! [ 0 ] . parts ) . toHaveLength ( 0 )
198+ } )
199+
168200 it ( 'applies deltas queued after an authoritative upsert in the same batch' , ( ) => {
169201 const queryClient = new QueryClient ( )
170202 const batcher = createPartsBatcher ( queryClient , 'http://localhost:5551' )
@@ -174,7 +206,7 @@ describe('createPartsBatcher', () => {
174206 [ assistantMessage ( 'session-1' , 'message-1' ) ] ,
175207 )
176208
177- 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' )
178210 batcher . queuePartDelta ( 'session-1' , 'message-1' , 'part-1' , 'text' , ' later' , '/repo' )
179211 batcher . flush ( )
180212
@@ -239,8 +271,8 @@ describe('createPartsBatcher', () => {
239271 [ {
240272 ...assistantMessage ( 'session-1' , 'message-1' ) ,
241273 parts : [
242- textPart ( 'session-1' , 'message-1' , 'part-1' , 'first' ) ,
243- 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' ) ,
244276 ] ,
245277 } ] ,
246278 )
@@ -264,11 +296,11 @@ describe('createPartsBatcher', () => {
264296
265297 queryClient . setQueryData (
266298 [ 'opencode' , 'messages' , 'http://localhost:5551' , 'session-a' , '/repo-a' ] ,
267- [ { ...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' ) ] } ] ,
268300 )
269301 queryClient . setQueryData (
270302 [ 'opencode' , 'messages' , 'http://localhost:5551' , 'session-b' , '/repo-b' ] ,
271- [ { ...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' ) ] } ] ,
272304 )
273305
274306 batcher . queuePartDelta ( 'session-a' , 'msg-1' , 'part-1' , 'text' , ' delta A' , '/repo-a' )
@@ -301,7 +333,7 @@ describe('createPartsBatcher', () => {
301333
302334 queryClient . setQueryData (
303335 [ 'opencode' , 'messages' , 'http://localhost:5551' , 'session-1' , '/repo' ] ,
304- [ { ...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' ) ] } ] ,
305337 )
306338
307339 batcher . queuePartDelta ( 'session-1' , 'msg-1' , 'part-1' , 'text' , ' updated' , '/repo' )
@@ -323,7 +355,7 @@ describe('createPartsBatcher', () => {
323355 )
324356 queryClient . setQueryData (
325357 [ 'opencode' , 'messages' , 'http://localhost:5551' , 'session-1' , '/repo-b' ] ,
326- [ { ...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' ) ] } ] ,
327359 )
328360
329361 batcher . queuePartDelta ( 'session-1' , 'message-1' , 'part-1' , 'text' , ' + chunk' , '/repo-b' )
0 commit comments