mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-12 18:11:51 +08:00
46b19dcf9c
Last extraction of Phase 3. Moves inter-agent messaging + create_agent +
destination projection into src/modules/agent-to-agent/. Core retains:
- `channel_type === 'agent'` dispatch in delivery.ts, guarded by
hasTable('agent_destinations') + dynamic import into module.
- Channel-permission ACL in delivery.ts, guarded by hasTable, with
inlined SQL (no module import from core).
- writeDestinations call in container-runner.ts, guarded by hasTable +
dynamic import into module.
- createMessagingGroupAgent's destination side effect in db/messaging-groups.ts,
guarded by hasTable. This is a documented transitional tier violation
(core imports from optional module), analogous to src/access.ts.
Migration `004-agent-destinations.ts` renamed to `module-agent-to-agent-
destinations.ts` preserving `name: 'agent-destinations'` so existing DBs
don't re-run it.
delivery.ts: 600 → 449 lines. handleSystemAction's last switch case gone
(just registry + default log-and-drop). notifyAgent helper removed (only
create_agent used it).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
429 lines
12 KiB
TypeScript
429 lines
12 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
|
|
import {
|
|
initTestDb,
|
|
closeDb,
|
|
runMigrations,
|
|
createAgentGroup,
|
|
getAgentGroup,
|
|
getAgentGroupByFolder,
|
|
getAllAgentGroups,
|
|
updateAgentGroup,
|
|
deleteAgentGroup,
|
|
createMessagingGroup,
|
|
getMessagingGroup,
|
|
getMessagingGroupByPlatform,
|
|
getAllMessagingGroups,
|
|
updateMessagingGroup,
|
|
deleteMessagingGroup,
|
|
createMessagingGroupAgent,
|
|
getMessagingGroupAgents,
|
|
getMessagingGroupAgent,
|
|
updateMessagingGroupAgent,
|
|
deleteMessagingGroupAgent,
|
|
createSession,
|
|
getSession,
|
|
findSession,
|
|
getSessionsByAgentGroup,
|
|
getActiveSessions,
|
|
getRunningSessions,
|
|
updateSession,
|
|
deleteSession,
|
|
createPendingQuestion,
|
|
getPendingQuestion,
|
|
deletePendingQuestion,
|
|
} from './index.js';
|
|
|
|
function now() {
|
|
return new Date().toISOString();
|
|
}
|
|
|
|
beforeEach(() => {
|
|
const db = initTestDb();
|
|
runMigrations(db);
|
|
});
|
|
|
|
afterEach(() => {
|
|
closeDb();
|
|
});
|
|
|
|
// ── Migrations ──
|
|
|
|
describe('migrations', () => {
|
|
it('should be idempotent', () => {
|
|
const db = initTestDb();
|
|
runMigrations(db);
|
|
// Running again should not throw
|
|
runMigrations(db);
|
|
});
|
|
});
|
|
|
|
// ── Agent Groups ──
|
|
|
|
describe('agent groups', () => {
|
|
const ag = () => ({
|
|
id: 'ag-1',
|
|
name: 'Test Agent',
|
|
folder: 'test-agent',
|
|
agent_provider: null,
|
|
created_at: now(),
|
|
});
|
|
|
|
it('should create and retrieve', () => {
|
|
createAgentGroup(ag());
|
|
const result = getAgentGroup('ag-1');
|
|
expect(result).toBeDefined();
|
|
expect(result!.name).toBe('Test Agent');
|
|
expect(result!.folder).toBe('test-agent');
|
|
});
|
|
|
|
it('should find by folder', () => {
|
|
createAgentGroup(ag());
|
|
const result = getAgentGroupByFolder('test-agent');
|
|
expect(result).toBeDefined();
|
|
expect(result!.id).toBe('ag-1');
|
|
});
|
|
|
|
it('should list all', () => {
|
|
createAgentGroup(ag());
|
|
createAgentGroup({ ...ag(), id: 'ag-2', name: 'Another', folder: 'another' });
|
|
expect(getAllAgentGroups()).toHaveLength(2);
|
|
});
|
|
|
|
it('should update', () => {
|
|
createAgentGroup(ag());
|
|
updateAgentGroup('ag-1', { name: 'Updated' });
|
|
expect(getAgentGroup('ag-1')!.name).toBe('Updated');
|
|
});
|
|
|
|
it('should delete', () => {
|
|
createAgentGroup(ag());
|
|
deleteAgentGroup('ag-1');
|
|
expect(getAgentGroup('ag-1')).toBeUndefined();
|
|
});
|
|
|
|
it('should enforce unique folder', () => {
|
|
createAgentGroup(ag());
|
|
expect(() => createAgentGroup({ ...ag(), id: 'ag-dup' })).toThrow();
|
|
});
|
|
});
|
|
|
|
// ── Messaging Groups ──
|
|
|
|
describe('messaging groups', () => {
|
|
const mg = () => ({
|
|
id: 'mg-1',
|
|
channel_type: 'discord',
|
|
platform_id: 'chan-123',
|
|
name: 'General',
|
|
is_group: 1,
|
|
unknown_sender_policy: 'strict' as const,
|
|
created_at: now(),
|
|
});
|
|
|
|
it('should create and retrieve', () => {
|
|
createMessagingGroup(mg());
|
|
const result = getMessagingGroup('mg-1');
|
|
expect(result).toBeDefined();
|
|
expect(result!.channel_type).toBe('discord');
|
|
});
|
|
|
|
it('should find by platform', () => {
|
|
createMessagingGroup(mg());
|
|
const result = getMessagingGroupByPlatform('discord', 'chan-123');
|
|
expect(result).toBeDefined();
|
|
expect(result!.id).toBe('mg-1');
|
|
});
|
|
|
|
it('should enforce unique channel_type + platform_id', () => {
|
|
createMessagingGroup(mg());
|
|
expect(() => createMessagingGroup({ ...mg(), id: 'mg-dup' })).toThrow();
|
|
});
|
|
|
|
it('should update', () => {
|
|
createMessagingGroup(mg());
|
|
updateMessagingGroup('mg-1', { name: 'Updated' });
|
|
expect(getMessagingGroup('mg-1')!.name).toBe('Updated');
|
|
});
|
|
|
|
it('should delete', () => {
|
|
createMessagingGroup(mg());
|
|
deleteMessagingGroup('mg-1');
|
|
expect(getMessagingGroup('mg-1')).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ── Messaging Group Agents ──
|
|
|
|
describe('messaging group agents', () => {
|
|
beforeEach(() => {
|
|
createAgentGroup({
|
|
id: 'ag-1',
|
|
name: 'Agent',
|
|
folder: 'agent',
|
|
agent_provider: null,
|
|
created_at: now(),
|
|
});
|
|
createMessagingGroup({
|
|
id: 'mg-1',
|
|
channel_type: 'discord',
|
|
platform_id: 'chan-1',
|
|
name: 'Gen',
|
|
is_group: 1,
|
|
unknown_sender_policy: 'strict',
|
|
created_at: now(),
|
|
});
|
|
});
|
|
|
|
const mga = () => ({
|
|
id: 'mga-1',
|
|
messaging_group_id: 'mg-1',
|
|
agent_group_id: 'ag-1',
|
|
trigger_rules: null,
|
|
response_scope: 'all' as const,
|
|
session_mode: 'shared' as const,
|
|
priority: 0,
|
|
created_at: now(),
|
|
});
|
|
|
|
it('should create and list by messaging group', () => {
|
|
createMessagingGroupAgent(mga());
|
|
const results = getMessagingGroupAgents('mg-1');
|
|
expect(results).toHaveLength(1);
|
|
expect(results[0].agent_group_id).toBe('ag-1');
|
|
});
|
|
|
|
it('should order by priority descending', () => {
|
|
createMessagingGroupAgent(mga());
|
|
createAgentGroup({
|
|
id: 'ag-2',
|
|
name: 'Agent2',
|
|
folder: 'agent2',
|
|
agent_provider: null,
|
|
created_at: now(),
|
|
});
|
|
createMessagingGroupAgent({ ...mga(), id: 'mga-2', agent_group_id: 'ag-2', priority: 10 });
|
|
const results = getMessagingGroupAgents('mg-1');
|
|
expect(results[0].agent_group_id).toBe('ag-2');
|
|
expect(results[1].agent_group_id).toBe('ag-1');
|
|
});
|
|
|
|
it('should enforce unique messaging_group + agent_group', () => {
|
|
createMessagingGroupAgent(mga());
|
|
expect(() => createMessagingGroupAgent({ ...mga(), id: 'mga-dup' })).toThrow();
|
|
});
|
|
|
|
it('should update', () => {
|
|
createMessagingGroupAgent(mga());
|
|
updateMessagingGroupAgent('mga-1', { priority: 5 });
|
|
expect(getMessagingGroupAgent('mga-1')!.priority).toBe(5);
|
|
});
|
|
|
|
it('should delete', () => {
|
|
createMessagingGroupAgent(mga());
|
|
deleteMessagingGroupAgent('mga-1');
|
|
expect(getMessagingGroupAgents('mg-1')).toHaveLength(0);
|
|
});
|
|
|
|
it('should enforce foreign key on agent_group_id', () => {
|
|
expect(() => createMessagingGroupAgent({ ...mga(), agent_group_id: 'nonexistent' })).toThrow();
|
|
});
|
|
|
|
it('auto-creates an agent_destinations row for the wiring', async () => {
|
|
const { getDestinationByTarget, getDestinations } = await import('../modules/agent-to-agent/db/agent-destinations.js');
|
|
createMessagingGroupAgent(mga());
|
|
|
|
const dest = getDestinationByTarget('ag-1', 'channel', 'mg-1');
|
|
expect(dest).toBeDefined();
|
|
expect(dest!.local_name).toBe('gen'); // normalized from mg.name='Gen'
|
|
expect(getDestinations('ag-1')).toHaveLength(1);
|
|
});
|
|
|
|
it('does not duplicate destination row on re-wiring', async () => {
|
|
const { getDestinations } = await import('../modules/agent-to-agent/db/agent-destinations.js');
|
|
createMessagingGroupAgent(mga());
|
|
// Re-create the same wiring throws (PK unique), but even if we got the
|
|
// row in some other way (e.g. via createDestination directly followed
|
|
// by createMessagingGroupAgent), we should not end up with two rows.
|
|
deleteMessagingGroupAgent('mga-1');
|
|
createMessagingGroupAgent(mga());
|
|
expect(getDestinations('ag-1')).toHaveLength(1);
|
|
});
|
|
|
|
it('breaks local_name collisions within an agent group', async () => {
|
|
const { getDestinations } = await import('../modules/agent-to-agent/db/agent-destinations.js');
|
|
// Two messaging groups with the same `name` wired to the same agent
|
|
// should get distinct local_names (gen, gen-2).
|
|
createMessagingGroupAgent(mga());
|
|
createMessagingGroup({
|
|
id: 'mg-2',
|
|
channel_type: 'discord',
|
|
platform_id: 'chan-2',
|
|
name: 'Gen',
|
|
is_group: 1,
|
|
unknown_sender_policy: 'strict',
|
|
created_at: now(),
|
|
});
|
|
createMessagingGroupAgent({ ...mga(), id: 'mga-2', messaging_group_id: 'mg-2' });
|
|
|
|
const dests = getDestinations('ag-1')
|
|
.map((d) => d.local_name)
|
|
.sort();
|
|
expect(dests).toEqual(['gen', 'gen-2']);
|
|
});
|
|
});
|
|
|
|
// ── Sessions ──
|
|
|
|
describe('sessions', () => {
|
|
beforeEach(() => {
|
|
createAgentGroup({
|
|
id: 'ag-1',
|
|
name: 'Agent',
|
|
folder: 'agent',
|
|
agent_provider: null,
|
|
created_at: now(),
|
|
});
|
|
createMessagingGroup({
|
|
id: 'mg-1',
|
|
channel_type: 'discord',
|
|
platform_id: 'chan-1',
|
|
name: 'Gen',
|
|
is_group: 1,
|
|
unknown_sender_policy: 'strict',
|
|
created_at: now(),
|
|
});
|
|
});
|
|
|
|
const sess = () => ({
|
|
id: 'sess-1',
|
|
agent_group_id: 'ag-1',
|
|
messaging_group_id: 'mg-1',
|
|
thread_id: null,
|
|
agent_provider: null,
|
|
status: 'active' as const,
|
|
container_status: 'stopped' as const,
|
|
last_active: null,
|
|
created_at: now(),
|
|
});
|
|
|
|
it('should create and retrieve', () => {
|
|
createSession(sess());
|
|
const result = getSession('sess-1');
|
|
expect(result).toBeDefined();
|
|
expect(result!.agent_group_id).toBe('ag-1');
|
|
});
|
|
|
|
it('should find by messaging group (shared, no thread)', () => {
|
|
createSession(sess());
|
|
const result = findSession('mg-1', null);
|
|
expect(result).toBeDefined();
|
|
expect(result!.id).toBe('sess-1');
|
|
});
|
|
|
|
it('should find by messaging group + thread', () => {
|
|
createSession({ ...sess(), thread_id: 'thread-1' });
|
|
expect(findSession('mg-1', 'thread-1')).toBeDefined();
|
|
expect(findSession('mg-1', 'thread-2')).toBeUndefined();
|
|
expect(findSession('mg-1', null)).toBeUndefined();
|
|
});
|
|
|
|
it('should only find active sessions', () => {
|
|
createSession({ ...sess(), status: 'closed' });
|
|
expect(findSession('mg-1', null)).toBeUndefined();
|
|
});
|
|
|
|
it('should list by agent group', () => {
|
|
createSession(sess());
|
|
createSession({ ...sess(), id: 'sess-2', thread_id: 'thread-1' });
|
|
expect(getSessionsByAgentGroup('ag-1')).toHaveLength(2);
|
|
});
|
|
|
|
it('should list active sessions', () => {
|
|
createSession(sess());
|
|
createSession({ ...sess(), id: 'sess-closed', status: 'closed', thread_id: 'thread-x' });
|
|
expect(getActiveSessions()).toHaveLength(1);
|
|
});
|
|
|
|
it('should list running sessions', () => {
|
|
createSession({ ...sess(), container_status: 'running' });
|
|
createSession({ ...sess(), id: 'sess-idle', container_status: 'idle', thread_id: 'thread-1' });
|
|
createSession({ ...sess(), id: 'sess-stopped', container_status: 'stopped', thread_id: 'thread-2' });
|
|
expect(getRunningSessions()).toHaveLength(2);
|
|
});
|
|
|
|
it('should update', () => {
|
|
createSession(sess());
|
|
updateSession('sess-1', { container_status: 'running', last_active: now() });
|
|
const result = getSession('sess-1')!;
|
|
expect(result.container_status).toBe('running');
|
|
expect(result.last_active).not.toBeNull();
|
|
});
|
|
|
|
it('should delete', () => {
|
|
createSession(sess());
|
|
deleteSession('sess-1');
|
|
expect(getSession('sess-1')).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ── Pending Questions ──
|
|
|
|
describe('pending questions', () => {
|
|
beforeEach(() => {
|
|
createAgentGroup({
|
|
id: 'ag-1',
|
|
name: 'Agent',
|
|
folder: 'agent',
|
|
agent_provider: null,
|
|
created_at: now(),
|
|
});
|
|
createSession({
|
|
id: 'sess-1',
|
|
agent_group_id: 'ag-1',
|
|
messaging_group_id: null,
|
|
thread_id: null,
|
|
agent_provider: null,
|
|
status: 'active',
|
|
container_status: 'stopped',
|
|
last_active: null,
|
|
created_at: now(),
|
|
});
|
|
});
|
|
|
|
it('should create and retrieve', () => {
|
|
createPendingQuestion({
|
|
question_id: 'q-1',
|
|
session_id: 'sess-1',
|
|
message_out_id: 'msg-out-1',
|
|
platform_id: 'chan-1',
|
|
channel_type: 'discord',
|
|
thread_id: null,
|
|
title: 'Test',
|
|
options: [{ label: 'Yes', selectedLabel: 'Yes', value: 'yes' }],
|
|
created_at: now(),
|
|
});
|
|
const result = getPendingQuestion('q-1');
|
|
expect(result).toBeDefined();
|
|
expect(result!.session_id).toBe('sess-1');
|
|
expect(result!.title).toBe('Test');
|
|
expect(result!.options[0].value).toBe('yes');
|
|
});
|
|
|
|
it('should delete', () => {
|
|
createPendingQuestion({
|
|
question_id: 'q-1',
|
|
session_id: 'sess-1',
|
|
message_out_id: 'msg-out-1',
|
|
platform_id: null,
|
|
channel_type: null,
|
|
thread_id: null,
|
|
title: 'Test',
|
|
options: [{ label: 'Yes', selectedLabel: 'Yes', value: 'yes' }],
|
|
created_at: now(),
|
|
});
|
|
deletePendingQuestion('q-1');
|
|
expect(getPendingQuestion('q-1')).toBeUndefined();
|
|
});
|
|
});
|