From f5d1ec302d595c6ed3f0232efe9fc6e2a764e2c7 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 25 Aug 2026 21:02:23 +0900 Subject: [PATCH] Guard against phantom "I'll be notified" dead-end finals at delivery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An agent could end a turn with "I'll wait for the build to complete — I'll be notified" without registering any watcher, so nothing ever resumed and the user was left staring at a dead-end (observed in the tts_site room). The prompt rule alone did not stop the model. At final-delivery time, when the final reads like such a phantom-notification dead-end AND no CI watcher is active for the chat, append an explicit notice telling the user no notification is coming and to send a follow-up — turning a silent hang into an actionable prompt. Pure detector + guard with unit tests; wired into the owner/reviewer final-delivery path. Co-Authored-By: Claude Opus 4.7 --- src/message-runtime-turns.ts | 11 +++- src/phantom-notification-guard.test.ts | 76 ++++++++++++++++++++++++++ src/phantom-notification-guard.ts | 53 ++++++++++++++++++ 3 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 src/phantom-notification-guard.test.ts create mode 100644 src/phantom-notification-guard.ts diff --git a/src/message-runtime-turns.ts b/src/message-runtime-turns.ts index e496f3d..a231f63 100644 --- a/src/message-runtime-turns.ts +++ b/src/message-runtime-turns.ts @@ -1,5 +1,6 @@ import { type AgentOutput } from './agent-runner.js'; -import { getLastBotFinalMessage } from './db.js'; +import { getLastBotFinalMessage, hasActiveCiWatcherForChat } from './db.js'; +import { applyPhantomNotificationGuard } from './phantom-notification-guard.js'; import { runAgentForGroup } from './message-agent-executor.js'; import { MessageTurnController } from './message-turn-controller.js'; import { @@ -207,9 +208,15 @@ export function createExecuteTurn(deps: CreateExecuteTurnDeps): ExecuteTurnFn { getCloseReason: () => deps.queue.getCloseReasonForRun?.(chatJid, runId) ?? null, deliverFinalText: async (text, options) => { + // Never let a turn close on a phantom "I'll wait … I'll be notified" + // dead-end when no watcher is actually registered — annotate it so the + // user gets an actionable follow-up prompt instead of silence. + const guardedText = applyPhantomNotificationGuard(text, { + hasActiveWatcher: hasActiveCiWatcherForChat(chatJid), + }); try { return await deps.deliverFinalText({ - text, + text: guardedText, ...(options?.attachments?.length ? { attachments: options.attachments } : {}), diff --git a/src/phantom-notification-guard.test.ts b/src/phantom-notification-guard.test.ts new file mode 100644 index 0000000..227476f --- /dev/null +++ b/src/phantom-notification-guard.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; + +import { + applyPhantomNotificationGuard, + isPhantomNotificationDeadEnd, + PHANTOM_NOTIFICATION_NOTICE, +} 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); + }); +}); diff --git a/src/phantom-notification-guard.ts b/src/phantom-notification-guard.ts new file mode 100644 index 0000000..0140193 --- /dev/null +++ b/src/phantom-notification-guard.ts @@ -0,0 +1,53 @@ +// Guard against "phantom notification" dead-end finals. +// +// Agents sometimes end a turn with a sentence like "I'll wait for the build to +// complete — I'll be notified", expecting a background-completion callback that +// this environment never delivers. The turn then simply ends and the user is +// left staring at that message forever (no watcher was registered, so nothing +// ever resumes). The claude-platform prompt already forbids this, but a prompt +// rule cannot reliably stop the model, so we also enforce it at delivery time: +// when a final looks like such a dead-end and no watcher is actually active, we +// append an explicit notice telling the user no notification is coming and to +// send a follow-up — turning a silent hang into an actionable prompt. + +const PHANTOM_NOTIFICATION_PATTERNS: readonly RegExp[] = [ + // First-person promise of a callback that never arrives. + /\bi['’]?\s*ll\s+be\s+notified\b/i, + /\bi\s+will\s+be\s+notified\b/i, + /\bi['’]?\s*m\s+(?:going to|gonna)\s+(?:wait|be notified)\b/i, + // "wait for the build/compile/deploy to complete/finish" + /\bwait(?:ing)?\s+for\s+the\s+[\w\s-]{0,40}?\bto\s+(?:complete|finish|be\s+done)\b/i, + // "notified when it finishes/completes/is done" + /\bnotified\s+when\s+[\w\s-]{0,40}?(?:finishes|completes|is\s+done)\b/i, +]; + +/** + * True when `text` reads like a background-completion "I'll be notified" + * dead-end. Conservative: matches the distinctive first-person phrasing rather + * than any mention of waiting, to avoid annotating legitimate status reports. + */ +export function isPhantomNotificationDeadEnd(text: string): boolean { + if (!text) return false; + return PHANTOM_NOTIFICATION_PATTERNS.some((pattern) => pattern.test(text)); +} + +export const PHANTOM_NOTIFICATION_NOTICE = + '\n\n[자동 안내] 이 환경에는 백그라운드 완료 알림이 없어서, 위처럼 "기다렸다가 알림을 받겠다"고 해도 실제로는 아무것도 다시 시작되지 않습니다. 감시 작업(watch_ci 등)도 등록되지 않았습니다. 계속 진행하려면 이 방에 메시지를 한 번 더 보내 주세요.'; + +const NOTICE_MARKER = '[자동 안내]'; + +/** + * Returns `text` unchanged unless it is a phantom-notification dead-end AND no + * watcher is active for the chat, in which case an explicit follow-up notice is + * appended. Idempotent: never appends the notice twice. When a watcher IS + * active the "I'll be notified" claim is legitimate, so the text is untouched. + */ +export function applyPhantomNotificationGuard( + text: string, + opts: { hasActiveWatcher: boolean }, +): string { + if (opts.hasActiveWatcher) return text; + if (!isPhantomNotificationDeadEnd(text)) return text; + if (text.includes(NOTICE_MARKER)) return text; + return `${text}${PHANTOM_NOTIFICATION_NOTICE}`; +}