diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts index 4e0a439219ca..eb99e1fbd895 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts @@ -47,8 +47,10 @@ import { setTurnStartedAt, setYoloActive } from '@/store/session' -import { clearSessionSubagents, pruneDelegateFallbackSubagents, upsertSubagent } from '@/store/subagents' +import { broadcastSessionsChanged } from '@/store/session-sync' import { clearActiveSessionTodos } from '@/store/todos' +import { clearSessionSubagents, pruneDelegateFallbackSubagents, pruneFinishedSessionSubagents, upsertSubagent } from '@/store/subagents' +import { setSessionTodos } from '@/store/todos' import { recordToolDiff } from '@/store/tool-diffs' import { reportInstallMethodWarning } from '@/store/updates' import { notifyWorkspaceChanged, toolChangedPath, toolMayMutateFiles } from '@/store/workspace-events' @@ -439,7 +441,7 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { } flushQueuedDeltas(sessionId) - clearSessionSubagents(sessionId) + pruneFinishedSessionSubagents(sessionId) setSessionCompacting(sessionId, false) compactedTurnRef.current.delete(sessionId) nativeSubagentSessionsRef.current.delete(sessionId) diff --git a/apps/desktop/src/store/subagents.test.ts b/apps/desktop/src/store/subagents.test.ts index c0b87ba58eae..51c752ddea22 100644 --- a/apps/desktop/src/store/subagents.test.ts +++ b/apps/desktop/src/store/subagents.test.ts @@ -8,6 +8,7 @@ import { clearSessionSubagents, failedSubagentCount, pruneDelegateFallbackSubagents, + pruneFinishedSessionSubagents, upsertSubagent } from './subagents' @@ -131,4 +132,55 @@ describe('subagent store', () => { expect($subagentsBySession.get().s1).toBeUndefined() expect($subagentsBySession.get().s2).toHaveLength(1) }) + + // Regression test for #64015: still-RUNNING background subagents must survive + // the per-turn wipe that previously dropped them at message.start. The fix + // replaces clearSessionSubagents() with pruneFinishedSessionSubagents() at + // the use-message-stream message.start handler, so only terminal-status rows + // get filtered out. + it('pruneFinishedSessionSubagents keeps running/queued and drops terminal rows', () => { + upsertSubagent('s1', { goal: 'live-a', status: 'running', subagent_id: 'live-a', task_index: 0 }) + upsertSubagent('s1', { goal: 'live-b', status: 'queued', subagent_id: 'live-b', task_index: 1 }) + upsertSubagent('s1', { goal: 'done', status: 'completed', subagent_id: 'done', task_index: 2 }) + upsertSubagent('s1', { goal: 'broken', status: 'failed', subagent_id: 'broken', task_index: 3 }) + upsertSubagent('s1', { goal: 'cancelled', status: 'interrupted', subagent_id: 'cancelled', task_index: 4 }) + + pruneFinishedSessionSubagents('s1') + + const ids = listFor('s1').map(item => item.id).sort() + expect(ids).toEqual(['live-a', 'live-b']) + expect(activeSubagentCount(listFor('s1'))).toBe(2) + }) + + // Companion test: after prune, a late `subagent.complete` event for a + // surviving live row must still be accepted by upsertSubagent (the wipe + // path previously silently dropped these). + it('surviving live subagents still accept createIfMissing=false completion', () => { + upsertSubagent('s1', { goal: 'live', status: 'running', subagent_id: 'live', task_index: 0 }) + + pruneFinishedSessionSubagents('s1') + + upsertSubagent( + 's1', + { status: 'completed', subagent_id: 'live', task_index: 0, summary: 'finished later' }, + false, + 'subagent.complete' + ) + + const item = listFor('s1')[0] + expect(item?.status).toBe('completed') + expect(item?.summary).toBe('finished later') + }) + + it('pruneFinishedSessionSubagents leaves other sessions untouched', () => { + upsertSubagent('s1', { goal: 'live', status: 'running', subagent_id: 'a', task_index: 0 }) + upsertSubagent('s1', { goal: 'done', status: 'completed', subagent_id: 'b', task_index: 1 }) + upsertSubagent('s2', { goal: 'live', status: 'running', subagent_id: 'c', task_index: 0 }) + upsertSubagent('s2', { goal: 'done', status: 'completed', subagent_id: 'd', task_index: 1 }) + + pruneFinishedSessionSubagents('s1') + + expect(listFor('s1').map(item => item.id)).toEqual(['a']) + expect(listFor('s2').map(item => item.id).sort()).toEqual(['c', 'd']) + }) }) diff --git a/apps/desktop/src/store/subagents.ts b/apps/desktop/src/store/subagents.ts index e54f3fc169b2..6127b15183c2 100644 --- a/apps/desktop/src/store/subagents.ts +++ b/apps/desktop/src/store/subagents.ts @@ -189,6 +189,35 @@ export function clearSessionSubagents(sid: string) { $subagentsBySession.set(rest) } +/** + * Prune terminal-status subagent rows for a session, leaving running/queued + * entries untouched. Used at the `message.start` boundary in the desktop + * message-stream hook so that the *previous* turn's finished rows get flushed + * from the display while background subagents that outlived the spawning turn + * remain visible (and still accept late progress/complete events). + * + * Distinct from `clearSessionSubagents` (used by the Stop action, which + * genuinely cancels running subagents and so should drop them all) and from + * `pruneDelegateFallbackSubagents` (which filters by id prefix to remove + * placeholder rows once the real native event arrives). + */ +export function pruneFinishedSessionSubagents(sid: string) { + const map = $subagentsBySession.get() + const list = map[sid] + + if (!list?.length) { + return + } + + const next = list.filter(item => item.status === 'running' || item.status === 'queued') + + if (next.length === list.length) { + return + } + + $subagentsBySession.set({ ...map, [sid]: next }) +} + export function pruneDelegateFallbackSubagents(sid: string) { const map = $subagentsBySession.get() const list = map[sid]