Guard against phantom "I'll be notified" dead-end finals at delivery
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 }
|
||||
: {}),
|
||||
|
||||
76
src/phantom-notification-guard.test.ts
Normal file
76
src/phantom-notification-guard.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
53
src/phantom-notification-guard.ts
Normal file
53
src/phantom-notification-guard.ts
Normal file
@@ -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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user