fix(desktop): don't drop the focused chat's own stream when unscoped (#42359)

#42178 dropped every session-scoped gateway event that arrived without an
explicit session_id, to stop background activity attaching to the focused
chat. But the gateway already stamps background sessions with their own id, so
an unscoped message/reasoning/tool/prompt event can only be the focused turn's
own output. Dropping those swallowed the live answer — it reappeared only after
a transcript refetch (manual refresh).

Narrow the guard to subagent.* (the only genuinely background/async family);
everything else falls back to the active session as before.
This commit is contained in:
brooklyn! 2026-06-08 15:24:15 -05:00 committed by GitHub
parent e88116256c
commit 6e7033bb4c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 25 additions and 30 deletions

View file

@ -3,11 +3,19 @@ import { describe, expect, it } from 'vitest'
import { gatewayEventRequiresSessionId } from './gateway-events'
describe('gateway event routing', () => {
it('requires explicit session ids for async session-scoped events', () => {
expect(gatewayEventRequiresSessionId('message.delta')).toBe(true)
expect(gatewayEventRequiresSessionId('tool.start')).toBe(true)
it('drops only unscoped subagent events (genuinely background work)', () => {
expect(gatewayEventRequiresSessionId('subagent.progress')).toBe(true)
expect(gatewayEventRequiresSessionId('approval.request')).toBe(true)
expect(gatewayEventRequiresSessionId('subagent.start')).toBe(true)
})
it('attributes unscoped foreground turn events to the active chat', () => {
// These must NOT be dropped when unscoped — they are the focused turn's own
// output, and dropping them loses the live response until a refetch (#42178).
expect(gatewayEventRequiresSessionId('message.delta')).toBe(false)
expect(gatewayEventRequiresSessionId('message.complete')).toBe(false)
expect(gatewayEventRequiresSessionId('reasoning.delta')).toBe(false)
expect(gatewayEventRequiresSessionId('tool.start')).toBe(false)
expect(gatewayEventRequiresSessionId('approval.request')).toBe(false)
})
it('allows global events to remain unscoped', () => {

View file

@ -7,37 +7,24 @@ interface RpcEventLike {
type?: string
}
const SESSION_SCOPED_EVENT_TYPES = new Set([
'approval.request',
'clarify.request',
'error',
'message.complete',
'message.delta',
'message.start',
'reasoning.available',
'reasoning.delta',
'secret.request',
'status.update',
'subagent.complete',
'subagent.progress',
'subagent.spawn_requested',
'subagent.start',
'subagent.thinking',
'subagent.tool',
'sudo.request',
'thinking.delta'
])
function asRecord(payload: unknown): Record<string, unknown> {
return payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
}
/**
* Whether an unscoped event (no `session_id`) must be dropped rather than
* attributed to the focused chat.
*
* Only `subagent.*` qualifies: it describes background/async work that must
* never attach to whichever chat happens to be focused. Every other scoped
* event message/reasoning/thinking/tool/status/prompt is, when unscoped,
* the active turn's own output. The gateway always stamps a *background*
* session's events with that session's id, so a missing id can only mean "the
* focused turn". #42178 dropped those too, which silently swallowed the live
* answer; it then reappeared only after a transcript refetch (manual refresh).
*/
export function gatewayEventRequiresSessionId(eventType: string | undefined): boolean {
if (!eventType) {
return false
}
return SESSION_SCOPED_EVENT_TYPES.has(eventType) || eventType.startsWith('tool.')
return eventType?.startsWith('subagent.') ?? false
}
export function gatewayEventCompletedFileDiff(event: RpcEventLike): boolean {