diff --git a/runners/agent-runner/src/index.ts b/runners/agent-runner/src/index.ts index a806117..efc2416 100644 --- a/runners/agent-runner/src/index.ts +++ b/runners/agent-runner/src/index.ts @@ -21,6 +21,11 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; import { fileURLToPath } from 'url'; +import { + prependRoomRoleHeader, + type RoomRoleContext, +} from './room-role-context.js'; + interface ContainerInput { prompt: string; sessionId?: string; @@ -30,6 +35,7 @@ interface ContainerInput { isScheduledTask?: boolean; assistantName?: string; secrets?: Record; + roomRoleContext?: RoomRoleContext; } /** Mirrors AgentOutput in src/agent-runner.ts (separate package, can't import directly). */ @@ -971,6 +977,7 @@ async function main(): Promise { log(`Draining ${pending.length} pending IPC messages into initial prompt`); prompt += '\n' + pending.join('\n'); } + prompt = prependRoomRoleHeader(prompt, containerInput.roomRoleContext); // --- Slash command handling --- // Only known session slash commands are handled here. This prevents diff --git a/runners/agent-runner/src/room-role-context.ts b/runners/agent-runner/src/room-role-context.ts new file mode 100644 index 0000000..19d9381 --- /dev/null +++ b/runners/agent-runner/src/room-role-context.ts @@ -0,0 +1,23 @@ +export interface RoomRoleContext { + serviceId: string; + role: 'owner' | 'reviewer'; + ownerServiceId: string; + reviewerServiceId: string; + failoverOwner: boolean; +} + +export function prependRoomRoleHeader( + prompt: string, + roomRoleContext?: RoomRoleContext, +): string { + if (!roomRoleContext) { + return prompt; + } + + const header = + `[ROOM_ROLE self=${roomRoleContext.serviceId} role=${roomRoleContext.role} ` + + `owner=${roomRoleContext.ownerServiceId} reviewer=${roomRoleContext.reviewerServiceId} ` + + `failover=${roomRoleContext.failoverOwner ? 1 : 0}]`; + + return `${header}\n\n${prompt}`; +} diff --git a/runners/agent-runner/test/room-role-context.test.ts b/runners/agent-runner/test/room-role-context.test.ts new file mode 100644 index 0000000..4ada0d4 --- /dev/null +++ b/runners/agent-runner/test/room-role-context.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; + +import { prependRoomRoleHeader } from '../src/room-role-context.js'; + +describe('claude runner room role header', () => { + it('prepends a canonical role header when metadata exists', () => { + expect( + prependRoomRoleHeader('hello', { + serviceId: 'codex-review', + role: 'owner', + ownerServiceId: 'codex-review', + reviewerServiceId: 'codex-main', + failoverOwner: true, + }), + ).toBe( + '[ROOM_ROLE self=codex-review role=owner owner=codex-review reviewer=codex-main failover=1]\n\nhello', + ); + }); + + it('leaves prompts unchanged without room metadata', () => { + expect(prependRoomRoleHeader('hello')).toBe('hello'); + }); +}); diff --git a/runners/codex-runner/src/index.ts b/runners/codex-runner/src/index.ts index b45183a..ee7f1f6 100644 --- a/runners/codex-runner/src/index.ts +++ b/runners/codex-runner/src/index.ts @@ -19,6 +19,10 @@ import { CodexAppServerClient, type AppServerInputItem, } from './app-server-client.js'; +import { + prependRoomRoleHeader, + type RoomRoleContext, +} from './room-role-context.js'; // ── Types ────────────────────────────────────────────────────────── @@ -31,6 +35,7 @@ interface ContainerInput { isScheduledTask?: boolean; assistantName?: string; agentType?: string; + roomRoleContext?: RoomRoleContext; } interface ContainerOutput { @@ -464,6 +469,7 @@ async function main(): Promise { if (pending.length > 0) { prompt += '\n' + pending.join('\n'); } + prompt = prependRoomRoleHeader(prompt, containerInput.roomRoleContext); try { log('Runtime selected: app-server'); diff --git a/runners/codex-runner/src/room-role-context.ts b/runners/codex-runner/src/room-role-context.ts new file mode 100644 index 0000000..19d9381 --- /dev/null +++ b/runners/codex-runner/src/room-role-context.ts @@ -0,0 +1,23 @@ +export interface RoomRoleContext { + serviceId: string; + role: 'owner' | 'reviewer'; + ownerServiceId: string; + reviewerServiceId: string; + failoverOwner: boolean; +} + +export function prependRoomRoleHeader( + prompt: string, + roomRoleContext?: RoomRoleContext, +): string { + if (!roomRoleContext) { + return prompt; + } + + const header = + `[ROOM_ROLE self=${roomRoleContext.serviceId} role=${roomRoleContext.role} ` + + `owner=${roomRoleContext.ownerServiceId} reviewer=${roomRoleContext.reviewerServiceId} ` + + `failover=${roomRoleContext.failoverOwner ? 1 : 0}]`; + + return `${header}\n\n${prompt}`; +} diff --git a/runners/codex-runner/test/room-role-context.test.ts b/runners/codex-runner/test/room-role-context.test.ts new file mode 100644 index 0000000..2d5b9c0 --- /dev/null +++ b/runners/codex-runner/test/room-role-context.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; + +import { prependRoomRoleHeader } from '../src/room-role-context.js'; + +describe('codex runner room role header', () => { + it('prepends a canonical role header when metadata exists', () => { + expect( + prependRoomRoleHeader('hello', { + serviceId: 'codex-main', + role: 'reviewer', + ownerServiceId: 'claude', + reviewerServiceId: 'codex-main', + failoverOwner: false, + }), + ).toBe( + '[ROOM_ROLE self=codex-main role=reviewer owner=claude reviewer=codex-main failover=0]\n\nhello', + ); + }); + + it('leaves prompts unchanged without room metadata', () => { + expect(prependRoomRoleHeader('hello')).toBe('hello'); + }); +}); diff --git a/src/agent-runner.test.ts b/src/agent-runner.test.ts index 2e9d9b4..f1bf1b5 100644 --- a/src/agent-runner.test.ts +++ b/src/agent-runner.test.ts @@ -312,6 +312,46 @@ describe('agent-runner timeout behavior', () => { ); }); + it('serializes roomRoleContext into the runner stdin payload', async () => { + vi.useRealTimers(); + fakeProc = createFakeProcess(); + + let stdinPayload = ''; + fakeProc.stdin.on('data', (chunk) => { + stdinPayload += chunk.toString(); + }); + + const resultPromise = runAgentProcess( + testGroup, + { + ...testInput, + roomRoleContext: { + serviceId: 'codex-main', + role: 'reviewer', + ownerServiceId: 'claude', + reviewerServiceId: 'codex-main', + failoverOwner: false, + }, + }, + () => {}, + async () => {}, + ); + + fakeProc.emit('close', 0); + const result = await resultPromise; + expect(result.status).toBe('success'); + expect(JSON.parse(stdinPayload)).toMatchObject({ + prompt: 'Hello', + roomRoleContext: { + serviceId: 'codex-main', + role: 'reviewer', + ownerServiceId: 'claude', + reviewerServiceId: 'codex-main', + failoverOwner: false, + }, + }); + }); + it('isolates IPC and session directories for isolated scheduled tasks', async () => { vi.useRealTimers(); fakeProc = createFakeProcess(); diff --git a/src/agent-runner.ts b/src/agent-runner.ts index 6b1e5ae..895cfa2 100644 --- a/src/agent-runner.ts +++ b/src/agent-runner.ts @@ -24,6 +24,7 @@ import { AgentOutputPhase, AgentType, RegisteredGroup, + RoomRoleContext, StructuredAgentOutput, } from './types.js'; @@ -40,6 +41,7 @@ export interface AgentInput { useTaskScopedSession?: boolean; assistantName?: string; agentType?: 'claude-code' | 'codex'; + roomRoleContext?: RoomRoleContext; } export interface AgentOutput { diff --git a/src/message-agent-executor.test.ts b/src/message-agent-executor.test.ts index 31ae1be..54a6dc8 100644 --- a/src/message-agent-executor.test.ts +++ b/src/message-agent-executor.test.ts @@ -15,6 +15,9 @@ vi.mock('./config.js', () => ({ CODEX_REVIEW_SERVICE_ID: 'codex-review', DATA_DIR: '/tmp/ejclaw-test-data', SERVICE_SESSION_SCOPE: 'claude', + normalizeServiceId: vi.fn((serviceId: string) => + serviceId === 'codex' ? 'codex-main' : serviceId, + ), })); vi.mock('./db.js', () => ({ @@ -231,6 +234,32 @@ describe('runAgentForGroup room memory', () => { ); }); + it('passes paired-room role metadata through to the runner input', async () => { + const group = { ...makeGroup(), folder: 'test-group' }; + + await runAgentForGroup(makeDeps(), { + group, + prompt: 'hello', + chatJid: 'group@test', + runId: 'run-room-role', + }); + + expect(agentRunner.runAgentProcess).toHaveBeenCalledWith( + group, + expect.objectContaining({ + roomRoleContext: { + serviceId: 'claude', + role: 'owner', + ownerServiceId: 'claude', + reviewerServiceId: 'codex-main', + failoverOwner: false, + }, + }), + expect.any(Function), + undefined, + ); + }); + it('adds reviewer silence guidance when the current service is the reviewer for the chat', async () => { const group = { ...makeGroup(), folder: 'test-group' }; vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({ diff --git a/src/message-agent-executor.ts b/src/message-agent-executor.ts index a283c81..d68f020 100644 --- a/src/message-agent-executor.ts +++ b/src/message-agent-executor.ts @@ -12,6 +12,7 @@ import { createServiceHandoff, getAllTasks } from './db.js'; import { GroupQueue } from './group-queue.js'; import { logger } from './logger.js'; import { buildRoomMemoryBriefing } from './memento-client.js'; +import { buildRoomRoleContext } from './room-role-context.js'; import { classifyRotationTrigger, type AgentTriggerReason, @@ -121,6 +122,10 @@ export async function runAgentForGroup( const currentLease = getEffectiveChannelLease(chatJid); const reviewerMode = currentLease.reviewer_service_id === SERVICE_SESSION_SCOPE; + const roomRoleContext = buildRoomRoleContext( + currentLease, + SERVICE_SESSION_SCOPE, + ); const effectivePrompt = buildStructuredOutputPrompt(prompt, { reviewerMode, }); @@ -180,6 +185,7 @@ export async function runAgentForGroup( runId, isMain, assistantName: deps.assistantName, + roomRoleContext, }; const runAttempt = async ( diff --git a/src/room-role-context.test.ts b/src/room-role-context.test.ts new file mode 100644 index 0000000..f40f3a2 --- /dev/null +++ b/src/room-role-context.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; + +import { buildRoomRoleContext } from './room-role-context.js'; + +describe('buildRoomRoleContext', () => { + it('returns reviewer context for a normal paired codex turn', () => { + expect( + buildRoomRoleContext( + { + chat_jid: 'group@test', + owner_service_id: 'claude', + reviewer_service_id: 'codex-main', + activated_at: null, + reason: null, + explicit: false, + }, + 'codex-main', + ), + ).toEqual({ + serviceId: 'codex-main', + role: 'reviewer', + ownerServiceId: 'claude', + reviewerServiceId: 'codex-main', + failoverOwner: false, + }); + }); + + it('returns owner failover context for a codex-review failover turn', () => { + expect( + buildRoomRoleContext( + { + chat_jid: 'group@test', + owner_service_id: 'codex-review', + reviewer_service_id: 'codex-main', + activated_at: '2026-03-28T10:00:00.000Z', + reason: 'claude-429', + explicit: true, + }, + 'codex-review', + ), + ).toEqual({ + serviceId: 'codex-review', + role: 'owner', + ownerServiceId: 'codex-review', + reviewerServiceId: 'codex-main', + failoverOwner: true, + }); + }); + + it('returns undefined for a non-paired room', () => { + expect( + buildRoomRoleContext( + { + chat_jid: 'solo@test', + owner_service_id: 'codex-main', + reviewer_service_id: null, + activated_at: null, + reason: null, + explicit: false, + }, + 'codex-main', + ), + ).toBeUndefined(); + }); +}); diff --git a/src/room-role-context.ts b/src/room-role-context.ts new file mode 100644 index 0000000..eadb001 --- /dev/null +++ b/src/room-role-context.ts @@ -0,0 +1,43 @@ +import { + CODEX_MAIN_SERVICE_ID, + CODEX_REVIEW_SERVICE_ID, + normalizeServiceId, +} from './config.js'; +import type { RoomRoleContext } from './types.js'; +import type { EffectiveChannelLease } from './service-routing.js'; + +export function buildRoomRoleContext( + lease: EffectiveChannelLease, + serviceId: string, +): RoomRoleContext | undefined { + const normalizedServiceId = normalizeServiceId(serviceId); + const reviewerServiceId = lease.reviewer_service_id + ? normalizeServiceId(lease.reviewer_service_id) + : null; + + if (!reviewerServiceId) { + return undefined; + } + + const ownerServiceId = normalizeServiceId(lease.owner_service_id); + const role = + ownerServiceId === normalizedServiceId + ? 'owner' + : reviewerServiceId === normalizedServiceId + ? 'reviewer' + : null; + + if (!role) { + return undefined; + } + + return { + serviceId: normalizedServiceId, + role, + ownerServiceId, + reviewerServiceId, + failoverOwner: + ownerServiceId === CODEX_REVIEW_SERVICE_ID && + reviewerServiceId === CODEX_MAIN_SERVICE_ID, + }; +} diff --git a/src/types.ts b/src/types.ts index 686cac4..f5c27a3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -23,6 +23,16 @@ export type VisiblePhase = 'silent' | 'progress' | 'final'; export type AgentVisibility = 'public' | 'silent'; +export type PairedRoomRole = 'owner' | 'reviewer'; + +export interface RoomRoleContext { + serviceId: string; + role: PairedRoomRole; + ownerServiceId: string; + reviewerServiceId: string; + failoverOwner: boolean; +} + export type StructuredAgentOutput = | { visibility: 'public';