Auto-continue after a phantom-notification dead-end instead of just warning

Extends the phantom-notification guard: when a non-paired turn dead-ends on an
"I'll wait … I'll be notified" final with no watcher registered, re-invoke the
agent once (inject a corrective nudge as an inbound instruction + re-enqueue) so
it actually finishes the work in the foreground, rather than only appending a
"send a follow-up" notice. A hard per-chat cap (max 1 re-run per phantom streak,
reset on any clean final) makes it loop-safe — a model that keeps producing the
dead-end can never spin the bot. Paired rooms are excluded because they already
continue on their own (owner → reviewer → …). Pure cap/decision logic is unit-
tested; reuses the proven ipc_injected_human + enqueueMessageCheck path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Codex
2026-08-25 23:05:29 +09:00
parent f5d1ec302d
commit 984922884c
3 changed files with 199 additions and 14 deletions

View File

@@ -1,9 +1,11 @@
import { describe, expect, it } from 'vitest';
import { beforeEach, describe, expect, it } from 'vitest';
import {
applyPhantomNotificationGuard,
isPhantomNotificationDeadEnd,
PHANTOM_NOTIFICATION_NOTICE,
resolvePhantomFinalOutcome,
_resetPhantomAutoContinueForTests,
} from './phantom-notification-guard.js';
describe('isPhantomNotificationDeadEnd', () => {
@@ -16,9 +18,9 @@ describe('isPhantomNotificationDeadEnd', () => {
});
it('detects close variants of the notification promise', () => {
expect(isPhantomNotificationDeadEnd('I will be notified when it finishes.')).toBe(
true,
);
expect(
isPhantomNotificationDeadEnd('I will be notified when it finishes.'),
).toBe(true);
expect(
isPhantomNotificationDeadEnd('Waiting for the deploy to finish now.'),
).toBe(true);
@@ -74,3 +76,85 @@ describe('applyPhantomNotificationGuard', () => {
expect(twice).toBe(once);
});
});
describe('resolvePhantomFinalOutcome', () => {
const deadEnd = "I'll wait for the build to complete — I'll be notified.";
const chatJid = 'dc:test-room';
beforeEach(() => {
_resetPhantomAutoContinueForTests();
});
it('auto-continues once, then falls back to the notice on a repeated dead-end', () => {
const first = resolvePhantomFinalOutcome({
chatJid,
text: deadEnd,
hasActiveWatcher: false,
canAutoContinue: true,
});
expect(first.kind).toBe('auto-continue');
// Second consecutive dead-end for the same chat hits the cap → notice, no
// further auto-continue (this is the loop guard).
const second = resolvePhantomFinalOutcome({
chatJid,
text: deadEnd,
hasActiveWatcher: false,
canAutoContinue: true,
});
expect(second.kind).toBe('notice');
expect(second.text).toContain(PHANTOM_NOTIFICATION_NOTICE.trim());
});
it('resets the streak after a clean final so a later dead-end can auto-continue again', () => {
expect(
resolvePhantomFinalOutcome({
chatJid,
text: deadEnd,
hasActiveWatcher: false,
canAutoContinue: true,
}).kind,
).toBe('auto-continue');
// A clean final resets the streak.
expect(
resolvePhantomFinalOutcome({
chatJid,
text: 'TASK_DONE\n작업을 마쳤습니다.',
hasActiveWatcher: false,
canAutoContinue: true,
}).kind,
).toBe('clean');
// Streak reset → next dead-end auto-continues again.
expect(
resolvePhantomFinalOutcome({
chatJid,
text: deadEnd,
hasActiveWatcher: false,
canAutoContinue: true,
}).kind,
).toBe('auto-continue');
});
it('never auto-continues when a watcher is active (legitimate wait)', () => {
expect(
resolvePhantomFinalOutcome({
chatJid,
text: deadEnd,
hasActiveWatcher: true,
canAutoContinue: true,
}).kind,
).toBe('clean');
});
it('emits the notice (never auto-continue) when auto-continue is disabled', () => {
const outcome = resolvePhantomFinalOutcome({
chatJid,
text: deadEnd,
hasActiveWatcher: false,
canAutoContinue: false,
});
expect(outcome.kind).toBe('notice');
});
});