Skip to content

Commit d6bc1d7

Browse files
fix(parts-batcher): defer operations when target message is missing from cache (#262)
* fix(parts-batcher): defer operations when target message is missing from cache * fix(parts-batcher): add TTL for deferred operations to prevent repeated invalidations
1 parent 5dc95dc commit d6bc1d7

3 files changed

Lines changed: 192 additions & 49 deletions

File tree

frontend/src/hooks/useSSE.test.tsx

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import type { ReactNode } from 'react'
44
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
55
import { useSSE } from './useSSE'
66
import { useSessionStatus } from '../stores/sessionStatusStore'
7-
import type { Part, MessageWithParts } from '@/api/types'
7+
import type { MessageWithParts } from '@/api/types'
8+
import { createTextPart } from '@/lib/partsBatcher'
89

910
const mocks = vi.hoisted(() => ({
1011
getSessionStatuses: vi.fn(),
@@ -293,14 +294,14 @@ describe('useSSE', () => {
293294
['opencode', 'messages', 'http://localhost:5551', 'session-1', '/repo-a'],
294295
[{
295296
...assistantMessage('session-1', 'message-1'),
296-
parts: [textPart('session-1', 'message-1', 'part-1', 'A')],
297+
parts: [createTextPart('session-1', 'message-1', 'part-1', 'A')],
297298
}],
298299
)
299300
queryClient.setQueryData(
300301
['opencode', 'messages', 'http://localhost:5551', 'session-1', '/repo-b'],
301302
[{
302303
...assistantMessage('session-1', 'message-1'),
303-
parts: [textPart('session-1', 'message-1', 'part-1', 'B')],
304+
parts: [createTextPart('session-1', 'message-1', 'part-1', 'B')],
304305
}],
305306
)
306307

@@ -377,7 +378,7 @@ describe('useSSE', () => {
377378
['opencode', 'messages', 'http://localhost:5551', 'session-1', '/repo'],
378379
[{
379380
...assistantMessage('session-1', 'message-1'),
380-
parts: [textPart('session-1', 'message-1', 'part-1', '')],
381+
parts: [createTextPart('session-1', 'message-1', 'part-1', '')],
381382
}],
382383
)
383384

@@ -450,6 +451,3 @@ function assistantMessage(sessionID: string, messageID: string): MessageWithPart
450451
}
451452
}
452453

453-
function textPart(sessionID: string, messageID: string, partID: string, text: string): Part {
454-
return { id: partID, sessionID, messageID, type: 'text', text } as Part
455-
}

frontend/src/lib/partsBatcher.test.ts

Lines changed: 89 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { 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

66
function 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-
3026
function 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

4036
describe('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

Comments
 (0)