refactor: land approved runtime cleanup and room binding cutover
This commit is contained in:
@@ -599,6 +599,17 @@ src/
|
|||||||
- `getAllRoomBindings()` 도입
|
- `getAllRoomBindings()` 도입
|
||||||
- startup sync 제거
|
- startup sync 제거
|
||||||
|
|
||||||
|
하위 컷 메모:
|
||||||
|
|
||||||
|
- 하위 컷 1. `registered_groups` 기반 owner/room-mode/snapshot 추론 제거
|
||||||
|
- 대상: `src/service-routing.ts`, `src/db.ts`, `src/db/room-registration.ts`
|
||||||
|
- 범위: stored canonical 값이 없을 때 `registered_groups` legacy row로 owner service, room mode, snapshot 의미를 추론하는 read-path fallback 제거
|
||||||
|
- 동반 테스트: `src/service-routing.test.ts`, `src/db.test.ts`
|
||||||
|
- 하위 컷 2. 남은 `registeredGroups` runtime consumer 제거
|
||||||
|
- 대상: `src/index.ts`, `src/message-runtime-loop.ts`, `src/ipc.ts`
|
||||||
|
- 범위: `getAllRegisteredGroups()` read-path 소비자 제거, `getAllRoomBindings()` 도입, startup sync 제거
|
||||||
|
- 주의: 여기서 말하는 추론 제거는 PR 4 / Phase 4의 `paired-state.ts` hydrate fallback 제거와 별개다.
|
||||||
|
|
||||||
### PR 4. Paired task canonicalization
|
### PR 4. Paired task canonicalization
|
||||||
|
|
||||||
- paired/channel owner/handoff/work item backfill
|
- paired/channel owner/handoff/work item backfill
|
||||||
|
|||||||
@@ -63,8 +63,6 @@ describe('register step', () => {
|
|||||||
'dc:test-room',
|
'dc:test-room',
|
||||||
'--name',
|
'--name',
|
||||||
'Test Room',
|
'Test Room',
|
||||||
'--trigger',
|
|
||||||
'@Andy',
|
|
||||||
'--folder',
|
'--folder',
|
||||||
'test-room',
|
'test-room',
|
||||||
]);
|
]);
|
||||||
@@ -73,8 +71,6 @@ describe('register step', () => {
|
|||||||
expect(assignRoomMock).toHaveBeenCalledWith('dc:test-room', {
|
expect(assignRoomMock).toHaveBeenCalledWith('dc:test-room', {
|
||||||
name: 'Test Room',
|
name: 'Test Room',
|
||||||
folder: 'test-room',
|
folder: 'test-room',
|
||||||
trigger: '@Andy',
|
|
||||||
requiresTrigger: true,
|
|
||||||
isMain: false,
|
isMain: false,
|
||||||
});
|
});
|
||||||
expect(mkdirSpy).toHaveBeenCalledWith('/tmp/ejclaw-groups/test-room/logs', {
|
expect(mkdirSpy).toHaveBeenCalledWith('/tmp/ejclaw-groups/test-room/logs', {
|
||||||
@@ -87,8 +83,6 @@ describe('register step', () => {
|
|||||||
NAME: 'Test Room',
|
NAME: 'Test Room',
|
||||||
FOLDER: 'test-room',
|
FOLDER: 'test-room',
|
||||||
CHANNEL: 'discord',
|
CHANNEL: 'discord',
|
||||||
TRIGGER: '@Andy',
|
|
||||||
REQUIRES_TRIGGER: true,
|
|
||||||
STATUS: 'success',
|
STATUS: 'success',
|
||||||
LOG: 'logs/setup.log',
|
LOG: 'logs/setup.log',
|
||||||
});
|
});
|
||||||
@@ -107,8 +101,6 @@ describe('register step', () => {
|
|||||||
'dc:test-room',
|
'dc:test-room',
|
||||||
'--name',
|
'--name',
|
||||||
'Test Room',
|
'Test Room',
|
||||||
'--trigger',
|
|
||||||
'@Andy',
|
|
||||||
'--folder',
|
'--folder',
|
||||||
'test-room',
|
'test-room',
|
||||||
'--assistant-name',
|
'--assistant-name',
|
||||||
@@ -141,19 +133,14 @@ describe('register step', () => {
|
|||||||
'dc:main-room',
|
'dc:main-room',
|
||||||
'--name',
|
'--name',
|
||||||
'Main Room',
|
'Main Room',
|
||||||
'--trigger',
|
|
||||||
'@Andy',
|
|
||||||
'--folder',
|
'--folder',
|
||||||
'main-room',
|
'main-room',
|
||||||
'--is-main',
|
'--is-main',
|
||||||
'--no-trigger-required',
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(assignRoomMock).toHaveBeenCalledWith('dc:main-room', {
|
expect(assignRoomMock).toHaveBeenCalledWith('dc:main-room', {
|
||||||
name: 'Main Room',
|
name: 'Main Room',
|
||||||
folder: 'main-room',
|
folder: 'main-room',
|
||||||
trigger: '@Andy',
|
|
||||||
requiresTrigger: false,
|
|
||||||
isMain: true,
|
isMain: true,
|
||||||
});
|
});
|
||||||
expect(mkdirSpy).toHaveBeenCalledTimes(1);
|
expect(mkdirSpy).toHaveBeenCalledTimes(1);
|
||||||
|
|||||||
@@ -15,10 +15,8 @@ import { emitStatus } from './status.js';
|
|||||||
interface RegisterArgs {
|
interface RegisterArgs {
|
||||||
jid: string;
|
jid: string;
|
||||||
name: string;
|
name: string;
|
||||||
trigger: string;
|
|
||||||
folder: string;
|
folder: string;
|
||||||
channel: string;
|
channel: string;
|
||||||
requiresTrigger: boolean;
|
|
||||||
isMain: boolean;
|
isMain: boolean;
|
||||||
assistantNameProvided: boolean;
|
assistantNameProvided: boolean;
|
||||||
}
|
}
|
||||||
@@ -27,10 +25,8 @@ function parseArgs(args: string[]): RegisterArgs {
|
|||||||
const result: RegisterArgs = {
|
const result: RegisterArgs = {
|
||||||
jid: '',
|
jid: '',
|
||||||
name: '',
|
name: '',
|
||||||
trigger: '',
|
|
||||||
folder: '',
|
folder: '',
|
||||||
channel: 'discord',
|
channel: 'discord',
|
||||||
requiresTrigger: true,
|
|
||||||
isMain: false,
|
isMain: false,
|
||||||
assistantNameProvided: false,
|
assistantNameProvided: false,
|
||||||
};
|
};
|
||||||
@@ -43,18 +39,12 @@ function parseArgs(args: string[]): RegisterArgs {
|
|||||||
case '--name':
|
case '--name':
|
||||||
result.name = args[++i] || '';
|
result.name = args[++i] || '';
|
||||||
break;
|
break;
|
||||||
case '--trigger':
|
|
||||||
result.trigger = args[++i] || '';
|
|
||||||
break;
|
|
||||||
case '--folder':
|
case '--folder':
|
||||||
result.folder = args[++i] || '';
|
result.folder = args[++i] || '';
|
||||||
break;
|
break;
|
||||||
case '--channel':
|
case '--channel':
|
||||||
result.channel = (args[++i] || '').toLowerCase();
|
result.channel = (args[++i] || '').toLowerCase();
|
||||||
break;
|
break;
|
||||||
case '--no-trigger-required':
|
|
||||||
result.requiresTrigger = false;
|
|
||||||
break;
|
|
||||||
case '--is-main':
|
case '--is-main':
|
||||||
result.isMain = true;
|
result.isMain = true;
|
||||||
break;
|
break;
|
||||||
@@ -71,7 +61,7 @@ function parseArgs(args: string[]): RegisterArgs {
|
|||||||
export async function run(args: string[]): Promise<void> {
|
export async function run(args: string[]): Promise<void> {
|
||||||
const parsed = parseArgs(args);
|
const parsed = parseArgs(args);
|
||||||
|
|
||||||
if (!parsed.jid || !parsed.name || !parsed.trigger || !parsed.folder) {
|
if (!parsed.jid || !parsed.name || !parsed.folder) {
|
||||||
emitStatus('REGISTER_CHANNEL', {
|
emitStatus('REGISTER_CHANNEL', {
|
||||||
STATUS: 'failed',
|
STATUS: 'failed',
|
||||||
ERROR: 'missing_required_args',
|
ERROR: 'missing_required_args',
|
||||||
@@ -115,8 +105,6 @@ export async function run(args: string[]): Promise<void> {
|
|||||||
assignRoom(parsed.jid, {
|
assignRoom(parsed.jid, {
|
||||||
name: parsed.name,
|
name: parsed.name,
|
||||||
folder: parsed.folder,
|
folder: parsed.folder,
|
||||||
trigger: parsed.trigger,
|
|
||||||
requiresTrigger: parsed.requiresTrigger,
|
|
||||||
isMain: parsed.isMain,
|
isMain: parsed.isMain,
|
||||||
});
|
});
|
||||||
logger.info('Assigned room through canonical room service');
|
logger.info('Assigned room through canonical room service');
|
||||||
@@ -131,8 +119,6 @@ export async function run(args: string[]): Promise<void> {
|
|||||||
NAME: parsed.name,
|
NAME: parsed.name,
|
||||||
FOLDER: parsed.folder,
|
FOLDER: parsed.folder,
|
||||||
CHANNEL: parsed.channel,
|
CHANNEL: parsed.channel,
|
||||||
TRIGGER: parsed.trigger,
|
|
||||||
REQUIRES_TRIGGER: parsed.requiresTrigger,
|
|
||||||
STATUS: 'success',
|
STATUS: 'success',
|
||||||
LOG: 'logs/setup.log',
|
LOG: 'logs/setup.log',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import { getAllChats } from './db.js';
|
|||||||
import type { RegisteredGroup } from './types.js';
|
import type { RegisteredGroup } from './types.js';
|
||||||
|
|
||||||
export function listAvailableGroups(
|
export function listAvailableGroups(
|
||||||
registeredGroups: Record<string, RegisteredGroup>,
|
roomBindings: Record<string, RegisteredGroup>,
|
||||||
): AvailableGroup[] {
|
): AvailableGroup[] {
|
||||||
const chats = getAllChats();
|
const chats = getAllChats();
|
||||||
const registeredJids = new Set(Object.keys(registeredGroups));
|
const registeredJids = new Set(Object.keys(roomBindings));
|
||||||
|
|
||||||
return chats
|
return chats
|
||||||
.filter((chat) => chat.jid !== '__group_sync__' && chat.is_group)
|
.filter((chat) => chat.jid !== '__group_sync__' && chat.is_group)
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ function createTestOpts(
|
|||||||
return {
|
return {
|
||||||
onMessage: vi.fn(),
|
onMessage: vi.fn(),
|
||||||
onChatMetadata: vi.fn(),
|
onChatMetadata: vi.fn(),
|
||||||
registeredGroups: vi.fn(() => ({
|
roomBindings: vi.fn(() => ({
|
||||||
'dc:1234567890123456': {
|
'dc:1234567890123456': {
|
||||||
name: 'Test Server #general',
|
name: 'Test Server #general',
|
||||||
folder: 'test-server',
|
folder: 'test-server',
|
||||||
@@ -441,7 +441,7 @@ describe('DiscordChannel', () => {
|
|||||||
|
|
||||||
it('uses sender name for DM chats (no guild)', async () => {
|
it('uses sender name for DM chats (no guild)', async () => {
|
||||||
const opts = createTestOpts({
|
const opts = createTestOpts({
|
||||||
registeredGroups: vi.fn(() => ({
|
roomBindings: vi.fn(() => ({
|
||||||
'dc:1234567890123456': {
|
'dc:1234567890123456': {
|
||||||
name: 'DM',
|
name: 'DM',
|
||||||
folder: 'dm',
|
folder: 'dm',
|
||||||
@@ -491,10 +491,10 @@ describe('DiscordChannel', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- @mention translation ---
|
// --- bot mention handling ---
|
||||||
|
|
||||||
describe('@mention translation', () => {
|
describe('bot mention handling', () => {
|
||||||
it('translates <@botId> mention to trigger format', async () => {
|
it('passes through <@botId> mentions without rewriting them', async () => {
|
||||||
const opts = createTestOpts();
|
const opts = createTestOpts();
|
||||||
const channel = new DiscordChannel('test-token', opts);
|
const channel = new DiscordChannel('test-token', opts);
|
||||||
await channel.connect();
|
await channel.connect();
|
||||||
@@ -509,12 +509,12 @@ describe('DiscordChannel', () => {
|
|||||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||||
'dc:1234567890123456',
|
'dc:1234567890123456',
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
content: '@Andy what time is it?',
|
content: '<@999888777> what time is it?',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not translate if message already matches trigger', async () => {
|
it('leaves mixed text and mentions untouched', async () => {
|
||||||
const opts = createTestOpts();
|
const opts = createTestOpts();
|
||||||
const channel = new DiscordChannel('test-token', opts);
|
const channel = new DiscordChannel('test-token', opts);
|
||||||
await channel.connect();
|
await channel.connect();
|
||||||
@@ -531,7 +531,7 @@ describe('DiscordChannel', () => {
|
|||||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||||
'dc:1234567890123456',
|
'dc:1234567890123456',
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
content: '@Andy hello',
|
content: '@Andy hello <@999888777>',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -555,7 +555,7 @@ describe('DiscordChannel', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles <@!botId> (nickname mention format)', async () => {
|
it('passes through <@!botId> nickname mentions without rewriting them', async () => {
|
||||||
const opts = createTestOpts();
|
const opts = createTestOpts();
|
||||||
const channel = new DiscordChannel('test-token', opts);
|
const channel = new DiscordChannel('test-token', opts);
|
||||||
await channel.connect();
|
await channel.connect();
|
||||||
@@ -570,7 +570,7 @@ describe('DiscordChannel', () => {
|
|||||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||||
'dc:1234567890123456',
|
'dc:1234567890123456',
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
content: '@Andy check this',
|
content: '<@!999888777> check this',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ import {
|
|||||||
export interface DiscordChannelOpts {
|
export interface DiscordChannelOpts {
|
||||||
onMessage: OnInboundMessage;
|
onMessage: OnInboundMessage;
|
||||||
onChatMetadata: OnChatMetadata;
|
onChatMetadata: OnChatMetadata;
|
||||||
registeredGroups: () => Record<string, RegisteredGroup>;
|
roomBindings: () => Record<string, RegisteredGroup>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class DiscordChannel implements Channel {
|
export class DiscordChannel implements Channel {
|
||||||
@@ -279,29 +279,6 @@ export class DiscordChannel implements Channel {
|
|||||||
chatName = senderName;
|
chatName = senderName;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Translate Discord @bot mentions into TRIGGER_PATTERN format.
|
|
||||||
// Discord mentions look like <@botUserId> — these won't match
|
|
||||||
// TRIGGER_PATTERN (e.g., ^@Andy\b), so we prepend the trigger
|
|
||||||
// when the bot is @mentioned.
|
|
||||||
if (this.client?.user) {
|
|
||||||
const botId = this.client.user.id;
|
|
||||||
const isBotMentioned =
|
|
||||||
message.mentions.users.has(botId) ||
|
|
||||||
content.includes(`<@${botId}>`) ||
|
|
||||||
content.includes(`<@!${botId}>`);
|
|
||||||
|
|
||||||
if (isBotMentioned) {
|
|
||||||
// Strip the <@botId> mention to avoid visual clutter
|
|
||||||
content = content
|
|
||||||
.replace(new RegExp(`<@!?${botId}>`, 'g'), '')
|
|
||||||
.trim();
|
|
||||||
// Prepend trigger if not already present
|
|
||||||
if (!TRIGGER_PATTERN.test(content)) {
|
|
||||||
content = `@${ASSISTANT_NAME} ${content}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle attachments — transcribe voice messages, download files
|
// Handle attachments — transcribe voice messages, download files
|
||||||
const isVoiceMessage = message.flags.has(MessageFlags.IsVoiceMessage);
|
const isVoiceMessage = message.flags.has(MessageFlags.IsVoiceMessage);
|
||||||
if (message.attachments.size > 0) {
|
if (message.attachments.size > 0) {
|
||||||
@@ -403,7 +380,7 @@ export class DiscordChannel implements Channel {
|
|||||||
|
|
||||||
// Only deliver full message for registered groups. Secondary role bots
|
// Only deliver full message for registered groups. Secondary role bots
|
||||||
// are configured as outbound-only, while the owner bot receives inbound.
|
// are configured as outbound-only, while the owner bot receives inbound.
|
||||||
const group = this.opts.registeredGroups()[chatJid];
|
const group = this.opts.roomBindings()[chatJid];
|
||||||
if (!group) {
|
if (!group) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
{ chatJid, chatName },
|
{ chatJid, chatName },
|
||||||
@@ -627,7 +604,7 @@ export class DiscordChannel implements Channel {
|
|||||||
if (!jid.startsWith('dc:')) return false;
|
if (!jid.startsWith('dc:')) return false;
|
||||||
if (!this.ownsDiscordJids) return false;
|
if (!this.ownsDiscordJids) return false;
|
||||||
if (!this.agentTypeFilter) return true;
|
if (!this.agentTypeFilter) return true;
|
||||||
const group = this.opts.registeredGroups()[jid];
|
const group = this.opts.roomBindings()[jid];
|
||||||
if (!group) return false;
|
if (!group) return false;
|
||||||
const groupType = group.agentType || 'claude-code';
|
const groupType = group.agentType || 'claude-code';
|
||||||
return groupType === this.agentTypeFilter;
|
return groupType === this.agentTypeFilter;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
export interface ChannelOpts {
|
export interface ChannelOpts {
|
||||||
onMessage: OnInboundMessage;
|
onMessage: OnInboundMessage;
|
||||||
onChatMetadata: OnChatMetadata;
|
onChatMetadata: OnChatMetadata;
|
||||||
registeredGroups: () => Record<string, RegisteredGroup>;
|
roomBindings: () => Record<string, RegisteredGroup>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ChannelFactory = (opts: ChannelOpts) => Channel | null;
|
export type ChannelFactory = (opts: ChannelOpts) => Channel | null;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export interface DashboardOptions {
|
|||||||
channels: Channel[];
|
channels: Channel[];
|
||||||
getSessions: () => Record<string, string>;
|
getSessions: () => Record<string, string>;
|
||||||
queue: GroupQueue;
|
queue: GroupQueue;
|
||||||
registeredGroups: () => Record<string, RegisteredGroup>;
|
roomBindings: () => Record<string, RegisteredGroup>;
|
||||||
statusChannelId: string;
|
statusChannelId: string;
|
||||||
statusUpdateInterval: number;
|
statusUpdateInterval: number;
|
||||||
usageUpdateInterval: number;
|
usageUpdateInterval: number;
|
||||||
@@ -30,7 +30,7 @@ export function buildStatusContent(opts: DashboardOptions): string {
|
|||||||
if (!STATUS_SHOW_ROOMS) return '';
|
if (!STATUS_SHOW_ROOMS) return '';
|
||||||
|
|
||||||
const sessions = opts.getSessions();
|
const sessions = opts.getSessions();
|
||||||
const groups = opts.registeredGroups();
|
const groups = opts.roomBindings();
|
||||||
const statuses = opts.queue.getStatuses(Object.keys(groups));
|
const statuses = opts.queue.getStatuses(Object.keys(groups));
|
||||||
|
|
||||||
let totalActive = 0;
|
let totalActive = 0;
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ function makeOptions(sessionId?: string): DashboardOptions {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
} as unknown as DashboardOptions['queue'],
|
} as unknown as DashboardOptions['queue'],
|
||||||
registeredGroups: () => ({
|
roomBindings: () => ({
|
||||||
'dc:123': {
|
'dc:123': {
|
||||||
name: 'clone-test',
|
name: 'clone-test',
|
||||||
folder: 'group-folder',
|
folder: 'group-folder',
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export async function startStatusDashboard(
|
|||||||
usageUpdateInterval: opts.usageUpdateInterval,
|
usageUpdateInterval: opts.usageUpdateInterval,
|
||||||
channels: opts.channels,
|
channels: opts.channels,
|
||||||
queue: opts.queue,
|
queue: opts.queue,
|
||||||
registeredGroups: opts.registeredGroups,
|
roomBindings: opts.roomBindings,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
160
src/db.test.ts
160
src/db.test.ts
@@ -25,7 +25,7 @@ import {
|
|||||||
deleteTask,
|
deleteTask,
|
||||||
getAllChats,
|
getAllChats,
|
||||||
getChannelOwnerLease,
|
getChannelOwnerLease,
|
||||||
getAllRegisteredGroups,
|
getAllRoomBindings,
|
||||||
getDueTasks,
|
getDueTasks,
|
||||||
getEffectiveRoomMode,
|
getEffectiveRoomMode,
|
||||||
getEffectiveRuntimeRoomMode,
|
getEffectiveRuntimeRoomMode,
|
||||||
@@ -1870,7 +1870,7 @@ describe('registered group isMain', () => {
|
|||||||
isMain: true,
|
isMain: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const groups = getAllRegisteredGroups();
|
const groups = getAllRoomBindings();
|
||||||
const group = groups['dc:main'];
|
const group = groups['dc:main'];
|
||||||
expect(group).toBeDefined();
|
expect(group).toBeDefined();
|
||||||
expect(group.isMain).toBe(true);
|
expect(group.isMain).toBe(true);
|
||||||
@@ -1885,7 +1885,7 @@ describe('registered group isMain', () => {
|
|||||||
added_at: '2024-01-01T00:00:00.000Z',
|
added_at: '2024-01-01T00:00:00.000Z',
|
||||||
});
|
});
|
||||||
|
|
||||||
const groups = getAllRegisteredGroups();
|
const groups = getAllRoomBindings();
|
||||||
const group = groups['group@g.us'];
|
const group = groups['group@g.us'];
|
||||||
expect(group).toBeDefined();
|
expect(group).toBeDefined();
|
||||||
expect(group.isMain).toBeUndefined();
|
expect(group.isMain).toBeUndefined();
|
||||||
@@ -1907,8 +1907,8 @@ describe('registered group isMain', () => {
|
|||||||
agentType: 'codex',
|
agentType: 'codex',
|
||||||
});
|
});
|
||||||
|
|
||||||
const claudeGroups = getAllRegisteredGroups('claude-code');
|
const claudeGroups = getAllRoomBindings('claude-code');
|
||||||
const codexGroups = getAllRegisteredGroups('codex');
|
const codexGroups = getAllRoomBindings('codex');
|
||||||
|
|
||||||
expect(claudeGroups['dc:shared']?.agentType).toBe('claude-code');
|
expect(claudeGroups['dc:shared']?.agentType).toBe('claude-code');
|
||||||
expect(claudeGroups['dc:shared']?.name).toBe('Shared Room');
|
expect(claudeGroups['dc:shared']?.name).toBe('Shared Room');
|
||||||
@@ -1946,9 +1946,9 @@ describe('room assignment writes', () => {
|
|||||||
folder: 'assigned-room',
|
folder: 'assigned-room',
|
||||||
});
|
});
|
||||||
|
|
||||||
const allGroups = getAllRegisteredGroups();
|
const allGroups = getAllRoomBindings();
|
||||||
const claudeGroups = getAllRegisteredGroups('claude-code');
|
const claudeGroups = getAllRoomBindings('claude-code');
|
||||||
const codexGroups = getAllRegisteredGroups('codex');
|
const codexGroups = getAllRoomBindings('codex');
|
||||||
|
|
||||||
expect(allGroups['dc:assigned-room']).toMatchObject({
|
expect(allGroups['dc:assigned-room']).toMatchObject({
|
||||||
name: 'Assigned Room',
|
name: 'Assigned Room',
|
||||||
@@ -1984,19 +1984,17 @@ describe('room assignment writes', () => {
|
|||||||
ownerAgentType: 'codex',
|
ownerAgentType: 'codex',
|
||||||
});
|
});
|
||||||
expect(
|
expect(
|
||||||
getAllRegisteredGroups('claude-code')['dc:projection-room'],
|
getAllRoomBindings('claude-code')['dc:projection-room'],
|
||||||
).toMatchObject({
|
).toMatchObject({
|
||||||
name: 'Projection Room Renamed',
|
name: 'Projection Room Renamed',
|
||||||
folder: 'projection-room',
|
folder: 'projection-room',
|
||||||
agentType: 'claude-code',
|
agentType: 'claude-code',
|
||||||
});
|
});
|
||||||
expect(getAllRegisteredGroups('codex')['dc:projection-room']).toMatchObject(
|
expect(getAllRoomBindings('codex')['dc:projection-room']).toMatchObject({
|
||||||
{
|
|
||||||
name: 'Projection Room Renamed',
|
name: 'Projection Room Renamed',
|
||||||
folder: 'projection-room',
|
folder: 'projection-room',
|
||||||
agentType: 'codex',
|
agentType: 'codex',
|
||||||
},
|
});
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not carry an owner override config onto a different owner agent type', () => {
|
it('does not carry an owner override config onto a different owner agent type', () => {
|
||||||
@@ -2017,19 +2015,17 @@ describe('room assignment writes', () => {
|
|||||||
folder: 'owner-switch',
|
folder: 'owner-switch',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(
|
expect(getAllRoomBindings('claude-code')['dc:owner-switch']).toMatchObject({
|
||||||
getAllRegisteredGroups('claude-code')['dc:owner-switch'],
|
|
||||||
).toMatchObject({
|
|
||||||
agentType: 'claude-code',
|
agentType: 'claude-code',
|
||||||
});
|
});
|
||||||
expect(
|
expect(
|
||||||
getAllRegisteredGroups('claude-code')['dc:owner-switch']?.agentConfig,
|
getAllRoomBindings('claude-code')['dc:owner-switch']?.agentConfig,
|
||||||
).toBeUndefined();
|
).toBeUndefined();
|
||||||
expect(getAllRegisteredGroups('codex')['dc:owner-switch']).toMatchObject({
|
expect(getAllRoomBindings('codex')['dc:owner-switch']).toMatchObject({
|
||||||
agentType: 'codex',
|
agentType: 'codex',
|
||||||
});
|
});
|
||||||
expect(
|
expect(
|
||||||
getAllRegisteredGroups('codex')['dc:owner-switch']?.agentConfig,
|
getAllRoomBindings('codex')['dc:owner-switch']?.agentConfig,
|
||||||
).toBeUndefined();
|
).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2057,14 +2053,14 @@ describe('room assignment writes', () => {
|
|||||||
|
|
||||||
expect(projectionRows).toEqual([]);
|
expect(projectionRows).toEqual([]);
|
||||||
expect(
|
expect(
|
||||||
getAllRegisteredGroups('claude-code')['dc:assign-no-projection'],
|
getAllRoomBindings('claude-code')['dc:assign-no-projection'],
|
||||||
).toMatchObject({
|
).toMatchObject({
|
||||||
name: 'Assign No Projection',
|
name: 'Assign No Projection',
|
||||||
folder: 'assign-no-projection',
|
folder: 'assign-no-projection',
|
||||||
agentType: 'claude-code',
|
agentType: 'claude-code',
|
||||||
});
|
});
|
||||||
expect(
|
expect(
|
||||||
getAllRegisteredGroups('codex')['dc:assign-no-projection'],
|
getAllRoomBindings('codex')['dc:assign-no-projection'],
|
||||||
).toMatchObject({
|
).toMatchObject({
|
||||||
name: 'Assign No Projection',
|
name: 'Assign No Projection',
|
||||||
folder: 'assign-no-projection',
|
folder: 'assign-no-projection',
|
||||||
@@ -2072,7 +2068,7 @@ describe('room assignment writes', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('recreates inferred room_settings when renaming a legacy projection-only room', () => {
|
it('does not recreate inferred room_settings when renaming a legacy projection-only room', () => {
|
||||||
_setRegisteredGroupForTests('dc:legacy-rename', {
|
_setRegisteredGroupForTests('dc:legacy-rename', {
|
||||||
name: 'Legacy Rename',
|
name: 'Legacy Rename',
|
||||||
folder: 'legacy-rename',
|
folder: 'legacy-rename',
|
||||||
@@ -2093,23 +2089,12 @@ describe('room assignment writes', () => {
|
|||||||
|
|
||||||
updateRegisteredGroupName('dc:legacy-rename', 'Legacy Rename Updated');
|
updateRegisteredGroupName('dc:legacy-rename', 'Legacy Rename Updated');
|
||||||
|
|
||||||
expect(getStoredRoomSettings('dc:legacy-rename')).toMatchObject({
|
expect(getStoredRoomSettings('dc:legacy-rename')).toBeUndefined();
|
||||||
chatJid: 'dc:legacy-rename',
|
expect(getRegisteredGroup('dc:legacy-rename')).toBeUndefined();
|
||||||
roomMode: 'tribunal',
|
|
||||||
modeSource: 'inferred',
|
|
||||||
name: 'Legacy Rename Updated',
|
|
||||||
folder: 'legacy-rename',
|
|
||||||
});
|
|
||||||
expect(
|
expect(
|
||||||
getAllRegisteredGroups('claude-code')['dc:legacy-rename'],
|
getAllRoomBindings('claude-code')['dc:legacy-rename'],
|
||||||
).toMatchObject({
|
).toBeUndefined();
|
||||||
name: 'Legacy Rename Updated',
|
expect(getAllRoomBindings('codex')['dc:legacy-rename']).toBeUndefined();
|
||||||
agentType: 'claude-code',
|
|
||||||
});
|
|
||||||
expect(getAllRegisteredGroups('codex')['dc:legacy-rename']).toMatchObject({
|
|
||||||
name: 'Legacy Rename Updated',
|
|
||||||
agentType: 'codex',
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('requires explicit migration before init when only legacy registered_groups rows exist', () => {
|
it('requires explicit migration before init when only legacy registered_groups rows exist', () => {
|
||||||
@@ -2169,6 +2154,36 @@ describe('room assignment writes', () => {
|
|||||||
expect(() => _initTestDatabaseFromFile(dbPath)).toThrow(
|
expect(() => _initTestDatabaseFromFile(dbPath)).toThrow(
|
||||||
/Legacy room migration required before startup/,
|
/Legacy room migration required before startup/,
|
||||||
);
|
);
|
||||||
|
const rawDbBeforeMigration = new Database(dbPath, { readonly: true });
|
||||||
|
expect(
|
||||||
|
rawDbBeforeMigration
|
||||||
|
.prepare(
|
||||||
|
`SELECT COUNT(*) as count
|
||||||
|
FROM room_settings`,
|
||||||
|
)
|
||||||
|
.get(),
|
||||||
|
).toEqual({ count: 0 });
|
||||||
|
expect(
|
||||||
|
rawDbBeforeMigration
|
||||||
|
.prepare(
|
||||||
|
`SELECT COUNT(*) as count
|
||||||
|
FROM room_role_overrides`,
|
||||||
|
)
|
||||||
|
.get(),
|
||||||
|
).toEqual({ count: 0 });
|
||||||
|
expect(
|
||||||
|
rawDbBeforeMigration
|
||||||
|
.prepare(
|
||||||
|
`SELECT jid, agent_type
|
||||||
|
FROM registered_groups
|
||||||
|
ORDER BY jid, agent_type`,
|
||||||
|
)
|
||||||
|
.all(),
|
||||||
|
).toEqual([
|
||||||
|
{ jid: 'dc:legacy-sql', agent_type: 'claude-code' },
|
||||||
|
{ jid: 'dc:legacy-sql', agent_type: 'codex' },
|
||||||
|
]);
|
||||||
|
rawDbBeforeMigration.close();
|
||||||
expect(migrateLegacyRoomRegistrationsInFile(dbPath)).toEqual({
|
expect(migrateLegacyRoomRegistrationsInFile(dbPath)).toEqual({
|
||||||
migratedRooms: 1,
|
migratedRooms: 1,
|
||||||
migratedRoleOverrides: 2,
|
migratedRoleOverrides: 2,
|
||||||
@@ -2546,9 +2561,7 @@ describe('room assignment writes', () => {
|
|||||||
agentType: 'codex',
|
agentType: 'codex',
|
||||||
});
|
});
|
||||||
expect(getRegisteredGroup('dc:ssot-room', 'claude-code')).toBeUndefined();
|
expect(getRegisteredGroup('dc:ssot-room', 'claude-code')).toBeUndefined();
|
||||||
expect(
|
expect(getAllRoomBindings('claude-code')['dc:ssot-room']).toBeUndefined();
|
||||||
getAllRegisteredGroups('claude-code')['dc:ssot-room'],
|
|
||||||
).toBeUndefined();
|
|
||||||
expect(getRegisteredAgentTypesForJid('dc:ssot-room')).toEqual(['codex']);
|
expect(getRegisteredAgentTypesForJid('dc:ssot-room')).toEqual(['codex']);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2712,45 +2725,55 @@ describe('room assignment writes', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('paired room registration', () => {
|
describe('paired room registration', () => {
|
||||||
it('detects when both Claude and Codex are registered on the same jid', () => {
|
it('detects paired capability types from canonical tribunal room settings', () => {
|
||||||
_setRegisteredGroupForTests('dc:123', {
|
assignRoom('dc:123', {
|
||||||
name: 'Paired Room',
|
name: 'Paired Room',
|
||||||
|
roomMode: 'tribunal',
|
||||||
|
ownerAgentType: 'codex',
|
||||||
folder: 'paired-room',
|
folder: 'paired-room',
|
||||||
trigger: '@Andy',
|
|
||||||
added_at: '2024-01-01T00:00:00.000Z',
|
|
||||||
agentType: 'claude-code',
|
|
||||||
});
|
|
||||||
_setRegisteredGroupForTests('dc:123', {
|
|
||||||
name: 'Paired Room',
|
|
||||||
folder: 'paired-room',
|
|
||||||
trigger: '@Codex',
|
|
||||||
added_at: '2024-01-01T00:00:00.000Z',
|
|
||||||
agentType: 'codex',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(getRegisteredAgentTypesForJid('dc:123').sort()).toEqual([
|
expect(getRegisteredAgentTypesForJid('dc:123').sort()).toEqual([
|
||||||
'claude-code',
|
'claude-code',
|
||||||
'codex',
|
'codex',
|
||||||
]);
|
]);
|
||||||
expect(getExplicitRoomMode('dc:123')).toBeUndefined();
|
expect(getExplicitRoomMode('dc:123')).toBe('tribunal');
|
||||||
expect(getEffectiveRoomMode('dc:123')).toBe('tribunal');
|
expect(getEffectiveRoomMode('dc:123')).toBe('tribunal');
|
||||||
expect(getEffectiveRuntimeRoomMode('dc:123')).toBe('tribunal');
|
expect(getEffectiveRuntimeRoomMode('dc:123')).toBe('tribunal');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not mark solo rooms as paired', () => {
|
it('does not mark canonical single rooms as paired', () => {
|
||||||
_setRegisteredGroupForTests('dc:solo', {
|
assignRoom('dc:solo', {
|
||||||
name: 'Solo Claude Room',
|
name: 'Solo Claude Room',
|
||||||
|
roomMode: 'single',
|
||||||
|
ownerAgentType: 'claude-code',
|
||||||
folder: 'solo-claude',
|
folder: 'solo-claude',
|
||||||
trigger: '@Andy',
|
|
||||||
added_at: '2024-01-01T00:00:00.000Z',
|
|
||||||
agentType: 'claude-code',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(getRegisteredAgentTypesForJid('dc:solo')).toEqual(['claude-code']);
|
expect(getRegisteredAgentTypesForJid('dc:solo')).toEqual(['claude-code']);
|
||||||
expect(getEffectiveRuntimeRoomMode('dc:solo')).toBe('single');
|
expect(getEffectiveRuntimeRoomMode('dc:solo')).toBe('single');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps inferred room mode available when no explicit override exists', () => {
|
it('keeps canonical inferred room mode available when no explicit override exists', () => {
|
||||||
|
assignRoom('dc:canonical-inferred-paired', {
|
||||||
|
name: 'Canonical Inferred Paired',
|
||||||
|
roomMode: 'tribunal',
|
||||||
|
ownerAgentType: 'codex',
|
||||||
|
folder: 'canonical-inferred-paired',
|
||||||
|
});
|
||||||
|
|
||||||
|
clearExplicitRoomMode('dc:canonical-inferred-paired');
|
||||||
|
|
||||||
|
expect(getExplicitRoomMode('dc:canonical-inferred-paired')).toBeUndefined();
|
||||||
|
expect(getEffectiveRoomMode('dc:canonical-inferred-paired')).toBe(
|
||||||
|
'tribunal',
|
||||||
|
);
|
||||||
|
expect(getEffectiveRuntimeRoomMode('dc:canonical-inferred-paired')).toBe(
|
||||||
|
'tribunal',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores legacy capability rows when canonical room settings are missing', () => {
|
||||||
_setRegisteredGroupForTests('dc:legacy-paired', {
|
_setRegisteredGroupForTests('dc:legacy-paired', {
|
||||||
name: 'Legacy Paired',
|
name: 'Legacy Paired',
|
||||||
folder: 'legacy-paired',
|
folder: 'legacy-paired',
|
||||||
@@ -2765,10 +2788,12 @@ describe('paired room registration', () => {
|
|||||||
added_at: '2024-01-01T00:00:00.000Z',
|
added_at: '2024-01-01T00:00:00.000Z',
|
||||||
agentType: 'codex',
|
agentType: 'codex',
|
||||||
});
|
});
|
||||||
|
_deleteStoredRoomSettingsForTests('dc:legacy-paired');
|
||||||
|
|
||||||
|
expect(getRegisteredAgentTypesForJid('dc:legacy-paired')).toEqual([]);
|
||||||
expect(getExplicitRoomMode('dc:legacy-paired')).toBeUndefined();
|
expect(getExplicitRoomMode('dc:legacy-paired')).toBeUndefined();
|
||||||
expect(getEffectiveRoomMode('dc:legacy-paired')).toBe('tribunal');
|
expect(getEffectiveRoomMode('dc:legacy-paired')).toBe('single');
|
||||||
expect(getEffectiveRuntimeRoomMode('dc:legacy-paired')).toBe('tribunal');
|
expect(getEffectiveRuntimeRoomMode('dc:legacy-paired')).toBe('single');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps room-level metadata synced on setRegisteredGroup helper writes', () => {
|
it('keeps room-level metadata synced on setRegisteredGroup helper writes', () => {
|
||||||
@@ -2901,13 +2926,12 @@ describe('paired room registration', () => {
|
|||||||
expect(getEffectiveRuntimeRoomMode('dc:explicit-tribunal')).toBe('single');
|
expect(getEffectiveRuntimeRoomMode('dc:explicit-tribunal')).toBe('single');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('trusts stored tribunal mode even when legacy capability rows are incomplete', () => {
|
it('trusts stored tribunal mode without projection rows', () => {
|
||||||
_setRegisteredGroupForTests('dc:explicit-tribunal-codex', {
|
assignRoom('dc:explicit-tribunal-codex', {
|
||||||
name: 'Explicit Tribunal Codex',
|
name: 'Explicit Tribunal Codex',
|
||||||
|
roomMode: 'single',
|
||||||
|
ownerAgentType: 'codex',
|
||||||
folder: 'explicit-tribunal-codex',
|
folder: 'explicit-tribunal-codex',
|
||||||
trigger: '@Codex',
|
|
||||||
added_at: '2024-01-01T00:00:00.000Z',
|
|
||||||
agentType: 'codex',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
setExplicitRoomMode('dc:explicit-tribunal-codex', 'tribunal');
|
setExplicitRoomMode('dc:explicit-tribunal-codex', 'tribunal');
|
||||||
@@ -3067,8 +3091,6 @@ describe('service handoff completion', () => {
|
|||||||
roomMode: 'tribunal',
|
roomMode: 'tribunal',
|
||||||
ownerAgentType: 'codex',
|
ownerAgentType: 'codex',
|
||||||
folder: 'owner-handoff-shadow',
|
folder: 'owner-handoff-shadow',
|
||||||
trigger: '@Owner',
|
|
||||||
requiresTrigger: true,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const handoff = createServiceHandoff({
|
const handoff = createServiceHandoff({
|
||||||
|
|||||||
100
src/db.ts
100
src/db.ts
@@ -3,7 +3,6 @@ import fs from 'fs';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ASSISTANT_NAME,
|
|
||||||
ARBITER_AGENT_TYPE,
|
ARBITER_AGENT_TYPE,
|
||||||
CLAUDE_SERVICE_ID,
|
CLAUDE_SERVICE_ID,
|
||||||
CODEX_MAIN_SERVICE_ID,
|
CODEX_MAIN_SERVICE_ID,
|
||||||
@@ -11,7 +10,6 @@ import {
|
|||||||
DATA_DIR,
|
DATA_DIR,
|
||||||
normalizeServiceId,
|
normalizeServiceId,
|
||||||
OWNER_AGENT_TYPE,
|
OWNER_AGENT_TYPE,
|
||||||
REVIEWER_AGENT_TYPE,
|
|
||||||
SERVICE_ID,
|
SERVICE_ID,
|
||||||
SERVICE_SESSION_SCOPE,
|
SERVICE_SESSION_SCOPE,
|
||||||
} from './config.js';
|
} from './config.js';
|
||||||
@@ -31,7 +29,6 @@ import {
|
|||||||
buildLegacyRoomMigrationPlan,
|
buildLegacyRoomMigrationPlan,
|
||||||
collectRegisteredAgentTypes,
|
collectRegisteredAgentTypes,
|
||||||
collectRegisteredAgentTypesForFolder,
|
collectRegisteredAgentTypesForFolder,
|
||||||
collectRoomRegistrationSnapshot,
|
|
||||||
deleteLegacyRegisteredGroupRowsForJid,
|
deleteLegacyRegisteredGroupRowsForJid,
|
||||||
getPendingLegacyRegisteredGroupJids as getPendingLegacyRegisteredGroupJidsFromDatabase,
|
getPendingLegacyRegisteredGroupJids as getPendingLegacyRegisteredGroupJidsFromDatabase,
|
||||||
getStoredRoomRowsFromDatabase,
|
getStoredRoomRowsFromDatabase,
|
||||||
@@ -223,8 +220,6 @@ export interface AssignRoomInput {
|
|||||||
roomMode?: RoomMode;
|
roomMode?: RoomMode;
|
||||||
ownerAgentType?: AgentType;
|
ownerAgentType?: AgentType;
|
||||||
folder?: string;
|
folder?: string;
|
||||||
trigger?: string;
|
|
||||||
requiresTrigger?: boolean;
|
|
||||||
isMain?: boolean;
|
isMain?: boolean;
|
||||||
workDir?: string;
|
workDir?: string;
|
||||||
addedAt?: string;
|
addedAt?: string;
|
||||||
@@ -791,9 +786,9 @@ function writeLegacyRegisteredGroupAndSyncRoomSettings(
|
|||||||
triggerPattern:
|
triggerPattern:
|
||||||
existingStored?.modeSource === 'explicit' && existingStored.trigger
|
existingStored?.modeSource === 'explicit' && existingStored.trigger
|
||||||
? existingStored.trigger
|
? existingStored.trigger
|
||||||
: group.trigger,
|
: (group.trigger ?? ''),
|
||||||
requiresTrigger:
|
requiresTrigger:
|
||||||
group.requiresTrigger ?? existingStored?.requiresTrigger ?? true,
|
group.requiresTrigger ?? existingStored?.requiresTrigger ?? false,
|
||||||
isMain: group.isMain ?? existingStored?.isMain ?? false,
|
isMain: group.isMain ?? existingStored?.isMain ?? false,
|
||||||
ownerAgentType,
|
ownerAgentType,
|
||||||
workDir: group.workDir ?? existingStored?.workDir ?? null,
|
workDir: group.workDir ?? existingStored?.workDir ?? null,
|
||||||
@@ -885,8 +880,8 @@ export function assignRoom(
|
|||||||
const snapshot: RoomRegistrationSnapshot = {
|
const snapshot: RoomRegistrationSnapshot = {
|
||||||
name: input.name,
|
name: input.name,
|
||||||
folder,
|
folder,
|
||||||
triggerPattern: input.trigger || existing?.trigger || `@${ASSISTANT_NAME}`,
|
triggerPattern: existing?.trigger ?? '',
|
||||||
requiresTrigger: input.requiresTrigger ?? existing?.requiresTrigger ?? true,
|
requiresTrigger: existing?.requiresTrigger ?? false,
|
||||||
isMain: input.isMain ?? existing?.isMain ?? false,
|
isMain: input.isMain ?? existing?.isMain ?? false,
|
||||||
ownerAgentType,
|
ownerAgentType,
|
||||||
workDir: input.workDir ?? existing?.workDir ?? null,
|
workDir: input.workDir ?? existing?.workDir ?? null,
|
||||||
@@ -959,7 +954,7 @@ export function updateRegisteredGroupName(jid: string, name: string): void {
|
|||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAllRegisteredGroups(
|
export function getAllRoomBindings(
|
||||||
agentTypeFilter?: string,
|
agentTypeFilter?: string,
|
||||||
): Record<string, RegisteredGroup> {
|
): Record<string, RegisteredGroup> {
|
||||||
const result: Record<string, RegisteredGroup> = {};
|
const result: Record<string, RegisteredGroup> = {};
|
||||||
@@ -984,21 +979,7 @@ export function getAllRegisteredGroups(
|
|||||||
export function getRegisteredAgentTypesForJid(jid: string): AgentType[] {
|
export function getRegisteredAgentTypesForJid(jid: string): AgentType[] {
|
||||||
if (!db) return [];
|
if (!db) return [];
|
||||||
const stored = getStoredRoomSettingsRowFromDatabase(db, jid);
|
const stored = getStoredRoomSettingsRowFromDatabase(db, jid);
|
||||||
if (stored) {
|
return stored ? resolveStoredRoomCapabilityTypes(db, stored) : [];
|
||||||
return resolveStoredRoomCapabilityTypes(db, stored);
|
|
||||||
}
|
|
||||||
return collectRegisteredAgentTypes(db, jid);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Internal registration/backfill helper.
|
|
||||||
* This infers the stored room mode from current registrations and must not be
|
|
||||||
* treated as runtime source-of-truth by callers.
|
|
||||||
*/
|
|
||||||
function inferStoredRoomModeForJid(jid: string): RoomMode {
|
|
||||||
return inferRoomModeFromRegisteredAgentTypes(
|
|
||||||
getRegisteredAgentTypesForJid(jid),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getStoredRoomSettings(
|
export function getStoredRoomSettings(
|
||||||
@@ -1018,31 +999,6 @@ function getStoredRoomModeRow(chatJid: string): StoredRoomModeRow | undefined {
|
|||||||
: undefined;
|
: undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncStoredRoomRegistrationSnapshotForJid(chatJid: string): void {
|
|
||||||
const existingSettings = getStoredRoomSettingsRowFromDatabase(db, chatJid);
|
|
||||||
const snapshot = collectRoomRegistrationSnapshot(
|
|
||||||
db,
|
|
||||||
chatJid,
|
|
||||||
existingSettings,
|
|
||||||
);
|
|
||||||
if (!snapshot) return;
|
|
||||||
|
|
||||||
if (!existingSettings) {
|
|
||||||
insertStoredRoomSettings(
|
|
||||||
db,
|
|
||||||
chatJid,
|
|
||||||
inferRoomModeFromRegisteredAgentTypes(
|
|
||||||
getRegisteredAgentTypesForJid(chatJid),
|
|
||||||
),
|
|
||||||
'inferred',
|
|
||||||
snapshot,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateStoredRoomMetadata(db, chatJid, snapshot);
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertNoPendingLegacyRoomMigration(): void {
|
function assertNoPendingLegacyRoomMigration(): void {
|
||||||
const pendingLegacyRows = countPendingLegacyRegisteredGroupRows(db);
|
const pendingLegacyRows = countPendingLegacyRegisteredGroupRows(db);
|
||||||
const hasLegacyRegisteredGroupsJson = fs.existsSync(
|
const hasLegacyRegisteredGroupsJson = fs.existsSync(
|
||||||
@@ -1096,8 +1052,8 @@ function buildRoomRegistrationSnapshotFromStoredRoom(
|
|||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
folder: resolveAssignedRoomFolder(db, stored.chatJid, name, stored.folder),
|
folder: resolveAssignedRoomFolder(db, stored.chatJid, name, stored.folder),
|
||||||
triggerPattern: stored.trigger || `@${ASSISTANT_NAME}`,
|
triggerPattern: stored.trigger ?? '',
|
||||||
requiresTrigger: stored.requiresTrigger ?? true,
|
requiresTrigger: stored.requiresTrigger ?? false,
|
||||||
isMain: stored.isMain ?? false,
|
isMain: stored.isMain ?? false,
|
||||||
ownerAgentType,
|
ownerAgentType,
|
||||||
workDir: stored.workDir ?? null,
|
workDir: stored.workDir ?? null,
|
||||||
@@ -1123,19 +1079,7 @@ function buildRoomRegistrationPlanForJid(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const snapshot = collectRoomRegistrationSnapshot(db, chatJid);
|
return undefined;
|
||||||
if (!snapshot) return undefined;
|
|
||||||
|
|
||||||
return {
|
|
||||||
snapshot: {
|
|
||||||
...snapshot,
|
|
||||||
name: overrides?.name ?? snapshot.name,
|
|
||||||
},
|
|
||||||
roomMode: inferRoomModeFromRegisteredAgentTypes(
|
|
||||||
collectRegisteredAgentTypes(db, chatJid),
|
|
||||||
),
|
|
||||||
hasStoredRoom: false,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function upsertStoredRoomMode(
|
function upsertStoredRoomMode(
|
||||||
@@ -1157,10 +1101,6 @@ function upsertStoredRoomMode(
|
|||||||
).run(chatJid, roomMode, source, new Date().toISOString());
|
).run(chatJid, roomMode, source, new Date().toISOString());
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncInferredRoomModeForJid(chatJid: string): void {
|
|
||||||
upsertStoredRoomMode(chatJid, inferStoredRoomModeForJid(chatJid), 'inferred');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getExplicitRoomMode(chatJid: string): RoomMode | undefined {
|
export function getExplicitRoomMode(chatJid: string): RoomMode | undefined {
|
||||||
const row = getStoredRoomModeRow(chatJid);
|
const row = getStoredRoomModeRow(chatJid);
|
||||||
return row?.source === 'explicit' ? row.roomMode : undefined;
|
return row?.source === 'explicit' ? row.roomMode : undefined;
|
||||||
@@ -1193,28 +1133,8 @@ export function getEffectiveRoomMode(chatJid: string): RoomMode {
|
|||||||
return getStoredRoomModeRow(chatJid)?.roomMode ?? 'single';
|
return getStoredRoomModeRow(chatJid)?.roomMode ?? 'single';
|
||||||
}
|
}
|
||||||
|
|
||||||
function canRunTribunalFromRegisteredAgentTypes(
|
|
||||||
agentTypes: readonly AgentType[],
|
|
||||||
): boolean {
|
|
||||||
const types = new Set(agentTypes);
|
|
||||||
if (types.size === 0) return false;
|
|
||||||
return REVIEWER_AGENT_TYPE === 'claude-code'
|
|
||||||
? types.has('claude-code')
|
|
||||||
: types.has('codex');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getEffectiveRuntimeRoomMode(chatJid: string): RoomMode {
|
export function getEffectiveRuntimeRoomMode(chatJid: string): RoomMode {
|
||||||
const stored = getStoredRoomSettings(chatJid);
|
return getStoredRoomSettings(chatJid)?.roomMode ?? 'single';
|
||||||
if (stored) {
|
|
||||||
return stored.roomMode;
|
|
||||||
}
|
|
||||||
|
|
||||||
return inferStoredRoomModeForJid(chatJid) === 'tribunal' &&
|
|
||||||
canRunTribunalFromRegisteredAgentTypes(
|
|
||||||
getRegisteredAgentTypesForJid(chatJid),
|
|
||||||
)
|
|
||||||
? 'tribunal'
|
|
||||||
: 'single';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Paired task/project/workspace state ---
|
// --- Paired task/project/workspace state ---
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import { Database } from 'bun:sqlite';
|
import { Database } from 'bun:sqlite';
|
||||||
|
|
||||||
import {
|
import { OWNER_AGENT_TYPE, REVIEWER_AGENT_TYPE } from '../config.js';
|
||||||
ASSISTANT_NAME,
|
|
||||||
OWNER_AGENT_TYPE,
|
|
||||||
REVIEWER_AGENT_TYPE,
|
|
||||||
} from '../config.js';
|
|
||||||
import { isValidGroupFolder } from '../group-folder.js';
|
import { isValidGroupFolder } from '../group-folder.js';
|
||||||
import { logger } from '../logger.js';
|
import { logger } from '../logger.js';
|
||||||
import type { AgentType, RegisteredGroup, RoomMode } from '../types.js';
|
import type { AgentType, RegisteredGroup, RoomMode } from '../types.js';
|
||||||
@@ -86,6 +82,10 @@ export function normalizeStoredAgentType(
|
|||||||
: undefined;
|
: undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy capability-row helper for migration/write flows.
|
||||||
|
* Runtime read paths must derive capability types from canonical room tables.
|
||||||
|
*/
|
||||||
export function collectRegisteredAgentTypes(
|
export function collectRegisteredAgentTypes(
|
||||||
database: Database,
|
database: Database,
|
||||||
jid: string,
|
jid: string,
|
||||||
@@ -154,73 +154,14 @@ export function parseRegisteredGroupRow(
|
|||||||
jid: row.jid,
|
jid: row.jid,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
folder: row.folder,
|
folder: row.folder,
|
||||||
trigger: row.trigger_pattern,
|
|
||||||
added_at: row.added_at,
|
added_at: row.added_at,
|
||||||
agentConfig: row.agent_config ? JSON.parse(row.agent_config) : undefined,
|
agentConfig: row.agent_config ? JSON.parse(row.agent_config) : undefined,
|
||||||
requiresTrigger:
|
|
||||||
row.requires_trigger === null ? undefined : row.requires_trigger === 1,
|
|
||||||
isMain: row.is_main === 1 ? true : undefined,
|
isMain: row.is_main === 1 ? true : undefined,
|
||||||
agentType: normalizeStoredAgentType(row.agent_type),
|
agentType: normalizeStoredAgentType(row.agent_type),
|
||||||
workDir: row.work_dir || undefined,
|
workDir: row.work_dir || undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLegacyRegisteredGroupRows(
|
|
||||||
database: Database,
|
|
||||||
agentTypeFilter?: string,
|
|
||||||
): RegisteredGroupDatabaseRow[] {
|
|
||||||
return (
|
|
||||||
agentTypeFilter
|
|
||||||
? database
|
|
||||||
.prepare(
|
|
||||||
`SELECT *
|
|
||||||
FROM registered_groups
|
|
||||||
WHERE agent_type = ?
|
|
||||||
AND NOT EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM room_settings
|
|
||||||
WHERE chat_jid = registered_groups.jid
|
|
||||||
)`,
|
|
||||||
)
|
|
||||||
.all(agentTypeFilter)
|
|
||||||
: database
|
|
||||||
.prepare(
|
|
||||||
`SELECT *
|
|
||||||
FROM registered_groups
|
|
||||||
WHERE NOT EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM room_settings
|
|
||||||
WHERE chat_jid = registered_groups.jid
|
|
||||||
)`,
|
|
||||||
)
|
|
||||||
.all()
|
|
||||||
) as RegisteredGroupDatabaseRow[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getLegacyRegisteredGroup(
|
|
||||||
database: Database,
|
|
||||||
jid: string,
|
|
||||||
agentType?: string,
|
|
||||||
): (RegisteredGroup & { jid: string }) | undefined {
|
|
||||||
if (getStoredRoomSettingsRowFromDatabase(database, jid)) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const row = (
|
|
||||||
agentType
|
|
||||||
? database
|
|
||||||
.prepare(
|
|
||||||
'SELECT * FROM registered_groups WHERE jid = ? AND agent_type = ?',
|
|
||||||
)
|
|
||||||
.get(jid, agentType)
|
|
||||||
: database
|
|
||||||
.prepare('SELECT * FROM registered_groups WHERE jid = ?')
|
|
||||||
.get(jid)
|
|
||||||
) as RegisteredGroupDatabaseRow | undefined;
|
|
||||||
|
|
||||||
return parseRegisteredGroupRow(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getPendingLegacyRegisteredGroupJids(
|
export function getPendingLegacyRegisteredGroupJids(
|
||||||
database: Database,
|
database: Database,
|
||||||
): string[] {
|
): string[] {
|
||||||
@@ -605,10 +546,8 @@ export function buildRegisteredGroupFromStoredSettings(
|
|||||||
jid: stored.chatJid,
|
jid: stored.chatJid,
|
||||||
name: stored.name || stored.chatJid,
|
name: stored.name || stored.chatJid,
|
||||||
folder: stored.folder,
|
folder: stored.folder,
|
||||||
trigger: stored.trigger || `@${ASSISTANT_NAME}`,
|
|
||||||
added_at: capabilityMetadata?.createdAt || new Date(0).toISOString(),
|
added_at: capabilityMetadata?.createdAt || new Date(0).toISOString(),
|
||||||
agentConfig: capabilityMetadata?.agentConfig,
|
agentConfig: capabilityMetadata?.agentConfig,
|
||||||
requiresTrigger: stored.requiresTrigger,
|
|
||||||
isMain: stored.isMain ? true : undefined,
|
isMain: stored.isMain ? true : undefined,
|
||||||
agentType: resolvedAgentType,
|
agentType: resolvedAgentType,
|
||||||
workDir: stored.workDir,
|
workDir: stored.workDir,
|
||||||
@@ -855,6 +794,10 @@ export function materializeRegisteredGroupsForRoom(
|
|||||||
.run(chatJid, ...desiredTypes);
|
.run(chatJid, ...desiredTypes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy snapshot helper for migration/write flows.
|
||||||
|
* Runtime read paths must not rebuild room metadata from registered_groups.
|
||||||
|
*/
|
||||||
export function collectRoomRegistrationSnapshot(
|
export function collectRoomRegistrationSnapshot(
|
||||||
database: Database,
|
database: Database,
|
||||||
jid: string,
|
jid: string,
|
||||||
|
|||||||
@@ -2989,7 +2989,7 @@ export function applySchemaMigrations(
|
|||||||
WHERE status IN ('produced', 'delivery_retry');
|
WHERE status IN ('produced', 'delivery_retry');
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const registeredGroupsSql = (
|
const roomBindingsSql = (
|
||||||
database
|
database
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'registered_groups'`,
|
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'registered_groups'`,
|
||||||
@@ -2997,8 +2997,8 @@ export function applySchemaMigrations(
|
|||||||
.get() as { sql?: string } | undefined
|
.get() as { sql?: string } | undefined
|
||||||
)?.sql;
|
)?.sql;
|
||||||
if (
|
if (
|
||||||
registeredGroupsSql &&
|
roomBindingsSql &&
|
||||||
!registeredGroupsSql.includes('PRIMARY KEY (jid, agent_type)')
|
!roomBindingsSql.includes('PRIMARY KEY (jid, agent_type)')
|
||||||
) {
|
) {
|
||||||
const registeredGroupCols = database
|
const registeredGroupCols = database
|
||||||
.prepare('PRAGMA table_info(registered_groups)')
|
.prepare('PRAGMA table_info(registered_groups)')
|
||||||
|
|||||||
@@ -319,55 +319,3 @@ describe('formatOutbound with tool-call leaks', () => {
|
|||||||
expect(formatOutbound(input)).toBe('Key: [REDACTED]');
|
expect(formatOutbound(input)).toBe('Key: [REDACTED]');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Trigger gating with requiresTrigger flag ---
|
|
||||||
|
|
||||||
describe('trigger gating (requiresTrigger interaction)', () => {
|
|
||||||
// Replicates the exact logic from processGroupMessages and startMessageLoop:
|
|
||||||
// if (!isMainGroup && group.requiresTrigger !== false) { check trigger }
|
|
||||||
function shouldRequireTrigger(
|
|
||||||
isMainGroup: boolean,
|
|
||||||
requiresTrigger: boolean | undefined,
|
|
||||||
): boolean {
|
|
||||||
return !isMainGroup && requiresTrigger !== false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldProcess(
|
|
||||||
isMainGroup: boolean,
|
|
||||||
requiresTrigger: boolean | undefined,
|
|
||||||
messages: NewMessage[],
|
|
||||||
): boolean {
|
|
||||||
if (!shouldRequireTrigger(isMainGroup, requiresTrigger)) return true;
|
|
||||||
return messages.some((m) => TRIGGER_PATTERN.test(m.content.trim()));
|
|
||||||
}
|
|
||||||
|
|
||||||
it('main group always processes (no trigger needed)', () => {
|
|
||||||
const msgs = [makeMsg({ content: 'hello no trigger' })];
|
|
||||||
expect(shouldProcess(true, undefined, msgs)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('main group processes even with requiresTrigger=true', () => {
|
|
||||||
const msgs = [makeMsg({ content: 'hello no trigger' })];
|
|
||||||
expect(shouldProcess(true, true, msgs)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('non-main group with requiresTrigger=undefined requires trigger (defaults to true)', () => {
|
|
||||||
const msgs = [makeMsg({ content: 'hello no trigger' })];
|
|
||||||
expect(shouldProcess(false, undefined, msgs)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('non-main group with requiresTrigger=true requires trigger', () => {
|
|
||||||
const msgs = [makeMsg({ content: 'hello no trigger' })];
|
|
||||||
expect(shouldProcess(false, true, msgs)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('non-main group with requiresTrigger=true processes when trigger present', () => {
|
|
||||||
const msgs = [makeMsg({ content: `@${ASSISTANT_NAME} do something` })];
|
|
||||||
expect(shouldProcess(false, true, msgs)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('non-main group with requiresTrigger=false always processes (no trigger needed)', () => {
|
|
||||||
const msgs = [makeMsg({ content: 'hello no trigger' })];
|
|
||||||
expect(shouldProcess(false, false, msgs)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ describe('host evidence IPC', () => {
|
|||||||
|
|
||||||
deps = {
|
deps = {
|
||||||
sendMessage: async () => {},
|
sendMessage: async () => {},
|
||||||
registeredGroups: () => ({ 'other@g.us': OTHER_GROUP }),
|
roomBindings: () => ({ 'other@g.us': OTHER_GROUP }),
|
||||||
assignRoom: () => {},
|
assignRoom: () => {},
|
||||||
syncGroups: async () => {},
|
syncGroups: async () => {},
|
||||||
getAvailableGroups: () => [],
|
getAvailableGroups: () => [],
|
||||||
|
|||||||
48
src/index.ts
48
src/index.ts
@@ -24,7 +24,7 @@ import { listAvailableGroups } from './available-groups.js';
|
|||||||
import {
|
import {
|
||||||
type AssignRoomInput,
|
type AssignRoomInput,
|
||||||
assignRoom,
|
assignRoom,
|
||||||
getAllRegisteredGroups,
|
getAllRoomBindings,
|
||||||
getAllSessions,
|
getAllSessions,
|
||||||
getAllTasks,
|
getAllTasks,
|
||||||
getLatestMessageSeqAtOrBefore,
|
getLatestMessageSeqAtOrBefore,
|
||||||
@@ -139,7 +139,7 @@ export async function editFormattedTrackedChannelMessage(
|
|||||||
|
|
||||||
let lastTimestamp = '';
|
let lastTimestamp = '';
|
||||||
let sessions: Record<string, string> = {};
|
let sessions: Record<string, string> = {};
|
||||||
let registeredGroups: Record<string, RegisteredGroup> = {};
|
let roomBindings: Record<string, RegisteredGroup> = {};
|
||||||
let lastAgentTimestamp: Record<string, string> = {};
|
let lastAgentTimestamp: Record<string, string> = {};
|
||||||
|
|
||||||
const channels: Channel[] = [];
|
const channels: Channel[] = [];
|
||||||
@@ -152,7 +152,7 @@ const runtime = createMessageRuntime({
|
|||||||
triggerPattern: TRIGGER_PATTERN,
|
triggerPattern: TRIGGER_PATTERN,
|
||||||
channels,
|
channels,
|
||||||
queue,
|
queue,
|
||||||
getRegisteredGroups: () => registeredGroups,
|
getRoomBindings: () => roomBindings,
|
||||||
getSessions: () => sessions,
|
getSessions: () => sessions,
|
||||||
getLastTimestamp: () => lastTimestamp,
|
getLastTimestamp: () => lastTimestamp,
|
||||||
setLastTimestamp: (timestamp) => {
|
setLastTimestamp: (timestamp) => {
|
||||||
@@ -185,10 +185,10 @@ function loadState(): void {
|
|||||||
lastAgentTimestamp = {};
|
lastAgentTimestamp = {};
|
||||||
}
|
}
|
||||||
sessions = getAllSessions();
|
sessions = getAllSessions();
|
||||||
registeredGroups = getAllRegisteredGroups();
|
roomBindings = getAllRoomBindings();
|
||||||
logger.info(
|
logger.info(
|
||||||
{
|
{
|
||||||
groupCount: Object.keys(registeredGroups).length,
|
groupCount: Object.keys(roomBindings).length,
|
||||||
agentType: 'unified',
|
agentType: 'unified',
|
||||||
},
|
},
|
||||||
'State loaded',
|
'State loaded',
|
||||||
@@ -231,7 +231,7 @@ function assignRoomForIpc(jid: string, input: AssignRoomInput): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { jid: _ignoredJid, ...storedGroup } = assignedGroup;
|
const { jid: _ignoredJid, ...storedGroup } = assignedGroup;
|
||||||
registeredGroups[jid] = storedGroup;
|
roomBindings[jid] = storedGroup;
|
||||||
fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true });
|
fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,14 +240,21 @@ function assignRoomForIpc(jid: string, input: AssignRoomInput): void {
|
|||||||
* Returns groups ordered by most recent activity.
|
* Returns groups ordered by most recent activity.
|
||||||
*/
|
*/
|
||||||
export function getAvailableGroups(): import('./agent-runner.js').AvailableGroup[] {
|
export function getAvailableGroups(): import('./agent-runner.js').AvailableGroup[] {
|
||||||
return listAvailableGroups(registeredGroups);
|
return listAvailableGroups(roomBindings);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @internal - exported for testing */
|
/** @internal - exported for testing */
|
||||||
export function _setRegisteredGroups(
|
export function _setRegisteredGroups(
|
||||||
groups: Record<string, RegisteredGroup>,
|
groups: Record<string, RegisteredGroup>,
|
||||||
): void {
|
): void {
|
||||||
registeredGroups = groups;
|
roomBindings = groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal - exported for testing */
|
||||||
|
export function _setRoomBindings(
|
||||||
|
groups: Record<string, RegisteredGroup>,
|
||||||
|
): void {
|
||||||
|
roomBindings = groups;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function announceRestartRecovery(
|
async function announceRestartRecovery(
|
||||||
@@ -288,10 +295,7 @@ async function announceRestartRecovery(
|
|||||||
return explicitContext;
|
return explicitContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
const inferred = inferRecentRestartContext(
|
const inferred = inferRecentRestartContext(roomBindings, processStartedAtMs);
|
||||||
registeredGroups,
|
|
||||||
processStartedAtMs,
|
|
||||||
);
|
|
||||||
if (!inferred) return null;
|
if (!inferred) return null;
|
||||||
|
|
||||||
if (hasRecentRestartAnnouncement(inferred.chatJid, dedupeSince)) {
|
if (hasRecentRestartAnnouncement(inferred.chatJid, dedupeSince)) {
|
||||||
@@ -334,7 +338,7 @@ async function main(): Promise<void> {
|
|||||||
leaseRecoveryTimer = null;
|
leaseRecoveryTimer = null;
|
||||||
}
|
}
|
||||||
const interruptedGroups = queue
|
const interruptedGroups = queue
|
||||||
.getStatuses(Object.keys(registeredGroups))
|
.getStatuses(Object.keys(roomBindings))
|
||||||
.filter(
|
.filter(
|
||||||
(
|
(
|
||||||
status,
|
status,
|
||||||
@@ -344,14 +348,14 @@ async function main(): Promise<void> {
|
|||||||
)
|
)
|
||||||
.map((status) => ({
|
.map((status) => ({
|
||||||
chatJid: status.jid,
|
chatJid: status.jid,
|
||||||
groupName: registeredGroups[status.jid]?.name || status.jid,
|
groupName: roomBindings[status.jid]?.name || status.jid,
|
||||||
status: status.status,
|
status: status.status,
|
||||||
elapsedMs: status.elapsedMs,
|
elapsedMs: status.elapsedMs,
|
||||||
pendingMessages: status.pendingMessages,
|
pendingMessages: status.pendingMessages,
|
||||||
pendingTasks: status.pendingTasks,
|
pendingTasks: status.pendingTasks,
|
||||||
}));
|
}));
|
||||||
const writtenPaths = writeShutdownRestartContext(
|
const writtenPaths = writeShutdownRestartContext(
|
||||||
registeredGroups,
|
roomBindings,
|
||||||
interruptedGroups,
|
interruptedGroups,
|
||||||
signal,
|
signal,
|
||||||
);
|
);
|
||||||
@@ -376,7 +380,7 @@ async function main(): Promise<void> {
|
|||||||
const channelOpts = {
|
const channelOpts = {
|
||||||
onMessage: (chatJid: string, msg: NewMessage) => {
|
onMessage: (chatJid: string, msg: NewMessage) => {
|
||||||
// Sender allowlist drop mode: discard messages from denied senders before storing
|
// Sender allowlist drop mode: discard messages from denied senders before storing
|
||||||
if (!msg.is_from_me && !msg.is_bot_message && registeredGroups[chatJid]) {
|
if (!msg.is_from_me && !msg.is_bot_message && roomBindings[chatJid]) {
|
||||||
const cfg = loadSenderAllowlist();
|
const cfg = loadSenderAllowlist();
|
||||||
if (
|
if (
|
||||||
shouldDropMessage(chatJid, cfg) &&
|
shouldDropMessage(chatJid, cfg) &&
|
||||||
@@ -400,7 +404,7 @@ async function main(): Promise<void> {
|
|||||||
channel?: string,
|
channel?: string,
|
||||||
isGroup?: boolean,
|
isGroup?: boolean,
|
||||||
) => storeChatMetadata(chatJid, timestamp, name, channel, isGroup),
|
) => storeChatMetadata(chatJid, timestamp, name, channel, isGroup),
|
||||||
registeredGroups: () => registeredGroups,
|
roomBindings: () => roomBindings,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create and connect all registered channels.
|
// Create and connect all registered channels.
|
||||||
@@ -433,7 +437,7 @@ async function main(): Promise<void> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
startSchedulerLoop({
|
startSchedulerLoop({
|
||||||
registeredGroups: () => registeredGroups,
|
roomBindings: () => roomBindings,
|
||||||
getSessions: () => sessions,
|
getSessions: () => sessions,
|
||||||
queue,
|
queue,
|
||||||
onProcess: (groupJid, proc, processName, ipcDir) =>
|
onProcess: (groupJid, proc, processName, ipcDir) =>
|
||||||
@@ -476,7 +480,7 @@ async function main(): Promise<void> {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
nudgeScheduler: nudgeSchedulerLoop,
|
nudgeScheduler: nudgeSchedulerLoop,
|
||||||
registeredGroups: () => registeredGroups,
|
roomBindings: () => roomBindings,
|
||||||
assignRoom: assignRoomForIpc,
|
assignRoom: assignRoomForIpc,
|
||||||
syncGroups: async (force: boolean) => {
|
syncGroups: async (force: boolean) => {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
@@ -494,7 +498,7 @@ async function main(): Promise<void> {
|
|||||||
const restartContext = await announceRestartRecovery(processStartedAtMs);
|
const restartContext = await announceRestartRecovery(processStartedAtMs);
|
||||||
for (const candidate of getInterruptedRecoveryCandidates(
|
for (const candidate of getInterruptedRecoveryCandidates(
|
||||||
restartContext,
|
restartContext,
|
||||||
registeredGroups,
|
roomBindings,
|
||||||
)) {
|
)) {
|
||||||
queue.enqueueMessageCheck(
|
queue.enqueueMessageCheck(
|
||||||
candidate.chatJid,
|
candidate.chatJid,
|
||||||
@@ -520,9 +524,9 @@ async function main(): Promise<void> {
|
|||||||
usageUpdateInterval: USAGE_UPDATE_INTERVAL,
|
usageUpdateInterval: USAGE_UPDATE_INTERVAL,
|
||||||
channels,
|
channels,
|
||||||
queue,
|
queue,
|
||||||
registeredGroups: () => registeredGroups,
|
roomBindings: () => roomBindings,
|
||||||
onGroupNameSynced: (jid, name) => {
|
onGroupNameSynced: (jid, name) => {
|
||||||
const group = registeredGroups[jid];
|
const group = roomBindings[jid];
|
||||||
if (group) {
|
if (group) {
|
||||||
group.name = name;
|
group.name = name;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
|
||||||
import { ASSISTANT_NAME } from './config.js';
|
|
||||||
import {
|
import {
|
||||||
_setRegisteredGroupForTests,
|
_setRegisteredGroupForTests,
|
||||||
_initTestDatabase,
|
_initTestDatabase,
|
||||||
@@ -60,7 +59,7 @@ beforeEach(() => {
|
|||||||
deps = {
|
deps = {
|
||||||
sendMessage: async () => {},
|
sendMessage: async () => {},
|
||||||
nudgeScheduler: vi.fn(),
|
nudgeScheduler: vi.fn(),
|
||||||
registeredGroups: () => groups,
|
roomBindings: () => groups,
|
||||||
assignRoom: (jid, room) => {
|
assignRoom: (jid, room) => {
|
||||||
const assigned = assignRoom(jid, room);
|
const assigned = assignRoom(jid, room);
|
||||||
if (!assigned) return;
|
if (!assigned) return;
|
||||||
@@ -440,14 +439,13 @@ describe('assign_room authorization', () => {
|
|||||||
jid: 'new@g.us',
|
jid: 'new@g.us',
|
||||||
name: 'New Group',
|
name: 'New Group',
|
||||||
folder: 'new-group',
|
folder: 'new-group',
|
||||||
trigger: '@Andy',
|
|
||||||
},
|
},
|
||||||
'other-group',
|
'other-group',
|
||||||
false,
|
false,
|
||||||
deps,
|
deps,
|
||||||
);
|
);
|
||||||
|
|
||||||
// registeredGroups should not have changed
|
// roomBindings should not have changed
|
||||||
expect(groups['new@g.us']).toBeUndefined();
|
expect(groups['new@g.us']).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -458,7 +456,6 @@ describe('assign_room authorization', () => {
|
|||||||
jid: 'new@g.us',
|
jid: 'new@g.us',
|
||||||
name: 'New Group',
|
name: 'New Group',
|
||||||
folder: '../../outside',
|
folder: '../../outside',
|
||||||
trigger: '@Andy',
|
|
||||||
},
|
},
|
||||||
'whatsapp_main',
|
'whatsapp_main',
|
||||||
true,
|
true,
|
||||||
@@ -494,9 +491,9 @@ describe('IPC message authorization', () => {
|
|||||||
sourceGroup: string,
|
sourceGroup: string,
|
||||||
isMain: boolean,
|
isMain: boolean,
|
||||||
targetChatJid: string,
|
targetChatJid: string,
|
||||||
registeredGroups: Record<string, RegisteredGroup>,
|
roomBindings: Record<string, RegisteredGroup>,
|
||||||
): boolean {
|
): boolean {
|
||||||
const targetGroup = registeredGroups[targetChatJid];
|
const targetGroup = roomBindings[targetChatJid];
|
||||||
return isMain || (!!targetGroup && targetGroup.folder === sourceGroup);
|
return isMain || (!!targetGroup && targetGroup.folder === sourceGroup);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -885,7 +882,6 @@ describe('assign_room success', () => {
|
|||||||
jid: 'new@g.us',
|
jid: 'new@g.us',
|
||||||
name: 'New Group',
|
name: 'New Group',
|
||||||
folder: 'new-group',
|
folder: 'new-group',
|
||||||
trigger: '@Andy',
|
|
||||||
},
|
},
|
||||||
'whatsapp_main',
|
'whatsapp_main',
|
||||||
true,
|
true,
|
||||||
@@ -896,10 +892,9 @@ describe('assign_room success', () => {
|
|||||||
expect(group).toBeDefined();
|
expect(group).toBeDefined();
|
||||||
expect(group!.name).toBe('New Group');
|
expect(group!.name).toBe('New Group');
|
||||||
expect(group!.folder).toBe('new-group');
|
expect(group!.folder).toBe('new-group');
|
||||||
expect(group!.trigger).toBe('@Andy');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('assign_room auto-fills missing folder and trigger for single rooms', async () => {
|
it('assign_room auto-fills missing folder for single rooms without exposing trigger metadata', async () => {
|
||||||
await processTaskIpc(
|
await processTaskIpc(
|
||||||
{
|
{
|
||||||
type: 'assign_room',
|
type: 'assign_room',
|
||||||
@@ -914,7 +909,6 @@ describe('assign_room success', () => {
|
|||||||
const group = getRegisteredGroup('partial@g.us');
|
const group = getRegisteredGroup('partial@g.us');
|
||||||
expect(group).toBeDefined();
|
expect(group).toBeDefined();
|
||||||
expect(group!.folder).toMatch(/^grp_whatsapp_/);
|
expect(group!.folder).toMatch(/^grp_whatsapp_/);
|
||||||
expect(group!.trigger).toBe(`@${ASSISTANT_NAME}`);
|
|
||||||
expect(getStoredRoomSettings('partial@g.us')?.roomMode).toBe('single');
|
expect(getStoredRoomSettings('partial@g.us')?.roomMode).toBe('single');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
26
src/ipc.ts
26
src/ipc.ts
@@ -35,7 +35,7 @@ export interface IpcDeps {
|
|||||||
senderRole?: string,
|
senderRole?: string,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
nudgeScheduler?: () => void;
|
nudgeScheduler?: () => void;
|
||||||
registeredGroups: () => Record<string, RegisteredGroup>;
|
roomBindings: () => Record<string, RegisteredGroup>;
|
||||||
assignRoom: (jid: string, room: AssignRoomInput) => void;
|
assignRoom: (jid: string, room: AssignRoomInput) => void;
|
||||||
syncGroups: (force: boolean) => Promise<void>;
|
syncGroups: (force: boolean) => Promise<void>;
|
||||||
getAvailableGroups: () => AvailableGroup[];
|
getAvailableGroups: () => AvailableGroup[];
|
||||||
@@ -65,14 +65,14 @@ export async function forwardAuthorizedIpcMessage(
|
|||||||
msg: IpcMessagePayload,
|
msg: IpcMessagePayload,
|
||||||
sourceGroup: string,
|
sourceGroup: string,
|
||||||
isMain: boolean,
|
isMain: boolean,
|
||||||
registeredGroups: Record<string, RegisteredGroup>,
|
roomBindings: Record<string, RegisteredGroup>,
|
||||||
sendMessage: IpcDeps['sendMessage'],
|
sendMessage: IpcDeps['sendMessage'],
|
||||||
): Promise<IpcMessageForwardResult> {
|
): Promise<IpcMessageForwardResult> {
|
||||||
if (!(msg.type === 'message' && msg.chatJid && msg.text)) {
|
if (!(msg.type === 'message' && msg.chatJid && msg.text)) {
|
||||||
return { outcome: 'ignored', senderRole: msg.senderRole ?? null };
|
return { outcome: 'ignored', senderRole: msg.senderRole ?? null };
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetGroup = registeredGroups[msg.chatJid];
|
const targetGroup = roomBindings[msg.chatJid];
|
||||||
const isMainOverride = isMain === true;
|
const isMainOverride = isMain === true;
|
||||||
if (
|
if (
|
||||||
!(isMainOverride || (targetGroup && targetGroup.folder === sourceGroup))
|
!(isMainOverride || (targetGroup && targetGroup.folder === sourceGroup))
|
||||||
@@ -184,11 +184,11 @@ export function startIpcWatcher(deps: IpcDeps): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const registeredGroups = deps.registeredGroups();
|
const roomBindings = deps.roomBindings();
|
||||||
|
|
||||||
// Build folder→isMain lookup from registered groups
|
// Build folder→isMain lookup from registered groups
|
||||||
const folderIsMain = new Map<string, boolean>();
|
const folderIsMain = new Map<string, boolean>();
|
||||||
for (const group of Object.values(registeredGroups)) {
|
for (const group of Object.values(roomBindings)) {
|
||||||
if (group.isMain) folderIsMain.set(group.folder, true);
|
if (group.isMain) folderIsMain.set(group.folder, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,7 +240,7 @@ export function startIpcWatcher(deps: IpcDeps): void {
|
|||||||
msg,
|
msg,
|
||||||
sourceGroup,
|
sourceGroup,
|
||||||
isMain,
|
isMain,
|
||||||
registeredGroups,
|
roomBindings,
|
||||||
deps.sendMessage,
|
deps.sendMessage,
|
||||||
);
|
);
|
||||||
if (forwardResult.outcome === 'sent') {
|
if (forwardResult.outcome === 'sent') {
|
||||||
@@ -353,8 +353,6 @@ export async function processTaskIpc(
|
|||||||
jid?: string;
|
jid?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
folder?: string;
|
folder?: string;
|
||||||
trigger?: string;
|
|
||||||
requiresTrigger?: boolean;
|
|
||||||
room_mode?: RoomMode;
|
room_mode?: RoomMode;
|
||||||
owner_agent_type?: AgentType;
|
owner_agent_type?: AgentType;
|
||||||
isMain?: boolean;
|
isMain?: boolean;
|
||||||
@@ -376,7 +374,7 @@ export async function processTaskIpc(
|
|||||||
isMain: boolean, // Verified from directory path
|
isMain: boolean, // Verified from directory path
|
||||||
deps: IpcDeps,
|
deps: IpcDeps,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const registeredGroups = deps.registeredGroups();
|
const roomBindings = deps.roomBindings();
|
||||||
|
|
||||||
switch (data.type) {
|
switch (data.type) {
|
||||||
case 'schedule_task':
|
case 'schedule_task':
|
||||||
@@ -389,8 +387,8 @@ export async function processTaskIpc(
|
|||||||
// Resolve the target group from JID
|
// Resolve the target group from JID
|
||||||
const targetJid = data.targetJid as string;
|
const targetJid = data.targetJid as string;
|
||||||
const targetGroupEntry =
|
const targetGroupEntry =
|
||||||
registeredGroups[targetJid] ||
|
roomBindings[targetJid] ||
|
||||||
Object.values(registeredGroups).find(
|
Object.values(roomBindings).find(
|
||||||
(group) => group.folder === targetJid,
|
(group) => group.folder === targetJid,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -404,9 +402,9 @@ export async function processTaskIpc(
|
|||||||
|
|
||||||
const targetFolder = targetGroupEntry.folder;
|
const targetFolder = targetGroupEntry.folder;
|
||||||
const resolvedTargetJid =
|
const resolvedTargetJid =
|
||||||
registeredGroups[targetJid] !== undefined
|
roomBindings[targetJid] !== undefined
|
||||||
? targetJid
|
? targetJid
|
||||||
: Object.entries(registeredGroups).find(
|
: Object.entries(roomBindings).find(
|
||||||
([, group]) => group.folder === targetFolder,
|
([, group]) => group.folder === targetFolder,
|
||||||
)?.[0];
|
)?.[0];
|
||||||
|
|
||||||
@@ -765,8 +763,6 @@ export async function processTaskIpc(
|
|||||||
roomMode: data.room_mode,
|
roomMode: data.room_mode,
|
||||||
ownerAgentType: data.owner_agent_type,
|
ownerAgentType: data.owner_agent_type,
|
||||||
folder: data.folder,
|
folder: data.folder,
|
||||||
trigger: data.trigger,
|
|
||||||
requiresTrigger: data.requiresTrigger,
|
|
||||||
isMain: data.isMain,
|
isMain: data.isMain,
|
||||||
workDir: data.workDir,
|
workDir: data.workDir,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -67,6 +67,10 @@ export function createPairedExecutionLifecycle(args: {
|
|||||||
let pairedTurnOutputPersisted = false;
|
let pairedTurnOutputPersisted = false;
|
||||||
let pairedTurnStateFinalized = false;
|
let pairedTurnStateFinalized = false;
|
||||||
let leaseHeartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
let leaseHeartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
const requiresVisibleVerdict =
|
||||||
|
pairedExecutionContext?.requiresVisibleVerdict === true;
|
||||||
|
const missingVisibleVerdictSummary =
|
||||||
|
'Execution completed without a visible terminal verdict.';
|
||||||
|
|
||||||
const finalizePairedTurnState = (
|
const finalizePairedTurnState = (
|
||||||
status: 'succeeded' | 'failed',
|
status: 'succeeded' | 'failed',
|
||||||
@@ -254,12 +258,31 @@ export function createPairedExecutionLifecycle(args: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const missingVisibleVerdict =
|
||||||
|
requiresVisibleVerdict &&
|
||||||
|
(!pairedFinalOutput || pairedFinalOutput.length === 0);
|
||||||
|
if (missingVisibleVerdict) {
|
||||||
|
pairedExecutionSummary = missingVisibleVerdictSummary;
|
||||||
|
log.warn(
|
||||||
|
{
|
||||||
|
pairedTaskId: pairedExecutionContext?.task.id ?? null,
|
||||||
|
role: completedRole,
|
||||||
|
runId,
|
||||||
|
},
|
||||||
|
'Treating paired execution as failed because it ended without a visible terminal verdict',
|
||||||
|
);
|
||||||
|
}
|
||||||
const effectiveStatus =
|
const effectiveStatus =
|
||||||
completedRole === 'owner' &&
|
completedRole === 'owner' &&
|
||||||
pairedExecutionStatus === 'succeeded' &&
|
pairedExecutionStatus === 'succeeded' &&
|
||||||
!pairedSawOutput
|
!pairedSawOutput
|
||||||
|
? 'failed'
|
||||||
|
: missingVisibleVerdict && pairedExecutionStatus === 'succeeded'
|
||||||
? 'failed'
|
? 'failed'
|
||||||
: pairedExecutionStatus;
|
: pairedExecutionStatus;
|
||||||
|
const sawOutputForFollowUp = missingVisibleVerdict
|
||||||
|
? false
|
||||||
|
: pairedSawOutput;
|
||||||
|
|
||||||
if (pairedExecutionContext && !pairedExecutionCompleted) {
|
if (pairedExecutionContext && !pairedExecutionCompleted) {
|
||||||
if (effectiveStatus === 'succeeded') {
|
if (effectiveStatus === 'succeeded') {
|
||||||
@@ -283,7 +306,10 @@ export function createPairedExecutionLifecycle(args: {
|
|||||||
pairedExecutionCompleted = true;
|
pairedExecutionCompleted = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
finalizePairedTurnState(effectiveStatus);
|
finalizePairedTurnState(
|
||||||
|
effectiveStatus,
|
||||||
|
effectiveStatus === 'failed' ? pairedExecutionSummary : null,
|
||||||
|
);
|
||||||
|
|
||||||
if (!pairedExecutionContext) {
|
if (!pairedExecutionContext) {
|
||||||
return;
|
return;
|
||||||
@@ -314,7 +340,7 @@ export function createPairedExecutionLifecycle(args: {
|
|||||||
const queueAction = resolvePairedFollowUpQueueAction({
|
const queueAction = resolvePairedFollowUpQueueAction({
|
||||||
completedRole,
|
completedRole,
|
||||||
executionStatus: effectiveStatus,
|
executionStatus: effectiveStatus,
|
||||||
sawOutput: pairedSawOutput,
|
sawOutput: sawOutputForFollowUp,
|
||||||
taskStatus: finishedTask?.status ?? null,
|
taskStatus: finishedTask?.status ?? null,
|
||||||
});
|
});
|
||||||
if (queueAction !== 'pending' || !finishedTask) {
|
if (queueAction !== 'pending' || !finishedTask) {
|
||||||
@@ -328,8 +354,8 @@ export function createPairedExecutionLifecycle(args: {
|
|||||||
source: 'executor-recovery',
|
source: 'executor-recovery',
|
||||||
completedRole,
|
completedRole,
|
||||||
executionStatus: effectiveStatus,
|
executionStatus: effectiveStatus,
|
||||||
sawOutput: pairedSawOutput,
|
sawOutput: sawOutputForFollowUp,
|
||||||
fallbackLastTurnOutputRole: pairedSawOutput ? completedRole : null,
|
fallbackLastTurnOutputRole: sawOutputForFollowUp ? completedRole : null,
|
||||||
enqueueMessageCheck,
|
enqueueMessageCheck,
|
||||||
});
|
});
|
||||||
if (followUpResult.kind !== 'paired-follow-up') {
|
if (followUpResult.kind !== 'paired-follow-up') {
|
||||||
|
|||||||
@@ -272,7 +272,7 @@ function makeDeps() {
|
|||||||
registerProcess: vi.fn(),
|
registerProcess: vi.fn(),
|
||||||
enqueueMessageCheck: vi.fn(),
|
enqueueMessageCheck: vi.fn(),
|
||||||
},
|
},
|
||||||
getRegisteredGroups: () => ({}),
|
getRoomBindings: () => ({}),
|
||||||
getSessions: () => ({}),
|
getSessions: () => ({}),
|
||||||
persistSession: vi.fn(),
|
persistSession: vi.fn(),
|
||||||
clearSession: vi.fn(),
|
clearSession: vi.fn(),
|
||||||
@@ -981,6 +981,108 @@ describe('runAgentForGroup room memory', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('fails paired reviewer turns that finish without a visible terminal verdict', async () => {
|
||||||
|
const group = {
|
||||||
|
...makeGroup(),
|
||||||
|
folder: 'test-group',
|
||||||
|
workDir: '/repo',
|
||||||
|
agentType: 'codex' as const,
|
||||||
|
};
|
||||||
|
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
|
||||||
|
chat_jid: 'group@test',
|
||||||
|
owner_agent_type: 'claude-code',
|
||||||
|
reviewer_agent_type: 'codex',
|
||||||
|
arbiter_agent_type: null,
|
||||||
|
owner_service_id: 'claude',
|
||||||
|
reviewer_service_id: 'codex-review',
|
||||||
|
arbiter_service_id: null,
|
||||||
|
activated_at: null,
|
||||||
|
reason: null,
|
||||||
|
explicit: false,
|
||||||
|
});
|
||||||
|
vi.mocked(
|
||||||
|
pairedExecutionContext.preparePairedExecutionContext,
|
||||||
|
).mockReturnValue({
|
||||||
|
task: {
|
||||||
|
id: 'paired-task-review-no-verdict',
|
||||||
|
chat_jid: 'group@test',
|
||||||
|
group_folder: 'test-group',
|
||||||
|
owner_service_id: 'claude',
|
||||||
|
reviewer_service_id: 'codex-review',
|
||||||
|
title: null,
|
||||||
|
source_ref: 'HEAD',
|
||||||
|
review_requested_at: '2026-04-10T00:00:00.000Z',
|
||||||
|
plan_notes: null,
|
||||||
|
round_trip_count: 0,
|
||||||
|
status: 'in_review',
|
||||||
|
arbiter_verdict: null,
|
||||||
|
arbiter_requested_at: null,
|
||||||
|
completion_reason: null,
|
||||||
|
created_at: '2026-04-10T00:00:00.000Z',
|
||||||
|
updated_at: '2026-04-10T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
claimedTaskUpdatedAt: '2026-04-10T00:00:00.000Z',
|
||||||
|
workspace: null,
|
||||||
|
envOverrides: {},
|
||||||
|
requiresVisibleVerdict: true,
|
||||||
|
});
|
||||||
|
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
|
||||||
|
async (_group, _input, _onProcess, onOutput) => {
|
||||||
|
await onOutput?.({
|
||||||
|
status: 'success',
|
||||||
|
phase: 'progress',
|
||||||
|
result: '검토 중입니다.',
|
||||||
|
output: { visibility: 'public', text: '검토 중입니다.' },
|
||||||
|
} as any);
|
||||||
|
return {
|
||||||
|
status: 'success',
|
||||||
|
result: null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await runAgentForGroup(makeDeps(), {
|
||||||
|
group,
|
||||||
|
prompt: 'hello',
|
||||||
|
chatJid: 'group@test',
|
||||||
|
runId: 'run-review-no-verdict',
|
||||||
|
forcedRole: 'reviewer',
|
||||||
|
pairedTurnIdentity: {
|
||||||
|
turnId:
|
||||||
|
'paired-task-review-no-verdict:2026-04-10T00:00:00.000Z:reviewer-turn',
|
||||||
|
taskId: 'paired-task-review-no-verdict',
|
||||||
|
taskUpdatedAt: '2026-04-10T00:00:00.000Z',
|
||||||
|
intentKind: 'reviewer-turn',
|
||||||
|
role: 'reviewer',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe('error');
|
||||||
|
expect(
|
||||||
|
pairedExecutionContext.completePairedExecutionContext,
|
||||||
|
).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
taskId: 'paired-task-review-no-verdict',
|
||||||
|
role: 'reviewer',
|
||||||
|
status: 'failed',
|
||||||
|
summary: 'Execution completed without a visible terminal verdict.',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(db.failPairedTurn).toHaveBeenCalledWith({
|
||||||
|
turnIdentity: {
|
||||||
|
turnId:
|
||||||
|
'paired-task-review-no-verdict:2026-04-10T00:00:00.000Z:reviewer-turn',
|
||||||
|
taskId: 'paired-task-review-no-verdict',
|
||||||
|
taskUpdatedAt: '2026-04-10T00:00:00.000Z',
|
||||||
|
intentKind: 'reviewer-turn',
|
||||||
|
role: 'reviewer',
|
||||||
|
},
|
||||||
|
error: 'Execution completed without a visible terminal verdict.',
|
||||||
|
});
|
||||||
|
expect(db.completePairedTurn).not.toHaveBeenCalled();
|
||||||
|
expect(db.insertPairedTurnOutput).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('passes paired workspace env overrides into the runner when execution metadata exists', async () => {
|
it('passes paired workspace env overrides into the runner when execution metadata exists', async () => {
|
||||||
const group = {
|
const group = {
|
||||||
...makeGroup(),
|
...makeGroup(),
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ import type { AgentType, PairedRoomRole, RegisteredGroup } from './types.js';
|
|||||||
export interface MessageAgentExecutorDeps {
|
export interface MessageAgentExecutorDeps {
|
||||||
assistantName: string;
|
assistantName: string;
|
||||||
queue: Pick<GroupQueue, 'registerProcess' | 'enqueueMessageCheck'>;
|
queue: Pick<GroupQueue, 'registerProcess' | 'enqueueMessageCheck'>;
|
||||||
getRegisteredGroups: () => Record<string, RegisteredGroup>;
|
getRoomBindings: () => Record<string, RegisteredGroup>;
|
||||||
getSessions: () => Record<string, string>;
|
getSessions: () => Record<string, string>;
|
||||||
persistSession: (groupFolder: string, sessionId: string) => void;
|
persistSession: (groupFolder: string, sessionId: string) => void;
|
||||||
clearSession: (groupFolder: string) => void;
|
clearSession: (groupFolder: string) => void;
|
||||||
@@ -175,7 +175,7 @@ export async function runAgentForGroup(
|
|||||||
writeGroupsSnapshot(
|
writeGroupsSnapshot(
|
||||||
group.folder,
|
group.folder,
|
||||||
isMain,
|
isMain,
|
||||||
listAvailableGroups(deps.getRegisteredGroups()),
|
listAvailableGroups(deps.getRoomBindings()),
|
||||||
);
|
);
|
||||||
|
|
||||||
let resetSessionRequested = false;
|
let resetSessionRequested = false;
|
||||||
@@ -194,6 +194,7 @@ export async function runAgentForGroup(
|
|||||||
runId,
|
runId,
|
||||||
roomRoleContext,
|
roomRoleContext,
|
||||||
hasHumanMessage: args.hasHumanMessage,
|
hasHumanMessage: args.hasHumanMessage,
|
||||||
|
pairedTurnIdentity: args.pairedTurnIdentity,
|
||||||
});
|
});
|
||||||
const preparedTurnTaskUpdatedAt = pairedExecutionContext
|
const preparedTurnTaskUpdatedAt = pairedExecutionContext
|
||||||
? (pairedExecutionContext.claimedTaskUpdatedAt ??
|
? (pairedExecutionContext.claimedTaskUpdatedAt ??
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
advanceLastAgentCursor,
|
advanceLastAgentCursor,
|
||||||
filterLoopingPairedBotMessages,
|
filterLoopingPairedBotMessages,
|
||||||
getProcessableMessages,
|
getProcessableMessages,
|
||||||
hasAllowedTrigger,
|
|
||||||
resolveCursorKey,
|
resolveCursorKey,
|
||||||
shouldSkipBotOnlyCollaboration,
|
shouldSkipBotOnlyCollaboration,
|
||||||
} from './message-runtime-rules.js';
|
} from './message-runtime-rules.js';
|
||||||
@@ -296,18 +295,6 @@ export async function processLoopGroupMessages(args: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
|
||||||
!hasAllowedTrigger({
|
|
||||||
chatJid,
|
|
||||||
messages: processableGroupMessages,
|
|
||||||
group,
|
|
||||||
triggerPattern: args.triggerPattern,
|
|
||||||
hasImplicitContinuationWindow: args.hasImplicitContinuationWindow,
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await processQueuedGroupDispatch({
|
await processQueuedGroupDispatch({
|
||||||
chatJid,
|
chatJid,
|
||||||
group,
|
group,
|
||||||
|
|||||||
@@ -1,27 +1,14 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import type { RegisteredGroup } from './types.js';
|
import type { RegisteredGroup } from './types.js';
|
||||||
|
|
||||||
const { handleSessionCommandMock, hasAllowedTriggerMock, loggerInfoMock } =
|
const { handleSessionCommandMock } = vi.hoisted(() => ({
|
||||||
vi.hoisted(() => ({
|
|
||||||
handleSessionCommandMock: vi.fn(),
|
handleSessionCommandMock: vi.fn(),
|
||||||
hasAllowedTriggerMock: vi.fn(),
|
}));
|
||||||
loggerInfoMock: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('./session-commands.js', () => ({
|
vi.mock('./session-commands.js', () => ({
|
||||||
handleSessionCommand: handleSessionCommandMock,
|
handleSessionCommand: handleSessionCommandMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('./message-runtime-rules.js', () => ({
|
|
||||||
hasAllowedTrigger: hasAllowedTriggerMock,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('./logger.js', () => ({
|
|
||||||
logger: {
|
|
||||||
info: loggerInfoMock,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { handleQueuedRunGates } from './message-runtime-gating.js';
|
import { handleQueuedRunGates } from './message-runtime-gating.js';
|
||||||
|
|
||||||
describe('message-runtime-gating', () => {
|
describe('message-runtime-gating', () => {
|
||||||
@@ -44,8 +31,6 @@ describe('message-runtime-gating', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
handleSessionCommandMock.mockReset();
|
handleSessionCommandMock.mockReset();
|
||||||
hasAllowedTriggerMock.mockReset();
|
|
||||||
loggerInfoMock.mockReset();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns session-command results directly when a command is handled', async () => {
|
it('returns session-command results directly when a command is handled', async () => {
|
||||||
@@ -57,29 +42,13 @@ describe('message-runtime-gating', () => {
|
|||||||
const result = await handleQueuedRunGates(baseArgs);
|
const result = await handleQueuedRunGates(baseArgs);
|
||||||
|
|
||||||
expect(result).toEqual({ handled: true, success: false });
|
expect(result).toEqual({ handled: true, success: false });
|
||||||
expect(hasAllowedTriggerMock).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('treats missing triggers as a handled no-op', async () => {
|
it('falls through when no session command is handled', async () => {
|
||||||
handleSessionCommandMock.mockResolvedValue({ handled: false });
|
handleSessionCommandMock.mockResolvedValue({ handled: false });
|
||||||
hasAllowedTriggerMock.mockReturnValue(false);
|
|
||||||
|
|
||||||
const result = await handleQueuedRunGates(baseArgs);
|
|
||||||
|
|
||||||
expect(result).toEqual({ handled: true, success: true });
|
|
||||||
expect(loggerInfoMock).toHaveBeenCalledWith(
|
|
||||||
{ chatJid: 'room-1', group: 'room', runId: 'run-1' },
|
|
||||||
'Skipping queued run because no allowed trigger was found',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls through when no session command is handled and the trigger is allowed', async () => {
|
|
||||||
handleSessionCommandMock.mockResolvedValue({ handled: false });
|
|
||||||
hasAllowedTriggerMock.mockReturnValue(true);
|
|
||||||
|
|
||||||
const result = await handleQueuedRunGates(baseArgs);
|
const result = await handleQueuedRunGates(baseArgs);
|
||||||
|
|
||||||
expect(result).toEqual({ handled: false });
|
expect(result).toEqual({ handled: false });
|
||||||
expect(loggerInfoMock).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
import { hasAllowedTrigger } from './message-runtime-rules.js';
|
|
||||||
import {
|
import {
|
||||||
handleSessionCommand,
|
handleSessionCommand,
|
||||||
type SessionCommandDeps,
|
type SessionCommandDeps,
|
||||||
@@ -32,21 +31,5 @@ export async function handleQueuedRunGates(args: {
|
|||||||
return cmdResult;
|
return cmdResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
|
||||||
!hasAllowedTrigger({
|
|
||||||
chatJid: args.chatJid,
|
|
||||||
messages: args.missedMessages,
|
|
||||||
group: args.group,
|
|
||||||
triggerPattern: args.triggerPattern,
|
|
||||||
hasImplicitContinuationWindow: args.hasImplicitContinuationWindow,
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
logger.info(
|
|
||||||
{ chatJid: args.chatJid, group: args.group.name, runId: args.runId },
|
|
||||||
'Skipping queued run because no allowed trigger was found',
|
|
||||||
);
|
|
||||||
return { handled: true, success: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { handled: false };
|
return { handled: false };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ describe('message-runtime-handoffs', () => {
|
|||||||
completed_at: null,
|
completed_at: null,
|
||||||
last_error: null,
|
last_error: null,
|
||||||
},
|
},
|
||||||
getRegisteredGroups: () => ({
|
getRoomBindings: () => ({
|
||||||
'group@test': makeGroup(),
|
'group@test': makeGroup(),
|
||||||
}),
|
}),
|
||||||
channels: [makeChannel('discord-main', true)],
|
channels: [makeChannel('discord-main', true)],
|
||||||
@@ -174,7 +174,7 @@ describe('message-runtime-handoffs', () => {
|
|||||||
completed_at: null,
|
completed_at: null,
|
||||||
last_error: null,
|
last_error: null,
|
||||||
},
|
},
|
||||||
getRegisteredGroups: () => ({
|
getRoomBindings: () => ({
|
||||||
'group@test': makeGroup(),
|
'group@test': makeGroup(),
|
||||||
}),
|
}),
|
||||||
channels: [
|
channels: [
|
||||||
@@ -249,7 +249,7 @@ describe('message-runtime-handoffs', () => {
|
|||||||
completed_at: null,
|
completed_at: null,
|
||||||
last_error: null,
|
last_error: null,
|
||||||
},
|
},
|
||||||
getRegisteredGroups: () => ({
|
getRoomBindings: () => ({
|
||||||
'group@test': makeGroup(),
|
'group@test': makeGroup(),
|
||||||
}),
|
}),
|
||||||
channels: [
|
channels: [
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ function failClaimedHandoff(args: {
|
|||||||
|
|
||||||
export async function processClaimedHandoff(args: {
|
export async function processClaimedHandoff(args: {
|
||||||
handoff: ServiceHandoff;
|
handoff: ServiceHandoff;
|
||||||
getRegisteredGroups: () => Record<string, RegisteredGroup>;
|
getRoomBindings: () => Record<string, RegisteredGroup>;
|
||||||
channels: Channel[];
|
channels: Channel[];
|
||||||
executeTurn: ExecuteTurnFn;
|
executeTurn: ExecuteTurnFn;
|
||||||
lastAgentTimestamps: Record<string, string>;
|
lastAgentTimestamps: Record<string, string>;
|
||||||
@@ -175,7 +175,7 @@ export async function processClaimedHandoff(args: {
|
|||||||
enqueueMessageCheck?: ((chatJid: string) => void) | undefined;
|
enqueueMessageCheck?: ((chatJid: string) => void) | undefined;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const { handoff } = args;
|
const { handoff } = args;
|
||||||
const group = args.getRegisteredGroups()[handoff.chat_jid];
|
const group = args.getRoomBindings()[handoff.chat_jid];
|
||||||
if (!group) {
|
if (!group) {
|
||||||
failClaimedHandoff({
|
failClaimedHandoff({
|
||||||
handoff,
|
handoff,
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export async function processMessageLoopTick(args: {
|
|||||||
triggerPattern: RegExp;
|
triggerPattern: RegExp;
|
||||||
timezone: string;
|
timezone: string;
|
||||||
channels: Channel[];
|
channels: Channel[];
|
||||||
getRegisteredGroups: () => Record<string, RegisteredGroup>;
|
getRoomBindings: () => Record<string, RegisteredGroup>;
|
||||||
getLastTimestamp: () => string;
|
getLastTimestamp: () => string;
|
||||||
setLastTimestamp: (timestamp: string) => void;
|
setLastTimestamp: (timestamp: string) => void;
|
||||||
lastAgentTimestamps: Record<string, string>;
|
lastAgentTimestamps: Record<string, string>;
|
||||||
@@ -54,8 +54,8 @@ export async function processMessageLoopTick(args: {
|
|||||||
labelPairedSenders: (chatJid: string, messages: NewMessage[]) => NewMessage[];
|
labelPairedSenders: (chatJid: string, messages: NewMessage[]) => NewMessage[];
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
args.enqueuePendingHandoffs();
|
args.enqueuePendingHandoffs();
|
||||||
const registeredGroups = args.getRegisteredGroups();
|
const roomBindings = args.getRoomBindings();
|
||||||
const jids = Object.keys(registeredGroups);
|
const jids = Object.keys(roomBindings);
|
||||||
const { messages, newSeqCursor } = getNewMessagesBySeq(
|
const { messages, newSeqCursor } = getNewMessagesBySeq(
|
||||||
jids,
|
jids,
|
||||||
args.getLastTimestamp(),
|
args.getLastTimestamp(),
|
||||||
@@ -81,7 +81,7 @@ export async function processMessageLoopTick(args: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const [chatJid, groupMessages] of messagesByGroup) {
|
for (const [chatJid, groupMessages] of messagesByGroup) {
|
||||||
const group = registeredGroups[chatJid];
|
const group = roomBindings[chatJid];
|
||||||
if (!group) continue;
|
if (!group) continue;
|
||||||
|
|
||||||
const channel = findChannel(args.channels, chatJid);
|
const channel = findChannel(args.channels, chatJid);
|
||||||
@@ -125,7 +125,7 @@ export async function processMessageLoopTick(args: {
|
|||||||
export function recoverPendingMessages(args: {
|
export function recoverPendingMessages(args: {
|
||||||
assistantName: string;
|
assistantName: string;
|
||||||
channels: Channel[];
|
channels: Channel[];
|
||||||
getRegisteredGroups: () => Record<string, RegisteredGroup>;
|
getRoomBindings: () => Record<string, RegisteredGroup>;
|
||||||
lastAgentTimestamps: Record<string, string>;
|
lastAgentTimestamps: Record<string, string>;
|
||||||
saveState: () => void;
|
saveState: () => void;
|
||||||
enqueueScopedGroupMessageCheck: (
|
enqueueScopedGroupMessageCheck: (
|
||||||
@@ -133,8 +133,8 @@ export function recoverPendingMessages(args: {
|
|||||||
groupFolder: string,
|
groupFolder: string,
|
||||||
) => void;
|
) => void;
|
||||||
}): void {
|
}): void {
|
||||||
const registeredGroups = args.getRegisteredGroups();
|
const roomBindings = args.getRoomBindings();
|
||||||
for (const [chatJid, group] of Object.entries(registeredGroups)) {
|
for (const [chatJid, group] of Object.entries(roomBindings)) {
|
||||||
const openWorkItem = getOpenWorkItemForChat(chatJid, SERVICE_SESSION_SCOPE);
|
const openWorkItem = getOpenWorkItemForChat(chatJid, SERVICE_SESSION_SCOPE);
|
||||||
if (openWorkItem) {
|
if (openWorkItem) {
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
@@ -398,7 +398,7 @@ describe('createMessageRuntime', () => {
|
|||||||
closeStdin: vi.fn(),
|
closeStdin: vi.fn(),
|
||||||
notifyIdle: vi.fn(),
|
notifyIdle: vi.fn(),
|
||||||
} as any,
|
} as any,
|
||||||
getRegisteredGroups: () => ({ [chatJid]: group }),
|
getRoomBindings: () => ({ [chatJid]: group }),
|
||||||
getSessions: () => ({}),
|
getSessions: () => ({}),
|
||||||
getLastTimestamp: () => '',
|
getLastTimestamp: () => '',
|
||||||
setLastTimestamp: vi.fn(),
|
setLastTimestamp: vi.fn(),
|
||||||
@@ -468,7 +468,7 @@ describe('createMessageRuntime', () => {
|
|||||||
closeStdin: vi.fn(),
|
closeStdin: vi.fn(),
|
||||||
notifyIdle: vi.fn(),
|
notifyIdle: vi.fn(),
|
||||||
} as any,
|
} as any,
|
||||||
getRegisteredGroups: () => ({ [chatJid]: group }),
|
getRoomBindings: () => ({ [chatJid]: group }),
|
||||||
getSessions: () => ({}),
|
getSessions: () => ({}),
|
||||||
getLastTimestamp: () => '',
|
getLastTimestamp: () => '',
|
||||||
setLastTimestamp: vi.fn(),
|
setLastTimestamp: vi.fn(),
|
||||||
@@ -545,7 +545,7 @@ describe('createMessageRuntime', () => {
|
|||||||
closeStdin: vi.fn(),
|
closeStdin: vi.fn(),
|
||||||
notifyIdle: vi.fn(),
|
notifyIdle: vi.fn(),
|
||||||
} as any,
|
} as any,
|
||||||
getRegisteredGroups: () => ({ [chatJid]: group }),
|
getRoomBindings: () => ({ [chatJid]: group }),
|
||||||
getSessions: () => ({}),
|
getSessions: () => ({}),
|
||||||
getLastTimestamp: () => '',
|
getLastTimestamp: () => '',
|
||||||
setLastTimestamp: vi.fn(),
|
setLastTimestamp: vi.fn(),
|
||||||
@@ -596,7 +596,7 @@ describe('createMessageRuntime', () => {
|
|||||||
registerProcess: vi.fn(),
|
registerProcess: vi.fn(),
|
||||||
closeStdin: vi.fn(),
|
closeStdin: vi.fn(),
|
||||||
} as any,
|
} as any,
|
||||||
getRegisteredGroups: () => ({ [chatJid]: group }),
|
getRoomBindings: () => ({ [chatJid]: group }),
|
||||||
getSessions: () => ({}),
|
getSessions: () => ({}),
|
||||||
getLastTimestamp: () => '',
|
getLastTimestamp: () => '',
|
||||||
setLastTimestamp: vi.fn(),
|
setLastTimestamp: vi.fn(),
|
||||||
@@ -666,7 +666,7 @@ describe('createMessageRuntime', () => {
|
|||||||
notifyIdle: vi.fn(),
|
notifyIdle: vi.fn(),
|
||||||
enqueueMessageCheck,
|
enqueueMessageCheck,
|
||||||
} as any,
|
} as any,
|
||||||
getRegisteredGroups: () => ({ [chatJid]: group }),
|
getRoomBindings: () => ({ [chatJid]: group }),
|
||||||
getSessions: () => ({}),
|
getSessions: () => ({}),
|
||||||
getLastTimestamp: () => '',
|
getLastTimestamp: () => '',
|
||||||
setLastTimestamp: vi.fn(),
|
setLastTimestamp: vi.fn(),
|
||||||
@@ -742,7 +742,7 @@ describe('createMessageRuntime', () => {
|
|||||||
notifyIdle: vi.fn(),
|
notifyIdle: vi.fn(),
|
||||||
enqueueMessageCheck,
|
enqueueMessageCheck,
|
||||||
} as any,
|
} as any,
|
||||||
getRegisteredGroups: () => ({ [chatJid]: group }),
|
getRoomBindings: () => ({ [chatJid]: group }),
|
||||||
getSessions: () => ({}),
|
getSessions: () => ({}),
|
||||||
getLastTimestamp: () => '',
|
getLastTimestamp: () => '',
|
||||||
setLastTimestamp: vi.fn(),
|
setLastTimestamp: vi.fn(),
|
||||||
@@ -832,7 +832,7 @@ describe('createMessageRuntime', () => {
|
|||||||
notifyIdle: vi.fn(),
|
notifyIdle: vi.fn(),
|
||||||
enqueueMessageCheck,
|
enqueueMessageCheck,
|
||||||
} as any,
|
} as any,
|
||||||
getRegisteredGroups: () => ({ [chatJid]: group }),
|
getRoomBindings: () => ({ [chatJid]: group }),
|
||||||
getSessions: () => ({}),
|
getSessions: () => ({}),
|
||||||
getLastTimestamp: () => '',
|
getLastTimestamp: () => '',
|
||||||
setLastTimestamp: vi.fn(),
|
setLastTimestamp: vi.fn(),
|
||||||
@@ -915,7 +915,7 @@ describe('createMessageRuntime', () => {
|
|||||||
notifyIdle: vi.fn(),
|
notifyIdle: vi.fn(),
|
||||||
enqueueMessageCheck,
|
enqueueMessageCheck,
|
||||||
} as any,
|
} as any,
|
||||||
getRegisteredGroups: () => ({ [chatJid]: group }),
|
getRoomBindings: () => ({ [chatJid]: group }),
|
||||||
getSessions: () => ({}),
|
getSessions: () => ({}),
|
||||||
getLastTimestamp: () => '',
|
getLastTimestamp: () => '',
|
||||||
setLastTimestamp: vi.fn(),
|
setLastTimestamp: vi.fn(),
|
||||||
@@ -1009,7 +1009,7 @@ describe('createMessageRuntime', () => {
|
|||||||
notifyIdle: vi.fn(),
|
notifyIdle: vi.fn(),
|
||||||
enqueueMessageCheck,
|
enqueueMessageCheck,
|
||||||
} as any,
|
} as any,
|
||||||
getRegisteredGroups: () => ({ [chatJid]: group }),
|
getRoomBindings: () => ({ [chatJid]: group }),
|
||||||
getSessions: () => ({}),
|
getSessions: () => ({}),
|
||||||
getLastTimestamp: () => '',
|
getLastTimestamp: () => '',
|
||||||
setLastTimestamp: vi.fn(),
|
setLastTimestamp: vi.fn(),
|
||||||
@@ -1130,7 +1130,7 @@ describe('createMessageRuntime', () => {
|
|||||||
notifyIdle: vi.fn(),
|
notifyIdle: vi.fn(),
|
||||||
enqueueMessageCheck: vi.fn(),
|
enqueueMessageCheck: vi.fn(),
|
||||||
} as any,
|
} as any,
|
||||||
getRegisteredGroups: () => ({ [chatJid]: group }),
|
getRoomBindings: () => ({ [chatJid]: group }),
|
||||||
getSessions: () => ({}),
|
getSessions: () => ({}),
|
||||||
getLastTimestamp: () => '',
|
getLastTimestamp: () => '',
|
||||||
setLastTimestamp: vi.fn(),
|
setLastTimestamp: vi.fn(),
|
||||||
@@ -1229,7 +1229,7 @@ describe('createMessageRuntime', () => {
|
|||||||
senderRole === 'reviewer',
|
senderRole === 'reviewer',
|
||||||
),
|
),
|
||||||
} as any,
|
} as any,
|
||||||
getRegisteredGroups: () => ({ [chatJid]: group }),
|
getRoomBindings: () => ({ [chatJid]: group }),
|
||||||
getSessions: () => ({}),
|
getSessions: () => ({}),
|
||||||
getLastTimestamp: () => '',
|
getLastTimestamp: () => '',
|
||||||
setLastTimestamp: vi.fn(),
|
setLastTimestamp: vi.fn(),
|
||||||
@@ -1320,7 +1320,7 @@ describe('createMessageRuntime', () => {
|
|||||||
noteDirectTerminalDelivery,
|
noteDirectTerminalDelivery,
|
||||||
hasDirectTerminalDeliveryForRun: vi.fn(() => false),
|
hasDirectTerminalDeliveryForRun: vi.fn(() => false),
|
||||||
} as any,
|
} as any,
|
||||||
getRegisteredGroups: () => ({ [chatJid]: group }),
|
getRoomBindings: () => ({ [chatJid]: group }),
|
||||||
getSessions: () => ({}),
|
getSessions: () => ({}),
|
||||||
getLastTimestamp: () => '',
|
getLastTimestamp: () => '',
|
||||||
setLastTimestamp: vi.fn(),
|
setLastTimestamp: vi.fn(),
|
||||||
@@ -1397,7 +1397,7 @@ describe('createMessageRuntime', () => {
|
|||||||
noteDirectTerminalDelivery,
|
noteDirectTerminalDelivery,
|
||||||
hasDirectTerminalDeliveryForRun,
|
hasDirectTerminalDeliveryForRun,
|
||||||
} as any,
|
} as any,
|
||||||
getRegisteredGroups: () => ({ [chatJid]: group }),
|
getRoomBindings: () => ({ [chatJid]: group }),
|
||||||
getSessions: () => ({}),
|
getSessions: () => ({}),
|
||||||
getLastTimestamp: () => '',
|
getLastTimestamp: () => '',
|
||||||
setLastTimestamp: vi.fn(),
|
setLastTimestamp: vi.fn(),
|
||||||
@@ -1513,7 +1513,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
|
|||||||
closeStdin: vi.fn(),
|
closeStdin: vi.fn(),
|
||||||
notifyIdle: vi.fn(),
|
notifyIdle: vi.fn(),
|
||||||
} as any,
|
} as any,
|
||||||
getRegisteredGroups: () => ({ [chatJid]: group }),
|
getRoomBindings: () => ({ [chatJid]: group }),
|
||||||
getSessions: () => ({}),
|
getSessions: () => ({}),
|
||||||
getLastTimestamp: () => '',
|
getLastTimestamp: () => '',
|
||||||
setLastTimestamp: vi.fn(),
|
setLastTimestamp: vi.fn(),
|
||||||
@@ -1619,7 +1619,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
|
|||||||
closeStdin: vi.fn(),
|
closeStdin: vi.fn(),
|
||||||
notifyIdle: vi.fn(),
|
notifyIdle: vi.fn(),
|
||||||
} as any,
|
} as any,
|
||||||
getRegisteredGroups: () => ({ [chatJid]: group }),
|
getRoomBindings: () => ({ [chatJid]: group }),
|
||||||
getSessions: () => ({}),
|
getSessions: () => ({}),
|
||||||
getLastTimestamp: () => '',
|
getLastTimestamp: () => '',
|
||||||
setLastTimestamp: vi.fn(),
|
setLastTimestamp: vi.fn(),
|
||||||
@@ -1848,7 +1848,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
|
|||||||
notifyIdle: vi.fn(),
|
notifyIdle: vi.fn(),
|
||||||
enqueueMessageCheck,
|
enqueueMessageCheck,
|
||||||
} as any,
|
} as any,
|
||||||
getRegisteredGroups: () => ({ [chatJid]: group }),
|
getRoomBindings: () => ({ [chatJid]: group }),
|
||||||
getSessions: () => ({}),
|
getSessions: () => ({}),
|
||||||
getLastTimestamp: () => '',
|
getLastTimestamp: () => '',
|
||||||
setLastTimestamp: vi.fn(),
|
setLastTimestamp: vi.fn(),
|
||||||
@@ -1976,7 +1976,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
|
|||||||
notifyIdle: vi.fn(),
|
notifyIdle: vi.fn(),
|
||||||
enqueueMessageCheck,
|
enqueueMessageCheck,
|
||||||
} as any,
|
} as any,
|
||||||
getRegisteredGroups: () => ({ [chatJid]: group }),
|
getRoomBindings: () => ({ [chatJid]: group }),
|
||||||
getSessions: () => ({}),
|
getSessions: () => ({}),
|
||||||
getLastTimestamp: () => '',
|
getLastTimestamp: () => '',
|
||||||
setLastTimestamp: vi.fn(),
|
setLastTimestamp: vi.fn(),
|
||||||
@@ -2076,7 +2076,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
|
|||||||
notifyIdle: vi.fn(),
|
notifyIdle: vi.fn(),
|
||||||
enqueueMessageCheck,
|
enqueueMessageCheck,
|
||||||
} as any,
|
} as any,
|
||||||
getRegisteredGroups: () => ({ [chatJid]: group }),
|
getRoomBindings: () => ({ [chatJid]: group }),
|
||||||
getSessions: () => ({}),
|
getSessions: () => ({}),
|
||||||
getLastTimestamp: () => '',
|
getLastTimestamp: () => '',
|
||||||
setLastTimestamp: vi.fn(),
|
setLastTimestamp: vi.fn(),
|
||||||
@@ -2161,7 +2161,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
|
|||||||
notifyIdle: vi.fn(),
|
notifyIdle: vi.fn(),
|
||||||
enqueueMessageCheck,
|
enqueueMessageCheck,
|
||||||
} as any,
|
} as any,
|
||||||
getRegisteredGroups: () => ({ [chatJid]: group }),
|
getRoomBindings: () => ({ [chatJid]: group }),
|
||||||
getSessions: () => ({}),
|
getSessions: () => ({}),
|
||||||
getLastTimestamp: () => '',
|
getLastTimestamp: () => '',
|
||||||
setLastTimestamp: vi.fn(),
|
setLastTimestamp: vi.fn(),
|
||||||
@@ -2254,7 +2254,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
|
|||||||
notifyIdle: vi.fn(),
|
notifyIdle: vi.fn(),
|
||||||
enqueueMessageCheck,
|
enqueueMessageCheck,
|
||||||
} as any,
|
} as any,
|
||||||
getRegisteredGroups: () => ({ [chatJid]: group }),
|
getRoomBindings: () => ({ [chatJid]: group }),
|
||||||
getSessions: () => ({}),
|
getSessions: () => ({}),
|
||||||
getLastTimestamp: () => '',
|
getLastTimestamp: () => '',
|
||||||
setLastTimestamp: vi.fn(),
|
setLastTimestamp: vi.fn(),
|
||||||
@@ -2349,7 +2349,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
|
|||||||
notifyIdle: vi.fn(),
|
notifyIdle: vi.fn(),
|
||||||
enqueueMessageCheck,
|
enqueueMessageCheck,
|
||||||
} as any,
|
} as any,
|
||||||
getRegisteredGroups: () => ({ [chatJid]: group }),
|
getRoomBindings: () => ({ [chatJid]: group }),
|
||||||
getSessions: () => ({}),
|
getSessions: () => ({}),
|
||||||
getLastTimestamp: () => '',
|
getLastTimestamp: () => '',
|
||||||
setLastTimestamp: vi.fn(),
|
setLastTimestamp: vi.fn(),
|
||||||
@@ -2462,7 +2462,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
|
|||||||
notifyIdle: vi.fn(),
|
notifyIdle: vi.fn(),
|
||||||
enqueueMessageCheck,
|
enqueueMessageCheck,
|
||||||
} as any,
|
} as any,
|
||||||
getRegisteredGroups: () => ({ [chatJid]: group }),
|
getRoomBindings: () => ({ [chatJid]: group }),
|
||||||
getSessions: () => ({}),
|
getSessions: () => ({}),
|
||||||
getLastTimestamp: () => '',
|
getLastTimestamp: () => '',
|
||||||
setLastTimestamp: vi.fn(),
|
setLastTimestamp: vi.fn(),
|
||||||
@@ -2578,7 +2578,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
|
|||||||
notifyIdle: vi.fn(),
|
notifyIdle: vi.fn(),
|
||||||
enqueueMessageCheck,
|
enqueueMessageCheck,
|
||||||
} as any,
|
} as any,
|
||||||
getRegisteredGroups: () => ({ [chatJid]: group }),
|
getRoomBindings: () => ({ [chatJid]: group }),
|
||||||
getSessions: () => ({}),
|
getSessions: () => ({}),
|
||||||
getLastTimestamp: () => '',
|
getLastTimestamp: () => '',
|
||||||
setLastTimestamp: vi.fn(),
|
setLastTimestamp: vi.fn(),
|
||||||
@@ -2670,7 +2670,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
|
|||||||
notifyIdle: vi.fn(),
|
notifyIdle: vi.fn(),
|
||||||
enqueueMessageCheck: vi.fn(),
|
enqueueMessageCheck: vi.fn(),
|
||||||
} as any,
|
} as any,
|
||||||
getRegisteredGroups: () => ({ [chatJid]: group }),
|
getRoomBindings: () => ({ [chatJid]: group }),
|
||||||
getSessions: () => ({}),
|
getSessions: () => ({}),
|
||||||
getLastTimestamp: () => '',
|
getLastTimestamp: () => '',
|
||||||
setLastTimestamp: vi.fn(),
|
setLastTimestamp: vi.fn(),
|
||||||
@@ -2759,7 +2759,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
|
|||||||
notifyIdle: vi.fn(),
|
notifyIdle: vi.fn(),
|
||||||
enqueueMessageCheck: vi.fn(),
|
enqueueMessageCheck: vi.fn(),
|
||||||
} as any,
|
} as any,
|
||||||
getRegisteredGroups: () => ({ [chatJid]: group }),
|
getRoomBindings: () => ({ [chatJid]: group }),
|
||||||
getSessions: () => ({}),
|
getSessions: () => ({}),
|
||||||
getLastTimestamp: () => '',
|
getLastTimestamp: () => '',
|
||||||
setLastTimestamp: vi.fn(),
|
setLastTimestamp: vi.fn(),
|
||||||
@@ -2823,7 +2823,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
|
|||||||
closeStdin: vi.fn(),
|
closeStdin: vi.fn(),
|
||||||
notifyIdle: vi.fn(),
|
notifyIdle: vi.fn(),
|
||||||
} as any,
|
} as any,
|
||||||
getRegisteredGroups: () => ({ [chatJid]: group }),
|
getRoomBindings: () => ({ [chatJid]: group }),
|
||||||
getSessions: () => ({}),
|
getSessions: () => ({}),
|
||||||
getLastTimestamp: () => '',
|
getLastTimestamp: () => '',
|
||||||
setLastTimestamp: vi.fn(),
|
setLastTimestamp: vi.fn(),
|
||||||
@@ -2896,7 +2896,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
|
|||||||
closeStdin: vi.fn(),
|
closeStdin: vi.fn(),
|
||||||
notifyIdle: vi.fn(),
|
notifyIdle: vi.fn(),
|
||||||
} as any,
|
} as any,
|
||||||
getRegisteredGroups: () => ({ [chatJid]: group }),
|
getRoomBindings: () => ({ [chatJid]: group }),
|
||||||
getSessions: () => ({}),
|
getSessions: () => ({}),
|
||||||
getLastTimestamp: () => '',
|
getLastTimestamp: () => '',
|
||||||
setLastTimestamp: vi.fn(),
|
setLastTimestamp: vi.fn(),
|
||||||
@@ -2966,7 +2966,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
|
|||||||
closeStdin: vi.fn(),
|
closeStdin: vi.fn(),
|
||||||
notifyIdle: vi.fn(),
|
notifyIdle: vi.fn(),
|
||||||
} as any,
|
} as any,
|
||||||
getRegisteredGroups: () => ({ [chatJid]: group }),
|
getRoomBindings: () => ({ [chatJid]: group }),
|
||||||
getSessions: () => ({}),
|
getSessions: () => ({}),
|
||||||
getLastTimestamp: () => '',
|
getLastTimestamp: () => '',
|
||||||
setLastTimestamp: vi.fn(),
|
setLastTimestamp: vi.fn(),
|
||||||
@@ -3085,7 +3085,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
|
|||||||
closeStdin: vi.fn(),
|
closeStdin: vi.fn(),
|
||||||
notifyIdle: vi.fn(),
|
notifyIdle: vi.fn(),
|
||||||
} as any,
|
} as any,
|
||||||
getRegisteredGroups: () => ({ [chatJid]: group }),
|
getRoomBindings: () => ({ [chatJid]: group }),
|
||||||
getSessions: () => ({}),
|
getSessions: () => ({}),
|
||||||
getLastTimestamp: () => '',
|
getLastTimestamp: () => '',
|
||||||
setLastTimestamp: vi.fn(),
|
setLastTimestamp: vi.fn(),
|
||||||
@@ -3200,7 +3200,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
|
|||||||
closeStdin: vi.fn(),
|
closeStdin: vi.fn(),
|
||||||
notifyIdle: vi.fn(),
|
notifyIdle: vi.fn(),
|
||||||
} as any,
|
} as any,
|
||||||
getRegisteredGroups: () => ({ [chatJid]: group }),
|
getRoomBindings: () => ({ [chatJid]: group }),
|
||||||
getSessions: () => ({}),
|
getSessions: () => ({}),
|
||||||
getLastTimestamp: () => '',
|
getLastTimestamp: () => '',
|
||||||
setLastTimestamp: vi.fn(),
|
setLastTimestamp: vi.fn(),
|
||||||
@@ -3219,7 +3219,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
|
|||||||
expect(agentRunner.runAgentProcess).toHaveBeenCalledTimes(1);
|
expect(agentRunner.runAgentProcess).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('allows follow-up messages without a trigger after a visible reply in non-main groups', async () => {
|
it('processes first and follow-up messages without trigger mentions in non-main groups', async () => {
|
||||||
const chatJid = 'group@test';
|
const chatJid = 'group@test';
|
||||||
const group: RegisteredGroup = {
|
const group: RegisteredGroup = {
|
||||||
...makeGroup('codex'),
|
...makeGroup('codex'),
|
||||||
@@ -3236,7 +3236,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
|
|||||||
chat_jid: chatJid,
|
chat_jid: chatJid,
|
||||||
sender: 'user@test',
|
sender: 'user@test',
|
||||||
sender_name: 'User',
|
sender_name: 'User',
|
||||||
content: '@Andy 첫 요청',
|
content: '첫 요청도 멘션 없이 보냄',
|
||||||
timestamp: '2026-03-18T09:00:00.000Z',
|
timestamp: '2026-03-18T09:00:00.000Z',
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
@@ -3278,7 +3278,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
|
|||||||
closeStdin: vi.fn(),
|
closeStdin: vi.fn(),
|
||||||
notifyIdle: vi.fn(),
|
notifyIdle: vi.fn(),
|
||||||
} as any,
|
} as any,
|
||||||
getRegisteredGroups: () => ({ [chatJid]: group }),
|
getRoomBindings: () => ({ [chatJid]: group }),
|
||||||
getSessions: () => ({}),
|
getSessions: () => ({}),
|
||||||
getLastTimestamp: () => '',
|
getLastTimestamp: () => '',
|
||||||
setLastTimestamp: vi.fn(),
|
setLastTimestamp: vi.fn(),
|
||||||
@@ -3289,7 +3289,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
|
|||||||
});
|
});
|
||||||
|
|
||||||
const first = await runtime.processGroupMessages(chatJid, {
|
const first = await runtime.processGroupMessages(chatJid, {
|
||||||
runId: 'run-triggered-first-turn',
|
runId: 'run-triggerless-first-turn',
|
||||||
reason: 'messages',
|
reason: 'messages',
|
||||||
});
|
});
|
||||||
const second = await runtime.processGroupMessages(chatJid, {
|
const second = await runtime.processGroupMessages(chatJid, {
|
||||||
@@ -3362,7 +3362,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
|
|||||||
closeStdin,
|
closeStdin,
|
||||||
notifyIdle,
|
notifyIdle,
|
||||||
} as any,
|
} as any,
|
||||||
getRegisteredGroups: () => ({ [chatJid]: group }),
|
getRoomBindings: () => ({ [chatJid]: group }),
|
||||||
getSessions: () => ({}),
|
getSessions: () => ({}),
|
||||||
getLastTimestamp: () => '',
|
getLastTimestamp: () => '',
|
||||||
setLastTimestamp: vi.fn(),
|
setLastTimestamp: vi.fn(),
|
||||||
@@ -3440,7 +3440,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
|
|||||||
closeStdin,
|
closeStdin,
|
||||||
notifyIdle,
|
notifyIdle,
|
||||||
} as any,
|
} as any,
|
||||||
getRegisteredGroups: () => ({ [chatJid]: group }),
|
getRoomBindings: () => ({ [chatJid]: group }),
|
||||||
getSessions: () => ({}),
|
getSessions: () => ({}),
|
||||||
getLastTimestamp: () => '',
|
getLastTimestamp: () => '',
|
||||||
setLastTimestamp: vi.fn(),
|
setLastTimestamp: vi.fn(),
|
||||||
@@ -3527,7 +3527,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
|
|||||||
closeStdin: vi.fn(),
|
closeStdin: vi.fn(),
|
||||||
notifyIdle,
|
notifyIdle,
|
||||||
} as any,
|
} as any,
|
||||||
getRegisteredGroups: () => ({ [chatJid]: group }),
|
getRoomBindings: () => ({ [chatJid]: group }),
|
||||||
getSessions: () => ({}),
|
getSessions: () => ({}),
|
||||||
getLastTimestamp: () => '',
|
getLastTimestamp: () => '',
|
||||||
setLastTimestamp: vi.fn(),
|
setLastTimestamp: vi.fn(),
|
||||||
@@ -3627,7 +3627,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
|
|||||||
closeStdin: vi.fn(),
|
closeStdin: vi.fn(),
|
||||||
notifyIdle: vi.fn(),
|
notifyIdle: vi.fn(),
|
||||||
} as any,
|
} as any,
|
||||||
getRegisteredGroups: () => ({ [chatJid]: group }),
|
getRoomBindings: () => ({ [chatJid]: group }),
|
||||||
getSessions: () => ({}),
|
getSessions: () => ({}),
|
||||||
getLastTimestamp: () => '',
|
getLastTimestamp: () => '',
|
||||||
setLastTimestamp: vi.fn(),
|
setLastTimestamp: vi.fn(),
|
||||||
@@ -3708,7 +3708,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
|
|||||||
closeStdin: vi.fn(),
|
closeStdin: vi.fn(),
|
||||||
notifyIdle: vi.fn(),
|
notifyIdle: vi.fn(),
|
||||||
} as any,
|
} as any,
|
||||||
getRegisteredGroups: () => ({ [chatJid]: group }),
|
getRoomBindings: () => ({ [chatJid]: group }),
|
||||||
getSessions: () => ({}),
|
getSessions: () => ({}),
|
||||||
getLastTimestamp: () => '',
|
getLastTimestamp: () => '',
|
||||||
setLastTimestamp: vi.fn(),
|
setLastTimestamp: vi.fn(),
|
||||||
@@ -3810,7 +3810,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
|
|||||||
closeStdin,
|
closeStdin,
|
||||||
notifyIdle: vi.fn(),
|
notifyIdle: vi.fn(),
|
||||||
} as any,
|
} as any,
|
||||||
getRegisteredGroups: () => ({ [chatJid]: group }),
|
getRoomBindings: () => ({ [chatJid]: group }),
|
||||||
getSessions: () => ({}),
|
getSessions: () => ({}),
|
||||||
getLastTimestamp: () => '',
|
getLastTimestamp: () => '',
|
||||||
setLastTimestamp: vi.fn(),
|
setLastTimestamp: vi.fn(),
|
||||||
@@ -3921,7 +3921,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
|
|||||||
closeStdin: vi.fn(),
|
closeStdin: vi.fn(),
|
||||||
notifyIdle: vi.fn(),
|
notifyIdle: vi.fn(),
|
||||||
} as any,
|
} as any,
|
||||||
getRegisteredGroups: () => ({ [chatJid]: group }),
|
getRoomBindings: () => ({ [chatJid]: group }),
|
||||||
getSessions: () => ({}),
|
getSessions: () => ({}),
|
||||||
getLastTimestamp: () => '',
|
getLastTimestamp: () => '',
|
||||||
setLastTimestamp: vi.fn(),
|
setLastTimestamp: vi.fn(),
|
||||||
@@ -4025,7 +4025,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
|
|||||||
closeStdin: vi.fn(),
|
closeStdin: vi.fn(),
|
||||||
notifyIdle,
|
notifyIdle,
|
||||||
} as any,
|
} as any,
|
||||||
getRegisteredGroups: () => ({ [chatJid]: group }),
|
getRoomBindings: () => ({ [chatJid]: group }),
|
||||||
getSessions: () => ({}),
|
getSessions: () => ({}),
|
||||||
getLastTimestamp: () => '',
|
getLastTimestamp: () => '',
|
||||||
setLastTimestamp: vi.fn(),
|
setLastTimestamp: vi.fn(),
|
||||||
@@ -4102,7 +4102,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
|
|||||||
closeStdin: vi.fn(),
|
closeStdin: vi.fn(),
|
||||||
notifyIdle: vi.fn(),
|
notifyIdle: vi.fn(),
|
||||||
} as any,
|
} as any,
|
||||||
getRegisteredGroups: () => ({ [chatJid]: group }),
|
getRoomBindings: () => ({ [chatJid]: group }),
|
||||||
getSessions: () => ({}),
|
getSessions: () => ({}),
|
||||||
getLastTimestamp: () => '',
|
getLastTimestamp: () => '',
|
||||||
setLastTimestamp: vi.fn(),
|
setLastTimestamp: vi.fn(),
|
||||||
@@ -4163,7 +4163,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
|
|||||||
closeStdin: vi.fn(),
|
closeStdin: vi.fn(),
|
||||||
notifyIdle: vi.fn(),
|
notifyIdle: vi.fn(),
|
||||||
} as any,
|
} as any,
|
||||||
getRegisteredGroups: () => ({ [chatJid]: group }),
|
getRoomBindings: () => ({ [chatJid]: group }),
|
||||||
getSessions: () => ({}),
|
getSessions: () => ({}),
|
||||||
getLastTimestamp: () => '',
|
getLastTimestamp: () => '',
|
||||||
setLastTimestamp: vi.fn(),
|
setLastTimestamp: vi.fn(),
|
||||||
@@ -4251,7 +4251,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
|
|||||||
closeStdin: vi.fn(),
|
closeStdin: vi.fn(),
|
||||||
notifyIdle: vi.fn(),
|
notifyIdle: vi.fn(),
|
||||||
} as any,
|
} as any,
|
||||||
getRegisteredGroups: () => ({ [chatJid]: group }),
|
getRoomBindings: () => ({ [chatJid]: group }),
|
||||||
getSessions: () => ({}),
|
getSessions: () => ({}),
|
||||||
getLastTimestamp: () => '',
|
getLastTimestamp: () => '',
|
||||||
setLastTimestamp: vi.fn(),
|
setLastTimestamp: vi.fn(),
|
||||||
@@ -4325,7 +4325,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
|
|||||||
closeStdin: vi.fn(),
|
closeStdin: vi.fn(),
|
||||||
notifyIdle: vi.fn(),
|
notifyIdle: vi.fn(),
|
||||||
} as any,
|
} as any,
|
||||||
getRegisteredGroups: () => ({ [chatJid]: group }),
|
getRoomBindings: () => ({ [chatJid]: group }),
|
||||||
getSessions: () => ({}),
|
getSessions: () => ({}),
|
||||||
getLastTimestamp: () => '',
|
getLastTimestamp: () => '',
|
||||||
setLastTimestamp: vi.fn(),
|
setLastTimestamp: vi.fn(),
|
||||||
@@ -4431,7 +4431,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
|
|||||||
closeStdin: vi.fn(),
|
closeStdin: vi.fn(),
|
||||||
notifyIdle: vi.fn(),
|
notifyIdle: vi.fn(),
|
||||||
} as any,
|
} as any,
|
||||||
getRegisteredGroups: () => ({ [chatJid]: group }),
|
getRoomBindings: () => ({ [chatJid]: group }),
|
||||||
getSessions: () => ({}),
|
getSessions: () => ({}),
|
||||||
getLastTimestamp: () => '',
|
getLastTimestamp: () => '',
|
||||||
setLastTimestamp: vi.fn(),
|
setLastTimestamp: vi.fn(),
|
||||||
@@ -4552,7 +4552,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
|
|||||||
closeStdin: vi.fn(),
|
closeStdin: vi.fn(),
|
||||||
notifyIdle: vi.fn(),
|
notifyIdle: vi.fn(),
|
||||||
} as any,
|
} as any,
|
||||||
getRegisteredGroups: () => ({ [chatJid]: group }),
|
getRoomBindings: () => ({ [chatJid]: group }),
|
||||||
getSessions: () => ({}),
|
getSessions: () => ({}),
|
||||||
getLastTimestamp: () => '',
|
getLastTimestamp: () => '',
|
||||||
setLastTimestamp: vi.fn(),
|
setLastTimestamp: vi.fn(),
|
||||||
@@ -4663,7 +4663,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
|
|||||||
closeStdin: vi.fn(),
|
closeStdin: vi.fn(),
|
||||||
notifyIdle: vi.fn(),
|
notifyIdle: vi.fn(),
|
||||||
} as any,
|
} as any,
|
||||||
getRegisteredGroups: () => ({ [chatJid]: group }),
|
getRoomBindings: () => ({ [chatJid]: group }),
|
||||||
getSessions: () => ({}),
|
getSessions: () => ({}),
|
||||||
getLastTimestamp: () => '',
|
getLastTimestamp: () => '',
|
||||||
setLastTimestamp: vi.fn(),
|
setLastTimestamp: vi.fn(),
|
||||||
@@ -4743,7 +4743,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
|
|||||||
closeStdin: vi.fn(),
|
closeStdin: vi.fn(),
|
||||||
notifyIdle: vi.fn(),
|
notifyIdle: vi.fn(),
|
||||||
} as any,
|
} as any,
|
||||||
getRegisteredGroups: () => ({ [chatJid]: group }),
|
getRoomBindings: () => ({ [chatJid]: group }),
|
||||||
getSessions: () => ({}),
|
getSessions: () => ({}),
|
||||||
getLastTimestamp: () => '',
|
getLastTimestamp: () => '',
|
||||||
setLastTimestamp: vi.fn(),
|
setLastTimestamp: vi.fn(),
|
||||||
@@ -4819,7 +4819,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
|
|||||||
closeStdin,
|
closeStdin,
|
||||||
notifyIdle: vi.fn(),
|
notifyIdle: vi.fn(),
|
||||||
} as any,
|
} as any,
|
||||||
getRegisteredGroups: () => ({ [chatJid]: group }),
|
getRoomBindings: () => ({ [chatJid]: group }),
|
||||||
getSessions: () => ({}),
|
getSessions: () => ({}),
|
||||||
getLastTimestamp: () => '',
|
getLastTimestamp: () => '',
|
||||||
setLastTimestamp: vi.fn(),
|
setLastTimestamp: vi.fn(),
|
||||||
@@ -4902,7 +4902,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
|
|||||||
closeStdin: vi.fn(),
|
closeStdin: vi.fn(),
|
||||||
notifyIdle: vi.fn(),
|
notifyIdle: vi.fn(),
|
||||||
} as any,
|
} as any,
|
||||||
getRegisteredGroups: () => ({ [chatJid]: group }),
|
getRoomBindings: () => ({ [chatJid]: group }),
|
||||||
getSessions: () => ({}),
|
getSessions: () => ({}),
|
||||||
getLastTimestamp: () => '',
|
getLastTimestamp: () => '',
|
||||||
setLastTimestamp: vi.fn(),
|
setLastTimestamp: vi.fn(),
|
||||||
@@ -4974,7 +4974,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
|
|||||||
closeStdin: vi.fn(),
|
closeStdin: vi.fn(),
|
||||||
notifyIdle: vi.fn(),
|
notifyIdle: vi.fn(),
|
||||||
} as any,
|
} as any,
|
||||||
getRegisteredGroups: () => ({ [chatJid]: group }),
|
getRoomBindings: () => ({ [chatJid]: group }),
|
||||||
getSessions: () => ({}),
|
getSessions: () => ({}),
|
||||||
getLastTimestamp: () => '',
|
getLastTimestamp: () => '',
|
||||||
setLastTimestamp: vi.fn(),
|
setLastTimestamp: vi.fn(),
|
||||||
@@ -5034,7 +5034,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
|
|||||||
enqueueMessageCheck,
|
enqueueMessageCheck,
|
||||||
enqueueTask,
|
enqueueTask,
|
||||||
} as any,
|
} as any,
|
||||||
getRegisteredGroups: () => ({ [chatJid]: group }),
|
getRoomBindings: () => ({ [chatJid]: group }),
|
||||||
getSessions: () => ({}),
|
getSessions: () => ({}),
|
||||||
getLastTimestamp: () => '',
|
getLastTimestamp: () => '',
|
||||||
setLastTimestamp: vi.fn(),
|
setLastTimestamp: vi.fn(),
|
||||||
@@ -5094,7 +5094,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
|
|||||||
enqueueMessageCheck,
|
enqueueMessageCheck,
|
||||||
enqueueTask,
|
enqueueTask,
|
||||||
} as any,
|
} as any,
|
||||||
getRegisteredGroups: () => ({ [chatJid]: group }),
|
getRoomBindings: () => ({ [chatJid]: group }),
|
||||||
getSessions: () => ({}),
|
getSessions: () => ({}),
|
||||||
getLastTimestamp: () => '',
|
getLastTimestamp: () => '',
|
||||||
setLastTimestamp: vi.fn(),
|
setLastTimestamp: vi.fn(),
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ export interface MessageRuntimeDeps {
|
|||||||
triggerPattern: RegExp;
|
triggerPattern: RegExp;
|
||||||
channels: Channel[];
|
channels: Channel[];
|
||||||
queue: GroupQueue;
|
queue: GroupQueue;
|
||||||
getRegisteredGroups: () => Record<string, RegisteredGroup>;
|
getRoomBindings: () => Record<string, RegisteredGroup>;
|
||||||
getSessions: () => Record<string, string>;
|
getSessions: () => Record<string, string>;
|
||||||
getLastTimestamp: () => string;
|
getLastTimestamp: () => string;
|
||||||
setLastTimestamp: (timestamp: string) => void;
|
setLastTimestamp: (timestamp: string) => void;
|
||||||
@@ -217,7 +217,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
{
|
{
|
||||||
assistantName: deps.assistantName,
|
assistantName: deps.assistantName,
|
||||||
queue: deps.queue,
|
queue: deps.queue,
|
||||||
getRegisteredGroups: deps.getRegisteredGroups,
|
getRoomBindings: deps.getRoomBindings,
|
||||||
getSessions: deps.getSessions,
|
getSessions: deps.getSessions,
|
||||||
persistSession: deps.persistSession,
|
persistSession: deps.persistSession,
|
||||||
clearSession: deps.clearSession,
|
clearSession: deps.clearSession,
|
||||||
@@ -272,6 +272,9 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
getEffectiveChannelLease(chatJid),
|
getEffectiveChannelLease(chatJid),
|
||||||
resolvedDeliveryRole ?? 'owner',
|
resolvedDeliveryRole ?? 'owner',
|
||||||
);
|
);
|
||||||
|
const allowProgressReplayWithoutFinal =
|
||||||
|
args.pairedTurnIdentity?.role !== 'reviewer' &&
|
||||||
|
args.pairedTurnIdentity?.role !== 'arbiter';
|
||||||
const turnController = new MessageTurnController({
|
const turnController = new MessageTurnController({
|
||||||
chatJid,
|
chatJid,
|
||||||
group,
|
group,
|
||||||
@@ -283,6 +286,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
clearSession: () => deps.clearSession(group.folder),
|
clearSession: () => deps.clearSession(group.folder),
|
||||||
requestClose: (reason) =>
|
requestClose: (reason) =>
|
||||||
deps.queue.closeStdin(chatJid, { runId, reason }),
|
deps.queue.closeStdin(chatJid, { runId, reason }),
|
||||||
|
allowProgressReplayWithoutFinal,
|
||||||
deliveryRole: resolvedDeliveryRole,
|
deliveryRole: resolvedDeliveryRole,
|
||||||
deliveryServiceId: resolvedDeliveryServiceId,
|
deliveryServiceId: resolvedDeliveryServiceId,
|
||||||
pairedTurnIdentity: args.pairedTurnIdentity ?? null,
|
pairedTurnIdentity: args.pairedTurnIdentity ?? null,
|
||||||
@@ -426,7 +430,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
processClaimedHandoff: async (handoff) => {
|
processClaimedHandoff: async (handoff) => {
|
||||||
await processClaimedServiceHandoff({
|
await processClaimedServiceHandoff({
|
||||||
handoff,
|
handoff,
|
||||||
getRegisteredGroups: deps.getRegisteredGroups,
|
getRoomBindings: deps.getRoomBindings,
|
||||||
channels: deps.channels,
|
channels: deps.channels,
|
||||||
executeTurn,
|
executeTurn,
|
||||||
lastAgentTimestamps: deps.getLastAgentTimestamps(),
|
lastAgentTimestamps: deps.getLastAgentTimestamps(),
|
||||||
@@ -444,7 +448,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
context: GroupRunContext,
|
context: GroupRunContext,
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
const { runId } = context;
|
const { runId } = context;
|
||||||
const group = deps.getRegisteredGroups()[chatJid];
|
const group = deps.getRoomBindings()[chatJid];
|
||||||
if (!group) return true;
|
if (!group) return true;
|
||||||
const log = createScopedLogger({
|
const log = createScopedLogger({
|
||||||
chatJid,
|
chatJid,
|
||||||
@@ -651,18 +655,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
},
|
},
|
||||||
formatMessages,
|
formatMessages,
|
||||||
isAdminSender: (msg) => isSessionCommandSenderAllowed(msg.sender),
|
isAdminSender: (msg) => isSessionCommandSenderAllowed(msg.sender),
|
||||||
canSenderInteract: (msg) => {
|
canSenderInteract: () => true,
|
||||||
const hasTrigger = deps.triggerPattern.test(msg.content.trim());
|
|
||||||
const requiresTrigger =
|
|
||||||
!isMainGroup && group.requiresTrigger !== false;
|
|
||||||
return (
|
|
||||||
isMainGroup ||
|
|
||||||
!requiresTrigger ||
|
|
||||||
(hasTrigger &&
|
|
||||||
(msg.is_from_me ||
|
|
||||||
isTriggerAllowed(chatJid, msg.sender, loadSenderAllowlist())))
|
|
||||||
);
|
|
||||||
},
|
|
||||||
resetPairedTask: () => {
|
resetPairedTask: () => {
|
||||||
if (hasReviewerLease(chatJid)) {
|
if (hasReviewerLease(chatJid)) {
|
||||||
const task = getLatestOpenPairedTaskForChat(chatJid);
|
const task = getLatestOpenPairedTaskForChat(chatJid);
|
||||||
@@ -717,7 +710,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
}
|
}
|
||||||
messageLoopRunning = true;
|
messageLoopRunning = true;
|
||||||
|
|
||||||
logger.info(`EJClaw running (trigger: @${deps.assistantName})`);
|
logger.info('EJClaw running');
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
try {
|
try {
|
||||||
@@ -727,7 +720,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
triggerPattern: deps.triggerPattern,
|
triggerPattern: deps.triggerPattern,
|
||||||
timezone: deps.timezone,
|
timezone: deps.timezone,
|
||||||
channels: deps.channels,
|
channels: deps.channels,
|
||||||
getRegisteredGroups: deps.getRegisteredGroups,
|
getRoomBindings: deps.getRoomBindings,
|
||||||
getLastTimestamp: deps.getLastTimestamp,
|
getLastTimestamp: deps.getLastTimestamp,
|
||||||
setLastTimestamp: deps.setLastTimestamp,
|
setLastTimestamp: deps.setLastTimestamp,
|
||||||
lastAgentTimestamps: deps.getLastAgentTimestamps(),
|
lastAgentTimestamps: deps.getLastAgentTimestamps(),
|
||||||
@@ -755,7 +748,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
recoverRuntimePendingMessages({
|
recoverRuntimePendingMessages({
|
||||||
assistantName: deps.assistantName,
|
assistantName: deps.assistantName,
|
||||||
channels: deps.channels,
|
channels: deps.channels,
|
||||||
getRegisteredGroups: deps.getRegisteredGroups,
|
getRoomBindings: deps.getRoomBindings,
|
||||||
lastAgentTimestamps: deps.getLastAgentTimestamps(),
|
lastAgentTimestamps: deps.getLastAgentTimestamps(),
|
||||||
saveState: deps.saveState,
|
saveState: deps.saveState,
|
||||||
enqueueScopedGroupMessageCheck,
|
enqueueScopedGroupMessageCheck,
|
||||||
|
|||||||
@@ -221,4 +221,56 @@ describe('MessageTurnController outbound audit logging', () => {
|
|||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not replay the last progress message as a final delivery for paired reviewer turns', async () => {
|
||||||
|
const channel = makeChannel();
|
||||||
|
const deliverFinalText = vi.fn().mockResolvedValue(true);
|
||||||
|
const controller = new MessageTurnController({
|
||||||
|
chatJid: 'dc:test-room',
|
||||||
|
group: makeGroup(),
|
||||||
|
runId: 'run-review-no-fake-final',
|
||||||
|
channel,
|
||||||
|
idleTimeout: 1_000,
|
||||||
|
failureFinalText: '실패',
|
||||||
|
isClaudeCodeAgent: true,
|
||||||
|
clearSession: vi.fn(),
|
||||||
|
requestClose: vi.fn(),
|
||||||
|
deliverFinalText,
|
||||||
|
allowProgressReplayWithoutFinal: false,
|
||||||
|
deliveryRole: 'reviewer',
|
||||||
|
deliveryServiceId: 'codex-review',
|
||||||
|
pairedTurnIdentity: makeTurnIdentity(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await controller.start();
|
||||||
|
await controller.handleOutput({
|
||||||
|
status: 'success',
|
||||||
|
phase: 'progress',
|
||||||
|
result: '확인하겠습니다.',
|
||||||
|
} as any);
|
||||||
|
await controller.handleOutput({
|
||||||
|
status: 'success',
|
||||||
|
phase: 'progress',
|
||||||
|
result: '근거를 다시 대조 중입니다.',
|
||||||
|
} as any);
|
||||||
|
await flushAsync();
|
||||||
|
|
||||||
|
const finishResult = await controller.finish('success');
|
||||||
|
|
||||||
|
expect(finishResult.visiblePhase).toBe('progress');
|
||||||
|
expect(channel.sendAndTrack).toHaveBeenCalledTimes(1);
|
||||||
|
expect(channel.editMessage).toHaveBeenCalledWith(
|
||||||
|
'dc:test-room',
|
||||||
|
'progress-1',
|
||||||
|
expect.stringContaining('확인하겠습니다.'),
|
||||||
|
);
|
||||||
|
expect(deliverFinalText).not.toHaveBeenCalled();
|
||||||
|
expect(getAuditEntries()).not.toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
auditEvent: 'final-delivery-attempt',
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ interface MessageTurnControllerOptions {
|
|||||||
clearSession: () => void;
|
clearSession: () => void;
|
||||||
requestClose: (reason: string) => void;
|
requestClose: (reason: string) => void;
|
||||||
deliverFinalText: (text: string) => Promise<boolean>;
|
deliverFinalText: (text: string) => Promise<boolean>;
|
||||||
|
allowProgressReplayWithoutFinal?: boolean;
|
||||||
deliveryRole?: PairedRoomRole | null;
|
deliveryRole?: PairedRoomRole | null;
|
||||||
deliveryServiceId?: string | null;
|
deliveryServiceId?: string | null;
|
||||||
pairedTurnIdentity?: PairedTurnIdentity | null;
|
pairedTurnIdentity?: PairedTurnIdentity | null;
|
||||||
@@ -338,11 +339,18 @@ export class MessageTurnController {
|
|||||||
!this.hadError &&
|
!this.hadError &&
|
||||||
this.latestProgressTextForFinal
|
this.latestProgressTextForFinal
|
||||||
) {
|
) {
|
||||||
|
await this.finalizeProgressMessage();
|
||||||
|
if (this.options.allowProgressReplayWithoutFinal !== false) {
|
||||||
this.log.info(
|
this.log.info(
|
||||||
'Sending a separate final message from the last progress output after agent completion',
|
'Sending a separate final message from the last progress output after agent completion',
|
||||||
);
|
);
|
||||||
await this.finalizeProgressMessage();
|
|
||||||
await this.deliverFinalText(this.latestProgressTextForFinal);
|
await this.deliverFinalText(this.latestProgressTextForFinal);
|
||||||
|
} else {
|
||||||
|
this.log.info(
|
||||||
|
'Skipped replaying the last progress output as a final message for this turn',
|
||||||
|
);
|
||||||
|
this.latestProgressTextForFinal = null;
|
||||||
|
}
|
||||||
} else if (
|
} else if (
|
||||||
this.visiblePhase === 'progress' &&
|
this.visiblePhase === 'progress' &&
|
||||||
!this.terminalObserved() &&
|
!this.terminalObserved() &&
|
||||||
|
|||||||
@@ -334,6 +334,72 @@ describe('paired execution context', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('preserves the claimed reviewer turn revision for in-review continuations and requires a visible verdict', () => {
|
||||||
|
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({
|
||||||
|
id: 'task-1',
|
||||||
|
chat_jid: 'dc:test',
|
||||||
|
group_folder: group.folder,
|
||||||
|
owner_service_id: 'codex-main',
|
||||||
|
reviewer_service_id: 'codex-review',
|
||||||
|
title: null,
|
||||||
|
source_ref: 'HEAD',
|
||||||
|
plan_notes: null,
|
||||||
|
round_trip_count: 0,
|
||||||
|
review_requested_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
status: 'in_review',
|
||||||
|
arbiter_verdict: null,
|
||||||
|
arbiter_requested_at: null,
|
||||||
|
completion_reason: null,
|
||||||
|
created_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
updated_at: '2026-03-28T00:05:00.000Z',
|
||||||
|
});
|
||||||
|
vi.mocked(db.getPairedTaskById).mockReturnValue({
|
||||||
|
id: 'task-1',
|
||||||
|
chat_jid: 'dc:test',
|
||||||
|
group_folder: group.folder,
|
||||||
|
owner_service_id: 'codex-main',
|
||||||
|
reviewer_service_id: 'codex-review',
|
||||||
|
title: null,
|
||||||
|
source_ref: 'HEAD',
|
||||||
|
plan_notes: null,
|
||||||
|
round_trip_count: 0,
|
||||||
|
review_requested_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
status: 'in_review',
|
||||||
|
arbiter_verdict: null,
|
||||||
|
arbiter_requested_at: null,
|
||||||
|
completion_reason: null,
|
||||||
|
created_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
updated_at: '2026-03-28T00:05:00.000Z',
|
||||||
|
});
|
||||||
|
vi.mocked(
|
||||||
|
pairedWorkspaceManager.prepareReviewerWorkspaceForExecution,
|
||||||
|
).mockReturnValue({
|
||||||
|
workspace: buildWorkspace('reviewer', '/tmp/paired/task-1/reviewer'),
|
||||||
|
autoRefreshed: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = preparePairedExecutionContext({
|
||||||
|
group,
|
||||||
|
chatJid: 'dc:test',
|
||||||
|
runId: 'run-review-continuation',
|
||||||
|
roomRoleContext: reviewerContext,
|
||||||
|
pairedTurnIdentity: {
|
||||||
|
turnId: 'task-1:2026-03-28T00:00:00.000Z:reviewer-turn',
|
||||||
|
taskId: 'task-1',
|
||||||
|
taskUpdatedAt: '2026-03-28T00:00:00.000Z',
|
||||||
|
intentKind: 'reviewer-turn',
|
||||||
|
role: 'reviewer',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result?.claimedTaskUpdatedAt).toBe('2026-03-28T00:00:00.000Z');
|
||||||
|
expect(result?.requiresVisibleVerdict).toBe(true);
|
||||||
|
expect(db.updatePairedTask).not.toHaveBeenCalledWith(
|
||||||
|
'task-1',
|
||||||
|
expect.objectContaining({ status: 'in_review' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('does not change task state when the reviewer snapshot is not ready', () => {
|
it('does not change task state when the reviewer snapshot is not ready', () => {
|
||||||
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({
|
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({
|
||||||
id: 'task-1',
|
id: 'task-1',
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import {
|
|||||||
prepareReviewerWorkspaceForExecution,
|
prepareReviewerWorkspaceForExecution,
|
||||||
provisionOwnerWorkspaceForPairedTask,
|
provisionOwnerWorkspaceForPairedTask,
|
||||||
} from './paired-workspace-manager.js';
|
} from './paired-workspace-manager.js';
|
||||||
|
import type { PairedTurnIdentity } from './paired-turn-identity.js';
|
||||||
import type {
|
import type {
|
||||||
AgentType,
|
AgentType,
|
||||||
PairedRoomRole,
|
PairedRoomRole,
|
||||||
@@ -193,6 +194,7 @@ export function preparePairedExecutionContext(args: {
|
|||||||
runId: string;
|
runId: string;
|
||||||
roomRoleContext?: RoomRoleContext;
|
roomRoleContext?: RoomRoleContext;
|
||||||
hasHumanMessage?: boolean;
|
hasHumanMessage?: boolean;
|
||||||
|
pairedTurnIdentity?: PairedTurnIdentity;
|
||||||
}): PreparedPairedExecutionContext | undefined {
|
}): PreparedPairedExecutionContext | undefined {
|
||||||
const { group, chatJid, roomRoleContext } = args;
|
const { group, chatJid, roomRoleContext } = args;
|
||||||
if (!roomRoleContext || !group.workDir) {
|
if (!roomRoleContext || !group.workDir) {
|
||||||
@@ -210,7 +212,19 @@ export function preparePairedExecutionContext(args: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const latestTask = getPairedTaskById(task.id) ?? task;
|
const latestTask = getPairedTaskById(task.id) ?? task;
|
||||||
const claimedTaskUpdatedAt = latestTask.updated_at;
|
const continuationTurnIdentity = args.pairedTurnIdentity;
|
||||||
|
const claimedTaskUpdatedAt =
|
||||||
|
continuationTurnIdentity &&
|
||||||
|
continuationTurnIdentity.taskId === latestTask.id &&
|
||||||
|
continuationTurnIdentity.role === roomRoleContext.role &&
|
||||||
|
((roomRoleContext.role === 'reviewer' &&
|
||||||
|
continuationTurnIdentity.intentKind === 'reviewer-turn' &&
|
||||||
|
latestTask.status === 'in_review') ||
|
||||||
|
(roomRoleContext.role === 'arbiter' &&
|
||||||
|
continuationTurnIdentity.intentKind === 'arbiter-turn' &&
|
||||||
|
latestTask.status === 'in_arbitration'))
|
||||||
|
? continuationTurnIdentity.taskUpdatedAt
|
||||||
|
: latestTask.updated_at;
|
||||||
let workspace: PairedWorkspace | null = null;
|
let workspace: PairedWorkspace | null = null;
|
||||||
let blockMessage: string | undefined;
|
let blockMessage: string | undefined;
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
@@ -354,6 +368,8 @@ export function preparePairedExecutionContext(args: {
|
|||||||
claimedTaskUpdatedAt,
|
claimedTaskUpdatedAt,
|
||||||
workspace,
|
workspace,
|
||||||
envOverrides,
|
envOverrides,
|
||||||
|
requiresVisibleVerdict:
|
||||||
|
roomRoleContext.role === 'reviewer' || roomRoleContext.role === 'arbiter',
|
||||||
blockMessage,
|
blockMessage,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ function makeGroup(folder: string): RegisteredGroup {
|
|||||||
|
|
||||||
describe('getInterruptedRecoveryCandidates', () => {
|
describe('getInterruptedRecoveryCandidates', () => {
|
||||||
it('returns only registered interrupted groups and deduplicates by chatJid', () => {
|
it('returns only registered interrupted groups and deduplicates by chatJid', () => {
|
||||||
const registeredGroups: Record<string, RegisteredGroup> = {
|
const roomBindings: Record<string, RegisteredGroup> = {
|
||||||
'dc:1': makeGroup('group-one'),
|
'dc:1': makeGroup('group-one'),
|
||||||
'dc:2': makeGroup('group-two'),
|
'dc:2': makeGroup('group-two'),
|
||||||
};
|
};
|
||||||
@@ -64,7 +64,7 @@ describe('getInterruptedRecoveryCandidates', () => {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(getInterruptedRecoveryCandidates(context, registeredGroups)).toEqual(
|
expect(getInterruptedRecoveryCandidates(context, roomBindings)).toEqual(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
chatJid: 'dc:1',
|
chatJid: 'dc:1',
|
||||||
|
|||||||
@@ -53,9 +53,9 @@ function formatKoreanTimestamp(timestamp: string | number | Date): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getMainGroupJid(
|
function getMainGroupJid(
|
||||||
registeredGroups: Record<string, RegisteredGroup>,
|
roomBindings: Record<string, RegisteredGroup>,
|
||||||
): string | null {
|
): string | null {
|
||||||
const mainEntry = Object.entries(registeredGroups).find(
|
const mainEntry = Object.entries(roomBindings).find(
|
||||||
([, group]) => group.isMain === true,
|
([, group]) => group.isMain === true,
|
||||||
);
|
);
|
||||||
return mainEntry?.[0] ?? null;
|
return mainEntry?.[0] ?? null;
|
||||||
@@ -140,7 +140,7 @@ export function writeRestartContext(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function writeShutdownRestartContext(
|
export function writeShutdownRestartContext(
|
||||||
registeredGroups: Record<string, RegisteredGroup>,
|
roomBindings: Record<string, RegisteredGroup>,
|
||||||
interruptedGroups: RestartInterruptedGroup[],
|
interruptedGroups: RestartInterruptedGroup[],
|
||||||
signal: string,
|
signal: string,
|
||||||
serviceIds: string[] = [SERVICE_ID],
|
serviceIds: string[] = [SERVICE_ID],
|
||||||
@@ -149,7 +149,7 @@ export function writeShutdownRestartContext(
|
|||||||
|
|
||||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||||
const mainChatJid =
|
const mainChatJid =
|
||||||
getMainGroupJid(registeredGroups) ?? interruptedGroups[0].chatJid;
|
getMainGroupJid(roomBindings) ?? interruptedGroups[0].chatJid;
|
||||||
const writtenPaths: string[] = [];
|
const writtenPaths: string[] = [];
|
||||||
|
|
||||||
for (const serviceId of serviceIds) {
|
for (const serviceId of serviceIds) {
|
||||||
@@ -233,7 +233,7 @@ export function buildRestartAnnouncement(context: RestartContext): string {
|
|||||||
|
|
||||||
export function getInterruptedRecoveryCandidates(
|
export function getInterruptedRecoveryCandidates(
|
||||||
context: RestartContext | null,
|
context: RestartContext | null,
|
||||||
registeredGroups: Record<string, RegisteredGroup>,
|
roomBindings: Record<string, RegisteredGroup>,
|
||||||
): RestartRecoveryCandidate[] {
|
): RestartRecoveryCandidate[] {
|
||||||
if (!context?.interruptedGroups?.length) return [];
|
if (!context?.interruptedGroups?.length) return [];
|
||||||
|
|
||||||
@@ -242,7 +242,7 @@ export function getInterruptedRecoveryCandidates(
|
|||||||
|
|
||||||
for (const interrupted of context.interruptedGroups) {
|
for (const interrupted of context.interruptedGroups) {
|
||||||
if (seen.has(interrupted.chatJid)) continue;
|
if (seen.has(interrupted.chatJid)) continue;
|
||||||
const group = registeredGroups[interrupted.chatJid];
|
const group = roomBindings[interrupted.chatJid];
|
||||||
if (!group) continue;
|
if (!group) continue;
|
||||||
seen.add(interrupted.chatJid);
|
seen.add(interrupted.chatJid);
|
||||||
candidates.push({
|
candidates.push({
|
||||||
@@ -258,10 +258,10 @@ export function getInterruptedRecoveryCandidates(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function inferRecentRestartContext(
|
export function inferRecentRestartContext(
|
||||||
registeredGroups: Record<string, RegisteredGroup>,
|
roomBindings: Record<string, RegisteredGroup>,
|
||||||
processStartedAtMs: number,
|
processStartedAtMs: number,
|
||||||
): InferredRestartContext | null {
|
): InferredRestartContext | null {
|
||||||
const chatJid = getMainGroupJid(registeredGroups);
|
const chatJid = getMainGroupJid(roomBindings);
|
||||||
if (!chatJid) return null;
|
if (!chatJid) return null;
|
||||||
|
|
||||||
const latestBuildTime = getLatestDistBuildTime();
|
const latestBuildTime = getLatestDistBuildTime();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { beforeEach, describe, expect, it } from 'vitest';
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
_deleteStoredRoomSettingsForTests,
|
||||||
_setRegisteredGroupForTests,
|
_setRegisteredGroupForTests,
|
||||||
_setStoredRoomOwnerAgentTypeForTests,
|
_setStoredRoomOwnerAgentTypeForTests,
|
||||||
_initTestDatabase,
|
_initTestDatabase,
|
||||||
@@ -235,10 +236,36 @@ describe('service-routing global failover', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps the legacy claude fallback for chats without room settings', () => {
|
it('defaults to the configured owner service for chats without canonical room settings', () => {
|
||||||
expect(getEffectiveChannelLease('dc:unregistered')).toMatchObject({
|
expect(getEffectiveChannelLease('dc:unregistered')).toMatchObject({
|
||||||
chat_jid: 'dc:unregistered',
|
chat_jid: 'dc:unregistered',
|
||||||
owner_service_id: 'claude',
|
owner_service_id: 'codex-main',
|
||||||
|
reviewer_service_id: null,
|
||||||
|
owner_failover_active: false,
|
||||||
|
explicit: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores legacy capability rows when canonical room settings are missing', () => {
|
||||||
|
_setRegisteredGroupForTests('dc:legacy-only', {
|
||||||
|
name: 'Legacy Only',
|
||||||
|
folder: 'legacy-only',
|
||||||
|
trigger: '@Andy',
|
||||||
|
added_at: '2024-01-01T00:00:00.000Z',
|
||||||
|
agentType: 'claude-code',
|
||||||
|
});
|
||||||
|
_setRegisteredGroupForTests('dc:legacy-only', {
|
||||||
|
name: 'Legacy Only',
|
||||||
|
folder: 'legacy-only',
|
||||||
|
trigger: '@Codex',
|
||||||
|
added_at: '2024-01-01T00:00:00.000Z',
|
||||||
|
agentType: 'codex',
|
||||||
|
});
|
||||||
|
_deleteStoredRoomSettingsForTests('dc:legacy-only');
|
||||||
|
|
||||||
|
expect(getEffectiveChannelLease('dc:legacy-only')).toMatchObject({
|
||||||
|
chat_jid: 'dc:legacy-only',
|
||||||
|
owner_service_id: 'codex-main',
|
||||||
reviewer_service_id: null,
|
reviewer_service_id: null,
|
||||||
owner_failover_active: false,
|
owner_failover_active: false,
|
||||||
explicit: false,
|
explicit: false,
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
clearChannelOwnerLease,
|
clearChannelOwnerLease,
|
||||||
getAllChannelOwnerLeases,
|
getAllChannelOwnerLeases,
|
||||||
getEffectiveRuntimeRoomMode,
|
getEffectiveRuntimeRoomMode,
|
||||||
getRegisteredAgentTypesForJid,
|
|
||||||
getStoredRoomSettings,
|
getStoredRoomSettings,
|
||||||
type ChannelOwnerLeaseRow,
|
type ChannelOwnerLeaseRow,
|
||||||
} from './db.js';
|
} from './db.js';
|
||||||
@@ -103,32 +102,14 @@ function normalizeLeaseRow(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function inferFallbackOwnerAgentType(
|
|
||||||
hasClaude: boolean,
|
|
||||||
hasCodex: boolean,
|
|
||||||
): AgentType | undefined {
|
|
||||||
if (hasClaude && hasCodex) return OWNER_AGENT_TYPE;
|
|
||||||
if (hasCodex) return 'codex';
|
|
||||||
if (hasClaude) return 'claude-code';
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveDefaultOwnerAgentType(chatJid: string): AgentType | undefined {
|
function resolveDefaultOwnerAgentType(chatJid: string): AgentType | undefined {
|
||||||
const storedOwnerAgentType = getStoredRoomSettings(chatJid)?.ownerAgentType;
|
return getStoredRoomSettings(chatJid)?.ownerAgentType;
|
||||||
if (storedOwnerAgentType) {
|
|
||||||
return storedOwnerAgentType;
|
|
||||||
}
|
|
||||||
|
|
||||||
const types = getRegisteredAgentTypesForJid(chatJid);
|
|
||||||
const hasClaude = types.includes('claude-code');
|
|
||||||
const hasCodex = types.includes('codex');
|
|
||||||
|
|
||||||
return inferFallbackOwnerAgentType(hasClaude, hasCodex);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDefaultLease(chatJid: string): EffectiveChannelLease {
|
function getDefaultLease(chatJid: string): EffectiveChannelLease {
|
||||||
const roomMode = getEffectiveRuntimeRoomMode(chatJid);
|
const roomMode = getEffectiveRuntimeRoomMode(chatJid);
|
||||||
const ownerAgentType = resolveDefaultOwnerAgentType(chatJid) ?? 'claude-code';
|
const ownerAgentType =
|
||||||
|
resolveDefaultOwnerAgentType(chatJid) ?? OWNER_AGENT_TYPE;
|
||||||
const rolePlan = resolveRoleAgentPlan({
|
const rolePlan = resolveRoleAgentPlan({
|
||||||
paired: roomMode === 'tribunal',
|
paired: roomMode === 'tribunal',
|
||||||
groupAgentType: ownerAgentType,
|
groupAgentType: ownerAgentType,
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ describe('extractSessionCommand', () => {
|
|||||||
expect(extractSessionCommand('/compact', trigger)).toBe('/compact');
|
expect(extractSessionCommand('/compact', trigger)).toBe('/compact');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('detects /compact with trigger prefix', () => {
|
it('does not treat bot-name prefixes as commands anymore', () => {
|
||||||
expect(extractSessionCommand('@Andy /compact', trigger)).toBe('/compact');
|
expect(extractSessionCommand('@Andy /compact', trigger)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('detects bare /clear', () => {
|
it('detects bare /clear', () => {
|
||||||
|
|||||||
@@ -14,13 +14,13 @@ const SESSION_COMMAND_CONTROL_PATTERNS = [
|
|||||||
|
|
||||||
function normalizeSessionCommandText(
|
function normalizeSessionCommandText(
|
||||||
content: string,
|
content: string,
|
||||||
triggerPattern: RegExp,
|
_triggerPattern: RegExp,
|
||||||
): string {
|
): string {
|
||||||
return content.trim().replace(triggerPattern, '').trim();
|
return content.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract a session slash command from a message, stripping the trigger prefix if present.
|
* Extract a session slash command from a message.
|
||||||
* Returns the slash command (e.g., '/compact') or null if not a session command.
|
* Returns the slash command (e.g., '/compact') or null if not a session command.
|
||||||
*/
|
*/
|
||||||
export function extractSessionCommand(
|
export function extractSessionCommand(
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export function resolveTaskExecutionContext(
|
|||||||
const groupDir = resolveGroupFolderPath(task.group_folder);
|
const groupDir = resolveGroupFolderPath(task.group_folder);
|
||||||
fs.mkdirSync(groupDir, { recursive: true });
|
fs.mkdirSync(groupDir, { recursive: true });
|
||||||
|
|
||||||
const groups = deps.registeredGroups();
|
const groups = deps.roomBindings();
|
||||||
const group = Object.values(groups).find(
|
const group = Object.values(groups).find(
|
||||||
(registeredGroup) => registeredGroup.folder === task.group_folder,
|
(registeredGroup) => registeredGroup.folder === task.group_folder,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { AgentType, RegisteredGroup } from './types.js';
|
|||||||
|
|
||||||
export interface SchedulerDependencies {
|
export interface SchedulerDependencies {
|
||||||
serviceAgentType?: AgentType;
|
serviceAgentType?: AgentType;
|
||||||
registeredGroups: () => Record<string, RegisteredGroup>;
|
roomBindings: () => Record<string, RegisteredGroup>;
|
||||||
getSessions: () => Record<string, string>;
|
getSessions: () => Record<string, string>;
|
||||||
queue: GroupQueue;
|
queue: GroupQueue;
|
||||||
onProcess: (
|
onProcess: (
|
||||||
|
|||||||
@@ -228,7 +228,7 @@ describe('task scheduler', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
startSchedulerLoop({
|
startSchedulerLoop({
|
||||||
registeredGroups: () => ({}),
|
roomBindings: () => ({}),
|
||||||
getSessions: () => ({}),
|
getSessions: () => ({}),
|
||||||
queue: { enqueueTask } as any,
|
queue: { enqueueTask } as any,
|
||||||
onProcess: () => {},
|
onProcess: () => {},
|
||||||
@@ -274,7 +274,7 @@ describe('task scheduler', () => {
|
|||||||
|
|
||||||
startSchedulerLoop({
|
startSchedulerLoop({
|
||||||
serviceAgentType: 'codex',
|
serviceAgentType: 'codex',
|
||||||
registeredGroups: () => ({
|
roomBindings: () => ({
|
||||||
'shared@g.us': {
|
'shared@g.us': {
|
||||||
name: 'Shared',
|
name: 'Shared',
|
||||||
folder: 'shared-group',
|
folder: 'shared-group',
|
||||||
@@ -324,7 +324,7 @@ describe('task scheduler', () => {
|
|||||||
|
|
||||||
await runSchedulerTickOnce({
|
await runSchedulerTickOnce({
|
||||||
serviceAgentType: 'codex',
|
serviceAgentType: 'codex',
|
||||||
registeredGroups: () => ({
|
roomBindings: () => ({
|
||||||
'shared@g.us': {
|
'shared@g.us': {
|
||||||
name: 'Shared',
|
name: 'Shared',
|
||||||
folder: 'shared-group',
|
folder: 'shared-group',
|
||||||
@@ -372,7 +372,7 @@ describe('task scheduler', () => {
|
|||||||
|
|
||||||
await runSchedulerTickOnce({
|
await runSchedulerTickOnce({
|
||||||
serviceAgentType: 'codex',
|
serviceAgentType: 'codex',
|
||||||
registeredGroups: () => ({
|
roomBindings: () => ({
|
||||||
'shared@g.us': {
|
'shared@g.us': {
|
||||||
name: 'Shared',
|
name: 'Shared',
|
||||||
folder: 'shared-group',
|
folder: 'shared-group',
|
||||||
@@ -425,7 +425,7 @@ describe('task scheduler', () => {
|
|||||||
|
|
||||||
await runSchedulerTickOnce({
|
await runSchedulerTickOnce({
|
||||||
serviceAgentType: 'codex',
|
serviceAgentType: 'codex',
|
||||||
registeredGroups: () => ({
|
roomBindings: () => ({
|
||||||
'shared@g.us': {
|
'shared@g.us': {
|
||||||
name: 'Shared',
|
name: 'Shared',
|
||||||
folder: 'shared-group',
|
folder: 'shared-group',
|
||||||
@@ -496,7 +496,7 @@ describe('task scheduler', () => {
|
|||||||
|
|
||||||
await runSchedulerTickOnce({
|
await runSchedulerTickOnce({
|
||||||
serviceAgentType: 'codex',
|
serviceAgentType: 'codex',
|
||||||
registeredGroups: () => ({
|
roomBindings: () => ({
|
||||||
'paired@g.us': {
|
'paired@g.us': {
|
||||||
name: 'Paired',
|
name: 'Paired',
|
||||||
folder: 'paired-group',
|
folder: 'paired-group',
|
||||||
@@ -568,7 +568,7 @@ describe('task scheduler', () => {
|
|||||||
|
|
||||||
await runSchedulerTickOnce({
|
await runSchedulerTickOnce({
|
||||||
serviceAgentType: 'codex',
|
serviceAgentType: 'codex',
|
||||||
registeredGroups: () => ({
|
roomBindings: () => ({
|
||||||
'paired@g.us': {
|
'paired@g.us': {
|
||||||
name: 'Paired',
|
name: 'Paired',
|
||||||
folder: 'paired-group',
|
folder: 'paired-group',
|
||||||
@@ -608,7 +608,7 @@ describe('task scheduler', () => {
|
|||||||
|
|
||||||
startSchedulerLoop({
|
startSchedulerLoop({
|
||||||
serviceAgentType: 'codex',
|
serviceAgentType: 'codex',
|
||||||
registeredGroups: () => ({
|
roomBindings: () => ({
|
||||||
'shared@g.us': {
|
'shared@g.us': {
|
||||||
name: 'Shared',
|
name: 'Shared',
|
||||||
folder: 'shared-group',
|
folder: 'shared-group',
|
||||||
@@ -658,7 +658,7 @@ Check the run.
|
|||||||
|
|
||||||
startSchedulerLoop({
|
startSchedulerLoop({
|
||||||
serviceAgentType: 'codex',
|
serviceAgentType: 'codex',
|
||||||
registeredGroups: () => ({
|
roomBindings: () => ({
|
||||||
'shared@g.us': {
|
'shared@g.us': {
|
||||||
name: 'Shared',
|
name: 'Shared',
|
||||||
folder: 'shared-group',
|
folder: 'shared-group',
|
||||||
@@ -751,7 +751,7 @@ Check the run.
|
|||||||
|
|
||||||
startSchedulerLoop({
|
startSchedulerLoop({
|
||||||
serviceAgentType: 'claude-code',
|
serviceAgentType: 'claude-code',
|
||||||
registeredGroups: () => ({
|
roomBindings: () => ({
|
||||||
'shared@g.us': {
|
'shared@g.us': {
|
||||||
name: 'Shared',
|
name: 'Shared',
|
||||||
folder: 'shared-group',
|
folder: 'shared-group',
|
||||||
@@ -850,7 +850,7 @@ Check the run.
|
|||||||
|
|
||||||
startSchedulerLoop({
|
startSchedulerLoop({
|
||||||
serviceAgentType: 'claude-code',
|
serviceAgentType: 'claude-code',
|
||||||
registeredGroups: () => ({
|
roomBindings: () => ({
|
||||||
'shared@g.us': {
|
'shared@g.us': {
|
||||||
name: 'Shared',
|
name: 'Shared',
|
||||||
folder: 'shared-group',
|
folder: 'shared-group',
|
||||||
@@ -928,7 +928,7 @@ Check the run.
|
|||||||
|
|
||||||
startSchedulerLoop({
|
startSchedulerLoop({
|
||||||
serviceAgentType: 'claude-code',
|
serviceAgentType: 'claude-code',
|
||||||
registeredGroups: () => ({
|
roomBindings: () => ({
|
||||||
'shared@g.us': {
|
'shared@g.us': {
|
||||||
name: 'Shared',
|
name: 'Shared',
|
||||||
folder: 'shared-group',
|
folder: 'shared-group',
|
||||||
@@ -1021,7 +1021,7 @@ Check the run.
|
|||||||
|
|
||||||
startSchedulerLoop({
|
startSchedulerLoop({
|
||||||
serviceAgentType: 'claude-code',
|
serviceAgentType: 'claude-code',
|
||||||
registeredGroups: () => ({
|
roomBindings: () => ({
|
||||||
'shared@g.us': {
|
'shared@g.us': {
|
||||||
name: 'Shared',
|
name: 'Shared',
|
||||||
folder: 'shared-group',
|
folder: 'shared-group',
|
||||||
@@ -1117,7 +1117,7 @@ Check the run.
|
|||||||
|
|
||||||
startSchedulerLoop({
|
startSchedulerLoop({
|
||||||
serviceAgentType: 'codex',
|
serviceAgentType: 'codex',
|
||||||
registeredGroups: () => ({
|
roomBindings: () => ({
|
||||||
'shared@g.us': {
|
'shared@g.us': {
|
||||||
name: 'Shared',
|
name: 'Shared',
|
||||||
folder: 'shared-group',
|
folder: 'shared-group',
|
||||||
@@ -1149,7 +1149,7 @@ Check the run.
|
|||||||
|
|
||||||
startSchedulerLoop({
|
startSchedulerLoop({
|
||||||
serviceAgentType: 'codex',
|
serviceAgentType: 'codex',
|
||||||
registeredGroups: () => ({
|
roomBindings: () => ({
|
||||||
'shared@g.us': {
|
'shared@g.us': {
|
||||||
name: 'Shared',
|
name: 'Shared',
|
||||||
folder: 'shared-group',
|
folder: 'shared-group',
|
||||||
@@ -1232,7 +1232,7 @@ Check the run.
|
|||||||
|
|
||||||
startSchedulerLoop({
|
startSchedulerLoop({
|
||||||
serviceAgentType: 'codex',
|
serviceAgentType: 'codex',
|
||||||
registeredGroups: () => ({
|
roomBindings: () => ({
|
||||||
'shared@g.us': {
|
'shared@g.us': {
|
||||||
name: 'Shared',
|
name: 'Shared',
|
||||||
folder: 'shared-group',
|
folder: 'shared-group',
|
||||||
@@ -1305,7 +1305,7 @@ Managed by host-driven watcher.
|
|||||||
|
|
||||||
startSchedulerLoop({
|
startSchedulerLoop({
|
||||||
serviceAgentType: 'codex',
|
serviceAgentType: 'codex',
|
||||||
registeredGroups: () => ({
|
roomBindings: () => ({
|
||||||
'shared@g.us': {
|
'shared@g.us': {
|
||||||
name: 'Shared',
|
name: 'Shared',
|
||||||
folder: 'shared-group',
|
folder: 'shared-group',
|
||||||
@@ -1381,7 +1381,7 @@ Managed by host-driven watcher.
|
|||||||
|
|
||||||
startSchedulerLoop({
|
startSchedulerLoop({
|
||||||
serviceAgentType: 'codex',
|
serviceAgentType: 'codex',
|
||||||
registeredGroups: () => ({
|
roomBindings: () => ({
|
||||||
'shared@g.us': {
|
'shared@g.us': {
|
||||||
name: 'Shared',
|
name: 'Shared',
|
||||||
folder: 'shared-group',
|
folder: 'shared-group',
|
||||||
@@ -1444,7 +1444,7 @@ Managed by host-driven watcher.
|
|||||||
|
|
||||||
startSchedulerLoop({
|
startSchedulerLoop({
|
||||||
serviceAgentType: 'codex',
|
serviceAgentType: 'codex',
|
||||||
registeredGroups: () => ({
|
roomBindings: () => ({
|
||||||
'shared@g.us': {
|
'shared@g.us': {
|
||||||
name: 'Shared',
|
name: 'Shared',
|
||||||
folder: 'shared-group',
|
folder: 'shared-group',
|
||||||
@@ -1513,7 +1513,7 @@ Managed by host-driven watcher.
|
|||||||
|
|
||||||
startSchedulerLoop({
|
startSchedulerLoop({
|
||||||
serviceAgentType: 'codex',
|
serviceAgentType: 'codex',
|
||||||
registeredGroups: () => ({
|
roomBindings: () => ({
|
||||||
'shared@g.us': {
|
'shared@g.us': {
|
||||||
name: 'Shared',
|
name: 'Shared',
|
||||||
folder: 'shared-group',
|
folder: 'shared-group',
|
||||||
@@ -1567,7 +1567,7 @@ Check the run.
|
|||||||
|
|
||||||
startSchedulerLoop({
|
startSchedulerLoop({
|
||||||
serviceAgentType: 'codex',
|
serviceAgentType: 'codex',
|
||||||
registeredGroups: () => ({
|
roomBindings: () => ({
|
||||||
'shared@g.us': {
|
'shared@g.us': {
|
||||||
name: 'Shared',
|
name: 'Shared',
|
||||||
folder: 'shared-group',
|
folder: 'shared-group',
|
||||||
@@ -1608,7 +1608,7 @@ Check the run.
|
|||||||
|
|
||||||
await runSchedulerTickOnce({
|
await runSchedulerTickOnce({
|
||||||
serviceAgentType: 'codex',
|
serviceAgentType: 'codex',
|
||||||
registeredGroups: () => ({
|
roomBindings: () => ({
|
||||||
'shared@g.us': {
|
'shared@g.us': {
|
||||||
name: 'Shared',
|
name: 'Shared',
|
||||||
folder: 'shared-group',
|
folder: 'shared-group',
|
||||||
@@ -1651,7 +1651,7 @@ Check the run.
|
|||||||
|
|
||||||
startSchedulerLoop({
|
startSchedulerLoop({
|
||||||
serviceAgentType: 'claude-code',
|
serviceAgentType: 'claude-code',
|
||||||
registeredGroups: () => ({
|
roomBindings: () => ({
|
||||||
'shared@g.us': {
|
'shared@g.us': {
|
||||||
name: 'Shared',
|
name: 'Shared',
|
||||||
folder: 'shared-group',
|
folder: 'shared-group',
|
||||||
|
|||||||
@@ -148,10 +148,10 @@ export function toVisiblePhase(phase: AgentOutputPhase): VisiblePhase {
|
|||||||
export interface RegisteredGroup {
|
export interface RegisteredGroup {
|
||||||
name: string;
|
name: string;
|
||||||
folder: string;
|
folder: string;
|
||||||
trigger: string;
|
trigger?: string;
|
||||||
added_at: string;
|
added_at: string;
|
||||||
agentConfig?: AgentConfig;
|
agentConfig?: AgentConfig;
|
||||||
requiresTrigger?: boolean; // Default: true for groups, false for solo chats
|
requiresTrigger?: boolean; // Compatibility-only legacy field
|
||||||
isMain?: boolean; // True for the main control group (no trigger, elevated privileges)
|
isMain?: boolean; // True for the main control group (no trigger, elevated privileges)
|
||||||
agentType?: AgentType;
|
agentType?: AgentType;
|
||||||
workDir?: string; // Working directory for the agent (defaults to group folder)
|
workDir?: string; // Working directory for the agent (defaults to group folder)
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export interface UnifiedDashboardOptions {
|
|||||||
usageUpdateInterval: number;
|
usageUpdateInterval: number;
|
||||||
channels: Channel[];
|
channels: Channel[];
|
||||||
queue: GroupQueue;
|
queue: GroupQueue;
|
||||||
registeredGroups: () => Record<string, RegisteredGroup>;
|
roomBindings: () => Record<string, RegisteredGroup>;
|
||||||
onGroupNameSynced?: (jid: string, name: string) => void;
|
onGroupNameSynced?: (jid: string, name: string) => void;
|
||||||
purgeOnStart?: boolean;
|
purgeOnStart?: boolean;
|
||||||
}
|
}
|
||||||
@@ -175,7 +175,7 @@ async function refreshChannelMeta(
|
|||||||
);
|
);
|
||||||
if (!channel?.getChannelMeta) return;
|
if (!channel?.getChannelMeta) return;
|
||||||
|
|
||||||
const localJids = Object.keys(opts.registeredGroups()).filter((jid) =>
|
const localJids = Object.keys(opts.roomBindings()).filter((jid) =>
|
||||||
jid.startsWith('dc:'),
|
jid.startsWith('dc:'),
|
||||||
);
|
);
|
||||||
const snapshotJids = readStatusSnapshots(STATUS_SNAPSHOT_MAX_AGE_MS)
|
const snapshotJids = readStatusSnapshots(STATUS_SNAPSHOT_MAX_AGE_MS)
|
||||||
@@ -189,7 +189,7 @@ async function refreshChannelMeta(
|
|||||||
|
|
||||||
for (const [jid, meta] of channelMetaCache) {
|
for (const [jid, meta] of channelMetaCache) {
|
||||||
if (!meta.name) continue;
|
if (!meta.name) continue;
|
||||||
const group = opts.registeredGroups()[jid];
|
const group = opts.roomBindings()[jid];
|
||||||
if (!group || group.name === meta.name) continue;
|
if (!group || group.name === meta.name) continue;
|
||||||
logger.info(
|
logger.info(
|
||||||
{ jid, oldName: group.name, newName: meta.name },
|
{ jid, oldName: group.name, newName: meta.name },
|
||||||
@@ -230,7 +230,7 @@ function formatRoomName(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function writeLocalStatusSnapshot(opts: UnifiedDashboardOptions): void {
|
function writeLocalStatusSnapshot(opts: UnifiedDashboardOptions): void {
|
||||||
const groups = opts.registeredGroups();
|
const groups = opts.roomBindings();
|
||||||
const statuses = opts.queue.getStatuses(Object.keys(groups));
|
const statuses = opts.queue.getStatuses(Object.keys(groups));
|
||||||
|
|
||||||
writeStatusSnapshot({
|
writeStatusSnapshot({
|
||||||
|
|||||||
Reference in New Issue
Block a user