diff --git a/src/message-agent-executor.test.ts b/src/message-agent-executor.test.ts index e729c8c..89ec15f 100644 --- a/src/message-agent-executor.test.ts +++ b/src/message-agent-executor.test.ts @@ -452,6 +452,68 @@ describe('runAgentForGroup room memory', () => { ); }); + it('honors a forced agent type for reviewer failover handoffs', async () => { + const group: RegisteredGroup = { + ...makeGroup(), + agentType: 'codex', + folder: 'test-group', + }; + vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({ + chat_jid: 'group@test', + owner_service_id: 'codex-main', + reviewer_service_id: 'claude', + arbiter_service_id: null, + activated_at: null, + reason: null, + explicit: false, + }); + vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({ + id: 'paired-task-reviewer-failover', + chat_jid: 'group@test', + group_folder: 'test-group', + owner_service_id: 'codex-main', + reviewer_service_id: 'claude', + title: null, + source_ref: 'HEAD', + plan_notes: null, + round_trip_count: 0, + review_requested_at: '2026-03-31T00:00:00.000Z', + status: 'active', + arbiter_verdict: null, + arbiter_requested_at: null, + completion_reason: null, + created_at: '2026-03-31T00:00:00.000Z', + updated_at: '2026-03-31T00:00:00.000Z', + }); + + await runAgentForGroup( + { + ...makeDeps(), + getSessions: () => ({ 'test-group:reviewer': 'claude-review-session' }), + }, + { + group, + prompt: 'please retry review with codex', + chatJid: 'group@test', + runId: 'run-forced-reviewer-codex', + forcedRole: 'reviewer', + forcedAgentType: 'codex', + }, + ); + + expect(agentRunner.runAgentProcess).toHaveBeenCalledWith( + expect.objectContaining({ + agentType: 'codex', + }), + expect.objectContaining({ + sessionId: undefined, + }), + expect.any(Function), + undefined, + undefined, + ); + }); + it('allows silent reviewer outputs', async () => { const group = { ...makeGroup(), folder: 'test-group', workDir: '/repo' }; vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({ diff --git a/src/message-agent-executor.ts b/src/message-agent-executor.ts index 6214694..d7ba990 100644 --- a/src/message-agent-executor.ts +++ b/src/message-agent-executor.ts @@ -44,7 +44,9 @@ import { shouldRetryFreshSessionOnAgentFailure, } from './session-recovery.js'; import { + ARBITER_AGENT_TYPE, CODEX_REVIEW_SERVICE_ID, + REVIEWER_AGENT_TYPE, SERVICE_SESSION_SCOPE, TIMEZONE, isClaudeService, @@ -69,7 +71,7 @@ import { } from './codex-token-rotation.js'; import type { CodexRotationReason } from './agent-error-detection.js'; import { getTokenCount } from './token-rotation.js'; -import type { PairedRoomRole, RegisteredGroup } from './types.js'; +import type { AgentType, PairedRoomRole, RegisteredGroup } from './types.js'; // ── Main executor ───────────────────────────────────────────────── @@ -93,6 +95,7 @@ export async function runAgentForGroup( endSeq?: number | null; hasHumanMessage?: boolean; forcedRole?: PairedRoomRole; + forcedAgentType?: AgentType; onOutput?: (output: AgentOutput) => Promise; }, ): Promise<'success' | 'error'> { @@ -128,10 +131,11 @@ export async function runAgentForGroup( group.agentType, ); - const effectiveAgentType = resolveEffectiveAgentType( + const configuredAgentType = resolveEffectiveAgentType( activeRole, group.agentType, ); + const effectiveAgentType = args.forcedAgentType ?? configuredAgentType; const effectiveGroup = effectiveAgentType !== roleAgentPlan.ownerAgentType ? { ...group, agentType: effectiveAgentType } @@ -144,7 +148,9 @@ export async function runAgentForGroup( ); // Arbiter always starts fresh — never resume a previous session const sessionId = - activeRole === 'arbiter' ? undefined : sessions[sessionFolder]; + activeRole === 'arbiter' || args.forcedAgentType + ? undefined + : sessions[sessionFolder]; const memoryBriefing = sessionId ? undefined : await buildRoomMemoryBriefing({ @@ -210,6 +216,29 @@ export async function runAgentForGroup( role: activeRole, serviceId: effectiveServiceId, }); + log.info( + { + forcedRole: args.forcedRole, + forcedAgentType: args.forcedAgentType ?? null, + inferredRole, + canHonorForcedRole, + pairedTaskId: pairedTask?.id, + pairedTaskStatus: pairedTask?.status, + configuredAgentType, + effectiveServiceId, + effectiveAgentType, + groupAgentType: group.agentType, + configuredReviewerAgentType: REVIEWER_AGENT_TYPE, + configuredArbiterAgentType: ARBITER_AGENT_TYPE, + reviewerServiceId: currentLease.reviewer_service_id, + arbiterServiceId: currentLease.arbiter_service_id, + reviewerMode, + arbiterMode, + sessionFolder, + resumedSession: sessionId ?? null, + }, + 'Resolved execution target for agent turn', + ); // ── MoA prompt enrichment ───────────────────────────────────── // When MoA is enabled and we're in arbiter mode, query external API diff --git a/src/message-runtime.ts b/src/message-runtime.ts index 747c4c9..69aab4a 100644 --- a/src/message-runtime.ts +++ b/src/message-runtime.ts @@ -58,6 +58,7 @@ import { import { Channel, NewMessage, + type AgentType, type PairedRoomRole, RegisteredGroup, type PairedTask, @@ -432,6 +433,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { endSeq?: number | null; hasHumanMessage?: boolean; forcedRole?: PairedRoomRole; + forcedAgentType?: AgentType; }, ): Promise<'success' | 'error'> => runAgentForGroup( @@ -452,6 +454,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { endSeq: options?.endSeq, hasHumanMessage: options?.hasHumanMessage, forcedRole: options?.forcedRole, + forcedAgentType: options?.forcedAgentType, onOutput, }, ); @@ -466,6 +469,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { endSeq: number | null; hasHumanMessage?: boolean; forcedRole?: PairedRoomRole; + forcedAgentType?: AgentType; }): Promise<{ outputStatus: 'success' | 'error'; deliverySucceeded: boolean; @@ -479,7 +483,8 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { }> => { const { group, prompt, chatJid, runId, channel, startSeq, endSeq } = args; const isClaudeCodeAgent = - (group.agentType || 'claude-code') === 'claude-code'; + (args.forcedAgentType ?? group.agentType ?? 'claude-code') === + 'claude-code'; const turnController = new MessageTurnController({ chatJid, @@ -497,7 +502,8 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { const workItem = createProducedWorkItem({ group_folder: group.folder, chat_jid: chatJid, - agent_type: group.agentType || 'claude-code', + agent_type: + args.forcedAgentType ?? group.agentType ?? 'claude-code', start_seq: startSeq, end_seq: endSeq, result_payload: text, @@ -527,6 +533,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { endSeq, hasHumanMessage: args.hasHumanMessage, forcedRole: args.forcedRole, + forcedAgentType: args.forcedAgentType, }, ); @@ -602,7 +609,9 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { let handoffChannel = channel; if (handoffRole === 'reviewer') { const revChName = - REVIEWER_AGENT_TYPE === 'claude-code' ? 'discord' : 'discord-review'; + handoff.target_agent_type === 'claude-code' + ? 'discord' + : 'discord-review'; handoffChannel = findChannelByName(deps.channels, revChName) || channel; } else if (handoffRole === 'arbiter') { handoffChannel = @@ -611,6 +620,20 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { const runId = `handoff-${handoff.id}`; try { + logger.info( + { + chatJid: handoff.chat_jid, + handoffId: handoff.id, + runId, + handoffRole, + targetServiceId: handoff.target_service_id, + targetAgentType: handoff.target_agent_type, + reason: handoff.reason, + intendedRole: handoff.intended_role ?? null, + channelName: handoffChannel.name, + }, + 'Dispatching claimed service handoff', + ); const result = await executeTurn({ group, prompt: handoff.prompt, @@ -620,6 +643,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { startSeq: handoff.start_seq, endSeq: handoff.end_seq, forcedRole: handoffRole, + forcedAgentType: handoff.target_agent_type, }); if (!result.deliverySucceeded) {