import { describe, it, expect, vi } from 'vitest'; import { AgentPool, OPENCODE_POOL_KEY } from '../agent-pool.js'; import type { AgentBackend, AgentSessionHandle, EnsureSessionOpts, PromptCtx, TurnResult, } from '../agent-backend.js'; /** * v2.6 Phase 3 — AgentPool lifecycle unit test (T.1). No DB / no child process: * a fake AgentBackend records dispose + reports busy/health, so we exercise * get-or-create, idle eviction, the LRU cap, the busy-never-evict rule, closeChat, * and dispose-drains directly. The pure decisions are covered separately in * backends/__tests__/lifecycle-decisions.test.ts; this verifies the wiring. */ class FakeBackend implements AgentBackend { disposed = 0; closedSessions = 0; private busyFlag = false; tickHealthCalls = 0; constructor(public readonly name = 'fake') {} setBusy(b: boolean): void { this.busyFlag = b; } // — AgentBackend — async ensureSession(sessionId: string, opts: EnsureSessionOpts): Promise { return { sessionId, agent: opts.agent, backend: 'acp_warm', chatId: opts.chatId, worktreeId: opts.worktreeId, agentSessionId: 'fake-session', serverPort: null, }; } async prompt(_h: AgentSessionHandle, _input: string, _ctx: PromptCtx): Promise { return { ok: true }; } async closeSession(): Promise { this.closedSessions++; } async dispose(): Promise { this.disposed++; } health(): 'up' | 'down' { return 'up'; } isBusy(): boolean { return this.busyFlag; } async tickHealth(): Promise { this.tickHealthCalls++; } } describe('AgentPool — get/register/touch (3.1)', () => { it('register then get returns the same backend', () => { const pool = new AgentPool(); const b = new FakeBackend(); pool.register('chat-1', 'goose', b); expect(pool.get('chat-1', 'goose')).toBe(b); expect(pool.get('chat-1', 'qwen')).toBeUndefined(); }); it('peek does NOT exist for a missing key', () => { const pool = new AgentPool(); expect(pool.peek('nope', 'goose')).toBeUndefined(); }); it('health reports size + busy count', () => { const pool = new AgentPool(); const a = new FakeBackend(); const b = new FakeBackend(); b.setBusy(true); pool.register('c1', 'goose', a); pool.register('c2', 'qwen', b); expect(pool.health()).toEqual({ size: 2, busy: 1 }); }); }); describe('AgentPool.sweep — idle TTL eviction (3.1)', () => { it('evicts an idle backend past the TTL and disposes it', async () => { const pool = new AgentPool({ idleTtlMs: 1_000, maxLive: 100 }); const b = new FakeBackend(); pool.register('c1', 'goose', b); // Sweep with now far past the registration → idle → evicted. const { evicted } = await pool.sweep(Date.now() + 10_000); expect(evicted).toEqual(['c1:goose']); expect(b.disposed).toBe(1); expect(pool.get('c1', 'goose')).toBeUndefined(); }); it('never evicts a busy backend even past the TTL', async () => { const pool = new AgentPool({ idleTtlMs: 1_000, maxLive: 100 }); const b = new FakeBackend(); b.setBusy(true); pool.register('c1', 'goose', b); const { evicted } = await pool.sweep(Date.now() + 10_000); expect(evicted).toEqual([]); expect(b.disposed).toBe(0); expect(pool.get('c1', 'goose')).toBe(b); }); it('touch keeps a backend warm so the TTL measures from the last turn', async () => { const pool = new AgentPool({ idleTtlMs: 5_000, maxLive: 100 }); const b = new FakeBackend(); pool.register('c1', 'goose', b); const base = Date.now(); // 4s later, touch — resets activity. A sweep at +6s from base is only +2s from // the touch → still within TTL → not evicted. vi.spyOn(Date, 'now').mockReturnValue(base + 4_000); pool.touch('c1', 'goose'); vi.restoreAllMocks(); const { evicted } = await pool.sweep(base + 6_000); expect(evicted).toEqual([]); }); }); describe('AgentPool.sweep — LRU cap (3.4)', () => { it('evicts the least-recently-used beyond the cap', async () => { const pool = new AgentPool({ idleTtlMs: 1_000_000, maxLive: 2 }); const base = 1_000_000; const mk = (key: string, regAt: number) => { vi.spyOn(Date, 'now').mockReturnValue(regAt); const b = new FakeBackend(key); const [chat, agent] = key.split(':'); pool.register(chat!, agent!, b); vi.restoreAllMocks(); return b; }; const a = mk('c1:goose', base + 100); const b = mk('c2:goose', base + 300); const c = mk('c3:goose', base + 200); // 3 entries, cap 2, all within idle TTL → LRU (oldest = a@+100) evicted. const { evicted } = await pool.sweep(base + 1_000); expect(evicted).toEqual(['c1:goose']); expect(a.disposed).toBe(1); expect(b.disposed).toBe(0); expect(c.disposed).toBe(0); }); }); describe('AgentPool.sweep — proactive health probe (3.2)', () => { it('drives each backend tickHealth before eviction', async () => { const pool = new AgentPool({ idleTtlMs: 1_000_000, maxLive: 100 }); const b = new FakeBackend(); pool.register('c1', 'opencode', b); await pool.sweep(Date.now()); expect(b.tickHealthCalls).toBe(1); }); }); describe('AgentPool.closeChat — chat-close teardown (3.3)', () => { it('disposes only the matching chat keys, leaving others + the shared server', async () => { const pool = new AgentPool(); const goose = new FakeBackend('goose'); const qwen = new FakeBackend('qwen'); const other = new FakeBackend('other-chat'); const ocServer = new FakeBackend('opencode-server'); pool.register('chat-1', 'goose', goose); pool.register('chat-1', 'qwen', qwen); pool.register('chat-2', 'goose', other); pool.register(OPENCODE_POOL_KEY, 'opencode', ocServer); const removed = await pool.closeChat('chat-1'); expect(removed.sort()).toEqual(['chat-1:goose', 'chat-1:qwen']); expect(goose.disposed).toBe(1); expect(qwen.disposed).toBe(1); // other chat + shared opencode server untouched. expect(other.disposed).toBe(0); expect(ocServer.disposed).toBe(0); expect(pool.peek('chat-2', 'goose')).toBe(other); expect(pool.peek(OPENCODE_POOL_KEY, 'opencode')).toBe(ocServer); }); it('does not dispose a busy backend on closeChat', async () => { const pool = new AgentPool(); const b = new FakeBackend(); b.setBusy(true); pool.register('chat-1', 'goose', b); const removed = await pool.closeChat('chat-1'); expect(removed).toEqual([]); expect(b.disposed).toBe(0); }); it('does not match a chat id that is a prefix of another', async () => { // 'chat-1' must not match 'chat-10' — keys are `${chatId}:${agent}` so the // colon delimiter prevents the prefix collision. const pool = new AgentPool(); const a = new FakeBackend(); const b = new FakeBackend(); pool.register('chat-1', 'goose', a); pool.register('chat-10', 'goose', b); await pool.closeChat('chat-1'); expect(a.disposed).toBe(1); expect(b.disposed).toBe(0); expect(pool.peek('chat-10', 'goose')).toBe(b); }); }); describe('AgentPool.dispose — drain all (T.1)', () => { it('disposes every backend and clears the map', async () => { const pool = new AgentPool(); const a = new FakeBackend(); const b = new FakeBackend(); pool.register('c1', 'goose', a); pool.register('c2', 'qwen', b); await pool.dispose(); expect(a.disposed).toBe(1); expect(b.disposed).toBe(1); expect(pool.health()).toEqual({ size: 0, busy: 0 }); }); it('tolerates a backend whose dispose throws', async () => { const pool = new AgentPool(); const good = new FakeBackend(); const bad = new FakeBackend(); bad.dispose = async () => { throw new Error('boom'); }; pool.register('c1', 'goose', bad); pool.register('c2', 'qwen', good); await expect(pool.dispose()).resolves.toBeUndefined(); expect(good.disposed).toBe(1); }); });