diff --git a/src/message-runtime.test.ts b/src/message-runtime.test.ts index 90d2cb7..93c221f 100644 --- a/src/message-runtime.test.ts +++ b/src/message-runtime.test.ts @@ -2200,702 +2200,13 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead }); expect(result).toBe(true); - expect(ownerChannel.sendMessage).toHaveBeenCalledWith( + expect(ownerChannel.sendMessage).toHaveBeenCalledWith( chatJid, 'DONE_WITH_CONCERNS\nowner handled fresh in_review input', ); expect(reviewerChannel.sendMessage).not.toHaveBeenCalled(); }); - it('consumes stale bot-only owner messages once the finalize turn output is already persisted', () => { - const chatJid = 'group@test'; - const group = makeGroup('codex'); - - vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(true); - const task = { - id: 'task-stale-owner-bot-message', - chat_jid: chatJid, - group_folder: group.folder, - owner_service_id: 'claude', - reviewer_service_id: 'codex-main', - title: null, - source_ref: 'HEAD', - plan_notes: null, - review_requested_at: '2026-03-30T00:00:00.000Z', - round_trip_count: 1, - status: 'merge_ready', - arbiter_verdict: null, - arbiter_requested_at: null, - completion_reason: null, - created_at: '2026-03-30T00:00:00.000Z', - updated_at: '2026-03-30T00:00:00.000Z', - } as any; - vi.mocked(db.getPairedTurnOutputs).mockReturnValue([ - { - id: 1, - task_id: 'task-stale-owner-bot-message', - turn_number: 1, - role: 'reviewer', - output_text: 'reviewer 승인', - created_at: '2026-03-30T00:00:01.000Z', - }, - { - id: 2, - task_id: 'task-stale-owner-bot-message', - turn_number: 2, - role: 'owner', - output_text: 'owner 최종 보고', - created_at: '2026-03-30T00:00:02.000Z', - }, - ]); - - expect( - buildPendingPairedTurn({ - chatJid, - timezone: 'UTC', - task, - rawMissedMessages: [ - { - seq: 43, - timestamp: '2026-03-30T00:00:04.000Z', - }, - ], - recentHumanMessages: [], - labeledRecentMessages: [], - resolveChannel: () => makeChannel(chatJid), - }), - ).toBeNull(); - - expect( - resolveBotOnlyPairedFollowUpAction({ - chatJid, - task, - isBotOnlyPairedFollowUp: true, - pendingCursorSource: { - seq: 43, - timestamp: '2026-03-30T00:00:04.000Z', - }, - }), - ).toEqual({ - kind: 'consume-stale-bot-message', - task, - cursor: 43, - currentStatus: 'merge_ready', - }); - }); - - it('runs merge_ready bot-only reviewer follow-ups inline in the message loop', async () => { - const chatJid = 'group@test'; - const group = makeGroup('codex'); - const channel = makeChannel(chatJid); - const enqueueMessageCheck = vi.fn(); - const closeStdin = vi.fn(); - const sendMessage = vi.fn(() => false); - const setLastTimestamp = vi.fn(); - const stopLoop = new Error('stop-message-loop'); - - vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(true); - vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({ - id: 'task-merge-ready-inline-loop', - chat_jid: chatJid, - group_folder: group.folder, - owner_service_id: 'claude', - reviewer_service_id: 'codex-main', - title: null, - source_ref: 'HEAD', - plan_notes: null, - review_requested_at: '2026-03-30T00:00:00.000Z', - round_trip_count: 1, - status: 'merge_ready', - arbiter_verdict: null, - arbiter_requested_at: null, - completion_reason: null, - created_at: '2026-03-30T00:00:00.000Z', - updated_at: '2026-03-30T00:00:00.000Z', - }); - vi.mocked(db.getPairedTurnOutputs).mockReturnValue([ - { - id: 1, - task_id: 'task-merge-ready-inline-loop', - turn_number: 1, - role: 'reviewer', - output_text: '리뷰 승인 요약', - created_at: '2026-03-30T00:00:03.000Z', - }, - ]); - vi.mocked(db.getNewMessages).mockReturnValue({ - messages: [ - { - id: 'reviewer-bot-message-inline-loop', - chat_jid: chatJid, - sender: 'reviewer-bot@test', - sender_name: '리뷰어', - content: 'DONE\n승인합니다.', - timestamp: '2026-03-30T00:00:04.000Z', - seq: 42, - is_bot_message: true, - } as any, - ], - newTimestamp: '42', - }); - vi.mocked(agentRunner.runAgentProcess).mockImplementation( - async (_group, input, _onProcess, onOutput) => { - expect(input.prompt).toBe( - "The reviewer approved your work (DONE). Finalize and report the result.\nIf you intend to close this paired turn now, your first line must be DONE.\nIf your first line is DONE_WITH_CONCERNS, the system will reopen review instead of finishing.\n\nReviewer's final assessment:\n리뷰 승인 요약", - ); - await onOutput?.({ - status: 'success', - phase: 'final', - result: '최종 정리 완료', - newSessionId: 'session-inline-loop-finalize', - }); - return { - status: 'success', - result: '최종 정리 완료', - newSessionId: 'session-inline-loop-finalize', - }; - }, - ); - - const runtime = createMessageRuntime({ - assistantName: 'Andy', - idleTimeout: 60_000, - pollInterval: 123, - timezone: 'UTC', - triggerPattern: /^@Andy\b/i, - channels: [channel], - queue: { - registerProcess: vi.fn(), - closeStdin, - enqueueMessageCheck, - notifyIdle: vi.fn(), - sendMessage, - } as any, - getRegisteredGroups: () => ({ [chatJid]: group }), - getSessions: () => ({}), - getLastTimestamp: () => '', - setLastTimestamp, - getLastAgentTimestamps: () => ({}), - saveState: vi.fn(), - persistSession: vi.fn(), - clearSession: vi.fn(), - }); - - const originalSetTimeout = global.setTimeout; - const setTimeoutSpy = vi.spyOn(global, 'setTimeout').mockImplementation((( - handler: any, - timeout?: number, - ...args: any[] - ) => { - if (timeout === 123) { - throw stopLoop; - } - return (originalSetTimeout as any)(handler, timeout, ...args); - }) as typeof setTimeout); - - try { - await expect(runtime.startMessageLoop()).rejects.toThrow( - stopLoop.message, - ); - } finally { - setTimeoutSpy.mockRestore(); - } - - expect(setLastTimestamp).toHaveBeenCalledWith('42'); - expect(agentRunner.runAgentProcess).toHaveBeenCalledTimes(1); - expect(channel.sendMessage).toHaveBeenCalledWith(chatJid, '최종 정리 완료'); - expect(closeStdin).not.toHaveBeenCalledWith( - chatJid, - expect.objectContaining({ reason: 'paired-pending-turn-follow-up' }), - ); - expect(enqueueMessageCheck).not.toHaveBeenCalledWith( - chatJid, - resolveGroupIpcPath(group.folder), - ); - expect(logger.info).toHaveBeenCalledWith( - expect.objectContaining({ - chatJid, - taskId: 'task-merge-ready-inline-loop', - taskStatus: 'merge_ready', - handoffMode: 'inline-finalize', - nextRole: 'owner', - cursor: 42, - }), - 'Executing merge_ready finalize turn inline after bot-only reviewer follow-up', - ); - }); - - it('requeues owner follow-ups from reviewer bot-only messages instead of piping them into the active run', async () => { - const chatJid = 'group@test'; - const group = makeGroup('codex'); - const channel = makeChannel(chatJid); - const enqueueMessageCheck = vi.fn(); - const closeStdin = vi.fn(); - const sendMessage = vi.fn(() => false); - const setLastTimestamp = vi.fn(); - const saveState = vi.fn(); - const lastAgentTimestamps: Record = {}; - const stopLoop = new Error('stop-message-loop'); - - vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(true); - vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({ - id: 'task-active-owner-follow-up-loop', - chat_jid: chatJid, - group_folder: group.folder, - owner_service_id: 'codex-main', - reviewer_service_id: 'claude', - title: null, - source_ref: 'HEAD', - plan_notes: null, - review_requested_at: '2026-03-30T00:00:00.000Z', - round_trip_count: 1, - status: 'active', - arbiter_verdict: null, - arbiter_requested_at: null, - completion_reason: null, - created_at: '2026-03-30T00:00:00.000Z', - updated_at: '2026-03-30T00:00:00.000Z', - }); - vi.mocked(db.getPairedTurnOutputs).mockReturnValue([ - { - id: 1, - task_id: 'task-active-owner-follow-up-loop', - turn_number: 1, - role: 'owner', - output_text: 'owner 초안', - created_at: '2026-03-30T00:00:01.000Z', - }, - { - id: 2, - task_id: 'task-active-owner-follow-up-loop', - turn_number: 2, - role: 'reviewer', - output_text: 'reviewer 수정 요청', - created_at: '2026-03-30T00:00:02.000Z', - }, - ]); - vi.mocked(db.getNewMessages).mockReturnValue({ - messages: [ - { - id: 'reviewer-bot-message-owner-follow-up-loop', - chat_jid: chatJid, - sender: 'reviewer-bot@test', - sender_name: '리뷰어', - content: 'DONE_WITH_CONCERNS\n\nreviewer direct message', - timestamp: '2026-03-30T00:00:04.000Z', - seq: 42, - is_bot_message: true, - } as any, - ], - newTimestamp: '42', - }); - - const runtime = createMessageRuntime({ - assistantName: 'Andy', - idleTimeout: 60_000, - pollInterval: 123, - timezone: 'UTC', - triggerPattern: /^@Andy\b/i, - channels: [channel], - queue: { - registerProcess: vi.fn(), - closeStdin, - enqueueMessageCheck, - notifyIdle: vi.fn(), - sendMessage, - } as any, - getRegisteredGroups: () => ({ [chatJid]: group }), - getSessions: () => ({}), - getLastTimestamp: () => '', - setLastTimestamp, - getLastAgentTimestamps: () => lastAgentTimestamps, - saveState, - persistSession: vi.fn(), - clearSession: vi.fn(), - }); - - const originalSetTimeout = global.setTimeout; - const setTimeoutSpy = vi.spyOn(global, 'setTimeout').mockImplementation((( - handler: any, - timeout?: number, - ...args: any[] - ) => { - if (timeout === 123) { - throw stopLoop; - } - return (originalSetTimeout as any)(handler, timeout, ...args); - }) as typeof setTimeout); - - try { - await expect(runtime.startMessageLoop()).rejects.toThrow( - stopLoop.message, - ); - } finally { - setTimeoutSpy.mockRestore(); - } - - expect(setLastTimestamp).toHaveBeenCalledWith('42'); - expect(agentRunner.runAgentProcess).not.toHaveBeenCalled(); - expect(closeStdin).toHaveBeenCalledWith( - chatJid, - expect.objectContaining({ reason: 'paired-pending-turn-follow-up' }), - ); - expect(enqueueMessageCheck).toHaveBeenCalledWith( - chatJid, - resolveGroupIpcPath(group.folder), - ); - expect(sendMessage).not.toHaveBeenCalled(); - }); - - it('auto-runs an owner follow-up when a task returns to active after reviewer feedback', async () => { - const chatJid = 'group@test'; - const group = makeGroup('codex'); - const channel = makeChannel(chatJid); - - vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(true); - vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({ - id: 'task-active-owner-follow-up', - chat_jid: chatJid, - group_folder: group.folder, - owner_service_id: 'codex-main', - reviewer_service_id: 'claude', - title: null, - source_ref: 'HEAD', - plan_notes: null, - review_requested_at: '2026-03-30T00:00:00.000Z', - round_trip_count: 1, - status: 'active', - arbiter_verdict: null, - arbiter_requested_at: null, - completion_reason: null, - created_at: '2026-03-30T00:00:00.000Z', - updated_at: '2026-03-30T00:00:00.000Z', - }); - vi.mocked(db.getMessagesSince).mockReturnValue([]); - vi.mocked(db.getPairedTurnOutputs).mockReturnValue([ - { - id: 1, - task_id: 'task-active-owner-follow-up', - turn_number: 1, - role: 'owner', - output_text: 'owner 초안', - created_at: '2026-03-30T00:00:01.000Z', - }, - { - id: 2, - task_id: 'task-active-owner-follow-up', - turn_number: 2, - role: 'reviewer', - output_text: '리뷰어가 수정 요청을 남김', - created_at: '2026-03-30T00:00:02.000Z', - }, - ]); - vi.mocked(db.getRecentChatMessages).mockReturnValue([ - { - id: 'human-1', - chat_jid: chatJid, - sender: 'user@test', - sender_name: '눈쟁이', - content: '이 기능 마무리해줘', - timestamp: '2026-03-30T00:00:00.500Z', - seq: 1, - is_bot_message: false, - } as any, - ]); - vi.mocked(agentRunner.runAgentProcess).mockImplementation( - async (_group, input, _onProcess, onOutput) => { - expect(input.prompt).toContain('이 기능 마무리해줘'); - expect(input.prompt).toContain('리뷰어가 수정 요청을 남김'); - await onOutput?.({ - status: 'success', - phase: 'final', - result: 'owner가 reviewer 피드백을 반영했습니다.', - newSessionId: 'session-owner-follow-up', - }); - return { - status: 'success', - result: 'owner가 reviewer 피드백을 반영했습니다.', - newSessionId: 'session-owner-follow-up', - }; - }, - ); - - const runtime = createMessageRuntime({ - assistantName: 'Andy', - idleTimeout: 1_000, - pollInterval: 1_000, - timezone: 'UTC', - triggerPattern: /^@Andy\b/i, - channels: [channel], - queue: { - registerProcess: vi.fn(), - closeStdin: vi.fn(), - notifyIdle: vi.fn(), - } as any, - getRegisteredGroups: () => ({ [chatJid]: group }), - getSessions: () => ({}), - getLastTimestamp: () => '', - setLastTimestamp: vi.fn(), - getLastAgentTimestamps: () => ({}), - saveState: vi.fn(), - persistSession: vi.fn(), - clearSession: vi.fn(), - }); - - const result = await runtime.processGroupMessages(chatJid, { - runId: 'run-active-owner-follow-up', - reason: 'messages', - }); - - expect(result).toBe(true); - expect(agentRunner.runAgentProcess).toHaveBeenCalledTimes(1); - expect(channel.sendMessage).toHaveBeenCalledWith( - chatJid, - 'owner가 reviewer 피드백을 반영했습니다.', - ); - }); - - it('builds owner follow-up prompts from paired turn outputs instead of raw reviewer bot delivery text', async () => { - const chatJid = 'group@test'; - const group = makeGroup('codex'); - const channel = makeChannel(chatJid); - - vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(true); - vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({ - id: 'task-active-bot-follow-up', - chat_jid: chatJid, - group_folder: group.folder, - owner_service_id: 'codex-main', - reviewer_service_id: 'claude', - title: null, - source_ref: 'HEAD', - plan_notes: null, - review_requested_at: '2026-03-30T00:00:00.000Z', - round_trip_count: 1, - status: 'active', - arbiter_verdict: null, - arbiter_requested_at: null, - completion_reason: null, - created_at: '2026-03-30T00:00:00.000Z', - updated_at: '2026-03-30T00:00:00.000Z', - }); - vi.mocked(db.getMessagesSince).mockReturnValue([ - { - id: 'reviewer-bot-message-active', - chat_jid: chatJid, - sender: 'reviewer-bot@test', - sender_name: '리뷰어', - content: 'DONE_WITH_CONCERNS\n\n리뷰어 디스코드 출력 원문', - timestamp: '2026-03-30T00:00:04.000Z', - seq: 42, - is_bot_message: true, - } as any, - ]); - vi.mocked(db.getPairedTurnOutputs).mockReturnValue([ - { - id: 1, - task_id: 'task-active-bot-follow-up', - turn_number: 1, - role: 'owner', - output_text: 'owner 초안', - created_at: '2026-03-30T00:00:01.000Z', - }, - { - id: 2, - task_id: 'task-active-bot-follow-up', - turn_number: 2, - role: 'reviewer', - output_text: 'paired_turn_outputs 에 저장된 reviewer 요약', - created_at: '2026-03-30T00:00:02.000Z', - }, - ]); - vi.mocked(db.getRecentChatMessages).mockReturnValue([ - { - id: 'human-1', - chat_jid: chatJid, - sender: 'user@test', - sender_name: '눈쟁이', - content: '이 기능 마무리해줘', - timestamp: '2026-03-30T00:00:00.500Z', - seq: 1, - is_bot_message: false, - } as any, - ]); - vi.mocked(agentRunner.runAgentProcess).mockImplementation( - async (_group, input, _onProcess, onOutput) => { - expect(input.prompt).toContain( - 'paired_turn_outputs 에 저장된 reviewer 요약', - ); - expect(input.prompt).not.toContain('리뷰어 디스코드 출력 원문'); - await onOutput?.({ - status: 'success', - phase: 'final', - result: 'owner follow-up ok', - newSessionId: 'session-owner-bot-follow-up', - }); - return { - status: 'success', - result: 'owner follow-up ok', - newSessionId: 'session-owner-bot-follow-up', - }; - }, - ); - - const runtime = createMessageRuntime({ - assistantName: 'Andy', - idleTimeout: 1_000, - pollInterval: 1_000, - timezone: 'UTC', - triggerPattern: /^@Andy\b/i, - channels: [channel], - queue: { - registerProcess: vi.fn(), - closeStdin: vi.fn(), - notifyIdle: vi.fn(), - } as any, - getRegisteredGroups: () => ({ [chatJid]: group }), - getSessions: () => ({}), - getLastTimestamp: () => '', - setLastTimestamp: vi.fn(), - getLastAgentTimestamps: () => ({}), - saveState: vi.fn(), - persistSession: vi.fn(), - clearSession: vi.fn(), - }); - - const result = await runtime.processGroupMessages(chatJid, { - runId: 'run-active-bot-follow-up', - reason: 'messages', - }); - - expect(result).toBe(true); - expect(agentRunner.runAgentProcess).toHaveBeenCalledTimes(1); - expect(channel.sendMessage).toHaveBeenCalledWith( - chatJid, - 'owner follow-up ok', - ); - }); - - it('reuses the shared arbiter prompt builder for pending arbiter turns', async () => { - const chatJid = 'group@test'; - const group = makeGroup('codex'); - const ownerChannel = makeChannel(chatJid); - const arbiterChannel = makeChannel(chatJid, 'discord-arbiter', false); - - vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(true); - vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({ - id: 'task-arbiter', - chat_jid: chatJid, - group_folder: group.folder, - owner_service_id: 'claude', - reviewer_service_id: 'codex-main', - title: null, - source_ref: 'HEAD', - plan_notes: null, - review_requested_at: '2026-03-30T00:00:00.000Z', - round_trip_count: 3, - status: 'arbiter_requested', - arbiter_verdict: null, - arbiter_requested_at: '2026-03-30T00:00:10.000Z', - completion_reason: null, - created_at: '2026-03-30T00:00:00.000Z', - updated_at: '2026-03-30T00:00:10.000Z', - }); - vi.mocked(db.getPairedTurnOutputs).mockReturnValue([ - { - id: 1, - task_id: 'task-arbiter', - turn_number: 1, - role: 'owner', - output_text: 'owner 산출물', - created_at: '2026-03-30T00:00:01.000Z', - }, - { - id: 2, - task_id: 'task-arbiter', - turn_number: 2, - role: 'reviewer', - output_text: 'reviewer 이견', - created_at: '2026-03-30T00:00:02.000Z', - }, - ]); - vi.mocked(db.getRecentChatMessages).mockReturnValue([ - { - id: 'human-1', - chat_jid: chatJid, - sender: 'user@test', - sender_name: 'User', - content: '추가 맥락', - timestamp: '2026-03-30T00:00:00.500Z', - is_bot_message: false, - } as any, - { - id: 'bot-1', - chat_jid: chatJid, - sender: 'bot@test', - sender_name: 'Bot', - content: 'bot progress', - timestamp: '2026-03-30T00:00:03.000Z', - is_bot_message: true, - } as any, - ]); - vi.mocked(agentRunner.runAgentProcess).mockImplementation( - async (_group, input, _onProcess, onOutput) => { - expect(input.prompt).toContain('task-arbiter'); - expect(input.prompt).toContain('3'); - expect(input.prompt).toContain('추가 맥락'); - expect(input.prompt).toContain('owner 산출물'); - expect(input.prompt).toContain('reviewer 이견'); - expect(input.prompt).not.toContain('bot progress'); - await onOutput?.({ - status: 'success', - phase: 'final', - result: 'arbiter 확인 완료', - newSessionId: 'session-arbiter-pending', - }); - return { - status: 'success', - result: 'arbiter 확인 완료', - newSessionId: 'session-arbiter-pending', - }; - }, - ); - - const runtime = createMessageRuntime({ - assistantName: 'Andy', - idleTimeout: 1_000, - pollInterval: 1_000, - timezone: 'UTC', - triggerPattern: /^@Andy\b/i, - channels: [ownerChannel, arbiterChannel], - queue: { - registerProcess: vi.fn(), - closeStdin: vi.fn(), - notifyIdle: vi.fn(), - } as any, - getRegisteredGroups: () => ({ [chatJid]: group }), - getSessions: () => ({}), - getLastTimestamp: () => '', - setLastTimestamp: vi.fn(), - getLastAgentTimestamps: () => ({}), - saveState: vi.fn(), - persistSession: vi.fn(), - clearSession: vi.fn(), - }); - - const result = await runtime.processGroupMessages(chatJid, { - runId: 'run-arbiter-pending-shared-prompt', - reason: 'messages', - }); - - expect(result).toBe(true); - expect(agentRunner.runAgentProcess).toHaveBeenCalledTimes(1); - expect(arbiterChannel.sendMessage).toHaveBeenCalledWith( - chatJid, - 'arbiter 확인 완료', - ); - }); - it('fails closed for in-review turns without fresh human input when the reviewer channel is missing', async () => { const chatJid = 'group@test'; const group = makeGroup('codex'); diff --git a/src/paired-follow-up-scheduler.test.ts b/src/paired-follow-up-scheduler.test.ts index 6d612ed..0469b4c 100644 --- a/src/paired-follow-up-scheduler.test.ts +++ b/src/paired-follow-up-scheduler.test.ts @@ -68,7 +68,6 @@ describe('paired follow-up scheduler', () => { expect(second).toBe(false); expect(enqueue).toHaveBeenCalledTimes(1); }); - it('keeps different round trips schedulable', () => { const enqueue = vi.fn();