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,
|
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,31 +255,29 @@ export function createExecuteTurn(deps: CreateExecuteTurnDeps): ExecuteTurnFn {
|
|||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (outcome.kind === 'auto-continue' && delivered) {
|
|
||||||
try {
|
try {
|
||||||
storeMessage({
|
const scheduled = scheduleAutoContinueForPhantomFinal({
|
||||||
id: `phantom-continue-${runId}-${Date.now().toString(36)}`,
|
outcomeKind: outcome.kind,
|
||||||
chat_jid: chatJid,
|
delivered,
|
||||||
sender: 'ejclaw-system',
|
chatJid,
|
||||||
sender_name: 'EJClaw',
|
groupFolder: group.folder,
|
||||||
content: PHANTOM_CONTINUE_NUDGE,
|
runId,
|
||||||
timestamp: new Date().toISOString(),
|
storeMessage,
|
||||||
is_from_me: false,
|
enqueueMessageCheck: (jid, folder) =>
|
||||||
is_bot_message: false,
|
deps.queue.enqueueMessageCheck(jid, folder),
|
||||||
message_source_kind: 'ipc_injected_human',
|
|
||||||
});
|
});
|
||||||
deps.queue.enqueueMessageCheck(chatJid, group.folder);
|
if (scheduled) {
|
||||||
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) {
|
} catch (err) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
{ group: group.name, chatJid, runId, err },
|
{ group: group.name, chatJid, runId, err },
|
||||||
'Failed to schedule phantom-notification auto-continue',
|
'Failed to schedule phantom-notification auto-continue',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return delivered;
|
return delivered;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user