feat: add structured room role metadata

This commit is contained in:
Eyejoker
2026-03-28 19:30:32 +09:00
parent 806e5e4ff1
commit 79a8a2639e
13 changed files with 300 additions and 0 deletions

View File

@@ -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<string, string>;
roomRoleContext?: RoomRoleContext;
}
/** Mirrors AgentOutput in src/agent-runner.ts (separate package, can't import directly). */
@@ -971,6 +977,7 @@ async function main(): Promise<void> {
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

View File

@@ -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}`;
}

View File

@@ -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');
});
});

View File

@@ -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<void> {
if (pending.length > 0) {
prompt += '\n' + pending.join('\n');
}
prompt = prependRoomRoleHeader(prompt, containerInput.roomRoleContext);
try {
log('Runtime selected: app-server');

View File

@@ -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}`;
}

View File

@@ -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');
});
});

View File

@@ -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();

View File

@@ -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 {

View File

@@ -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({

View File

@@ -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 (

View File

@@ -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();
});
});

43
src/room-role-context.ts Normal file
View File

@@ -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,
};
}

View File

@@ -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';