test(agent-status): pin both half-migration structured-row filters

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.
This commit is contained in:
Merge Sim
2026-09-08 23:45:51 -07:00
parent 5d4e3f4319
commit 889acf1c1d
4 changed files with 184 additions and 1 deletions
@@ -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({
@@ -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
+31
View File
@@ -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 = [
{
@@ -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>
): 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'
])
})
})