import { describe, expect, it, vi } from 'vitest'; import { Program } from '#/program/program'; import type { ProgramSessionControllerInput } from '#/program/programDependencies'; import { FakeRuntime } from '#/runtime/fakeRuntime'; import type { RuntimeStatus } from '#/runtime/runtime'; import { RuntimeRegistry } from '#/runtime/runtimeRegistry'; function runtime(generation: string, status: RuntimeStatus = 'ready'): FakeRuntime { return Object.assign( new FakeRuntime( { workspaceId: 'workspace', runtimeId: 'local', generation }, { status, capabilities: ['fs', 'process'] }, ), { fs: {}, process: {} }, ) as FakeRuntime; } function deferred(): { readonly promise: Promise; resolve(): void; reject(error: Error): void } { let resolve!: () => void; let reject!: (error: Error) => void; const promise = new Promise((onResolve, onReject) => { resolve = onResolve; reject = onReject; }); return { promise, resolve, reject }; } function setup(readiness = new Map>(), order: string[] = []) { const registry = new RuntimeRegistry('workspace', 50); const program = new Program( 'workspace', registry, { _serviceBrand: undefined, workspaceId: 'workspace', cwd: '/workspace', source: 'local', meta: { id: 'workspace', name: 'workspace', root: '/workspace', createdAt: 0, lastOpenedAt: 0, }, persistenceScope: 'sessions/workspace', }, { agentProfiles: { entries: () => [] }, createSessionController: (input: ProgramSessionControllerInput) => ({ dispose: input.onDispose }) as never, } as never, ); const create = vi.fn(() => { const lease = registry.acquire(program.binding, ['fs', 'process']); const id = lease.runtime.identity.generation; const behavior = { ready: readiness.get(id) ?? Promise.resolve(), dispose: () => { order.push(`behavior:${id}`); }, }; const catalog = { listSkills: () => [], listInvocableSkills: () => [], getSkippedByPolicy: () => [], getSkillRoots: () => [], }; return { id, lease, state: behavior, dirs: behavior, fs: behavior, git: behavior, instructions: { ...behavior, snapshot: {} }, mcpConfig: { ...behavior, servers: () => ({}) }, mcp: behavior, trust: { ...behavior, isTrusted: () => false }, skills: { ...behavior, catalog }, agentProfiles: behavior, userAgentProfiles: behavior, pluginAgentProfiles: behavior, explicitAgentProfiles: behavior, extraAgentProfiles: behavior, disposables: [behavior], ready: false, failed: false, references: 1, retired: false, }; }); (program as unknown as { createGeneration: typeof create }).createGeneration = create; return { registry, program, create }; } describe('Program', () => { it('acquires only available local generations and recovers after reconnect', async () => { const { registry, program, create } = setup(); const current = runtime('one', 'disconnected'); registry.register(current); expect(create).toHaveBeenCalledTimes(1); expect(program.status).toBe('degraded'); expect(() => program.dirs).toThrow('no available local runtime generation'); current.setStatus('ready'); await program.ready; expect(create).toHaveBeenCalledTimes(2); expect(program.sessionControllerGeneration).toBe('one'); expect(program.status).toBe('ready'); program.dispose(); await registry.dispose(); }); it('stays preparing until the fixed local generation behavior becomes ready', async () => { const pending = deferred(); const { registry, program } = setup(new Map([['one', pending.promise]])); registry.register(runtime('one')); expect(program.binding).toEqual({ workspaceId: 'workspace', runtimeId: 'local' }); expect(program.status).toBe('preparing'); expect(program.snapshot().ready).toBe(false); pending.resolve(); await program.ready; await Promise.resolve(); expect(program.status).toBe('ready'); expect(program.snapshot().ready).toBe(true); program.dispose(); await registry.dispose(); }); it('marks rejected behavior readiness degraded', async () => { const failed = deferred(); const { registry, program } = setup(new Map([['one', failed.promise]])); registry.register(runtime('one')); failed.reject(new Error('failed')); await program.ready; await Promise.resolve(); expect(program.status).toBe('degraded'); program.dispose(); await registry.dispose(); }); it('retains the replaced generation lease until its session controller is disposed', async () => { const { registry, program } = setup(); const first = runtime('one'); const registration = registry.register(first); await program.ready; const controller = program.createSessionController(); const replacement = registration.replace(runtime('two')); await Promise.resolve(); expect(program.sessionControllerGeneration).toBe('two'); expect(first.disposed).toBe(false); controller.dispose(); await replacement; expect(first.disposed).toBe(true); program.dispose(); await registry.dispose(); }); it('owns catalog, instructions, MCP, provenance, and current runtime in one generation', async () => { const { registry, program, create } = setup(); registry.register(runtime('one')); const generation = create.mock.results[0]?.value as { skills: { catalog: { listSkills(): unknown[]; listInvocableSkills(): unknown[]; getSkippedByPolicy(): unknown[]; getSkillRoots(): string[]; } }; instructions: { snapshot: { agentsMdPaths?: readonly string[] } }; mcpConfig: { servers(): Record }; mcp: unknown; }; const skill = { source: 'workspace' }; generation.skills.catalog.listSkills = () => [skill]; generation.skills.catalog.listInvocableSkills = () => [skill]; generation.skills.catalog.getSkippedByPolicy = () => []; generation.skills.catalog.getSkillRoots = () => ['/workspace/.agents/skills']; generation.instructions.snapshot = { agentsMdPaths: ['/workspace/AGENTS.md'] }; generation.mcpConfig.servers = () => ({ baseline: {} }); await program.ready; await Promise.resolve(); expect(program.skills).toBe(generation.skills); expect(program.instructions).toBe(generation.instructions); expect(program.mcpConfig).toBe(generation.mcpConfig); expect(program.mcp).toBe(generation.mcp); expect(program.snapshot()).toMatchObject({ workspaceId: 'workspace', binding: { workspaceId: 'workspace', runtimeId: 'local' }, status: 'ready', ready: true, generation: 'one', trusted: false, catalog: { skills: { total: 1, invocable: 1, skipped: 0 }, agentProfiles: 0, mcpServers: 1, }, sources: { skills: [{ source: 'workspace', count: 1 }], skillRoots: ['/workspace/.agents/skills'], agentProfiles: [], instructionPaths: ['/workspace/AGENTS.md'], mcpServers: ['baseline'], }, runtimes: [{ runtimeId: 'local', generation: 'one', status: 'ready' }], }); program.dispose(); await registry.dispose(); }); it('replaces generations atomically and disposes behavior before releasing its lease', async () => { const firstReady = deferred(); const order: string[] = []; const { registry, program } = setup(new Map([['one', firstReady.promise]]), order); const registration = registry.register(runtime('one')); const replacement = registration.replace(runtime('two')); await replacement; await Promise.resolve(); expect(program.sessionControllerGeneration).toBe('two'); expect(program.status).toBe('ready'); expect(order).toEqual(['behavior:one']); firstReady.resolve(); await Promise.resolve(); expect(program.sessionControllerGeneration).toBe('two'); expect(program.status).toBe('ready'); program.dispose(); expect(order).toEqual(['behavior:one', 'behavior:two']); await registry.dispose(); }); });