Checkpoints the builder-agent dev-agent/worktree/swap flow (create_dev_agent, request_swap, classifier, deadman, promote) before pivoting to a unified draft-activate approach with OS-level RO enforcement. Lifts container_config out of the agent_groups row into groups/<folder>/container.json so install_packages, add_mcp_server, and rebuild flows can eventually route through the same draft path as source edits. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
341 lines
10 KiB
TypeScript
341 lines
10 KiB
TypeScript
/**
|
|
* Integration tests for the v2 host core.
|
|
* Tests routing, session creation, message writing, and delivery
|
|
* without spawning actual containers.
|
|
*/
|
|
import Database from 'better-sqlite3';
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
|
|
import {
|
|
initTestDb,
|
|
closeDb,
|
|
runMigrations,
|
|
createAgentGroup,
|
|
createMessagingGroup,
|
|
createMessagingGroupAgent,
|
|
} from './db/index.js';
|
|
import {
|
|
resolveSession,
|
|
writeSessionMessage,
|
|
initSessionFolder,
|
|
sessionDir,
|
|
inboundDbPath,
|
|
outboundDbPath,
|
|
sessionsBaseDir,
|
|
} from './session-manager.js';
|
|
import { getSession, findSession } from './db/sessions.js';
|
|
import type { InboundEvent } from './router.js';
|
|
|
|
// Mock container runner to prevent actual Docker spawning
|
|
vi.mock('./container-runner.js', () => ({
|
|
wakeContainer: vi.fn().mockResolvedValue(undefined),
|
|
resetContainerIdleTimer: vi.fn(),
|
|
isContainerRunning: vi.fn().mockReturnValue(false),
|
|
getActiveContainerCount: vi.fn().mockReturnValue(0),
|
|
killContainer: vi.fn(),
|
|
}));
|
|
|
|
// Override DATA_DIR for tests
|
|
vi.mock('./config.js', async () => {
|
|
const actual = await vi.importActual('./config.js');
|
|
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-host' };
|
|
});
|
|
|
|
function now() {
|
|
return new Date().toISOString();
|
|
}
|
|
|
|
const TEST_DIR = '/tmp/nanoclaw-test-host';
|
|
|
|
beforeEach(() => {
|
|
// Clean test directory
|
|
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
|
fs.mkdirSync(TEST_DIR, { recursive: true });
|
|
|
|
const db = initTestDb();
|
|
runMigrations(db);
|
|
});
|
|
|
|
afterEach(() => {
|
|
closeDb();
|
|
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
|
});
|
|
|
|
describe('session manager', () => {
|
|
beforeEach(() => {
|
|
createAgentGroup({
|
|
id: 'ag-1',
|
|
name: 'Test Agent',
|
|
folder: 'test-agent',
|
|
agent_provider: null,
|
|
created_at: now(),
|
|
});
|
|
createMessagingGroup({
|
|
id: 'mg-1',
|
|
channel_type: 'discord',
|
|
platform_id: 'chan-123',
|
|
name: 'General',
|
|
is_group: 1,
|
|
unknown_sender_policy: 'strict',
|
|
created_at: now(),
|
|
});
|
|
});
|
|
|
|
it('should create session folder and both DBs', () => {
|
|
initSessionFolder('ag-1', 'sess-test');
|
|
const dir = sessionDir('ag-1', 'sess-test');
|
|
expect(fs.existsSync(dir)).toBe(true);
|
|
expect(fs.existsSync(path.join(dir, 'outbox'))).toBe(true);
|
|
|
|
// Verify inbound.db
|
|
const inPath = inboundDbPath('ag-1', 'sess-test');
|
|
expect(fs.existsSync(inPath)).toBe(true);
|
|
const inDb = new Database(inPath);
|
|
const inTables = inDb.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{ name: string }>;
|
|
expect(inTables.map((t) => t.name)).toContain('messages_in');
|
|
expect(inTables.map((t) => t.name)).toContain('delivered');
|
|
inDb.close();
|
|
|
|
// Verify outbound.db
|
|
const outPath = outboundDbPath('ag-1', 'sess-test');
|
|
expect(fs.existsSync(outPath)).toBe(true);
|
|
const outDb = new Database(outPath);
|
|
const outTables = outDb.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{
|
|
name: string;
|
|
}>;
|
|
expect(outTables.map((t) => t.name)).toContain('messages_out');
|
|
expect(outTables.map((t) => t.name)).toContain('processing_ack');
|
|
outDb.close();
|
|
});
|
|
|
|
it('should resolve to existing session (shared mode)', () => {
|
|
const { session: s1, created: c1 } = resolveSession('ag-1', 'mg-1', null, 'shared');
|
|
expect(c1).toBe(true);
|
|
|
|
const { session: s2, created: c2 } = resolveSession('ag-1', 'mg-1', null, 'shared');
|
|
expect(c2).toBe(false);
|
|
expect(s2.id).toBe(s1.id);
|
|
});
|
|
|
|
it('should create separate sessions per thread (per-thread mode)', () => {
|
|
const { session: s1 } = resolveSession('ag-1', 'mg-1', 'thread-1', 'per-thread');
|
|
const { session: s2 } = resolveSession('ag-1', 'mg-1', 'thread-2', 'per-thread');
|
|
expect(s1.id).not.toBe(s2.id);
|
|
});
|
|
|
|
it('should reuse session for same thread', () => {
|
|
const { session: s1 } = resolveSession('ag-1', 'mg-1', 'thread-1', 'per-thread');
|
|
const { session: s2, created } = resolveSession('ag-1', 'mg-1', 'thread-1', 'per-thread');
|
|
expect(created).toBe(false);
|
|
expect(s2.id).toBe(s1.id);
|
|
});
|
|
|
|
it('should write message to inbound DB', () => {
|
|
const { session } = resolveSession('ag-1', 'mg-1', null, 'shared');
|
|
|
|
writeSessionMessage('ag-1', session.id, {
|
|
id: 'msg-1',
|
|
kind: 'chat',
|
|
timestamp: now(),
|
|
platformId: 'chan-123',
|
|
channelType: 'discord',
|
|
threadId: null,
|
|
content: JSON.stringify({ sender: 'User', text: 'Hello' }),
|
|
});
|
|
|
|
// Read from the inbound DB
|
|
const dbPath = inboundDbPath('ag-1', session.id);
|
|
const db = new Database(dbPath);
|
|
const rows = db.prepare('SELECT * FROM messages_in').all() as Array<{
|
|
id: string;
|
|
kind: string;
|
|
status: string;
|
|
content: string;
|
|
}>;
|
|
db.close();
|
|
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0].id).toBe('msg-1');
|
|
expect(rows[0].status).toBe('pending');
|
|
expect(JSON.parse(rows[0].content).text).toBe('Hello');
|
|
});
|
|
|
|
it('should update last_active on message write', () => {
|
|
const { session } = resolveSession('ag-1', 'mg-1', null, 'shared');
|
|
expect(getSession(session.id)!.last_active).toBeNull();
|
|
|
|
writeSessionMessage('ag-1', session.id, {
|
|
id: 'msg-1',
|
|
kind: 'chat',
|
|
timestamp: now(),
|
|
content: JSON.stringify({ text: 'hi' }),
|
|
});
|
|
|
|
expect(getSession(session.id)!.last_active).not.toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('router', () => {
|
|
beforeEach(() => {
|
|
createAgentGroup({
|
|
id: 'ag-1',
|
|
name: 'Test Agent',
|
|
folder: 'test-agent',
|
|
agent_provider: null,
|
|
created_at: now(),
|
|
});
|
|
// Use 'public' policy so the router tests exercise routing, not the
|
|
// access gate. Dedicated access-gate tests live with the access module.
|
|
createMessagingGroup({
|
|
id: 'mg-1',
|
|
channel_type: 'discord',
|
|
platform_id: 'chan-123',
|
|
name: 'General',
|
|
is_group: 1,
|
|
unknown_sender_policy: 'public',
|
|
created_at: now(),
|
|
});
|
|
createMessagingGroupAgent({
|
|
id: 'mga-1',
|
|
messaging_group_id: 'mg-1',
|
|
agent_group_id: 'ag-1',
|
|
trigger_rules: null,
|
|
response_scope: 'all',
|
|
session_mode: 'shared',
|
|
priority: 0,
|
|
created_at: now(),
|
|
});
|
|
});
|
|
|
|
it('should route a message end-to-end', async () => {
|
|
const { routeInbound } = await import('./router.js');
|
|
const { wakeContainer } = await import('./container-runner.js');
|
|
|
|
const event: InboundEvent = {
|
|
channelType: 'discord',
|
|
platformId: 'chan-123',
|
|
threadId: null,
|
|
message: {
|
|
id: 'msg-in-1',
|
|
kind: 'chat',
|
|
content: JSON.stringify({ sender: 'User', text: 'Hello agent!' }),
|
|
timestamp: now(),
|
|
},
|
|
};
|
|
|
|
await routeInbound(event);
|
|
|
|
// Verify session was created
|
|
const session = findSession('mg-1', null);
|
|
expect(session).toBeDefined();
|
|
|
|
// Verify message was written to inbound DB
|
|
const dbPath = inboundDbPath('ag-1', session!.id);
|
|
const db = new Database(dbPath);
|
|
const rows = db.prepare('SELECT * FROM messages_in').all() as Array<{ id: string; content: string }>;
|
|
db.close();
|
|
|
|
expect(rows).toHaveLength(1);
|
|
expect(JSON.parse(rows[0].content).text).toBe('Hello agent!');
|
|
|
|
// Verify container was woken
|
|
expect(wakeContainer).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should auto-create messaging group for unknown platform', async () => {
|
|
const { routeInbound } = await import('./router.js');
|
|
|
|
const event: InboundEvent = {
|
|
channelType: 'slack',
|
|
platformId: 'C-NEW-CHANNEL',
|
|
threadId: null,
|
|
message: {
|
|
id: 'msg-2',
|
|
kind: 'chat',
|
|
content: JSON.stringify({ sender: 'User', text: 'Hi' }),
|
|
timestamp: now(),
|
|
},
|
|
};
|
|
|
|
await routeInbound(event);
|
|
|
|
const { getMessagingGroupByPlatform } = await import('./db/messaging-groups.js');
|
|
const mg = getMessagingGroupByPlatform('slack', 'C-NEW-CHANNEL');
|
|
expect(mg).toBeDefined();
|
|
});
|
|
|
|
it('should route multiple messages to the same session', async () => {
|
|
const { routeInbound } = await import('./router.js');
|
|
|
|
await routeInbound({
|
|
channelType: 'discord',
|
|
platformId: 'chan-123',
|
|
threadId: null,
|
|
message: { id: 'msg-a', kind: 'chat', content: JSON.stringify({ sender: 'A', text: 'First' }), timestamp: now() },
|
|
});
|
|
|
|
await routeInbound({
|
|
channelType: 'discord',
|
|
platformId: 'chan-123',
|
|
threadId: null,
|
|
message: {
|
|
id: 'msg-b',
|
|
kind: 'chat',
|
|
content: JSON.stringify({ sender: 'B', text: 'Second' }),
|
|
timestamp: now(),
|
|
},
|
|
});
|
|
|
|
// Both should be in the same session
|
|
const session = findSession('mg-1', null);
|
|
const dbPath = inboundDbPath('ag-1', session!.id);
|
|
const db = new Database(dbPath);
|
|
const rows = db.prepare('SELECT * FROM messages_in ORDER BY timestamp').all();
|
|
db.close();
|
|
|
|
expect(rows).toHaveLength(2);
|
|
});
|
|
});
|
|
|
|
describe('delivery', () => {
|
|
it('should detect undelivered messages in outbound DB', () => {
|
|
createAgentGroup({
|
|
id: 'ag-1',
|
|
name: 'Agent',
|
|
folder: 'agent',
|
|
agent_provider: null,
|
|
created_at: now(),
|
|
});
|
|
createMessagingGroup({
|
|
id: 'mg-test',
|
|
channel_type: 'discord',
|
|
platform_id: 'chan-test',
|
|
name: 'Test',
|
|
is_group: 0,
|
|
unknown_sender_policy: 'strict',
|
|
created_at: now(),
|
|
});
|
|
|
|
const { session } = resolveSession('ag-1', 'mg-test', null, 'shared');
|
|
|
|
// Write a response to the outbound DB (simulating what the agent-runner does)
|
|
const dbPath = outboundDbPath('ag-1', session.id);
|
|
const db = new Database(dbPath);
|
|
db.prepare(
|
|
`INSERT INTO messages_out (id, timestamp, kind, platform_id, channel_type, content)
|
|
VALUES ('out-1', datetime('now'), 'chat', 'chan-123', 'discord', ?)`,
|
|
).run(JSON.stringify({ text: 'Agent response' }));
|
|
|
|
const undelivered = db.prepare('SELECT * FROM messages_out').all() as Array<{
|
|
id: string;
|
|
content: string;
|
|
}>;
|
|
db.close();
|
|
|
|
expect(undelivered).toHaveLength(1);
|
|
expect(JSON.parse(undelivered[0].content).text).toBe('Agent response');
|
|
});
|
|
});
|