diff --git a/src/services/reddit-account-runtime-builder.ts b/src/services/reddit-account-runtime-builder.ts index e5dad27..8be748d 100644 --- a/src/services/reddit-account-runtime-builder.ts +++ b/src/services/reddit-account-runtime-builder.ts @@ -257,16 +257,15 @@ async function buildRuntime( undefined, undefined, mediaExchange, + // Read through to the live config object rather than snapshotting, so a + // dashboard configuration save applies to a running account without a + // restart. The service reads these on every inbound reply. { - offPlatformKill: accountConfig.conversations.offPlatformKill, - killOnSellerKeyword: accountConfig.conversations.killOnSellerKeyword, + get offPlatformKill() { return accountConfig.conversations.offPlatformKill; }, + get killOnSellerKeyword() { return accountConfig.conversations.killOnSellerKeyword; }, triage: conversationReadStateRepo, screener: aiClient, - screening: { - enabled: accountConfig.conversations.aiReplyScreening.enabled, - killConfidence: accountConfig.conversations.aiReplyScreening.killConfidence, - flagConfidence: accountConfig.conversations.aiReplyScreening.flagConfidence, - }, + get screening() { return accountConfig.conversations.aiReplyScreening; }, }, ); diff --git a/src/web/routes/config.ts b/src/web/routes/config.ts index feb2124..5bf08da 100644 --- a/src/web/routes/config.ts +++ b/src/web/routes/config.ts @@ -14,6 +14,7 @@ const AdvancedConfigSchema = z.strictObject({ }), openrouter: ConfigSchema.shape.openrouter.omit({ apiKey: true }), browser: z.strictObject({ sessionDir: z.string() }), + conversations: ConfigSchema.shape.conversations, media: ConfigSchema.shape.media, strategies: ConfigSchema.shape.strategies, chatStrategies: ConfigSchema.shape.chatStrategies, @@ -33,6 +34,7 @@ function advancedConfig(ctx: WebContext): z.infer { reddit, openrouter, browser, + conversations: ctx.config.conversations, media: ctx.config.media, strategies: ctx.config.strategies, chatStrategies: ctx.config.chatStrategies, @@ -140,6 +142,13 @@ export function createConfigRouter(ctx: WebContext): Router { Object.assign(ctx.config.reddit, parsedAdvanced.data.reddit); Object.assign(ctx.config.openrouter, parsedAdvanced.data.openrouter); Object.assign(ctx.config.browser, parsedAdvanced.data.browser); + // Assigned field by field, never replaced: running account runtimes + // hold a reference to these objects and read the kill controls on every + // reply, so a dashboard save applies without a restart. + const nextConversations = parsedAdvanced.data.conversations; + ctx.config.conversations.offPlatformKill = nextConversations.offPlatformKill; + ctx.config.conversations.killOnSellerKeyword = nextConversations.killOnSellerKeyword; + Object.assign(ctx.config.conversations.aiReplyScreening, nextConversations.aiReplyScreening); ctx.config.media = parsedAdvanced.data.media; ctx.config.strategies = parsedAdvanced.data.strategies; ctx.config.chatStrategies = parsedAdvanced.data.chatStrategies; diff --git a/tests/conversation-automation.test.ts b/tests/conversation-automation.test.ts index 3ab011a..3043953 100644 --- a/tests/conversation-automation.test.ts +++ b/tests/conversation-automation.test.ts @@ -327,6 +327,28 @@ describe('conversation automation', () => { expect(chat.sendChatMessage).toHaveBeenCalledOnce(); }); + it('re-reads the screening options on every reply so a config change applies without a restart', async () => { + const screener = { + screenConversationReply: vi.fn().mockResolvedValue({ + classification: 'seller', confidence: 0.95, evidence: 'selling', + }), + }; + const screening = { enabled: false, killConfidence: 0.8, flagConfidence: 0.6 }; + const { service, getSaved } = harness([], [], 0, { mode: 'r4r', maxAutomatedTurns: 5 }, undefined, undefined, { + screener, + screening, + }); + + expect((await service.processConversation(incoming(1)))?.kind).toBe('sent'); + expect(screener.screenConversationReply).not.toHaveBeenCalled(); + + screening.enabled = true; + + expect((await service.processConversation(incoming(2)))?.kind).toBe('killed'); + expect(screener.screenConversationReply).toHaveBeenCalledOnce(); + expect(getSaved()?.automation.status).toBe('killed'); + }); + it('does not call the screener at all when screening is disabled', async () => { const screener = { screenConversationReply: vi.fn() }; const { service } = harness([], [], 0, undefined, undefined, undefined, { diff --git a/tests/web/config-route.test.ts b/tests/web/config-route.test.ts new file mode 100644 index 0000000..5184d31 --- /dev/null +++ b/tests/web/config-route.test.ts @@ -0,0 +1,111 @@ +import express from 'express'; +import type { AddressInfo } from 'node:net'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { WebContext } from '../../src/web/context.js'; +import { createConfigRouter } from '../../src/web/routes/config.js'; +import { ConfigSchema, type Config } from '../../src/config/index.js'; +import { getTestConfig } from '../testUtils.js'; + +const servers: Array> = []; + +async function request(app: express.Express, path: string, init?: RequestInit): Promise { + const server = app.listen(0); + servers.push(server); + await new Promise((resolve) => server.once('listening', resolve)); + const { port } = server.address() as AddressInfo; + return fetch(`http://127.0.0.1:${port}${path}`, init); +} + +afterEach(async () => { + await Promise.all(servers.splice(0).map((server) => new Promise((resolve) => server.close(() => resolve())))); +}); + +function harness() { + // Deep-cloned because zod reuses one default object across parses, so two + // harnesses would otherwise share the same `conversations` instance. + const config = structuredClone(ConfigSchema.parse(getTestConfig())) as Config; + const saved: Config[] = []; + const ctx = { + config, + runtimeConfig: config, + services: { configStore: { save: (next: Config) => saved.push(next) } }, + getOnConfigChange: () => undefined, + activity: { push: vi.fn() }, + broadcaster: { push: vi.fn() }, + logger: { error: vi.fn(), info: vi.fn(), warn: vi.fn() }, + } as unknown as WebContext; + const app = express(); + app.use(express.json()); + app.use('/api', createConfigRouter(ctx)); + return { app, config, saved }; +} + +describe('config route conversation kill controls', () => { + it('exposes the conversation section in the advanced payload', async () => { + const { app } = harness(); + + const body = await (await request(app, '/api/config')).json(); + + expect(body.advanced.conversations).toEqual({ + offPlatformKill: true, + killOnSellerKeyword: true, + aiReplyScreening: { enabled: false, killConfidence: 0.8, flagConfidence: 0.6 }, + }); + }); + + it('persists an edited conversation section in place, so running runtimes see it', async () => { + const { app, config, saved } = harness(); + const conversationsRef = config.conversations; + const screeningRef = config.conversations.aiReplyScreening; + const current = await (await request(app, '/api/config')).json(); + + const response = await request(app, '/api/config', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + advanced: { + ...current.advanced, + conversations: { + offPlatformKill: true, + killOnSellerKeyword: false, + aiReplyScreening: { enabled: true, killConfidence: 0.9, flagConfidence: 0.7 }, + }, + }, + }), + }); + + expect(response.status).toBe(200); + // Same objects, mutated: an account runtime holding this reference reads + // the new values on its next reply without being rebuilt. + expect(config.conversations).toBe(conversationsRef); + expect(config.conversations.aiReplyScreening).toBe(screeningRef); + expect(config.conversations.killOnSellerKeyword).toBe(false); + expect(config.conversations.aiReplyScreening).toEqual({ + enabled: true, killConfidence: 0.9, flagConfidence: 0.7, + }); + expect(saved.at(-1)?.conversations.aiReplyScreening.enabled).toBe(true); + }); + + it('rejects an out-of-range screening threshold', async () => { + const { app, config } = harness(); + const current = await (await request(app, '/api/config')).json(); + + const response = await request(app, '/api/config', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + advanced: { + ...current.advanced, + conversations: { + offPlatformKill: true, + killOnSellerKeyword: true, + aiReplyScreening: { enabled: true, killConfidence: 4, flagConfidence: 0.7 }, + }, + }, + }), + }); + + expect(response.status).toBe(400); + expect(config.conversations.aiReplyScreening.enabled).toBe(false); + }); +});