feat: support room-level reviewer and arbiter agent selection
This commit is contained in:
@@ -798,6 +798,14 @@ If folder is omitted, the host generates one automatically.`,
|
|||||||
.enum(['claude-code', 'codex'])
|
.enum(['claude-code', 'codex'])
|
||||||
.optional()
|
.optional()
|
||||||
.describe('Preferred owner agent type for the room'),
|
.describe('Preferred owner agent type for the room'),
|
||||||
|
reviewer_agent_type: z
|
||||||
|
.enum(['claude-code', 'codex'])
|
||||||
|
.optional()
|
||||||
|
.describe('Optional reviewer agent type override for tribunal rooms'),
|
||||||
|
arbiter_agent_type: z
|
||||||
|
.enum(['claude-code', 'codex'])
|
||||||
|
.optional()
|
||||||
|
.describe('Optional arbiter agent type override for tribunal rooms'),
|
||||||
folder: z
|
folder: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
@@ -826,6 +834,8 @@ If folder is omitted, the host generates one automatically.`,
|
|||||||
name: string;
|
name: string;
|
||||||
room_mode?: 'single' | 'tribunal';
|
room_mode?: 'single' | 'tribunal';
|
||||||
owner_agent_type?: 'claude-code' | 'codex';
|
owner_agent_type?: 'claude-code' | 'codex';
|
||||||
|
reviewer_agent_type?: 'claude-code' | 'codex';
|
||||||
|
arbiter_agent_type?: 'claude-code' | 'codex';
|
||||||
folder?: string;
|
folder?: string;
|
||||||
trigger?: string;
|
trigger?: string;
|
||||||
requires_trigger?: boolean;
|
requires_trigger?: boolean;
|
||||||
@@ -850,6 +860,8 @@ If folder is omitted, the host generates one automatically.`,
|
|||||||
name: args.name,
|
name: args.name,
|
||||||
room_mode: args.room_mode || 'single',
|
room_mode: args.room_mode || 'single',
|
||||||
owner_agent_type: args.owner_agent_type,
|
owner_agent_type: args.owner_agent_type,
|
||||||
|
reviewer_agent_type: args.reviewer_agent_type,
|
||||||
|
arbiter_agent_type: args.arbiter_agent_type,
|
||||||
folder: args.folder,
|
folder: args.folder,
|
||||||
trigger: args.trigger,
|
trigger: args.trigger,
|
||||||
requiresTrigger: args.requires_trigger,
|
requiresTrigger: args.requires_trigger,
|
||||||
|
|||||||
@@ -2396,6 +2396,29 @@ describe('room assignment writes', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('includes arbiter-only room agent overrides in capability views', () => {
|
||||||
|
assignRoom('dc:arbiter-only-capability', {
|
||||||
|
name: 'Arbiter Only Capability',
|
||||||
|
roomMode: 'tribunal',
|
||||||
|
ownerAgentType: 'claude-code',
|
||||||
|
reviewerAgentType: 'claude-code',
|
||||||
|
arbiterAgentType: 'codex',
|
||||||
|
folder: 'arbiter-only-capability',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getRegisteredAgentTypesForJid('dc:arbiter-only-capability').sort()).toEqual([
|
||||||
|
'claude-code',
|
||||||
|
'codex',
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
getAllRoomBindings('codex')['dc:arbiter-only-capability'],
|
||||||
|
).toMatchObject({
|
||||||
|
name: 'Arbiter Only Capability',
|
||||||
|
folder: 'arbiter-only-capability',
|
||||||
|
agentType: 'codex',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('updates room_settings-backed metadata across tribunal capability views', () => {
|
it('updates room_settings-backed metadata across tribunal capability views', () => {
|
||||||
assignRoom('dc:projection-room', {
|
assignRoom('dc:projection-room', {
|
||||||
name: 'Projection Room',
|
name: 'Projection Room',
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export {
|
|||||||
getRouterStateForService,
|
getRouterStateForService,
|
||||||
getSession,
|
getSession,
|
||||||
getSessionForAgentType,
|
getSessionForAgentType,
|
||||||
|
getStoredRoomRoleAgentPlan,
|
||||||
getStoredRoomSettings,
|
getStoredRoomSettings,
|
||||||
getTaskById,
|
getTaskById,
|
||||||
getTasksForGroup,
|
getTasksForGroup,
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import { Database } from 'bun:sqlite';
|
import { Database } from 'bun:sqlite';
|
||||||
|
|
||||||
import { OWNER_AGENT_TYPE, REVIEWER_AGENT_TYPE } from '../config.js';
|
import {
|
||||||
|
ARBITER_AGENT_TYPE,
|
||||||
|
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 RoleAgentPlan, resolveRoleAgentPlan } from '../role-agent-plan.js';
|
||||||
import type { AgentType, RegisteredGroup, RoomMode } from '../types.js';
|
import type { AgentType, RegisteredGroup, RoomMode } from '../types.js';
|
||||||
|
|
||||||
export type RoomModeSource = 'explicit' | 'inferred';
|
export type RoomModeSource = 'explicit' | 'inferred';
|
||||||
@@ -58,8 +63,12 @@ export interface LegacyRoomMigrationPlan {
|
|||||||
export interface SyncRoomRoleOverridesOptions {
|
export interface SyncRoomRoleOverridesOptions {
|
||||||
ownerAgentConfig?: RegisteredGroup['agentConfig'];
|
ownerAgentConfig?: RegisteredGroup['agentConfig'];
|
||||||
ownerCreatedAt?: string;
|
ownerCreatedAt?: string;
|
||||||
|
reviewerAgentType?: AgentType;
|
||||||
reviewerAgentConfig?: RegisteredGroup['agentConfig'];
|
reviewerAgentConfig?: RegisteredGroup['agentConfig'];
|
||||||
reviewerCreatedAt?: string;
|
reviewerCreatedAt?: string;
|
||||||
|
arbiterAgentType?: AgentType | null;
|
||||||
|
arbiterAgentConfig?: RegisteredGroup['agentConfig'];
|
||||||
|
arbiterCreatedAt?: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,14 +216,32 @@ export function getStoredRoomRowsFromDatabase(
|
|||||||
.filter((row): row is StoredRoomSettings => Boolean(row));
|
.filter((row): row is StoredRoomSettings => Boolean(row));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveStoredRoomRoleAgentPlan(
|
||||||
|
database: Database,
|
||||||
|
stored: StoredRoomSettings,
|
||||||
|
): RoleAgentPlan {
|
||||||
|
const overrides = getStoredRoomRoleOverrideRows(database, stored.chatJid);
|
||||||
|
const ownerAgentType =
|
||||||
|
stored.ownerAgentType ?? overrides.get('owner')?.agentType ?? OWNER_AGENT_TYPE;
|
||||||
|
|
||||||
|
return resolveRoleAgentPlan({
|
||||||
|
paired: stored.roomMode === 'tribunal',
|
||||||
|
groupAgentType: ownerAgentType,
|
||||||
|
configuredReviewer:
|
||||||
|
overrides.get('reviewer')?.agentType ?? REVIEWER_AGENT_TYPE,
|
||||||
|
configuredArbiter: overrides.get('arbiter')?.agentType ?? ARBITER_AGENT_TYPE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function buildRegisteredGroupFromStoredSettings(
|
export function buildRegisteredGroupFromStoredSettings(
|
||||||
database: Database,
|
database: Database,
|
||||||
stored: StoredRoomSettings,
|
stored: StoredRoomSettings,
|
||||||
requestedAgentType?: AgentType,
|
requestedAgentType?: AgentType,
|
||||||
): (RegisteredGroup & { jid: string }) | undefined {
|
): (RegisteredGroup & { jid: string }) | undefined {
|
||||||
const capabilityTypes = resolveStoredRoomCapabilityTypes(database, stored);
|
const capabilityTypes = resolveStoredRoomCapabilityTypes(database, stored);
|
||||||
|
const rolePlan = resolveStoredRoomRoleAgentPlan(database, stored);
|
||||||
const ownerAgentType =
|
const ownerAgentType =
|
||||||
stored.ownerAgentType ||
|
rolePlan.ownerAgentType ||
|
||||||
(capabilityTypes.length > 0
|
(capabilityTypes.length > 0
|
||||||
? inferOwnerAgentTypeFromRegisteredAgentTypes(capabilityTypes)
|
? inferOwnerAgentTypeFromRegisteredAgentTypes(capabilityTypes)
|
||||||
: undefined);
|
: undefined);
|
||||||
@@ -233,8 +260,12 @@ export function buildRegisteredGroupFromStoredSettings(
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const role: 'owner' | 'reviewer' =
|
const role: 'owner' | 'reviewer' | 'arbiter' =
|
||||||
ownerAgentType === resolvedAgentType ? 'owner' : 'reviewer';
|
ownerAgentType === resolvedAgentType
|
||||||
|
? 'owner'
|
||||||
|
: rolePlan.reviewerAgentType === resolvedAgentType
|
||||||
|
? 'reviewer'
|
||||||
|
: 'arbiter';
|
||||||
const capabilityMetadata = getStoredRoomRoleOverrideRows(
|
const capabilityMetadata = getStoredRoomRoleOverrideRows(
|
||||||
database,
|
database,
|
||||||
stored.chatJid,
|
stored.chatJid,
|
||||||
@@ -273,24 +304,17 @@ export function resolveStoredRoomCapabilityTypes(
|
|||||||
database: Database,
|
database: Database,
|
||||||
stored: StoredRoomSettings,
|
stored: StoredRoomSettings,
|
||||||
): AgentType[] {
|
): AgentType[] {
|
||||||
const overrides = getStoredRoomRoleOverrideRows(database, stored.chatJid);
|
|
||||||
const ownerAgentType =
|
|
||||||
stored.ownerAgentType ??
|
|
||||||
overrides.get('owner')?.agentType ??
|
|
||||||
OWNER_AGENT_TYPE;
|
|
||||||
|
|
||||||
if (stored.modeSource !== 'explicit') {
|
if (stored.modeSource !== 'explicit') {
|
||||||
return inferStoredRoomCapabilityTypes(database, stored);
|
return inferStoredRoomCapabilityTypes(database, stored);
|
||||||
}
|
}
|
||||||
|
|
||||||
const types = new Set<AgentType>([ownerAgentType]);
|
const rolePlan = resolveStoredRoomRoleAgentPlan(database, stored);
|
||||||
if (stored.roomMode === 'tribunal') {
|
const types = new Set<AgentType>([rolePlan.ownerAgentType]);
|
||||||
const reviewerAgentType =
|
if (stored.roomMode === 'tribunal' && rolePlan.reviewerAgentType) {
|
||||||
overrides.get('reviewer')?.agentType ??
|
types.add(rolePlan.reviewerAgentType);
|
||||||
(ownerAgentType === REVIEWER_AGENT_TYPE
|
}
|
||||||
? OWNER_AGENT_TYPE
|
if (stored.roomMode === 'tribunal' && rolePlan.arbiterAgentType) {
|
||||||
: REVIEWER_AGENT_TYPE);
|
types.add(rolePlan.arbiterAgentType);
|
||||||
types.add(reviewerAgentType);
|
|
||||||
}
|
}
|
||||||
return [...types];
|
return [...types];
|
||||||
}
|
}
|
||||||
@@ -526,11 +550,19 @@ export function syncRoomRoleOverridesForRoom(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (roomMode === 'tribunal') {
|
if (roomMode === 'tribunal') {
|
||||||
const reviewerAgentType =
|
const existingReviewerOverride = existingOverrides.get('reviewer');
|
||||||
ownerAgentType === REVIEWER_AGENT_TYPE
|
const previousDefaultReviewerAgentType =
|
||||||
|
existingOwnerOverride?.agentType === REVIEWER_AGENT_TYPE
|
||||||
? OWNER_AGENT_TYPE
|
? OWNER_AGENT_TYPE
|
||||||
: REVIEWER_AGENT_TYPE;
|
: REVIEWER_AGENT_TYPE;
|
||||||
const existingReviewerOverride = existingOverrides.get('reviewer');
|
const computedReviewerAgentType =
|
||||||
|
ownerAgentType === REVIEWER_AGENT_TYPE ? OWNER_AGENT_TYPE : REVIEWER_AGENT_TYPE;
|
||||||
|
const reviewerAgentType =
|
||||||
|
options.reviewerAgentType ??
|
||||||
|
(existingReviewerOverride &&
|
||||||
|
existingReviewerOverride.agentType !== previousDefaultReviewerAgentType
|
||||||
|
? existingReviewerOverride.agentType
|
||||||
|
: computedReviewerAgentType);
|
||||||
upsertRoomRoleOverride(database, chatJid, {
|
upsertRoomRoleOverride(database, chatJid, {
|
||||||
role: 'reviewer',
|
role: 'reviewer',
|
||||||
agentType: reviewerAgentType,
|
agentType: reviewerAgentType,
|
||||||
@@ -547,6 +579,40 @@ export function syncRoomRoleOverridesForRoom(
|
|||||||
now,
|
now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const existingArbiterOverride = existingOverrides.get('arbiter');
|
||||||
|
const arbiterAgentType =
|
||||||
|
options.arbiterAgentType ??
|
||||||
|
existingArbiterOverride?.agentType ??
|
||||||
|
ARBITER_AGENT_TYPE ??
|
||||||
|
null;
|
||||||
|
|
||||||
|
if (arbiterAgentType) {
|
||||||
|
upsertRoomRoleOverride(database, chatJid, {
|
||||||
|
role: 'arbiter',
|
||||||
|
agentType: arbiterAgentType,
|
||||||
|
agentConfig:
|
||||||
|
options.arbiterAgentConfig ??
|
||||||
|
(existingArbiterOverride?.agentType === arbiterAgentType
|
||||||
|
? existingArbiterOverride.agentConfig
|
||||||
|
: undefined),
|
||||||
|
createdAt:
|
||||||
|
(existingArbiterOverride?.agentType === arbiterAgentType
|
||||||
|
? existingArbiterOverride.createdAt
|
||||||
|
: undefined) ??
|
||||||
|
options.arbiterCreatedAt ??
|
||||||
|
now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
database
|
||||||
|
.prepare(
|
||||||
|
`DELETE FROM room_role_overrides
|
||||||
|
WHERE chat_jid = ?
|
||||||
|
AND role = 'arbiter'`,
|
||||||
|
)
|
||||||
|
.run(chatJid);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
database
|
database
|
||||||
.prepare(
|
.prepare(
|
||||||
@@ -555,6 +621,13 @@ export function syncRoomRoleOverridesForRoom(
|
|||||||
AND role = 'reviewer'`,
|
AND role = 'reviewer'`,
|
||||||
)
|
)
|
||||||
.run(chatJid);
|
.run(chatJid);
|
||||||
|
database
|
||||||
|
.prepare(
|
||||||
|
`DELETE FROM room_role_overrides
|
||||||
|
WHERE chat_jid = ?
|
||||||
|
AND role = 'arbiter'`,
|
||||||
|
)
|
||||||
|
.run(chatJid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
normalizeStoredAgentType,
|
normalizeStoredAgentType,
|
||||||
resolveAssignedRoomFolder,
|
resolveAssignedRoomFolder,
|
||||||
resolveStoredRoomCapabilityTypes,
|
resolveStoredRoomCapabilityTypes,
|
||||||
|
resolveStoredRoomRoleAgentPlan,
|
||||||
syncRoomRoleOverridesForRoom,
|
syncRoomRoleOverridesForRoom,
|
||||||
updateStoredRoomMetadata,
|
updateStoredRoomMetadata,
|
||||||
upsertRoomRoleOverride,
|
upsertRoomRoleOverride,
|
||||||
@@ -31,6 +32,8 @@ export interface AssignRoomInput {
|
|||||||
name: string;
|
name: string;
|
||||||
roomMode?: RoomMode;
|
roomMode?: RoomMode;
|
||||||
ownerAgentType?: AgentType;
|
ownerAgentType?: AgentType;
|
||||||
|
reviewerAgentType?: AgentType;
|
||||||
|
arbiterAgentType?: AgentType | null;
|
||||||
folder?: string;
|
folder?: string;
|
||||||
isMain?: boolean;
|
isMain?: boolean;
|
||||||
workDir?: string;
|
workDir?: string;
|
||||||
@@ -233,6 +236,8 @@ export function assignRoomInDatabase(
|
|||||||
syncRoomRoleOverridesForRoom(database, chatJid, roomMode, ownerAgentType, {
|
syncRoomRoleOverridesForRoom(database, chatJid, roomMode, ownerAgentType, {
|
||||||
ownerAgentConfig: input.ownerAgentConfig,
|
ownerAgentConfig: input.ownerAgentConfig,
|
||||||
ownerCreatedAt: input.addedAt ?? now,
|
ownerCreatedAt: input.addedAt ?? now,
|
||||||
|
reviewerAgentType: input.reviewerAgentType,
|
||||||
|
arbiterAgentType: input.arbiterAgentType,
|
||||||
updatedAt: input.addedAt ?? now,
|
updatedAt: input.addedAt ?? now,
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
@@ -294,6 +299,14 @@ export function getStoredRoomSettingsFromDatabase(
|
|||||||
return getStoredRoomSettingsRowFromDatabase(database, chatJid);
|
return getStoredRoomSettingsRowFromDatabase(database, chatJid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getStoredRoomRoleAgentPlanFromDatabase(
|
||||||
|
database: Database,
|
||||||
|
chatJid: string,
|
||||||
|
): ReturnType<typeof resolveStoredRoomRoleAgentPlan> | undefined {
|
||||||
|
const stored = getStoredRoomSettingsRowFromDatabase(database, chatJid);
|
||||||
|
return stored ? resolveStoredRoomRoleAgentPlan(database, stored) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function getStoredRoomModeRowFromDatabase(
|
function getStoredRoomModeRowFromDatabase(
|
||||||
database: Database,
|
database: Database,
|
||||||
chatJid: string,
|
chatJid: string,
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
getExplicitRoomModeFromDatabase,
|
getExplicitRoomModeFromDatabase,
|
||||||
getRegisteredAgentTypesForJidFromDatabase,
|
getRegisteredAgentTypesForJidFromDatabase,
|
||||||
getRegisteredGroupFromDatabase,
|
getRegisteredGroupFromDatabase,
|
||||||
|
getStoredRoomRoleAgentPlanFromDatabase,
|
||||||
getStoredRoomSettingsFromDatabase,
|
getStoredRoomSettingsFromDatabase,
|
||||||
setExplicitRoomModeInDatabase,
|
setExplicitRoomModeInDatabase,
|
||||||
setRegisteredGroupForTestsInDatabase,
|
setRegisteredGroupForTestsInDatabase,
|
||||||
@@ -354,6 +355,14 @@ export function getStoredRoomSettings(
|
|||||||
return getStoredRoomSettingsFromDatabase(db, chatJid);
|
return getStoredRoomSettingsFromDatabase(db, chatJid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getStoredRoomRoleAgentPlan(
|
||||||
|
chatJid: string,
|
||||||
|
): ReturnType<typeof getStoredRoomRoleAgentPlanFromDatabase> {
|
||||||
|
const db = getDatabaseIfInitialized();
|
||||||
|
if (!db) return undefined;
|
||||||
|
return getStoredRoomRoleAgentPlanFromDatabase(db, chatJid);
|
||||||
|
}
|
||||||
|
|
||||||
export function getExplicitRoomMode(chatJid: string): RoomMode | undefined {
|
export function getExplicitRoomMode(chatJid: string): RoomMode | undefined {
|
||||||
return getExplicitRoomModeFromDatabase(requireDatabase(), chatJid);
|
return getExplicitRoomModeFromDatabase(requireDatabase(), chatJid);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -565,6 +565,73 @@ describe('IPC message authorization', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('injects authorized inbound messages for the source group without routing them as outbound sends', async () => {
|
||||||
|
const sendMessage = vi.fn(async () => {});
|
||||||
|
const injectInboundMessage = vi.fn(async () => {});
|
||||||
|
|
||||||
|
const result = await (forwardAuthorizedIpcMessage as any)(
|
||||||
|
{
|
||||||
|
type: 'inject_inbound_message',
|
||||||
|
chatJid: 'other@g.us',
|
||||||
|
text: 'act like a human input',
|
||||||
|
senderRole: 'owner',
|
||||||
|
sender: '1480575043007418420',
|
||||||
|
senderName: 'algo',
|
||||||
|
treatAsHuman: true,
|
||||||
|
sourceKind: 'trusted_external_bot',
|
||||||
|
},
|
||||||
|
'other-group',
|
||||||
|
false,
|
||||||
|
groups,
|
||||||
|
sendMessage,
|
||||||
|
injectInboundMessage,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(sendMessage).not.toHaveBeenCalled();
|
||||||
|
expect(injectInboundMessage).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
chatJid: 'other@g.us',
|
||||||
|
text: 'act like a human input',
|
||||||
|
sender: '1480575043007418420',
|
||||||
|
senderName: 'algo',
|
||||||
|
treatAsHuman: true,
|
||||||
|
sourceKind: 'trusted_external_bot',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
outcome: 'sent',
|
||||||
|
chatJid: 'other@g.us',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not inject unauthorized inbound messages for another group', async () => {
|
||||||
|
const sendMessage = vi.fn(async () => {});
|
||||||
|
const injectInboundMessage = vi.fn(async () => {});
|
||||||
|
|
||||||
|
const result = await (forwardAuthorizedIpcMessage as any)(
|
||||||
|
{
|
||||||
|
type: 'inject_inbound_message',
|
||||||
|
chatJid: 'third@g.us',
|
||||||
|
text: 'should be blocked',
|
||||||
|
sender: '1480575043007418420',
|
||||||
|
senderName: 'algo',
|
||||||
|
treatAsHuman: true,
|
||||||
|
sourceKind: 'trusted_external_bot',
|
||||||
|
},
|
||||||
|
'other-group',
|
||||||
|
false,
|
||||||
|
groups,
|
||||||
|
sendMessage,
|
||||||
|
injectInboundMessage,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.outcome).toBe('blocked');
|
||||||
|
expect(sendMessage).not.toHaveBeenCalled();
|
||||||
|
expect(injectInboundMessage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('does not forward unauthorized IPC messages even when senderRole exists', async () => {
|
it('does not forward unauthorized IPC messages even when senderRole exists', async () => {
|
||||||
const sendMessage = vi.fn(async () => {});
|
const sendMessage = vi.fn(async () => {});
|
||||||
|
|
||||||
@@ -975,6 +1042,39 @@ describe('assign_room success', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('main group can assign per-room reviewer and arbiter agent types', async () => {
|
||||||
|
await processTaskIpc(
|
||||||
|
{
|
||||||
|
type: 'assign_room',
|
||||||
|
jid: 'tribunal-role-overrides@g.us',
|
||||||
|
name: 'Tribunal Role Overrides',
|
||||||
|
room_mode: 'tribunal',
|
||||||
|
owner_agent_type: 'claude-code',
|
||||||
|
reviewer_agent_type: 'codex',
|
||||||
|
arbiter_agent_type: 'claude-code',
|
||||||
|
},
|
||||||
|
'whatsapp_main',
|
||||||
|
true,
|
||||||
|
deps,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getStoredRoomSettings('tribunal-role-overrides@g.us')).toMatchObject({
|
||||||
|
chatJid: 'tribunal-role-overrides@g.us',
|
||||||
|
roomMode: 'tribunal',
|
||||||
|
modeSource: 'explicit',
|
||||||
|
name: 'Tribunal Role Overrides',
|
||||||
|
ownerAgentType: 'claude-code',
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
getRegisteredAgentTypesForJid('tribunal-role-overrides@g.us').sort(),
|
||||||
|
).toEqual(['claude-code', 'codex']);
|
||||||
|
expect(
|
||||||
|
getRegisteredGroup('tribunal-role-overrides@g.us', 'codex'),
|
||||||
|
).toMatchObject({
|
||||||
|
agentType: 'codex',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('assign_room rejects request with missing fields', async () => {
|
it('assign_room rejects request with missing fields', async () => {
|
||||||
await processTaskIpc(
|
await processTaskIpc(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -241,6 +241,69 @@ export async function processTaskIpc(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.action === 'ejclaw_room_runtime') {
|
||||||
|
if (!data.chatJid) {
|
||||||
|
writeHostEvidenceResponse(sourceGroup, {
|
||||||
|
requestId: data.requestId,
|
||||||
|
ok: false,
|
||||||
|
action: 'ejclaw_room_runtime',
|
||||||
|
command: 'internal:ejclaw_room_runtime',
|
||||||
|
stdout: '',
|
||||||
|
stderr: '',
|
||||||
|
exitCode: 1,
|
||||||
|
error: 'Missing chatJid for ejclaw_room_runtime request',
|
||||||
|
});
|
||||||
|
logger.warn(
|
||||||
|
{ sourceGroup, requestId: data.requestId },
|
||||||
|
'Rejected ejclaw_room_runtime request without chatJid',
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!deps.getRoomRuntimeReport) {
|
||||||
|
writeHostEvidenceResponse(sourceGroup, {
|
||||||
|
requestId: data.requestId,
|
||||||
|
ok: false,
|
||||||
|
action: 'ejclaw_room_runtime',
|
||||||
|
command: 'internal:ejclaw_room_runtime',
|
||||||
|
stdout: '',
|
||||||
|
stderr: '',
|
||||||
|
exitCode: 1,
|
||||||
|
error: 'Room runtime reporting is not configured',
|
||||||
|
});
|
||||||
|
logger.warn(
|
||||||
|
{ sourceGroup, requestId: data.requestId, chatJid: data.chatJid },
|
||||||
|
'Rejected ejclaw_room_runtime request because runtime reporter is unavailable',
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const report = deps.getRoomRuntimeReport({
|
||||||
|
chatJid: data.chatJid,
|
||||||
|
sourceGroup,
|
||||||
|
isMain,
|
||||||
|
});
|
||||||
|
writeHostEvidenceResponse(sourceGroup, {
|
||||||
|
requestId: data.requestId,
|
||||||
|
ok: true,
|
||||||
|
action: 'ejclaw_room_runtime',
|
||||||
|
command: 'internal:ejclaw_room_runtime',
|
||||||
|
stdout: JSON.stringify(report, null, 2),
|
||||||
|
stderr: '',
|
||||||
|
exitCode: 0,
|
||||||
|
});
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
sourceGroup,
|
||||||
|
requestId: data.requestId,
|
||||||
|
action: data.action,
|
||||||
|
chatJid: data.chatJid,
|
||||||
|
},
|
||||||
|
'Processed room runtime report request via IPC',
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
if (!isHostEvidenceAction(data.action)) {
|
if (!isHostEvidenceAction(data.action)) {
|
||||||
writeHostEvidenceResponse(sourceGroup, {
|
writeHostEvidenceResponse(sourceGroup, {
|
||||||
requestId: data.requestId,
|
requestId: data.requestId,
|
||||||
@@ -405,11 +468,36 @@ export async function processTaskIpc(
|
|||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
data.reviewer_agent_type !== undefined &&
|
||||||
|
data.reviewer_agent_type !== 'claude-code' &&
|
||||||
|
data.reviewer_agent_type !== 'codex'
|
||||||
|
) {
|
||||||
|
logger.warn(
|
||||||
|
{ sourceGroup, reviewerAgentType: data.reviewer_agent_type },
|
||||||
|
'Invalid assign_room request - unknown reviewer_agent_type',
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
data.arbiter_agent_type !== undefined &&
|
||||||
|
data.arbiter_agent_type !== null &&
|
||||||
|
data.arbiter_agent_type !== 'claude-code' &&
|
||||||
|
data.arbiter_agent_type !== 'codex'
|
||||||
|
) {
|
||||||
|
logger.warn(
|
||||||
|
{ sourceGroup, arbiterAgentType: data.arbiter_agent_type },
|
||||||
|
'Invalid assign_room request - unknown arbiter_agent_type',
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
deps.assignRoom(data.jid, {
|
deps.assignRoom(data.jid, {
|
||||||
name: data.name,
|
name: data.name,
|
||||||
roomMode: data.room_mode,
|
roomMode: data.room_mode,
|
||||||
ownerAgentType: data.owner_agent_type,
|
ownerAgentType: data.owner_agent_type,
|
||||||
|
reviewerAgentType: data.reviewer_agent_type,
|
||||||
|
arbiterAgentType: data.arbiter_agent_type,
|
||||||
folder: data.folder,
|
folder: data.folder,
|
||||||
isMain: data.isMain,
|
isMain: data.isMain,
|
||||||
workDir: data.workDir,
|
workDir: data.workDir,
|
||||||
|
|||||||
@@ -1,6 +1,65 @@
|
|||||||
import type { AvailableGroup } from './agent-runner.js';
|
import type { AvailableGroup } from './agent-runner.js';
|
||||||
import type { AssignRoomInput } from './db.js';
|
import type { AssignRoomInput } from './db.js';
|
||||||
import type { AgentType, RegisteredGroup, RoomMode } from './types.js';
|
import type {
|
||||||
|
AgentType,
|
||||||
|
MessageSourceKind,
|
||||||
|
PairedTask,
|
||||||
|
RegisteredGroup,
|
||||||
|
RoomMode,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
export interface InjectInboundMessagePayload {
|
||||||
|
chatJid: string;
|
||||||
|
text: string;
|
||||||
|
sender?: string;
|
||||||
|
senderName?: string;
|
||||||
|
messageId?: string;
|
||||||
|
timestamp?: string;
|
||||||
|
treatAsHuman: boolean;
|
||||||
|
sourceKind?: MessageSourceKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoomRuntimeReport {
|
||||||
|
chatJid: string;
|
||||||
|
groupFolder: string;
|
||||||
|
room: RegisteredGroup | null;
|
||||||
|
queue: {
|
||||||
|
status: 'processing' | 'waiting' | 'inactive';
|
||||||
|
runPhase: string;
|
||||||
|
elapsedMs: number | null;
|
||||||
|
pendingMessages: boolean;
|
||||||
|
pendingTasks: number;
|
||||||
|
currentRunId: string | null;
|
||||||
|
runningTaskId: string | null;
|
||||||
|
processName: string | null;
|
||||||
|
ipcDir: string | null;
|
||||||
|
retryCount: number;
|
||||||
|
retryScheduledAt: number | null;
|
||||||
|
waiting: boolean;
|
||||||
|
};
|
||||||
|
latestOpenTask: Pick<
|
||||||
|
PairedTask,
|
||||||
|
| 'id'
|
||||||
|
| 'status'
|
||||||
|
| 'title'
|
||||||
|
| 'round_trip_count'
|
||||||
|
| 'updated_at'
|
||||||
|
| 'owner_agent_type'
|
||||||
|
| 'reviewer_agent_type'
|
||||||
|
| 'arbiter_agent_type'
|
||||||
|
> | null;
|
||||||
|
recentMessages: Array<{
|
||||||
|
id: string;
|
||||||
|
seq?: number;
|
||||||
|
timestamp: string;
|
||||||
|
sender: string;
|
||||||
|
senderName: string;
|
||||||
|
isFromMe: boolean;
|
||||||
|
isBotMessage: boolean;
|
||||||
|
sourceKind: MessageSourceKind;
|
||||||
|
contentPreview: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IpcDeps {
|
export interface IpcDeps {
|
||||||
sendMessage: (
|
sendMessage: (
|
||||||
@@ -19,6 +78,12 @@ export interface IpcDeps {
|
|||||||
isMain: boolean,
|
isMain: boolean,
|
||||||
availableGroups: AvailableGroup[],
|
availableGroups: AvailableGroup[],
|
||||||
) => void;
|
) => void;
|
||||||
|
injectInboundMessage?: (payload: InjectInboundMessagePayload) => Promise<void>;
|
||||||
|
getRoomRuntimeReport?: (args: {
|
||||||
|
chatJid: string;
|
||||||
|
sourceGroup: string;
|
||||||
|
isMain: boolean;
|
||||||
|
}) => RoomRuntimeReport;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IpcMessagePayload {
|
export interface IpcMessagePayload {
|
||||||
@@ -27,6 +92,12 @@ export interface IpcMessagePayload {
|
|||||||
text?: string;
|
text?: string;
|
||||||
senderRole?: string;
|
senderRole?: string;
|
||||||
runId?: string;
|
runId?: string;
|
||||||
|
sender?: string;
|
||||||
|
senderName?: string;
|
||||||
|
messageId?: string;
|
||||||
|
timestamp?: string;
|
||||||
|
treatAsHuman?: boolean;
|
||||||
|
sourceKind?: MessageSourceKind;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IpcMessageForwardResult {
|
export interface IpcMessageForwardResult {
|
||||||
@@ -54,6 +125,8 @@ export interface TaskIpcPayload {
|
|||||||
folder?: string;
|
folder?: string;
|
||||||
room_mode?: RoomMode;
|
room_mode?: RoomMode;
|
||||||
owner_agent_type?: AgentType;
|
owner_agent_type?: AgentType;
|
||||||
|
reviewer_agent_type?: AgentType;
|
||||||
|
arbiter_agent_type?: AgentType | null;
|
||||||
isMain?: boolean;
|
isMain?: boolean;
|
||||||
workDir?: string;
|
workDir?: string;
|
||||||
scopeKind?: string;
|
scopeKind?: string;
|
||||||
|
|||||||
@@ -315,6 +315,28 @@ describe('message-runtime-rules', () => {
|
|||||||
expect(resolution.effectiveServiceId).toBe(resolution.reviewerServiceId);
|
expect(resolution.effectiveServiceId).toBe(resolution.reviewerServiceId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses lease reviewer metadata when the room reviewer differs from the owner agent type', () => {
|
||||||
|
const resolution = resolveExecutionTarget({
|
||||||
|
lease: {
|
||||||
|
...baseLease,
|
||||||
|
owner_agent_type: 'claude-code',
|
||||||
|
reviewer_agent_type: 'codex',
|
||||||
|
reviewer_service_id: 'codex-review',
|
||||||
|
},
|
||||||
|
pairedTaskStatus: 'review_ready',
|
||||||
|
groupFolder: 'group-1',
|
||||||
|
groupAgentType: 'claude-code',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolution).toMatchObject({
|
||||||
|
activeRole: 'reviewer',
|
||||||
|
configuredAgentType: 'codex',
|
||||||
|
effectiveAgentType: 'codex',
|
||||||
|
sessionFolder: 'group-1:reviewer',
|
||||||
|
});
|
||||||
|
expect(resolution.effectiveServiceId).toBe('codex-review');
|
||||||
|
});
|
||||||
|
|
||||||
it('resolves merge_ready execution target back to the owner/finalize path', () => {
|
it('resolves merge_ready execution target back to the owner/finalize path', () => {
|
||||||
const resolution = resolveExecutionTarget({
|
const resolution = resolveExecutionTarget({
|
||||||
lease: baseLease,
|
lease: baseLease,
|
||||||
@@ -346,8 +368,8 @@ describe('message-runtime-rules', () => {
|
|||||||
expect(resolution).toMatchObject({
|
expect(resolution).toMatchObject({
|
||||||
inferredRole: 'arbiter',
|
inferredRole: 'arbiter',
|
||||||
activeRole: 'arbiter',
|
activeRole: 'arbiter',
|
||||||
configuredAgentType: 'claude-code',
|
configuredAgentType: 'codex',
|
||||||
effectiveAgentType: 'claude-code',
|
effectiveAgentType: 'codex',
|
||||||
sessionFolder: 'group-1:arbiter',
|
sessionFolder: 'group-1:arbiter',
|
||||||
});
|
});
|
||||||
expect(resolution.effectiveServiceId).toBe(
|
expect(resolution.effectiveServiceId).toBe(
|
||||||
@@ -355,6 +377,30 @@ describe('message-runtime-rules', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses lease arbiter metadata when the room arbiter differs from the owner agent type', () => {
|
||||||
|
const resolution = resolveExecutionTarget({
|
||||||
|
lease: {
|
||||||
|
...baseLease,
|
||||||
|
owner_agent_type: 'codex',
|
||||||
|
reviewer_agent_type: 'claude-code',
|
||||||
|
reviewer_service_id: 'claude',
|
||||||
|
arbiter_agent_type: 'claude-code',
|
||||||
|
arbiter_service_id: 'claude',
|
||||||
|
},
|
||||||
|
pairedTaskStatus: 'arbiter_requested',
|
||||||
|
groupFolder: 'group-1',
|
||||||
|
groupAgentType: 'codex',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolution).toMatchObject({
|
||||||
|
activeRole: 'arbiter',
|
||||||
|
configuredAgentType: 'claude-code',
|
||||||
|
effectiveAgentType: 'claude-code',
|
||||||
|
sessionFolder: 'group-1:arbiter',
|
||||||
|
});
|
||||||
|
expect(resolution.effectiveServiceId).toBe('claude');
|
||||||
|
});
|
||||||
|
|
||||||
it('honors an arbiter forced role only when arbiter is configured', () => {
|
it('honors an arbiter forced role only when arbiter is configured', () => {
|
||||||
const resolution = resolveExecutionTarget({
|
const resolution = resolveExecutionTarget({
|
||||||
lease: {
|
lease: {
|
||||||
|
|||||||
@@ -248,12 +248,14 @@ export function resolveCursorKeyForRole(
|
|||||||
export function resolveConfiguredRoleAgentPlan(
|
export function resolveConfiguredRoleAgentPlan(
|
||||||
paired: boolean,
|
paired: boolean,
|
||||||
groupAgentType: AgentType | undefined,
|
groupAgentType: AgentType | undefined,
|
||||||
|
configuredReviewer: AgentType = REVIEWER_AGENT_TYPE,
|
||||||
|
configuredArbiter: AgentType | null | undefined = ARBITER_AGENT_TYPE,
|
||||||
): RoleAgentPlan {
|
): RoleAgentPlan {
|
||||||
return resolveRoleAgentPlan({
|
return resolveRoleAgentPlan({
|
||||||
paired,
|
paired,
|
||||||
groupAgentType,
|
groupAgentType,
|
||||||
configuredReviewer: REVIEWER_AGENT_TYPE,
|
configuredReviewer,
|
||||||
configuredArbiter: ARBITER_AGENT_TYPE,
|
configuredArbiter,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,8 +263,15 @@ export function resolveConfiguredRoleAgentPlan(
|
|||||||
export function resolveEffectiveAgentType(
|
export function resolveEffectiveAgentType(
|
||||||
role: 'owner' | 'reviewer' | 'arbiter',
|
role: 'owner' | 'reviewer' | 'arbiter',
|
||||||
groupAgentType: AgentType | undefined,
|
groupAgentType: AgentType | undefined,
|
||||||
|
reviewerAgentType: AgentType = REVIEWER_AGENT_TYPE,
|
||||||
|
arbiterAgentType: AgentType | null | undefined = ARBITER_AGENT_TYPE,
|
||||||
): AgentType {
|
): AgentType {
|
||||||
const plan = resolveConfiguredRoleAgentPlan(role !== 'owner', groupAgentType);
|
const plan = resolveConfiguredRoleAgentPlan(
|
||||||
|
role !== 'owner',
|
||||||
|
groupAgentType,
|
||||||
|
reviewerAgentType,
|
||||||
|
arbiterAgentType,
|
||||||
|
);
|
||||||
return resolveAgentTypeForRole(plan, role);
|
return resolveAgentTypeForRole(plan, role);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,10 +280,17 @@ export function resolveSessionFolder(
|
|||||||
groupFolder: string,
|
groupFolder: string,
|
||||||
role: 'owner' | 'reviewer' | 'arbiter',
|
role: 'owner' | 'reviewer' | 'arbiter',
|
||||||
groupAgentType: AgentType | undefined,
|
groupAgentType: AgentType | undefined,
|
||||||
|
reviewerAgentType: AgentType = REVIEWER_AGENT_TYPE,
|
||||||
|
arbiterAgentType: AgentType | null | undefined = ARBITER_AGENT_TYPE,
|
||||||
): string {
|
): string {
|
||||||
// Arbiter always gets a separate session — must never share with owner/reviewer
|
// Arbiter always gets a separate session — must never share with owner/reviewer
|
||||||
if (role === 'arbiter') return `${groupFolder}:arbiter`;
|
if (role === 'arbiter') return `${groupFolder}:arbiter`;
|
||||||
const plan = resolveConfiguredRoleAgentPlan(role !== 'owner', groupAgentType);
|
const plan = resolveConfiguredRoleAgentPlan(
|
||||||
|
role !== 'owner',
|
||||||
|
groupAgentType,
|
||||||
|
reviewerAgentType,
|
||||||
|
arbiterAgentType,
|
||||||
|
);
|
||||||
const effectiveType = resolveAgentTypeForRole(plan, role);
|
const effectiveType = resolveAgentTypeForRole(plan, role);
|
||||||
const groupDefault = plan.ownerAgentType;
|
const groupDefault = plan.ownerAgentType;
|
||||||
if (effectiveType === groupDefault) return groupFolder;
|
if (effectiveType === groupDefault) return groupFolder;
|
||||||
@@ -316,14 +332,14 @@ export function resolveExecutionTarget(args: {
|
|||||||
|
|
||||||
const reviewerServiceId = resolveLeaseServiceId(args.lease, 'reviewer');
|
const reviewerServiceId = resolveLeaseServiceId(args.lease, 'reviewer');
|
||||||
const arbiterServiceId = resolveLeaseServiceId(args.lease, 'arbiter');
|
const arbiterServiceId = resolveLeaseServiceId(args.lease, 'arbiter');
|
||||||
|
const ownerAgentType = args.lease.owner_agent_type ?? args.groupAgentType;
|
||||||
const roleAgentPlan = resolveConfiguredRoleAgentPlan(
|
const roleAgentPlan = resolveConfiguredRoleAgentPlan(
|
||||||
args.lease.reviewer_agent_type != null,
|
reviewerServiceId != null,
|
||||||
args.groupAgentType,
|
ownerAgentType,
|
||||||
);
|
args.lease.reviewer_agent_type ?? REVIEWER_AGENT_TYPE,
|
||||||
const configuredAgentType = resolveEffectiveAgentType(
|
args.lease.arbiter_agent_type ?? ARBITER_AGENT_TYPE,
|
||||||
activeRole,
|
|
||||||
args.groupAgentType,
|
|
||||||
);
|
);
|
||||||
|
const configuredAgentType = resolveAgentTypeForRole(roleAgentPlan, activeRole);
|
||||||
const effectiveAgentType = args.forcedAgentType ?? configuredAgentType;
|
const effectiveAgentType = args.forcedAgentType ?? configuredAgentType;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -339,7 +355,9 @@ export function resolveExecutionTarget(args: {
|
|||||||
sessionFolder: resolveSessionFolder(
|
sessionFolder: resolveSessionFolder(
|
||||||
args.groupFolder,
|
args.groupFolder,
|
||||||
activeRole,
|
activeRole,
|
||||||
args.groupAgentType,
|
roleAgentPlan.ownerAgentType,
|
||||||
|
roleAgentPlan.reviewerAgentType ?? REVIEWER_AGENT_TYPE,
|
||||||
|
roleAgentPlan.arbiterAgentType ?? ARBITER_AGENT_TYPE,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -250,6 +250,32 @@ describe('paired execution context', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses room role context agent overrides when creating a paired task', () => {
|
||||||
|
preparePairedExecutionContext({
|
||||||
|
group,
|
||||||
|
chatJid: 'dc:test',
|
||||||
|
runId: 'run-room-overrides',
|
||||||
|
roomRoleContext: {
|
||||||
|
...ownerContext,
|
||||||
|
ownerAgentType: 'codex',
|
||||||
|
reviewerAgentType: 'codex',
|
||||||
|
reviewerServiceId: config.CODEX_REVIEW_SERVICE_ID,
|
||||||
|
arbiterAgentType: 'claude-code',
|
||||||
|
},
|
||||||
|
hasHumanMessage: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(db.createPairedTask).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
owner_service_id: config.CODEX_MAIN_SERVICE_ID,
|
||||||
|
reviewer_service_id: config.CODEX_REVIEW_SERVICE_ID,
|
||||||
|
owner_agent_type: 'codex',
|
||||||
|
reviewer_agent_type: 'codex',
|
||||||
|
arbiter_agent_type: 'claude-code',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('persists stable role-slot service shadow instead of the transient failover owner lease', () => {
|
it('persists stable role-slot service shadow instead of the transient failover owner lease', () => {
|
||||||
preparePairedExecutionContext({
|
preparePairedExecutionContext({
|
||||||
group,
|
group,
|
||||||
@@ -562,6 +588,49 @@ describe('paired execution context', () => {
|
|||||||
delete process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE;
|
delete process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('honors room-level reviewer agent overrides for unsafe host reviewer runtime env', () => {
|
||||||
|
process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE = '1';
|
||||||
|
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue(
|
||||||
|
buildPairedTask({
|
||||||
|
status: 'review_ready',
|
||||||
|
review_requested_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
reviewer_agent_type: 'codex',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
vi.mocked(db.getPairedTaskById).mockReturnValue(
|
||||||
|
buildPairedTask({
|
||||||
|
status: 'review_ready',
|
||||||
|
review_requested_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
reviewer_agent_type: 'codex',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
vi.mocked(
|
||||||
|
pairedWorkspaceManager.prepareReviewerWorkspaceForExecution,
|
||||||
|
).mockReturnValue({
|
||||||
|
workspace: buildWorkspace('reviewer', '/tmp/paired/task-1/reviewer'),
|
||||||
|
autoRefreshed: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = preparePairedExecutionContext({
|
||||||
|
group,
|
||||||
|
chatJid: 'dc:test',
|
||||||
|
runId: 'run-host-reviewer-room-override',
|
||||||
|
roomRoleContext: {
|
||||||
|
...reviewerContext,
|
||||||
|
reviewerAgentType: 'codex',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result?.envOverrides).toMatchObject({
|
||||||
|
EJCLAW_WORK_DIR: '/tmp/paired/task-1/reviewer',
|
||||||
|
EJCLAW_PAIRED_ROLE: 'reviewer',
|
||||||
|
EJCLAW_UNSAFE_HOST_PAIRED_MODE: '1',
|
||||||
|
});
|
||||||
|
expect(result?.envOverrides.EJCLAW_CLAUDE_REVIEWER_READONLY).toBeUndefined();
|
||||||
|
expect(result?.envOverrides.EJCLAW_REVIEWER_RUNTIME).toBeUndefined();
|
||||||
|
delete process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE;
|
||||||
|
});
|
||||||
|
|
||||||
it('completePairedExecutionContext logs without error', () => {
|
it('completePairedExecutionContext logs without error', () => {
|
||||||
completePairedExecutionContext({
|
completePairedExecutionContext({
|
||||||
taskId: 'task-1',
|
taskId: 'task-1',
|
||||||
|
|||||||
@@ -106,14 +106,17 @@ function createActiveTaskForRoom(args: {
|
|||||||
group: RegisteredGroup;
|
group: RegisteredGroup;
|
||||||
chatJid: string;
|
chatJid: string;
|
||||||
canonicalWorkDir: string;
|
canonicalWorkDir: string;
|
||||||
|
roomRoleContext?: RoomRoleContext;
|
||||||
}): PairedTask {
|
}): PairedTask {
|
||||||
const { group, chatJid, canonicalWorkDir } = args;
|
const { group, chatJid, canonicalWorkDir, roomRoleContext } = args;
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const rolePlan = resolveRoleAgentPlan({
|
const rolePlan = resolveRoleAgentPlan({
|
||||||
paired: true,
|
paired: true,
|
||||||
groupAgentType: group.agentType,
|
groupAgentType: roomRoleContext?.ownerAgentType ?? group.agentType,
|
||||||
configuredReviewer: REVIEWER_AGENT_TYPE,
|
configuredReviewer:
|
||||||
configuredArbiter: ARBITER_AGENT_TYPE,
|
roomRoleContext?.reviewerAgentType ?? REVIEWER_AGENT_TYPE,
|
||||||
|
configuredArbiter:
|
||||||
|
roomRoleContext?.arbiterAgentType ?? ARBITER_AGENT_TYPE,
|
||||||
});
|
});
|
||||||
const ownerServiceShadow = resolvePairedTaskServiceShadow(
|
const ownerServiceShadow = resolvePairedTaskServiceShadow(
|
||||||
'owner',
|
'owner',
|
||||||
@@ -195,6 +198,7 @@ export interface ResolvedOwnerHumanTask {
|
|||||||
export function resolveOwnerTaskForHumanMessage(args: {
|
export function resolveOwnerTaskForHumanMessage(args: {
|
||||||
group: RegisteredGroup;
|
group: RegisteredGroup;
|
||||||
chatJid: string;
|
chatJid: string;
|
||||||
|
roomRoleContext?: RoomRoleContext;
|
||||||
existingTask?: PairedTask | null;
|
existingTask?: PairedTask | null;
|
||||||
}): ResolvedOwnerHumanTask {
|
}): ResolvedOwnerHumanTask {
|
||||||
const canonicalWorkDir = ensurePairedProject(args.group, args.chatJid);
|
const canonicalWorkDir = ensurePairedProject(args.group, args.chatJid);
|
||||||
@@ -208,6 +212,7 @@ export function resolveOwnerTaskForHumanMessage(args: {
|
|||||||
group: args.group,
|
group: args.group,
|
||||||
chatJid: args.chatJid,
|
chatJid: args.chatJid,
|
||||||
canonicalWorkDir,
|
canonicalWorkDir,
|
||||||
|
roomRoleContext: args.roomRoleContext,
|
||||||
})
|
})
|
||||||
: null,
|
: null,
|
||||||
supersededTask: null,
|
supersededTask: null,
|
||||||
@@ -247,6 +252,7 @@ export function resolveOwnerTaskForHumanMessage(args: {
|
|||||||
group: args.group,
|
group: args.group,
|
||||||
chatJid: args.chatJid,
|
chatJid: args.chatJid,
|
||||||
canonicalWorkDir,
|
canonicalWorkDir,
|
||||||
|
roomRoleContext: args.roomRoleContext,
|
||||||
}),
|
}),
|
||||||
supersededTask: existing,
|
supersededTask: existing,
|
||||||
};
|
};
|
||||||
@@ -267,6 +273,7 @@ function ensureActiveTask(
|
|||||||
return resolveOwnerTaskForHumanMessage({
|
return resolveOwnerTaskForHumanMessage({
|
||||||
group,
|
group,
|
||||||
chatJid,
|
chatJid,
|
||||||
|
roomRoleContext,
|
||||||
existingTask: existing ?? null,
|
existingTask: existing ?? null,
|
||||||
}).task;
|
}).task;
|
||||||
}
|
}
|
||||||
@@ -287,6 +294,7 @@ function ensureActiveTask(
|
|||||||
group,
|
group,
|
||||||
chatJid,
|
chatJid,
|
||||||
canonicalWorkDir,
|
canonicalWorkDir,
|
||||||
|
roomRoleContext,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -470,7 +478,7 @@ export function preparePairedExecutionContext(args: {
|
|||||||
envOverrides,
|
envOverrides,
|
||||||
buildPairedReadonlyRuntimeEnvOverrides({
|
buildPairedReadonlyRuntimeEnvOverrides({
|
||||||
role: 'reviewer',
|
role: 'reviewer',
|
||||||
agentType: REVIEWER_AGENT_TYPE,
|
agentType: roomRoleContext.reviewerAgentType ?? REVIEWER_AGENT_TYPE,
|
||||||
unsafeHostPairedMode,
|
unsafeHostPairedMode,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -489,7 +497,10 @@ export function preparePairedExecutionContext(args: {
|
|||||||
envOverrides,
|
envOverrides,
|
||||||
buildPairedReadonlyRuntimeEnvOverrides({
|
buildPairedReadonlyRuntimeEnvOverrides({
|
||||||
role: 'arbiter',
|
role: 'arbiter',
|
||||||
agentType: ARBITER_AGENT_TYPE ?? REVIEWER_AGENT_TYPE,
|
agentType:
|
||||||
|
roomRoleContext.arbiterAgentType ??
|
||||||
|
ARBITER_AGENT_TYPE ??
|
||||||
|
REVIEWER_AGENT_TYPE,
|
||||||
unsafeHostPairedMode,
|
unsafeHostPairedMode,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
_setRegisteredGroupForTests,
|
_setRegisteredGroupForTests,
|
||||||
_setStoredRoomOwnerAgentTypeForTests,
|
_setStoredRoomOwnerAgentTypeForTests,
|
||||||
_initTestDatabase,
|
_initTestDatabase,
|
||||||
|
assignRoom,
|
||||||
setChannelOwnerLease,
|
setChannelOwnerLease,
|
||||||
setExplicitRoomMode,
|
setExplicitRoomMode,
|
||||||
} from './db.js';
|
} from './db.js';
|
||||||
@@ -236,6 +237,29 @@ describe('service-routing global failover', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses room-level reviewer and arbiter agent overrides from assign_room', () => {
|
||||||
|
assignRoom('dc:room-role-overrides', {
|
||||||
|
name: 'Room Role Overrides',
|
||||||
|
roomMode: 'tribunal',
|
||||||
|
ownerAgentType: 'claude-code',
|
||||||
|
reviewerAgentType: 'codex',
|
||||||
|
arbiterAgentType: 'claude-code',
|
||||||
|
folder: 'room-role-overrides',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getEffectiveChannelLease('dc:room-role-overrides')).toMatchObject({
|
||||||
|
chat_jid: 'dc:room-role-overrides',
|
||||||
|
owner_agent_type: 'claude-code',
|
||||||
|
reviewer_agent_type: 'codex',
|
||||||
|
arbiter_agent_type: 'claude-code',
|
||||||
|
owner_service_id: 'claude',
|
||||||
|
reviewer_service_id: 'codex-review',
|
||||||
|
arbiter_service_id: 'claude',
|
||||||
|
owner_failover_active: false,
|
||||||
|
explicit: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('defaults to the configured owner service for chats without canonical 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',
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
clearChannelOwnerLease,
|
clearChannelOwnerLease,
|
||||||
getAllChannelOwnerLeases,
|
getAllChannelOwnerLeases,
|
||||||
getEffectiveRuntimeRoomMode,
|
getEffectiveRuntimeRoomMode,
|
||||||
|
getStoredRoomRoleAgentPlan,
|
||||||
getStoredRoomSettings,
|
getStoredRoomSettings,
|
||||||
type ChannelOwnerLeaseRow,
|
type ChannelOwnerLeaseRow,
|
||||||
} from './db.js';
|
} from './db.js';
|
||||||
@@ -46,8 +47,10 @@ function normalizeLeaseRow(
|
|||||||
row: ChannelOwnerLeaseRow,
|
row: ChannelOwnerLeaseRow,
|
||||||
explicit: boolean,
|
explicit: boolean,
|
||||||
): EffectiveChannelLease {
|
): EffectiveChannelLease {
|
||||||
|
const storedRolePlan = getStoredRoomRoleAgentPlan(row.chat_jid);
|
||||||
const ownerAgentType =
|
const ownerAgentType =
|
||||||
row.owner_agent_type ??
|
row.owner_agent_type ??
|
||||||
|
storedRolePlan?.ownerAgentType ??
|
||||||
getStoredRoomSettings(row.chat_jid)?.ownerAgentType ??
|
getStoredRoomSettings(row.chat_jid)?.ownerAgentType ??
|
||||||
inferAgentTypeFromServiceShadow(row.owner_service_id) ??
|
inferAgentTypeFromServiceShadow(row.owner_service_id) ??
|
||||||
'claude-code';
|
'claude-code';
|
||||||
@@ -56,6 +59,7 @@ function normalizeLeaseRow(
|
|||||||
? null
|
? null
|
||||||
: (row.reviewer_agent_type ??
|
: (row.reviewer_agent_type ??
|
||||||
inferAgentTypeFromServiceShadow(row.reviewer_service_id) ??
|
inferAgentTypeFromServiceShadow(row.reviewer_service_id) ??
|
||||||
|
storedRolePlan?.reviewerAgentType ??
|
||||||
resolveRoleAgentPlan({
|
resolveRoleAgentPlan({
|
||||||
paired: true,
|
paired: true,
|
||||||
groupAgentType: ownerAgentType,
|
groupAgentType: ownerAgentType,
|
||||||
@@ -67,6 +71,7 @@ function normalizeLeaseRow(
|
|||||||
(row.arbiter_service_id
|
(row.arbiter_service_id
|
||||||
? inferAgentTypeFromServiceShadow(row.arbiter_service_id)
|
? inferAgentTypeFromServiceShadow(row.arbiter_service_id)
|
||||||
: undefined) ??
|
: undefined) ??
|
||||||
|
storedRolePlan?.arbiterAgentType ??
|
||||||
null;
|
null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -102,20 +107,21 @@ function normalizeLeaseRow(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveDefaultOwnerAgentType(chatJid: string): AgentType | undefined {
|
|
||||||
return getStoredRoomSettings(chatJid)?.ownerAgentType;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDefaultLease(chatJid: string): EffectiveChannelLease {
|
function getDefaultLease(chatJid: string): EffectiveChannelLease {
|
||||||
const roomMode = getEffectiveRuntimeRoomMode(chatJid);
|
const roomMode = getEffectiveRuntimeRoomMode(chatJid);
|
||||||
|
const storedRolePlan = getStoredRoomRoleAgentPlan(chatJid);
|
||||||
const ownerAgentType =
|
const ownerAgentType =
|
||||||
resolveDefaultOwnerAgentType(chatJid) ?? OWNER_AGENT_TYPE;
|
storedRolePlan?.ownerAgentType ??
|
||||||
const rolePlan = resolveRoleAgentPlan({
|
getStoredRoomSettings(chatJid)?.ownerAgentType ??
|
||||||
paired: roomMode === 'tribunal',
|
OWNER_AGENT_TYPE;
|
||||||
groupAgentType: ownerAgentType,
|
const rolePlan =
|
||||||
configuredReviewer: REVIEWER_AGENT_TYPE,
|
storedRolePlan ??
|
||||||
configuredArbiter: ARBITER_AGENT_TYPE,
|
resolveRoleAgentPlan({
|
||||||
});
|
paired: roomMode === 'tribunal',
|
||||||
|
groupAgentType: ownerAgentType,
|
||||||
|
configuredReviewer: REVIEWER_AGENT_TYPE,
|
||||||
|
configuredArbiter: ARBITER_AGENT_TYPE,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
chat_jid: chatJid,
|
chat_jid: chatJid,
|
||||||
|
|||||||
Reference in New Issue
Block a user