diff --git a/src/bot-message-filter.test.ts b/src/bot-message-filter.test.ts index 4b69143..529c689 100644 --- a/src/bot-message-filter.test.ts +++ b/src/bot-message-filter.test.ts @@ -138,4 +138,41 @@ describe('filterProcessableMessages', () => { expect(result[0].id).toBe('human-1'); expect(result[1].id).toBe('other-1'); }); + + it('filters paired execution status control bot messages in paired rooms', () => { + const isOwn = (m: NewMessage) => + m.is_bot_message === true && m.sender === OWN_BOT_ID; + const result = filterProcessableMessages( + [ + makeMsg({ id: 'human-1', content: 'human' }), + makeMsg({ + id: 'control-1', + sender: 'other-bot-456', + sender_name: 'OtherBot', + content: '<@216851709744513024> ✅ 작업 완료.', + is_bot_message: true, + }), + makeMsg({ + id: 'control-2', + sender: 'other-bot-456', + sender_name: 'OtherBot', + content: '⚠️ 자동 해결 불가 — 확인이 필요합니다.', + is_bot_message: true, + }), + makeMsg({ + id: 'other-1', + sender: 'other-bot-456', + sender_name: 'OtherBot', + content: 'partner response', + is_bot_message: true, + }), + ], + true, + isOwn, + ); + + expect(result).toHaveLength(2); + expect(result[0].id).toBe('human-1'); + expect(result[1].id).toBe('other-1'); + }); }); diff --git a/src/bot-message-filter.ts b/src/bot-message-filter.ts index 216f77b..bb717c4 100644 --- a/src/bot-message-filter.ts +++ b/src/bot-message-filter.ts @@ -1,6 +1,13 @@ import { isSessionCommandControlMessage } from './session-commands.js'; import { NewMessage } from './types.js'; +const PAIRED_EXECUTION_CONTROL_MESSAGE_PATTERN = + /^(?:<@\d+>\s+)?(?:✅ 작업 완료\.|⚠️ 자동 해결 불가 — 확인이 필요합니다\.|⚠️ 중재자 판단: 사람 개입이 필요합니다\.)$/; + +function isPairedExecutionControlMessage(content: string): boolean { + return PAIRED_EXECUTION_CONTROL_MESSAGE_PATTERN.test(content.trim()); +} + /** * Filter messages before processing. * - Normal rooms: drop all bot messages. @@ -16,7 +23,8 @@ export function filterProcessableMessages( (message) => !( message.is_bot_message && - isSessionCommandControlMessage(message.content) + (isSessionCommandControlMessage(message.content) || + isPairedExecutionControlMessage(message.content)) ), ); diff --git a/src/message-agent-executor-paired.ts b/src/message-agent-executor-paired.ts index 7970956..12db155 100644 --- a/src/message-agent-executor-paired.ts +++ b/src/message-agent-executor-paired.ts @@ -244,7 +244,6 @@ export function createPairedExecutionLifecycle(args: { const sender = getLastHumanMessageSender(chatJid); const mention = sender ? `<@${sender}>` : ''; const notifications: Record = { - done: `${mention} ✅ 작업 완료.`, escalated: `${mention} ⚠️ 자동 해결 불가 — 확인이 필요합니다.`, arbiter_escalated: `${mention} ⚠️ 중재자 판단: 사람 개입이 필요합니다.`, }; diff --git a/src/message-agent-executor.test.ts b/src/message-agent-executor.test.ts index db10721..c514f3d 100644 --- a/src/message-agent-executor.test.ts +++ b/src/message-agent-executor.test.ts @@ -837,6 +837,176 @@ describe('runAgentForGroup room memory', () => { }); }); + it('does not emit an extra public done notification after owner finalization completes the paired task', async () => { + const group = { + ...makeGroup(), + folder: 'test-group', + workDir: '/repo/canonical', + }; + const outputs: string[] = []; + + vi.mocked(db.getLastHumanMessageSender).mockReturnValue('216851709744513024'); + vi.mocked( + pairedExecutionContext.preparePairedExecutionContext, + ).mockReturnValue({ + task: { + id: 'paired-task-final-done', + chat_jid: 'group@test', + group_folder: 'test-group', + owner_service_id: 'claude', + reviewer_service_id: 'codex-main', + title: null, + source_ref: 'HEAD', + plan_notes: null, + round_trip_count: 1, + review_requested_at: '2026-04-09T00:00:00.000Z', + status: 'merge_ready', + arbiter_verdict: null, + arbiter_requested_at: null, + completion_reason: null, + created_at: '2026-04-09T00:00:00.000Z', + updated_at: '2026-04-09T00:00:00.000Z', + }, + workspace: null, + envOverrides: {}, + }); + vi.mocked(db.getPairedTaskById).mockReturnValue({ + id: 'paired-task-final-done', + chat_jid: 'group@test', + group_folder: 'test-group', + owner_service_id: 'claude', + reviewer_service_id: 'codex-main', + title: null, + source_ref: 'HEAD', + plan_notes: null, + round_trip_count: 1, + review_requested_at: '2026-04-09T00:00:00.000Z', + status: 'completed', + arbiter_verdict: null, + arbiter_requested_at: null, + completion_reason: 'done', + created_at: '2026-04-09T00:00:00.000Z', + updated_at: '2026-04-09T00:00:01.000Z', + }); + vi.mocked(agentRunner.runAgentProcess).mockImplementation( + async (_group, _input, _onProcess, onOutput) => { + await onOutput?.({ + status: 'success', + result: 'DONE\nfinalized', + output: { visibility: 'public', text: 'DONE\nfinalized' }, + phase: 'final', + }); + return { + status: 'success', + result: 'DONE\nfinalized', + }; + }, + ); + + const result = await runAgentForGroup(makeDeps(), { + group, + prompt: 'finalize', + chatJid: 'group@test', + runId: 'run-final-done', + onOutput: async (output) => { + if (output.output?.visibility === 'public' && output.output.text) { + outputs.push(output.output.text); + } + }, + }); + + expect(result).toBe('success'); + expect(outputs).toEqual(['DONE\nfinalized']); + }); + + it('still emits escalation notifications when the paired task completes with human intervention required', async () => { + const group = { + ...makeGroup(), + folder: 'test-group', + workDir: '/repo/canonical', + }; + const outputs: string[] = []; + + vi.mocked(db.getLastHumanMessageSender).mockReturnValue('216851709744513024'); + vi.mocked( + pairedExecutionContext.preparePairedExecutionContext, + ).mockReturnValue({ + task: { + id: 'paired-task-final-escalated', + chat_jid: 'group@test', + group_folder: 'test-group', + owner_service_id: 'claude', + reviewer_service_id: 'codex-main', + title: null, + source_ref: 'HEAD', + plan_notes: null, + round_trip_count: 1, + review_requested_at: '2026-04-09T00:00:00.000Z', + status: 'merge_ready', + arbiter_verdict: null, + arbiter_requested_at: null, + completion_reason: null, + created_at: '2026-04-09T00:00:00.000Z', + updated_at: '2026-04-09T00:00:00.000Z', + }, + workspace: null, + envOverrides: {}, + }); + vi.mocked(db.getPairedTaskById).mockReturnValue({ + id: 'paired-task-final-escalated', + chat_jid: 'group@test', + group_folder: 'test-group', + owner_service_id: 'claude', + reviewer_service_id: 'codex-main', + title: null, + source_ref: 'HEAD', + plan_notes: null, + round_trip_count: 1, + review_requested_at: '2026-04-09T00:00:00.000Z', + status: 'completed', + arbiter_verdict: null, + arbiter_requested_at: null, + completion_reason: 'escalated', + created_at: '2026-04-09T00:00:00.000Z', + updated_at: '2026-04-09T00:00:01.000Z', + }); + vi.mocked(agentRunner.runAgentProcess).mockImplementation( + async (_group, _input, _onProcess, onOutput) => { + await onOutput?.({ + status: 'success', + result: 'BLOCKED\nhuman intervention required', + output: { + visibility: 'public', + text: 'BLOCKED\nhuman intervention required', + }, + phase: 'final', + }); + return { + status: 'success', + result: 'BLOCKED\nhuman intervention required', + }; + }, + ); + + const result = await runAgentForGroup(makeDeps(), { + group, + prompt: 'finalize', + chatJid: 'group@test', + runId: 'run-final-escalated', + onOutput: async (output) => { + if (output.output?.visibility === 'public' && output.output.text) { + outputs.push(output.output.text); + } + }, + }); + + expect(result).toBe('success'); + expect(outputs).toEqual([ + 'BLOCKED\nhuman intervention required', + '<@216851709744513024> ⚠️ 자동 해결 불가 — 확인이 필요합니다.', + ]); + }); + it('logs streamed activity with resolved execution attribution', async () => { const group = { ...makeGroup(),