From 6e7033bb4c790b5b2f2a1242c6fdb35275c68cb6 Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Mon, 8 Jun 2026 15:24:15 -0500 Subject: [PATCH] fix(desktop): don't drop the focused chat's own stream when unscoped (#42359) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #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. --- apps/desktop/src/lib/gateway-events.test.ts | 16 ++++++--- apps/desktop/src/lib/gateway-events.ts | 39 +++++++-------------- 2 files changed, 25 insertions(+), 30 deletions(-) diff --git a/apps/desktop/src/lib/gateway-events.test.ts b/apps/desktop/src/lib/gateway-events.test.ts index ad118beb680..d51a943611f 100644 --- a/apps/desktop/src/lib/gateway-events.test.ts +++ b/apps/desktop/src/lib/gateway-events.test.ts @@ -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', () => { diff --git a/apps/desktop/src/lib/gateway-events.ts b/apps/desktop/src/lib/gateway-events.ts index 0da4a8683cc..673d1df8c6d 100644 --- a/apps/desktop/src/lib/gateway-events.ts +++ b/apps/desktop/src/lib/gateway-events.ts @@ -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 { return payload && typeof payload === 'object' ? (payload as Record) : {} } +/** + * 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 {