Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions apps/desktop/src/app/session/hooks/use-message-stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
} from '@/lib/chat-messages'
import { coerceGatewayText, coerceThinkingText, normalizePersonalityValue } from '@/lib/chat-runtime'
import { playCompletionSound } from '@/lib/completion-sound'
import { gatewayEventRequiresSessionId } from '@/lib/gateway-events'
import { resolveGatewayEventSessionId } from '@/lib/gateway-events'
import {
dedupeGeneratedImageEchoesInParts,
generatedImageEchoSources,
Expand Down Expand Up @@ -262,6 +262,8 @@ export function useMessageStream({
sessionStateByRuntimeIdRef,
updateSessionState
}: MessageStreamOptions) {
const unscopedStreamSessionIdRef = useRef<string | null>(null)

const sessionInterrupted = useCallback(
(sessionId: string) => sessionStateByRuntimeIdRef.current.get(sessionId)?.interrupted ?? false,
[sessionStateByRuntimeIdRef]
Expand Down Expand Up @@ -715,11 +717,20 @@ export function useMessageStream({
const payload = event.payload as GatewayEventPayload | undefined
const explicitSid = event.session_id || ''

if (!explicitSid && gatewayEventRequiresSessionId(event.type)) {
const route = resolveGatewayEventSessionId({
activeSessionId: activeSessionIdRef.current,
eventType: event.type,
explicitSessionId: explicitSid,
unscopedStreamSessionId: unscopedStreamSessionIdRef.current
})

unscopedStreamSessionIdRef.current = route.nextUnscopedStreamSessionId

if (route.drop) {
return
}

const sessionId = explicitSid || activeSessionIdRef.current
const sessionId = route.sessionId
const isActiveEvent = !!sessionId && sessionId === activeSessionIdRef.current

if (event.type === 'gateway.ready') {
Expand Down
73 changes: 72 additions & 1 deletion apps/desktop/src/lib/gateway-events.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'

import { gatewayEventRequiresSessionId } from './gateway-events'
import { gatewayEventRequiresSessionId, resolveGatewayEventSessionId } from './gateway-events'

describe('gateway event routing', () => {
it('drops only unscoped subagent events (genuinely background work)', () => {
Expand All @@ -24,4 +24,75 @@ describe('gateway event routing', () => {
expect(gatewayEventRequiresSessionId('session.info')).toBe(false)
expect(gatewayEventRequiresSessionId(undefined)).toBe(false)
})

it('keeps unscoped stream events pinned to the session that started them', () => {
const started = resolveGatewayEventSessionId({
activeSessionId: 'session-a',
eventType: 'message.start',
explicitSessionId: '',
unscopedStreamSessionId: null
})

expect(started).toEqual({
drop: false,
nextUnscopedStreamSessionId: 'session-a',
sessionId: 'session-a'
})

const delta = resolveGatewayEventSessionId({
activeSessionId: 'session-b',
eventType: 'message.delta',
explicitSessionId: '',
unscopedStreamSessionId: started.nextUnscopedStreamSessionId
})

expect(delta).toEqual({
drop: false,
nextUnscopedStreamSessionId: 'session-a',
sessionId: 'session-a'
})

const completed = resolveGatewayEventSessionId({
activeSessionId: 'session-b',
eventType: 'message.complete',
explicitSessionId: '',
unscopedStreamSessionId: delta.nextUnscopedStreamSessionId
})

expect(completed).toEqual({
drop: false,
nextUnscopedStreamSessionId: null,
sessionId: 'session-a'
})
})

it('routes a new unscoped stream start to the currently active session', () => {
const routed = resolveGatewayEventSessionId({
activeSessionId: 'session-b',
eventType: 'message.start',
explicitSessionId: '',
unscopedStreamSessionId: 'session-a'
})

expect(routed).toEqual({
drop: false,
nextUnscopedStreamSessionId: 'session-b',
sessionId: 'session-b'
})
})

it('keeps explicit events scoped and clears a matching pinned stream on completion', () => {
const routed = resolveGatewayEventSessionId({
activeSessionId: 'session-b',
eventType: 'message.complete',
explicitSessionId: 'session-a',
unscopedStreamSessionId: 'session-a'
})

expect(routed).toEqual({
drop: false,
nextUnscopedStreamSessionId: null,
sessionId: 'session-a'
})
})
})
79 changes: 79 additions & 0 deletions apps/desktop/src/lib/gateway-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,28 @@ function asRecord(payload: unknown): Record<string, unknown> {
return payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
}

const UNSCOPED_STREAM_EVENT_TYPES = new Set([
'approval.request',
'browser.progress',
'clarify.request',
'error',
'message.complete',
'message.delta',
'message.start',
'reasoning.available',
'reasoning.delta',
'secret.request',
'status.update',
'sudo.request',
'thinking.delta',
'tool.complete',
'tool.generating',
'tool.progress',
'tool.start'
])

const UNSCOPED_STREAM_END_EVENT_TYPES = new Set(['error', 'message.complete'])

/**
* Whether an unscoped event (no `session_id`) must be dropped rather than
* attributed to the focused chat.
Expand All @@ -27,6 +49,63 @@ export function gatewayEventRequiresSessionId(eventType: string | undefined): bo
return eventType?.startsWith('subagent.') ?? false
}

export interface GatewayEventSessionRouteInput {
activeSessionId: null | string
eventType: string | undefined
explicitSessionId: string
unscopedStreamSessionId: null | string
}

export interface GatewayEventSessionRoute {
drop: boolean
nextUnscopedStreamSessionId: null | string
sessionId: null | string
}

export function resolveGatewayEventSessionId({
activeSessionId,
eventType,
explicitSessionId,
unscopedStreamSessionId
}: GatewayEventSessionRouteInput): GatewayEventSessionRoute {
if (explicitSessionId) {
const nextUnscopedStreamSessionId =
eventType && UNSCOPED_STREAM_END_EVENT_TYPES.has(eventType) && explicitSessionId === unscopedStreamSessionId
? null
: unscopedStreamSessionId

return {
drop: false,
nextUnscopedStreamSessionId,
sessionId: explicitSessionId
}
}

if (gatewayEventRequiresSessionId(eventType)) {
return {
drop: true,
nextUnscopedStreamSessionId: unscopedStreamSessionId,
sessionId: null
}
}

const streamEvent = eventType ? UNSCOPED_STREAM_EVENT_TYPES.has(eventType) : false
const sessionId = eventType === 'message.start' ? activeSessionId : streamEvent ? unscopedStreamSessionId || activeSessionId : activeSessionId
let nextUnscopedStreamSessionId = unscopedStreamSessionId

if (eventType === 'message.start' && activeSessionId) {
nextUnscopedStreamSessionId = activeSessionId
} else if (eventType && UNSCOPED_STREAM_END_EVENT_TYPES.has(eventType)) {
nextUnscopedStreamSessionId = null
}

return {
drop: false,
nextUnscopedStreamSessionId,
sessionId
}
}

export function gatewayEventCompletedFileDiff(event: RpcEventLike): boolean {
if (event.type !== 'tool.complete') {
return false
Expand Down