assign_room (MCP tool + host-side assignRoomInDatabase) and setup register previously fell back to 'single' when no room_mode was given, so newly added rooms silently lost the reviewer. Default to 'tribunal' across the MCP zod schema, the IPC arg fallback, and the DB helper, and update the ipc-auth expectation accordingly. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1261 lines
32 KiB
TypeScript
1261 lines
32 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
|
|
import {
|
|
_setRegisteredGroupForTests,
|
|
_initTestDatabase,
|
|
assignRoom,
|
|
createTask,
|
|
getAllTasks,
|
|
recallMemories,
|
|
getRegisteredAgentTypesForJid,
|
|
getRegisteredGroup,
|
|
getStoredRoomSettings,
|
|
getTaskById,
|
|
} from './db.js';
|
|
import { forwardAuthorizedIpcMessage, processTaskIpc, IpcDeps } from './ipc.js';
|
|
import { DEFAULT_WATCH_CI_MAX_DURATION_MS } from './task-watch-status.js';
|
|
import { RegisteredGroup } from './types.js';
|
|
|
|
// Set up registered groups used across tests
|
|
const MAIN_GROUP: RegisteredGroup = {
|
|
name: 'Main',
|
|
folder: 'whatsapp_main',
|
|
trigger: 'always',
|
|
added_at: '2024-01-01T00:00:00.000Z',
|
|
isMain: true,
|
|
};
|
|
|
|
const OTHER_GROUP: RegisteredGroup = {
|
|
name: 'Other',
|
|
folder: 'other-group',
|
|
trigger: '@Andy',
|
|
added_at: '2024-01-01T00:00:00.000Z',
|
|
};
|
|
|
|
const THIRD_GROUP: RegisteredGroup = {
|
|
name: 'Third',
|
|
folder: 'third-group',
|
|
trigger: '@Andy',
|
|
added_at: '2024-01-01T00:00:00.000Z',
|
|
};
|
|
|
|
let groups: Record<string, RegisteredGroup>;
|
|
let deps: IpcDeps;
|
|
|
|
beforeEach(() => {
|
|
_initTestDatabase();
|
|
|
|
groups = {
|
|
'main@g.us': MAIN_GROUP,
|
|
'other@g.us': OTHER_GROUP,
|
|
'third@g.us': THIRD_GROUP,
|
|
};
|
|
|
|
// Populate DB as well
|
|
_setRegisteredGroupForTests('main@g.us', MAIN_GROUP);
|
|
_setRegisteredGroupForTests('other@g.us', OTHER_GROUP);
|
|
_setRegisteredGroupForTests('third@g.us', THIRD_GROUP);
|
|
|
|
deps = {
|
|
sendMessage: async () => {},
|
|
nudgeScheduler: vi.fn(),
|
|
roomBindings: () => groups,
|
|
assignRoom: (jid, room) => {
|
|
const assigned = assignRoom(jid, room);
|
|
if (!assigned) return;
|
|
const { jid: _ignoredJid, ...group } = assigned;
|
|
groups[jid] = group;
|
|
},
|
|
syncGroups: async () => {},
|
|
getAvailableGroups: () => [],
|
|
writeGroupsSnapshot: () => {},
|
|
};
|
|
});
|
|
|
|
// --- schedule_task authorization ---
|
|
|
|
describe('schedule_task authorization', () => {
|
|
it('main group can schedule for another group', async () => {
|
|
await processTaskIpc(
|
|
{
|
|
type: 'schedule_task',
|
|
prompt: 'do something',
|
|
schedule_type: 'once',
|
|
schedule_value: '2025-06-01T00:00:00',
|
|
targetJid: 'other@g.us',
|
|
},
|
|
'whatsapp_main',
|
|
true,
|
|
deps,
|
|
);
|
|
|
|
// Verify task was created in DB for the other group
|
|
const allTasks = getAllTasks();
|
|
expect(allTasks.length).toBe(1);
|
|
expect(allTasks[0].group_folder).toBe('other-group');
|
|
});
|
|
|
|
it('non-main group can schedule for itself', async () => {
|
|
await processTaskIpc(
|
|
{
|
|
type: 'schedule_task',
|
|
prompt: 'self task',
|
|
schedule_type: 'once',
|
|
schedule_value: '2025-06-01T00:00:00',
|
|
targetJid: 'other@g.us',
|
|
},
|
|
'other-group',
|
|
false,
|
|
deps,
|
|
);
|
|
|
|
const allTasks = getAllTasks();
|
|
expect(allTasks.length).toBe(1);
|
|
expect(allTasks[0].group_folder).toBe('other-group');
|
|
});
|
|
|
|
it('stores the target group agent type on scheduled tasks', async () => {
|
|
groups['other@g.us'] = {
|
|
...OTHER_GROUP,
|
|
agentType: 'codex',
|
|
};
|
|
_setRegisteredGroupForTests('other@g.us', groups['other@g.us']);
|
|
|
|
await processTaskIpc(
|
|
{
|
|
type: 'schedule_task',
|
|
prompt: 'codex owned task',
|
|
schedule_type: 'once',
|
|
schedule_value: '2025-06-01T00:00:00',
|
|
targetJid: 'other@g.us',
|
|
},
|
|
'whatsapp_main',
|
|
true,
|
|
deps,
|
|
);
|
|
|
|
const allTasks = getAllTasks();
|
|
expect(allTasks).toHaveLength(1);
|
|
expect(allTasks[0].agent_type).toBe('codex');
|
|
});
|
|
|
|
it('stores the scheduling caller agent type when provided', async () => {
|
|
groups['other@g.us'] = {
|
|
...OTHER_GROUP,
|
|
agentType: 'claude-code',
|
|
};
|
|
_setRegisteredGroupForTests('other@g.us', groups['other@g.us']);
|
|
|
|
await processTaskIpc(
|
|
{
|
|
type: 'schedule_task',
|
|
prompt: 'caller owned task',
|
|
schedule_type: 'once',
|
|
schedule_value: '2025-06-01T00:00:00',
|
|
targetJid: 'other@g.us',
|
|
agent_type: 'codex',
|
|
room_role: 'owner',
|
|
},
|
|
'whatsapp_main',
|
|
true,
|
|
deps,
|
|
);
|
|
|
|
const allTasks = getAllTasks();
|
|
expect(allTasks).toHaveLength(1);
|
|
expect(allTasks[0].agent_type).toBe('codex');
|
|
expect(allTasks[0].room_role).toBe('owner');
|
|
});
|
|
|
|
it('non-main group cannot schedule for another group', async () => {
|
|
await processTaskIpc(
|
|
{
|
|
type: 'schedule_task',
|
|
prompt: 'unauthorized',
|
|
schedule_type: 'once',
|
|
schedule_value: '2025-06-01T00:00:00',
|
|
targetJid: 'main@g.us',
|
|
},
|
|
'other-group',
|
|
false,
|
|
deps,
|
|
);
|
|
|
|
const allTasks = getAllTasks();
|
|
expect(allTasks.length).toBe(0);
|
|
});
|
|
|
|
it('rejects schedule_task for unregistered target JID', async () => {
|
|
await processTaskIpc(
|
|
{
|
|
type: 'schedule_task',
|
|
prompt: 'no target',
|
|
schedule_type: 'once',
|
|
schedule_value: '2025-06-01T00:00:00',
|
|
targetJid: 'unknown@g.us',
|
|
},
|
|
'whatsapp_main',
|
|
true,
|
|
deps,
|
|
);
|
|
|
|
const allTasks = getAllTasks();
|
|
expect(allTasks.length).toBe(0);
|
|
});
|
|
|
|
it('accepts a target folder name and resolves it to the registered JID', async () => {
|
|
await processTaskIpc(
|
|
{
|
|
type: 'schedule_task',
|
|
prompt: 'folder-based target',
|
|
schedule_type: 'once',
|
|
schedule_value: '2025-06-01T00:00:00',
|
|
targetJid: 'other-group',
|
|
},
|
|
'other-group',
|
|
false,
|
|
deps,
|
|
);
|
|
|
|
const allTasks = getAllTasks();
|
|
expect(allTasks).toHaveLength(1);
|
|
expect(allTasks[0].group_folder).toBe('other-group');
|
|
expect(allTasks[0].chat_jid).toBe('other@g.us');
|
|
});
|
|
});
|
|
|
|
describe('persist_memory authorization', () => {
|
|
it('allows a room runtime to persist memory for its own room scope', async () => {
|
|
await processTaskIpc(
|
|
{
|
|
type: 'persist_memory',
|
|
scopeKind: 'room',
|
|
scopeKey: 'room:other-group',
|
|
content: 'compact summary',
|
|
keywords: ['room:other-group'],
|
|
source_kind: 'compact',
|
|
source_ref: 'compact:test',
|
|
},
|
|
'other-group',
|
|
false,
|
|
deps,
|
|
);
|
|
|
|
const recalled = recallMemories({
|
|
scopeKind: 'room',
|
|
scopeKey: 'room:other-group',
|
|
limit: 10,
|
|
});
|
|
expect(recalled).toHaveLength(1);
|
|
expect(recalled[0].content).toBe('compact summary');
|
|
});
|
|
|
|
it('blocks persist_memory when scopeKey does not match the source group', async () => {
|
|
await processTaskIpc(
|
|
{
|
|
type: 'persist_memory',
|
|
scopeKind: 'room',
|
|
scopeKey: 'room:third-group',
|
|
content: 'should be denied',
|
|
keywords: ['room:third-group'],
|
|
source_kind: 'compact',
|
|
},
|
|
'other-group',
|
|
false,
|
|
deps,
|
|
);
|
|
|
|
const recalled = recallMemories({
|
|
scopeKind: 'room',
|
|
scopeKey: 'room:third-group',
|
|
limit: 10,
|
|
});
|
|
expect(recalled).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
// --- pause_task authorization ---
|
|
|
|
describe('pause_task authorization', () => {
|
|
beforeEach(() => {
|
|
createTask({
|
|
id: 'task-main',
|
|
group_folder: 'whatsapp_main',
|
|
chat_jid: 'main@g.us',
|
|
prompt: 'main task',
|
|
schedule_type: 'once',
|
|
schedule_value: '2025-06-01T00:00:00',
|
|
context_mode: 'isolated',
|
|
next_run: '2025-06-01T00:00:00.000Z',
|
|
status: 'active',
|
|
created_at: '2024-01-01T00:00:00.000Z',
|
|
});
|
|
createTask({
|
|
id: 'task-other',
|
|
group_folder: 'other-group',
|
|
chat_jid: 'other@g.us',
|
|
prompt: 'other task',
|
|
schedule_type: 'once',
|
|
schedule_value: '2025-06-01T00:00:00',
|
|
context_mode: 'isolated',
|
|
next_run: '2025-06-01T00:00:00.000Z',
|
|
status: 'active',
|
|
created_at: '2024-01-01T00:00:00.000Z',
|
|
});
|
|
});
|
|
|
|
it('main group can pause any task', async () => {
|
|
await processTaskIpc(
|
|
{ type: 'pause_task', taskId: 'task-other' },
|
|
'whatsapp_main',
|
|
true,
|
|
deps,
|
|
);
|
|
expect(getTaskById('task-other')!.status).toBe('paused');
|
|
});
|
|
|
|
it('non-main group can pause its own task', async () => {
|
|
await processTaskIpc(
|
|
{ type: 'pause_task', taskId: 'task-other' },
|
|
'other-group',
|
|
false,
|
|
deps,
|
|
);
|
|
expect(getTaskById('task-other')!.status).toBe('paused');
|
|
});
|
|
|
|
it('non-main group cannot pause another groups task', async () => {
|
|
await processTaskIpc(
|
|
{ type: 'pause_task', taskId: 'task-main' },
|
|
'other-group',
|
|
false,
|
|
deps,
|
|
);
|
|
expect(getTaskById('task-main')!.status).toBe('active');
|
|
});
|
|
});
|
|
|
|
// --- resume_task authorization ---
|
|
|
|
describe('resume_task authorization', () => {
|
|
beforeEach(() => {
|
|
createTask({
|
|
id: 'task-paused',
|
|
group_folder: 'other-group',
|
|
chat_jid: 'other@g.us',
|
|
prompt: 'paused task',
|
|
schedule_type: 'once',
|
|
schedule_value: '2025-06-01T00:00:00',
|
|
context_mode: 'isolated',
|
|
next_run: '2025-06-01T00:00:00.000Z',
|
|
status: 'paused',
|
|
created_at: '2024-01-01T00:00:00.000Z',
|
|
});
|
|
});
|
|
|
|
it('main group can resume any task', async () => {
|
|
await processTaskIpc(
|
|
{ type: 'resume_task', taskId: 'task-paused' },
|
|
'whatsapp_main',
|
|
true,
|
|
deps,
|
|
);
|
|
expect(getTaskById('task-paused')!.status).toBe('active');
|
|
});
|
|
|
|
it('non-main group can resume its own task', async () => {
|
|
await processTaskIpc(
|
|
{ type: 'resume_task', taskId: 'task-paused' },
|
|
'other-group',
|
|
false,
|
|
deps,
|
|
);
|
|
expect(getTaskById('task-paused')!.status).toBe('active');
|
|
});
|
|
|
|
it('non-main group cannot resume another groups task', async () => {
|
|
await processTaskIpc(
|
|
{ type: 'resume_task', taskId: 'task-paused' },
|
|
'third-group',
|
|
false,
|
|
deps,
|
|
);
|
|
expect(getTaskById('task-paused')!.status).toBe('paused');
|
|
});
|
|
});
|
|
|
|
// --- update_task behavior ---
|
|
|
|
describe('update_task behavior', () => {
|
|
it('updates once tasks even when next_run is not recomputed', async () => {
|
|
createTask({
|
|
id: 'task-once-update',
|
|
group_folder: 'other-group',
|
|
chat_jid: 'other@g.us',
|
|
prompt: 'original once task',
|
|
schedule_type: 'once',
|
|
schedule_value: '2025-06-01T00:00:00',
|
|
context_mode: 'isolated',
|
|
next_run: '2025-06-01T00:00:00.000Z',
|
|
status: 'active',
|
|
created_at: '2024-01-01T00:00:00.000Z',
|
|
});
|
|
|
|
await processTaskIpc(
|
|
{
|
|
type: 'update_task',
|
|
taskId: 'task-once-update',
|
|
prompt: 'updated once task',
|
|
schedule_value: '2025-07-01T00:00:00',
|
|
},
|
|
'other-group',
|
|
false,
|
|
deps,
|
|
);
|
|
|
|
const task = getTaskById('task-once-update')!;
|
|
expect(task.prompt).toBe('updated once task');
|
|
expect(task.schedule_value).toBe('2025-07-01T00:00:00');
|
|
expect(task.next_run).toBe('2025-06-01T00:00:00.000Z');
|
|
});
|
|
});
|
|
|
|
// --- cancel_task authorization ---
|
|
|
|
describe('cancel_task authorization', () => {
|
|
it('main group can cancel any task', async () => {
|
|
createTask({
|
|
id: 'task-to-cancel',
|
|
group_folder: 'other-group',
|
|
chat_jid: 'other@g.us',
|
|
prompt: 'cancel me',
|
|
schedule_type: 'once',
|
|
schedule_value: '2025-06-01T00:00:00',
|
|
context_mode: 'isolated',
|
|
next_run: null,
|
|
status: 'active',
|
|
created_at: '2024-01-01T00:00:00.000Z',
|
|
});
|
|
|
|
await processTaskIpc(
|
|
{ type: 'cancel_task', taskId: 'task-to-cancel' },
|
|
'whatsapp_main',
|
|
true,
|
|
deps,
|
|
);
|
|
expect(getTaskById('task-to-cancel')).toBeUndefined();
|
|
});
|
|
|
|
it('non-main group can cancel its own task', async () => {
|
|
createTask({
|
|
id: 'task-own',
|
|
group_folder: 'other-group',
|
|
chat_jid: 'other@g.us',
|
|
prompt: 'my task',
|
|
schedule_type: 'once',
|
|
schedule_value: '2025-06-01T00:00:00',
|
|
context_mode: 'isolated',
|
|
next_run: null,
|
|
status: 'active',
|
|
created_at: '2024-01-01T00:00:00.000Z',
|
|
});
|
|
|
|
await processTaskIpc(
|
|
{ type: 'cancel_task', taskId: 'task-own' },
|
|
'other-group',
|
|
false,
|
|
deps,
|
|
);
|
|
expect(getTaskById('task-own')).toBeUndefined();
|
|
});
|
|
|
|
it('non-main group cannot cancel another groups task', async () => {
|
|
createTask({
|
|
id: 'task-foreign',
|
|
group_folder: 'whatsapp_main',
|
|
chat_jid: 'main@g.us',
|
|
prompt: 'not yours',
|
|
schedule_type: 'once',
|
|
schedule_value: '2025-06-01T00:00:00',
|
|
context_mode: 'isolated',
|
|
next_run: null,
|
|
status: 'active',
|
|
created_at: '2024-01-01T00:00:00.000Z',
|
|
});
|
|
|
|
await processTaskIpc(
|
|
{ type: 'cancel_task', taskId: 'task-foreign' },
|
|
'other-group',
|
|
false,
|
|
deps,
|
|
);
|
|
expect(getTaskById('task-foreign')).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// --- assign_room authorization ---
|
|
|
|
describe('assign_room authorization', () => {
|
|
it('non-main group cannot assign a room', async () => {
|
|
await processTaskIpc(
|
|
{
|
|
type: 'assign_room',
|
|
jid: 'new@g.us',
|
|
name: 'New Group',
|
|
folder: 'new-group',
|
|
},
|
|
'other-group',
|
|
false,
|
|
deps,
|
|
);
|
|
|
|
// roomBindings should not have changed
|
|
expect(groups['new@g.us']).toBeUndefined();
|
|
});
|
|
|
|
it('main group cannot assign with unsafe folder path', async () => {
|
|
await processTaskIpc(
|
|
{
|
|
type: 'assign_room',
|
|
jid: 'new@g.us',
|
|
name: 'New Group',
|
|
folder: '../../outside',
|
|
},
|
|
'whatsapp_main',
|
|
true,
|
|
deps,
|
|
);
|
|
|
|
expect(groups['new@g.us']).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// --- refresh_groups authorization ---
|
|
|
|
describe('refresh_groups authorization', () => {
|
|
it('non-main group cannot trigger refresh', async () => {
|
|
// This should be silently blocked (no crash, no effect)
|
|
await processTaskIpc(
|
|
{ type: 'refresh_groups' },
|
|
'other-group',
|
|
false,
|
|
deps,
|
|
);
|
|
// If we got here without error, the auth gate worked
|
|
});
|
|
});
|
|
|
|
// --- IPC message authorization ---
|
|
// Tests the authorization pattern from startIpcWatcher (ipc.ts).
|
|
// The logic: isMain || (targetGroup && targetGroup.folder === sourceGroup)
|
|
|
|
describe('IPC message authorization', () => {
|
|
// Replicate the exact check from the IPC watcher
|
|
function isMessageAuthorized(
|
|
sourceGroup: string,
|
|
isMain: boolean,
|
|
targetChatJid: string,
|
|
roomBindings: Record<string, RegisteredGroup>,
|
|
): boolean {
|
|
const targetGroup = roomBindings[targetChatJid];
|
|
return isMain || (!!targetGroup && targetGroup.folder === sourceGroup);
|
|
}
|
|
|
|
it('main group can send to any group', () => {
|
|
expect(
|
|
isMessageAuthorized('whatsapp_main', true, 'other@g.us', groups),
|
|
).toBe(true);
|
|
expect(
|
|
isMessageAuthorized('whatsapp_main', true, 'third@g.us', groups),
|
|
).toBe(true);
|
|
});
|
|
|
|
it('non-main group can send to its own chat', () => {
|
|
expect(
|
|
isMessageAuthorized('other-group', false, 'other@g.us', groups),
|
|
).toBe(true);
|
|
});
|
|
|
|
it('non-main group cannot send to another groups chat', () => {
|
|
expect(isMessageAuthorized('other-group', false, 'main@g.us', groups)).toBe(
|
|
false,
|
|
);
|
|
expect(
|
|
isMessageAuthorized('other-group', false, 'third@g.us', groups),
|
|
).toBe(false);
|
|
});
|
|
|
|
it('non-main group cannot send to unregistered JID', () => {
|
|
expect(
|
|
isMessageAuthorized('other-group', false, 'unknown@g.us', groups),
|
|
).toBe(false);
|
|
});
|
|
|
|
it('main group can send to unregistered JID', () => {
|
|
// Main is always authorized regardless of target
|
|
expect(
|
|
isMessageAuthorized('whatsapp_main', true, 'unknown@g.us', groups),
|
|
).toBe(true);
|
|
});
|
|
|
|
it('forwards senderRole through authorized IPC messages', async () => {
|
|
const sendMessage = vi.fn(async () => {});
|
|
|
|
const result = await forwardAuthorizedIpcMessage(
|
|
{
|
|
type: 'message',
|
|
chatJid: 'other@g.us',
|
|
text: 'review text',
|
|
senderRole: 'reviewer',
|
|
runId: 'run-reviewer-ipc',
|
|
},
|
|
'other-group',
|
|
false,
|
|
groups,
|
|
sendMessage,
|
|
);
|
|
|
|
expect(sendMessage).toHaveBeenCalledWith(
|
|
'other@g.us',
|
|
'review text',
|
|
'reviewer',
|
|
'run-reviewer-ipc',
|
|
undefined,
|
|
);
|
|
expect(result).toEqual(
|
|
expect.objectContaining({
|
|
outcome: 'sent',
|
|
senderRole: 'reviewer',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('forwards structured attachments through authorized IPC messages', async () => {
|
|
const sendMessage = vi.fn(async () => {});
|
|
const attachments = [
|
|
{
|
|
path: '/tmp/ejclaw-room-mobile-list-390.png',
|
|
name: 'room.png',
|
|
mime: 'image/png',
|
|
},
|
|
];
|
|
|
|
const result = await forwardAuthorizedIpcMessage(
|
|
{
|
|
type: 'message',
|
|
chatJid: 'other@g.us',
|
|
text: '스크린샷입니다.',
|
|
attachments,
|
|
},
|
|
'other-group',
|
|
false,
|
|
groups,
|
|
sendMessage,
|
|
);
|
|
|
|
expect(sendMessage).toHaveBeenCalledWith(
|
|
'other@g.us',
|
|
'스크린샷입니다.',
|
|
undefined,
|
|
undefined,
|
|
attachments,
|
|
);
|
|
expect(result.outcome).toBe('sent');
|
|
});
|
|
|
|
it('normalizes raw EJClaw envelopes in authorized IPC messages', async () => {
|
|
const sendMessage = vi.fn(async () => {});
|
|
|
|
const result = await forwardAuthorizedIpcMessage(
|
|
{
|
|
type: 'message',
|
|
chatJid: 'other@g.us',
|
|
text: JSON.stringify({
|
|
ejclaw: {
|
|
visibility: 'public',
|
|
text: '첨부를 렌더링했습니다.',
|
|
verdict: 'done',
|
|
attachments: [
|
|
{
|
|
path: '/tmp/bar-chart-label-fit-playwright.png',
|
|
name: 'bar-chart-label-fit-playwright.png',
|
|
mime: 'image/png',
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
},
|
|
'other-group',
|
|
false,
|
|
groups,
|
|
sendMessage,
|
|
);
|
|
|
|
expect(sendMessage).toHaveBeenCalledWith(
|
|
'other@g.us',
|
|
'첨부를 렌더링했습니다.',
|
|
undefined,
|
|
undefined,
|
|
[
|
|
{
|
|
path: '/tmp/bar-chart-label-fit-playwright.png',
|
|
name: 'bar-chart-label-fit-playwright.png',
|
|
mime: 'image/png',
|
|
},
|
|
],
|
|
);
|
|
expect(result.outcome).toBe('sent');
|
|
});
|
|
|
|
it('injects authorized inbound messages for the source group without routing them as outbound sends', async () => {
|
|
const sendMessage = vi.fn(async () => {});
|
|
const injectInboundMessage = vi.fn(async () => {});
|
|
|
|
const result = await (forwardAuthorizedIpcMessage as any)(
|
|
{
|
|
type: 'inject_inbound_message',
|
|
chatJid: 'other@g.us',
|
|
text: 'act like a human input',
|
|
senderRole: 'owner',
|
|
sender: '1480575043007418420',
|
|
senderName: 'algo',
|
|
treatAsHuman: true,
|
|
sourceKind: 'trusted_external_bot',
|
|
},
|
|
'other-group',
|
|
false,
|
|
groups,
|
|
sendMessage,
|
|
injectInboundMessage,
|
|
);
|
|
|
|
expect(sendMessage).not.toHaveBeenCalled();
|
|
expect(injectInboundMessage).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
chatJid: 'other@g.us',
|
|
text: 'act like a human input',
|
|
sender: '1480575043007418420',
|
|
senderName: 'algo',
|
|
treatAsHuman: true,
|
|
sourceKind: 'trusted_external_bot',
|
|
}),
|
|
);
|
|
expect(result).toEqual(
|
|
expect.objectContaining({
|
|
outcome: 'sent',
|
|
chatJid: 'other@g.us',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('does not inject unauthorized inbound messages for another group', async () => {
|
|
const sendMessage = vi.fn(async () => {});
|
|
const injectInboundMessage = vi.fn(async () => {});
|
|
|
|
const result = await (forwardAuthorizedIpcMessage as any)(
|
|
{
|
|
type: 'inject_inbound_message',
|
|
chatJid: 'third@g.us',
|
|
text: 'should be blocked',
|
|
sender: '1480575043007418420',
|
|
senderName: 'algo',
|
|
treatAsHuman: true,
|
|
sourceKind: 'trusted_external_bot',
|
|
},
|
|
'other-group',
|
|
false,
|
|
groups,
|
|
sendMessage,
|
|
injectInboundMessage,
|
|
);
|
|
|
|
expect(result.outcome).toBe('blocked');
|
|
expect(sendMessage).not.toHaveBeenCalled();
|
|
expect(injectInboundMessage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not forward unauthorized IPC messages even when senderRole exists', async () => {
|
|
const sendMessage = vi.fn(async () => {});
|
|
|
|
const result = await forwardAuthorizedIpcMessage(
|
|
{
|
|
type: 'message',
|
|
chatJid: 'third@g.us',
|
|
text: 'review text',
|
|
senderRole: 'reviewer',
|
|
runId: 'run-reviewer-ipc',
|
|
},
|
|
'other-group',
|
|
false,
|
|
groups,
|
|
sendMessage,
|
|
);
|
|
|
|
expect(result.outcome).toBe('blocked');
|
|
expect(result.senderRole).toBe('reviewer');
|
|
expect(sendMessage).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// --- schedule_task with cron and interval types ---
|
|
|
|
describe('schedule_task schedule types', () => {
|
|
it('creates task with cron schedule and computes next_run', async () => {
|
|
await processTaskIpc(
|
|
{
|
|
type: 'schedule_task',
|
|
prompt: 'cron task',
|
|
schedule_type: 'cron',
|
|
schedule_value: '0 9 * * *', // every day at 9am
|
|
targetJid: 'other@g.us',
|
|
},
|
|
'whatsapp_main',
|
|
true,
|
|
deps,
|
|
);
|
|
|
|
const tasks = getAllTasks();
|
|
expect(tasks).toHaveLength(1);
|
|
expect(tasks[0].schedule_type).toBe('cron');
|
|
expect(tasks[0].next_run).toBeTruthy();
|
|
// next_run should be a valid ISO date in the future
|
|
expect(new Date(tasks[0].next_run!).getTime()).toBeGreaterThan(
|
|
Date.now() - 60000,
|
|
);
|
|
});
|
|
|
|
it('rejects invalid cron expression', async () => {
|
|
await processTaskIpc(
|
|
{
|
|
type: 'schedule_task',
|
|
prompt: 'bad cron',
|
|
schedule_type: 'cron',
|
|
schedule_value: 'not a cron',
|
|
targetJid: 'other@g.us',
|
|
},
|
|
'whatsapp_main',
|
|
true,
|
|
deps,
|
|
);
|
|
|
|
expect(getAllTasks()).toHaveLength(0);
|
|
});
|
|
|
|
it('creates task with interval schedule', async () => {
|
|
const before = Date.now();
|
|
|
|
await processTaskIpc(
|
|
{
|
|
type: 'schedule_task',
|
|
prompt: 'interval task',
|
|
schedule_type: 'interval',
|
|
schedule_value: '3600000', // 1 hour
|
|
targetJid: 'other@g.us',
|
|
},
|
|
'whatsapp_main',
|
|
true,
|
|
deps,
|
|
);
|
|
|
|
const tasks = getAllTasks();
|
|
expect(tasks).toHaveLength(1);
|
|
expect(tasks[0].schedule_type).toBe('interval');
|
|
// next_run should be ~1 hour from now
|
|
const nextRun = new Date(tasks[0].next_run!).getTime();
|
|
expect(nextRun).toBeGreaterThanOrEqual(before + 3600000 - 1000);
|
|
expect(nextRun).toBeLessThanOrEqual(Date.now() + 3600000 + 1000);
|
|
});
|
|
|
|
it('starts watch_ci interval tasks immediately and nudges the scheduler', async () => {
|
|
const before = Date.now();
|
|
|
|
await processTaskIpc(
|
|
{
|
|
type: 'schedule_task',
|
|
prompt: `
|
|
[BACKGROUND CI WATCH]
|
|
|
|
Watch target:
|
|
GitHub Actions run 123456
|
|
|
|
Check instructions:
|
|
Check the run.
|
|
`.trim(),
|
|
schedule_type: 'interval',
|
|
schedule_value: '60000',
|
|
targetJid: 'other@g.us',
|
|
},
|
|
'whatsapp_main',
|
|
true,
|
|
deps,
|
|
);
|
|
|
|
const tasks = getAllTasks();
|
|
expect(tasks).toHaveLength(1);
|
|
expect(tasks[0].schedule_type).toBe('interval');
|
|
expect(tasks[0].max_duration_ms).toBe(DEFAULT_WATCH_CI_MAX_DURATION_MS);
|
|
const nextRun = new Date(tasks[0].next_run!).getTime();
|
|
expect(nextRun).toBeGreaterThanOrEqual(before - 1000);
|
|
expect(nextRun).toBeLessThanOrEqual(Date.now() + 1000);
|
|
expect(deps.nudgeScheduler).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('persists structured GitHub watch metadata for host-driven watchers', async () => {
|
|
await processTaskIpc(
|
|
{
|
|
type: 'schedule_task',
|
|
prompt: `
|
|
[BACKGROUND CI WATCH]
|
|
|
|
Watch target:
|
|
GitHub Actions run 654321
|
|
|
|
Check instructions:
|
|
Managed by host-driven watcher.
|
|
`.trim(),
|
|
schedule_type: 'interval',
|
|
schedule_value: '15000',
|
|
ci_provider: 'github',
|
|
ci_metadata: JSON.stringify({
|
|
repo: 'owner/repo',
|
|
run_id: 654321,
|
|
}),
|
|
targetJid: 'other@g.us',
|
|
},
|
|
'whatsapp_main',
|
|
true,
|
|
deps,
|
|
);
|
|
|
|
const tasks = getAllTasks();
|
|
expect(tasks).toHaveLength(1);
|
|
expect(tasks[0].ci_provider).toBe('github');
|
|
expect(tasks[0].ci_metadata).toContain('owner/repo');
|
|
expect(tasks[0].max_duration_ms).toBe(DEFAULT_WATCH_CI_MAX_DURATION_MS);
|
|
});
|
|
|
|
it('does not assign a max duration to regular scheduled tasks', async () => {
|
|
await processTaskIpc(
|
|
{
|
|
type: 'schedule_task',
|
|
prompt: 'regular interval task',
|
|
schedule_type: 'interval',
|
|
schedule_value: '60000',
|
|
targetJid: 'other@g.us',
|
|
},
|
|
'whatsapp_main',
|
|
true,
|
|
deps,
|
|
);
|
|
|
|
const tasks = getAllTasks();
|
|
expect(tasks).toHaveLength(1);
|
|
expect(tasks[0].max_duration_ms).toBeNull();
|
|
});
|
|
|
|
it('rejects invalid interval (non-numeric)', async () => {
|
|
await processTaskIpc(
|
|
{
|
|
type: 'schedule_task',
|
|
prompt: 'bad interval',
|
|
schedule_type: 'interval',
|
|
schedule_value: 'abc',
|
|
targetJid: 'other@g.us',
|
|
},
|
|
'whatsapp_main',
|
|
true,
|
|
deps,
|
|
);
|
|
|
|
expect(getAllTasks()).toHaveLength(0);
|
|
});
|
|
|
|
it('rejects invalid interval (zero)', async () => {
|
|
await processTaskIpc(
|
|
{
|
|
type: 'schedule_task',
|
|
prompt: 'zero interval',
|
|
schedule_type: 'interval',
|
|
schedule_value: '0',
|
|
targetJid: 'other@g.us',
|
|
},
|
|
'whatsapp_main',
|
|
true,
|
|
deps,
|
|
);
|
|
|
|
expect(getAllTasks()).toHaveLength(0);
|
|
});
|
|
|
|
it('rejects invalid once timestamp', async () => {
|
|
await processTaskIpc(
|
|
{
|
|
type: 'schedule_task',
|
|
prompt: 'bad once',
|
|
schedule_type: 'once',
|
|
schedule_value: 'not-a-date',
|
|
targetJid: 'other@g.us',
|
|
},
|
|
'whatsapp_main',
|
|
true,
|
|
deps,
|
|
);
|
|
|
|
expect(getAllTasks()).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
// --- context_mode defaulting ---
|
|
|
|
describe('schedule_task context_mode', () => {
|
|
it('accepts context_mode=group', async () => {
|
|
await processTaskIpc(
|
|
{
|
|
type: 'schedule_task',
|
|
prompt: 'group context',
|
|
schedule_type: 'once',
|
|
schedule_value: '2025-06-01T00:00:00',
|
|
context_mode: 'group',
|
|
targetJid: 'other@g.us',
|
|
},
|
|
'whatsapp_main',
|
|
true,
|
|
deps,
|
|
);
|
|
|
|
const tasks = getAllTasks();
|
|
expect(tasks[0].context_mode).toBe('group');
|
|
});
|
|
|
|
it('accepts context_mode=isolated', async () => {
|
|
await processTaskIpc(
|
|
{
|
|
type: 'schedule_task',
|
|
prompt: 'isolated context',
|
|
schedule_type: 'once',
|
|
schedule_value: '2025-06-01T00:00:00',
|
|
context_mode: 'isolated',
|
|
targetJid: 'other@g.us',
|
|
},
|
|
'whatsapp_main',
|
|
true,
|
|
deps,
|
|
);
|
|
|
|
const tasks = getAllTasks();
|
|
expect(tasks[0].context_mode).toBe('isolated');
|
|
});
|
|
|
|
it('defaults invalid context_mode to isolated', async () => {
|
|
await processTaskIpc(
|
|
{
|
|
type: 'schedule_task',
|
|
prompt: 'bad context',
|
|
schedule_type: 'once',
|
|
schedule_value: '2025-06-01T00:00:00',
|
|
context_mode: 'bogus' as any,
|
|
targetJid: 'other@g.us',
|
|
},
|
|
'whatsapp_main',
|
|
true,
|
|
deps,
|
|
);
|
|
|
|
const tasks = getAllTasks();
|
|
expect(tasks[0].context_mode).toBe('isolated');
|
|
});
|
|
|
|
it('defaults missing context_mode to isolated', async () => {
|
|
await processTaskIpc(
|
|
{
|
|
type: 'schedule_task',
|
|
prompt: 'no context mode',
|
|
schedule_type: 'once',
|
|
schedule_value: '2025-06-01T00:00:00',
|
|
targetJid: 'other@g.us',
|
|
},
|
|
'whatsapp_main',
|
|
true,
|
|
deps,
|
|
);
|
|
|
|
const tasks = getAllTasks();
|
|
expect(tasks[0].context_mode).toBe('isolated');
|
|
});
|
|
});
|
|
|
|
// --- assign_room success path ---
|
|
|
|
describe('assign_room success', () => {
|
|
it('main group can assign a new room', async () => {
|
|
await processTaskIpc(
|
|
{
|
|
type: 'assign_room',
|
|
jid: 'new@g.us',
|
|
name: 'New Group',
|
|
folder: 'new-group',
|
|
},
|
|
'whatsapp_main',
|
|
true,
|
|
deps,
|
|
);
|
|
|
|
const group = getRegisteredGroup('new@g.us');
|
|
expect(group).toBeDefined();
|
|
expect(group!.name).toBe('New Group');
|
|
expect(group!.folder).toBe('new-group');
|
|
});
|
|
|
|
it('assign_room persists trigger metadata through IPC', async () => {
|
|
await processTaskIpc(
|
|
{
|
|
type: 'assign_room',
|
|
jid: 'triggered@g.us',
|
|
name: 'Triggered Group',
|
|
folder: 'triggered-group',
|
|
trigger: '@Repro',
|
|
requiresTrigger: true,
|
|
},
|
|
'whatsapp_main',
|
|
true,
|
|
deps,
|
|
);
|
|
|
|
expect(getStoredRoomSettings('triggered@g.us')).toMatchObject({
|
|
trigger: '@Repro',
|
|
requiresTrigger: true,
|
|
});
|
|
expect(getRegisteredGroup('triggered@g.us')).toMatchObject({
|
|
trigger: '@Repro',
|
|
requiresTrigger: true,
|
|
});
|
|
});
|
|
|
|
it('assign_room auto-fills missing folder and defaults to tribunal without exposing trigger metadata', async () => {
|
|
await processTaskIpc(
|
|
{
|
|
type: 'assign_room',
|
|
jid: 'partial@g.us',
|
|
name: 'Partial',
|
|
},
|
|
'whatsapp_main',
|
|
true,
|
|
deps,
|
|
);
|
|
|
|
const group = getRegisteredGroup('partial@g.us');
|
|
expect(group).toBeDefined();
|
|
expect(group!.folder).toMatch(/^grp_whatsapp_/);
|
|
expect(getStoredRoomSettings('partial@g.us')?.roomMode).toBe('tribunal');
|
|
});
|
|
|
|
it('assign_room preserves an existing tribunal room mode when room_mode is omitted', async () => {
|
|
assignRoom('legacy-tribunal@g.us', {
|
|
name: 'Legacy Tribunal',
|
|
roomMode: 'tribunal',
|
|
ownerAgentType: 'codex',
|
|
folder: 'legacy-tribunal',
|
|
});
|
|
|
|
await processTaskIpc(
|
|
{
|
|
type: 'assign_room',
|
|
jid: 'legacy-tribunal@g.us',
|
|
name: 'Legacy Tribunal Renamed',
|
|
folder: 'legacy-tribunal',
|
|
},
|
|
'whatsapp_main',
|
|
true,
|
|
deps,
|
|
);
|
|
|
|
expect(getStoredRoomSettings('legacy-tribunal@g.us')).toMatchObject({
|
|
chatJid: 'legacy-tribunal@g.us',
|
|
roomMode: 'tribunal',
|
|
modeSource: 'explicit',
|
|
name: 'Legacy Tribunal Renamed',
|
|
});
|
|
expect(
|
|
getRegisteredAgentTypesForJid('legacy-tribunal@g.us').sort(),
|
|
).toEqual(['claude-code', 'codex']);
|
|
});
|
|
|
|
it('main group can assign a tribunal room and materialize capability rows', async () => {
|
|
await processTaskIpc(
|
|
{
|
|
type: 'assign_room',
|
|
jid: 'tribunal@g.us',
|
|
name: 'Tribunal Room',
|
|
room_mode: 'tribunal',
|
|
owner_agent_type: 'codex',
|
|
},
|
|
'whatsapp_main',
|
|
true,
|
|
deps,
|
|
);
|
|
|
|
const stored = getStoredRoomSettings('tribunal@g.us');
|
|
expect(stored).toMatchObject({
|
|
chatJid: 'tribunal@g.us',
|
|
roomMode: 'tribunal',
|
|
modeSource: 'explicit',
|
|
name: 'Tribunal Room',
|
|
ownerAgentType: 'codex',
|
|
});
|
|
expect(stored?.folder).toMatch(/^grp_whatsapp_/);
|
|
expect(getRegisteredAgentTypesForJid('tribunal@g.us').sort()).toEqual([
|
|
'claude-code',
|
|
'codex',
|
|
]);
|
|
});
|
|
|
|
it('main group can assign per-room reviewer and arbiter agent types', async () => {
|
|
await processTaskIpc(
|
|
{
|
|
type: 'assign_room',
|
|
jid: 'tribunal-role-overrides@g.us',
|
|
name: 'Tribunal Role Overrides',
|
|
room_mode: 'tribunal',
|
|
owner_agent_type: 'claude-code',
|
|
reviewer_agent_type: 'codex',
|
|
arbiter_agent_type: 'claude-code',
|
|
},
|
|
'whatsapp_main',
|
|
true,
|
|
deps,
|
|
);
|
|
|
|
expect(getStoredRoomSettings('tribunal-role-overrides@g.us')).toMatchObject(
|
|
{
|
|
chatJid: 'tribunal-role-overrides@g.us',
|
|
roomMode: 'tribunal',
|
|
modeSource: 'explicit',
|
|
name: 'Tribunal Role Overrides',
|
|
ownerAgentType: 'claude-code',
|
|
},
|
|
);
|
|
expect(
|
|
getRegisteredAgentTypesForJid('tribunal-role-overrides@g.us').sort(),
|
|
).toEqual(['claude-code', 'codex']);
|
|
expect(
|
|
getRegisteredGroup('tribunal-role-overrides@g.us', 'codex'),
|
|
).toMatchObject({
|
|
agentType: 'codex',
|
|
});
|
|
});
|
|
|
|
it('assign_room rejects request with missing fields', async () => {
|
|
await processTaskIpc(
|
|
{
|
|
type: 'assign_room',
|
|
jid: 'broken@g.us',
|
|
},
|
|
'whatsapp_main',
|
|
true,
|
|
deps,
|
|
);
|
|
|
|
expect(getRegisteredGroup('broken@g.us')).toBeUndefined();
|
|
});
|
|
});
|