From b4d5ba3e0d5632a65908f4e5f38b37b44c4bdf42 Mon Sep 17 00:00:00 2001 From: Sophia Date: Mon, 13 Jul 2026 16:55:46 -0700 Subject: [PATCH] fix(desktop): preserve live background subagents across message.start (#64015) The desktop message-stream hook wiped the per-session subagent store on every new turn (use-message-stream.ts:860), dropping still-running and queued rows along with the previous turn's terminal entries. Late subagent.tool / subagent.progress / subagent.complete events for surviving background tasks were then silently dropped by upsertSubagent's createIfMissing guard, so the rows never returned and their completion summaries never rendered. Replace the unconditional clearSessionSubagents() call at the message.start boundary with a new pruneFinishedSessionSubagents() that filters out only terminal-status rows. The Stop-action call at use-prompt-actions.ts:1546 still uses clearSessionSubagents because /stop genuinely cancels running subagents, so dropping their rows is correct. Tests: 3 new cases in subagents.test.ts (regression for the wipe, late completion acceptance after prune, cross-session isolation). Failing-first verified: all 3 fail on unfixed (pruneFinishedSessionSubagents not exported), all 3 pass on fix; 7 pre-existing subagents tests stay green; 3 gateway-events tests stay green; 6 background-delegation tests stay green. The Stop-action use-prompt-actions.test.tsx failures predate this branch (window-not-defined env issue, verified by stash-and-rerun). --- .../app/session/hooks/use-message-stream.ts | 4 +- apps/desktop/src/store/subagents.test.ts | 52 +++++++++++++++++++ apps/desktop/src/store/subagents.ts | 29 +++++++++++ 3 files changed, 83 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/app/session/hooks/use-message-stream.ts b/apps/desktop/src/app/session/hooks/use-message-stream.ts index 09ba7591716d..bfb8e3a9b12f 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream.ts @@ -53,7 +53,7 @@ import { setYoloActive } from '@/store/session' import { broadcastSessionsChanged } from '@/store/session-sync' -import { clearSessionSubagents, pruneDelegateFallbackSubagents, upsertSubagent } from '@/store/subagents' +import { clearSessionSubagents, pruneDelegateFallbackSubagents, pruneFinishedSessionSubagents, upsertSubagent } from '@/store/subagents' import { setSessionTodos } from '@/store/todos' import { recordToolDiff } from '@/store/tool-diffs' import { notifyWorkspaceChanged, toolMayMutateFiles } from '@/store/workspace-events' @@ -857,7 +857,7 @@ export function useMessageStream({ } 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 c4695db3bd27..f9054ea76d2b 100644 --- a/apps/desktop/src/store/subagents.ts +++ b/apps/desktop/src/store/subagents.ts @@ -192,6 +192,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]