Skip to content

Commit ec7a47d

Browse files
feat: replay session statuses on upstream reconnect and add compact type (#268)
1 parent 95ae584 commit ec7a47d

4 files changed

Lines changed: 128 additions & 4 deletions

File tree

backend/src/services/sse-aggregator.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ interface PendingQuestion {
3737
[key: string]: unknown
3838
}
3939

40+
type SessionStatusValue = { type: string } & Record<string, unknown>
41+
type SessionStatusMap = Record<string, SessionStatusValue>
42+
4043
const OPENCODE_PORT = ENV.OPENCODE.PORT
4144
const { RECONNECT_DELAY_MS, MAX_RECONNECT_DELAY_MS } = DEFAULTS.SSE
4245

@@ -202,6 +205,61 @@ class SSEAggregator {
202205
await Promise.allSettled(tasks)
203206
}
204207

208+
private async replaySessionStatusesForAllClients(): Promise<void> {
209+
const fetcher = this.pendingActionsFetcher
210+
if (!fetcher) return
211+
212+
const directories = new Set<string>()
213+
this.clients.forEach((client) => {
214+
client.directories.forEach(dir => directories.add(dir))
215+
})
216+
217+
if (directories.size === 0) return
218+
logger.info(`replay: replaying session statuses for ${directories.size} directory(ies) after upstream reconnect`)
219+
await Promise.allSettled(Array.from(directories).map(directory =>
220+
this.replaySessionStatusesForDirectory(directory, fetcher)
221+
))
222+
}
223+
224+
private async replaySessionStatusesForDirectory(
225+
directory: string,
226+
fetcher: PendingActionsFetcher,
227+
): Promise<void> {
228+
let statuses: SessionStatusMap
229+
try {
230+
statuses = await fetcher.getJson<SessionStatusMap>('/session/status', { directory })
231+
} catch (error) {
232+
logger.warn(`replay: failed to fetch session statuses for ${directory}: ${String(error)}`)
233+
return
234+
}
235+
236+
if (!statuses) return
237+
238+
const previouslyActive = new Set(this.activeSessions.get(directory) ?? [])
239+
const nowActive = new Set<string>()
240+
241+
let replayed = 0
242+
for (const [sessionID, status] of Object.entries(statuses)) {
243+
if (!sessionID || !status || status.type === 'idle') continue
244+
nowActive.add(sessionID)
245+
const data = JSON.stringify({ directory, payload: { type: 'session.status', properties: { sessionID, status } } })
246+
this.handleUpstreamMessage(data)
247+
replayed++
248+
}
249+
250+
let cleared = 0
251+
for (const sessionID of previouslyActive) {
252+
if (nowActive.has(sessionID)) continue
253+
const data = JSON.stringify({ directory, payload: { type: 'session.status', properties: { sessionID, status: { type: 'idle' } } } })
254+
this.handleUpstreamMessage(data)
255+
cleared++
256+
}
257+
258+
if (replayed > 0 || cleared > 0) {
259+
logger.info(`replay: re-emitted ${replayed} active and ${cleared} idle session status(es) for ${directory}`)
260+
}
261+
}
262+
205263
private async replayPendingActionsForDirectory(
206264
clientId: string,
207265
directory: string,
@@ -289,6 +347,7 @@ class SSEAggregator {
289347
this.everConnected = true
290348
if (wasConnectedBefore) {
291349
void this.replayPendingActionsForAllClients()
350+
void this.replaySessionStatusesForAllClients()
292351
}
293352
}
294353

backend/test/services/sse-aggregator.test.ts

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,16 @@ function createCapturingClient() {
3434
return { callback, writeFrame, events, frames }
3535
}
3636

37-
function makeFetcher(map: Record<string, { permissions?: unknown[]; questions?: unknown[] }>): PendingActionsFetcher {
37+
function makeFetcher(
38+
map: Record<string, { permissions?: unknown[]; questions?: unknown[]; statuses?: Record<string, { type: string }> }>,
39+
): PendingActionsFetcher {
3840
return {
3941
async getJson<T>(path: string, opts?: { directory?: string }): Promise<T> {
4042
const directory = opts?.directory ?? ''
4143
const entry = map[directory] ?? {}
4244
if (path === '/permission') return (entry.permissions ?? []) as T
4345
if (path === '/question') return (entry.questions ?? []) as T
46+
if (path === '/session/status') return (entry.statuses ?? {}) as T
4447
throw new Error(`unexpected path: ${path}`)
4548
},
4649
}
@@ -197,6 +200,66 @@ describe('SSEAggregator pending replay on connect', () => {
197200
})
198201
})
199202

203+
describe('SSEAggregator session status replay on upstream reconnect', () => {
204+
beforeEach(() => {
205+
sseAggregator.shutdown()
206+
sseAggregator.setPendingActionsFetcher(null)
207+
})
208+
209+
it('re-emits active session statuses to subscribed clients', async () => {
210+
const fetcher = makeFetcher({
211+
'/repo/a': { statuses: { 'sess-1': { type: 'busy' }, 'sess-2': { type: 'idle' } } },
212+
})
213+
sseAggregator.setPendingActionsFetcher(fetcher)
214+
215+
const clientA = createCapturingClient()
216+
sseAggregator.addClient('status-1', clientA.callback, clientA.writeFrame, ['/repo/a'])
217+
218+
await (sseAggregator as any).replaySessionStatusesForAllClients()
219+
await flushReplay()
220+
221+
const parsed = clientA.frames.map(f => JSON.parse(f.replace(/^event: message\ndata: /, '').trim()) as {
222+
directory: string
223+
payload: { type: string; properties: { sessionID: string; status: { type: string } } }
224+
})
225+
226+
const statusEvents = parsed.filter(p => p.payload.type === 'session.status')
227+
expect(statusEvents).toHaveLength(1)
228+
expect(statusEvents[0]?.payload.properties.sessionID).toBe('sess-1')
229+
expect(statusEvents[0]?.payload.properties.status.type).toBe('busy')
230+
})
231+
232+
it('emits idle for sessions that finished during the disconnect', async () => {
233+
const clientA = createCapturingClient()
234+
sseAggregator.addClient('status-2', clientA.callback, clientA.writeFrame, ['/repo/a'])
235+
236+
const busy = JSON.stringify({ directory: '/repo/a', payload: { type: 'session.status', properties: { sessionID: 'sess-9', status: { type: 'busy' } } } })
237+
;(sseAggregator as any).handleUpstreamMessage(busy)
238+
239+
sseAggregator.setPendingActionsFetcher(makeFetcher({ '/repo/a': { statuses: {} } }))
240+
241+
await (sseAggregator as any).replaySessionStatusesForAllClients()
242+
await flushReplay()
243+
244+
const parsed = clientA.frames.map(f => JSON.parse(f.replace(/^event: message\ndata: /, '').trim()) as {
245+
payload: { type: string; properties: { sessionID: string; status: { type: string } } }
246+
})
247+
const idleEvents = parsed.filter(p => p.payload.type === 'session.status' && p.payload.properties.status.type === 'idle')
248+
expect(idleEvents).toHaveLength(1)
249+
expect(idleEvents[0]?.payload.properties.sessionID).toBe('sess-9')
250+
})
251+
252+
it('does nothing when no fetcher is configured', async () => {
253+
const clientA = createCapturingClient()
254+
sseAggregator.addClient('status-3', clientA.callback, clientA.writeFrame, ['/repo/a'])
255+
256+
await (sseAggregator as any).replaySessionStatusesForAllClients()
257+
await flushReplay()
258+
259+
expect(clientA.frames).toHaveLength(0)
260+
})
261+
})
262+
200263
describe('SSEAggregator directory-indexed broadcast', () => {
201264
beforeEach(() => {
202265
sseAggregator.shutdown()

frontend/src/api/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,8 @@ export interface SSESessionStatusEvent {
182182
type: 'idle'
183183
} | {
184184
type: 'busy'
185+
} | {
186+
type: 'compact'
185187
} | {
186188
type: 'retry'
187189
attempt: number

frontend/src/components/message/MessageThread.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,8 @@ const isMessageStreaming = (msg: Message): boolean => {
3636
return !('completed' in msg.time && msg.time.completed)
3737
}
3838

39-
function isSessionInRetry(sessionStatus: { type?: string }): boolean {
40-
return sessionStatus?.type === 'retry'
39+
function isSessionStatusActive(sessionStatus: { type?: string }): boolean {
40+
return sessionStatus?.type !== undefined && sessionStatus.type !== 'idle'
4141
}
4242

4343
const compareMessageIds = (id1: string, id2: string): number => {
@@ -363,7 +363,7 @@ export const MessageThread = memo(function MessageThread({
363363
return map
364364
}, [messages])
365365

366-
const isSessionBusy = !!pendingAssistantId || isSessionInRetry(sessionStatus)
366+
const isSessionBusy = !!pendingAssistantId || isSessionStatusActive(sessionStatus)
367367
const setSessionTodos = useSessionTodos((state) => state.setTodos)
368368

369369
useEffect(() => {

0 commit comments

Comments
 (0)