Move the storeMessage → enqueueMessageCheck side effect out of the delivery callback into scheduleAutoContinueForPhantomFinal so the injection path is directly unit-tested: an auto-continue outcome for a delivered final injects the corrective nudge as an ipc_injected_human message and re-enqueues a turn, while a non-auto-continue outcome or an undelivered final does nothing. Behavior is identical to the inline version — this only adds test coverage and a no-op refactor, so no redeploy is required. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
231 lines
6.8 KiB
TypeScript
231 lines
6.8 KiB
TypeScript
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();
|
|
});
|
|
});
|