Skip to content

Commit 662ecb9

Browse files
committed
fix(mship): redis replay
1 parent 225c6b8 commit 662ecb9

4 files changed

Lines changed: 114 additions & 214 deletions

File tree

apps/sim/app/api/mothership/chats/[chatId]/route.test.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ describe('GET /api/mothership/chats/[chatId]', () => {
173173
expect(mockReadEvents).not.toHaveBeenCalled()
174174
})
175175

176-
it('returns the live activeStreamId when redis confirms the lock', async () => {
176+
it('returns the live activeStreamId without shipping the event snapshot', async () => {
177177
mockGetAccessibleCopilotChat.mockResolvedValueOnce({
178178
id: 'chat-live',
179179
type: 'mothership',
@@ -191,9 +191,11 @@ describe('GET /api/mothership/chats/[chatId]', () => {
191191
const body = await response.json()
192192

193193
expect(body.chat.activeStreamId).toBe('stream-live')
194+
// Events are read only to synthesize the in-flight assistant turn for the
195+
// initial paint; the client reconnects to the replay buffer for the rest.
194196
expect(mockReadEvents).toHaveBeenCalledWith('stream-live', '0')
195-
expect(body.chat.streamSnapshot).toBeDefined()
196-
expect(body.chat.streamSnapshot.status).toBe('active')
197+
expect(body.chat.streamSnapshot).toBeUndefined()
198+
expect(mockReadFilePreviewSessions).not.toHaveBeenCalled()
197199
})
198200

199201
it('uses the Redis lock owner when it differs from a stale persisted streamId', async () => {

apps/sim/app/api/mothership/chats/[chatId]/route.ts

Lines changed: 10 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ import {
2626
} from '@/lib/copilot/request/http'
2727
import type { FilePreviewSession } from '@/lib/copilot/request/session'
2828
import { readEvents } from '@/lib/copilot/request/session/buffer'
29-
import { readFilePreviewSessions } from '@/lib/copilot/request/session/file-preview-session'
3029
import { type StreamBatchEvent, toStreamBatchEvent } from '@/lib/copilot/request/session/types'
3130
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
3231
import { captureServerEvent } from '@/lib/posthog/server'
@@ -50,7 +49,12 @@ export const GET = withRouteHandler(
5049
return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 })
5150
}
5251

53-
let streamSnapshot: {
52+
// The Redis replay buffer is read here only to synthesize the in-flight
53+
// assistant turn for the initial paint. The raw events are NOT shipped
54+
// to the client: when `activeStreamId` is set, the client reconnects to
55+
// the replay buffer (from seq 0) via the stream resume endpoint, which
56+
// is the source of truth for streaming state.
57+
let liveTurnSnapshot: {
5458
events: StreamBatchEvent[]
5559
previewSessions: FilePreviewSession[]
5660
status: string
@@ -64,17 +68,7 @@ export const GET = withRouteHandler(
6468

6569
if (liveStreamId) {
6670
try {
67-
const [events, previewSessions] = await Promise.all([
68-
readEvents(liveStreamId, '0'),
69-
readFilePreviewSessions(liveStreamId).catch((error) => {
70-
logger.warn('Failed to read preview sessions for mothership chat', {
71-
chatId,
72-
streamId: liveStreamId,
73-
error: toError(error).message,
74-
})
75-
return []
76-
}),
77-
])
71+
const events = await readEvents(liveStreamId, '0')
7872
const run = await getLatestRunForStream(liveStreamId, userId).catch((error) => {
7973
logger.warn('Failed to fetch latest run for mothership chat snapshot', {
8074
chatId,
@@ -84,9 +78,9 @@ export const GET = withRouteHandler(
8478
return null
8579
})
8680

87-
streamSnapshot = {
81+
liveTurnSnapshot = {
8882
events: events.map(toStreamBatchEvent),
89-
previewSessions,
83+
previewSessions: [],
9084
status:
9185
typeof run?.status === 'string'
9286
? run.status
@@ -111,7 +105,7 @@ export const GET = withRouteHandler(
111105
const effectiveMessages = buildEffectiveChatTranscript({
112106
messages: normalizedMessages,
113107
activeStreamId: liveStreamId,
114-
...(streamSnapshot ? { streamSnapshot } : {}),
108+
...(liveTurnSnapshot ? { streamSnapshot: liveTurnSnapshot } : {}),
115109
})
116110

117111
return NextResponse.json({
@@ -124,7 +118,6 @@ export const GET = withRouteHandler(
124118
resources: Array.isArray(chat.resources) ? chat.resources : [],
125119
createdAt: chat.createdAt,
126120
updatedAt: chat.updatedAt,
127-
...(streamSnapshot ? { streamSnapshot } : {}),
128121
},
129122
})
130123
} catch (error) {

apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.test.ts

Lines changed: 14 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -142,79 +142,61 @@ describe('reconcileLiveAssistantTurn', () => {
142142
})
143143

144144
describe('selectReconnectReplayState', () => {
145-
it('hydrates nonzero cursor replay from a cached live assistant that is ahead', () => {
146-
const cachedBlock: ContentBlock = { type: 'text', content: 'Hello world' }
145+
it('continues from a nonzero cursor when live streaming state exists in memory', () => {
146+
const currentBlock: ContentBlock = { type: 'text', content: 'Hello world' }
147147

148148
const result = selectReconnectReplayState({
149149
afterCursor: '4',
150-
cachedLiveAssistant: {
151-
content: 'Hello world',
152-
contentBlocks: [cachedBlock],
153-
},
154-
currentContent: 'Hello',
155-
currentBlocks: [],
150+
currentContent: 'Hello world',
151+
currentBlocks: [currentBlock],
156152
})
157153

158154
expect(result).toEqual({
159155
afterCursor: '4',
160-
content: 'Hello world',
161-
contentBlocks: [cachedBlock],
162156
preserveExistingState: true,
163-
source: 'cache',
157+
source: 'live',
164158
})
165159
})
166160

167-
it('resets to replay from the beginning when a nonzero cursor has no usable live cache', () => {
161+
it('continues when only blocks carry live state (e.g. tool-only turn)', () => {
168162
const result = selectReconnectReplayState({
169163
afterCursor: '4',
170-
cachedLiveAssistant: null,
171164
currentContent: '',
172-
currentBlocks: [],
165+
currentBlocks: [{ type: 'tool_call', toolCall: { id: 't1', name: 'grep' } } as ContentBlock],
173166
})
174167

175168
expect(result).toEqual({
176-
afterCursor: '0',
177-
content: '',
178-
contentBlocks: [],
179-
preserveExistingState: false,
180-
source: 'reset',
169+
afterCursor: '4',
170+
preserveExistingState: true,
171+
source: 'live',
181172
})
182173
})
183174

184-
it('resets when cached live content diverges from the local prefix', () => {
175+
it('replays the buffer from seq 0 when a nonzero cursor has no live in-memory state', () => {
185176
const result = selectReconnectReplayState({
186177
afterCursor: '4',
187-
cachedLiveAssistant: {
188-
content: 'Goodbye world',
189-
contentBlocks: [{ type: 'text', content: 'Goodbye world' }],
190-
},
191-
currentContent: 'Hello',
192-
currentBlocks: [{ type: 'text', content: 'Hello' }],
178+
currentContent: '',
179+
currentBlocks: [],
193180
})
194181

195182
expect(result).toEqual({
196183
afterCursor: '0',
197-
content: '',
198-
contentBlocks: [],
199184
preserveExistingState: false,
200185
source: 'reset',
201186
})
202187
})
203188

204-
it('resets current state for cursor zero replay', () => {
189+
it('resets for cursor zero replay even when local state exists', () => {
205190
const currentBlock: ContentBlock = { type: 'text', content: 'Hello' }
206191

207192
const result = selectReconnectReplayState({
208193
afterCursor: '0',
209-
cachedLiveAssistant: null,
210194
currentContent: 'Hello',
211195
currentBlocks: [currentBlock],
212196
})
213197

214198
expect(result).toEqual({
215199
afterCursor: '0',
216-
content: '',
217-
contentBlocks: [],
218200
preserveExistingState: false,
219201
source: 'reset',
220202
})

0 commit comments

Comments
 (0)