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>
150 lines
7.0 KiB
TypeScript
150 lines
7.0 KiB
TypeScript
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;
|
||
}
|