test: bun backend + store/hardening unit tests (WIP, agents running)
This commit is contained in:
parent
dd1d692e7b
commit
e75f90407b
12 changed files with 2536 additions and 0 deletions
288
packages/stores/__tests__/health.test.ts
Normal file
288
packages/stores/__tests__/health.test.ts
Normal file
|
|
@ -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<string, { startTime: number; count: number }>;
|
||||||
|
costSnapshots: Array<[number, number]>;
|
||||||
|
totalTokens: number;
|
||||||
|
totalCost: number;
|
||||||
|
status: 'inactive' | 'running' | 'idle' | 'done' | 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeTracker(overrides: Partial<ProjectTracker> = {}): 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
|
||||||
|
});
|
||||||
|
});
|
||||||
129
packages/stores/__tests__/notifications.test.ts
Normal file
129
packages/stores/__tests__/notifications.test.ts
Normal file
|
|
@ -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<ToastType, number[]>();
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
85
packages/stores/__tests__/theme.test.ts
Normal file
85
packages/stores/__tests__/theme.test.ts
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
"dev:hmr": "concurrently \"bun run hmr\" \"bun run start\"",
|
"dev:hmr": "concurrently \"bun run hmr\" \"bun run start\"",
|
||||||
"hmr": "vite --port 9760",
|
"hmr": "vite --port 9760",
|
||||||
"build:canary": "vite build && electrobun build --env=canary",
|
"build:canary": "vite build && electrobun build --env=canary",
|
||||||
|
"test": "bun test src/bun/__tests__/",
|
||||||
"test:e2e": "wdio run tests/e2e/wdio.conf.js"
|
"test:e2e": "wdio run tests/e2e/wdio.conf.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
|
||||||
347
ui-electrobun/src/bun/__tests__/btmsg-db.test.ts
Normal file
347
ui-electrobun/src/bun/__tests__/btmsg-db.test.ts
Normal file
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
211
ui-electrobun/src/bun/__tests__/bttask-db.test.ts
Normal file
211
ui-electrobun/src/bun/__tests__/bttask-db.test.ts
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
428
ui-electrobun/src/bun/__tests__/message-adapter.test.ts
Normal file
428
ui-electrobun/src/bun/__tests__/message-adapter.test.ts
Normal file
|
|
@ -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<string, unknown> {
|
||||||
|
return msg.content as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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<string, unknown>).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<string, unknown>).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);
|
||||||
|
});
|
||||||
|
});
|
||||||
187
ui-electrobun/src/bun/__tests__/relay-client.test.ts
Normal file
187
ui-electrobun/src/bun/__tests__/relay-client.test.ts
Normal file
|
|
@ -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<string, { status: string; cancelled: boolean }>();
|
||||||
|
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<string, { status: string; ws: null }>();
|
||||||
|
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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
118
ui-electrobun/src/bun/__tests__/search-db.test.ts
Normal file
118
ui-electrobun/src/bun/__tests__/search-db.test.ts
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
190
ui-electrobun/src/bun/__tests__/session-db.test.ts
Normal file
190
ui-electrobun/src/bun/__tests__/session-db.test.ts
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
240
ui-electrobun/src/bun/__tests__/sidecar-manager.test.ts
Normal file
240
ui-electrobun/src/bun/__tests__/sidecar-manager.test.ts
Normal file
|
|
@ -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<string, string> | undefined): Record<string, string> | undefined {
|
||||||
|
if (!extraEnv) return undefined;
|
||||||
|
const clean: Record<string, string> = {};
|
||||||
|
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<string, string>,
|
||||||
|
extraEnv?: Record<string, string>,
|
||||||
|
claudeConfigDir?: string,
|
||||||
|
): Record<string, string> {
|
||||||
|
const clean: Record<string, string> = {};
|
||||||
|
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<string, { status: string }>();
|
||||||
|
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<string, { status: string }>();
|
||||||
|
sessions.set("s1", { status: "running" });
|
||||||
|
sessions.delete("s1");
|
||||||
|
expect(sessions.has("s1")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects duplicate session IDs", () => {
|
||||||
|
const sessions = new Map<string, { status: string }>();
|
||||||
|
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<string, { sessionId: string; provider: string; status: string }>();
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
312
ui-electrobun/tests/unit/agent-store.test.ts
Normal file
312
ui-electrobun/tests/unit/agent-store.test.ts
Normal file
|
|
@ -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<string, string> | undefined): Record<string, string> | undefined {
|
||||||
|
if (!env) return undefined;
|
||||||
|
const clean: Record<string, string> = {};
|
||||||
|
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<string, unknown> | 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<string, number>();
|
||||||
|
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<number>();
|
||||||
|
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<string>();
|
||||||
|
|
||||||
|
// 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<string, AgentSession> = {
|
||||||
|
'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<string>();
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue