From 889acf1c1d0d0c8762002ef35cf7044a6c25f3d5 Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Tue, 8 Sep 2026 23:45:51 -0700 Subject: [PATCH] test(agent-status): pin both half-migration structured-row filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neither the `agentStatus:getSnapshot` filter nor the main-window listener's had a single assertion, so deleting either — the first step of PR 2 — was green everywhere. Also covers the perf skip and the drop's lack of a renderer clear. --- .../server-ingest-structured-status.test.ts | 58 ++++++++++++ .../server/server-status-update.ts | 6 +- src/main/ipc/agent-hooks.test.ts | 31 +++++++ ...in-window-structured-status-filter.test.ts | 90 +++++++++++++++++++ 4 files changed, 184 insertions(+), 1 deletion(-) create mode 100644 src/main/startup/main-window-structured-status-filter.test.ts diff --git a/src/main/agent-hooks/server-ingest-structured-status.test.ts b/src/main/agent-hooks/server-ingest-structured-status.test.ts index 0dd64823577..5b43cc10bce 100644 --- a/src/main/agent-hooks/server-ingest-structured-status.test.ts +++ b/src/main/agent-hooks/server-ingest-structured-status.test.ts @@ -128,6 +128,64 @@ describe('AgentHookServer ingestStructuredStatus', () => { expect(server.getStatusSnapshot()).toEqual([]) }) + // The resume-identity remnant a dismissed PTY pane keeps exists so the agent can be resumed in + // that pane. A structured session has no pane, and the record store owns its resume identity — + // so a remnant here would be an unclearable row that every null-status publish re-minted. + it('leaves no resume-identity remnant behind, even carrying a provider session', () => { + const server = new AgentHookServer() + const withProviderSession = summary({ + providerSession: { key: 'session_id', id: 'codex-thread-1' } + }) + server.ingestStructuredStatus(withProviderSession) + expect(server.getStatusSnapshot()[0]?.providerSession).toEqual({ + key: 'session_id', + id: 'codex-thread-1' + }) + + server.dropStructuredStatus(SESSION) + expect(server.getStatusSnapshot()).toEqual([]) + }) + + // Structured rows are never serialized, so persisting one could only rewrite the file already + // on disk — once per debounce window for the whole of every streaming chat. + // "Exactly one writer per pane key" has to hold for deletes too: the renderer's feed bridge owns + // this pane, so a pane-status-clear would be main reaching into a row it does not write. + it('drops the row without sending the renderer a clear for a pane it does not write', () => { + const server = new AgentHookServer() + const cleared: unknown[] = [] + const dropped: string[] = [] + server.setPaneStatusClearListener((clear) => cleared.push(clear)) + server.subscribeStatusDrop((paneKey) => dropped.push(paneKey)) + + server.ingestStructuredStatus(summary()) + server.dropStructuredStatus(SESSION) + + expect(server.getStatusSnapshot()).toEqual([]) + expect(cleared).toEqual([]) + expect(dropped).toEqual([STRUCTURED_PANE]) + }) + + it('schedules no persist for a structured row, while a hook row still does', () => { + const server = new AgentHookServer() + const persists: number[] = [] + const scheduled = server as unknown as { scheduleStatusPersist: () => void } + const original = scheduled.scheduleStatusPersist.bind(server) + scheduled.scheduleStatusPersist = () => { + persists.push(1) + original() + } + + server.ingestStructuredStatus(summary()) + expect(persists).toHaveLength(0) + + server.ingestTerminalStatus({ + paneKey: PANE, + connectionId: null, + payload: { state: 'working', prompt: 'watch the build', agentType: 'claude' } + }) + expect(persists).toHaveLength(1) + }) + it('leaves a hook-reported pane alone', () => { const server = new AgentHookServer() server.ingestTerminalStatus({ diff --git a/src/main/agent-hooks/server/server-status-update.ts b/src/main/agent-hooks/server/server-status-update.ts index dff0bd40e9d..1a3798efe47 100644 --- a/src/main/agent-hooks/server/server-status-update.ts +++ b/src/main/agent-hooks/server/server-status-update.ts @@ -199,7 +199,11 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA this.runtimeObservedStatusPaneKeys.add(enriched.paneKey) } this.state.lastStatusByPaneKey.set(enriched.paneKey, enriched) - this.scheduleStatusPersist() + // Why skipped for structured rows: the serializer drops them, so the whole walk and stringify + // can only ever reproduce the last file — once per debounce window for a streaming chat. + if (!enriched.structuredHost) { + this.scheduleStatusPersist() + } this.notifyStatusChangeListeners() this.emitEnrichedStatus(enriched) return enriched diff --git a/src/main/ipc/agent-hooks.test.ts b/src/main/ipc/agent-hooks.test.ts index 411a6084124..001380e934d 100644 --- a/src/main/ipc/agent-hooks.test.ts +++ b/src/main/ipc/agent-hooks.test.ts @@ -152,6 +152,37 @@ describe('agentStatus:getSnapshot IPC', () => { expect(handler!({})).toEqual(snapshot) }) + // The half-migration seam: until PR 2 retires the renderer's own feed bridge, main must not + // publish structured rows to the renderer at all — one pane key, one writer. + it('omits structured rows the renderer feed bridge still owns', async () => { + getStatusSnapshot.mockReturnValue([ + { + paneKey: PANE_KEY, + state: 'done', + prompt: 'hook row', + agentType: 'claude', + connectionId: null, + receivedAt: 1_700_000_000_000, + stateStartedAt: 1_699_999_999_000 + }, + { + paneKey: CHILD_PANE_KEY, + state: 'working', + prompt: 'native chat row', + agentType: 'codex', + connectionId: null, + structuredHost: 'owned', + receivedAt: 1_700_000_001_000, + stateStartedAt: 1_700_000_000_500 + } + ]) + const { registerAgentHookHandlers } = await import('./agent-hooks') + registerAgentHookHandlers() + + const rows = handleHandlers.get('agentStatus:getSnapshot')!({}) as { paneKey: string }[] + expect(rows.map((row) => row.paneKey)).toEqual([PANE_KEY]) + }) + it('enriches the hook cache snapshot with runtime lineage metadata', async () => { const snapshot = [ { diff --git a/src/main/startup/main-window-structured-status-filter.test.ts b/src/main/startup/main-window-structured-status-filter.test.ts new file mode 100644 index 00000000000..a3cd1bfdf0a --- /dev/null +++ b/src/main/startup/main-window-structured-status-filter.test.ts @@ -0,0 +1,90 @@ +// The renderer half of the half-migration seam. +// +// Until PR 2 retires `StructuredAgentSessionStatusBridge`, the renderer writes structured rows +// itself. Main forwarding them too would give one pane key two writers, so the window listener +// drops them — a filter nothing else asserts, which makes deleting it green everywhere. + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { EnrichedAgentHookEventPayload } from '../agent-hooks/server' + +const hooks = vi.hoisted(() => ({ + listener: null as ((payload: EnrichedAgentHookEventPayload) => void) | null +})) + +vi.mock('electron', () => ({ + app: { getPath: () => '', on: vi.fn(), isReady: () => true } +})) +vi.mock('../agent-hooks/server', () => ({ + agentHookServer: { + setListener: (listener: ((payload: EnrichedAgentHookEventPayload) => void) | null) => { + hooks.listener = listener + }, + setPaneStatusClearListener: vi.fn() + } +})) +vi.mock('../agent-hooks/migration-unsupported-pty-state', () => ({ + setMigrationUnsupportedPtyListener: vi.fn() +})) +vi.mock('../window/dashboard-popout-window', () => ({ + getDashboardPopoutWindow: () => null +})) +vi.mock('./synthetic-title-runtime', () => ({ + driveSyntheticTitleFromHook: vi.fn(), + shouldSuppressCodexAutoApprovalSyntheticTitleFromHook: () => false, + stopAllSyntheticTitleSpinners: vi.fn() +})) + +import { installMainWindowAgentStatusListeners } from './main-window-agent-status' +import { mainProcessState } from './main-process-state' + +const sent: { channel: string; event: { paneKey: string } }[] = [] + +function statusPayload( + over: Partial +): EnrichedAgentHookEventPayload { + return { + paneKey: 'pane-1', + tabId: 'tab-1', + worktreeId: 'repo::/wt', + connectionId: null, + receivedAt: 1, + stateStartedAt: 1, + payload: { state: 'working', prompt: 'ship it', agentType: 'codex' }, + ...over + } as EnrichedAgentHookEventPayload +} + +beforeEach(() => { + sent.length = 0 + hooks.listener = null + mainProcessState.runtime = null + mainProcessState.mainWindow = { + isDestroyed: () => false, + webContents: { + send: (channel: string, event: { paneKey: string }) => sent.push({ channel, event }) + } + } as unknown as typeof mainProcessState.mainWindow + installMainWindowAgentStatusListeners({ + window: mainProcessState.mainWindow!, + maybeAutoRenameBranchOnFirstWork: vi.fn(), + onRecordAgentState: vi.fn() + }) +}) + +describe('the main-window agent-status listener', () => { + it('forwards a hook row but never a structured one', () => { + expect(hooks.listener).not.toBeNull() + + hooks.listener!(statusPayload({ paneKey: 'hook-pane' })) + hooks.listener!( + statusPayload({ + paneKey: 'structured-agent-session-s1:leaf', + structuredHost: 'owned' + }) + ) + + expect(sent.map((entry) => `${entry.channel}:${entry.event.paneKey}`)).toEqual([ + 'agentStatus:set:hook-pane' + ]) + }) +})