// Tests for Electrobun agent-store — pure logic and data flow. // Uses bun:test. Mocks appRpc since store depends on RPC calls. import { describe, it, expect, beforeEach, mock, spyOn } from 'bun:test'; // ── Replicated types ────────────────────────────────────────────────────────── type AgentStatus = 'idle' | 'running' | 'done' | 'error'; type MsgRole = 'user' | 'assistant' | 'tool-call' | 'tool-result' | 'thinking' | 'system'; interface AgentMessage { id: string; seqId: number; role: MsgRole; content: string; toolName?: string; toolInput?: string; toolPath?: string; timestamp: number; } interface AgentSession { sessionId: string; projectId: string; provider: string; status: AgentStatus; messages: AgentMessage[]; costUsd: number; inputTokens: number; outputTokens: number; model: string; error?: string; } // ── Replicated pure functions ────────────────────────────────────────────────── function normalizeStatus(status: string): AgentStatus { if (status === 'running' || status === 'idle' || status === 'done' || status === 'error') { return status; } return 'idle'; } const BLOCKED_ENV_PREFIXES = ['CLAUDE', 'CODEX', 'OLLAMA', 'ANTHROPIC_']; function validateExtraEnv(env: Record | undefined): Record | undefined { if (!env) return undefined; const clean: Record = {}; for (const [key, value] of Object.entries(env)) { const blocked = BLOCKED_ENV_PREFIXES.some(p => key.startsWith(p)); if (blocked) continue; clean[key] = value; } return Object.keys(clean).length > 0 ? clean : undefined; } function extractToolPath(name: string, input: Record | undefined): string | undefined { if (!input) return undefined; if (typeof input.file_path === 'string') return input.file_path; if (typeof input.path === 'string') return input.path; if (name === 'Bash' && typeof input.command === 'string') { return (input.command as string).length > 80 ? (input.command as string).slice(0, 80) + '...' : input.command as string; } return undefined; } function truncateOutput(text: string, maxLines: number): string { const lines = text.split('\n'); if (lines.length <= maxLines) return text; return lines.slice(0, maxLines).join('\n') + `\n... (${lines.length - maxLines} more lines)`; } // ── seqId monotonic counter ───────────────────────────────────────────────── function createSeqCounter() { const counters = new Map(); return { next(sessionId: string): number { const current = counters.get(sessionId) ?? 0; const next = current + 1; counters.set(sessionId, next); return next; }, get(sessionId: string): number { return counters.get(sessionId) ?? 0; }, set(sessionId: string, value: number): void { counters.set(sessionId, value); }, }; } // ── Tests ─────────────────────────────────────────────────────────────────── describe('normalizeStatus', () => { it('passes through valid statuses', () => { expect(normalizeStatus('running')).toBe('running'); expect(normalizeStatus('idle')).toBe('idle'); expect(normalizeStatus('done')).toBe('done'); expect(normalizeStatus('error')).toBe('error'); }); it('normalizes unknown statuses to idle', () => { expect(normalizeStatus('starting')).toBe('idle'); expect(normalizeStatus('unknown')).toBe('idle'); expect(normalizeStatus('')).toBe('idle'); }); }); describe('validateExtraEnv', () => { it('returns undefined for undefined input', () => { expect(validateExtraEnv(undefined)).toBeUndefined(); }); it('strips CLAUDE-prefixed keys', () => { const result = validateExtraEnv({ CLAUDE_API_KEY: 'secret', MY_VAR: 'ok' }); expect(result).toEqual({ MY_VAR: 'ok' }); }); it('strips CODEX-prefixed keys', () => { const result = validateExtraEnv({ CODEX_TOKEN: 'secret', SAFE: '1' }); expect(result).toEqual({ SAFE: '1' }); }); it('strips OLLAMA-prefixed keys', () => { const result = validateExtraEnv({ OLLAMA_HOST: 'localhost', PATH: '/usr/bin' }); expect(result).toEqual({ PATH: '/usr/bin' }); }); it('strips ANTHROPIC_-prefixed keys', () => { const result = validateExtraEnv({ ANTHROPIC_API_KEY: 'sk-xxx' }); expect(result).toBeUndefined(); // all stripped, empty → undefined }); it('returns undefined when all keys blocked', () => { const result = validateExtraEnv({ CLAUDE_KEY: 'a', CODEX_KEY: 'b' }); expect(result).toBeUndefined(); }); it('passes through safe keys', () => { const result = validateExtraEnv({ BTMSG_AGENT_ID: 'manager', NODE_ENV: 'test' }); expect(result).toEqual({ BTMSG_AGENT_ID: 'manager', NODE_ENV: 'test' }); }); }); describe('seqId counter', () => { it('starts at 1', () => { const counter = createSeqCounter(); expect(counter.next('s1')).toBe(1); }); it('increments monotonically', () => { const counter = createSeqCounter(); expect(counter.next('s1')).toBe(1); expect(counter.next('s1')).toBe(2); expect(counter.next('s1')).toBe(3); }); it('tracks separate sessions independently', () => { const counter = createSeqCounter(); expect(counter.next('s1')).toBe(1); expect(counter.next('s2')).toBe(1); expect(counter.next('s1')).toBe(2); expect(counter.next('s2')).toBe(2); }); it('resumes from set value', () => { const counter = createSeqCounter(); counter.set('s1', 42); expect(counter.next('s1')).toBe(43); }); }); describe('deduplication on restore', () => { it('removes duplicate seqIds', () => { const messages = [ { msgId: 'a', seqId: 1, role: 'user', content: 'hello', timestamp: 1000 }, { msgId: 'b', seqId: 2, role: 'assistant', content: 'hi', timestamp: 1001 }, { msgId: 'a-dup', seqId: 1, role: 'user', content: 'hello', timestamp: 1002 }, // duplicate seqId { msgId: 'c', seqId: 3, role: 'assistant', content: 'ok', timestamp: 1003 }, ]; const seqIdSet = new Set(); const deduplicated: Array<{ msgId: string; seqId: number }> = []; let maxSeqId = 0; for (const m of messages) { const sid = m.seqId ?? 0; if (sid > 0 && seqIdSet.has(sid)) continue; if (sid > 0) seqIdSet.add(sid); if (sid > maxSeqId) maxSeqId = sid; deduplicated.push({ msgId: m.msgId, seqId: sid }); } expect(deduplicated).toHaveLength(3); expect(maxSeqId).toBe(3); expect(deduplicated.map(m => m.msgId)).toEqual(['a', 'b', 'c']); }); it('counter resumes from max seqId after restore', () => { const counter = createSeqCounter(); // Simulate restore const maxSeqId = 15; counter.set('s1', maxSeqId); // Next should be 16 expect(counter.next('s1')).toBe(16); }); }); describe('extractToolPath', () => { it('extracts file_path from input', () => { expect(extractToolPath('Read', { file_path: '/src/main.ts' })).toBe('/src/main.ts'); }); it('extracts path from input', () => { expect(extractToolPath('Glob', { path: '/src', pattern: '*.ts' })).toBe('/src'); }); it('extracts command from Bash tool', () => { expect(extractToolPath('Bash', { command: 'ls -la' })).toBe('ls -la'); }); it('truncates long Bash commands at 80 chars', () => { const longCmd = 'a'.repeat(100); const result = extractToolPath('Bash', { command: longCmd }); expect(result!.length).toBe(83); // 80 + '...' }); it('returns undefined for unknown tool without paths', () => { expect(extractToolPath('Custom', { data: 'value' })).toBeUndefined(); }); }); describe('truncateOutput', () => { it('returns short text unchanged', () => { expect(truncateOutput('hello\nworld', 10)).toBe('hello\nworld'); }); it('truncates text exceeding maxLines', () => { const lines = Array.from({ length: 20 }, (_, i) => `line ${i}`); const result = truncateOutput(lines.join('\n'), 5); expect(result.split('\n')).toHaveLength(6); // 5 lines + truncation message expect(result).toContain('15 more lines'); }); }); describe('double-start guard', () => { it('startingProjects Set prevents concurrent starts', () => { const startingProjects = new Set(); // First start: succeeds expect(startingProjects.has('proj-1')).toBe(false); startingProjects.add('proj-1'); expect(startingProjects.has('proj-1')).toBe(true); // Second start: blocked const blocked = startingProjects.has('proj-1'); expect(blocked).toBe(true); // After completion: removed startingProjects.delete('proj-1'); expect(startingProjects.has('proj-1')).toBe(false); }); }); describe('persistMessages — lastPersistedIndex', () => { it('only saves new messages from lastPersistedIndex', () => { const allMessages = [ { id: 'm1', content: 'a' }, { id: 'm2', content: 'b' }, { id: 'm3', content: 'c' }, { id: 'm4', content: 'd' }, ]; const lastPersistedIndex = 2; const newMsgs = allMessages.slice(lastPersistedIndex); expect(newMsgs).toHaveLength(2); expect(newMsgs[0].id).toBe('m3'); expect(newMsgs[1].id).toBe('m4'); }); it('returns empty when nothing new', () => { const allMessages = [{ id: 'm1', content: 'a' }]; const lastPersistedIndex = 1; const newMsgs = allMessages.slice(lastPersistedIndex); expect(newMsgs).toHaveLength(0); }); }); describe('loadLastSession — active session guard', () => { it('skips restore if project has running session', () => { const sessions: Record = { 's1': { sessionId: 's1', projectId: 'p1', provider: 'claude', status: 'running', messages: [], costUsd: 0, inputTokens: 0, outputTokens: 0, model: 'claude-opus-4-5', }, }; const projectSessionMap = new Map([['p1', 's1']]); const startingProjects = new Set(); const existingSessionId = projectSessionMap.get('p1'); let shouldSkip = false; if (existingSessionId) { const existing = sessions[existingSessionId]; if (existing && (existing.status === 'running' || startingProjects.has('p1'))) { shouldSkip = true; } } expect(shouldSkip).toBe(true); }); });