Files
EJClaw/src/phantom-notification-guard.ts
Codex 007e95fe40 Extract + unit-test the phantom auto-continue injection path
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>
2026-08-25 23:12:18 +09:00

150 lines
7.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { NewMessage } from './types.js';
// 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}`;
}
// ── Auto-continue after a phantom dead-end ────────────────────────────────
//
// When a non-paired turn dead-ends on a phantom "I'll be notified" final with no
// watcher, instead of just leaving a notice we re-invoke the agent once so it
// actually finishes the work in the foreground. A hard per-chat cap makes this
// safe: at most MAX_PHANTOM_AUTO_CONTINUES re-runs per phantom streak, so a model
// that keeps producing the dead-end can never spin the bot in a loop. The streak
// resets as soon as any clean (non-phantom) final is produced.
const MAX_PHANTOM_AUTO_CONTINUES = 1;
const phantomAutoContinueStreak = new Map<string, number>();
/** Corrective nudge injected (as an inbound instruction) before the re-run. */
export const PHANTOM_CONTINUE_NUDGE =
'[시스템 자동 안내] 방금 실제 감시 작업(watch_ci 등)을 등록하지 않은 채 "기다렸다가 알림을 받겠다"는 식으로 응답을 끝냈습니다. 이 환경에는 백그라운드 완료 알림이 없어, 그대로 두면 아무것도 다시 시작되지 않습니다. 지금 바로 포그라운드에서 작업을 끝까지 진행해 실제 결과를 보고하세요. 정말 오래 걸리는 작업이면 watch_ci/schedule_task로 감시를 등록한 뒤 그 사실을 알려주세요. 다시는 "알림을 받겠다"며 끝내지 마세요.';
export type PhantomFinalOutcome =
| { kind: 'clean'; text: string }
| { kind: 'auto-continue'; text: string }
| { kind: 'notice'; text: string };
/**
* Decide what to do with a final that may be a phantom dead-end. Pure except for
* the per-chat streak counter, which is the loop guard.
*
* - `clean`: not a dead-end (or a watcher is active) — deliver as-is; resets streak.
* - `auto-continue`: dead-end, under the cap — deliver as-is, and the caller must
* re-invoke the agent once with PHANTOM_CONTINUE_NUDGE.
* - `notice`: dead-end but the cap is exhausted (or auto-continue disabled) —
* deliver with the follow-up notice appended so the user isn't left hanging.
*/
export function resolvePhantomFinalOutcome(args: {
chatJid: string;
text: string;
hasActiveWatcher: boolean;
canAutoContinue: boolean;
}): PhantomFinalOutcome {
const { chatJid, text, hasActiveWatcher, canAutoContinue } = args;
if (hasActiveWatcher || !isPhantomNotificationDeadEnd(text)) {
phantomAutoContinueStreak.delete(chatJid);
return { kind: 'clean', text };
}
const prior = phantomAutoContinueStreak.get(chatJid) ?? 0;
if (canAutoContinue && prior < MAX_PHANTOM_AUTO_CONTINUES) {
phantomAutoContinueStreak.set(chatJid, prior + 1);
return { kind: 'auto-continue', text };
}
return {
kind: 'notice',
text: applyPhantomNotificationGuard(text, { hasActiveWatcher: false }),
};
}
/** Test-only: clear the per-chat auto-continue streak counters. */
export function _resetPhantomAutoContinueForTests(): void {
phantomAutoContinueStreak.clear();
}
/**
* Perform the auto-continue side effect after a final was delivered: inject the
* corrective nudge as an inbound instruction and re-enqueue a turn so the agent
* runs again and finishes the work. No-op unless the outcome was `auto-continue`
* AND the final actually delivered. Returns true when it scheduled a re-run.
*
* Extracted from the delivery callback so the storeMessage → enqueueMessageCheck
* path is unit-testable without standing up a full turn.
*/
export function scheduleAutoContinueForPhantomFinal(args: {
outcomeKind: PhantomFinalOutcome['kind'];
delivered: boolean;
chatJid: string;
groupFolder: string;
runId: string;
storeMessage: (message: NewMessage) => void;
enqueueMessageCheck: (chatJid: string, groupFolder: string) => void;
}): boolean {
if (args.outcomeKind !== 'auto-continue' || !args.delivered) {
return false;
}
args.storeMessage({
id: `phantom-continue-${args.runId}-${Date.now().toString(36)}`,
chat_jid: args.chatJid,
sender: 'ejclaw-system',
sender_name: 'EJClaw',
content: PHANTOM_CONTINUE_NUDGE,
timestamp: new Date().toISOString(),
is_from_me: false,
is_bot_message: false,
message_source_kind: 'ipc_injected_human',
});
args.enqueueMessageCheck(args.chatJid, args.groupFolder);
return true;
}