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(); /** 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; }