Merge branch 'codex/room-mode-paired-source'
This commit is contained in:
@@ -17,6 +17,7 @@ import {
|
|||||||
getAllRegisteredGroups,
|
getAllRegisteredGroups,
|
||||||
getDueTasks,
|
getDueTasks,
|
||||||
getEffectiveRoomMode,
|
getEffectiveRoomMode,
|
||||||
|
getEffectiveRuntimeRoomMode,
|
||||||
getExplicitRoomMode,
|
getExplicitRoomMode,
|
||||||
getLatestMessageSeqAtOrBefore,
|
getLatestMessageSeqAtOrBefore,
|
||||||
getLatestPairedTaskForChat,
|
getLatestPairedTaskForChat,
|
||||||
@@ -806,7 +807,7 @@ describe('paired room registration', () => {
|
|||||||
expect(isPairedRoomJid('dc:solo')).toBe(false);
|
expect(isPairedRoomJid('dc:solo')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to legacy paired-room inference when no explicit room mode exists', () => {
|
it('falls back to inferred room mode when no explicit room mode exists', () => {
|
||||||
setRegisteredGroup('dc:legacy-paired', {
|
setRegisteredGroup('dc:legacy-paired', {
|
||||||
name: 'Legacy Paired Claude',
|
name: 'Legacy Paired Claude',
|
||||||
folder: 'legacy-paired-claude',
|
folder: 'legacy-paired-claude',
|
||||||
@@ -823,12 +824,12 @@ describe('paired room registration', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(getExplicitRoomMode('dc:legacy-paired')).toBeUndefined();
|
expect(getExplicitRoomMode('dc:legacy-paired')).toBeUndefined();
|
||||||
expect(getEffectiveRoomMode('dc:legacy-paired')).toBe(
|
expect(getEffectiveRoomMode('dc:legacy-paired')).toBe('tribunal');
|
||||||
isPairedRoomJid('dc:legacy-paired') ? 'tribunal' : 'single',
|
expect(getEffectiveRuntimeRoomMode('dc:legacy-paired')).toBe('tribunal');
|
||||||
);
|
expect(isPairedRoomJid('dc:legacy-paired')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('lets explicit single override dual registration without changing legacy paired inference yet', () => {
|
it('lets explicit single override dual registration for paired-room checks', () => {
|
||||||
setRegisteredGroup('dc:explicit-single', {
|
setRegisteredGroup('dc:explicit-single', {
|
||||||
name: 'Explicit Single Claude',
|
name: 'Explicit Single Claude',
|
||||||
folder: 'explicit-single-claude',
|
folder: 'explicit-single-claude',
|
||||||
@@ -848,10 +849,11 @@ describe('paired room registration', () => {
|
|||||||
|
|
||||||
expect(getExplicitRoomMode('dc:explicit-single')).toBe('single');
|
expect(getExplicitRoomMode('dc:explicit-single')).toBe('single');
|
||||||
expect(getEffectiveRoomMode('dc:explicit-single')).toBe('single');
|
expect(getEffectiveRoomMode('dc:explicit-single')).toBe('single');
|
||||||
expect(isPairedRoomJid('dc:explicit-single')).toBe(true);
|
expect(getEffectiveRuntimeRoomMode('dc:explicit-single')).toBe('single');
|
||||||
|
expect(isPairedRoomJid('dc:explicit-single')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('lets explicit tribunal override solo fallback and clears back to inferred mode', () => {
|
it('lets explicit tribunal become runnable when the configured reviewer can run on the solo registration', () => {
|
||||||
setRegisteredGroup('dc:explicit-tribunal', {
|
setRegisteredGroup('dc:explicit-tribunal', {
|
||||||
name: 'Explicit Tribunal Claude',
|
name: 'Explicit Tribunal Claude',
|
||||||
folder: 'explicit-tribunal-claude',
|
folder: 'explicit-tribunal-claude',
|
||||||
@@ -866,11 +868,35 @@ describe('paired room registration', () => {
|
|||||||
|
|
||||||
expect(getExplicitRoomMode('dc:explicit-tribunal')).toBe('tribunal');
|
expect(getExplicitRoomMode('dc:explicit-tribunal')).toBe('tribunal');
|
||||||
expect(getEffectiveRoomMode('dc:explicit-tribunal')).toBe('tribunal');
|
expect(getEffectiveRoomMode('dc:explicit-tribunal')).toBe('tribunal');
|
||||||
|
expect(getEffectiveRuntimeRoomMode('dc:explicit-tribunal')).toBe(
|
||||||
|
'tribunal',
|
||||||
|
);
|
||||||
|
expect(isPairedRoomJid('dc:explicit-tribunal')).toBe(true);
|
||||||
|
|
||||||
clearExplicitRoomMode('dc:explicit-tribunal');
|
clearExplicitRoomMode('dc:explicit-tribunal');
|
||||||
|
|
||||||
expect(getExplicitRoomMode('dc:explicit-tribunal')).toBeUndefined();
|
expect(getExplicitRoomMode('dc:explicit-tribunal')).toBeUndefined();
|
||||||
expect(getEffectiveRoomMode('dc:explicit-tribunal')).toBe('single');
|
expect(getEffectiveRoomMode('dc:explicit-tribunal')).toBe('single');
|
||||||
|
expect(getEffectiveRuntimeRoomMode('dc:explicit-tribunal')).toBe('single');
|
||||||
|
expect(isPairedRoomJid('dc:explicit-tribunal')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps explicit tribunal non-runnable when the configured reviewer service is unavailable', () => {
|
||||||
|
setRegisteredGroup('dc:explicit-tribunal-codex', {
|
||||||
|
name: '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');
|
||||||
|
|
||||||
|
expect(getEffectiveRoomMode('dc:explicit-tribunal-codex')).toBe('tribunal');
|
||||||
|
expect(getEffectiveRuntimeRoomMode('dc:explicit-tribunal-codex')).toBe(
|
||||||
|
'single',
|
||||||
|
);
|
||||||
|
expect(isPairedRoomJid('dc:explicit-tribunal-codex')).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
23
src/db.ts
23
src/db.ts
@@ -9,6 +9,7 @@ import {
|
|||||||
CODEX_REVIEW_SERVICE_ID,
|
CODEX_REVIEW_SERVICE_ID,
|
||||||
DATA_DIR,
|
DATA_DIR,
|
||||||
normalizeServiceId,
|
normalizeServiceId,
|
||||||
|
REVIEWER_AGENT_TYPE,
|
||||||
SERVICE_ID,
|
SERVICE_ID,
|
||||||
SERVICE_SESSION_SCOPE,
|
SERVICE_SESSION_SCOPE,
|
||||||
STORE_DIR,
|
STORE_DIR,
|
||||||
@@ -1978,9 +1979,27 @@ export function getEffectiveRoomMode(chatJid: string): RoomMode {
|
|||||||
return getExplicitRoomMode(chatJid) ?? inferRoomModeForJid(chatJid);
|
return getExplicitRoomMode(chatJid) ?? inferRoomModeForJid(chatJid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
return getEffectiveRoomMode(chatJid) === 'tribunal' &&
|
||||||
|
canRunTribunalFromRegisteredAgentTypes(
|
||||||
|
getRegisteredAgentTypesForJid(chatJid),
|
||||||
|
)
|
||||||
|
? 'tribunal'
|
||||||
|
: 'single';
|
||||||
|
}
|
||||||
|
|
||||||
export function isPairedRoomJid(jid: string): boolean {
|
export function isPairedRoomJid(jid: string): boolean {
|
||||||
const types = getRegisteredAgentTypesForJid(jid);
|
return getEffectiveRuntimeRoomMode(jid) === 'tribunal';
|
||||||
return types.includes('claude-code') && types.includes('codex');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Paired task/project/workspace state ---
|
// --- Paired task/project/workspace state ---
|
||||||
|
|||||||
@@ -338,6 +338,60 @@ describe('runAgentForGroup room memory', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('preserves reviewer role metadata for same-service review turns', async () => {
|
||||||
|
const group = { ...makeGroup(), folder: 'test-group' };
|
||||||
|
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
|
||||||
|
chat_jid: 'group@test',
|
||||||
|
owner_service_id: 'claude',
|
||||||
|
reviewer_service_id: 'claude',
|
||||||
|
arbiter_service_id: null,
|
||||||
|
activated_at: null,
|
||||||
|
reason: null,
|
||||||
|
explicit: false,
|
||||||
|
});
|
||||||
|
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({
|
||||||
|
id: 'paired-task-review',
|
||||||
|
chat_jid: 'group@test',
|
||||||
|
group_folder: 'test-group',
|
||||||
|
owner_service_id: 'claude',
|
||||||
|
reviewer_service_id: 'claude',
|
||||||
|
title: null,
|
||||||
|
source_ref: 'HEAD',
|
||||||
|
plan_notes: null,
|
||||||
|
round_trip_count: 0,
|
||||||
|
review_requested_at: '2026-03-29T00:00:00.000Z',
|
||||||
|
status: 'review_ready',
|
||||||
|
arbiter_verdict: null,
|
||||||
|
arbiter_requested_at: null,
|
||||||
|
completion_reason: null,
|
||||||
|
created_at: '2026-03-29T00:00:00.000Z',
|
||||||
|
updated_at: '2026-03-29T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
await runAgentForGroup(makeDeps(), {
|
||||||
|
group,
|
||||||
|
prompt: 'hello',
|
||||||
|
chatJid: 'group@test',
|
||||||
|
runId: 'run-same-service-reviewer',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(agentRunner.runAgentProcess).toHaveBeenCalledWith(
|
||||||
|
group,
|
||||||
|
expect.objectContaining({
|
||||||
|
roomRoleContext: {
|
||||||
|
serviceId: 'claude',
|
||||||
|
role: 'reviewer',
|
||||||
|
ownerServiceId: 'claude',
|
||||||
|
reviewerServiceId: 'claude',
|
||||||
|
failoverOwner: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
expect.any(Function),
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('allows silent reviewer outputs', async () => {
|
it('allows silent reviewer outputs', async () => {
|
||||||
const group = { ...makeGroup(), folder: 'test-group', workDir: '/repo' };
|
const group = { ...makeGroup(), folder: 'test-group', workDir: '/repo' };
|
||||||
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
|
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
|
||||||
|
|||||||
@@ -172,6 +172,7 @@ export async function runAgentForGroup(
|
|||||||
const roomRoleContext = buildRoomRoleContext(
|
const roomRoleContext = buildRoomRoleContext(
|
||||||
currentLease,
|
currentLease,
|
||||||
effectiveServiceId,
|
effectiveServiceId,
|
||||||
|
activeRole,
|
||||||
);
|
);
|
||||||
const pairedExecutionContext = preparePairedExecutionContext({
|
const pairedExecutionContext = preparePairedExecutionContext({
|
||||||
group,
|
group,
|
||||||
|
|||||||
@@ -180,6 +180,7 @@ import * as db from './db.js';
|
|||||||
import { resolveGroupIpcPath } from './group-folder.js';
|
import { resolveGroupIpcPath } from './group-folder.js';
|
||||||
import { createMessageRuntime } from './message-runtime.js';
|
import { createMessageRuntime } from './message-runtime.js';
|
||||||
import * as config from './config.js';
|
import * as config from './config.js';
|
||||||
|
import * as serviceRouting from './service-routing.js';
|
||||||
import type { Channel, RegisteredGroup } from './types.js';
|
import type { Channel, RegisteredGroup } from './types.js';
|
||||||
|
|
||||||
function makeGroup(agentType: 'claude-code' | 'codex'): RegisteredGroup {
|
function makeGroup(agentType: 'claude-code' | 'codex'): RegisteredGroup {
|
||||||
@@ -869,6 +870,228 @@ describe('createMessageRuntime', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not fabricate owner labels from same-service raw bot history in paired turn prompts', async () => {
|
||||||
|
const chatJid = 'group@test';
|
||||||
|
const group = makeGroup('claude-code');
|
||||||
|
const saveState = vi.fn();
|
||||||
|
const lastAgentTimestamps: Record<string, string> = {};
|
||||||
|
const channel: Channel = {
|
||||||
|
...makeChannel(chatJid),
|
||||||
|
isOwnMessage: vi.fn((msg) => msg.sender === 'shared-bot@test'),
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(db.isPairedRoomJid).mockReturnValue(true);
|
||||||
|
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
|
||||||
|
chat_jid: chatJid,
|
||||||
|
owner_service_id: 'claude',
|
||||||
|
reviewer_service_id: 'claude',
|
||||||
|
arbiter_service_id: null,
|
||||||
|
activated_at: null,
|
||||||
|
reason: null,
|
||||||
|
explicit: false,
|
||||||
|
});
|
||||||
|
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({
|
||||||
|
id: 'task-same-service',
|
||||||
|
chat_jid: chatJid,
|
||||||
|
group_folder: group.folder,
|
||||||
|
owner_service_id: 'claude',
|
||||||
|
reviewer_service_id: 'claude',
|
||||||
|
title: null,
|
||||||
|
source_ref: 'HEAD',
|
||||||
|
plan_notes: null,
|
||||||
|
review_requested_at: '2026-03-30T00:00:00.000Z',
|
||||||
|
round_trip_count: 1,
|
||||||
|
status: 'in_review',
|
||||||
|
arbiter_verdict: null,
|
||||||
|
arbiter_requested_at: null,
|
||||||
|
completion_reason: null,
|
||||||
|
created_at: '2026-03-30T00:00:00.000Z',
|
||||||
|
updated_at: '2026-03-30T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
vi.mocked(db.getPairedTurnOutputs).mockReturnValue([]);
|
||||||
|
vi.mocked(db.getMessagesSince).mockReturnValue([
|
||||||
|
{
|
||||||
|
id: 'human-1',
|
||||||
|
chat_jid: chatJid,
|
||||||
|
sender: 'user@test',
|
||||||
|
sender_name: 'User',
|
||||||
|
content: '계속 진행해줘',
|
||||||
|
timestamp: '2026-03-30T00:00:01.000Z',
|
||||||
|
is_bot_message: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bot-1',
|
||||||
|
chat_jid: chatJid,
|
||||||
|
sender: 'shared-bot@test',
|
||||||
|
sender_name: 'Shared Bot',
|
||||||
|
content: 'reviewer-like reply',
|
||||||
|
timestamp: '2026-03-30T00:00:02.000Z',
|
||||||
|
is_bot_message: true,
|
||||||
|
},
|
||||||
|
] as any);
|
||||||
|
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
|
||||||
|
async (_group, input, _onProcess, onOutput) => {
|
||||||
|
expect(input.prompt).toContain(
|
||||||
|
'<message sender="Shared Bot" time="30 Mar 09:00">reviewer-like reply</message>',
|
||||||
|
);
|
||||||
|
expect(input.prompt).not.toContain(
|
||||||
|
'<message sender="owner" time="30 Mar 09:00">reviewer-like reply</message>',
|
||||||
|
);
|
||||||
|
await onOutput?.({
|
||||||
|
status: 'success',
|
||||||
|
phase: 'final',
|
||||||
|
result: '같은 서비스 턴 확인',
|
||||||
|
newSessionId: 'session-same-service-fallback',
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
status: 'success',
|
||||||
|
result: '같은 서비스 턴 확인',
|
||||||
|
newSessionId: 'session-same-service-fallback',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const runtime = createMessageRuntime({
|
||||||
|
assistantName: 'Andy',
|
||||||
|
idleTimeout: 1_000,
|
||||||
|
pollInterval: 1_000,
|
||||||
|
timezone: 'Asia/Seoul',
|
||||||
|
triggerPattern: /^@Andy\b/i,
|
||||||
|
channels: [channel],
|
||||||
|
queue: {
|
||||||
|
registerProcess: vi.fn(),
|
||||||
|
closeStdin: vi.fn(),
|
||||||
|
notifyIdle: vi.fn(),
|
||||||
|
} as any,
|
||||||
|
getRegisteredGroups: () => ({ [chatJid]: group }),
|
||||||
|
getSessions: () => ({}),
|
||||||
|
getLastTimestamp: () => '',
|
||||||
|
setLastTimestamp: vi.fn(),
|
||||||
|
getLastAgentTimestamps: () => lastAgentTimestamps,
|
||||||
|
saveState,
|
||||||
|
persistSession: vi.fn(),
|
||||||
|
clearSession: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await runtime.processGroupMessages(chatJid, {
|
||||||
|
runId: 'run-same-service-raw-history',
|
||||||
|
reason: 'messages',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(agentRunner.runAgentProcess).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not fabricate owner labels from same-service raw bot history in arbiter prompts', async () => {
|
||||||
|
const chatJid = 'group@test';
|
||||||
|
const group = makeGroup('claude-code');
|
||||||
|
const channel: Channel = {
|
||||||
|
...makeChannel(chatJid),
|
||||||
|
isOwnMessage: vi.fn((msg) => msg.sender === 'shared-bot@test'),
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(db.isPairedRoomJid).mockReturnValue(true);
|
||||||
|
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
|
||||||
|
chat_jid: chatJid,
|
||||||
|
owner_service_id: 'claude',
|
||||||
|
reviewer_service_id: 'claude',
|
||||||
|
arbiter_service_id: null,
|
||||||
|
activated_at: null,
|
||||||
|
reason: null,
|
||||||
|
explicit: false,
|
||||||
|
});
|
||||||
|
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({
|
||||||
|
id: 'task-same-service-arbiter',
|
||||||
|
chat_jid: chatJid,
|
||||||
|
group_folder: group.folder,
|
||||||
|
owner_service_id: 'claude',
|
||||||
|
reviewer_service_id: 'claude',
|
||||||
|
title: null,
|
||||||
|
source_ref: 'HEAD',
|
||||||
|
plan_notes: null,
|
||||||
|
review_requested_at: '2026-03-30T00:00:00.000Z',
|
||||||
|
round_trip_count: 2,
|
||||||
|
status: 'arbiter_requested',
|
||||||
|
arbiter_verdict: null,
|
||||||
|
arbiter_requested_at: '2026-03-30T00:00:10.000Z',
|
||||||
|
completion_reason: null,
|
||||||
|
created_at: '2026-03-30T00:00:00.000Z',
|
||||||
|
updated_at: '2026-03-30T00:00:10.000Z',
|
||||||
|
});
|
||||||
|
vi.mocked(db.getPairedTurnOutputs).mockReturnValue([]);
|
||||||
|
vi.mocked(db.getRecentChatMessages).mockReturnValue([
|
||||||
|
{
|
||||||
|
id: 'human-1',
|
||||||
|
chat_jid: chatJid,
|
||||||
|
sender: 'user@test',
|
||||||
|
sender_name: 'User',
|
||||||
|
content: '둘 중 누가 맞는지 판단해줘',
|
||||||
|
timestamp: '2026-03-30T00:00:01.000Z',
|
||||||
|
is_bot_message: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bot-1',
|
||||||
|
chat_jid: chatJid,
|
||||||
|
sender: 'shared-bot@test',
|
||||||
|
sender_name: 'Shared Bot',
|
||||||
|
content: 'reviewer-like reply',
|
||||||
|
timestamp: '2026-03-30T00:00:02.000Z',
|
||||||
|
is_bot_message: true,
|
||||||
|
},
|
||||||
|
] as any);
|
||||||
|
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
|
||||||
|
async (_group, input, _onProcess, onOutput) => {
|
||||||
|
expect(input.prompt).toContain(
|
||||||
|
'<message sender="Shared Bot" time="30 Mar 09:00">reviewer-like reply</message>',
|
||||||
|
);
|
||||||
|
expect(input.prompt).not.toContain(
|
||||||
|
'<message sender="owner" time="30 Mar 09:00">reviewer-like reply</message>',
|
||||||
|
);
|
||||||
|
await onOutput?.({
|
||||||
|
status: 'success',
|
||||||
|
phase: 'final',
|
||||||
|
result: '중재 문맥 확인',
|
||||||
|
newSessionId: 'session-same-service-arbiter',
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
status: 'success',
|
||||||
|
result: '중재 문맥 확인',
|
||||||
|
newSessionId: 'session-same-service-arbiter',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const runtime = createMessageRuntime({
|
||||||
|
assistantName: 'Andy',
|
||||||
|
idleTimeout: 1_000,
|
||||||
|
pollInterval: 1_000,
|
||||||
|
timezone: 'Asia/Seoul',
|
||||||
|
triggerPattern: /^@Andy\b/i,
|
||||||
|
channels: [channel],
|
||||||
|
queue: {
|
||||||
|
registerProcess: vi.fn(),
|
||||||
|
closeStdin: vi.fn(),
|
||||||
|
notifyIdle: vi.fn(),
|
||||||
|
} as any,
|
||||||
|
getRegisteredGroups: () => ({ [chatJid]: group }),
|
||||||
|
getSessions: () => ({}),
|
||||||
|
getLastTimestamp: () => '',
|
||||||
|
setLastTimestamp: vi.fn(),
|
||||||
|
getLastAgentTimestamps: () => ({}),
|
||||||
|
saveState: vi.fn(),
|
||||||
|
persistSession: vi.fn(),
|
||||||
|
clearSession: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await runtime.processGroupMessages(chatJid, {
|
||||||
|
runId: 'run-same-service-arbiter-fallback',
|
||||||
|
reason: 'messages',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(agentRunner.runAgentProcess).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('allows follow-up messages without a trigger after a visible reply in non-main groups', async () => {
|
it('allows follow-up messages without a trigger after a visible reply in non-main groups', async () => {
|
||||||
const chatJid = 'group@test';
|
const chatJid = 'group@test';
|
||||||
const group: RegisteredGroup = {
|
const group: RegisteredGroup = {
|
||||||
|
|||||||
@@ -132,6 +132,9 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
): NewMessage[] => {
|
): NewMessage[] => {
|
||||||
if (!isPairedRoomJid(chatJid)) return messages;
|
if (!isPairedRoomJid(chatJid)) return messages;
|
||||||
const lease = getEffectiveChannelLease(chatJid);
|
const lease = getEffectiveChannelLease(chatJid);
|
||||||
|
const sharedOwnerReviewerService =
|
||||||
|
lease.reviewer_service_id !== null &&
|
||||||
|
lease.owner_service_id === lease.reviewer_service_id;
|
||||||
// Build bot-user-id → channel-name mapping from connected channels
|
// Build bot-user-id → channel-name mapping from connected channels
|
||||||
const botIdToChannelName = new Map<string, string>();
|
const botIdToChannelName = new Map<string, string>();
|
||||||
for (const ch of deps.channels) {
|
for (const ch of deps.channels) {
|
||||||
@@ -155,6 +158,15 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
if (!channelName) return msg;
|
if (!channelName) return msg;
|
||||||
const serviceId = channelToService[channelName];
|
const serviceId = channelToService[channelName];
|
||||||
if (!serviceId) return msg;
|
if (!serviceId) return msg;
|
||||||
|
// Raw channel history cannot tell owner/reviewer apart when both roles
|
||||||
|
// are delivered by the same service, so avoid fabricating a role label.
|
||||||
|
if (
|
||||||
|
sharedOwnerReviewerService &&
|
||||||
|
serviceId === lease.owner_service_id &&
|
||||||
|
serviceId === lease.reviewer_service_id
|
||||||
|
) {
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
const role =
|
const role =
|
||||||
serviceId === lease.owner_service_id
|
serviceId === lease.owner_service_id
|
||||||
? 'owner'
|
? 'owner'
|
||||||
|
|||||||
@@ -75,6 +75,31 @@ describe('buildRoomRoleContext', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses the preferred reviewer role when owner and reviewer share the same service', () => {
|
||||||
|
expect(
|
||||||
|
buildRoomRoleContext(
|
||||||
|
{
|
||||||
|
chat_jid: 'group@test',
|
||||||
|
owner_service_id: 'claude',
|
||||||
|
reviewer_service_id: 'claude',
|
||||||
|
arbiter_service_id: null,
|
||||||
|
activated_at: null,
|
||||||
|
reason: null,
|
||||||
|
explicit: false,
|
||||||
|
},
|
||||||
|
'claude',
|
||||||
|
'reviewer',
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
serviceId: 'claude',
|
||||||
|
role: 'reviewer',
|
||||||
|
ownerServiceId: 'claude',
|
||||||
|
reviewerServiceId: 'claude',
|
||||||
|
failoverOwner: false,
|
||||||
|
arbiterServiceId: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('returns undefined for a non-paired room', () => {
|
it('returns undefined for a non-paired room', () => {
|
||||||
expect(
|
expect(
|
||||||
buildRoomRoleContext(
|
buildRoomRoleContext(
|
||||||
|
|||||||
@@ -3,12 +3,13 @@ import {
|
|||||||
CODEX_REVIEW_SERVICE_ID,
|
CODEX_REVIEW_SERVICE_ID,
|
||||||
normalizeServiceId,
|
normalizeServiceId,
|
||||||
} from './config.js';
|
} from './config.js';
|
||||||
import type { RoomRoleContext } from './types.js';
|
import type { PairedRoomRole, RoomRoleContext } from './types.js';
|
||||||
import type { EffectiveChannelLease } from './service-routing.js';
|
import type { EffectiveChannelLease } from './service-routing.js';
|
||||||
|
|
||||||
export function buildRoomRoleContext(
|
export function buildRoomRoleContext(
|
||||||
lease: EffectiveChannelLease,
|
lease: EffectiveChannelLease,
|
||||||
serviceId: string,
|
serviceId: string,
|
||||||
|
preferredRole?: PairedRoomRole,
|
||||||
): RoomRoleContext | undefined {
|
): RoomRoleContext | undefined {
|
||||||
const normalizedServiceId = normalizeServiceId(serviceId);
|
const normalizedServiceId = normalizeServiceId(serviceId);
|
||||||
const reviewerServiceId = lease.reviewer_service_id
|
const reviewerServiceId = lease.reviewer_service_id
|
||||||
@@ -24,16 +25,22 @@ export function buildRoomRoleContext(
|
|||||||
? normalizeServiceId(lease.arbiter_service_id)
|
? normalizeServiceId(lease.arbiter_service_id)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
// Check arbiter role first: if this service matches the arbiter_service_id,
|
const matches = {
|
||||||
// it takes the arbiter role (even if it also matches owner or reviewer)
|
owner: ownerServiceId === normalizedServiceId,
|
||||||
|
reviewer: reviewerServiceId === normalizedServiceId,
|
||||||
|
arbiter: arbiterServiceId === normalizedServiceId,
|
||||||
|
};
|
||||||
|
|
||||||
const role =
|
const role =
|
||||||
arbiterServiceId && arbiterServiceId === normalizedServiceId
|
preferredRole && matches[preferredRole]
|
||||||
? 'arbiter'
|
? preferredRole
|
||||||
: ownerServiceId === normalizedServiceId
|
: matches.arbiter
|
||||||
? 'owner'
|
? 'arbiter'
|
||||||
: reviewerServiceId === normalizedServiceId
|
: matches.owner
|
||||||
? 'reviewer'
|
? 'owner'
|
||||||
: null;
|
: matches.reviewer
|
||||||
|
? 'reviewer'
|
||||||
|
: null;
|
||||||
|
|
||||||
if (!role) {
|
if (!role) {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { beforeEach, describe, expect, it } from 'vitest';
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { _initTestDatabase, setRegisteredGroup } from './db.js';
|
import {
|
||||||
|
_initTestDatabase,
|
||||||
|
setExplicitRoomMode,
|
||||||
|
setRegisteredGroup,
|
||||||
|
} from './db.js';
|
||||||
import {
|
import {
|
||||||
activateCodexFailover,
|
activateCodexFailover,
|
||||||
clearGlobalFailover,
|
clearGlobalFailover,
|
||||||
@@ -78,4 +82,67 @@ describe('service-routing global failover', () => {
|
|||||||
explicit: false,
|
explicit: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses explicit single room mode to suppress reviewer lease on dual registration', () => {
|
||||||
|
setRegisteredGroup('dc:explicit-single', {
|
||||||
|
name: 'Explicit Single Claude',
|
||||||
|
folder: 'explicit-single-claude',
|
||||||
|
trigger: '@Andy',
|
||||||
|
added_at: '2024-01-01T00:00:00.000Z',
|
||||||
|
agentType: 'claude-code',
|
||||||
|
});
|
||||||
|
setRegisteredGroup('dc:explicit-single', {
|
||||||
|
name: 'Explicit Single Codex',
|
||||||
|
folder: 'explicit-single-codex',
|
||||||
|
trigger: '@Codex',
|
||||||
|
added_at: '2024-01-01T00:00:00.000Z',
|
||||||
|
agentType: 'codex',
|
||||||
|
});
|
||||||
|
setExplicitRoomMode('dc:explicit-single', 'single');
|
||||||
|
|
||||||
|
expect(getEffectiveChannelLease('dc:explicit-single')).toMatchObject({
|
||||||
|
chat_jid: 'dc:explicit-single',
|
||||||
|
owner_service_id: 'codex-main',
|
||||||
|
reviewer_service_id: null,
|
||||||
|
explicit: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a same-service reviewer lease when explicit tribunal is runnable on a solo registration', () => {
|
||||||
|
setRegisteredGroup('dc:explicit-tribunal', {
|
||||||
|
name: 'Explicit Tribunal Claude',
|
||||||
|
folder: 'explicit-tribunal-claude',
|
||||||
|
trigger: '@Andy',
|
||||||
|
added_at: '2024-01-01T00:00:00.000Z',
|
||||||
|
agentType: 'claude-code',
|
||||||
|
});
|
||||||
|
setExplicitRoomMode('dc:explicit-tribunal', 'tribunal');
|
||||||
|
|
||||||
|
expect(getEffectiveChannelLease('dc:explicit-tribunal')).toMatchObject({
|
||||||
|
chat_jid: 'dc:explicit-tribunal',
|
||||||
|
owner_service_id: 'claude',
|
||||||
|
reviewer_service_id: 'claude',
|
||||||
|
explicit: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps reviewer lease disabled when explicit tribunal cannot deliver the configured reviewer', () => {
|
||||||
|
setRegisteredGroup('dc:explicit-tribunal-codex', {
|
||||||
|
name: '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');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getEffectiveChannelLease('dc:explicit-tribunal-codex'),
|
||||||
|
).toMatchObject({
|
||||||
|
chat_jid: 'dc:explicit-tribunal-codex',
|
||||||
|
owner_service_id: 'codex-main',
|
||||||
|
reviewer_service_id: null,
|
||||||
|
explicit: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
clearChannelOwnerLease,
|
clearChannelOwnerLease,
|
||||||
getAllChannelOwnerLeases,
|
getAllChannelOwnerLeases,
|
||||||
|
getEffectiveRuntimeRoomMode,
|
||||||
getRegisteredAgentTypesForJid,
|
getRegisteredAgentTypesForJid,
|
||||||
setChannelOwnerLease,
|
setChannelOwnerLease,
|
||||||
type ChannelOwnerLeaseRow,
|
type ChannelOwnerLeaseRow,
|
||||||
@@ -58,11 +59,17 @@ function getDefaultLease(chatJid: string): EffectiveChannelLease {
|
|||||||
const types = getRegisteredAgentTypesForJid(chatJid);
|
const types = getRegisteredAgentTypesForJid(chatJid);
|
||||||
const hasClaude = types.includes('claude-code');
|
const hasClaude = types.includes('claude-code');
|
||||||
const hasCodex = types.includes('codex');
|
const hasCodex = types.includes('codex');
|
||||||
|
const roomMode = getEffectiveRuntimeRoomMode(chatJid);
|
||||||
|
|
||||||
if (hasClaude && hasCodex) {
|
if (roomMode === 'tribunal') {
|
||||||
// Owner/reviewer service IDs derived from OWNER_AGENT_TYPE / REVIEWER_AGENT_TYPE env vars
|
|
||||||
const ownerServiceId =
|
const ownerServiceId =
|
||||||
OWNER_AGENT_TYPE === 'codex' ? CODEX_MAIN_SERVICE_ID : CLAUDE_SERVICE_ID;
|
hasClaude && hasCodex
|
||||||
|
? OWNER_AGENT_TYPE === 'codex'
|
||||||
|
? CODEX_MAIN_SERVICE_ID
|
||||||
|
: CLAUDE_SERVICE_ID
|
||||||
|
: hasCodex
|
||||||
|
? CODEX_MAIN_SERVICE_ID
|
||||||
|
: CLAUDE_SERVICE_ID;
|
||||||
return {
|
return {
|
||||||
chat_jid: chatJid,
|
chat_jid: chatJid,
|
||||||
owner_service_id: ownerServiceId,
|
owner_service_id: ownerServiceId,
|
||||||
|
|||||||
Reference in New Issue
Block a user