diff --git a/src/main/agent-hooks/migration-unsupported-pty-state.test.ts b/src/main/agent-hooks/migration-unsupported-pty-state.test.ts index 25b801f30c6..6c9f3a9c08a 100644 --- a/src/main/agent-hooks/migration-unsupported-pty-state.test.ts +++ b/src/main/agent-hooks/migration-unsupported-pty-state.test.ts @@ -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]) + }) }) diff --git a/src/main/agent-hooks/migration-unsupported-pty-state.ts b/src/main/agent-hooks/migration-unsupported-pty-state.ts index 17fa0d09e8f..36bdc838569 100644 --- a/src/main/agent-hooks/migration-unsupported-pty-state.ts +++ b/src/main/agent-hooks/migration-unsupported-pty-state.ts @@ -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()) +} diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index d173acf8bc7..1a04818b673 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -54,6 +54,13 @@ type Body = { payload: Record } +type AgentHookServerCacheInternals = { + assistantMessageRetryTimers: Map> + promptSentDedupeByPaneKey: Map + runtimeObservedStatusPaneKeys: Set + scheduleStatusPersist: () => void +} + function buildBody(payload: Record, overrides: Partial = {}): 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 => + 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() diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 567bf392ac0..542a31c9c06 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -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>() private promptSentDedupeByPaneKey = new Map() private promptSentHashSalt = randomBytes(16).toString('hex') + private closedAgentStatusTabIds = new Set() // 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() + 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 — diff --git a/src/main/ipc/agent-hooks.test.ts b/src/main/ipc/agent-hooks.test.ts index 48201441a45..b4ff9701854 100644 --- a/src/main/ipc/agent-hooks.test.ts +++ b/src/main/ipc/agent-hooks.test.ts @@ -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 void>() const handleHandlers = new Map 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') + }) +}) diff --git a/src/main/ipc/agent-hooks.ts b/src/main/ipc/agent-hooks.ts index f168f833646..b1e8cb4b2cb 100644 --- a/src/main/ipc/agent-hooks.ts +++ b/src/main/ipc/agent-hooks.ts @@ -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 diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 91f58d645d3..de10206160e 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -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<{ diff --git a/src/preload/index.ts b/src/preload/index.ts index 5611df3becf..63522990d4f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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) } }, diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index e6b4d46cd36..16204df867e 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -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() diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 94977d77b7f..6475df50a42 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -228,6 +228,17 @@ let remoteWorkspaceSnapshotApplyDepth = 0 let remoteWorkspaceSnapshotWriteSuppressUntil = 0 const REMOTE_WORKSPACE_SNAPSHOT_WRITE_SUPPRESS_MS = 1000 +function isAgentStatusForRecentlyClosedTab( + store: Pick, + paneKey: string +): boolean { + const tabId = parsePaneKey(paneKey)?.tabId + if (!tabId) { + return false + } + return store.recentlyClosedAgentStatusTabIds[tabId] === true +} + function getAuthoritativeDetectedWorktreeIds(state: AppState, repoId: string): Set | 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, diff --git a/src/renderer/src/store/slices/agent-status-ack-cleanup.test.ts b/src/renderer/src/store/slices/agent-status-ack-cleanup.test.ts index 1cdaa7a6295..b763262a22d 100644 --- a/src/renderer/src/store/slices/agent-status-ack-cleanup.test.ts +++ b/src/renderer/src/store/slices/agent-status-ack-cleanup.test.ts @@ -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, diff --git a/src/renderer/src/store/slices/agent-status-drop-ipc.test.ts b/src/renderer/src/store/slices/agent-status-drop-ipc.test.ts index 3bfb4e1916d..db690363e47 100644 --- a/src/renderer/src/store/slices/agent-status-drop-ipc.test.ts +++ b/src/renderer/src/store/slices/agent-status-drop-ipc.test.ts @@ -24,12 +24,16 @@ afterEach(() => { } }) -function stubWindowApi(): { drop: ReturnType } { +function stubWindowApi(): { + drop: ReturnType + dropByTabPrefix: ReturnType +} { 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() diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index 9103f548f28..df638d3f144 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -104,6 +104,10 @@ export type AgentStatusSlice = { * disappearance. Consumed by the retention sync as a one-shot suppressor. */ retentionSuppressedPaneKeys: Record + /** Terminal tabs explicitly closed in this renderer session. Used only to + * drop late in-flight IPC statuses and stale main-cache replays. */ + recentlyClosedAgentStatusTabIds: Record + /** 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, + 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 { set((s) => { @@ -915,6 +930,16 @@ export const createAgentStatusSlice: StateCreator { 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 = { + ...s.recentlyClosedAgentStatusTabIds, + [tabIdPrefix]: true + } + if ( liveKeys.length === 0 && launchConfigKeys.length === 0 && @@ -1535,9 +1565,12 @@ export const createAgentStatusSlice: StateCreator 0 @@ -1589,6 +1622,7 @@ export const createAgentStatusSlice: StateCreator freshness.schedule()) } + if (typeof window !== 'undefined') { + window.api?.agentStatus?.dropByTabPrefix?.(tabIdPrefix) + } }, dropHibernatedAgentStatusPane: (worktreeId, paneKey, opts) => { diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 8cec6b9a39d..061ab0b12d5 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -655,7 +655,8 @@ function createWebPreloadApi(): Partial { onMigrationUnsupported: () => noopUnsubscribe, onMigrationUnsupportedClear: () => noopUnsubscribe, getMigrationUnsupportedSnapshot: () => Promise.resolve([]), - drop: () => {} + drop: () => {}, + dropByTabPrefix: () => {} }, mobile: { listNetworkInterfaces: () => Promise.resolve({ interfaces: [] }),