diff --git a/src/message-runtime-handoffs.test.ts b/src/message-runtime-handoffs.test.ts index cd82fa1..33b752b 100644 --- a/src/message-runtime-handoffs.test.ts +++ b/src/message-runtime-handoffs.test.ts @@ -9,6 +9,7 @@ vi.mock('./db.js', () => ({ claimPairedTurnReservation: vi.fn(() => true), completeServiceHandoffAndAdvanceTargetCursor: vi.fn(() => null), failServiceHandoff: vi.fn(), + getPairedTaskById: vi.fn(), getPairedTurnOutputs: vi.fn(() => []), getPendingServiceHandoffs: vi.fn(() => []), reservePairedTurnReservation: vi.fn(() => true), @@ -25,6 +26,7 @@ vi.mock('./logger.js', () => ({ import * as db from './db.js'; import { + enqueueMessageRuntimePendingHandoffs, enqueuePendingHandoffs, processClaimedHandoff, } from './message-runtime-handoffs.js'; @@ -52,6 +54,93 @@ function makeChannel(name: string, ownsChatJid = false): Channel { } as unknown as Channel; } +function makeClaimedHandoff(overrides: Record = {}) { + return { + id: 13, + chat_jid: 'group@test', + group_folder: 'test-group', + source_service_id: 'claude', + target_service_id: 'codex-main', + source_role: 'reviewer', + source_agent_type: 'claude-code', + target_role: 'owner', + target_agent_type: 'codex', + prompt: 'owner handoff', + status: 'claimed', + start_seq: 13, + end_seq: 14, + reason: 'owner-follow-up', + intended_role: 'owner', + created_at: '2026-04-10T00:00:00.000Z', + claimed_at: '2026-04-10T00:00:01.000Z', + completed_at: null, + last_error: null, + ...overrides, + } as any; +} + +describe('enqueueMessageRuntimePendingHandoffs', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('enqueues pending handoffs with runtime-bound processing dependencies', async () => { + const handoff = makeClaimedHandoff(); + vi.mocked(db.getPendingServiceHandoffs).mockReturnValue([handoff]); + vi.mocked(db.claimServiceHandoff).mockReturnValue(true); + vi.mocked(db.completeServiceHandoffAndAdvanceTargetCursor).mockReturnValue( + '14', + ); + const enqueueTask = vi.fn(); + const executeTurn = vi.fn(async () => ({ + outputStatus: 'success' as const, + deliverySucceeded: true, + visiblePhase: 'final', + })); + const lastAgentTimestamps: Record = {}; + const getLastAgentTimestamps = vi.fn(() => lastAgentTimestamps); + const saveState = vi.fn(); + const enqueueMessageCheck = vi.fn(); + + enqueueMessageRuntimePendingHandoffs({ + enqueueTask, + getRoomBindings: () => ({ + 'group@test': makeGroup(), + }), + channels: [makeChannel('discord-main', true)], + executeTurn, + getLastAgentTimestamps, + saveState, + enqueueMessageCheck, + }); + + expect(db.getPendingServiceHandoffs).toHaveBeenCalledWith('codex-main'); + expect(db.claimServiceHandoff).toHaveBeenCalledWith(13); + expect(getLastAgentTimestamps).not.toHaveBeenCalled(); + expect(enqueueTask).toHaveBeenCalledWith( + 'group@test', + 'handoff:13', + expect.any(Function), + ); + + const queuedTask = vi.mocked(enqueueTask).mock.calls[0]?.[2]; + await queuedTask?.(); + + expect(getLastAgentTimestamps).toHaveBeenCalledTimes(1); + expect(executeTurn).toHaveBeenCalledWith( + expect.objectContaining({ + chatJid: 'group@test', + runId: 'handoff-13', + forcedRole: 'owner', + forcedAgentType: 'codex', + }), + ); + expect(lastAgentTimestamps['group@test']).toBe('14'); + expect(saveState).toHaveBeenCalledTimes(1); + expect(enqueueMessageCheck).not.toHaveBeenCalled(); + }); +}); + describe('message-runtime-handoffs', () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/src/message-runtime-handoffs.ts b/src/message-runtime-handoffs.ts index 7d81156..e442a02 100644 --- a/src/message-runtime-handoffs.ts +++ b/src/message-runtime-handoffs.ts @@ -4,6 +4,7 @@ import { claimServiceHandoff, completeServiceHandoffAndAdvanceTargetCursor, failServiceHandoff, + getPairedTaskById, getPendingServiceHandoffs, type ServiceHandoff, } from './db.js'; @@ -45,6 +46,36 @@ export function enqueuePendingHandoffs(args: { } } +export function enqueueMessageRuntimePendingHandoffs(args: { + enqueueTask?: + | ((chatJid: string, taskId: string, task: () => Promise) => void) + | undefined; + getRoomBindings: () => Record; + channels: Channel[]; + executeTurn: ExecuteTurnFn; + getLastAgentTimestamps: () => Record; + saveState: () => void; + enqueueMessageCheck: (chatJid: string) => void; +}): void { + enqueuePendingHandoffs({ + enqueueTask: (chatJid, taskId, task) => { + args.enqueueTask?.(chatJid, taskId, task); + }, + processClaimedHandoff: async (handoff) => { + await processClaimedHandoff({ + handoff, + getRoomBindings: args.getRoomBindings, + channels: args.channels, + executeTurn: args.executeTurn, + lastAgentTimestamps: args.getLastAgentTimestamps(), + saveState: args.saveState, + getPairedTaskById, + enqueueMessageCheck: args.enqueueMessageCheck, + }); + }, + }); +} + function requeueFailedClaimedPairedTurn(args: { handoff: ServiceHandoff; error: string; diff --git a/src/message-runtime.ts b/src/message-runtime.ts index f128588..0d518f8 100644 --- a/src/message-runtime.ts +++ b/src/message-runtime.ts @@ -3,7 +3,6 @@ import { getMessagesSinceSeq, getLatestOpenPairedTaskForChat, markWorkItemDelivered, - getPairedTaskById, updatePairedTurnProgressText, } from './db.js'; import { @@ -17,10 +16,7 @@ import { enqueueGenericFollowUpAfterDeliveryRetry as enqueueDeliveryRetryFollowU import { processOpenWorkItemDelivery } from './message-runtime-delivery.js'; import { deliverCanonicalOutboundMessage } from './ipc-outbound-delivery.js'; import { handleQueuedRunGates } from './message-runtime-gating.js'; -import { - enqueuePendingHandoffs as enqueueClaimedServiceHandoffs, - processClaimedHandoff as processClaimedServiceHandoff, -} from './message-runtime-handoffs.js'; +import { enqueueMessageRuntimePendingHandoffs } from './message-runtime-handoffs.js'; import { buildScopedMessageCheckEnqueuer, processMessageLoopTick, @@ -229,23 +225,14 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { }); const enqueuePendingHandoffs = (): void => { - enqueueClaimedServiceHandoffs({ - enqueueTask: (chatJid, taskId, task) => { - deps.queue.enqueueTask?.(chatJid, taskId, task); - }, - processClaimedHandoff: async (handoff) => { - await processClaimedServiceHandoff({ - handoff, - getRoomBindings: deps.getRoomBindings, - channels: deps.channels, - executeTurn, - lastAgentTimestamps: deps.getLastAgentTimestamps(), - saveState: deps.saveState, - getPairedTaskById, - enqueueMessageCheck: (chatJid) => - deps.queue.enqueueMessageCheck(chatJid), - }); - }, + enqueueMessageRuntimePendingHandoffs({ + enqueueTask: deps.queue.enqueueTask?.bind(deps.queue), + getRoomBindings: deps.getRoomBindings, + channels: deps.channels, + executeTurn, + getLastAgentTimestamps: deps.getLastAgentTimestamps, + saveState: deps.saveState, + enqueueMessageCheck: (chatJid) => deps.queue.enqueueMessageCheck(chatJid), }); };