diff --git a/config/scripts/reclaim-release-test-caches.test.ts b/config/scripts/reclaim-release-test-caches.test.ts index 5715229ed5f..85d56da05e1 100644 --- a/config/scripts/reclaim-release-test-caches.test.ts +++ b/config/scripts/reclaim-release-test-caches.test.ts @@ -91,19 +91,24 @@ it('removes old generated trees and retains recent, future, malformed and mutabl it('does not follow cache, label, entry or shared-format symlinks', async () => { const options = await fixture() - const outside = await stamped(join(options.root, 'outside', `${commit}-format-4`), 4) + const outsideCache = join(options.root, 'outside-cache') + const outsideLabel = join(options.root, 'outside-label') + const outsideEntry = join(options.root, 'outside-entry', `${commit}-format-4`) + const outsideFormat = join(options.root, 'outside-format') + const sentinels = [ + await stamped(join(outsideCache, 'v-old', `${commit}-format-4`), 4), + await stamped(join(outsideLabel, `${commit}-format-4`), 4), + await stamped(outsideEntry, 4), + await stamped(join(outsideFormat, commit), CHECKOUT_FORMAT) + ] const second = join(options.root, 'linked-worktree') await mkdir(join(second, 'tests', 'e2e'), { recursive: true }) - await symlink( - join(outside, '..'), - join(second, 'tests', 'e2e', '.cross-version-checkouts'), - 'junction' - ) + await symlink(outsideCache, join(second, 'tests', 'e2e', '.cross-version-checkouts'), 'junction') options.targets.worktrees.push(second) - await symlink(join(outside, '..'), join(options.cache, 'linked-label'), 'junction') + await symlink(outsideLabel, join(options.cache, 'linked-label'), 'junction') await mkdir(join(options.cache, 'linked-entry')) - await symlink(outside, join(options.cache, 'linked-entry', `${commit}-format-4`), 'junction') - await symlink(join(outside, '..'), join(options.shared, 'format-4'), 'junction') + await symlink(outsideEntry, join(options.cache, 'linked-entry', `${commit}-format-4`), 'junction') + await symlink(outsideFormat, join(options.shared, `format-${CHECKOUT_FORMAT}`), 'junction') const result = await reclaimReleaseTestCaches({ ...options, apply: true, @@ -111,7 +116,11 @@ it('does not follow cache, label, entry or shared-format symlinks', async () => minAgeDays: 0 }) expect(result.entries).toBe(0) - expect(await readFile(join(outside, 'source.ts'), 'utf8')).toContain('historical') + for (const sentinel of sentinels) { + expect(await readFile(join(sentinel, 'source.ts'), 'utf8')).toBe( + 'export const historical = true\n' + ) + } }) it('skips publication locks and a shared source copy lease', async () => { diff --git a/config/scripts/reclaim-vite-caches.test.mjs b/config/scripts/reclaim-vite-caches.test.mjs index b2036e86f46..583d09ff7e2 100644 --- a/config/scripts/reclaim-vite-caches.test.mjs +++ b/config/scripts/reclaim-vite-caches.test.mjs @@ -182,6 +182,14 @@ it('does not sweep while cache initialization holds the maintenance lock', async it('tracks a real Vite server until shutdown has completed', async () => { const options = await fixture() + let unblockClose + let enteredClose + const closeBlocked = new Promise((resolve) => { + unblockClose = resolve + }) + const closeEntered = new Promise((resolve) => { + enteredClose = resolve + }) const server = await createServer({ configFile: false, root: options.worktree, @@ -189,13 +197,30 @@ it('tracks a real Vite server until shutdown has completed', async () => { logLevel: 'silent', server: { middlewareMode: true, watch: null, hmr: false }, optimizeDeps: { noDiscovery: true, include: [] }, - plugins: [createViteCacheRetentionPlugin(options.cacheRoot)] + plugins: [ + createViteCacheRetentionPlugin(options.cacheRoot), + { + name: 'hold-vite-close', + async closeBundle() { + enteredClose() + await closeBlocked + } + } + ] }) try { await age(options.cacheRoot) expect(await reclaimViteCaches({ ...options, apply: true })).toMatchObject({ entries: 0 }) } finally { - await server.close() + const closing = server.close() + try { + await closeEntered + await age(options.cacheRoot) + expect(await reclaimViteCaches({ ...options, apply: true })).toMatchObject({ entries: 0 }) + } finally { + unblockClose() + await closing + } } expect(await readdir(join(options.cacheRoot, VITE_CACHE_USAGE_DIRECTORY))).toEqual([]) expect(await reclaimViteCaches({ ...options, apply: true })).toMatchObject({ entries: 0 }) diff --git a/config/scripts/reclaim-worktree-command.test.mjs b/config/scripts/reclaim-worktree-command.test.mjs index 6b3f05eded8..88b74062ccc 100644 --- a/config/scripts/reclaim-worktree-command.test.mjs +++ b/config/scripts/reclaim-worktree-command.test.mjs @@ -56,6 +56,7 @@ it('does not interpret piped input as approval', async () => { const reclaim = vi.fn().mockResolvedValue({ completed: true }) await runReclaimCommand([], { ...streams, reclaim }) expect(reclaim).toHaveBeenCalledTimes(1) + expect(reclaim.mock.calls[0][0].apply).toBe(false) expect(streams.text()).not.toContain('[y/N]') }) diff --git a/config/scripts/reclaim-worktree-space.test.mjs b/config/scripts/reclaim-worktree-space.test.mjs index c810a45e7da..c398ef9f0e8 100644 --- a/config/scripts/reclaim-worktree-space.test.mjs +++ b/config/scripts/reclaim-worktree-space.test.mjs @@ -61,6 +61,7 @@ it('leaves historical caches out of automatic invocations', async () => { excludeWorktree: '/dev-worktree' } await reclaimWorktreeSpace(options) + expect(options.execute).toHaveBeenCalledTimes(3) expect(options.execute.mock.calls.flatMap(([spec]) => spec.args).join(' ')).not.toContain( 'reclaim-release-test-caches' ) diff --git a/src/main/agent-hooks/server-closed-tab-suppression.test.ts b/src/main/agent-hooks/server-closed-tab-suppression.test.ts index fe094e275a9..796a9420c71 100644 --- a/src/main/agent-hooks/server-closed-tab-suppression.test.ts +++ b/src/main/agent-hooks/server-closed-tab-suppression.test.ts @@ -303,7 +303,7 @@ describe('AgentHookServer listener replay', () => { }) } - it('re-attaching a retired pane while idle re-opens it for a much later first turn', async () => { + it('re-attaching an idle retired pane accepts completion and a later turn', async () => { const server = new AgentHookServer() await server.start({ env: 'production' }) try { @@ -707,6 +707,13 @@ describe('AgentHookServer closed-tab suppression bound', () => { internals.markTabClosedForAgentStatus(`closed-tab-${i}`) } + const refreshedTab = `closed-tab-${total - CLOSED_AGENT_STATUS_TAB_IDS_MAX}` + const nextOldestTab = `closed-tab-${total - CLOSED_AGENT_STATUS_TAB_IDS_MAX + 1}` + internals.markTabClosedForAgentStatus(refreshedTab) + internals.markTabClosedForAgentStatus('closed-tab-next') + + expect(internals.closedAgentStatusTabIds.has(refreshedTab)).toBe(true) + expect(internals.closedAgentStatusTabIds.has(nextOldestTab)).toBe(false) expect(internals.closedAgentStatusTabIds.size).toBe(CLOSED_AGENT_STATUS_TAB_IDS_MAX) expect(internals.closedAgentStatusTabIds.has('closed-tab-0')).toBe(false) expect(internals.closedAgentStatusTabIds.has(`closed-tab-${total - 1}`)).toBe(true) diff --git a/src/shared/agent-hook-endpoint-file.test.ts b/src/shared/agent-hook-endpoint-file.test.ts index dfa928290ea..d3a6d272f0c 100644 --- a/src/shared/agent-hook-endpoint-file.test.ts +++ b/src/shared/agent-hook-endpoint-file.test.ts @@ -1,13 +1,25 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { describe, expect, it } from 'vitest' -import { isAgentHookEndpointFileName, parseAgentHookEndpointFile } from './agent-hook-endpoint-file' +import { parseAgentHookEndpointFile } from './agent-hook-endpoint-file' +import { getEndpointFileName, writeEndpointFile } from './agent-hook-listener/endpoint-publication' + +const ENDPOINT_LINES = [ + 'ORCA_AGENT_HOOK_PORT=12345', + 'ORCA_AGENT_HOOK_TOKEN=token-123', + 'ORCA_AGENT_HOOK_ENV=production', + 'ORCA_AGENT_HOOK_VERSION=1' +] +const REQUIRED_FIELDS = [ + 'ORCA_AGENT_HOOK_PORT', + 'ORCA_AGENT_HOOK_TOKEN', + 'ORCA_AGENT_HOOK_ENV', + 'ORCA_AGENT_HOOK_VERSION' +] +const ENDPOINT = { port: '12345', token: 'token-123', env: 'production', version: '1' } describe('agent hook endpoint files', () => { - it('recognizes POSIX and Windows endpoint file names', () => { - expect(isAgentHookEndpointFileName('endpoint.env')).toBe(true) - expect(isAgentHookEndpointFileName('endpoint.cmd')).toBe(true) - expect(isAgentHookEndpointFileName('endpoint.ps1')).toBe(false) - }) - it('parses POSIX endpoint.env contents', () => { expect( parseAgentHookEndpointFile( @@ -44,22 +56,39 @@ describe('agent hook endpoint files', () => { }) }) - it('preserves equals signs in endpoint values', () => { - expect( - parseAgentHookEndpointFile( - [ - 'ORCA_AGENT_HOOK_PORT=12345', - 'ORCA_AGENT_HOOK_TOKEN=token=with=equals', - 'ORCA_AGENT_HOOK_ENV=production', - 'ORCA_AGENT_HOOK_VERSION=1' - ].join('\n') - ).token - ).toBe('token=with=equals') - }) - - it('throws when required endpoint fields are missing', () => { - expect(() => parseAgentHookEndpointFile('ORCA_AGENT_HOOK_PORT=12345')).toThrow( + it.each(REQUIRED_FIELDS)('rejects an independently missing %s', (field) => { + expect(parseAgentHookEndpointFile(ENDPOINT_LINES.join('\n'))).toEqual(ENDPOINT) + const missing = ENDPOINT_LINES.filter((line) => !line.startsWith(`${field}=`)).join('\n') + expect(() => parseAgentHookEndpointFile(missing)).toThrow( 'Agent hook endpoint file is missing required fields' ) }) + + it.each(REQUIRED_FIELDS)('rejects an independently empty %s', (field) => { + expect(parseAgentHookEndpointFile(ENDPOINT_LINES.join('\n'))).toEqual(ENDPOINT) + const empty = ENDPOINT_LINES.map((line) => + line.startsWith(`${field}=`) ? `${field}=` : line + ).join('\n') + expect(() => parseAgentHookEndpointFile(empty)).toThrow( + 'Agent hook endpoint file is missing required fields' + ) + }) + + it('parses the endpoint bytes published by the platform writer', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-hook-endpoint-parser-')) + try { + const path = join(directory, getEndpointFileName()) + expect( + writeEndpointFile(directory, path, { + port: 12345, + token: 'token-123', + env: 'production', + version: '1' + }) + ).toBe(true) + expect(parseAgentHookEndpointFile(readFileSync(path, 'utf8'))).toEqual(ENDPOINT) + } finally { + rmSync(directory, { recursive: true, force: true }) + } + }) }) diff --git a/src/shared/agent-hook-endpoint-temp-cleanup.test.ts b/src/shared/agent-hook-endpoint-temp-cleanup.test.ts index 85b4ef98516..3931558be3c 100644 --- a/src/shared/agent-hook-endpoint-temp-cleanup.test.ts +++ b/src/shared/agent-hook-endpoint-temp-cleanup.test.ts @@ -1,20 +1,30 @@ +import { mkdtempSync, readdirSync, rmSync, utimesSync, writeFileSync } from 'node:fs' +import type * as NodeFs from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' const fsMocks = vi.hoisted(() => ({ + enabled: false, closeSync: vi.fn(), readSync: vi.fn(), statSync: vi.fn(), unlinkSync: vi.fn() })) -vi.mock('node:fs', () => ({ - opendirSync: () => ({ - closeSync: fsMocks.closeSync, - readSync: fsMocks.readSync - }), - statSync: fsMocks.statSync, - unlinkSync: fsMocks.unlinkSync -})) +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + opendirSync: (path: string, options: { bufferSize: number }) => + fsMocks.enabled + ? { closeSync: fsMocks.closeSync, readSync: fsMocks.readSync } + : actual.opendirSync(path, options), + statSync: (path: string) => (fsMocks.enabled ? fsMocks.statSync(path) : actual.statSync(path)), + unlinkSync: (path: string) => + fsMocks.enabled ? fsMocks.unlinkSync(path) : actual.unlinkSync(path) + } +}) import { AGENT_HOOK_ENDPOINT_SWEEP_MAX_ENTRIES, @@ -23,11 +33,13 @@ import { describe('agent hook endpoint temp cleanup', () => { beforeEach(() => { - vi.clearAllMocks() + vi.resetAllMocks() + fsMocks.enabled = false fsMocks.statSync.mockReturnValue({ mtimeMs: 0 }) }) it('stops an unbounded directory source at the scan cap', () => { + fsMocks.enabled = true let entry = 0 fsMocks.readSync.mockImplementation(() => ({ name: `.endpoint-${(entry += 1)}.tmp` @@ -40,19 +52,47 @@ describe('agent hook endpoint temp cleanup', () => { expect(fsMocks.closeSync).toHaveBeenCalledOnce() }) - it('only removes stale endpoint temp files', () => { - fsMocks.readSync - .mockReturnValueOnce({ name: '.endpoint-stale.tmp' }) - .mockReturnValueOnce({ name: '.endpoint-fresh.tmp' }) - .mockReturnValueOnce({ name: 'endpoint.env' }) - .mockReturnValueOnce(null) - fsMocks.statSync - .mockReturnValueOnce({ mtimeMs: 0 }) - .mockReturnValueOnce({ mtimeMs: 9 * 60 * 1000 }) - + it('counts skipped names against the scan cap', () => { + fsMocks.enabled = true + let scanned = 0 + fsMocks.readSync.mockImplementation(() => + ++scanned <= AGENT_HOOK_ENDPOINT_SWEEP_MAX_ENTRIES + 2 + ? { name: `unrelated-${scanned}` } + : null + ) sweepStaleAgentHookEndpointTemps('/endpoint', 10 * 60 * 1000) + expect(fsMocks.readSync).toHaveBeenCalledTimes(AGENT_HOOK_ENDPOINT_SWEEP_MAX_ENTRIES) + expect(fsMocks.statSync).not.toHaveBeenCalled() + expect(fsMocks.unlinkSync).not.toHaveBeenCalled() + expect(fsMocks.closeSync).toHaveBeenCalledOnce() + }) - expect(fsMocks.unlinkSync).toHaveBeenCalledOnce() - expect(fsMocks.unlinkSync).toHaveBeenCalledWith(expect.stringContaining('.endpoint-stale.tmp')) + it('removes only matching temp files strictly older than five minutes', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-hook-endpoint-sweep-')) + try { + const now = 1_000_000 + for (const [name, age] of [ + ['.endpoint-stale.tmp', 300_001], + ['.endpoint-boundary.tmp', 300_000], + ['.endpoint-fresh.tmp', 299_999], + ['.endpoint-wrong-extension.txt', 900_000], + ['wrong-prefix.tmp', 900_000], + ['endpoint.env', 900_000] + ] as const) { + const path = join(directory, name) + writeFileSync(path, 'endpoint fixture') + utimesSync(path, (now - age) / 1000, (now - age) / 1000) + } + sweepStaleAgentHookEndpointTemps(directory, now) + expect(readdirSync(directory).sort()).toEqual([ + '.endpoint-boundary.tmp', + '.endpoint-fresh.tmp', + '.endpoint-wrong-extension.txt', + 'endpoint.env', + 'wrong-prefix.tmp' + ]) + } finally { + rmSync(directory, { recursive: true, force: true }) + } }) }) diff --git a/src/shared/agent-hook-listener-extraction-characterization.test.ts b/src/shared/agent-hook-listener-extraction-characterization.test.ts index e6ae7736c59..aada0f643d1 100644 --- a/src/shared/agent-hook-listener-extraction-characterization.test.ts +++ b/src/shared/agent-hook-listener-extraction-characterization.test.ts @@ -1,6 +1,7 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { normalizeHookPayload } from './agent-hook-listener' +import type { AgentHookSource } from './agent-hook-relay' import { clearAllListenerCaches, clearPaneCacheState, @@ -10,54 +11,53 @@ import { } from './agent-hook-listener/listener-state' import { warnOnHookEnvOrVersionMismatch } from './agent-hook-listener/listener-limits' import { resolveHookSource } from './agent-hook-listener/source-routing' +import { bindOpenCodeSession } from './agent-hook-listener/opencode-session-registry' import { makePaneKey } from './stable-pane-id' const PANE = makePaneKey('tab-hooks', '11111111-1111-4111-8112-111111111111') const MOVED_PANE = makePaneKey('tab-hooks', '22222222-2222-4222-8222-222222222222') -const ROUTES = { - '/hook/claude': 'claude', - '/hook/codex': 'codex', - '/hook/gemini': 'gemini', - '/hook/antigravity': 'antigravity', - '/hook/amp': 'amp', - '/hook/opencode': 'opencode', - '/hook/mimo-code': 'mimo-code', - '/hook/cursor': 'cursor', - '/hook/pi': 'pi', - '/hook/omp': 'omp', - '/hook/prime-agent': 'prime-agent', - '/hook/droid': 'droid', - '/hook/command-code': 'command-code', - '/hook/grok': 'grok', - '/hook/copilot': 'copilot', - '/hook/hermes': 'hermes', - '/hook/devin': 'devin', - '/hook/kimi': 'kimi', - '/hook/jcode': 'jcode' -} as const function normalizeProviderState( - source: (typeof ROUTES)[keyof typeof ROUTES], + source: AgentHookSource, eventName: string, payload: Record = {} ) { + const routedSource = resolveHookSource(`/hook/${source}`) + if (routedSource === null) { + return null + } return ( normalizeHookPayload( createHookListenerState(), - source, + routedSource, { paneKey: PANE, hook_event_name: eventName, payload }, 'production' )?.payload ?? null ) } +afterEach(() => vi.restoreAllMocks()) describe('agent hook extraction boundaries', () => { - it('routes exactly the complete provider vocabulary', () => { - for (const [pathname, source] of Object.entries(ROUTES)) { - expect(resolveHookSource(pathname)).toBe(source) - expect(resolveHookSource(`${pathname}/`)).toBeNull() - expect(resolveHookSource(`${pathname}?v=1`)).toBeNull() - expect(resolveHookSource(pathname.toUpperCase())).toBeNull() - } + it.each([ + ['qoder', 'UserPromptSubmit'], + ['qoder-cn', 'UserPromptSubmit'], + ['qwen-code', 'UserPromptSubmit'], + ['codebuddy', 'UserPromptSubmit'], + ['opencode2', 'SessionBusy'], + ['muse', 'UserPromptSubmit'], + ['zcode', 'UserPromptSubmit'], + ['dsh', 'UserPromptSubmit'], + ['kiro', 'userPromptSubmit'] + ] as const)('normalizes %s hook traffic through its routed source', (source, eventName) => { + expect(normalizeProviderState(source, eventName, { prompt: 'routed prompt' })).toMatchObject({ + state: 'working', + agentType: source, + prompt: 'routed prompt' + }) + }) + + it('rejects unknown and malformed hook paths', () => { expect(resolveHookSource('/hook/unknown')).toBeNull() + expect(resolveHookSource('/hook/devin/')).toBeNull() + expect(resolveHookSource('/HOOK/DEVIN')).toBeNull() }) it('preserves thin provider lifecycle branches and source attribution', () => { @@ -86,9 +86,17 @@ describe('agent hook extraction boundaries', () => { } }) - it('warns before tab rejection and caps version and environment warning keys independently', () => { + it('warns before tab rejection and bounds distinct version and environment warnings', () => { const orderState = createHookListenerState() const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + expect( + normalizeHookPayload( + createHookListenerState(), + 'devin', + { paneKey: PANE, payload: { hook_event_name: 'UserPromptSubmit' } }, + 'production' + )?.payload + ).toMatchObject({ state: 'working', agentType: 'devin' }) const rejected = normalizeHookPayload( orderState, 'devin', @@ -96,13 +104,25 @@ describe('agent hook extraction boundaries', () => { paneKey: PANE, tabId: 'wrong-tab', version: 'outdated', - payload: { hook_event_name: 'UnknownEvent' } + payload: { hook_event_name: 'UserPromptSubmit' } }, 'production' ) expect(rejected).toBeNull() expect(warn).toHaveBeenCalledTimes(1) + warn.mockClear() + const repeatedState = createHookListenerState() + for (let attempt = 0; attempt < 2; attempt++) { + warnOnHookEnvOrVersionMismatch(repeatedState, { + version: 'repeated-version', + env: 'repeated-environment', + expectedEnv: 'production' + }) + } + expect(warn).toHaveBeenCalledTimes(2) + + warn.mockClear() const cappedState = createHookListenerState() for (let index = 0; index < 40; index++) { warnOnHookEnvOrVersionMismatch(cappedState, { @@ -113,10 +133,10 @@ describe('agent hook extraction boundaries', () => { } expect(cappedState.warnedVersions.size).toBe(32) expect(cappedState.warnedEnvs.size).toBe(32) - warn.mockRestore() + expect(warn).toHaveBeenCalledTimes(64) }) - it('moves every pane-owned collection and preserves NUL suffixes', () => { + it('moves selected pane-owned collections and preserves NUL suffixes', () => { const state = createHookListenerState() const scoped = `${PANE}\0child` const movedScoped = `${MOVED_PANE}\0child` @@ -169,22 +189,59 @@ describe('agent hook extraction boundaries', () => { expect(cache.get(movedScoped)).toBe('scoped') expect(cache.get(sibling)).toBe('sibling') expect(cache.has(PANE)).toBe(false) + expect(cache.has(scoped)).toBe(false) } expect(state.lastStatusByPaneKey.get(MOVED_PANE)?.payload.prompt).toBe('exact') expect(state.lastStatusByPaneKey.get(movedScoped)?.payload.prompt).toBe('scoped') expect(state.lastStatusByPaneKey.get(sibling)?.payload.prompt).toBe('sibling') expect(state.lastStatusByPaneKey.has(PANE)).toBe(false) + expect(state.lastStatusByPaneKey.has(scoped)).toBe(false) for (const set of paneSets) { expect(set.has(MOVED_PANE)).toBe(true) expect(set.has(movedScoped)).toBe(true) expect(set.has(sibling)).toBe(true) expect(set.has(PANE)).toBe(false) + expect(set.has(scoped)).toBe(false) } movePaneCacheState(state, MOVED_PANE, MOVED_PANE) expect((state.lastPromptByPaneKey as Map).get(MOVED_PANE)).toBe('exact') }) + it('preserves bound OpenCode session ownership and launch identity after a pane move', () => { + const state = createHookListenerState() + normalizeHookPayload( + state, + 'devin', + { + paneKey: PANE, + launchToken: 'moved-launch', + payload: { hook_event_name: 'UserPromptSubmit' } + }, + 'production' + ) + expect( + bindOpenCodeSession(state, 'moved-session', { paneKey: PANE, boundAt: 1, basis: 'argv' }) + ).toBe(true) + movePaneCacheState(state, PANE, MOVED_PANE) + const event = normalizeHookPayload( + state, + 'opencode', + { + paneKey: PANE, + opencodeSharedServer: 1, + payload: { hook_event_name: 'SessionBusy', sessionID: 'moved-session' } + }, + 'production' + ) + expect(event).toMatchObject({ + paneKey: MOVED_PANE, + launchToken: 'moved-launch', + payload: { state: 'working', agentType: 'opencode' } + }) + expect(state.lastLaunchTokenByPaneKey.has(PANE)).toBe(false) + }) + it('clears exact and NUL-scoped cache keys but not sibling prefixes', () => { const state = createHookListenerState() const scoped = `${PANE}\0thread` @@ -230,6 +287,7 @@ describe('agent hook extraction boundaries', () => { expect(state.lastStatusByPaneKey.has(scoped)).toBe(false) expect(state.lastStatusByPaneKey.get(sibling)?.payload.prompt).toBe('sibling') expect(state.ampCompletedCacheKeys.has(scoped)).toBe(false) + expect(state.ampCompletedCacheKeys.has(PANE)).toBe(false) expect(state.ampCompletedCacheKeys.has(sibling)).toBe(true) expect(state.claudeLeadStateByPaneKey.has(PANE)).toBe(false) expect(state.codexLeadStateByPaneKey.has(PANE)).toBe(false) @@ -267,6 +325,28 @@ describe('agent hook extraction boundaries', () => { state.claudeRunningNonAgentTaskPaneKeys.add(PANE) state.codexLeadStateByPaneKey.set(PANE, { state: 'working', stateStartedAt: 1 }) state.grokActiveTurnByPaneKey.set(PANE, { promptId: 'prompt-1' }) + const notification = { + paneKey: PANE, + payload: { hook_event_name: 'Notification', notification_type: 'permission_prompt' } + } + normalizeHookPayload( + state, + 'muse', + { + paneKey: PANE, + payload: { + hook_event_name: 'PermissionRequest', + tool_name: 'stale-tool', + tool_input: { command: 'old command' } + } + }, + 'production' + ) + expect(normalizeHookPayload(state, 'muse', notification, 'production')?.payload).toMatchObject({ + state: 'waiting', + agentType: 'muse', + toolName: 'stale-tool' + }) clearAllListenerCaches(state) @@ -276,5 +356,8 @@ describe('agent hook extraction boundaries', () => { expect(state.claudeRunningNonAgentTaskPaneKeys.size).toBe(0) expect(state.codexLeadStateByPaneKey.size).toBe(0) expect(state.grokActiveTurnByPaneKey.size).toBe(0) + const afterReset = normalizeHookPayload(state, 'muse', notification, 'production') + expect(afterReset?.payload).toMatchObject({ state: 'waiting', agentType: 'muse' }) + expect(afterReset?.payload.toolName).toBeUndefined() }) }) diff --git a/src/shared/agent-hook-listener/listener-state.ts b/src/shared/agent-hook-listener/listener-state.ts index 842499ba6c3..b4e6efffc5e 100644 --- a/src/shared/agent-hook-listener/listener-state.ts +++ b/src/shared/agent-hook-listener/listener-state.ts @@ -313,6 +313,7 @@ export function clearAllListenerCaches(state: HookListenerState): void { state.codexLeadStateByPaneKey.clear() state.grokActiveTurnByPaneKey.clear() state.grokMainAgentStatusByPaneKey.clear() + state.musePaneStateByPaneKey.clear() state.opencodeSessionPaneBySessionId.clear() state.lastLaunchTokenByPaneKey.clear() } diff --git a/src/shared/browser-annotation-viewport-live-target.test.ts b/src/shared/browser-annotation-viewport-live-target.test.ts index 94e58f9b9ff..72a7b0af5cd 100644 --- a/src/shared/browser-annotation-viewport-live-target.test.ts +++ b/src/shared/browser-annotation-viewport-live-target.test.ts @@ -248,7 +248,7 @@ describe('annotation viewport bridge live target', () => { guest.install(true) guest.flushFrames() - expect(guest.lastReport()?.target).toBeUndefined() + expect(guest.lastReport()).toEqual({ scrollX: 0, scrollY: 0, viewportWidth: 1000 }) }) it('re-reports on resize and when the element changes size', async () => { @@ -279,7 +279,7 @@ describe('annotation viewport bridge live target', () => { guest.target.removeAttribute(BROWSER_GRAB_SELECTED_ATTRIBUTE) guest.window.dispatchEvent(new guest.window.Event('scroll')) guest.flushFrames() - expect(guest.lastReport()?.target).toBeUndefined() + expect(guest.lastReport()).toEqual({ scrollX: 0, scrollY: 0, viewportWidth: 1000 }) }) }) diff --git a/src/wsl-guest/agent-hook-envelope-publication.test.ts b/src/wsl-guest/agent-hook-envelope-publication.test.ts index 164c36f9c7a..b34e8e96206 100644 --- a/src/wsl-guest/agent-hook-envelope-publication.test.ts +++ b/src/wsl-guest/agent-hook-envelope-publication.test.ts @@ -590,6 +590,13 @@ describe('publishAgentHookEnvelope redelivery', () => { publishAgentHookEnvelope(dispatcher, envelope) } + const refreshed = makeEnvelope({}) + refreshed.paneKey = 'tab-1:4f1b0f4e-0000-4000-8000-000000000001' + publishAgentHookEnvelope(dispatcher, refreshed) + const newest = makeEnvelope({}) + newest.paneKey = 'tab-1:4f1b0f4e-0000-4000-8000-000000000065' + publishAgentHookEnvelope(dispatcher, newest) + primary.blocked = false primary.drain() vi.advanceTimersByTime(1_000) @@ -598,6 +605,9 @@ describe('publishAgentHookEnvelope redelivery', () => { expect(redelivered).toHaveLength(64) expect(redelivered).toContain('tab-1:4f1b0f4e-0000-4000-8000-000000000064') expect(redelivered).not.toContain('tab-1:4f1b0f4e-0000-4000-8000-000000000000') + expect(redelivered).toContain('tab-1:4f1b0f4e-0000-4000-8000-000000000001') + expect(redelivered).not.toContain('tab-1:4f1b0f4e-0000-4000-8000-000000000002') + expect(redelivered).toContain('tab-1:4f1b0f4e-0000-4000-8000-000000000065') } finally { dispatcher.dispose() } diff --git a/src/wsl-guest/agent-hook-integration.test.ts b/src/wsl-guest/agent-hook-integration.test.ts index 0953b831f65..5e94d776a45 100644 --- a/src/wsl-guest/agent-hook-integration.test.ts +++ b/src/wsl-guest/agent-hook-integration.test.ts @@ -150,7 +150,11 @@ describe('Integration: relay hook server → mux → AgentHookServer.ingestRemot connectionId: 'conn-test', mainAgent: { state: 'done', outcome: 'cancellation' } }) + const replayOutcomes: (string | undefined)[] = [] + orcaServer.setListener((event) => replayOutcomes.push(event.payload.mainAgent?.outcome)) + replayOutcomes.length = 0 expect(hookServer.replayCachedPayloadsForPanes()).toBe(1) + await expect.poll(() => replayOutcomes).toEqual(['cancellation']) await expect .poll(() => orcaServer.getStatusSnapshotForPane(paneKey)[0]?.mainAgent?.outcome) .toBe('cancellation') @@ -341,6 +345,7 @@ describe('Integration: relay hook server → mux → AgentHookServer.ingestRemot tool_input: { command: 'pnpm test' } }) ).resolves.toMatchObject({ status: 204 }) + await expect.poll(() => orcaServer.getStatusSnapshot()[0]?.state).toBe('waiting') await expect( postClaude({ hook_event_name: 'PreToolUse', @@ -394,6 +399,7 @@ describe('Integration: relay hook server → mux → AgentHookServer.ingestRemot tool_name: 'Bash', tool_input: { command: 'false' } }) + await expect.poll(() => orcaServer.getStatusSnapshot()[0]?.state).toBe('waiting') await postClaude({ hook_event_name: 'TeammateIdle', teammate_name: 'reviewer' }) const start = Date.now() @@ -444,6 +450,7 @@ describe('Integration: relay hook server → mux → AgentHookServer.ingestRemot tool_input: { command: 'rm -rf /tmp/orca-2824-permission-target' } }) ).resolves.toMatchObject({ status: 204 }) + await expect.poll(() => orcaServer.getStatusSnapshot()[0]?.state).toBe('waiting') await expect( postClaude({ hook_event_name: 'PostToolUse',