From 007e95fe40b094611721b71fd34430040b6d9f78 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 25 Aug 2026 23:12:18 +0900 Subject: [PATCH] Extract + unit-test the phantom auto-continue injection path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/message-runtime-turns.ts | 38 +++++++------- src/phantom-notification-guard.test.ts | 72 +++++++++++++++++++++++++- src/phantom-notification-guard.ts | 38 ++++++++++++++ 3 files changed, 127 insertions(+), 21 deletions(-) diff --git a/src/message-runtime-turns.ts b/src/message-runtime-turns.ts index 335efe6..6d602c6 100644 --- a/src/message-runtime-turns.ts +++ b/src/message-runtime-turns.ts @@ -5,8 +5,8 @@ import { storeMessage, } from './db.js'; import { - PHANTOM_CONTINUE_NUDGE, resolvePhantomFinalOutcome, + scheduleAutoContinueForPhantomFinal, } from './phantom-notification-guard.js'; import { runAgentForGroup } from './message-agent-executor.js'; import { MessageTurnController } from './message-turn-controller.js'; @@ -255,30 +255,28 @@ export function createExecuteTurn(deps: CreateExecuteTurnDeps): ExecuteTurnFn { ); return false; } - if (outcome.kind === 'auto-continue' && delivered) { - try { - storeMessage({ - id: `phantom-continue-${runId}-${Date.now().toString(36)}`, - chat_jid: 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', - }); - deps.queue.enqueueMessageCheck(chatJid, group.folder); + try { + const scheduled = scheduleAutoContinueForPhantomFinal({ + outcomeKind: outcome.kind, + delivered, + chatJid, + groupFolder: group.folder, + runId, + storeMessage, + enqueueMessageCheck: (jid, folder) => + deps.queue.enqueueMessageCheck(jid, folder), + }); + if (scheduled) { logger.info( { group: group.name, chatJid, runId }, 'Auto-continuing after phantom-notification dead-end final (no watcher registered)', ); - } catch (err) { - logger.warn( - { group: group.name, chatJid, runId, err }, - 'Failed to schedule phantom-notification auto-continue', - ); } + } catch (err) { + logger.warn( + { group: group.name, chatJid, runId, err }, + 'Failed to schedule phantom-notification auto-continue', + ); } return delivered; }, diff --git a/src/phantom-notification-guard.test.ts b/src/phantom-notification-guard.test.ts index f473e3d..b7c35e6 100644 --- a/src/phantom-notification-guard.test.ts +++ b/src/phantom-notification-guard.test.ts @@ -1,10 +1,12 @@ -import { beforeEach, describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { applyPhantomNotificationGuard, isPhantomNotificationDeadEnd, + PHANTOM_CONTINUE_NUDGE, PHANTOM_NOTIFICATION_NOTICE, resolvePhantomFinalOutcome, + scheduleAutoContinueForPhantomFinal, _resetPhantomAutoContinueForTests, } from './phantom-notification-guard.js'; @@ -158,3 +160,71 @@ describe('resolvePhantomFinalOutcome', () => { expect(outcome.kind).toBe('notice'); }); }); + +describe('scheduleAutoContinueForPhantomFinal', () => { + it('injects the corrective nudge and re-enqueues when the dead-end final delivered', () => { + const storeMessage = vi.fn(); + const enqueueMessageCheck = vi.fn(); + + const scheduled = scheduleAutoContinueForPhantomFinal({ + outcomeKind: 'auto-continue', + delivered: true, + chatJid: 'dc:test-room', + groupFolder: 'test-room', + runId: 'run-xyz', + storeMessage, + enqueueMessageCheck, + }); + + expect(scheduled).toBe(true); + expect(storeMessage).toHaveBeenCalledTimes(1); + expect(storeMessage).toHaveBeenCalledWith( + expect.objectContaining({ + chat_jid: 'dc:test-room', + content: PHANTOM_CONTINUE_NUDGE, + is_from_me: false, + is_bot_message: false, + message_source_kind: 'ipc_injected_human', + }), + ); + expect(enqueueMessageCheck).toHaveBeenCalledWith('dc:test-room', 'test-room'); + }); + + it('does nothing when the outcome is not auto-continue', () => { + const storeMessage = vi.fn(); + const enqueueMessageCheck = vi.fn(); + + const scheduled = scheduleAutoContinueForPhantomFinal({ + outcomeKind: 'notice', + delivered: true, + chatJid: 'dc:test-room', + groupFolder: 'test-room', + runId: 'run-xyz', + storeMessage, + enqueueMessageCheck, + }); + + expect(scheduled).toBe(false); + expect(storeMessage).not.toHaveBeenCalled(); + expect(enqueueMessageCheck).not.toHaveBeenCalled(); + }); + + it('does nothing when the final was not delivered', () => { + const storeMessage = vi.fn(); + const enqueueMessageCheck = vi.fn(); + + const scheduled = scheduleAutoContinueForPhantomFinal({ + outcomeKind: 'auto-continue', + delivered: false, + chatJid: 'dc:test-room', + groupFolder: 'test-room', + runId: 'run-xyz', + storeMessage, + enqueueMessageCheck, + }); + + expect(scheduled).toBe(false); + expect(storeMessage).not.toHaveBeenCalled(); + expect(enqueueMessageCheck).not.toHaveBeenCalled(); + }); +}); diff --git a/src/phantom-notification-guard.ts b/src/phantom-notification-guard.ts index afc77f4..f1b0bca 100644 --- a/src/phantom-notification-guard.ts +++ b/src/phantom-notification-guard.ts @@ -1,3 +1,5 @@ +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 @@ -109,3 +111,39 @@ export function resolvePhantomFinalOutcome(args: { 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; +}