From 22685e4f311e8ddc6cf9a7bd44756f67697e1a4c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 28 Aug 2026 01:26:51 -0700 Subject: [PATCH] test(agent-status): cover the closed-tab lift IPC and its renderer trigger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lift's only production trigger — the renderer re-mirror call — and the IPC handler that carries it had no test: reverting either left every suite green. The server-side method was the sole covered hunk. Also adds liftClosedAgentStatusTabs to the agentHookServer double; the handler swallows its own errors, so the omitted field would have passed vacuously. --- src/main/ipc/agent-hooks.test.ts | 62 +++++++++++++++++++ ...ync-host-tab-retraction-ghost-rows.test.ts | 11 ++++ 2 files changed, 73 insertions(+) diff --git a/src/main/ipc/agent-hooks.test.ts b/src/main/ipc/agent-hooks.test.ts index 48440713cd0..8b77455c56c 100644 --- a/src/main/ipc/agent-hooks.test.ts +++ b/src/main/ipc/agent-hooks.test.ts @@ -9,6 +9,7 @@ import { makePaneKey } from '../../shared/stable-pane-id' const dropStatusEntry = vi.fn() const dropStatusEntriesByTabPrefix = vi.fn() +const liftClosedAgentStatusTabs = vi.fn() const retirePaneAuthority = vi.fn() const transferPaneAuthority = vi.fn() const canTransferPaneAuthority = vi.fn(() => true) @@ -45,6 +46,7 @@ vi.mock('../agent-hooks/server', async () => { agentHookServer: { dropStatusEntry, dropStatusEntriesByTabPrefix, + liftClosedAgentStatusTabs, retirePaneAuthority, transferPaneAuthority, canTransferPaneAuthority, @@ -106,6 +108,7 @@ vi.mock('../kimi/hook-service', () => ({ beforeEach(() => { dropStatusEntry.mockReset() dropStatusEntriesByTabPrefix.mockReset() + liftClosedAgentStatusTabs.mockReset() retirePaneAuthority.mockReset() transferPaneAuthority.mockReset() canTransferPaneAuthority.mockReset() @@ -389,6 +392,65 @@ describe('agentStatus:dropByTabPrefix IPC', () => { }) }) +describe('agentStatus:liftClosedTabs IPC', () => { + it('forwards validated tab ids so a returning tab stops being suppressed', async () => { + const { registerAgentHookHandlers } = await import('./agent-hooks') + registerAgentHookHandlers() + + const handler = onHandlers.get('agentStatus:liftClosedTabs') + expect(handler).toBeDefined() + handler!({}, ['tab-1', 'tab-2']) + + expect(liftClosedAgentStatusTabs).toHaveBeenCalledWith(['tab-1', 'tab-2']) + }) + + it('ignores a payload that is not an array', async () => { + const { registerAgentHookHandlers } = await import('./agent-hooks') + registerAgentHookHandlers() + + const handler = onHandlers.get('agentStatus:liftClosedTabs')! + for (const value of ['tab-1', 123, undefined, null, {}] as unknown[]) { + expect(() => handler({}, value)).not.toThrow() + } + + expect(liftClosedAgentStatusTabs).not.toHaveBeenCalled() + }) + + it('filters malformed ids out of an otherwise valid array', async () => { + const { registerAgentHookHandlers } = await import('./agent-hooks') + registerAgentHookHandlers() + + onHandlers.get('agentStatus:liftClosedTabs')!({}, [ + 'tab-1', + 123, + null, + 'tab-1:leaf', + ' leading-space', + 'x'.repeat(161) + ]) + + // Why assert the exact argument: a filter that let a malformed id through would still + // "have been called", so only the forwarded value proves the boundary held. + expect(liftClosedAgentStatusTabs).toHaveBeenCalledWith(['tab-1']) + }) + + it('skips the server entirely when every id is rejected', async () => { + const { registerAgentHookHandlers } = await import('./agent-hooks') + registerAgentHookHandlers() + + onHandlers.get('agentStatus:liftClosedTabs')!({}, ['', 'tab-1:leaf', 'x'.repeat(161)]) + + expect(liftClosedAgentStatusTabs).not.toHaveBeenCalled() + }) + + it('removes any existing listener before registering the lift channel', async () => { + const { registerAgentHookHandlers } = await import('./agent-hooks') + registerAgentHookHandlers() + + expect(removeAllListeners).toHaveBeenCalledWith('agentStatus:liftClosedTabs') + }) +}) + describe('agent pane authority IPC', () => { it('retires one validated pane authority', async () => { const { registerAgentHookHandlers } = await import('./agent-hooks') diff --git a/src/renderer/src/runtime/web-session-tabs-sync-host-tab-retraction-ghost-rows.test.ts b/src/renderer/src/runtime/web-session-tabs-sync-host-tab-retraction-ghost-rows.test.ts index 79d15c68fe8..f571af226b3 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync-host-tab-retraction-ghost-rows.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync-host-tab-retraction-ghost-rows.test.ts @@ -273,16 +273,23 @@ function observeSidebar(store: TestStore, now: number): SidebarObservation { } } +const liftClosedTabs = vi.fn() + describe('a host-retracted paired tab leaves no ghost agent row behind', () => { beforeEach(() => { vi.useFakeTimers() vi.setSystemTime(T0) resetWebSessionTabsSnapshotFreshnessForTests() resetRendererOwnedAgentStatusPanesForTests() + liftClosedTabs.mockClear() + // Why stub the bridge: lifting only the renderer marker leaves the tab suppressed in the + // main-process closed-tab set, which gates every hook event upstream of the renderer. + vi.stubGlobal('window', { ...globalThis.window, api: { agentStatus: { liftClosedTabs } } }) }) afterEach(() => { vi.useRealTimers() + vi.unstubAllGlobals() resetRendererOwnedAgentStatusPanesForTests() }) @@ -591,6 +598,10 @@ describe('a host-retracted paired tab leaves no ghost agent row behind', () => { store.getState().recentlyClosedAgentStatusTabIds[mirrorTabId(RETRACTED_TAB)], 'a re-mirrored tab id kept its closed-tab marker' ).toBeUndefined() + expect( + liftClosedTabs.mock.calls.flatMap(([tabIds]) => tabIds as string[]), + 'the renderer lifted its own marker but left the tab suppressed in the main process' + ).toContain(mirrorTabId(RETRACTED_TAB)) // The returning pane's byte-derived status must land again. const release2 = replayClientByteStatus(