Keep closed tab agents from reappearing (#6095)

This commit is contained in:
Brennan Benson
2026-06-22 13:36:05 -07:00
committed by GitHub
parent 28824cda78
commit 3cc610bcc3
14 changed files with 669 additions and 9 deletions
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import type { MigrationUnsupportedPtyEntry } from '../../shared/agent-status-types'
import {
clearMigrationUnsupportedPty,
clearMigrationUnsupportedPtysByTabPrefix,
clearMigrationUnsupportedPtysForPaneKey,
getMigrationUnsupportedPtySnapshot,
setMigrationUnsupportedPty,
@@ -54,4 +55,30 @@ describe('migration unsupported PTY state', () => {
expect(persist).toHaveBeenCalledTimes(1)
expect(persist).toHaveBeenCalledWith([otherPane])
})
it('persists once when clearing multiple entries under one tab prefix', () => {
const listener = vi.fn()
const persist = vi.fn()
setMigrationUnsupportedPtyListener(listener)
setMigrationUnsupportedPtyPersistenceListener(persist)
const first = makeEntry('pty-1', 'tab-1:leaf-a')
const second = makeEntry('pty-2', 'tab-1:leaf-b')
const sibling = makeEntry('pty-3', 'tab-10:leaf-c')
const otherTab = makeEntry('pty-4', 'tab-2:leaf-d')
setMigrationUnsupportedPty(first)
setMigrationUnsupportedPty(second)
setMigrationUnsupportedPty(sibling)
setMigrationUnsupportedPty(otherTab)
listener.mockClear()
persist.mockClear()
clearMigrationUnsupportedPtysByTabPrefix('tab-1')
expect(listener).toHaveBeenCalledTimes(2)
expect(listener).toHaveBeenNthCalledWith(1, { type: 'clear', ptyId: 'pty-1' })
expect(listener).toHaveBeenNthCalledWith(2, { type: 'clear', ptyId: 'pty-2' })
expect(persist).toHaveBeenCalledTimes(1)
expect(persist).toHaveBeenCalledWith([sibling, otherTab])
})
})
@@ -57,3 +57,21 @@ export function clearMigrationUnsupportedPtysForPaneKey(paneKey: string): void {
}
persistenceListener?.(getMigrationUnsupportedPtySnapshot())
}
export function clearMigrationUnsupportedPtysByTabPrefix(tabId: string): void {
const prefix = `${tabId}:`
const ptyIdsToClear: string[] = []
for (const [ptyId, entry] of entriesByPtyId) {
if (entry.paneKey?.startsWith(prefix)) {
ptyIdsToClear.push(ptyId)
}
}
if (ptyIdsToClear.length === 0) {
return
}
for (const ptyId of ptyIdsToClear) {
entriesByPtyId.delete(ptyId)
listener?.({ type: 'clear', ptyId })
}
persistenceListener?.(getMigrationUnsupportedPtySnapshot())
}
+200
View File
@@ -54,6 +54,13 @@ type Body = {
payload: Record<string, unknown>
}
type AgentHookServerCacheInternals = {
assistantMessageRetryTimers: Map<string, number | ReturnType<typeof globalThis.setTimeout>>
promptSentDedupeByPaneKey: Map<string, unknown>
runtimeObservedStatusPaneKeys: Set<string>
scheduleStatusPersist: () => void
}
function buildBody(payload: Record<string, unknown>, overrides: Partial<Body> = {}): Body {
return {
paneKey: PANE,
@@ -1086,6 +1093,199 @@ describe('AgentHookServer listener replay', () => {
expect(listener).toHaveBeenCalledWith(PANE)
})
it('drops cached statuses and pane-scoped listener caches under one tab prefix', () => {
vi.useFakeTimers()
try {
const server = new AgentHookServer()
const internals = server as unknown as AgentHookServerCacheInternals
const sameTabPane = makePaneKey('tab-1', LEAF_2)
const siblingPrefixPane = makePaneKey('tab-10', LEAF_3)
const statusListener = vi.fn()
const aliasPersist = vi.fn()
const sameTabRetry = vi.fn()
const siblingRetry = vi.fn()
server.subscribeStatusChanges(statusListener)
server.setPaneKeyAliasPersistenceListener(aliasPersist)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'working', prompt: 'first', agentType: 'claude' }
},
'conn-1'
)
server.ingestRemote(
{
paneKey: sameTabPane,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'done', prompt: 'second', agentType: 'codex' }
},
'conn-1'
)
server.ingestRemote(
{
paneKey: siblingPrefixPane,
tabId: 'tab-10',
worktreeId: 'wt-2',
payload: { state: 'working', prompt: 'sibling', agentType: 'claude' }
},
'conn-1'
)
server.registerPaneKeyAlias('tab-1:0', sameTabPane, 'pty-1')
const state = server._getStateForTests()
state.lastPromptByPaneKey.set(PANE, 'cached prompt')
state.lastToolByPaneKey.set(`${sameTabPane}\0tool`, {} as never)
state.antigravityCompletedTranscriptByPaneKey.set(`${sameTabPane}\0done`, 'cached')
state.ampCompletedCacheKeys.add(`${sameTabPane}\0amp`)
state.lastPromptByPaneKey.set(siblingPrefixPane, 'sibling prompt')
internals.assistantMessageRetryTimers.set(PANE, setTimeout(sameTabRetry, 1_000))
internals.assistantMessageRetryTimers.set(siblingPrefixPane, setTimeout(siblingRetry, 1_000))
internals.promptSentDedupeByPaneKey.set(PANE, { promptHash: 'same-tab' })
internals.promptSentDedupeByPaneKey.set(siblingPrefixPane, { promptHash: 'sibling' })
const scheduleStatusPersist = vi.spyOn(internals, 'scheduleStatusPersist')
statusListener.mockClear()
aliasPersist.mockClear()
scheduleStatusPersist.mockClear()
server.dropStatusEntriesByTabPrefix('tab-1')
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({ paneKey: siblingPrefixPane, prompt: 'sibling' })
])
expect(state.lastPromptByPaneKey.has(PANE)).toBe(false)
expect(state.lastToolByPaneKey.has(`${sameTabPane}\0tool`)).toBe(false)
expect(state.antigravityCompletedTranscriptByPaneKey.has(`${sameTabPane}\0done`)).toBe(false)
expect(state.ampCompletedCacheKeys.has(`${sameTabPane}\0amp`)).toBe(false)
expect(state.lastPromptByPaneKey.get(siblingPrefixPane)).toBe('sibling prompt')
expect(internals.assistantMessageRetryTimers.has(PANE)).toBe(false)
expect(internals.assistantMessageRetryTimers.has(siblingPrefixPane)).toBe(true)
expect(internals.promptSentDedupeByPaneKey.has(PANE)).toBe(false)
expect(internals.promptSentDedupeByPaneKey.get(siblingPrefixPane)).toEqual({
promptHash: 'sibling'
})
expect(internals.runtimeObservedStatusPaneKeys.has(PANE)).toBe(false)
expect(internals.runtimeObservedStatusPaneKeys.has(sameTabPane)).toBe(false)
expect(internals.runtimeObservedStatusPaneKeys.has(siblingPrefixPane)).toBe(true)
expect(statusListener).toHaveBeenCalledTimes(1)
expect(statusListener).toHaveBeenCalledWith([
expect.objectContaining({ state: 'working', observedInCurrentRuntime: true })
])
expect(aliasPersist).toHaveBeenCalledTimes(1)
expect(aliasPersist).toHaveBeenCalledWith([])
expect(scheduleStatusPersist).toHaveBeenCalledTimes(1)
vi.advanceTimersByTime(1_000)
expect(sameTabRetry).not.toHaveBeenCalled()
expect(siblingRetry).toHaveBeenCalledTimes(1)
} finally {
vi.clearAllTimers()
vi.useRealTimers()
}
})
it('suppresses late writes for a closed tab for the rest of the server session', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
try {
const server = new AgentHookServer()
const listener = vi.fn()
server.setListener(listener)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'working', prompt: 'before close', agentType: 'codex' }
},
'conn-1'
)
server.dropStatusEntriesByTabPrefix('tab-1')
listener.mockClear()
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'done', prompt: 'late remote', agentType: 'codex' }
},
'conn-1'
)
server.ingestTerminalStatus({
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'done', prompt: 'late terminal', agentType: 'codex' }
})
vi.setSystemTime(16_001)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'working', prompt: 'future reuse', agentType: 'codex' }
},
'conn-1'
)
expect(listener).not.toHaveBeenCalled()
expect(server.getStatusSnapshot()).toEqual([])
} finally {
vi.useRealTimers()
}
})
it('accepts statuses for unrelated tabs while another tab is recently closed', () => {
const server = new AgentHookServer()
server.dropStatusEntriesByTabPrefix('tab-1')
server.ingestRemote(
{
paneKey: GOOD_PANE,
tabId: 'tab-good',
worktreeId: 'wt-1',
payload: { state: 'working', prompt: 'unrelated', agentType: 'claude' }
},
'conn-1'
)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({ paneKey: GOOD_PANE, state: 'working', prompt: 'unrelated' })
])
})
it('suppresses local HTTP hook writes for a recently closed tab', async () => {
const server = new AgentHookServer()
await server.start({ env: 'production' })
try {
const env = server.buildPtyEnv()
const postHook = (prompt: string): Promise<Response> =>
fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
},
body: JSON.stringify(buildBody({ hook_event_name: 'UserPromptSubmit', prompt }))
})
await expect(postHook('before close')).resolves.toMatchObject({ status: 204 })
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({ paneKey: PANE, prompt: 'before close' })
])
server.dropStatusEntriesByTabPrefix('tab-1')
await expect(postHook('late local')).resolves.toMatchObject({ status: 204 })
expect(server.getStatusSnapshot()).toEqual([])
} finally {
server.stop()
}
})
it('hydrates cached statuses as not observed in the current runtime', async () => {
const dir = mkdtempSync(join(tmpdir(), 'orca-agent-hooks-'))
const firstServer = new AgentHookServer()
+101 -1
View File
@@ -271,6 +271,15 @@ function trackEmptyPaneKeyHook(body: unknown): void {
track('agent_hook_unattributed', { reason: 'empty_pane_key' })
}
function paneCacheKeyTabId(key: string): string | null {
const paneKey = key.split('\0', 1)[0] ?? key
return parsePaneKey(paneKey)?.tabId ?? parseLegacyNumericPaneKey(paneKey)?.tabId ?? null
}
function paneCacheKeyMatchesTab(key: string, tabId: string): boolean {
return paneCacheKeyTabId(key) === tabId
}
function shouldKeepClaudePermissionVisible(
previous: EnrichedAgentHookEventPayload | undefined,
next: AgentHookEventPayload
@@ -434,6 +443,7 @@ export class AgentHookServer {
private assistantMessageRetryTimers = new Map<string, ReturnType<typeof setTimeout>>()
private promptSentDedupeByPaneKey = new Map<string, AgentPromptSentDedupeEntry>()
private promptSentHashSalt = randomBytes(16).toString('hex')
private closedAgentStatusTabIds = new Set<string>()
// Why: identity check — skip writes when the JSON-stringified contents
// exactly match the last successful disk write. Cheap protection against
// re-firing trailing timers when nothing changed.
@@ -571,6 +581,18 @@ export class AgentHookServer {
}
}
private markTabClosedForAgentStatus(tabId: string): void {
this.closedAgentStatusTabIds.add(tabId)
}
private shouldSuppressClosedTabStatus(paneKey: string): boolean {
const tabId = parsePaneKey(paneKey)?.tabId
if (!tabId) {
return false
}
return this.closedAgentStatusTabIds.has(tabId)
}
private attachStatusTiming(
payload: AgentHookEventPayload,
now = Date.now()
@@ -936,6 +958,9 @@ export class AgentHookServer {
if (tabId !== undefined && tabId !== parsedPaneKey.tabId) {
return
}
if (this.shouldSuppressClosedTabStatus(paneKey)) {
return
}
const worktreeId =
event.worktreeId !== undefined && event.worktreeId.trim().length > 0
? event.worktreeId.trim()
@@ -1039,6 +1064,9 @@ export class AgentHookServer {
if (tabId !== undefined && tabId !== parsedPaneKey.tabId) {
return
}
if (this.shouldSuppressClosedTabStatus(paneKey)) {
return
}
const worktreeId =
envelope.worktreeId !== undefined && envelope.worktreeId.trim().length > 0
? envelope.worktreeId.trim()
@@ -1167,7 +1195,7 @@ export class AgentHookServer {
trackEmptyPaneKeyHook(body)
const aliasedBody = this.normalizeHookBodyPaneKeyAlias(body)
const normalized = normalizeHookPayload(this.state, source, aliasedBody, this.env)
if (normalized) {
if (normalized && !this.shouldSuppressClosedTabStatus(normalized.paneKey)) {
const enriched = this.applyNormalizedStatus(normalized)
this.scheduleAssistantMessageRetry(source, aliasedBody, enriched)
}
@@ -1235,6 +1263,7 @@ export class AgentHookServer {
this.lastWrittenJson = null
this.runtimeObservedStatusPaneKeys.clear()
this.promptSentDedupeByPaneKey.clear()
this.closedAgentStatusTabIds.clear()
this.legacyPaneKeyAliases.clear()
clearAllListenerCaches(this.state)
this.notifyStatusChangeListeners()
@@ -1263,6 +1292,77 @@ export class AgentHookServer {
this.notifyStatusChangeListeners()
}
dropStatusEntriesByTabPrefix(tabId: string): void {
this.markTabClosedForAgentStatus(tabId)
const paneKeysToClear = new Set<string>()
for (const key of this.state.lastStatusByPaneKey.keys()) {
if (paneCacheKeyMatchesTab(key, tabId)) {
paneKeysToClear.add(key)
}
}
for (const key of this.state.lastPromptByPaneKey.keys()) {
if (paneCacheKeyMatchesTab(key, tabId)) {
paneKeysToClear.add(key.split('\0', 1)[0] ?? key)
}
}
for (const key of this.state.lastToolByPaneKey.keys()) {
if (paneCacheKeyMatchesTab(key, tabId)) {
paneKeysToClear.add(key.split('\0', 1)[0] ?? key)
}
}
for (const key of this.state.antigravityCompletedTranscriptByPaneKey.keys()) {
if (paneCacheKeyMatchesTab(key, tabId)) {
paneKeysToClear.add(key.split('\0', 1)[0] ?? key)
}
}
for (const key of this.state.ampCompletedCacheKeys) {
if (paneCacheKeyMatchesTab(key, tabId)) {
paneKeysToClear.add(key.split('\0', 1)[0] ?? key)
}
}
for (const paneKey of this.runtimeObservedStatusPaneKeys) {
if (paneCacheKeyMatchesTab(paneKey, tabId)) {
paneKeysToClear.add(paneKey)
}
}
for (const paneKey of this.promptSentDedupeByPaneKey.keys()) {
if (paneCacheKeyMatchesTab(paneKey, tabId)) {
paneKeysToClear.add(paneKey)
}
}
let aliasChanged = false
for (const [legacyPaneKey, entry] of this.legacyPaneKeyAliases) {
if (
paneCacheKeyMatchesTab(legacyPaneKey, tabId) ||
paneCacheKeyMatchesTab(entry.stablePaneKey, tabId)
) {
this.legacyPaneKeyAliases.delete(legacyPaneKey)
paneKeysToClear.add(legacyPaneKey)
paneKeysToClear.add(entry.stablePaneKey)
aliasChanged = true
}
}
let statusChanged = false
for (const paneKey of paneKeysToClear) {
if (this.state.lastStatusByPaneKey.has(paneKey)) {
statusChanged = true
}
this.clearAssistantMessageRetry(paneKey)
clearPaneCacheState(this.state, paneKey)
this.runtimeObservedStatusPaneKeys.delete(paneKey)
this.promptSentDedupeByPaneKey.delete(paneKey)
}
if (aliasChanged) {
this.notifyPaneKeyAliasPersistenceListener()
}
if (statusChanged) {
this.scheduleStatusPersist()
this.notifyStatusChangeListeners()
}
}
clearPaneState(paneKey: string): void {
const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey)
// Why: only schedule a write when we actually evicted a status entry —
+58
View File
@@ -8,8 +8,11 @@ import { makePaneKey } from '../../shared/stable-pane-id'
// evicts the entry.
const dropStatusEntry = vi.fn()
const dropStatusEntriesByTabPrefix = vi.fn()
const getStatusSnapshot = vi.fn()
const inferInterrupt = vi.fn()
const clearMigrationUnsupportedPtysByTabPrefix = vi.fn()
const clearMigrationUnsupportedPtysForPaneKey = vi.fn()
const onHandlers = new Map<string, (event: unknown, ...args: unknown[]) => void>()
const handleHandlers = new Map<string, (event: unknown, ...args: unknown[]) => unknown>()
const removeHandler = vi.fn()
@@ -38,12 +41,19 @@ vi.mock('../agent-hooks/server', async () => {
...actual,
agentHookServer: {
dropStatusEntry,
dropStatusEntriesByTabPrefix,
getStatusSnapshot,
inferInterrupt
}
}
})
vi.mock('../agent-hooks/migration-unsupported-pty-state', () => ({
clearMigrationUnsupportedPtysByTabPrefix,
clearMigrationUnsupportedPtysForPaneKey,
getMigrationUnsupportedPtySnapshot: vi.fn(() => [])
}))
vi.mock('../claude/hook-service', () => ({
claudeHookService: { getStatus: vi.fn(() => ({ agent: 'claude', state: 'absent' })) }
}))
@@ -89,8 +99,11 @@ vi.mock('../kimi/hook-service', () => ({
beforeEach(() => {
dropStatusEntry.mockReset()
dropStatusEntriesByTabPrefix.mockReset()
getStatusSnapshot.mockReset()
inferInterrupt.mockReset()
clearMigrationUnsupportedPtysByTabPrefix.mockReset()
clearMigrationUnsupportedPtysForPaneKey.mockReset()
onHandlers.clear()
handleHandlers.clear()
removeHandler.mockReset()
@@ -294,6 +307,7 @@ describe('agentStatus:drop IPC', () => {
expect(handler).toBeDefined()
handler!({}, PANE_KEY)
expect(dropStatusEntry).toHaveBeenCalledWith(PANE_KEY)
expect(clearMigrationUnsupportedPtysForPaneKey).toHaveBeenCalledWith(PANE_KEY)
})
it('rejects non-string paneKey (defensive against a malformed renderer message)', async () => {
@@ -320,3 +334,47 @@ describe('agentStatus:drop IPC', () => {
expect(dropStatusEntry).not.toHaveBeenCalled()
})
})
describe('agentStatus:dropByTabPrefix IPC', () => {
it('forwards valid tab ids to tab-prefix cache eviction', async () => {
const { registerAgentHookHandlers } = await import('./agent-hooks')
registerAgentHookHandlers()
const handler = onHandlers.get('agentStatus:dropByTabPrefix')
expect(handler).toBeDefined()
handler!({}, 'tab-1')
expect(dropStatusEntriesByTabPrefix).toHaveBeenCalledWith('tab-1')
expect(clearMigrationUnsupportedPtysByTabPrefix).toHaveBeenCalledWith('tab-1')
})
it('rejects malformed tab ids', async () => {
const { registerAgentHookHandlers } = await import('./agent-hooks')
registerAgentHookHandlers()
const handler = onHandlers.get('agentStatus:dropByTabPrefix')!
const bad: unknown[] = [
123,
undefined,
'',
null,
{},
[],
'tab-1:leaf',
' leading-space',
'trailing-space ',
'x'.repeat(161)
]
for (const value of bad) {
expect(() => handler({}, value)).not.toThrow()
}
expect(dropStatusEntriesByTabPrefix).not.toHaveBeenCalled()
expect(clearMigrationUnsupportedPtysByTabPrefix).not.toHaveBeenCalled()
})
it('removes any existing listener before registering the tab-prefix channel', async () => {
const { registerAgentHookHandlers } = await import('./agent-hooks')
registerAgentHookHandlers()
expect(removeAllListeners).toHaveBeenCalledWith('agentStatus:dropByTabPrefix')
})
})
+25
View File
@@ -7,8 +7,10 @@ import type {
import type { AgentInterruptInferenceRequest } from '../../shared/agent-interrupt-intent'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
import { agentHookServer, isValidPaneKey } from '../agent-hooks/server'
import { isValidTerminalTabId } from '../../shared/terminal-tab-id'
import { ampHookService } from '../amp/hook-service'
import {
clearMigrationUnsupportedPtysByTabPrefix,
clearMigrationUnsupportedPtysForPaneKey,
getMigrationUnsupportedPtySnapshot
} from '../agent-hooks/migration-unsupported-pty-state'
@@ -31,6 +33,8 @@ type AgentStatusRuntimeEnrichment = Pick<
'getAgentStatusTerminalHandleForPaneKey' | 'getAgentStatusOrchestrationContextForPaneKey'
>
const MAX_AGENT_STATUS_DROP_TAB_ID_LENGTH = 160
function enrichAgentStatusIpcPayload(
data: AgentStatusIpcPayload,
runtime: AgentStatusRuntimeEnrichment | undefined
@@ -47,6 +51,15 @@ function enrichAgentStatusIpcPayload(
}
}
function isValidAgentStatusDropTabId(value: unknown): value is string {
return (
typeof value === 'string' &&
value.length <= MAX_AGENT_STATUS_DROP_TAB_ID_LENGTH &&
value.trim() === value &&
isValidTerminalTabId(value)
)
}
// Why: install/remove are intentionally not exposed to the renderer. Orca
// auto-installs managed hooks at app startup (see src/main/index.ts), so a
// renderer-triggered remove would be silently reverted on the next launch
@@ -80,6 +93,7 @@ export function registerAgentHookHandlers(runtime?: AgentStatusRuntimeEnrichment
// round-trip a response. Removing first keeps re-registration safe even
// though the module-level registered guard already prevents re-entry today.
ipcMain.removeAllListeners('agentStatus:drop')
ipcMain.removeAllListeners('agentStatus:dropByTabPrefix')
ipcMain.on('agentStatus:drop', (_event, paneKey: unknown) => {
if (typeof paneKey !== 'string' || !isValidPaneKey(paneKey)) {
return
@@ -95,6 +109,17 @@ export function registerAgentHookHandlers(runtime?: AgentStatusRuntimeEnrichment
console.warn('[agent-hooks] dropStatusEntry failed:', err)
}
})
ipcMain.on('agentStatus:dropByTabPrefix', (_event, tabId: unknown) => {
if (!isValidAgentStatusDropTabId(tabId)) {
return
}
try {
agentHookServer.dropStatusEntriesByTabPrefix(tabId)
clearMigrationUnsupportedPtysByTabPrefix(tabId)
} catch (err) {
console.warn('[agent-hooks] dropStatusEntriesByTabPrefix failed:', err)
}
})
ipcMain.handle('agentStatus:getSnapshot', (): AgentStatusIpcPayload[] => {
// Why: the renderer pulls this after workspace hydration, so startup cannot
// lose replayed statuses while its local store is still empty. Match the
+3
View File
@@ -2690,6 +2690,9 @@ export type PreloadApi = {
/** Drop a paneKey from the main-process hook cache and the on-disk
* last-status file. Fire-and-forget. */
drop: (paneKey: string) => void
/** Drop every cached hook status under one terminal tab prefix.
* Fire-and-forget. */
dropByTabPrefix: (tabId: string) => void
}
mobile: {
listNetworkInterfaces: () => Promise<{
+5
View File
@@ -3838,6 +3838,11 @@ const api = {
* cannot resurrect it. Fire-and-forget; no response. */
drop: (paneKey: string): void => {
ipcRenderer.send('agentStatus:drop', paneKey)
},
/** Drop all cached hook statuses under one terminal tab prefix. Fired on
* explicit tab close even when the renderer has no matching local row. */
dropByTabPrefix: (tabId: string): void => {
ipcRenderer.send('agentStatus:dropByTabPrefix', tabId)
}
},
+123
View File
@@ -3522,6 +3522,7 @@ describe('useIpcEvents agent status snapshot integration', () => {
runtimePaneTitlesByTabId: {},
terminalLayoutsByTabId: {},
agentStatusByPaneKey: {},
recentlyClosedAgentStatusTabIds: {},
repos: [],
worktreesByRepo: {},
tabsByWorktree: {},
@@ -4061,6 +4062,128 @@ describe('useIpcEvents agent status snapshot integration', () => {
)
})
it('drops late push events for a recently closed terminal tab', async () => {
const setAgentStatus = vi.fn()
const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = {
current: null
}
const storeState: StoreLike = buildStoreState({
setAgentStatus,
workspaceSessionReady: true,
recentlyClosedAgentStatusTabIds: { 'tab-future': true },
settings: { terminalFontSize: 13, notifications: { enabled: false } },
tabsByWorktree: {},
terminalLayoutsByTabId: {}
})
stubReactSyncEffect()
vi.doMock('../store', () => ({
useAppStore: {
subscribe: vi.fn(() => () => {}),
getState: () => storeState
}
}))
stubAuxiliaryModules()
vi.stubGlobal(
'window',
buildWindowApi({
onSet: (cb) => {
onSetListenerRef.current = cb
return () => {}
}
})
)
const { useIpcEvents } = await import('./useIpcEvents')
useIpcEvents()
await Promise.resolve()
if (typeof onSetListenerRef.current !== 'function') {
throw new Error('Expected agentStatus.onSet listener to be registered')
}
onSetListenerRef.current({
paneKey: FUTURE_PANE_KEY,
state: 'done',
prompt: 'late completion',
agentType: 'codex',
receivedAt: 1_700_000_000_200,
stateStartedAt: 1_699_999_999_100
})
expect(setAgentStatus).not.toHaveBeenCalled()
})
it('keeps missing-tab runtime attribution for tabs that were never explicitly closed', async () => {
const setAgentStatus = vi.fn()
const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = {
current: null
}
const storeState: StoreLike = buildStoreState({
setAgentStatus,
workspaceSessionReady: true,
recentlyClosedAgentStatusTabIds: {},
settings: { terminalFontSize: 13, notifications: { enabled: false } },
repos: [{ id: 'repo-1', connectionId: null }],
worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] },
tabsByWorktree: { 'wt-1': [] },
terminalLayoutsByTabId: {}
})
stubReactSyncEffect()
vi.doMock('../store', () => ({
useAppStore: {
subscribe: vi.fn(() => () => {}),
getState: () => storeState
}
}))
stubAuxiliaryModules()
vi.stubGlobal(
'window',
buildWindowApi({
onSet: (cb) => {
onSetListenerRef.current = cb
return () => {}
}
})
)
const { useIpcEvents } = await import('./useIpcEvents')
useIpcEvents()
await Promise.resolve()
if (typeof onSetListenerRef.current !== 'function') {
throw new Error('Expected agentStatus.onSet listener to be registered')
}
onSetListenerRef.current({
paneKey: FUTURE_PANE_KEY,
state: 'working',
prompt: 'runtime child',
agentType: 'codex',
worktreeId: 'wt-1',
receivedAt: 1_700_000_000_200,
stateStartedAt: 1_700_000_000_000,
orchestration: {
parentPaneKey: 'parent-tab:11111111-1111-4111-8111-111111111111'
}
})
expect(setAgentStatus).toHaveBeenCalledTimes(1)
expect(setAgentStatus).toHaveBeenCalledWith(
FUTURE_PANE_KEY,
expect.objectContaining({ state: 'working', prompt: 'runtime child', agentType: 'codex' }),
undefined,
{ updatedAt: 1_700_000_000_200, stateStartedAt: 1_700_000_000_000 },
expectWorktreeRouting('wt-1'),
undefined
)
})
it('clears a worktree-attributed live row when main reports pane teardown', async () => {
const setAgentStatus = vi.fn()
const removeAgentStatus = vi.fn()
+14
View File
@@ -228,6 +228,17 @@ let remoteWorkspaceSnapshotApplyDepth = 0
let remoteWorkspaceSnapshotWriteSuppressUntil = 0
const REMOTE_WORKSPACE_SNAPSHOT_WRITE_SUPPRESS_MS = 1000
function isAgentStatusForRecentlyClosedTab(
store: Pick<AppState, 'recentlyClosedAgentStatusTabIds'>,
paneKey: string
): boolean {
const tabId = parsePaneKey(paneKey)?.tabId
if (!tabId) {
return false
}
return store.recentlyClosedAgentStatusTabIds[tabId] === true
}
function getAuthoritativeDetectedWorktreeIds(state: AppState, repoId: string): Set<string> | null {
const detected = state.detectedWorktreesByRepo[repoId]
if (detected?.authoritative !== true) {
@@ -2644,6 +2655,9 @@ export function useIpcEvents(): void {
if (!store.workspaceSessionReady) {
return 'dropped'
}
if (isAgentStatusForRecentlyClosedTab(store, data.paneKey)) {
return 'dropped'
}
const payload = normalizeAgentStatusPayload({
state: data.state,
prompt: data.prompt,
@@ -86,7 +86,8 @@ describe('acknowledgedAgentsByPaneKey cleanup on teardown', () => {
vi.setSystemTime(new Date('2026-04-29T12:00:00.000Z'))
const store = createTestStore()
// Session 1: agent runs on tab-1:0, user acks it, tab closes.
// Session 1: agent runs on tab-1:0, user acks it, pane state is torn down
// without marking the whole tab closed.
store
.getState()
.setAgentStatus('tab-1:0', { state: 'working', prompt: 'first', agentType: 'claude' })
@@ -94,7 +95,7 @@ describe('acknowledgedAgentsByPaneKey cleanup on teardown', () => {
const firstAck = store.getState().acknowledgedAgentsByPaneKey['tab-1:0']
expect(firstAck).toBeGreaterThan(0)
store.getState().dropAgentStatusByTabPrefix('tab-1')
store.getState().removeAgentStatus('tab-1:0')
expect(store.getState().acknowledgedAgentsByPaneKey['tab-1:0']).toBeUndefined()
// Session 2: a brand-new tab+pane happens to collide on the same paneKey,
@@ -24,12 +24,16 @@ afterEach(() => {
}
})
function stubWindowApi(): { drop: ReturnType<typeof vi.fn> } {
function stubWindowApi(): {
drop: ReturnType<typeof vi.fn>
dropByTabPrefix: ReturnType<typeof vi.fn>
} {
const drop = vi.fn()
const dropByTabPrefix = vi.fn()
;(globalThis as { window?: unknown }).window = {
api: { agentStatus: { drop } }
api: { agentStatus: { drop, dropByTabPrefix } }
}
return { drop }
return { drop, dropByTabPrefix }
}
describe('dropAgentStatus → IPC fan-out', () => {
@@ -73,6 +77,50 @@ describe('dropAgentStatus → IPC fan-out', () => {
})
})
describe('dropAgentStatusByTabPrefix -> IPC fan-out', () => {
it('fires window.api.agentStatus.dropByTabPrefix after dropping local tab rows', () => {
const { dropByTabPrefix } = stubWindowApi()
const store = createTestStore()
store
.getState()
.setAgentStatus('tab-1:0', { state: 'working', prompt: 'p', agentType: 'claude' })
store
.getState()
.setAgentStatus('tab-2:0', { state: 'working', prompt: 'p', agentType: 'claude' })
store.getState().dropAgentStatusByTabPrefix('tab-1')
expect(dropByTabPrefix).toHaveBeenCalledTimes(1)
expect(dropByTabPrefix).toHaveBeenCalledWith('tab-1')
expect(store.getState().agentStatusByPaneKey['tab-1:0']).toBeUndefined()
expect(store.getState().agentStatusByPaneKey['tab-2:0']).toBeDefined()
})
it('fires dropByTabPrefix when no local rows match so stale main cache can be evicted', () => {
const { dropByTabPrefix } = stubWindowApi()
const store = createTestStore()
store.getState().dropAgentStatusByTabPrefix('tab-missing')
expect(dropByTabPrefix).toHaveBeenCalledTimes(1)
expect(dropByTabPrefix).toHaveBeenCalledWith('tab-missing')
expect(store.getState().recentlyClosedAgentStatusTabIds['tab-missing']).toBe(true)
})
it('keeps closed tab markers for the renderer session', () => {
stubWindowApi()
const store = createTestStore()
store.getState().dropAgentStatusByTabPrefix('tab-old')
store.getState().dropAgentStatusByTabPrefix('tab-new')
expect(store.getState().recentlyClosedAgentStatusTabIds).toEqual({
'tab-old': true,
'tab-new': true
})
})
})
describe('dismissRetainedAgentsByWorktree → IPC fan-out', () => {
it('fires drop once per dismissed paneKey under the worktree', () => {
const { drop } = stubWindowApi()
+39 -2
View File
@@ -104,6 +104,10 @@ export type AgentStatusSlice = {
* disappearance. Consumed by the retention sync as a one-shot suppressor. */
retentionSuppressedPaneKeys: Record<string, true>
/** Terminal tabs explicitly closed in this renderer session. Used only to
* drop late in-flight IPC statuses and stale main-cache replays. */
recentlyClosedAgentStatusTabIds: Record<string, true>
/** Update or insert an agent status entry from a status payload. */
setAgentStatus: (
paneKey: string,
@@ -227,6 +231,16 @@ function getLeafIdFromPaneKey(paneKey: string): string | null {
return leafId.length > 0 ? leafId : null
}
function isRecentlyClosedAgentStatusTab(
closedTabs: Record<string, true>,
tabId: string | null
): boolean {
if (!tabId) {
return false
}
return closedTabs[tabId] === true
}
function findAgentPaneWorktreeId(state: AppState, paneKey: string): string | null {
const tabId = getTabIdFromPaneKey(paneKey)
if (!tabId) {
@@ -770,6 +784,7 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
sleepingAgentSessionsByPaneKey: {},
agentLaunchConfigByPaneKey: {},
retentionSuppressedPaneKeys: {},
recentlyClosedAgentStatusTabIds: {},
setRuntimeAgentOrchestrationByPaneKey: (entries) => {
set((s) => {
@@ -915,6 +930,16 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
setAgentStatus: (paneKey, payload, terminalTitle, timing, routing, metadata) => {
const updatedAt = timing?.updatedAt ?? Date.now()
if (
// Why: a closed terminal tab is no longer a valid destination for hook
// replays or late status events, even if main still receives them.
isRecentlyClosedAgentStatusTab(
get().recentlyClosedAgentStatusTabIds,
getTabIdFromPaneKey(paneKey)
)
) {
return
}
let completionRefreshWorktreeId: string | null = null
let suppressedInheritedTerminalStatus = false
set((s) => {
@@ -1528,6 +1553,11 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
delete nextAck[k]
}
}
const nextClosedTabs: Record<string, true> = {
...s.recentlyClosedAgentStatusTabIds,
[tabIdPrefix]: true
}
if (
liveKeys.length === 0 &&
launchConfigKeys.length === 0 &&
@@ -1535,9 +1565,12 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
!migrationUnsupported.changed
) {
if (nextAck !== s.acknowledgedAgentsByPaneKey) {
return { acknowledgedAgentsByPaneKey: nextAck }
return {
acknowledgedAgentsByPaneKey: nextAck,
recentlyClosedAgentStatusTabIds: nextClosedTabs
}
}
return s
return { recentlyClosedAgentStatusTabIds: nextClosedTabs }
}
hadLive = liveKeys.length > 0
@@ -1589,6 +1622,7 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
retainedAgentsByPaneKey: nextRetained,
migrationUnsupportedByPtyId: migrationUnsupported.next,
retentionSuppressedPaneKeys: nextRetentionSuppressedPaneKeys,
recentlyClosedAgentStatusTabIds: nextClosedTabs,
...(nextAck !== s.acknowledgedAgentsByPaneKey
? { acknowledgedAgentsByPaneKey: nextAck }
: {}),
@@ -1603,6 +1637,9 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
if (hadLive) {
queueMicrotask(() => freshness.schedule())
}
if (typeof window !== 'undefined') {
window.api?.agentStatus?.dropByTabPrefix?.(tabIdPrefix)
}
},
dropHibernatedAgentStatusPane: (worktreeId, paneKey, opts) => {
+2 -1
View File
@@ -655,7 +655,8 @@ function createWebPreloadApi(): Partial<PreloadApi> {
onMigrationUnsupported: () => noopUnsubscribe,
onMigrationUnsupportedClear: () => noopUnsubscribe,
getMigrationUnsupportedSnapshot: () => Promise.resolve([]),
drop: () => {}
drop: () => {},
dropByTabPrefix: () => {}
},
mobile: {
listNetworkInterfaces: () => Promise.resolve({ interfaces: [] }),