From e75f90407b48f04b316fa3d9326dc12baef9f5bb Mon Sep 17 00:00:00 2001 From: Hibryda Date: Sun, 22 Mar 2026 05:02:02 +0100 Subject: [PATCH] test: bun backend + store/hardening unit tests (WIP, agents running) --- packages/stores/__tests__/health.test.ts | 288 ++++++++++++ .../stores/__tests__/notifications.test.ts | 129 ++++++ packages/stores/__tests__/theme.test.ts | 85 ++++ ui-electrobun/package.json | 1 + .../src/bun/__tests__/btmsg-db.test.ts | 347 ++++++++++++++ .../src/bun/__tests__/bttask-db.test.ts | 211 +++++++++ .../src/bun/__tests__/message-adapter.test.ts | 428 ++++++++++++++++++ .../src/bun/__tests__/relay-client.test.ts | 187 ++++++++ .../src/bun/__tests__/search-db.test.ts | 118 +++++ .../src/bun/__tests__/session-db.test.ts | 190 ++++++++ .../src/bun/__tests__/sidecar-manager.test.ts | 240 ++++++++++ ui-electrobun/tests/unit/agent-store.test.ts | 312 +++++++++++++ 12 files changed, 2536 insertions(+) create mode 100644 packages/stores/__tests__/health.test.ts create mode 100644 packages/stores/__tests__/notifications.test.ts create mode 100644 packages/stores/__tests__/theme.test.ts create mode 100644 ui-electrobun/src/bun/__tests__/btmsg-db.test.ts create mode 100644 ui-electrobun/src/bun/__tests__/bttask-db.test.ts create mode 100644 ui-electrobun/src/bun/__tests__/message-adapter.test.ts create mode 100644 ui-electrobun/src/bun/__tests__/relay-client.test.ts create mode 100644 ui-electrobun/src/bun/__tests__/search-db.test.ts create mode 100644 ui-electrobun/src/bun/__tests__/session-db.test.ts create mode 100644 ui-electrobun/src/bun/__tests__/sidecar-manager.test.ts create mode 100644 ui-electrobun/tests/unit/agent-store.test.ts diff --git a/packages/stores/__tests__/health.test.ts b/packages/stores/__tests__/health.test.ts new file mode 100644 index 0000000..bae3198 --- /dev/null +++ b/packages/stores/__tests__/health.test.ts @@ -0,0 +1,288 @@ +// Tests for @agor/stores health store +// The store uses Svelte 5 runes — we replicate and test the pure logic. + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── Replicated types from health.svelte.ts ────────────────────────────────── + +type ActivityState = 'inactive' | 'running' | 'idle' | 'stalled'; + +interface ProjectHealth { + projectId: string; + activityState: ActivityState; + activeTool: string | null; + idleDurationMs: number; + burnRatePerHour: number; + contextPressure: number | null; + fileConflictCount: number; + attentionScore: number; + attentionReason: string | null; +} + +// ── Replicated pure functions from health.svelte.ts (Electrobun version) ──── + +const DEFAULT_STALL_THRESHOLD_MS = 15 * 60 * 1000; +const BURN_RATE_WINDOW_MS = 5 * 60 * 1000; +const DEFAULT_CONTEXT_LIMIT = 200_000; + +function scoreAttention( + activityState: ActivityState, + contextPressure: number | null, + fileConflictCount: number, + status: string, +): { score: number; reason: string | null } { + if (status === 'error') return { score: 90, reason: 'Agent error' }; + if (activityState === 'stalled') return { score: 100, reason: 'Agent stalled (>15 min)' }; + if (contextPressure !== null && contextPressure > 0.9) return { score: 80, reason: 'Context >90%' }; + if (fileConflictCount > 0) return { score: 70, reason: `${fileConflictCount} file conflict(s)` }; + if (contextPressure !== null && contextPressure > 0.75) return { score: 40, reason: 'Context >75%' }; + return { score: 0, reason: null }; +} + +function computeBurnRate(snapshots: Array<[number, number]>): number { + if (snapshots.length < 2) return 0; + const now = Date.now(); + const windowStart = now - BURN_RATE_WINDOW_MS; + const recent = snapshots.filter(([ts]) => ts >= windowStart); + if (recent.length < 2) return 0; + const first = recent[0]; + const last = recent[recent.length - 1]; + const elapsedHours = (last[0] - first[0]) / 3_600_000; + if (elapsedHours < 0.001) return 0; + const costDelta = last[1] - first[1]; + return Math.max(0, costDelta / elapsedHours); +} + +// Simplified tracker for testing +interface ProjectTracker { + projectId: string; + lastActivityTs: number; + lastToolName: string | null; + toolsInFlight: number; + activeToolMap: Map; + costSnapshots: Array<[number, number]>; + totalTokens: number; + totalCost: number; + status: 'inactive' | 'running' | 'idle' | 'done' | 'error'; +} + +function makeTracker(overrides: Partial = {}): ProjectTracker { + return { + projectId: 'test-project', + lastActivityTs: Date.now(), + lastToolName: null, + toolsInFlight: 0, + activeToolMap: new Map(), + costSnapshots: [], + totalTokens: 0, + totalCost: 0, + status: 'inactive', + ...overrides, + }; +} + +function computeHealth(tracker: ProjectTracker, now: number): ProjectHealth { + let activityState: ActivityState; + let idleDurationMs = 0; + let activeTool: string | null = null; + + if (tracker.status === 'inactive' || tracker.status === 'done' || tracker.status === 'error') { + activityState = 'inactive'; + } else if (tracker.toolsInFlight > 0) { + activityState = 'running'; + activeTool = tracker.lastToolName; + } else { + idleDurationMs = now - tracker.lastActivityTs; + activityState = idleDurationMs >= DEFAULT_STALL_THRESHOLD_MS ? 'stalled' : 'idle'; + } + + let contextPressure: number | null = null; + if (tracker.totalTokens > 0) { + contextPressure = Math.min(1, tracker.totalTokens / DEFAULT_CONTEXT_LIMIT); + } + + const burnRatePerHour = computeBurnRate(tracker.costSnapshots); + const attention = scoreAttention(activityState, contextPressure, 0, tracker.status); + + return { + projectId: tracker.projectId, + activityState, + activeTool, + idleDurationMs, + burnRatePerHour, + contextPressure, + fileConflictCount: 0, + attentionScore: attention.score, + attentionReason: attention.reason, + }; +} + +// ── Tests ─────────────────────────────────────────────────────────────────── + +describe('scoreAttention', () => { + it('returns 0 when no attention needed', () => { + const r = scoreAttention('inactive', null, 0, 'inactive'); + expect(r.score).toBe(0); + expect(r.reason).toBeNull(); + }); + + it('scores stalled highest (100)', () => { + const r = scoreAttention('stalled', null, 0, 'running'); + expect(r.score).toBe(100); + expect(r.reason).toContain('stalled'); + }); + + it('scores error at 90', () => { + const r = scoreAttention('inactive', null, 0, 'error'); + expect(r.score).toBe(90); + expect(r.reason).toContain('error'); + }); + + it('scores context >90% at 80', () => { + const r = scoreAttention('running', 0.95, 0, 'running'); + expect(r.score).toBe(80); + expect(r.reason).toContain('90%'); + }); + + it('scores file conflict at 70', () => { + const r = scoreAttention('running', 0.5, 2, 'running'); + expect(r.score).toBe(70); + expect(r.reason).toContain('2 file conflict'); + }); + + it('scores context >75% at 40', () => { + const r = scoreAttention('running', 0.8, 0, 'running'); + expect(r.score).toBe(40); + expect(r.reason).toContain('75%'); + }); + + it('priority: stalled beats context pressure', () => { + const r = scoreAttention('stalled', 0.95, 3, 'running'); + expect(r.score).toBe(100); + }); +}); + +describe('computeBurnRate', () => { + it('returns 0 with fewer than 2 snapshots', () => { + expect(computeBurnRate([])).toBe(0); + expect(computeBurnRate([[Date.now(), 1.0]])).toBe(0); + }); + + it('calculates $/hr from cost snapshots', () => { + const now = Date.now(); + const snapshots: Array<[number, number]> = [ + [now - 60_000, 0.50], // 1 min ago, $0.50 + [now, 1.50], // now, $1.50 + ]; + const rate = computeBurnRate(snapshots); + // $1.00 over 1 minute = $60/hr + expect(rate).toBeCloseTo(60, 0); + }); + + it('returns 0 when snapshots outside window', () => { + const now = Date.now(); + const snapshots: Array<[number, number]> = [ + [now - BURN_RATE_WINDOW_MS - 10_000, 0.50], + [now - BURN_RATE_WINDOW_MS - 5_000, 1.50], + ]; + expect(computeBurnRate(snapshots)).toBe(0); + }); +}); + +describe('computeHealth', () => { + it('inactive tracker returns inactive state', () => { + const tracker = makeTracker({ status: 'inactive' }); + const h = computeHealth(tracker, Date.now()); + expect(h.activityState).toBe('inactive'); + expect(h.attentionScore).toBe(0); + }); + + it('running tracker with tool in flight returns running state', () => { + const tracker = makeTracker({ status: 'running', toolsInFlight: 1, lastToolName: 'Bash' }); + const h = computeHealth(tracker, Date.now()); + expect(h.activityState).toBe('running'); + expect(h.activeTool).toBe('Bash'); + }); + + it('detects stall when idle exceeds threshold', () => { + const stalledTs = Date.now() - DEFAULT_STALL_THRESHOLD_MS - 1000; + const tracker = makeTracker({ status: 'running', lastActivityTs: stalledTs }); + const h = computeHealth(tracker, Date.now()); + expect(h.activityState).toBe('stalled'); + expect(h.attentionScore).toBe(100); + }); + + it('calculates context pressure from tokens', () => { + const tracker = makeTracker({ status: 'running', totalTokens: 180_000, toolsInFlight: 1 }); + const h = computeHealth(tracker, Date.now()); + expect(h.contextPressure).toBeCloseTo(0.9, 2); + }); + + it('context pressure capped at 1.0', () => { + const tracker = makeTracker({ status: 'running', totalTokens: 300_000, toolsInFlight: 1 }); + const h = computeHealth(tracker, Date.now()); + expect(h.contextPressure).toBe(1.0); + }); + + it('toolsInFlight counter tracks concurrent tools', () => { + const tracker = makeTracker({ status: 'running', toolsInFlight: 3, lastToolName: 'Read' }); + const h = computeHealth(tracker, Date.now()); + expect(h.activityState).toBe('running'); + expect(h.activeTool).toBe('Read'); + }); +}); + +describe('tool histogram logic', () => { + it('activeToolMap tracks per-tool starts', () => { + const tracker = makeTracker({ status: 'running' }); + // Simulate recordActivity with toolName + tracker.activeToolMap.set('Bash', { startTime: Date.now(), count: 1 }); + tracker.toolsInFlight = 1; + tracker.lastToolName = 'Bash'; + expect(tracker.activeToolMap.size).toBe(1); + expect(tracker.activeToolMap.get('Bash')!.count).toBe(1); + }); + + it('incrementing count for same tool', () => { + const tracker = makeTracker({ status: 'running' }); + tracker.activeToolMap.set('Bash', { startTime: Date.now(), count: 1 }); + // Second Bash call + const entry = tracker.activeToolMap.get('Bash')!; + entry.count++; + tracker.toolsInFlight = 2; + expect(entry.count).toBe(2); + expect(tracker.toolsInFlight).toBe(2); + }); + + it('decrement removes entry when count reaches 0', () => { + const tracker = makeTracker({ status: 'running' }); + tracker.activeToolMap.set('Read', { startTime: Date.now(), count: 1 }); + tracker.toolsInFlight = 1; + // Simulate recordToolDone + tracker.toolsInFlight = Math.max(0, tracker.toolsInFlight - 1); + const entry = tracker.activeToolMap.get('Read')!; + entry.count--; + if (entry.count <= 0) tracker.activeToolMap.delete('Read'); + expect(tracker.activeToolMap.size).toBe(0); + expect(tracker.toolsInFlight).toBe(0); + }); +}); + +describe('attention queue sorting', () => { + it('sorts by score descending', () => { + const trackers = [ + makeTracker({ projectId: 'a', status: 'running', totalTokens: 180_000, toolsInFlight: 1 }), // ctx 90% + makeTracker({ projectId: 'b', status: 'error' }), // error + makeTracker({ projectId: 'c', status: 'inactive' }), // no attention + ]; + const now = Date.now(); + const healths = trackers.map(t => computeHealth(t, now)); + const queue = healths + .filter(h => h.attentionScore > 0) + .sort((a, b) => b.attentionScore - a.attentionScore); + + expect(queue).toHaveLength(2); + expect(queue[0].projectId).toBe('b'); // error = 90 + expect(queue[1].projectId).toBe('a'); // ctx 90% = 80 + }); +}); diff --git a/packages/stores/__tests__/notifications.test.ts b/packages/stores/__tests__/notifications.test.ts new file mode 100644 index 0000000..cee8620 --- /dev/null +++ b/packages/stores/__tests__/notifications.test.ts @@ -0,0 +1,129 @@ +// Tests for @agor/stores notification store +// The store uses Svelte 5 runes ($state) so we can't import directly in vitest. +// We test the pure logic and types that can be validated without the Svelte compiler. + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock the backend module +vi.mock('../../../src/lib/backend/backend', () => ({ + getBackend: vi.fn(() => ({ + sendDesktopNotification: vi.fn(), + })), +})); + +// Since the store uses $state (Svelte 5 rune), we cannot import it directly. +// Instead we replicate the pure functions and test them. + +// ── Replicated pure logic from notifications.svelte.ts ────────────────────── + +type ToastType = 'info' | 'success' | 'warning' | 'error'; +type NotificationType = 'agent_complete' | 'agent_error' | 'task_review' | 'wake_event' | 'conflict' | 'system'; + +function notificationTypeToToast(type: NotificationType): ToastType { + switch (type) { + case 'agent_complete': return 'success'; + case 'agent_error': return 'error'; + case 'task_review': return 'info'; + case 'wake_event': return 'info'; + case 'conflict': return 'warning'; + case 'system': return 'info'; + } +} + +function notificationUrgency(type: NotificationType): 'low' | 'normal' | 'critical' { + switch (type) { + case 'agent_error': return 'critical'; + case 'conflict': return 'normal'; + case 'system': return 'normal'; + default: return 'low'; + } +} + +// Rate limiter logic (replicated) +const RATE_LIMIT_WINDOW_MS = 30_000; +const RATE_LIMIT_MAX_PER_TYPE = 3; + +function createRateLimiter() { + const recentToasts = new Map(); + return { + isRateLimited(type: ToastType, now = Date.now()): boolean { + const timestamps = recentToasts.get(type) ?? []; + const recent = timestamps.filter(t => now - t < RATE_LIMIT_WINDOW_MS); + recentToasts.set(type, recent); + if (recent.length >= RATE_LIMIT_MAX_PER_TYPE) return true; + recent.push(now); + return false; + }, + }; +} + +describe('notification type mapping', () => { + it('maps agent_complete to success toast', () => { + expect(notificationTypeToToast('agent_complete')).toBe('success'); + }); + + it('maps agent_error to error toast', () => { + expect(notificationTypeToToast('agent_error')).toBe('error'); + }); + + it('maps conflict to warning toast', () => { + expect(notificationTypeToToast('conflict')).toBe('warning'); + }); + + it('maps system to info toast', () => { + expect(notificationTypeToToast('system')).toBe('info'); + }); +}); + +describe('notification urgency', () => { + it('agent_error is critical', () => { + expect(notificationUrgency('agent_error')).toBe('critical'); + }); + + it('conflict is normal', () => { + expect(notificationUrgency('conflict')).toBe('normal'); + }); + + it('task_review is low', () => { + expect(notificationUrgency('task_review')).toBe('low'); + }); +}); + +describe('rate limiter', () => { + it('allows first 3 toasts of same type', () => { + const rl = createRateLimiter(); + const now = Date.now(); + expect(rl.isRateLimited('info', now)).toBe(false); + expect(rl.isRateLimited('info', now + 1)).toBe(false); + expect(rl.isRateLimited('info', now + 2)).toBe(false); + }); + + it('blocks 4th toast of same type within window', () => { + const rl = createRateLimiter(); + const now = Date.now(); + rl.isRateLimited('error', now); + rl.isRateLimited('error', now + 1); + rl.isRateLimited('error', now + 2); + expect(rl.isRateLimited('error', now + 3)).toBe(true); + }); + + it('allows toast of different type', () => { + const rl = createRateLimiter(); + const now = Date.now(); + rl.isRateLimited('error', now); + rl.isRateLimited('error', now + 1); + rl.isRateLimited('error', now + 2); + // Different type should not be rate limited + expect(rl.isRateLimited('info', now + 3)).toBe(false); + }); + + it('resets after window expires', () => { + const rl = createRateLimiter(); + const now = Date.now(); + rl.isRateLimited('warning', now); + rl.isRateLimited('warning', now + 1); + rl.isRateLimited('warning', now + 2); + // After window expires, should allow again + expect(rl.isRateLimited('warning', now + RATE_LIMIT_WINDOW_MS + 1)).toBe(false); + }); +}); diff --git a/packages/stores/__tests__/theme.test.ts b/packages/stores/__tests__/theme.test.ts new file mode 100644 index 0000000..242150d --- /dev/null +++ b/packages/stores/__tests__/theme.test.ts @@ -0,0 +1,85 @@ +// Tests for @agor/stores theme store + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock the settings-store module that theme.svelte.ts imports +vi.mock('../../../src/lib/stores/settings-store.svelte', () => ({ + getSetting: vi.fn().mockResolvedValue(null), + setSetting: vi.fn().mockResolvedValue(undefined), +})); + +// Mock the styles/themes module — no DOM needed +vi.mock('../../../src/lib/styles/themes', () => { + const ALL_THEME_IDS = [ + 'mocha', 'macchiato', 'frappe', 'latte', + 'vscode-dark', 'atom-one-dark', 'monokai', 'dracula', + 'nord', 'solarized-dark', 'github-dark', + 'tokyo-night', 'gruvbox-dark', 'ayu-dark', 'poimandres', + 'vesper', 'midnight', + ]; + return { + ALL_THEME_IDS, + THEME_LIST: ALL_THEME_IDS.map(id => ({ id, label: id, group: 'test', isDark: true })), + applyCssVariables: vi.fn(), + applyPaletteDirect: vi.fn(), + buildXtermTheme: vi.fn(() => ({ background: '#000', foreground: '#fff' })), + buildXtermThemeFromPalette: vi.fn(() => ({ background: '#111', foreground: '#eee' })), + }; +}); + +// Mock custom-themes dynamic import +vi.mock('../../../src/lib/styles/custom-themes', () => ({ + loadCustomThemes: vi.fn().mockResolvedValue([]), +})); + +// Mock handle-error +vi.mock('../../../src/lib/utils/handle-error', () => ({ + handleInfraError: vi.fn(), +})); + +import { getSetting, setSetting } from '../../../src/lib/stores/settings-store.svelte'; +import { applyCssVariables } from '../../../src/lib/styles/themes'; + +// We can't import from .svelte.ts in vitest without svelte compiler. +// Test the exported functions indirectly by validating the mocked dependencies. +// Instead, test the module's data/logic we CAN import: +import { ALL_THEME_IDS, THEME_LIST } from '../../../src/lib/styles/themes'; + +describe('theme store — data integrity', () => { + it('ALL_THEME_IDS has exactly 17 entries', () => { + expect(ALL_THEME_IDS).toHaveLength(17); + }); + + it('THEME_LIST has 17 entries matching ALL_THEME_IDS', () => { + expect(THEME_LIST).toHaveLength(17); + for (const meta of THEME_LIST) { + expect(ALL_THEME_IDS).toContain(meta.id); + } + }); + + it('all 4 Catppuccin flavors are present', () => { + const catFlavors = ['mocha', 'macchiato', 'frappe', 'latte']; + for (const f of catFlavors) { + expect(ALL_THEME_IDS).toContain(f); + } + }); + + it('deep dark themes are present', () => { + const deepDark = ['tokyo-night', 'gruvbox-dark', 'ayu-dark', 'poimandres', 'vesper', 'midnight']; + for (const t of deepDark) { + expect(ALL_THEME_IDS).toContain(t); + } + }); + + it('editor themes are present', () => { + const editors = ['vscode-dark', 'atom-one-dark', 'monokai', 'dracula', 'nord', 'solarized-dark', 'github-dark']; + for (const t of editors) { + expect(ALL_THEME_IDS).toContain(t); + } + }); + + it('no duplicate theme IDs', () => { + const unique = new Set(ALL_THEME_IDS); + expect(unique.size).toBe(ALL_THEME_IDS.length); + }); +}); diff --git a/ui-electrobun/package.json b/ui-electrobun/package.json index dab9f42..4330468 100644 --- a/ui-electrobun/package.json +++ b/ui-electrobun/package.json @@ -9,6 +9,7 @@ "dev:hmr": "concurrently \"bun run hmr\" \"bun run start\"", "hmr": "vite --port 9760", "build:canary": "vite build && electrobun build --env=canary", + "test": "bun test src/bun/__tests__/", "test:e2e": "wdio run tests/e2e/wdio.conf.js" }, "dependencies": { diff --git a/ui-electrobun/src/bun/__tests__/btmsg-db.test.ts b/ui-electrobun/src/bun/__tests__/btmsg-db.test.ts new file mode 100644 index 0000000..0d75f0c --- /dev/null +++ b/ui-electrobun/src/bun/__tests__/btmsg-db.test.ts @@ -0,0 +1,347 @@ +/** + * Unit tests for BtmsgDb — in-memory SQLite. + */ +import { describe, it, expect, beforeEach } from "bun:test"; +import { Database } from "bun:sqlite"; +import { randomUUID } from "crypto"; + +const SCHEMA = ` +CREATE TABLE IF NOT EXISTS agents ( + id TEXT PRIMARY KEY, name TEXT NOT NULL, role TEXT NOT NULL, + group_id TEXT NOT NULL, tier INTEGER NOT NULL DEFAULT 2, + model TEXT, cwd TEXT, system_prompt TEXT, + status TEXT DEFAULT 'stopped', last_active_at TEXT, + created_at TEXT DEFAULT (datetime('now')) +); +CREATE TABLE IF NOT EXISTS contacts ( + agent_id TEXT NOT NULL, contact_id TEXT NOT NULL, + PRIMARY KEY (agent_id, contact_id) +); +CREATE TABLE IF NOT EXISTS messages ( + id TEXT PRIMARY KEY, from_agent TEXT NOT NULL, to_agent TEXT NOT NULL, + content TEXT NOT NULL, read INTEGER DEFAULT 0, reply_to TEXT, + group_id TEXT NOT NULL, sender_group_id TEXT, + created_at TEXT DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_messages_to ON messages(to_agent, read); +CREATE TABLE IF NOT EXISTS channels ( + id TEXT PRIMARY KEY, name TEXT NOT NULL, group_id TEXT NOT NULL, + created_by TEXT NOT NULL, created_at TEXT DEFAULT (datetime('now')) +); +CREATE TABLE IF NOT EXISTS channel_members ( + channel_id TEXT NOT NULL, agent_id TEXT NOT NULL, + joined_at TEXT DEFAULT (datetime('now')), + PRIMARY KEY (channel_id, agent_id) +); +CREATE TABLE IF NOT EXISTS channel_messages ( + id TEXT PRIMARY KEY, channel_id TEXT NOT NULL, from_agent TEXT NOT NULL, + content TEXT NOT NULL, created_at TEXT DEFAULT (datetime('now')) +); +CREATE TABLE IF NOT EXISTS heartbeats ( + agent_id TEXT PRIMARY KEY, timestamp INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS seen_messages ( + session_id TEXT NOT NULL, message_id TEXT NOT NULL, + seen_at INTEGER NOT NULL DEFAULT (unixepoch()), + PRIMARY KEY (session_id, message_id) +); +`; + +function createDb(): Database { + const db = new Database(":memory:"); + db.exec("PRAGMA foreign_keys = ON"); + db.exec(SCHEMA); + return db; +} + +function registerAgent(db: Database, id: string, name: string, role: string, groupId: string, tier = 2) { + db.query( + `INSERT INTO agents (id, name, role, group_id, tier) + VALUES (?1, ?2, ?3, ?4, ?5) + ON CONFLICT(id) DO UPDATE SET name=excluded.name, role=excluded.role, group_id=excluded.group_id` + ).run(id, name, role, groupId, tier); +} + +function sendMessage(db: Database, fromAgent: string, toAgent: string, content: string): string { + const sender = db.query<{ group_id: string }, [string]>( + "SELECT group_id FROM agents WHERE id = ?" + ).get(fromAgent); + if (!sender) throw new Error(`Sender '${fromAgent}' not found`); + + const recipient = db.query<{ group_id: string }, [string]>( + "SELECT group_id FROM agents WHERE id = ?" + ).get(toAgent); + if (!recipient) throw new Error(`Recipient '${toAgent}' not found`); + if (sender.group_id !== recipient.group_id) { + throw new Error(`Cross-group messaging denied`); + } + + const id = randomUUID(); + db.query( + `INSERT INTO messages (id, from_agent, to_agent, content, group_id, sender_group_id) + VALUES (?1, ?2, ?3, ?4, ?5, ?5)` + ).run(id, fromAgent, toAgent, content, sender.group_id); + return id; +} + +describe("BtmsgDb", () => { + let db: Database; + + beforeEach(() => { + db = createDb(); + }); + + // ── Agent registration ──────────────────────────────────────────────── + + describe("registerAgent", () => { + it("inserts a new agent", () => { + registerAgent(db, "mgr-1", "Manager", "manager", "grp-a", 1); + const agents = db.query<{ id: string; role: string }, [string]>( + "SELECT id, role FROM agents WHERE group_id = ?" + ).all("grp-a"); + expect(agents).toHaveLength(1); + expect(agents[0].role).toBe("manager"); + }); + + it("upserts on conflict", () => { + registerAgent(db, "a1", "Old", "dev", "g1"); + registerAgent(db, "a1", "New", "tester", "g1"); + const row = db.query<{ name: string; role: string }, [string]>( + "SELECT name, role FROM agents WHERE id = ?" + ).get("a1"); + expect(row!.name).toBe("New"); + expect(row!.role).toBe("tester"); + }); + }); + + // ── Direct messaging ────────────────────────────────────────────────── + + describe("sendMessage", () => { + it("sends a message between agents in same group", () => { + registerAgent(db, "a1", "A1", "dev", "grp"); + registerAgent(db, "a2", "A2", "dev", "grp"); + const msgId = sendMessage(db, "a1", "a2", "hello"); + expect(msgId).toBeTruthy(); + + const row = db.query<{ content: string }, [string]>( + "SELECT content FROM messages WHERE id = ?" + ).get(msgId); + expect(row!.content).toBe("hello"); + }); + + it("rejects cross-group messaging", () => { + registerAgent(db, "a1", "A1", "dev", "grp-a"); + registerAgent(db, "a2", "A2", "dev", "grp-b"); + expect(() => sendMessage(db, "a1", "a2", "nope")).toThrow("Cross-group"); + }); + + it("throws for unknown sender", () => { + registerAgent(db, "a2", "A2", "dev", "grp"); + expect(() => sendMessage(db, "unknown", "a2", "hi")).toThrow("not found"); + }); + + it("throws for unknown recipient", () => { + registerAgent(db, "a1", "A1", "dev", "grp"); + expect(() => sendMessage(db, "a1", "unknown", "hi")).toThrow("not found"); + }); + }); + + describe("listMessages", () => { + it("returns messages between two agents in order", () => { + registerAgent(db, "a1", "A1", "dev", "g"); + registerAgent(db, "a2", "A2", "dev", "g"); + sendMessage(db, "a1", "a2", "msg1"); + sendMessage(db, "a2", "a1", "msg2"); + + const rows = db.query<{ content: string }, [string, string, string, string, number]>( + `SELECT content FROM messages + WHERE (from_agent = ?1 AND to_agent = ?2) OR (from_agent = ?3 AND to_agent = ?4) + ORDER BY created_at ASC LIMIT ?5` + ).all("a1", "a2", "a2", "a1", 50); + expect(rows).toHaveLength(2); + }); + }); + + describe("markRead", () => { + it("marks messages as read", () => { + registerAgent(db, "a1", "A1", "dev", "g"); + registerAgent(db, "a2", "A2", "dev", "g"); + const id = sendMessage(db, "a1", "a2", "hello"); + + db.query("UPDATE messages SET read = 1 WHERE id = ? AND to_agent = ?").run(id, "a2"); + + const row = db.query<{ read: number }, [string]>( + "SELECT read FROM messages WHERE id = ?" + ).get(id); + expect(row!.read).toBe(1); + }); + + it("no-ops for empty array", () => { + // Just ensure no crash + expect(() => {}).not.toThrow(); + }); + }); + + // ── Channels ────────────────────────────────────────────────────────── + + describe("createChannel", () => { + it("creates channel and auto-adds creator as member", () => { + registerAgent(db, "creator", "Creator", "manager", "g"); + const channelId = randomUUID(); + + const tx = db.transaction(() => { + db.query("INSERT INTO channels (id, name, group_id, created_by) VALUES (?, ?, ?, ?)").run( + channelId, "general", "g", "creator" + ); + db.query("INSERT OR IGNORE INTO channel_members (channel_id, agent_id) VALUES (?, ?)").run( + channelId, "creator" + ); + }); + tx(); + + const members = db.query<{ agent_id: string }, [string]>( + "SELECT agent_id FROM channel_members WHERE channel_id = ?" + ).all(channelId); + expect(members).toHaveLength(1); + expect(members[0].agent_id).toBe("creator"); + }); + }); + + describe("joinChannel / leaveChannel", () => { + it("join adds member, leave removes", () => { + registerAgent(db, "a1", "A1", "dev", "g"); + const chId = randomUUID(); + db.query("INSERT INTO channels (id, name, group_id, created_by) VALUES (?, ?, ?, ?)").run( + chId, "ch", "g", "a1" + ); + + db.query("INSERT OR IGNORE INTO channel_members (channel_id, agent_id) VALUES (?, ?)").run(chId, "a1"); + let members = db.query<{ agent_id: string }, [string]>( + "SELECT agent_id FROM channel_members WHERE channel_id = ?" + ).all(chId); + expect(members).toHaveLength(1); + + db.query("DELETE FROM channel_members WHERE channel_id = ? AND agent_id = ?").run(chId, "a1"); + members = db.query<{ agent_id: string }, [string]>( + "SELECT agent_id FROM channel_members WHERE channel_id = ?" + ).all(chId); + expect(members).toHaveLength(0); + }); + + it("joinChannel on nonexistent channel throws", () => { + registerAgent(db, "a1", "A1", "dev", "g"); + const chId = "nonexistent"; + const ch = db.query<{ id: string }, [string]>( + "SELECT id FROM channels WHERE id = ?" + ).get(chId); + expect(ch).toBeNull(); + }); + }); + + describe("sendChannelMessage", () => { + it("allows member to send message", () => { + registerAgent(db, "a1", "A1", "dev", "g"); + const chId = randomUUID(); + db.query("INSERT INTO channels (id, name, group_id, created_by) VALUES (?, ?, ?, ?)").run( + chId, "ch", "g", "a1" + ); + db.query("INSERT INTO channel_members (channel_id, agent_id) VALUES (?, ?)").run(chId, "a1"); + + const msgId = randomUUID(); + db.query("INSERT INTO channel_messages (id, channel_id, from_agent, content) VALUES (?, ?, ?, ?)").run( + msgId, chId, "a1", "hello channel" + ); + + const row = db.query<{ content: string }, [string]>( + "SELECT content FROM channel_messages WHERE id = ?" + ).get(msgId); + expect(row!.content).toBe("hello channel"); + }); + + it("rejects non-member", () => { + registerAgent(db, "a1", "A1", "dev", "g"); + registerAgent(db, "a2", "A2", "dev", "g"); + const chId = randomUUID(); + db.query("INSERT INTO channels (id, name, group_id, created_by) VALUES (?, ?, ?, ?)").run( + chId, "ch", "g", "a1" + ); + db.query("INSERT INTO channel_members (channel_id, agent_id) VALUES (?, ?)").run(chId, "a1"); + + // Check membership before inserting + const member = db.query<{ agent_id: string }, [string, string]>( + "SELECT agent_id FROM channel_members WHERE channel_id = ? AND agent_id = ?" + ).get(chId, "a2"); + expect(member).toBeNull(); + }); + }); + + describe("getChannelMembers", () => { + it("returns members with name and role", () => { + registerAgent(db, "a1", "Alice", "dev", "g"); + registerAgent(db, "a2", "Bob", "tester", "g"); + const chId = randomUUID(); + db.query("INSERT INTO channels (id, name, group_id, created_by) VALUES (?, ?, ?, ?)").run( + chId, "ch", "g", "a1" + ); + db.query("INSERT INTO channel_members (channel_id, agent_id) VALUES (?, ?)").run(chId, "a1"); + db.query("INSERT INTO channel_members (channel_id, agent_id) VALUES (?, ?)").run(chId, "a2"); + + const members = db.query<{ agent_id: string; name: string; role: string }, [string]>( + `SELECT cm.agent_id, a.name, a.role FROM channel_members cm + JOIN agents a ON cm.agent_id = a.id WHERE cm.channel_id = ?` + ).all(chId); + expect(members).toHaveLength(2); + expect(members.map((m) => m.name).sort()).toEqual(["Alice", "Bob"]); + }); + }); + + // ── Heartbeat ───────────────────────────────────────────────────────── + + describe("heartbeat", () => { + it("upserts heartbeat timestamp", () => { + const now = Math.floor(Date.now() / 1000); + db.query( + "INSERT INTO heartbeats (agent_id, timestamp) VALUES (?, ?) ON CONFLICT(agent_id) DO UPDATE SET timestamp = excluded.timestamp" + ).run("a1", now); + + const row = db.query<{ timestamp: number }, [string]>( + "SELECT timestamp FROM heartbeats WHERE agent_id = ?" + ).get("a1"); + expect(row!.timestamp).toBe(now); + + // Update + db.query( + "INSERT INTO heartbeats (agent_id, timestamp) VALUES (?, ?) ON CONFLICT(agent_id) DO UPDATE SET timestamp = excluded.timestamp" + ).run("a1", now + 60); + const row2 = db.query<{ timestamp: number }, [string]>( + "SELECT timestamp FROM heartbeats WHERE agent_id = ?" + ).get("a1"); + expect(row2!.timestamp).toBe(now + 60); + }); + }); + + // ── Seen messages (pruneSeen) ───────────────────────────────────────── + + describe("pruneSeen", () => { + it("deletes old seen_messages entries", () => { + // Insert with an old timestamp (manually set seen_at) + db.query( + "INSERT INTO seen_messages (session_id, message_id, seen_at) VALUES (?, ?, ?)" + ).run("s1", "m1", 1000); // ancient timestamp + db.query( + "INSERT INTO seen_messages (session_id, message_id, seen_at) VALUES (?, ?, ?)" + ).run("s1", "m2", Math.floor(Date.now() / 1000)); // recent + + const result = db.query( + "DELETE FROM seen_messages WHERE seen_at < unixepoch() - ?" + ).run(3600); // 1 hour max age + expect((result as { changes: number }).changes).toBe(1); + + const remaining = db.query<{ message_id: string }, []>( + "SELECT message_id FROM seen_messages" + ).all(); + expect(remaining).toHaveLength(1); + expect(remaining[0].message_id).toBe("m2"); + }); + }); +}); diff --git a/ui-electrobun/src/bun/__tests__/bttask-db.test.ts b/ui-electrobun/src/bun/__tests__/bttask-db.test.ts new file mode 100644 index 0000000..0841c64 --- /dev/null +++ b/ui-electrobun/src/bun/__tests__/bttask-db.test.ts @@ -0,0 +1,211 @@ +/** + * Unit tests for BttaskDb — in-memory SQLite. + */ +import { describe, it, expect, beforeEach } from "bun:test"; +import { Database } from "bun:sqlite"; +import { randomUUID } from "crypto"; + +const TASK_SCHEMA = ` +CREATE TABLE IF NOT EXISTS tasks ( + id TEXT PRIMARY KEY, title TEXT NOT NULL, description TEXT DEFAULT '', + status TEXT DEFAULT 'todo', priority TEXT DEFAULT 'medium', + assigned_to TEXT, created_by TEXT NOT NULL, group_id TEXT NOT NULL, + parent_task_id TEXT, sort_order INTEGER DEFAULT 0, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')), version INTEGER DEFAULT 1 +); +CREATE INDEX IF NOT EXISTS idx_tasks_group ON tasks(group_id); +CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status); +CREATE TABLE IF NOT EXISTS task_comments ( + id TEXT PRIMARY KEY, task_id TEXT NOT NULL, agent_id TEXT NOT NULL, + content TEXT NOT NULL, created_at TEXT DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_task_comments_task ON task_comments(task_id); +`; + +const VALID_STATUSES = ["todo", "progress", "review", "done", "blocked"]; + +function createDb(): Database { + const db = new Database(":memory:"); + db.exec(TASK_SCHEMA); + return db; +} + +function createTask(db: Database, title: string, groupId: string, createdBy: string): string { + const id = randomUUID(); + db.query( + `INSERT INTO tasks (id, title, description, priority, group_id, created_by) + VALUES (?1, ?2, '', 'medium', ?3, ?4)` + ).run(id, title, groupId, createdBy); + return id; +} + +function updateTaskStatus(db: Database, taskId: string, status: string, expectedVersion: number): number { + if (!VALID_STATUSES.includes(status)) { + throw new Error(`Invalid status '${status}'`); + } + const result = db.query( + `UPDATE tasks SET status = ?1, version = version + 1, updated_at = datetime('now') + WHERE id = ?2 AND version = ?3` + ).run(status, taskId, expectedVersion); + if ((result as { changes: number }).changes === 0) { + throw new Error("Task was modified by another agent (version conflict)"); + } + return expectedVersion + 1; +} + +describe("BttaskDb", () => { + let db: Database; + + beforeEach(() => { + db = createDb(); + }); + + // ── createTask ──────────────────────────────────────────────────────── + + describe("createTask", () => { + it("inserts a task with default status=todo and version=1", () => { + const id = createTask(db, "Fix bug", "grp-1", "agent-1"); + const row = db.query<{ title: string; status: string; version: number }, [string]>( + "SELECT title, status, COALESCE(version, 1) as version FROM tasks WHERE id = ?" + ).get(id); + expect(row!.title).toBe("Fix bug"); + expect(row!.status).toBe("todo"); + expect(row!.version).toBe(1); + }); + }); + + // ── listTasks ───────────────────────────────────────────────────────── + + describe("listTasks", () => { + it("returns tasks filtered by groupId", () => { + createTask(db, "T1", "grp-a", "agent"); + createTask(db, "T2", "grp-a", "agent"); + createTask(db, "T3", "grp-b", "agent"); + + const rows = db.query<{ title: string }, [string]>( + "SELECT title FROM tasks WHERE group_id = ?" + ).all("grp-a"); + expect(rows).toHaveLength(2); + }); + + it("returns empty array for unknown group", () => { + const rows = db.query<{ id: string }, [string]>( + "SELECT id FROM tasks WHERE group_id = ?" + ).all("nonexistent"); + expect(rows).toHaveLength(0); + }); + }); + + // ── updateTaskStatus ────────────────────────────────────────────────── + + describe("updateTaskStatus", () => { + it("succeeds with correct version", () => { + const id = createTask(db, "Task", "g", "a"); + const newVersion = updateTaskStatus(db, id, "progress", 1); + expect(newVersion).toBe(2); + + const row = db.query<{ status: string; version: number }, [string]>( + "SELECT status, version FROM tasks WHERE id = ?" + ).get(id); + expect(row!.status).toBe("progress"); + expect(row!.version).toBe(2); + }); + + it("throws on version conflict", () => { + const id = createTask(db, "Task", "g", "a"); + updateTaskStatus(db, id, "progress", 1); // version -> 2 + expect(() => updateTaskStatus(db, id, "done", 1)).toThrow("version conflict"); + }); + + it("rejects invalid status", () => { + const id = createTask(db, "Task", "g", "a"); + expect(() => updateTaskStatus(db, id, "invalid", 1)).toThrow("Invalid status"); + }); + + it("accepts all valid statuses", () => { + for (const status of VALID_STATUSES) { + const id = createTask(db, `Task-${status}`, "g", "a"); + const v = updateTaskStatus(db, id, status, 1); + expect(v).toBe(2); + } + }); + }); + + // ── deleteTask ──────────────────────────────────────────────────────── + + describe("deleteTask", () => { + it("deletes task and cascades comments", () => { + const taskId = createTask(db, "Doomed", "g", "a"); + const commentId = randomUUID(); + db.query( + "INSERT INTO task_comments (id, task_id, agent_id, content) VALUES (?, ?, ?, ?)" + ).run(commentId, taskId, "a", "a comment"); + + // Delete in transaction (mirrors BttaskDb.deleteTask) + const tx = db.transaction(() => { + db.query("DELETE FROM task_comments WHERE task_id = ?").run(taskId); + db.query("DELETE FROM tasks WHERE id = ?").run(taskId); + }); + tx(); + + expect(db.query<{ id: string }, [string]>("SELECT id FROM tasks WHERE id = ?").get(taskId)).toBeNull(); + expect( + db.query<{ id: string }, [string]>("SELECT id FROM task_comments WHERE task_id = ?").get(taskId) + ).toBeNull(); + }); + }); + + // ── addComment ──────────────────────────────────────────────────────── + + describe("addComment", () => { + it("adds comment to existing task", () => { + const taskId = createTask(db, "Task", "g", "a"); + const task = db.query<{ id: string }, [string]>("SELECT id FROM tasks WHERE id = ?").get(taskId); + expect(task).not.toBeNull(); + + const cid = randomUUID(); + db.query( + "INSERT INTO task_comments (id, task_id, agent_id, content) VALUES (?, ?, ?, ?)" + ).run(cid, taskId, "agent-1", "looks good"); + + const comments = db.query<{ content: string }, [string]>( + "SELECT content FROM task_comments WHERE task_id = ? ORDER BY created_at ASC" + ).all(taskId); + expect(comments).toHaveLength(1); + expect(comments[0].content).toBe("looks good"); + }); + + it("validates task exists before adding comment", () => { + const task = db.query<{ id: string }, [string]>( + "SELECT id FROM tasks WHERE id = ?" + ).get("nonexistent"); + expect(task).toBeNull(); // Would throw in BttaskDb.addComment + }); + }); + + // ── reviewQueueCount ────────────────────────────────────────────────── + + describe("reviewQueueCount", () => { + it("counts tasks with status=review in group", () => { + createTask(db, "T1", "g", "a"); + const t2 = createTask(db, "T2", "g", "a"); + const t3 = createTask(db, "T3", "g", "a"); + updateTaskStatus(db, t2, "review", 1); + updateTaskStatus(db, t3, "review", 1); + + const row = db.query<{ cnt: number }, [string]>( + "SELECT COUNT(*) AS cnt FROM tasks WHERE group_id = ? AND status = 'review'" + ).get("g"); + expect(row!.cnt).toBe(2); + }); + + it("returns 0 when no review tasks", () => { + createTask(db, "T1", "g", "a"); + const row = db.query<{ cnt: number }, [string]>( + "SELECT COUNT(*) AS cnt FROM tasks WHERE group_id = ? AND status = 'review'" + ).get("g"); + expect(row!.cnt).toBe(0); + }); + }); +}); diff --git a/ui-electrobun/src/bun/__tests__/message-adapter.test.ts b/ui-electrobun/src/bun/__tests__/message-adapter.test.ts new file mode 100644 index 0000000..a5b25e0 --- /dev/null +++ b/ui-electrobun/src/bun/__tests__/message-adapter.test.ts @@ -0,0 +1,428 @@ +/** + * Unit tests for message-adapter.ts — parseMessage for all 3 providers. + */ +import { describe, it, expect } from "bun:test"; +import { parseMessage, type AgentMessage, type ProviderId } from "../message-adapter.ts"; + +// ── Helpers ───────────────────────────────────────────────────────────────── + +function firstOf(msgs: AgentMessage[]): AgentMessage { + expect(msgs.length).toBeGreaterThanOrEqual(1); + return msgs[0]; +} + +function contentOf(msg: AgentMessage): Record { + return msg.content as Record; +} + +// ── Claude adapter ────────────────────────────────────────────────────────── + +describe("Claude adapter", () => { + it("parses system/init event", () => { + const msg = firstOf( + parseMessage("claude", { + type: "system", + subtype: "init", + session_id: "sess-123", + model: "opus-4", + cwd: "/home/user/project", + tools: ["Bash", "Read"], + }) + ); + expect(msg.type).toBe("init"); + const c = contentOf(msg); + expect(c.sessionId).toBe("sess-123"); + expect(c.model).toBe("opus-4"); + expect(c.cwd).toBe("/home/user/project"); + expect(c.tools).toEqual(["Bash", "Read"]); + }); + + it("parses system/compact_boundary event", () => { + const msg = firstOf( + parseMessage("claude", { + type: "system", + subtype: "compact_boundary", + compact_metadata: { trigger: "auto", pre_tokens: 50000 }, + }) + ); + expect(msg.type).toBe("compaction"); + const c = contentOf(msg); + expect(c.trigger).toBe("auto"); + expect(c.preTokens).toBe(50000); + }); + + it("parses assistant text block", () => { + const msgs = parseMessage("claude", { + type: "assistant", + uuid: "u1", + message: { + content: [{ type: "text", text: "Hello world" }], + }, + }); + const msg = firstOf(msgs); + expect(msg.type).toBe("text"); + expect(contentOf(msg).text).toBe("Hello world"); + }); + + it("parses assistant thinking block", () => { + const msgs = parseMessage("claude", { + type: "assistant", + uuid: "u1", + message: { + content: [{ type: "thinking", thinking: "Let me consider..." }], + }, + }); + expect(firstOf(msgs).type).toBe("thinking"); + expect(contentOf(firstOf(msgs)).text).toBe("Let me consider..."); + }); + + it("parses assistant tool_use block", () => { + const msgs = parseMessage("claude", { + type: "assistant", + uuid: "u1", + message: { + content: [ + { + type: "tool_use", + id: "tu-1", + name: "Bash", + input: { command: "ls -la" }, + }, + ], + }, + }); + const msg = firstOf(msgs); + expect(msg.type).toBe("tool_call"); + const c = contentOf(msg); + expect(c.toolUseId).toBe("tu-1"); + expect(c.name).toBe("Bash"); + expect((c.input as Record).command).toBe("ls -la"); + }); + + it("parses user/tool_result block", () => { + const msgs = parseMessage("claude", { + type: "user", + uuid: "u2", + message: { + content: [ + { + type: "tool_result", + tool_use_id: "tu-1", + content: "file1.txt\nfile2.txt", + }, + ], + }, + }); + const msg = firstOf(msgs); + expect(msg.type).toBe("tool_result"); + expect(contentOf(msg).toolUseId).toBe("tu-1"); + }); + + it("parses result/cost event", () => { + const msg = firstOf( + parseMessage("claude", { + type: "result", + uuid: "u3", + total_cost_usd: 0.42, + duration_ms: 15000, + usage: { input_tokens: 1000, output_tokens: 500 }, + num_turns: 3, + is_error: false, + result: "Task completed", + }) + ); + expect(msg.type).toBe("cost"); + const c = contentOf(msg); + expect(c.totalCostUsd).toBe(0.42); + expect(c.durationMs).toBe(15000); + expect(c.inputTokens).toBe(1000); + expect(c.outputTokens).toBe(500); + expect(c.numTurns).toBe(3); + expect(c.isError).toBe(false); + expect(c.result).toBe("Task completed"); + }); + + it("returns empty for assistant with no content", () => { + const msgs = parseMessage("claude", { + type: "assistant", + message: {}, + }); + expect(msgs).toHaveLength(0); + }); + + it("returns empty for assistant with no message", () => { + const msgs = parseMessage("claude", { type: "assistant" }); + expect(msgs).toHaveLength(0); + }); + + it("maps multiple content blocks into multiple messages", () => { + const msgs = parseMessage("claude", { + type: "assistant", + uuid: "multi", + message: { + content: [ + { type: "thinking", thinking: "hmm" }, + { type: "text", text: "answer" }, + { type: "tool_use", id: "t1", name: "Read", input: { path: "/a" } }, + ], + }, + }); + expect(msgs).toHaveLength(3); + expect(msgs.map((m) => m.type)).toEqual(["thinking", "text", "tool_call"]); + }); + + it("handles unknown type as 'unknown'", () => { + const msg = firstOf(parseMessage("claude", { type: "weird_event" })); + expect(msg.type).toBe("unknown"); + }); + + it("preserves parent_tool_use_id as parentId", () => { + const msgs = parseMessage("claude", { + type: "assistant", + uuid: "u1", + parent_tool_use_id: "parent-tu", + message: { + content: [{ type: "text", text: "subagent response" }], + }, + }); + expect(firstOf(msgs).parentId).toBe("parent-tu"); + }); +}); + +// ── Codex adapter ─────────────────────────────────────────────────────────── + +describe("Codex adapter", () => { + it("parses thread.started as init", () => { + const msg = firstOf( + parseMessage("codex", { type: "thread.started", thread_id: "th-1" }) + ); + expect(msg.type).toBe("init"); + expect(contentOf(msg).sessionId).toBe("th-1"); + }); + + it("parses turn.started as status", () => { + const msg = firstOf( + parseMessage("codex", { type: "turn.started" }) + ); + expect(msg.type).toBe("status"); + }); + + it("parses item.started with command_execution as tool_call", () => { + const msgs = parseMessage("codex", { + type: "item.started", + item: { + type: "command_execution", + id: "cmd-1", + command: "npm test", + }, + }); + const msg = firstOf(msgs); + expect(msg.type).toBe("tool_call"); + const c = contentOf(msg); + expect(c.name).toBe("Bash"); + expect((c.input as Record).command).toBe("npm test"); + }); + + it("parses item.completed with command_execution as tool_result", () => { + const msgs = parseMessage("codex", { + type: "item.completed", + item: { + type: "command_execution", + id: "cmd-1", + aggregated_output: "All tests passed", + }, + }); + const msg = firstOf(msgs); + expect(msg.type).toBe("tool_result"); + expect(contentOf(msg).output).toBe("All tests passed"); + }); + + it("parses item.completed with agent_message as text", () => { + const msg = firstOf( + parseMessage("codex", { + type: "item.completed", + item: { type: "agent_message", text: "Done!" }, + }) + ); + expect(msg.type).toBe("text"); + expect(contentOf(msg).text).toBe("Done!"); + }); + + it("ignores item.started for agent_message (no output yet)", () => { + const msgs = parseMessage("codex", { + type: "item.started", + item: { type: "agent_message", text: "" }, + }); + expect(msgs).toHaveLength(0); + }); + + it("parses turn.completed with usage as cost", () => { + const msg = firstOf( + parseMessage("codex", { + type: "turn.completed", + usage: { input_tokens: 200, output_tokens: 100 }, + }) + ); + expect(msg.type).toBe("cost"); + expect(contentOf(msg).inputTokens).toBe(200); + expect(contentOf(msg).outputTokens).toBe(100); + }); + + it("parses turn.failed as error", () => { + const msg = firstOf( + parseMessage("codex", { + type: "turn.failed", + error: { message: "Rate limited" }, + }) + ); + expect(msg.type).toBe("error"); + expect(contentOf(msg).message).toBe("Rate limited"); + }); + + it("parses error event", () => { + const msg = firstOf( + parseMessage("codex", { type: "error", message: "Connection lost" }) + ); + expect(msg.type).toBe("error"); + expect(contentOf(msg).message).toBe("Connection lost"); + }); + + it("handles unknown codex type as 'unknown'", () => { + const msg = firstOf(parseMessage("codex", { type: "weird" })); + expect(msg.type).toBe("unknown"); + }); +}); + +// ── Ollama adapter ────────────────────────────────────────────────────────── + +describe("Ollama adapter", () => { + it("parses system/init event", () => { + const msg = firstOf( + parseMessage("ollama", { + type: "system", + subtype: "init", + session_id: "oll-1", + model: "qwen3:8b", + cwd: "/tmp", + }) + ); + expect(msg.type).toBe("init"); + expect(contentOf(msg).model).toBe("qwen3:8b"); + }); + + it("parses text chunk", () => { + const msgs = parseMessage("ollama", { + type: "chunk", + message: { content: "Hello" }, + done: false, + }); + const textMsgs = msgs.filter((m) => m.type === "text"); + expect(textMsgs).toHaveLength(1); + expect(contentOf(textMsgs[0]).text).toBe("Hello"); + }); + + it("parses thinking chunk", () => { + const msgs = parseMessage("ollama", { + type: "chunk", + message: { thinking: "Let me think...", content: "" }, + done: false, + }); + const thinkMsgs = msgs.filter((m) => m.type === "thinking"); + expect(thinkMsgs).toHaveLength(1); + expect(contentOf(thinkMsgs[0]).text).toBe("Let me think..."); + }); + + it("parses done chunk with cost", () => { + const msgs = parseMessage("ollama", { + type: "chunk", + message: { content: "" }, + done: true, + eval_duration: 5_000_000_000, + prompt_eval_count: 100, + eval_count: 50, + }); + const costMsgs = msgs.filter((m) => m.type === "cost"); + expect(costMsgs).toHaveLength(1); + const c = contentOf(costMsgs[0]); + expect(c.totalCostUsd).toBe(0); + expect(c.durationMs).toBe(5000); + expect(c.inputTokens).toBe(100); + expect(c.outputTokens).toBe(50); + }); + + it("parses error event", () => { + const msg = firstOf( + parseMessage("ollama", { type: "error", message: "Model not found" }) + ); + expect(msg.type).toBe("error"); + expect(contentOf(msg).message).toBe("Model not found"); + }); + + it("handles unknown ollama type as 'unknown'", () => { + const msg = firstOf(parseMessage("ollama", { type: "strange" })); + expect(msg.type).toBe("unknown"); + }); + + it("emits both thinking and text from same chunk", () => { + const msgs = parseMessage("ollama", { + type: "chunk", + message: { thinking: "hmm", content: "answer" }, + done: false, + }); + expect(msgs).toHaveLength(2); + expect(msgs[0].type).toBe("thinking"); + expect(msgs[1].type).toBe("text"); + }); +}); + +// ── Edge cases ────────────────────────────────────────────────────────────── + +describe("Edge cases", () => { + it("unknown provider falls back to claude adapter", () => { + const msg = firstOf( + parseMessage("unknown-provider" as ProviderId, { + type: "system", + subtype: "init", + session_id: "s1", + model: "test", + }) + ); + expect(msg.type).toBe("init"); + }); + + it("handles empty content gracefully", () => { + const msgs = parseMessage("claude", { + type: "assistant", + message: { content: [] }, + }); + expect(msgs).toHaveLength(0); + }); + + it("all messages have id and timestamp", () => { + const providers: ProviderId[] = ["claude", "codex", "ollama"]; + for (const p of providers) { + const msgs = parseMessage(p, { type: "error", message: "test" }); + for (const m of msgs) { + expect(typeof m.id).toBe("string"); + expect(m.id.length).toBeGreaterThan(0); + expect(typeof m.timestamp).toBe("number"); + expect(m.timestamp).toBeGreaterThan(0); + } + } + }); + + it("str() guard returns fallback for non-string values", () => { + // Pass a number where string is expected — should use fallback + const msg = firstOf( + parseMessage("claude", { + type: "result", + total_cost_usd: "not-a-number", + usage: { input_tokens: "bad" }, + }) + ); + expect(msg.type).toBe("cost"); + // num() should return 0 for string values + expect(contentOf(msg).totalCostUsd).toBe(0); + expect(contentOf(msg).inputTokens).toBe(0); + }); +}); diff --git a/ui-electrobun/src/bun/__tests__/relay-client.test.ts b/ui-electrobun/src/bun/__tests__/relay-client.test.ts new file mode 100644 index 0000000..be178fd --- /dev/null +++ b/ui-electrobun/src/bun/__tests__/relay-client.test.ts @@ -0,0 +1,187 @@ +/** + * Unit tests for RelayClient — tests pure logic without real WebSocket connections. + */ +import { describe, it, expect } from "bun:test"; + +// ── TCP probe URL parsing (reimplemented from relay-client.ts) ────────────── + +function parseTcpTarget(wsUrl: string): { hostname: string; port: number } | null { + try { + const httpUrl = wsUrl.replace(/^ws(s)?:\/\//, "http$1://"); + const parsed = new URL(httpUrl); + const hostname = parsed.hostname; + const port = parsed.port ? parseInt(parsed.port, 10) : 9750; + return { hostname, port }; + } catch { + return null; + } +} + +// ── Tests ─────────────────────────────────────────────────────────────────── + +describe("RelayClient", () => { + // ── TCP probe URL parsing ─────────────────────────────────────────────── + + describe("TCP probe URL parsing", () => { + it("parses ws:// with explicit port", () => { + const result = parseTcpTarget("ws://192.168.1.10:9750"); + expect(result).toEqual({ hostname: "192.168.1.10", port: 9750 }); + }); + + it("parses wss:// with explicit port", () => { + const result = parseTcpTarget("wss://relay.example.com:8443"); + expect(result).toEqual({ hostname: "relay.example.com", port: 8443 }); + }); + + it("defaults to port 9750 when no port specified", () => { + const result = parseTcpTarget("ws://relay.local"); + expect(result).toEqual({ hostname: "relay.local", port: 9750 }); + }); + + it("parses IPv6 address with brackets", () => { + const result = parseTcpTarget("ws://[::1]:9750"); + expect(result).not.toBeNull(); + expect(result!.hostname).toBe("[::1]"); // Bun's URL() keeps brackets for IPv6 + expect(result!.port).toBe(9750); + }); + + it("parses IPv6 with port", () => { + const result = parseTcpTarget("ws://[2001:db8::1]:4567"); + expect(result).not.toBeNull(); + expect(result!.hostname).toBe("[2001:db8::1]"); + expect(result!.port).toBe(4567); + }); + + it("returns null for invalid URL", () => { + const result = parseTcpTarget("not a url at all"); + expect(result).toBeNull(); + }); + + it("handles ws:// path correctly", () => { + const result = parseTcpTarget("ws://relay.example.com:9750/connect"); + expect(result).toEqual({ hostname: "relay.example.com", port: 9750 }); + }); + }); + + // ── Machine tracking ────────────────────────────────────────────────── + + describe("machine tracking", () => { + it("machineId is a UUID string", () => { + const id = crypto.randomUUID(); + expect(typeof id).toBe("string"); + expect(id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ + ); + }); + + it("disconnect removes from map", () => { + const machines = new Map(); + machines.set("m1", { status: "connected", cancelled: false }); + + // Simulate disconnect + const m = machines.get("m1")!; + m.cancelled = true; + m.status = "disconnected"; + machines.delete("m1"); + + expect(machines.has("m1")).toBe(false); + }); + + it("removeMachine cleans up completely", () => { + const machines = new Map(); + machines.set("m1", { status: "connected", ws: null }); + machines.set("m2", { status: "connecting", ws: null }); + + // removeMachine = disconnect + delete + machines.delete("m1"); + expect(machines.size).toBe(1); + expect(machines.has("m1")).toBe(false); + expect(machines.has("m2")).toBe(true); + }); + + it("listMachines returns all tracked machines", () => { + const machines = new Map< + string, + { machineId: string; label: string; url: string; status: string; latencyMs: number | null } + >(); + machines.set("m1", { + machineId: "m1", + label: "Dev Server", + url: "ws://dev:9750", + status: "connected", + latencyMs: 12, + }); + machines.set("m2", { + machineId: "m2", + label: "Staging", + url: "ws://staging:9750", + status: "disconnected", + latencyMs: null, + }); + + const list = Array.from(machines.values()); + expect(list).toHaveLength(2); + expect(list[0].label).toBe("Dev Server"); + expect(list[0].latencyMs).toBe(12); + expect(list[1].latencyMs).toBeNull(); + }); + }); + + // ── Status and event callbacks ──────────────────────────────────────── + + describe("callback management", () => { + it("stores and invokes event listeners", () => { + const listeners: Array<(id: string, ev: unknown) => void> = []; + const received: unknown[] = []; + + listeners.push((id, ev) => received.push({ id, ev })); + listeners.push((id, ev) => received.push({ id, ev })); + + const event = { type: "pty_created", sessionId: "s1" }; + for (const cb of listeners) { + cb("m1", event); + } + expect(received).toHaveLength(2); + }); + + it("survives callback errors", () => { + const listeners: Array<(id: string) => void> = []; + const results: string[] = []; + + listeners.push(() => { + throw new Error("boom"); + }); + listeners.push((id) => results.push(id)); + + for (const cb of listeners) { + try { + cb("m1"); + } catch { + // swallow like RelayClient does + } + } + expect(results).toEqual(["m1"]); + }); + }); + + // ── Reconnection backoff ────────────────────────────────────────────── + + describe("exponential backoff", () => { + it("doubles delay up to maxDelay", () => { + let delay = 1000; + const maxDelay = 30000; + const delays: number[] = []; + + for (let i = 0; i < 10; i++) { + delays.push(delay); + delay = Math.min(delay * 2, maxDelay); + } + + expect(delays[0]).toBe(1000); + expect(delays[1]).toBe(2000); + expect(delays[4]).toBe(16000); + expect(delays[5]).toBe(30000); // capped + expect(delays[9]).toBe(30000); // stays capped + }); + }); +}); diff --git a/ui-electrobun/src/bun/__tests__/search-db.test.ts b/ui-electrobun/src/bun/__tests__/search-db.test.ts new file mode 100644 index 0000000..d59e935 --- /dev/null +++ b/ui-electrobun/src/bun/__tests__/search-db.test.ts @@ -0,0 +1,118 @@ +/** + * Unit tests for SearchDb — in-memory FTS5 SQLite. + * SearchDb accepts a custom dbPath, so we use ":memory:" directly. + */ +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { SearchDb } from "../search-db.ts"; + +describe("SearchDb", () => { + let search: SearchDb; + + beforeEach(() => { + search = new SearchDb(":memory:"); + }); + + afterEach(() => { + search.close(); + }); + + // ── indexMessage ────────────────────────────────────────────────────── + + describe("indexMessage", () => { + it("indexes a message searchable by content", () => { + search.indexMessage("sess-1", "assistant", "implement the authentication module"); + const results = search.searchAll("authentication"); + expect(results.length).toBeGreaterThanOrEqual(1); + expect(results[0].resultType).toBe("message"); + expect(results[0].id).toBe("sess-1"); + }); + }); + + // ── indexTask ───────────────────────────────────────────────────────── + + describe("indexTask", () => { + it("indexes a task searchable by title", () => { + search.indexTask("task-1", "Fix login bug", "Users cannot login", "todo", "agent-1"); + const results = search.searchAll("login"); + expect(results.length).toBeGreaterThanOrEqual(1); + expect(results[0].resultType).toBe("task"); + expect(results[0].title).toBe("Fix login bug"); + }); + + it("indexes a task searchable by description", () => { + search.indexTask("task-2", "Refactor", "Extract database connection pooling", "progress", "agent-1"); + const results = search.searchAll("pooling"); + expect(results.length).toBeGreaterThanOrEqual(1); + expect(results[0].resultType).toBe("task"); + }); + }); + + // ── indexBtmsg ──────────────────────────────────────────────────────── + + describe("indexBtmsg", () => { + it("indexes a btmsg searchable by content", () => { + search.indexBtmsg("msg-1", "manager", "architect", "review the PR for auth changes", "general"); + const results = search.searchAll("auth"); + expect(results.length).toBeGreaterThanOrEqual(1); + expect(results[0].resultType).toBe("btmsg"); + }); + }); + + // ── searchAll ───────────────────────────────────────────────────────── + + describe("searchAll", () => { + it("returns results across all tables ranked by relevance", () => { + search.indexMessage("s1", "user", "deploy the kubernetes cluster"); + search.indexTask("t1", "Deploy staging", "kubernetes deployment", "todo", "a1"); + search.indexBtmsg("b1", "ops", "dev", "kubernetes rollout status", "ops-channel"); + + const results = search.searchAll("kubernetes"); + expect(results).toHaveLength(3); + // All three types should be present + const types = results.map((r) => r.resultType).sort(); + expect(types).toEqual(["btmsg", "message", "task"]); + }); + + it("returns empty array for empty query", () => { + search.indexMessage("s1", "user", "some content"); + expect(search.searchAll("")).toEqual([]); + expect(search.searchAll(" ")).toEqual([]); + }); + + it("handles bad FTS5 query gracefully (no crash)", () => { + search.indexMessage("s1", "user", "test content"); + // Unbalanced quotes are invalid FTS5 syntax — should not throw + const results = search.searchAll('"unbalanced'); + // May return empty or partial results, but must not crash + expect(Array.isArray(results)).toBe(true); + }); + + it("respects limit parameter", () => { + for (let i = 0; i < 10; i++) { + search.indexMessage(`s-${i}`, "user", `message about testing number ${i}`); + } + const results = search.searchAll("testing", 3); + expect(results.length).toBeLessThanOrEqual(3); + }); + }); + + // ── rebuildIndex ────────────────────────────────────────────────────── + + describe("rebuildIndex", () => { + it("clears all indexed data and recreates tables", () => { + search.indexMessage("s1", "user", "important data"); + search.indexTask("t1", "Task", "desc", "todo", "a"); + search.indexBtmsg("b1", "from", "to", "msg", "ch"); + + search.rebuildIndex(); + + const results = search.searchAll("important"); + expect(results).toHaveLength(0); + + // Can still index after rebuild + search.indexMessage("s2", "user", "new data"); + const after = search.searchAll("new"); + expect(after).toHaveLength(1); + }); + }); +}); diff --git a/ui-electrobun/src/bun/__tests__/session-db.test.ts b/ui-electrobun/src/bun/__tests__/session-db.test.ts new file mode 100644 index 0000000..b6436b6 --- /dev/null +++ b/ui-electrobun/src/bun/__tests__/session-db.test.ts @@ -0,0 +1,190 @@ +/** + * Unit tests for SessionDb — in-memory SQLite. + */ +import { describe, it, expect, beforeEach } from "bun:test"; +import { Database } from "bun:sqlite"; + +const SESSION_SCHEMA = ` +CREATE TABLE IF NOT EXISTS agent_sessions ( + project_id TEXT NOT NULL, session_id TEXT PRIMARY KEY, provider TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'idle', cost_usd REAL NOT NULL DEFAULT 0, + input_tokens INTEGER NOT NULL DEFAULT 0, output_tokens INTEGER NOT NULL DEFAULT 0, + model TEXT NOT NULL DEFAULT '', error TEXT, + created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_agent_sessions_project ON agent_sessions(project_id); +CREATE TABLE IF NOT EXISTS agent_messages ( + session_id TEXT NOT NULL, msg_id TEXT NOT NULL, + role TEXT NOT NULL, content TEXT NOT NULL DEFAULT '', + tool_name TEXT, tool_input TEXT, + timestamp INTEGER NOT NULL, cost_usd REAL NOT NULL DEFAULT 0, + input_tokens INTEGER NOT NULL DEFAULT 0, output_tokens INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (session_id, msg_id), + FOREIGN KEY (session_id) REFERENCES agent_sessions(session_id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_agent_messages_session ON agent_messages(session_id, timestamp); +`; + +function createDb(): Database { + const db = new Database(":memory:"); + db.exec("PRAGMA foreign_keys = ON"); + db.exec(SESSION_SCHEMA); + return db; +} + +function insertSession(db: Database, projectId: string, sessionId: string, updatedAt: number) { + db.query( + `INSERT INTO agent_sessions + (project_id, session_id, provider, status, cost_usd, input_tokens, output_tokens, model, created_at, updated_at) + VALUES (?1, ?2, 'claude', 'idle', 0, 0, 0, 'opus', ?3, ?4)` + ).run(projectId, sessionId, updatedAt, updatedAt); +} + +function insertMessage(db: Database, sessionId: string, msgId: string, ts: number) { + db.query( + `INSERT INTO agent_messages + (session_id, msg_id, role, content, timestamp, cost_usd, input_tokens, output_tokens) + VALUES (?1, ?2, 'assistant', 'hello', ?3, 0, 0, 0)` + ).run(sessionId, msgId, ts); +} + +describe("SessionDb", () => { + let db: Database; + + beforeEach(() => { + db = createDb(); + }); + + describe("saveSession / loadSession", () => { + it("round-trips a session", () => { + insertSession(db, "proj-1", "sess-1", 1000); + const row = db.query<{ session_id: string; project_id: string }, [string]>( + "SELECT * FROM agent_sessions WHERE project_id = ? ORDER BY updated_at DESC LIMIT 1" + ).get("proj-1"); + expect(row!.session_id).toBe("sess-1"); + }); + + it("returns null for missing project", () => { + const row = db.query<{ session_id: string }, [string]>( + "SELECT * FROM agent_sessions WHERE project_id = ? LIMIT 1" + ).get("nonexistent"); + expect(row).toBeNull(); + }); + + it("upserts on conflict", () => { + insertSession(db, "p", "s1", 100); + db.query( + `INSERT INTO agent_sessions + (project_id, session_id, provider, status, cost_usd, input_tokens, output_tokens, model, created_at, updated_at) + VALUES ('p', 's1', 'claude', 'done', 1.5, 100, 200, 'opus', 100, 200) + ON CONFLICT(session_id) DO UPDATE SET status = excluded.status, cost_usd = excluded.cost_usd, updated_at = excluded.updated_at` + ).run(); + const row = db.query<{ status: string; cost_usd: number }, [string]>( + "SELECT status, cost_usd FROM agent_sessions WHERE session_id = ?" + ).get("s1"); + expect(row!.status).toBe("done"); + expect(row!.cost_usd).toBe(1.5); + }); + }); + + describe("saveMessage / loadMessages", () => { + it("stores and retrieves messages in order", () => { + insertSession(db, "p", "s1", 100); + insertMessage(db, "s1", "m1", 10); + insertMessage(db, "s1", "m2", 20); + insertMessage(db, "s1", "m3", 5); + + const rows = db.query<{ msg_id: string }, [string]>( + "SELECT msg_id FROM agent_messages WHERE session_id = ? ORDER BY timestamp ASC" + ).all("s1"); + expect(rows.map((r) => r.msg_id)).toEqual(["m3", "m1", "m2"]); + }); + + it("saveMessages batch inserts via transaction", () => { + insertSession(db, "p", "s1", 100); + const stmt = db.prepare( + `INSERT INTO agent_messages (session_id, msg_id, role, content, timestamp, cost_usd, input_tokens, output_tokens) + VALUES (?1, ?2, 'user', 'text', ?3, 0, 0, 0) ON CONFLICT DO NOTHING` + ); + const tx = db.transaction(() => { + for (let i = 0; i < 5; i++) stmt.run("s1", `msg-${i}`, i * 10); + }); + tx(); + + const count = db.query<{ cnt: number }, [string]>( + "SELECT COUNT(*) as cnt FROM agent_messages WHERE session_id = ?" + ).get("s1"); + expect(count!.cnt).toBe(5); + }); + + it("ON CONFLICT DO NOTHING skips duplicates", () => { + insertSession(db, "p", "s1", 100); + insertMessage(db, "s1", "m1", 10); + // Insert same msg_id again — should not throw + db.query( + `INSERT INTO agent_messages (session_id, msg_id, role, content, timestamp, cost_usd, input_tokens, output_tokens) + VALUES ('s1', 'm1', 'user', 'different', 20, 0, 0, 0) ON CONFLICT(session_id, msg_id) DO NOTHING` + ).run(); + + const row = db.query<{ content: string }, [string, string]>( + "SELECT content FROM agent_messages WHERE session_id = ? AND msg_id = ?" + ).get("s1", "m1"); + expect(row!.content).toBe("hello"); // original, not overwritten + }); + }); + + describe("listSessionsByProject", () => { + it("returns sessions ordered by updated_at DESC, limit 20", () => { + for (let i = 0; i < 25; i++) { + insertSession(db, "proj", `s-${i}`, i); + } + const rows = db.query<{ session_id: string }, [string]>( + "SELECT session_id FROM agent_sessions WHERE project_id = ? ORDER BY updated_at DESC LIMIT 20" + ).all("proj"); + expect(rows).toHaveLength(20); + expect(rows[0].session_id).toBe("s-24"); // most recent + }); + }); + + describe("pruneOldSessions", () => { + it("keeps only keepCount most recent sessions", () => { + for (let i = 0; i < 5; i++) { + insertSession(db, "proj", `s-${i}`, i * 100); + } + + db.query( + `DELETE FROM agent_sessions WHERE project_id = ?1 + AND session_id NOT IN ( + SELECT session_id FROM agent_sessions WHERE project_id = ?1 + ORDER BY updated_at DESC LIMIT ?2 + )` + ).run("proj", 2); + + const remaining = db.query<{ session_id: string }, [string]>( + "SELECT session_id FROM agent_sessions WHERE project_id = ? ORDER BY updated_at DESC" + ).all("proj"); + expect(remaining).toHaveLength(2); + expect(remaining[0].session_id).toBe("s-4"); + expect(remaining[1].session_id).toBe("s-3"); + }); + + it("cascades message deletion with foreign key", () => { + insertSession(db, "proj", "old", 1); + insertSession(db, "proj", "new", 1000); + insertMessage(db, "old", "m1", 1); + + db.query( + `DELETE FROM agent_sessions WHERE project_id = ?1 + AND session_id NOT IN ( + SELECT session_id FROM agent_sessions WHERE project_id = ?1 + ORDER BY updated_at DESC LIMIT ?2 + )` + ).run("proj", 1); + + const msgs = db.query<{ msg_id: string }, [string]>( + "SELECT msg_id FROM agent_messages WHERE session_id = ?" + ).all("old"); + expect(msgs).toHaveLength(0); + }); + }); +}); diff --git a/ui-electrobun/src/bun/__tests__/sidecar-manager.test.ts b/ui-electrobun/src/bun/__tests__/sidecar-manager.test.ts new file mode 100644 index 0000000..80c6aeb --- /dev/null +++ b/ui-electrobun/src/bun/__tests__/sidecar-manager.test.ts @@ -0,0 +1,240 @@ +/** + * Unit tests for SidecarManager — tests pure functions and state management. + * Mocks filesystem and Bun.spawn to avoid real process spawning. + */ +import { describe, it, expect, beforeEach, mock } from "bun:test"; + +// ── Test the exported pure functions by reimplementing them ────────────────── +// (The module has side effects via the constructor, so we test the logic directly) + +const STRIP_PREFIXES = ["CLAUDE", "CODEX", "OLLAMA", "ANTHROPIC_"]; +const WHITELIST_PREFIXES = ["CLAUDE_CODE_EXPERIMENTAL_"]; + +function validateExtraEnv(extraEnv: Record | undefined): Record | undefined { + if (!extraEnv) return undefined; + const clean: Record = {}; + for (const [key, value] of Object.entries(extraEnv)) { + const blocked = STRIP_PREFIXES.some((p) => key.startsWith(p)); + if (blocked) continue; + clean[key] = value; + } + return Object.keys(clean).length > 0 ? clean : undefined; +} + +function buildCleanEnv( + processEnv: Record, + extraEnv?: Record, + claudeConfigDir?: string, +): Record { + const clean: Record = {}; + for (const [key, value] of Object.entries(processEnv)) { + const shouldStrip = STRIP_PREFIXES.some((p) => key.startsWith(p)); + const isWhitelisted = WHITELIST_PREFIXES.some((p) => key.startsWith(p)); + if (!shouldStrip || isWhitelisted) { + clean[key] = value; + } + } + if (claudeConfigDir) { + clean["CLAUDE_CONFIG_DIR"] = claudeConfigDir; + } + const validated = validateExtraEnv(extraEnv); + if (validated) { + Object.assign(clean, validated); + } + return clean; +} + +function findClaudeCli(existsSync: (p: string) => boolean, homedir: string): string | undefined { + const candidates = [ + `${homedir}/.local/bin/claude`, + `${homedir}/.claude/local/claude`, + "/usr/local/bin/claude", + "/usr/bin/claude", + ]; + for (const p of candidates) { + if (existsSync(p)) return p; + } + return undefined; +} + +// ── Tests ─────────────────────────────────────────────────────────────────── + +describe("validateExtraEnv", () => { + it("returns undefined for undefined input", () => { + expect(validateExtraEnv(undefined)).toBeUndefined(); + }); + + it("passes through non-provider-prefixed keys", () => { + const result = validateExtraEnv({ + BTMSG_AGENT_ID: "manager-1", + MY_VAR: "hello", + }); + expect(result).toEqual({ + BTMSG_AGENT_ID: "manager-1", + MY_VAR: "hello", + }); + }); + + it("rejects CLAUDE-prefixed keys", () => { + const result = validateExtraEnv({ + CLAUDE_API_KEY: "secret", + BTMSG_AGENT_ID: "ok", + }); + expect(result).toEqual({ BTMSG_AGENT_ID: "ok" }); + }); + + it("rejects CODEX-prefixed keys", () => { + const result = validateExtraEnv({ + CODEX_TOKEN: "bad", + SAFE_KEY: "good", + }); + expect(result).toEqual({ SAFE_KEY: "good" }); + }); + + it("rejects OLLAMA-prefixed keys", () => { + const result = validateExtraEnv({ + OLLAMA_HOST: "bad", + }); + expect(result).toBeUndefined(); // all keys rejected -> empty -> undefined + }); + + it("rejects ANTHROPIC_-prefixed keys", () => { + const result = validateExtraEnv({ + ANTHROPIC_API_KEY: "bad", + GOOD_VAR: "ok", + }); + expect(result).toEqual({ GOOD_VAR: "ok" }); + }); + + it("returns undefined when all keys are rejected", () => { + const result = validateExtraEnv({ + CLAUDE_KEY: "a", + CODEX_KEY: "b", + OLLAMA_KEY: "c", + }); + expect(result).toBeUndefined(); + }); +}); + +describe("buildCleanEnv", () => { + it("strips CLAUDE-prefixed vars from process env", () => { + const env = buildCleanEnv({ + HOME: "/home/user", + PATH: "/usr/bin", + CLAUDE_API_KEY: "secret", + CLAUDE_SESSION: "sess", + }); + expect(env.HOME).toBe("/home/user"); + expect(env.PATH).toBe("/usr/bin"); + expect(env.CLAUDE_API_KEY).toBeUndefined(); + expect(env.CLAUDE_SESSION).toBeUndefined(); + }); + + it("whitelists CLAUDE_CODE_EXPERIMENTAL_* vars", () => { + const env = buildCleanEnv({ + CLAUDE_CODE_EXPERIMENTAL_FEATURE: "true", + CLAUDE_API_KEY: "secret", + }); + expect(env.CLAUDE_CODE_EXPERIMENTAL_FEATURE).toBe("true"); + expect(env.CLAUDE_API_KEY).toBeUndefined(); + }); + + it("strips CODEX and OLLAMA prefixed vars", () => { + const env = buildCleanEnv({ + CODEX_TOKEN: "x", + OLLAMA_HOST: "y", + NORMAL: "z", + }); + expect(env.CODEX_TOKEN).toBeUndefined(); + expect(env.OLLAMA_HOST).toBeUndefined(); + expect(env.NORMAL).toBe("z"); + }); + + it("sets CLAUDE_CONFIG_DIR when provided", () => { + const env = buildCleanEnv({ HOME: "/h" }, undefined, "/custom/config"); + expect(env.CLAUDE_CONFIG_DIR).toBe("/custom/config"); + }); + + it("merges validated extraEnv", () => { + const env = buildCleanEnv( + { HOME: "/h" }, + { BTMSG_AGENT_ID: "mgr", CLAUDE_BAD: "no" }, + ); + expect(env.BTMSG_AGENT_ID).toBe("mgr"); + expect(env.CLAUDE_BAD).toBeUndefined(); + }); +}); + +describe("findClaudeCli", () => { + it("returns first existing candidate path", () => { + const exists = mock((p: string) => p === "/usr/local/bin/claude"); + const result = findClaudeCli(exists, "/home/user"); + expect(result).toBe("/usr/local/bin/claude"); + }); + + it("checks ~/.local/bin/claude first", () => { + const checked: string[] = []; + const exists = mock((p: string) => { + checked.push(p); + return p === "/home/user/.local/bin/claude"; + }); + const result = findClaudeCli(exists, "/home/user"); + expect(result).toBe("/home/user/.local/bin/claude"); + expect(checked[0]).toBe("/home/user/.local/bin/claude"); + }); + + it("returns undefined when no candidate exists", () => { + const exists = mock((_p: string) => false); + const result = findClaudeCli(exists, "/home/user"); + expect(result).toBeUndefined(); + }); + + it("checks all 4 candidates in order", () => { + const checked: string[] = []; + const exists = mock((p: string) => { + checked.push(p); + return false; + }); + findClaudeCli(exists, "/home/user"); + expect(checked).toEqual([ + "/home/user/.local/bin/claude", + "/home/user/.claude/local/claude", + "/usr/local/bin/claude", + "/usr/bin/claude", + ]); + }); +}); + +describe("SidecarManager session lifecycle", () => { + it("tracks sessions in a Map", () => { + const sessions = new Map(); + sessions.set("s1", { status: "running" }); + expect(sessions.has("s1")).toBe(true); + expect(sessions.get("s1")!.status).toBe("running"); + }); + + it("stopSession removes session from map", () => { + const sessions = new Map(); + sessions.set("s1", { status: "running" }); + sessions.delete("s1"); + expect(sessions.has("s1")).toBe(false); + }); + + it("rejects duplicate session IDs", () => { + const sessions = new Map(); + sessions.set("s1", { status: "running" }); + const exists = sessions.has("s1"); + expect(exists).toBe(true); + // SidecarManager.startSession returns {ok: false} in this case + }); + + it("listSessions returns snapshot of all sessions", () => { + const sessions = new Map(); + sessions.set("s1", { sessionId: "s1", provider: "claude", status: "running" }); + sessions.set("s2", { sessionId: "s2", provider: "ollama", status: "idle" }); + + const list = Array.from(sessions.values()).map((s) => ({ ...s })); + expect(list).toHaveLength(2); + expect(list[0].sessionId).toBe("s1"); + }); +}); diff --git a/ui-electrobun/tests/unit/agent-store.test.ts b/ui-electrobun/tests/unit/agent-store.test.ts new file mode 100644 index 0000000..387f244 --- /dev/null +++ b/ui-electrobun/tests/unit/agent-store.test.ts @@ -0,0 +1,312 @@ +// 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); + }); +});