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>
This commit is contained in:
Codex
2026-08-25 23:12:18 +09:00
parent bc9ba1c2d2
commit 007e95fe40
3 changed files with 127 additions and 21 deletions

View File

@@ -5,8 +5,8 @@ import {
storeMessage, storeMessage,
} from './db.js'; } from './db.js';
import { import {
PHANTOM_CONTINUE_NUDGE,
resolvePhantomFinalOutcome, resolvePhantomFinalOutcome,
scheduleAutoContinueForPhantomFinal,
} from './phantom-notification-guard.js'; } from './phantom-notification-guard.js';
import { runAgentForGroup } from './message-agent-executor.js'; import { runAgentForGroup } from './message-agent-executor.js';
import { MessageTurnController } from './message-turn-controller.js'; import { MessageTurnController } from './message-turn-controller.js';
@@ -255,30 +255,28 @@ export function createExecuteTurn(deps: CreateExecuteTurnDeps): ExecuteTurnFn {
); );
return false; return false;
} }
if (outcome.kind === 'auto-continue' && delivered) { try {
try { const scheduled = scheduleAutoContinueForPhantomFinal({
storeMessage({ outcomeKind: outcome.kind,
id: `phantom-continue-${runId}-${Date.now().toString(36)}`, delivered,
chat_jid: chatJid, chatJid,
sender: 'ejclaw-system', groupFolder: group.folder,
sender_name: 'EJClaw', runId,
content: PHANTOM_CONTINUE_NUDGE, storeMessage,
timestamp: new Date().toISOString(), enqueueMessageCheck: (jid, folder) =>
is_from_me: false, deps.queue.enqueueMessageCheck(jid, folder),
is_bot_message: false, });
message_source_kind: 'ipc_injected_human', if (scheduled) {
});
deps.queue.enqueueMessageCheck(chatJid, group.folder);
logger.info( logger.info(
{ group: group.name, chatJid, runId }, { group: group.name, chatJid, runId },
'Auto-continuing after phantom-notification dead-end final (no watcher registered)', '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; return delivered;
}, },

View File

@@ -1,10 +1,12 @@
import { beforeEach, describe, expect, it } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { import {
applyPhantomNotificationGuard, applyPhantomNotificationGuard,
isPhantomNotificationDeadEnd, isPhantomNotificationDeadEnd,
PHANTOM_CONTINUE_NUDGE,
PHANTOM_NOTIFICATION_NOTICE, PHANTOM_NOTIFICATION_NOTICE,
resolvePhantomFinalOutcome, resolvePhantomFinalOutcome,
scheduleAutoContinueForPhantomFinal,
_resetPhantomAutoContinueForTests, _resetPhantomAutoContinueForTests,
} from './phantom-notification-guard.js'; } from './phantom-notification-guard.js';
@@ -158,3 +160,71 @@ describe('resolvePhantomFinalOutcome', () => {
expect(outcome.kind).toBe('notice'); 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();
});
});

View File

@@ -1,3 +1,5 @@
import type { NewMessage } from './types.js';
// Guard against "phantom notification" dead-end finals. // Guard against "phantom notification" dead-end finals.
// //
// Agents sometimes end a turn with a sentence like "I'll wait for the build to // 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 { export function _resetPhantomAutoContinueForTests(): void {
phantomAutoContinueStreak.clear(); 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;
}