import type { DenialCircuitBreaker } from '../src/circuit-breaker.js' import type { AutoReviewConfigFileSystem } from '../src/config-store.js' import type { AutoReviewExtensionDependencies } from '../src/extension.js' import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, RegisteredCommand, } from '@earendil-works/pi-coding-agent' import type { Authorizer, PermissionsService } from '@gotgenes/pi-permission-system' import { PERMISSIONS_READY_CHANNEL } from '@gotgenes/pi-permission-system' import { describe, expect, it, vi } from 'vitest' import { AutoReviewConfigStore } from '../src/config-store.js' import { autoReviewConfigSchema } from '../src/config.js' import { createAutoReviewExtension, createAutoReviewExtensionWithConfigStore } from '../src/extension.js' type Handler = (...arguments_: unknown[]) => unknown function createPiHarness() { const handlers = new Map() const eventHandlers = new Map() const commands = new Map>() const add = (target: Map, name: string, handler: Handler): void => { target.set(name, [...(target.get(name) ?? []), handler]) } const pi = { on: vi.fn((name: string, handler: Handler) => add(handlers, name, handler)), events: { on: vi.fn((name: string, handler: Handler) => add(eventHandlers, name, handler)), }, registerCommand: vi.fn((name: string, command: Omit) => { commands.set(name, command) }), } as unknown as ExtensionAPI return { pi, emit(name: string, ...arguments_: unknown[]) { for (const handler of handlers.get(name) ?? []) { handler(...arguments_) } }, emitEvent(name: string, ...arguments_: unknown[]) { for (const handler of eventHandlers.get(name) ?? []) { handler(...arguments_) } }, getCommand(name: string) { return commands.get(name) }, } } function context(): ExtensionContext { return { cwd: '/project', modelRegistry: {}, sessionManager: {}, } as ExtensionContext } function configResult() { return { config: autoReviewConfigSchema.parse({}), issues: [], globalPath: '/global/config.json', projectPath: '/project/config.json', } } function createConfigStore(initial: Record) { const files = new Map(Object.entries(initial)) const fileSystem: AutoReviewConfigFileSystem = { readFile: path => files.get(path), writeFile: (path, source) => { files.set(path, source) }, rename: (sourcePath, destinationPath) => { const source = files.get(sourcePath) if (source === undefined) { throw new Error(`missing ${sourcePath}`) } files.set(destinationPath, source) files.delete(sourcePath) }, mkdir: () => {}, unlink: path => { files.delete(path) }, } return { files, store: new AutoReviewConfigStore({ agentDir: '/agent', fileSystem }), } } function commandContext(notify = vi.fn()): ExtensionCommandContext { return { ...context(), mode: 'tui', hasUI: true, ui: { confirm: vi.fn(async () => true), notify, }, waitForIdle: vi.fn(async () => {}), reload: vi.fn(), } as unknown as ExtensionCommandContext } describe('extension lifecycle', () => { it('registers once when session_start happens before permissions:ready', () => { const harness = createPiHarness() const dispose = vi.fn() const registerAuthorizer = vi.fn(() => dispose) let service: PermissionsService | undefined const authorize = vi.fn() createAutoReviewExtension(harness.pi, { loadConfig: configResult, getPermissionsService: () => service, createReviewer: () => authorize, }) harness.emit('session_start', {}, context()) expect(registerAuthorizer).not.toHaveBeenCalled() service = { registerAuthorizer } as unknown as PermissionsService harness.emitEvent(PERMISSIONS_READY_CHANNEL, {}) harness.emitEvent(PERMISSIONS_READY_CHANNEL, {}) expect(registerAuthorizer).toHaveBeenCalledOnce() expect(registerAuthorizer).toHaveBeenCalledWith('auto-review', authorize) harness.emit('session_shutdown') expect(dispose).toHaveBeenCalledOnce() harness.emit('session_start', {}, context()) expect(registerAuthorizer).toHaveBeenCalledTimes(2) }) it('registers when permissions:ready happens before session_start', () => { const harness = createPiHarness() const registerAuthorizer = vi.fn(() => vi.fn()) const service = { registerAuthorizer, } as unknown as PermissionsService createAutoReviewExtension(harness.pi, { loadConfig: configResult, getPermissionsService: () => service, createReviewer: () => vi.fn(), }) harness.emitEvent(PERMISSIONS_READY_CHANNEL, {}) expect(registerAuthorizer).not.toHaveBeenCalled() harness.emit('session_start', {}, context()) expect(registerAuthorizer).toHaveBeenCalledOnce() }) it('makes later instances passive for a shared service and leaves disposal to the owner', async () => { const ownerHarness = createPiHarness() const passiveHarness = createPiHarness() const ownerDispose = vi.fn() const registerAuthorizer = vi.fn(() => ownerDispose) const service = { registerAuthorizer } as unknown as PermissionsService const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) const globalPath = '/agent/extensions/pi-permission-auto-review/config.json' const { store } = createConfigStore({ [globalPath]: JSON.stringify({ reasoning: 'high' }), }) createAutoReviewExtension(ownerHarness.pi, { loadConfig: configResult, getPermissionsService: () => service, createReviewer: () => vi.fn(), }) createAutoReviewExtensionWithConfigStore(passiveHarness.pi, store, { getPermissionsService: () => service, createReviewer: () => vi.fn(), }) ownerHarness.emit('session_start', {}, context()) passiveHarness.emit('session_start', {}, context()) passiveHarness.emitEvent(PERMISSIONS_READY_CHANNEL, {}) passiveHarness.emitEvent(PERMISSIONS_READY_CHANNEL, {}) expect(registerAuthorizer).toHaveBeenCalledOnce() expect(warn).not.toHaveBeenCalled() const notify = vi.fn() await passiveHarness.getCommand('permission-auto-review')?.handler('reset global', commandContext(notify)) expect(registerAuthorizer).toHaveBeenCalledOnce() expect(ownerDispose).not.toHaveBeenCalled() expect(notify).toHaveBeenCalledWith(expect.stringContaining('managed by the main Pi session'), 'error') passiveHarness.emit('session_shutdown') expect(ownerDispose).not.toHaveBeenCalled() ownerHarness.emit('session_shutdown') expect(ownerDispose).toHaveBeenCalledOnce() const replacementHarness = createPiHarness() createAutoReviewExtension(replacementHarness.pi, { loadConfig: configResult, getPermissionsService: () => service, createReviewer: () => vi.fn(), }) replacementHarness.emit('session_start', {}, context()) expect(registerAuthorizer).toHaveBeenCalledTimes(2) replacementHarness.emit('session_shutdown') warn.mockRestore() }) it('keeps a replacement service owner when the old owner shuts down late', () => { const oldHarness = createPiHarness() const newHarness = createPiHarness() const observerHarness = createPiHarness() const oldDispose = vi.fn() const newDispose = vi.fn() const oldRegisterAuthorizer = vi.fn(() => oldDispose) const newRegisterAuthorizer = vi.fn(() => newDispose) const oldService = { registerAuthorizer: oldRegisterAuthorizer } as unknown as PermissionsService const newService = { registerAuthorizer: newRegisterAuthorizer } as unknown as PermissionsService for (const [harness, service] of [ [oldHarness, oldService], [newHarness, newService], [observerHarness, newService], ] as const) { createAutoReviewExtension(harness.pi, { loadConfig: configResult, getPermissionsService: () => service, createReviewer: () => vi.fn(), }) } oldHarness.emit('session_start', {}, context()) newHarness.emit('session_start', {}, context()) expect(oldRegisterAuthorizer).toHaveBeenCalledOnce() expect(newRegisterAuthorizer).toHaveBeenCalledOnce() oldHarness.emit('session_shutdown') observerHarness.emit('session_start', {}, context()) expect(oldDispose).toHaveBeenCalledOnce() expect(newRegisterAuthorizer).toHaveBeenCalledOnce() observerHarness.emit('session_shutdown') expect(newDispose).not.toHaveBeenCalled() newHarness.emit('session_shutdown') expect(newDispose).toHaveBeenCalledOnce() }) it('registers a defer-only reviewer when config is invalid', async () => { const harness = createPiHarness() let registered: Authorizer['authorize'] | undefined const service = { registerAuthorizer: vi.fn((_name: string, authorize: Authorizer['authorize']) => { registered = authorize return vi.fn() }), } as unknown as PermissionsService createAutoReviewExtension(harness.pi, { loadConfig: () => ({ config: undefined, issues: [], globalPath: '/global/config.json', projectPath: '/project/config.json', }), getPermissionsService: () => service, }) harness.emit('session_start', {}, context()) const log = { review: vi.fn(), debug: vi.fn() } await expect( registered?.( { requestId: 'request', source: 'tool_call', agentName: null, payload: { kind: 'tool', request: { requester: { agentName: null, forwarded: false, sessionId: null }, surface: 'test-tool', toolName: 'test-tool', invokedToolName: null, value: 'request', matchedPattern: '*', commandContext: null, executedUnit: null, }, evidence: [], annotations: [], }, }, {} as never, log, ), ).resolves.toEqual({ kind: 'defer' }) expect(log.review).toHaveBeenCalledWith( 'auto_review.decision', expect.objectContaining({ errorCategory: 'config-invalid' }), ) }) it('hot-swaps only the reviewer generation after a config reset', async () => { const globalPath = '/agent/extensions/pi-permission-auto-review/config.json' const { files, store } = createConfigStore({ [globalPath]: JSON.stringify({ model: 'old-review-model' }), }) const harness = createPiHarness() const firstDispose = vi.fn() const secondDispose = vi.fn() const firstAuthorize = vi.fn() const secondAuthorize = vi.fn() const createReviewer = vi .fn>() .mockReturnValueOnce(firstAuthorize) .mockReturnValueOnce(secondAuthorize) const registerAuthorizer = vi.fn().mockReturnValueOnce(firstDispose).mockReturnValueOnce(secondDispose) const service = { registerAuthorizer } as unknown as PermissionsService createAutoReviewExtensionWithConfigStore(harness.pi, store, { getPermissionsService: () => service, createReviewer, }) harness.emit('session_start', {}, context()) const circuitBreaker: DenialCircuitBreaker | undefined = createReviewer.mock.calls[0]?.[0].circuitBreaker if (circuitBreaker === undefined) { throw new Error('reviewer was not created') } circuitBreaker.recordDenied() circuitBreaker.recordDenied() circuitBreaker.recordDenied() expect(circuitBreaker.isOpen()).toBe(true) const command = harness.getCommand('permission-auto-review') const ctx = commandContext() await command?.handler('reset global', ctx) expect(files.has(globalPath)).toBe(false) expect(firstDispose).toHaveBeenCalledOnce() expect(registerAuthorizer).toHaveBeenNthCalledWith(1, 'auto-review', firstAuthorize) expect(registerAuthorizer).toHaveBeenNthCalledWith(2, 'auto-review', secondAuthorize) expect(createReviewer.mock.calls[0]?.[0]).toMatchObject({ config: { model: 'old-review-model' }, }) expect(createReviewer.mock.calls[1]?.[0]).toMatchObject({ config: { model: 'codex-auto-review' }, }) expect(circuitBreaker.isOpen()).toBe(false) harness.emit('session_shutdown') expect(secondDispose).toHaveBeenCalledOnce() }) it('preserves the old reviewer when reset leaves the merged config invalid', async () => { const globalPath = '/agent/extensions/pi-permission-auto-review/config.json' const projectPath = '/project/.pi/extensions/pi-permission-auto-review/config.json' const { files, store } = createConfigStore({ [globalPath]: JSON.stringify({ reasoning: 'high' }), [projectPath]: JSON.stringify({ includeBaselinePolicy: false, additionalPolicy: 'Review conservatively.', }), }) const harness = createPiHarness() const firstDispose = vi.fn() const registerAuthorizer = vi.fn(() => firstDispose) const service = { registerAuthorizer } as unknown as PermissionsService const createReviewer = vi.fn(() => vi.fn()) const notify = vi.fn() createAutoReviewExtensionWithConfigStore(harness.pi, store, { getPermissionsService: () => service, createReviewer, }) harness.emit('session_start', {}, context()) files.set(projectPath, JSON.stringify({ includeBaselinePolicy: false })) await harness.getCommand('permission-auto-review')?.handler('reset global', commandContext(notify)) expect(files.has(globalPath)).toBe(false) expect(firstDispose).not.toHaveBeenCalled() expect(registerAuthorizer).toHaveBeenCalledOnce() expect(createReviewer).toHaveBeenCalledOnce() expect(notify).toHaveBeenCalledWith( expect.stringContaining('the merged config is invalid; the previous reviewer remains active'), 'error', ) }) it('restores the old reviewer if candidate registration fails', async () => { const globalPath = '/agent/extensions/pi-permission-auto-review/config.json' const { store } = createConfigStore({ [globalPath]: JSON.stringify({ reasoning: 'high' }), }) const harness = createPiHarness() const firstDispose = vi.fn() const restoredDispose = vi.fn() const firstAuthorize = vi.fn() const secondAuthorize = vi.fn() const registerAuthorizer = vi .fn() .mockReturnValueOnce(firstDispose) .mockImplementationOnce(() => { throw new Error('candidate rejected') }) .mockReturnValueOnce(restoredDispose) const service = { registerAuthorizer } as unknown as PermissionsService const notify = vi.fn() createAutoReviewExtensionWithConfigStore(harness.pi, store, { getPermissionsService: () => service, createReviewer: vi.fn().mockReturnValueOnce(firstAuthorize).mockReturnValueOnce(secondAuthorize), }) harness.emit('session_start', {}, context()) await harness.getCommand('permission-auto-review')?.handler('reset global', commandContext(notify)) expect(firstDispose).toHaveBeenCalledOnce() expect(registerAuthorizer).toHaveBeenNthCalledWith(2, 'auto-review', secondAuthorize) expect(registerAuthorizer).toHaveBeenNthCalledWith(3, 'auto-review', firstAuthorize) expect(notify).toHaveBeenCalledWith(expect.stringContaining('old reviewer was restored'), 'error') harness.emit('session_shutdown') expect(restoredDispose).toHaveBeenCalledOnce() }) it('keeps a saved generation pending until permission-system becomes ready', async () => { const globalPath = '/agent/extensions/pi-permission-auto-review/config.json' const { store } = createConfigStore({ [globalPath]: JSON.stringify({ reasoning: 'high' }), }) const harness = createPiHarness() const oldAuthorize = vi.fn() const pendingAuthorize = vi.fn() const createReviewer = vi.fn().mockReturnValueOnce(oldAuthorize).mockReturnValueOnce(pendingAuthorize) const registerAuthorizer = vi.fn(() => vi.fn()) let service: PermissionsService | undefined const notify = vi.fn() createAutoReviewExtensionWithConfigStore(harness.pi, store, { getPermissionsService: () => service, createReviewer, }) harness.emit('session_start', {}, context()) await harness.getCommand('permission-auto-review')?.handler('reset global', commandContext(notify)) expect(registerAuthorizer).not.toHaveBeenCalled() expect(notify).toHaveBeenCalledWith( expect.stringContaining('will activate when pi-permission-system is ready'), 'warning', ) service = { registerAuthorizer } as unknown as PermissionsService harness.emitEvent(PERMISSIONS_READY_CHANNEL, {}) expect(registerAuthorizer).toHaveBeenCalledWith('auto-review', pendingAuthorize) }) })