fix(agent-status): preserve open chat status when terminals close (#28188)

This commit is contained in:
Brennan Benson
2026-10-13 17:25:03 -07:00
committed by GitHub
parent 23bd828af1
commit ac03226b1e
4 changed files with 126 additions and 2 deletions
@@ -191,7 +191,8 @@ export function buildRetractedMirroredTabSweepPatch(
retentionSuppressedPaneKeys: state.retentionSuppressedPaneKeys ?? {},
sortEpoch: agentStatusPatch?.sortEpoch ?? state.sortEpoch,
// Keep the sweep state consistent with the accepted host inventory.
tabsByWorktree: nextTabsByWorktree
tabsByWorktree: nextTabsByWorktree,
unifiedTabsByWorktree: state.unifiedTabsByWorktree
}
// Why: a retraction can be a reconnect re-key, not pane death (ssh-execution-boundary); keeping
// cutoffs means a republished pane cannot replay activity the user cleared on this client.
@@ -41,6 +41,7 @@ export type AgentStatusTabPrefixDropState = Pick<
| 'retentionSuppressedPaneKeys'
| 'sortEpoch'
| 'tabsByWorktree'
| 'unifiedTabsByWorktree'
>
/** Pure form of the dropAgentStatusByTabPrefix reducer: the paired snapshot
@@ -75,6 +75,12 @@ export function findCompletedOrphanPaneKeysForTabClose(
return []
}
const openTabIds = new Set((state.tabsByWorktree[worktreeId] ?? []).map((tab) => tab.id))
// Terminal owners must still exist in the terminal inventory; unified metadata can lag a close.
for (const tab of state.unifiedTabsByWorktree[worktreeId] ?? []) {
if (tab.contentType !== 'terminal') {
openTabIds.add(tab.id)
}
}
const paneKeys: string[] = []
for (const [paneKey, entry] of Object.entries(state.agentStatusByPaneKey)) {
if (paneKey.startsWith(prefix) || entry.state !== 'done' || entry.worktreeId !== worktreeId) {
@@ -4,9 +4,18 @@ import {
type AgentStatusEntry
} from '../../../../shared/agent-status-types'
import { flushMicrotasks } from './agent-status-test-harness'
import { createTestStore, makeTab } from './store-test-helpers'
import { createTestStore, makeTab, makeUnifiedTab } from './store-test-helpers'
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
import type { RetainedAgentEntry } from './agent-status'
import {
structuredAgentSessionPaneKey,
structuredAgentSessionTabId
} from '../../../../shared/structured-agent-session-projection'
import { buildAgentStatusTabPrefixDropPatch } from './agent-status-drop-reducer'
vi.mock('@/runtime/structured-agent-session-tab-retirement', () => ({
beginStructuredAgentSessionTabClose: vi.fn()
}))
{
describe('agent status freshness expiry', () => {
@@ -336,6 +345,9 @@ import type { RetainedAgentEntry } from './agent-status'
makeTab({ id: 'tab-live', worktreeId: 'wt-1' })
],
'wt-2': []
},
unifiedTabsByWorktree: {
'wt-1': [makeUnifiedTab({ id: 'tab-orphan', worktreeId: 'wt-1', groupId: 'group-1' })]
}
})
store
@@ -392,6 +404,110 @@ import type { RetainedAgentEntry } from './agent-status'
expect(s.agentStatusByPaneKey['tab-other-orphan:0']).toBeDefined()
})
it.each(['wt-1', 'folder-workspace-1'])(
'preserves an open chat and its read state when an unrelated terminal closes in %s',
(worktreeId) => {
vi.useFakeTimers()
const store = createTestStore()
const sessionId = 'chat-1'
const chat = makeUnifiedTab({
id: structuredAgentSessionTabId(sessionId),
entityId: sessionId,
contentType: 'agent-session',
executionHostId: 'local',
worktreeId,
groupId: 'group-1'
})
const paneKey = structuredAgentSessionPaneKey(chat.id, sessionId)
const missingOwnerPaneKey = structuredAgentSessionPaneKey(
structuredAgentSessionTabId('missing-chat'),
'missing-chat'
)
store.setState({
tabsByWorktree: {
[worktreeId]: [
makeTab({ id: 'tab-closed', worktreeId }),
makeTab({ id: 'tab-live', worktreeId })
]
},
unifiedTabsByWorktree: { [worktreeId]: [chat] }
})
for (const key of [paneKey, missingOwnerPaneKey]) {
store
.getState()
.setAgentStatus(
key,
{ state: 'done', prompt: 'completed chat', agentType: 'codex' },
undefined,
undefined,
{ worktreeId }
)
}
store.getState().acknowledgeAgents([paneKey, missingOwnerPaneKey])
store.setState({
activityClearedAtByPaneKey: { [paneKey]: 100, [missingOwnerPaneKey]: 100 },
manuallyUnreadTurnsByPaneKey: { [paneKey]: 200, [missingOwnerPaneKey]: 200 }
})
const before = store.getState()
store.getState().closeTab('tab-closed')
const after = store.getState()
expect(after.agentStatusByPaneKey[paneKey]).toBe(before.agentStatusByPaneKey[paneKey])
expect(after.acknowledgedAgentsByPaneKey[paneKey]).toBe(
before.acknowledgedAgentsByPaneKey[paneKey]
)
expect(after.activityClearedAtByPaneKey[paneKey]).toBe(100)
expect(after.manuallyUnreadTurnsByPaneKey[paneKey]).toBe(200)
expect(after.agentStatusByPaneKey[missingOwnerPaneKey]).toBeUndefined()
expect(after.acknowledgedAgentsByPaneKey[missingOwnerPaneKey]).toBeUndefined()
expect(after.activityClearedAtByPaneKey[missingOwnerPaneKey]).toBeUndefined()
expect(after.manuallyUnreadTurnsByPaneKey[missingOwnerPaneKey]).toBeUndefined()
store.getState().closeUnifiedTab(chat.id)
store.getState().closeTab('tab-live')
const closed = store.getState()
expect(closed.unifiedTabsByWorktree[worktreeId]).toEqual([])
expect(closed.agentStatusByPaneKey[paneKey]).toBeUndefined()
expect(closed.acknowledgedAgentsByPaneKey[paneKey]).toBeUndefined()
expect(closed.activityClearedAtByPaneKey[paneKey]).toBeUndefined()
expect(closed.manuallyUnreadTurnsByPaneKey[paneKey]).toBeUndefined()
}
)
it('keeps an exact-pane remote retirement scoped to that pane', () => {
vi.useFakeTimers()
const store = createTestStore()
const retiringPaneKey = 'host-tab:11111111-1111-4111-8111-111111111111'
const siblingPaneKey = 'host-tab:22222222-2222-4222-8222-222222222222'
for (const paneKey of [retiringPaneKey, siblingPaneKey, 'missing-owner:0']) {
store
.getState()
.setAgentStatus(
paneKey,
{ state: 'done', prompt: 'remote chat', agentType: 'codex' },
undefined,
undefined,
{ worktreeId: 'wt-1' }
)
}
const before = store.getState()
const { patch } = buildAgentStatusTabPrefixDropPatch(before, 'host-tab', [], {
worktreeId: 'wt-1',
paneKeys: new Set([retiringPaneKey])
})
expect(patch.agentStatusByPaneKey?.[retiringPaneKey]).toBeUndefined()
expect(patch.agentStatusByPaneKey?.[siblingPaneKey]).toBe(
before.agentStatusByPaneKey[siblingPaneKey]
)
expect(patch.agentStatusByPaneKey?.['missing-owner:0']).toBe(
before.agentStatusByPaneKey['missing-owner:0']
)
})
it('on a paneKey with neither live nor retained entry: no-op (same state reference, no epoch bumps)', () => {
vi.useFakeTimers()
const store = createTestStore()