From 63ee660da8b0b60917456fc924787bc99949f7fc Mon Sep 17 00:00:00 2001 From: laibao Date: Thu, 22 Oct 2026 17:53:17 +0800 Subject: [PATCH 1/5] fix(agent-status): preserve Codex turn state across compaction --- .../server-codex-review-turn-end.test.ts | 101 +++++++++--------- .../providers/codex-compaction-status.test.ts | 97 +++++++++++++++++ .../providers/codex-events.ts | 4 + 3 files changed, 154 insertions(+), 48 deletions(-) create mode 100644 src/shared/agent-hook-listener/providers/codex-compaction-status.test.ts diff --git a/src/main/agent-hooks/server-codex-review-turn-end.test.ts b/src/main/agent-hooks/server-codex-review-turn-end.test.ts index 9099490d648f..0b0be49ccfcd 100644 --- a/src/main/agent-hooks/server-codex-review-turn-end.test.ts +++ b/src/main/agent-hooks/server-codex-review-turn-end.test.ts @@ -69,57 +69,62 @@ describe('Codex turn completion without Stop', () => { rmSync(directory, { recursive: true, force: true }) }) - it('settles a /review from its child rollout and leaves later turns working', async () => { - await startHost(false) - const childPath = join(directory, 'review-child.jsonl') - writeFileSync(childPath, `${reviewRecords[0]}\n`) - // Real review hooks carry the root session ID and child turn/path, with no agent_id or Stop. - const review = { transcript_path: childPath, turn_id: 'child-turn' } - await hook({ ...review, hook_event_name: 'UserPromptSubmit', prompt: 'Review changes' }) - for (const hookEventName of ['PreToolUse', 'PostToolUse', 'PreToolUse', 'PostToolUse']) { - await hook({ ...review, hook_event_name: hookEventName, tool_name: 'Bash' }) - } - expect(desktop.getStatusSnapshot()[0]).toMatchObject({ - state: 'working', - providerSession: { id: 'root-session', transcriptPath: childPath } - }) + it.each([false, true])( + 'settles a /review and preserves completion across compaction (relayed=%s)', + async (relayed) => { + await startHost(relayed) + const childPath = join(directory, 'review-child.jsonl') + writeFileSync(childPath, `${reviewRecords[0]}\n`) + // Real review hooks carry the root session ID and child turn/path, with no agent_id or Stop. + const review = { transcript_path: childPath, turn_id: 'child-turn' } + await hook({ ...review, hook_event_name: 'UserPromptSubmit', prompt: 'Review changes' }) + for (const hookEventName of ['PreToolUse', 'PostToolUse', 'PreToolUse', 'PostToolUse']) { + await hook({ ...review, hook_event_name: hookEventName, tool_name: 'Bash' }) + } + expect(desktop.getStatusSnapshot()[0]).toMatchObject({ + state: 'working', + providerSession: { id: 'root-session', transcriptPath: childPath } + }) - appendFileSync(childPath, `${reviewRecords[1]}\n`) - await vi.waitFor( - () => - expect(desktop.getStatusSnapshot()[0]).toMatchObject({ - state: 'done', - mainAgent: { state: 'done' } - }), - { timeout: 3_000 } - ) - // Desktops that predate host-owned turn verdicts still recognize a labelled Stop. - expect(published.at(-1)).toBe('Stop') + appendFileSync(childPath, `${reviewRecords[1]}\n`) + await vi.waitFor( + () => + expect(desktop.getStatusSnapshot()[0]).toMatchObject({ + state: 'done', + mainAgent: { state: 'done' } + }), + { timeout: 3_000 } + ) + // Desktops that predate host-owned turn verdicts still recognize a labelled Stop. + expect(published.at(-1)).toBe('Stop') - // Neither a compaction queued before the next prompt nor a newer turn replays the review's end. - await hook({ hook_event_name: 'SessionStart', source: 'compact', transcript_path: childPath }) - await settle() - expect(published.at(-1)).toBe('SessionStart') - appendFileSync(childPath, event({ type: 'task_started', turn_id: 'next-turn' })) - await hook({ - ...review, - turn_id: 'next-turn', - hook_event_name: 'UserPromptSubmit', - prompt: 'Next' - }) - await settle() - expect(desktop.getStatusSnapshot()[0]).toMatchObject({ mainAgent: { state: 'working' } }) - // A delayed hook from the review's ended turn does not end the newer turn. - await hook({ ...review, hook_event_name: 'PostToolUse', tool_name: 'Bash' }) - expect(desktop.getStatusSnapshot()[0]).toMatchObject({ mainAgent: { state: 'working' } }) + // Neither a compaction queued before the next prompt nor a newer turn replays the review's end. + await hook({ hook_event_name: 'SessionStart', source: 'compact', transcript_path: childPath }) + await settle() + expect(published.at(-1)).toBe('Stop') + expect(desktop.getStatusSnapshot()[0]).toMatchObject({ state: 'done' }) + appendFileSync(childPath, event({ type: 'task_started', turn_id: 'next-turn' })) + await hook({ + ...review, + turn_id: 'next-turn', + hook_event_name: 'UserPromptSubmit', + prompt: 'Next' + }) + await settle() + expect(desktop.getStatusSnapshot()[0]).toMatchObject({ mainAgent: { state: 'working' } }) + // A delayed hook from the review's ended turn does not end the newer turn. + await hook({ ...review, hook_event_name: 'PostToolUse', tool_name: 'Bash' }) + expect(desktop.getStatusSnapshot()[0]).toMatchObject({ mainAgent: { state: 'working' } }) - // A real Stop that lands before its rollout end, then a compaction, never rearms that turn. - await hook({ ...review, turn_id: 'next-turn', hook_event_name: 'Stop' }) - await hook({ hook_event_name: 'SessionStart', source: 'compact', transcript_path: childPath }) - appendFileSync(childPath, event({ type: 'task_complete', turn_id: 'next-turn' })) - await settle() - expect(published.slice(-2)).toEqual(['Stop', 'SessionStart']) - }) + // A real Stop that lands before its rollout end, then a compaction, never rearms that turn. + await hook({ ...review, turn_id: 'next-turn', hook_event_name: 'Stop' }) + await hook({ hook_event_name: 'SessionStart', source: 'compact', transcript_path: childPath }) + appendFileSync(childPath, event({ type: 'task_complete', turn_id: 'next-turn' })) + await settle() + expect(published.at(-1)).toBe('Stop') + expect(desktop.getStatusSnapshot()[0]).toMatchObject({ state: 'done' }) + } + ) it.each([ ['locally', 'in an earlier poll', false, true], diff --git a/src/shared/agent-hook-listener/providers/codex-compaction-status.test.ts b/src/shared/agent-hook-listener/providers/codex-compaction-status.test.ts new file mode 100644 index 000000000000..dfc47ab5d70a --- /dev/null +++ b/src/shared/agent-hook-listener/providers/codex-compaction-status.test.ts @@ -0,0 +1,97 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { appendFileSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { normalizeAndAccept, PANE_KEY } from '../../agent-hook-listener-test-harness' +import { createHookListenerState, type HookListenerState } from '../listener-state' +import { pollCodexTranscriptStatus } from './codex-transcript-poll' +import { transcriptPollUpdate } from '../transcript-poll-policy' + +describe('Codex compaction status', () => { + let state: HookListenerState + let directory: string + let transcriptPath: string + + beforeEach(() => { + state = createHookListenerState() + directory = mkdtempSync(join(tmpdir(), 'codex-compaction-')) + transcriptPath = join(directory, 'rollout.jsonl') + writeFileSync(transcriptPath, event('task_started')) + }) + + afterEach(() => rmSync(directory, { recursive: true, force: true })) + + function event(type: string): string { + return `${JSON.stringify({ type: 'event_msg', payload: { type, turn_id: 'turn-1' } })}\n` + } + + function hook(payload: Record) { + return normalizeAndAccept(state, 'codex', { + session_id: 'session-1', + transcript_path: transcriptPath, + ...payload + }) + } + + it.each([undefined, 'turn-1'])( + 'keeps a finished turn done when compact carries turn %s', + (turnId) => { + hook({ hook_event_name: 'UserPromptSubmit', turn_id: 'turn-1', prompt: 'Do work' }) + appendFileSync(transcriptPath, event('task_complete')) + const done = hook({ hook_event_name: 'Stop', turn_id: 'turn-1' }) + + expect( + hook({ hook_event_name: 'SessionStart', source: 'compact', turn_id: turnId }) + ).toBeNull() + expect(state.lastStatusByPaneKey.get(PANE_KEY)).toBe(done) + expect(state.codexLeadStateByPaneKey.get(PANE_KEY)).toMatchObject({ + state: 'done', + turnId: 'turn-1' + }) + + expect( + hook({ hook_event_name: 'UserPromptSubmit', turn_id: 'turn-2', prompt: 'Next' })?.payload + ).toMatchObject({ state: 'working', prompt: 'Next' }) + } + ) + + it('keeps a live turn and its child roster so transcript completion still settles the row', () => { + hook({ hook_event_name: 'UserPromptSubmit', turn_id: 'turn-1', prompt: 'Do work' }) + const original = hook({ hook_event_name: 'SubagentStart', agent_id: 'child-1' }) + if (!original) { + throw new Error('Expected a working status') + } + const lead = state.codexLeadStateByPaneKey.get(PANE_KEY) + const transcript = state.codexSubagentTranscriptByPaneKey.get(PANE_KEY) + + expect(hook({ hook_event_name: 'SessionStart', source: 'compact' })).toBeNull() + expect(state.codexLeadStateByPaneKey.get(PANE_KEY)).toBe(lead) + expect(state.codexSubagentTranscriptByPaneKey.get(PANE_KEY)).toBe(transcript) + expect(state.lastStatusByPaneKey.get(PANE_KEY)?.payload.subagents).toEqual([ + expect.objectContaining({ id: 'child-1', state: 'working' }) + ]) + + appendFileSync(transcriptPath, event('task_complete')) + const polled = pollCodexTranscriptStatus(state, original, { paneKey: PANE_KEY }, 'production') + if (!polled) { + throw new Error('Expected transcript completion') + } + expect(transcriptPollUpdate('codex', original, polled)).toMatchObject({ + hookEventName: 'Stop', + payload: { state: 'done', prompt: 'Do work', mainAgent: { state: 'done' } } + }) + }) + + it('preserves a pending human question across compaction', () => { + hook({ hook_event_name: 'UserPromptSubmit', turn_id: 'turn-1', prompt: 'Do work' }) + const waiting = hook({ + hook_event_name: 'PreToolUse', + turn_id: 'turn-1', + tool_name: 'request_user_input', + tool_input: { questions: [{ id: 'choice', question: 'Which option?' }] } + }) + expect(waiting?.payload.state).toBe('waiting') + expect(hook({ hook_event_name: 'SessionStart', source: 'compact' })).toBeNull() + expect(state.lastStatusByPaneKey.get(PANE_KEY)).toBe(waiting) + }) +}) diff --git a/src/shared/agent-hook-listener/providers/codex-events.ts b/src/shared/agent-hook-listener/providers/codex-events.ts index aa2f0f08e52c..94dc88993e99 100644 --- a/src/shared/agent-hook-listener/providers/codex-events.ts +++ b/src/shared/agent-hook-listener/providers/codex-events.ts @@ -297,6 +297,10 @@ export function codexHookEventForEndedTurn( eventName: unknown, hookPayload: Record ): unknown { + // Compaction continues the same session; it must not reopen a finished turn or erase a live one. + if (eventName === 'SessionStart' && readString(hookPayload, 'source') === 'compact') { + return undefined + } const turnId = readString(hookPayload, 'turn_id') if ( !turnId || From a057f01eaa58aa9e6ad1672a8a6159938a9fb9d1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:48:26 -0700 Subject: [PATCH 2/5] test: cover compacted Codex status consumers --- .../index-codex-compaction-consumers.test.ts | 179 ++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 src/cli/index-codex-compaction-consumers.test.ts diff --git a/src/cli/index-codex-compaction-consumers.test.ts b/src/cli/index-codex-compaction-consumers.test.ts new file mode 100644 index 000000000000..9d01c15de608 --- /dev/null +++ b/src/cli/index-codex-compaction-consumers.test.ts @@ -0,0 +1,179 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { AgentHookServer } from '../main/agent-hooks/server' +import { buildBody, PANE, postHookEvent } from '../main/agent-hooks/server.test-fixtures' +import { + createTranscriptPane, + waitForTranscriptIdle +} from '../main/runtime/agent-transcript-pane-test-harness' +import type { ResolvedWorktreeSnapshot } from '../main/runtime/runtime-resolved-worktree-cache' +import * as stores from '../renderer/src/store/slices/store-test-helpers' +import { buildWindowApi } from '../renderer/src/hooks/ipc-events-agent-status-window-test-fixtures' +import { toAgentStatusIpcPayload } from '../main/agent-hooks/server/server-status-identity' + +const mocks = vi.hoisted(() => ({ + callMock: vi.fn(), + runtimeClientConstructorMock: vi.fn(), + serveOrcaAppMock: vi.fn(), + getDefaultUserDataPathMock: vi.fn(() => '/tmp') +})) +vi.mock('./runtime-client', async () => { + const { createRuntimeClientModuleMock } = await import('./index-test-harness') + return createRuntimeClientModuleMock(mocks) +}) +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp') } +})) +import { main } from './index' + +function codexCompactionSequence(state: 'done' | 'waiting'): Record[] { + return [ + { hook_event_name: 'UserPromptSubmit', turn_id: 'turn-1', prompt: 'Do work' }, + state === 'done' + ? { hook_event_name: 'Stop', turn_id: 'turn-1' } + : { + hook_event_name: 'PreToolUse', + turn_id: 'turn-1', + tool_name: 'request_user_input', + tool_input: { questions: [{ id: 'choice', question: 'Which option?' }] } + }, + { hook_event_name: 'SessionStart', source: 'compact' } + ].map((payload) => ({ session_id: 'compact-session', ...payload })) +} + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllEnvs() + vi.unstubAllGlobals() + vi.resetModules() +}) + +it.each(['done', 'waiting'] as const)( + 'keeps compacted %s through runtime consumers and CLI commands', + async (state) => { + const server = new AgentHookServer() + const store = stores.createTestStore() + store.setState({ + workspaceSessionReady: true, + tabsByWorktree: { 'wt-1': [stores.makeTab({ id: 'tab-1', worktreeId: 'wt-1' })] } + }) + vi.doMock('../renderer/src/store', () => ({ useAppStore: store })) + vi.stubGlobal( + 'window', + buildWindowApi({ + onSet: (listener) => { + server.setListener((row) => listener(toAgentStatusIpcPayload(row))) + return () => server.setListener(null) + }, + getSnapshot: async () => server.getStatusSnapshot() + }) + ) + const { registerAgentStatusIpcBridge } = + await import('../renderer/src/hooks/ipc-events/agent-status-ipc-bridge') + const unsubs: (() => void)[] = [] + let bridge = registerAgentStatusIpcBridge(unsubs) + const stopBridge = () => { + bridge.disposeAsyncState() + bridge.unsubscribeStore() + unsubs.splice(0).forEach((fn) => fn()) + } + await server.start({ env: 'production' }) + const pane = await createTranscriptPane( + { + paneTitle: 'Codex', + foregroundProcess: 'codex', + launchAgent: 'codex', + data: '• Working (4s • esc to interrupt)\r\n' + }, + { + getAgentStatusSnapshot: () => server.getStatusSnapshot(), + getAgentProviderSessionRowsForPane: () => server.getStatusSnapshot() + } + ) + const { runtime, handle } = pane + const worktree = stores.makeWorktree({ id: 'wt-1', repoId: 'repo-1', path: '/repo/app' }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: expose only the existing protected inventory seam; status projections stay real. + const inventory = runtime as unknown as { + listResolvedWorktreeSnapshot(): Promise + } + vi.spyOn(inventory, 'listResolvedWorktreeSnapshot').mockResolvedValue({ + worktrees: [ + { ...worktree, parentWorktreeId: null, childWorktreeIds: [], lineage: null, git: worktree } + ], + platformByRepoId: new Map() + }) + vi.stubEnv('ORCA_PAIRING_CODE', '') + vi.stubEnv('ORCA_ENVIRONMENT', '') + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + const exitCode = process.exitCode + try { + for (const payload of codexCompactionSequence(state)) { + const body = buildBody(payload, { launchToken: 'transcript-launch' }) + expect((await postHookEvent(server, body, '/hook/codex')).status).toBe(204) + } + const expected = { state, providerSession: { id: 'compact-session' } } + await vi.waitFor(() => + expect(store.getState().agentStatusByPaneKey[PANE]).toMatchObject(expected) + ) + stopBridge() + store.setState({ agentStatusByPaneKey: {} }) + bridge = registerAgentStatusIpcBridge(unsubs) + await vi.waitFor(() => + expect(store.getState().agentStatusByPaneKey[PANE]).toMatchObject(expected) + ) + store.getState().captureAllSleepingAgentSessions('quit') + expect(store.getState().sleepingAgentSessionsByPaneKey[PANE]).toMatchObject(expected) + const tabs = await runtime.listMobileSessionTabs('id:wt-1') + expect(tabs.tabs[0]).toMatchObject({ + agentStatus: { + state, + providerSession: { id: 'compact-session' }, + ...(state === 'waiting' + ? { + toolName: 'request_user_input', + interactivePrompt: expect.stringContaining('Which option?') + } + : {}) + } + }) + expect(runtime.getOrchestrationFleetAgentStatusSnapshot()[0].activity).toMatchObject({ + state, + paneKey: server.getStatusSnapshot()[0].paneKey + }) + expect(runtime.getExactWorkerProviderSession(handle, 0)).toMatchObject({ + agent: 'codex', + providerSession: { id: 'compact-session' } + }) + mocks.callMock.mockImplementation(async (method: string) => { + if (method === 'worktree.ps') { + return { ok: true, result: await runtime.getWorktreePs() } + } + if (method === 'terminal.wait') { + return { ok: true, result: { wait: await waitForTranscriptIdle(pane, 2_000) } } + } + throw new Error(`Unexpected RPC: ${method}`) + }) + await main(['worktree', 'ps', '--json'], '/repo/app') + expect( + JSON.parse(String(log.mock.calls.at(-1)?.[0])).result.worktrees[0].agents[0] + ).toMatchObject({ state, prompt: 'Do work' }) + await main( + ['terminal', 'wait', '--terminal', handle, '--for', 'tui-idle', '--json'], + '/repo/app' + ) + expect(JSON.parse(String(log.mock.calls.at(-1)?.[0]))).toMatchObject( + state === 'done' + ? { result: { wait: { satisfied: true } } } + : { ok: false, error: { message: 'timeout' } } + ) + expect(process.exitCode).toBe(state === 'waiting' ? 1 : exitCode) + } finally { + stopBridge() + process.exitCode = exitCode + runtime.onPtyExit('pty-1', 0) + server.stop() + } + } +) From 38cc6cedb8d3df5de81e3605f0542464726ce578 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:57:36 -0700 Subject: [PATCH 3/5] test: preserve live Codex child after parent completion --- .../providers/codex-compaction-status.test.ts | 29 +++++++++++++++++-- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/src/shared/agent-hook-listener/providers/codex-compaction-status.test.ts b/src/shared/agent-hook-listener/providers/codex-compaction-status.test.ts index dfc47ab5d70a..7f9d0b47323a 100644 --- a/src/shared/agent-hook-listener/providers/codex-compaction-status.test.ts +++ b/src/shared/agent-hook-listener/providers/codex-compaction-status.test.ts @@ -3,7 +3,11 @@ import { appendFileSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { normalizeAndAccept, PANE_KEY } from '../../agent-hook-listener-test-harness' -import { createHookListenerState, type HookListenerState } from '../listener-state' +import { + createHookListenerState, + seedLegacyAgentStatusForTests, + type HookListenerState +} from '../listener-state' import { pollCodexTranscriptStatus } from './codex-transcript-poll' import { transcriptPollUpdate } from '../transcript-poll-policy' @@ -55,7 +59,7 @@ describe('Codex compaction status', () => { } ) - it('keeps a live turn and its child roster so transcript completion still settles the row', () => { + it('keeps a compacted parent and live child until their own completions', () => { hook({ hook_event_name: 'UserPromptSubmit', turn_id: 'turn-1', prompt: 'Do work' }) const original = hook({ hook_event_name: 'SubagentStart', agent_id: 'child-1' }) if (!original) { @@ -63,10 +67,12 @@ describe('Codex compaction status', () => { } const lead = state.codexLeadStateByPaneKey.get(PANE_KEY) const transcript = state.codexSubagentTranscriptByPaneKey.get(PANE_KEY) + const roster = state.codexSubagentRosterByPaneKey.get(PANE_KEY) expect(hook({ hook_event_name: 'SessionStart', source: 'compact' })).toBeNull() expect(state.codexLeadStateByPaneKey.get(PANE_KEY)).toBe(lead) expect(state.codexSubagentTranscriptByPaneKey.get(PANE_KEY)).toBe(transcript) + expect(state.codexSubagentRosterByPaneKey.get(PANE_KEY)).toBe(roster) expect(state.lastStatusByPaneKey.get(PANE_KEY)?.payload.subagents).toEqual([ expect.objectContaining({ id: 'child-1', state: 'working' }) ]) @@ -78,8 +84,25 @@ describe('Codex compaction status', () => { } expect(transcriptPollUpdate('codex', original, polled)).toMatchObject({ hookEventName: 'Stop', - payload: { state: 'done', prompt: 'Do work', mainAgent: { state: 'done' } } + payload: { + state: 'working', + prompt: 'Do work', + mainAgent: { state: 'done' }, + subagents: [{ id: 'child-1', state: 'working' }] + } }) + seedLegacyAgentStatusForTests(state, polled) + expect(state.codexLeadStateByPaneKey.get(PANE_KEY)).toMatchObject({ + state: 'done', + turnId: 'turn-1' + }) + const stopped = hook({ hook_event_name: 'SubagentStop', agent_id: 'child-1' }) + expect(stopped?.payload).toMatchObject({ state: 'done', mainAgent: { state: 'done' } }) + expect(stopped?.payload.subagents).toBeUndefined() + expect( + hook({ hook_event_name: 'UserPromptSubmit', turn_id: 'turn-2', prompt: 'Next' })?.payload + ).toMatchObject({ state: 'working', prompt: 'Next', mainAgent: { state: 'working' } }) + expect(state.codexLeadStateByPaneKey.get(PANE_KEY)?.turnId).toBe('turn-2') }) it('preserves a pending human question across compaction', () => { From 47666879894b12853917102f90cbbf1664677309 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 25 Oct 2026 13:13:48 -0700 Subject: [PATCH 4/5] test: isolate Codex compaction consumer compiler domains --- .../agent-transcript-pane-test-harness.ts | 5 +- .../codex-compaction-consumers.test.ts} | 108 ++++++------------ .../agent-status-codex-compaction.test.ts | 86 ++++++++++++++ .../agent-hook-listener-test-harness.ts | 15 +++ 4 files changed, 136 insertions(+), 78 deletions(-) rename src/{cli/index-codex-compaction-consumers.test.ts => main/runtime/codex-compaction-consumers.test.ts} (50%) create mode 100644 src/renderer/src/hooks/ipc-events/agent-status-codex-compaction.test.ts diff --git a/src/main/runtime/agent-transcript-pane-test-harness.ts b/src/main/runtime/agent-transcript-pane-test-harness.ts index fbde1c071cc4..1bccdc43da91 100644 --- a/src/main/runtime/agent-transcript-pane-test-harness.ts +++ b/src/main/runtime/agent-transcript-pane-test-harness.ts @@ -54,9 +54,10 @@ function transcriptLayoutStore(): RuntimeStore { export async function createTranscriptPane( options: TranscriptPaneOptions, - runtimeDeps?: ConstructorParameters[2] + runtimeDeps?: ConstructorParameters[2], + Runtime: typeof OrcaRuntimeService = OrcaRuntimeService ): Promise<{ runtime: OrcaRuntimeService; handle: string }> { - const runtime = new OrcaRuntimeService( + const runtime = new Runtime( ORCA_LAYOUT_OWNER_IS_MAIN ? transcriptLayoutStore() : null, undefined, runtimeDeps diff --git a/src/cli/index-codex-compaction-consumers.test.ts b/src/main/runtime/codex-compaction-consumers.test.ts similarity index 50% rename from src/cli/index-codex-compaction-consumers.test.ts rename to src/main/runtime/codex-compaction-consumers.test.ts index 9d01c15de608..59a72c3ada90 100644 --- a/src/cli/index-codex-compaction-consumers.test.ts +++ b/src/main/runtime/codex-compaction-consumers.test.ts @@ -1,14 +1,12 @@ import { afterEach, expect, it, vi } from 'vitest' -import { AgentHookServer } from '../main/agent-hooks/server' -import { buildBody, PANE, postHookEvent } from '../main/agent-hooks/server.test-fixtures' -import { - createTranscriptPane, - waitForTranscriptIdle -} from '../main/runtime/agent-transcript-pane-test-harness' -import type { ResolvedWorktreeSnapshot } from '../main/runtime/runtime-resolved-worktree-cache' -import * as stores from '../renderer/src/store/slices/store-test-helpers' -import { buildWindowApi } from '../renderer/src/hooks/ipc-events-agent-status-window-test-fixtures' -import { toAgentStatusIpcPayload } from '../main/agent-hooks/server/server-status-identity' +import { AgentHookServer } from '../agent-hooks/server' +import { buildBody, postHookEvent } from '../agent-hooks/server.test-fixtures' +import { createTranscriptPane, waitForTranscriptIdle } from './agent-transcript-pane-test-harness' +import { OrcaRuntimeService } from './orca-runtime' +import type { ResolvedWorktreeSnapshot } from './runtime-resolved-worktree-cache' +import { storeWorktreeMeta } from './runtime-store-partial-fixture' +import { codexCompactionSequence } from '../../shared/agent-hook-listener-test-harness' +import { buildWorktree } from '../../cli/test-fixtures' const mocks = vi.hoisted(() => ({ callMock: vi.fn(), @@ -16,8 +14,8 @@ const mocks = vi.hoisted(() => ({ serveOrcaAppMock: vi.fn(), getDefaultUserDataPathMock: vi.fn(() => '/tmp') })) -vi.mock('./runtime-client', async () => { - const { createRuntimeClientModuleMock } = await import('./index-test-harness') +vi.mock('../../cli/runtime-client', async () => { + const { createRuntimeClientModuleMock } = await import('../../cli/index-test-harness.js') return createRuntimeClientModuleMock(mocks) }) vi.mock('electron', () => ({ @@ -26,21 +24,27 @@ vi.mock('electron', () => ({ ipcMain: { on: vi.fn(), removeListener: vi.fn() }, app: { getPath: vi.fn(() => '/tmp') } })) -import { main } from './index' +import { main } from '../../cli/index' -function codexCompactionSequence(state: 'done' | 'waiting'): Record[] { - return [ - { hook_event_name: 'UserPromptSubmit', turn_id: 'turn-1', prompt: 'Do work' }, - state === 'done' - ? { hook_event_name: 'Stop', turn_id: 'turn-1' } - : { - hook_event_name: 'PreToolUse', - turn_id: 'turn-1', - tool_name: 'request_user_input', - tool_input: { questions: [{ id: 'choice', question: 'Which option?' }] } - }, - { hook_event_name: 'SessionStart', source: 'compact' } - ].map((payload) => ({ session_id: 'compact-session', ...payload })) +class CompactionRuntime extends OrcaRuntimeService { + protected override async listResolvedWorktreeSnapshot(): Promise { + const { git } = buildWorktree('/repo/app', 'feature') + return { + worktrees: [ + { + ...storeWorktreeMeta(), + ...git, + id: 'wt-1', + repoId: 'repo-1', + parentWorktreeId: null, + childWorktreeIds: [], + lineage: null, + git + } + ], + platformByRepoId: new Map() + } + } } afterEach(() => { @@ -54,31 +58,6 @@ it.each(['done', 'waiting'] as const)( 'keeps compacted %s through runtime consumers and CLI commands', async (state) => { const server = new AgentHookServer() - const store = stores.createTestStore() - store.setState({ - workspaceSessionReady: true, - tabsByWorktree: { 'wt-1': [stores.makeTab({ id: 'tab-1', worktreeId: 'wt-1' })] } - }) - vi.doMock('../renderer/src/store', () => ({ useAppStore: store })) - vi.stubGlobal( - 'window', - buildWindowApi({ - onSet: (listener) => { - server.setListener((row) => listener(toAgentStatusIpcPayload(row))) - return () => server.setListener(null) - }, - getSnapshot: async () => server.getStatusSnapshot() - }) - ) - const { registerAgentStatusIpcBridge } = - await import('../renderer/src/hooks/ipc-events/agent-status-ipc-bridge') - const unsubs: (() => void)[] = [] - let bridge = registerAgentStatusIpcBridge(unsubs) - const stopBridge = () => { - bridge.disposeAsyncState() - bridge.unsubscribeStore() - unsubs.splice(0).forEach((fn) => fn()) - } await server.start({ env: 'production' }) const pane = await createTranscriptPane( { @@ -90,20 +69,10 @@ it.each(['done', 'waiting'] as const)( { getAgentStatusSnapshot: () => server.getStatusSnapshot(), getAgentProviderSessionRowsForPane: () => server.getStatusSnapshot() - } + }, + CompactionRuntime ) const { runtime, handle } = pane - const worktree = stores.makeWorktree({ id: 'wt-1', repoId: 'repo-1', path: '/repo/app' }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: expose only the existing protected inventory seam; status projections stay real. - const inventory = runtime as unknown as { - listResolvedWorktreeSnapshot(): Promise - } - vi.spyOn(inventory, 'listResolvedWorktreeSnapshot').mockResolvedValue({ - worktrees: [ - { ...worktree, parentWorktreeId: null, childWorktreeIds: [], lineage: null, git: worktree } - ], - platformByRepoId: new Map() - }) vi.stubEnv('ORCA_PAIRING_CODE', '') vi.stubEnv('ORCA_ENVIRONMENT', '') const log = vi.spyOn(console, 'log').mockImplementation(() => {}) @@ -113,18 +82,6 @@ it.each(['done', 'waiting'] as const)( const body = buildBody(payload, { launchToken: 'transcript-launch' }) expect((await postHookEvent(server, body, '/hook/codex')).status).toBe(204) } - const expected = { state, providerSession: { id: 'compact-session' } } - await vi.waitFor(() => - expect(store.getState().agentStatusByPaneKey[PANE]).toMatchObject(expected) - ) - stopBridge() - store.setState({ agentStatusByPaneKey: {} }) - bridge = registerAgentStatusIpcBridge(unsubs) - await vi.waitFor(() => - expect(store.getState().agentStatusByPaneKey[PANE]).toMatchObject(expected) - ) - store.getState().captureAllSleepingAgentSessions('quit') - expect(store.getState().sleepingAgentSessionsByPaneKey[PANE]).toMatchObject(expected) const tabs = await runtime.listMobileSessionTabs('id:wt-1') expect(tabs.tabs[0]).toMatchObject({ agentStatus: { @@ -170,7 +127,6 @@ it.each(['done', 'waiting'] as const)( ) expect(process.exitCode).toBe(state === 'waiting' ? 1 : exitCode) } finally { - stopBridge() process.exitCode = exitCode runtime.onPtyExit('pty-1', 0) server.stop() diff --git a/src/renderer/src/hooks/ipc-events/agent-status-codex-compaction.test.ts b/src/renderer/src/hooks/ipc-events/agent-status-codex-compaction.test.ts new file mode 100644 index 000000000000..540d3a28c429 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/agent-status-codex-compaction.test.ts @@ -0,0 +1,86 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { createTestStore, makeTab } from '../../store/slices/store-test-helpers' +import { buildWindowApi } from '../ipc-events-agent-status-window-test-fixtures' +import type { AgentStatusSetData } from '../ipc-events-agent-status-store-test-fixtures' +import { + codexCompactionSequence, + normalizeAndAccept, + PANE_KEY +} from '../../../../shared/agent-hook-listener-test-harness' +import { createHookListenerState } from '../../../../shared/agent-hook-listener/listener-state' +import { toAgentStatusIpcPayload } from '../../../../main/agent-hooks/server/server-status-identity' + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + vi.resetModules() +}) + +it.each(['done', 'waiting'] as const)( + 'keeps compacted %s through desktop recovery', + async (state) => { + const store = createTestStore() + store.setState({ + workspaceSessionReady: true, + tabsByWorktree: { 'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })] } + }) + vi.doMock('../../store', () => ({ useAppStore: store })) + let listener: ((row: AgentStatusSetData) => void) | undefined + let snapshot: AgentStatusSetData[] = [] + vi.stubGlobal( + 'window', + buildWindowApi({ + onSet: (cb) => { + listener = cb + return () => { + listener = undefined + } + }, + getSnapshot: async () => snapshot + }) + ) + const { registerAgentStatusIpcBridge } = await import('./agent-status-ipc-bridge') + const unsubs: (() => void)[] = [] + let bridge = registerAgentStatusIpcBridge(unsubs) + const stopBridge = () => { + bridge.disposeAsyncState() + bridge.unsubscribeStore() + unsubs.splice(0).forEach((fn) => fn()) + } + try { + const hookState = createHookListenerState() + for (const payload of codexCompactionSequence(state)) { + const event = normalizeAndAccept(hookState, 'codex', payload) + if (!event) { + continue + } + const row = toAgentStatusIpcPayload({ + ...event, + tabId: 'tab-1', + worktreeId: 'wt-1', + receivedAt: Date.now(), + stateStartedAt: Date.now() + }) + if (row.providerSession?.key !== 'session_id') { + throw new Error('Expected Codex session_id metadata') + } + snapshot = [{ ...row, providerSession: { ...row.providerSession, key: 'session_id' } }] + listener?.(snapshot[0]) + } + const expected = { state, providerSession: { id: 'compact-session' } } + await vi.waitFor(() => + expect(store.getState().agentStatusByPaneKey[PANE_KEY]).toMatchObject(expected) + ) + stopBridge() + store.setState({ agentStatusByPaneKey: {} }) + bridge = registerAgentStatusIpcBridge(unsubs) + await vi.waitFor(() => + expect(store.getState().agentStatusByPaneKey[PANE_KEY]).toMatchObject(expected) + ) + store.getState().captureAllSleepingAgentSessions('quit') + expect(store.getState().sleepingAgentSessionsByPaneKey[PANE_KEY]).toMatchObject(expected) + } finally { + stopBridge() + } + } +) diff --git a/src/shared/agent-hook-listener-test-harness.ts b/src/shared/agent-hook-listener-test-harness.ts index 9cd1c2bbd09e..15930fdc5d2e 100644 --- a/src/shared/agent-hook-listener-test-harness.ts +++ b/src/shared/agent-hook-listener-test-harness.ts @@ -21,3 +21,18 @@ export function normalizeAndAccept( } return event } + +export function codexCompactionSequence(state: 'done' | 'waiting'): Record[] { + return [ + { hook_event_name: 'UserPromptSubmit', turn_id: 'turn-1', prompt: 'Do work' }, + state === 'done' + ? { hook_event_name: 'Stop', turn_id: 'turn-1' } + : { + hook_event_name: 'PreToolUse', + turn_id: 'turn-1', + tool_name: 'request_user_input', + tool_input: { questions: [{ id: 'choice', question: 'Which option?' }] } + }, + { hook_event_name: 'SessionStart', source: 'compact' } + ].map((payload) => ({ session_id: 'compact-session', ...payload })) +} From df3813e11a28cd0d8633f90c4263af94e92f822d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 25 Oct 2026 13:42:32 -0700 Subject: [PATCH 5/5] test: place Codex compaction consumers in integration domain --- ...agent-status-codex-compaction.unit.test.ts | 23 +++++++++-------- .../codex-compaction-consumers.unit.test.ts | 25 +++++++++++-------- 2 files changed, 27 insertions(+), 21 deletions(-) rename src/renderer/src/hooks/ipc-events/agent-status-codex-compaction.test.ts => tests/e2e/agent-status-codex-compaction.unit.test.ts (72%) rename src/main/runtime/codex-compaction-consumers.test.ts => tests/e2e/codex-compaction-consumers.unit.test.ts (83%) diff --git a/src/renderer/src/hooks/ipc-events/agent-status-codex-compaction.test.ts b/tests/e2e/agent-status-codex-compaction.unit.test.ts similarity index 72% rename from src/renderer/src/hooks/ipc-events/agent-status-codex-compaction.test.ts rename to tests/e2e/agent-status-codex-compaction.unit.test.ts index 540d3a28c429..f2ecf247616e 100644 --- a/src/renderer/src/hooks/ipc-events/agent-status-codex-compaction.test.ts +++ b/tests/e2e/agent-status-codex-compaction.unit.test.ts @@ -1,14 +1,14 @@ import { afterEach, expect, it, vi } from 'vitest' -import { createTestStore, makeTab } from '../../store/slices/store-test-helpers' -import { buildWindowApi } from '../ipc-events-agent-status-window-test-fixtures' -import type { AgentStatusSetData } from '../ipc-events-agent-status-store-test-fixtures' +import { createTestStore, makeTab } from '../../src/renderer/src/store/slices/store-test-helpers' +import { buildWindowApi } from '../../src/renderer/src/hooks/ipc-events-agent-status-window-test-fixtures' +import type { AgentStatusSetData } from '../../src/renderer/src/hooks/ipc-events-agent-status-store-test-fixtures' import { codexCompactionSequence, normalizeAndAccept, PANE_KEY -} from '../../../../shared/agent-hook-listener-test-harness' -import { createHookListenerState } from '../../../../shared/agent-hook-listener/listener-state' -import { toAgentStatusIpcPayload } from '../../../../main/agent-hooks/server/server-status-identity' +} from '../../src/shared/agent-hook-listener-test-harness' +import { createHookListenerState } from '../../src/shared/agent-hook-listener/listener-state' +import { toAgentStatusIpcPayload } from '../../src/main/agent-hooks/server/server-status-identity' afterEach(() => { vi.restoreAllMocks() @@ -19,12 +19,14 @@ afterEach(() => { it.each(['done', 'waiting'] as const)( 'keeps compacted %s through desktop recovery', async (state) => { + const now = 1_800_000_000_000 + vi.spyOn(Date, 'now').mockReturnValue(now) const store = createTestStore() store.setState({ workspaceSessionReady: true, tabsByWorktree: { 'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })] } }) - vi.doMock('../../store', () => ({ useAppStore: store })) + vi.doMock('../../src/renderer/src/store', () => ({ useAppStore: store })) let listener: ((row: AgentStatusSetData) => void) | undefined let snapshot: AgentStatusSetData[] = [] vi.stubGlobal( @@ -39,7 +41,8 @@ it.each(['done', 'waiting'] as const)( getSnapshot: async () => snapshot }) ) - const { registerAgentStatusIpcBridge } = await import('./agent-status-ipc-bridge') + const { registerAgentStatusIpcBridge } = + await import('../../src/renderer/src/hooks/ipc-events/agent-status-ipc-bridge') const unsubs: (() => void)[] = [] let bridge = registerAgentStatusIpcBridge(unsubs) const stopBridge = () => { @@ -58,8 +61,8 @@ it.each(['done', 'waiting'] as const)( ...event, tabId: 'tab-1', worktreeId: 'wt-1', - receivedAt: Date.now(), - stateStartedAt: Date.now() + receivedAt: now, + stateStartedAt: now }) if (row.providerSession?.key !== 'session_id') { throw new Error('Expected Codex session_id metadata') diff --git a/src/main/runtime/codex-compaction-consumers.test.ts b/tests/e2e/codex-compaction-consumers.unit.test.ts similarity index 83% rename from src/main/runtime/codex-compaction-consumers.test.ts rename to tests/e2e/codex-compaction-consumers.unit.test.ts index 59a72c3ada90..00fff1f526e7 100644 --- a/src/main/runtime/codex-compaction-consumers.test.ts +++ b/tests/e2e/codex-compaction-consumers.unit.test.ts @@ -1,12 +1,15 @@ import { afterEach, expect, it, vi } from 'vitest' -import { AgentHookServer } from '../agent-hooks/server' -import { buildBody, postHookEvent } from '../agent-hooks/server.test-fixtures' -import { createTranscriptPane, waitForTranscriptIdle } from './agent-transcript-pane-test-harness' -import { OrcaRuntimeService } from './orca-runtime' -import type { ResolvedWorktreeSnapshot } from './runtime-resolved-worktree-cache' -import { storeWorktreeMeta } from './runtime-store-partial-fixture' -import { codexCompactionSequence } from '../../shared/agent-hook-listener-test-harness' -import { buildWorktree } from '../../cli/test-fixtures' +import { AgentHookServer } from '../../src/main/agent-hooks/server' +import { buildBody, postHookEvent } from '../../src/main/agent-hooks/server.test-fixtures' +import { + createTranscriptPane, + waitForTranscriptIdle +} from '../../src/main/runtime/agent-transcript-pane-test-harness' +import { OrcaRuntimeService } from '../../src/main/runtime/orca-runtime' +import type { ResolvedWorktreeSnapshot } from '../../src/main/runtime/runtime-resolved-worktree-cache' +import { storeWorktreeMeta } from '../../src/main/runtime/runtime-store-partial-fixture' +import { codexCompactionSequence } from '../../src/shared/agent-hook-listener-test-harness' +import { buildWorktree } from '../../src/cli/test-fixtures' const mocks = vi.hoisted(() => ({ callMock: vi.fn(), @@ -14,8 +17,8 @@ const mocks = vi.hoisted(() => ({ serveOrcaAppMock: vi.fn(), getDefaultUserDataPathMock: vi.fn(() => '/tmp') })) -vi.mock('../../cli/runtime-client', async () => { - const { createRuntimeClientModuleMock } = await import('../../cli/index-test-harness.js') +vi.mock('../../src/cli/runtime-client', async () => { + const { createRuntimeClientModuleMock } = await import('../../src/cli/index-test-harness.js') return createRuntimeClientModuleMock(mocks) }) vi.mock('electron', () => ({ @@ -24,7 +27,7 @@ vi.mock('electron', () => ({ ipcMain: { on: vi.fn(), removeListener: vi.fn() }, app: { getPath: vi.fn(() => '/tmp') } })) -import { main } from '../../cli/index' +import { main } from '../../src/cli/index' class CompactionRuntime extends OrcaRuntimeService { protected override async listResolvedWorktreeSnapshot(): Promise {