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:
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user