fix(desktop): preserve live background subagents across message.start (rebased onto main, #64015)

Cherry-pick of b4d5ba3e onto current main. Original commit's target file
apps/desktop/src/app/session/hooks/use-message-stream.ts has since been
moved into apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts.
The fix is unchanged: replace clearSessionSubagents() with
pruneFinishedSessionSubagents() at the message.start boundary so that
still-running subagent rows survive new turns.

Per @teknium1's review of #64038 on 2026-07-16: 'mergeable_state=dirty'
because the dispatcher moved. Resolved by retargeting the call.

Tests: 3 new cases in subagents.test.ts (unchanged from original PR).
Failing-first verified in original PR (3/3 fail on unfixed, 3/3 pass on fix).
This commit is contained in:
Sophia 2026-07-18 09:10:17 -07:00 committed by Teknium
parent 666076d137
commit 8a32426300
3 changed files with 85 additions and 2 deletions

View file

@ -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)

View file

@ -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'])
})
})

View file

@ -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]