import { describe, expect, test } from 'vitest'; type LifecycleRunner = (args: string[]) => boolean; type LifecycleRegister = (api: unknown, runner: LifecycleRunner) => void; type Handler = (event: Record, ctx: Record) => unknown; const lifecycleModuleUrl = new URL('../../framework/runtime/pi/lease-lifecycle.ts', import.meta.url) .href; const { registerLeaseLifecycleHooks } = (await import(lifecycleModuleUrl)) as { registerLeaseLifecycleHooks: LifecycleRegister; }; function fakePi() { const handlers = new Map(); return { handlers, api: { on(event: string, handler: Handler) { handlers.set(event, [...(handlers.get(event) ?? []), handler]); }, }, async emit(event: string, value: Record = {}) { const results = []; for (const handler of handlers.get(event) ?? []) { results.push(await handler(value, {})); } return results; }, }; } describe('Pi compaction and runtime-generation lease lifecycle', () => { test('pre-compaction and first post-compaction context independently revoke', async () => { const pi = fakePi(); const calls: string[][] = []; registerLeaseLifecycleHooks(pi.api as never, (args) => { calls.push(args); return true; }); expect(await pi.emit('session_before_compact', { reason: 'threshold' })).toEqual([undefined]); expect(calls.at(-1)).toEqual([ '--runtime', 'pi', '--reason', 'pi-session-before-compact:threshold', ]); await pi.emit('session_compact', { reason: 'threshold' }); await pi.emit('context', { messages: [] }); expect(calls.at(-1)).toEqual([ '--runtime', 'pi', '--reason', 'pi-context-after-compact:threshold', ]); const afterFirstContext = calls.length; await pi.emit('context', { messages: [] }); expect(calls).toHaveLength(afterFirstContext); }); test.each(['reload', 'new', 'resume', 'fork'])( '%s bumps generation before reuse', async (reason) => { const pi = fakePi(); const calls: string[][] = []; registerLeaseLifecycleHooks(pi.api as never, (args) => { calls.push(args); return true; }); await pi.emit('session_start', { reason }); expect(calls).toEqual([ ['--runtime', 'pi', '--reason', `pi-session-start:${reason}`, '--bump-generation'], ]); }, ); test('startup leaves the launcher generation intact and tools locally open', async () => { const pi = fakePi(); const calls: string[][] = []; registerLeaseLifecycleHooks(pi.api as never, (args) => { calls.push(args); return true; }); await pi.emit('session_start', { reason: 'startup' }); await pi.emit('session_start'); expect(calls).toEqual([]); expect(await pi.emit('tool_call', { toolName: 'bash' })).toEqual([undefined]); }); test('failed post-compaction revoke blocks tools until context retries successfully', async () => { const pi = fakePi(); const outcomes = [false, true]; registerLeaseLifecycleHooks(pi.api as never, () => outcomes.shift() ?? true); await pi.emit('session_compact'); await pi.emit('context'); expect(await pi.emit('tool_call', { toolName: 'bash' })).toEqual([ { block: true, reason: expect.stringContaining('lease lifecycle revoke failed'), }, ]); await pi.emit('context'); expect(await pi.emit('tool_call', { toolName: 'bash' })).toEqual([undefined]); }); test('failed lifecycle revoke cancels compaction and closes later tool calls', async () => { const pi = fakePi(); registerLeaseLifecycleHooks(pi.api as never, () => false); const compact = await pi.emit('session_before_compact', { reason: 'manual' }); expect(compact).toEqual([{ cancel: true }]); await pi.emit('session_start', { reason: 'resume' }); const tool = await pi.emit('tool_call', { toolName: 'bash' }); expect(tool).toEqual([ { block: true, reason: expect.stringContaining('lease lifecycle revoke failed'), }, ]); }); });