import { beforeEach, describe, expect, it, vi } from 'vitest'; import { applyPhantomNotificationGuard, isPhantomNotificationDeadEnd, PHANTOM_CONTINUE_NUDGE, PHANTOM_NOTIFICATION_NOTICE, resolvePhantomFinalOutcome, scheduleAutoContinueForPhantomFinal, _resetPhantomAutoContinueForTests, } from './phantom-notification-guard.js'; describe('isPhantomNotificationDeadEnd', () => { it('detects the reported "I\'ll wait for the build … I\'ll be notified" final', () => { expect( isPhantomNotificationDeadEnd( "I'll wait for the build to complete — I'll be notified.", ), ).toBe(true); }); it('detects close variants of the notification promise', () => { expect( isPhantomNotificationDeadEnd('I will be notified when it finishes.'), ).toBe(true); expect( isPhantomNotificationDeadEnd('Waiting for the deploy to finish now.'), ).toBe(true); expect( isPhantomNotificationDeadEnd("I'll be notified once CI is green."), ).toBe(true); }); it('does not flag ordinary status finals', () => { expect( isPhantomNotificationDeadEnd( 'TASK_DONE\n빌드를 끝냈고 테스트 1597개가 모두 통과했습니다.', ), ).toBe(false); expect( isPhantomNotificationDeadEnd('The build completed and I pushed the fix.'), ).toBe(false); expect(isPhantomNotificationDeadEnd('')).toBe(false); }); }); describe('applyPhantomNotificationGuard', () => { const deadEnd = "I'll wait for the build to complete — I'll be notified."; it('appends the follow-up notice for a dead-end final with no active watcher', () => { const result = applyPhantomNotificationGuard(deadEnd, { hasActiveWatcher: false, }); expect(result.startsWith(deadEnd)).toBe(true); expect(result).toContain(PHANTOM_NOTIFICATION_NOTICE.trim()); }); it('leaves the final untouched when a watcher is actually active', () => { expect( applyPhantomNotificationGuard(deadEnd, { hasActiveWatcher: true }), ).toBe(deadEnd); }); it('leaves ordinary finals untouched', () => { const ok = 'TASK_DONE\n작업을 마쳤습니다.'; expect(applyPhantomNotificationGuard(ok, { hasActiveWatcher: false })).toBe( ok, ); }); it('is idempotent — never appends the notice twice', () => { const once = applyPhantomNotificationGuard(deadEnd, { hasActiveWatcher: false, }); const twice = applyPhantomNotificationGuard(once, { hasActiveWatcher: false, }); expect(twice).toBe(once); }); }); describe('resolvePhantomFinalOutcome', () => { const deadEnd = "I'll wait for the build to complete — I'll be notified."; const chatJid = 'dc:test-room'; beforeEach(() => { _resetPhantomAutoContinueForTests(); }); it('auto-continues once, then falls back to the notice on a repeated dead-end', () => { const first = resolvePhantomFinalOutcome({ chatJid, text: deadEnd, hasActiveWatcher: false, canAutoContinue: true, }); expect(first.kind).toBe('auto-continue'); // Second consecutive dead-end for the same chat hits the cap → notice, no // further auto-continue (this is the loop guard). const second = resolvePhantomFinalOutcome({ chatJid, text: deadEnd, hasActiveWatcher: false, canAutoContinue: true, }); expect(second.kind).toBe('notice'); expect(second.text).toContain(PHANTOM_NOTIFICATION_NOTICE.trim()); }); it('resets the streak after a clean final so a later dead-end can auto-continue again', () => { expect( resolvePhantomFinalOutcome({ chatJid, text: deadEnd, hasActiveWatcher: false, canAutoContinue: true, }).kind, ).toBe('auto-continue'); // A clean final resets the streak. expect( resolvePhantomFinalOutcome({ chatJid, text: 'TASK_DONE\n작업을 마쳤습니다.', hasActiveWatcher: false, canAutoContinue: true, }).kind, ).toBe('clean'); // Streak reset → next dead-end auto-continues again. expect( resolvePhantomFinalOutcome({ chatJid, text: deadEnd, hasActiveWatcher: false, canAutoContinue: true, }).kind, ).toBe('auto-continue'); }); it('never auto-continues when a watcher is active (legitimate wait)', () => { expect( resolvePhantomFinalOutcome({ chatJid, text: deadEnd, hasActiveWatcher: true, canAutoContinue: true, }).kind, ).toBe('clean'); }); it('emits the notice (never auto-continue) when auto-continue is disabled', () => { const outcome = resolvePhantomFinalOutcome({ chatJid, text: deadEnd, hasActiveWatcher: false, canAutoContinue: false, }); expect(outcome.kind).toBe('notice'); }); }); describe('scheduleAutoContinueForPhantomFinal', () => { it('injects the corrective nudge and re-enqueues when the dead-end final delivered', () => { const storeMessage = vi.fn(); const enqueueMessageCheck = vi.fn(); const scheduled = scheduleAutoContinueForPhantomFinal({ outcomeKind: 'auto-continue', delivered: true, chatJid: 'dc:test-room', groupFolder: 'test-room', runId: 'run-xyz', storeMessage, enqueueMessageCheck, }); expect(scheduled).toBe(true); expect(storeMessage).toHaveBeenCalledTimes(1); expect(storeMessage).toHaveBeenCalledWith( expect.objectContaining({ chat_jid: 'dc:test-room', content: PHANTOM_CONTINUE_NUDGE, is_from_me: false, is_bot_message: false, message_source_kind: 'ipc_injected_human', }), ); expect(enqueueMessageCheck).toHaveBeenCalledWith('dc:test-room', 'test-room'); }); it('does nothing when the outcome is not auto-continue', () => { const storeMessage = vi.fn(); const enqueueMessageCheck = vi.fn(); const scheduled = scheduleAutoContinueForPhantomFinal({ outcomeKind: 'notice', delivered: true, chatJid: 'dc:test-room', groupFolder: 'test-room', runId: 'run-xyz', storeMessage, enqueueMessageCheck, }); expect(scheduled).toBe(false); expect(storeMessage).not.toHaveBeenCalled(); expect(enqueueMessageCheck).not.toHaveBeenCalled(); }); it('does nothing when the final was not delivered', () => { const storeMessage = vi.fn(); const enqueueMessageCheck = vi.fn(); const scheduled = scheduleAutoContinueForPhantomFinal({ outcomeKind: 'auto-continue', delivered: false, chatJid: 'dc:test-room', groupFolder: 'test-room', runId: 'run-xyz', storeMessage, enqueueMessageCheck, }); expect(scheduled).toBe(false); expect(storeMessage).not.toHaveBeenCalled(); expect(enqueueMessageCheck).not.toHaveBeenCalled(); }); });