feat: add scan mode so conversations stay in sync without sending
Stopping the bot previously halted conversation syncing too. On the
multi-account path the run-flag check sat above both the reply-sync and
response-check branches, so a stop left only MediaSyncService's coarse
30-minute chat refresh. The legacy single-account path did the opposite
and kept syncing while stopped — one boolean meaning two things.
Replace the boolean with an explicit three-way mode:
running — discover, screen, send, automate replies
scan — refresh chats, detect replies, check responses; send nothing
and discover nothing; actionable replies surface as backlog
stopped — everything halts, including background media sync
scan differs from browser.dryRun, which still discovers and pays for AI
screening before suppressing the send.
The mode persists in runtime_state.bot_mode; a migration backfills it
from bot_running, and rows written by an older build still read
correctly. Both loops now gate on the mode, the account runtime loop
reconciles background media sync on transitions only, and the dashboard
and agent API expose all three states.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3a40651930
commit
14d92e14ee
38 files changed
+724
-140
No files matched your search
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { RedditPost } from '../src/reddit/api.js';
|
||||
import type { RedditAccountRuntimeResources } from '../src/services/reddit-account-runtime.js';
|
||||
import { materializePreparedPostForAccount } from '../src/services/reddit-account-runtime-builder.js';
|
||||
import { AccountRuntimeLoop, materializePreparedPostForAccount } from '../src/services/reddit-account-runtime-builder.js';
|
||||
import type { ProcessedPost } from '../src/services/processor.js';
|
||||
|
||||
describe('materializePreparedPostForAccount', () => {
|
||||
@@ -38,3 +38,91 @@ describe('materializePreparedPostForAccount', () => {
|
||||
expect(updateAnalysis).toHaveBeenCalledWith('post-1', 'analysis', 'message');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AccountRuntimeLoop mode gating', () => {
|
||||
function loopHarness(getBotMode: () => 'running' | 'scan' | 'stopped') {
|
||||
const conversationAutomation = { processPendingConversations: vi.fn().mockResolvedValue([]) };
|
||||
const responseChecker = { checkForResponses: vi.fn().mockResolvedValue([]) };
|
||||
const mediaSyncService = { start: vi.fn(), stop: vi.fn() };
|
||||
const chatService = {
|
||||
runStartupSync: vi.fn().mockResolvedValue(undefined),
|
||||
enrichUsernames: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const resources = {
|
||||
conversationAutomation,
|
||||
responseChecker,
|
||||
mediaSyncService,
|
||||
chatService,
|
||||
accountAuth: {
|
||||
isFullyAuthenticated: () => true,
|
||||
getSnapshot: () => ({ redditUsername: 'tester' }),
|
||||
},
|
||||
activityLogRepo: { insert: vi.fn() },
|
||||
recipientAccountStatusService: { start: vi.fn(), stop: vi.fn() },
|
||||
establishmentService: { start: vi.fn(), stop: vi.fn() },
|
||||
browserClient: { close: vi.fn().mockResolvedValue(undefined) },
|
||||
};
|
||||
const config = { reddit: { pollIntervalMs: 1_000 } };
|
||||
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
|
||||
const loop = new AccountRuntimeLoop(
|
||||
resources as never, config as never, logger as never, getBotMode, { } as never,
|
||||
);
|
||||
return { loop, conversationAutomation, responseChecker, mediaSyncService, chatService };
|
||||
}
|
||||
|
||||
/** Lets the loop complete at least one full pass before we assert. */
|
||||
async function runBriefly(loop: AccountRuntimeLoop): Promise<void> {
|
||||
loop.start();
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
await loop.stop();
|
||||
}
|
||||
|
||||
it('keeps conversations in sync in scan mode but suppresses replies', async () => {
|
||||
const fixture = loopHarness(() => 'scan');
|
||||
|
||||
await runBriefly(fixture.loop);
|
||||
|
||||
expect(fixture.conversationAutomation.processPendingConversations)
|
||||
.toHaveBeenCalledWith(expect.anything(), { scanOnly: true });
|
||||
expect(fixture.responseChecker.checkForResponses).toHaveBeenCalled();
|
||||
expect(fixture.mediaSyncService.start).toHaveBeenCalled();
|
||||
expect(fixture.chatService.runStartupSync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends replies in running mode', async () => {
|
||||
const fixture = loopHarness(() => 'running');
|
||||
|
||||
await runBriefly(fixture.loop);
|
||||
|
||||
expect(fixture.conversationAutomation.processPendingConversations)
|
||||
.toHaveBeenCalledWith(expect.anything(), { scanOnly: false });
|
||||
});
|
||||
|
||||
it('halts every background refresh when stopped', async () => {
|
||||
const fixture = loopHarness(() => 'stopped');
|
||||
|
||||
await runBriefly(fixture.loop);
|
||||
|
||||
expect(fixture.conversationAutomation.processPendingConversations).not.toHaveBeenCalled();
|
||||
expect(fixture.responseChecker.checkForResponses).not.toHaveBeenCalled();
|
||||
expect(fixture.mediaSyncService.start).not.toHaveBeenCalled();
|
||||
expect(fixture.chatService.runStartupSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops the background media sync when the operator drops from scan to stopped', async () => {
|
||||
let mode: 'running' | 'scan' | 'stopped' = 'scan';
|
||||
const fixture = loopHarness(() => mode);
|
||||
|
||||
fixture.loop.start();
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
expect(fixture.mediaSyncService.start).toHaveBeenCalledTimes(1);
|
||||
|
||||
mode = 'stopped';
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
await fixture.loop.stop();
|
||||
|
||||
expect(fixture.mediaSyncService.stop).toHaveBeenCalled();
|
||||
// Only ever started once — the reconcile must not re-enter on every tick.
|
||||
expect(fixture.mediaSyncService.start).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user