From ef13c3d8ab73a42bfaa5676c398c3dfa8fb39c06 Mon Sep 17 00:00:00 2001 From: Cameron Date: Tue, 3 Mar 2026 11:59:59 -0800 Subject: [PATCH] fix: add resolveEmailPrompt tests and consolidate prompt builders (#474) --- src/core/prompts.ts | 40 +++-------------- src/polling/service.test.ts | 84 +++++++++++++++++++++++++++++++++++- src/polling/service.ts | 86 +++++++++++++++++++++---------------- 3 files changed, 139 insertions(+), 71 deletions(-) diff --git a/src/core/prompts.ts b/src/core/prompts.ts index f1b19b3..16a08ea 100644 --- a/src/core/prompts.ts +++ b/src/core/prompts.ts @@ -233,14 +233,18 @@ something they need to know or act on. } /** - * Email prompt (silent mode) - for Gmail polling + * Email prompt (silent mode) - for Gmail polling. + * When customPrompt is provided it replaces the default body text. */ export function buildEmailPrompt( account: string, emailCount: number, emailData: string, - time: string + time: string, + customPrompt?: string, ): string { + const body = customPrompt + ?? 'Review and summarize important emails. Use `lettabot-message send --text "..."` to notify the user if needed.'; return ` ${SILENT_MODE_PREFIX} @@ -257,37 +261,7 @@ To notify your human about important emails, run: NEW EMAILS (${emailCount}): ${emailData} -Review and summarize important emails. Use \`lettabot-message send --text "..."\` to notify the user if needed. -`.trim(); -} - -/** - * Custom email prompt - wraps user-provided text with silent mode envelope - */ -export function buildCustomEmailPrompt( - customPrompt: string, - account: string, - emailCount: number, - emailData: string, - time: string -): string { - return ` -${SILENT_MODE_PREFIX} - -TRIGGER: Email polling -ACCOUNT: ${account} -TIME: ${time} - -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - -YOUR TEXT OUTPUT IS PRIVATE - only you can see it. -To notify your human, run: - lettabot-message send --text "Your message here" - -${customPrompt} - -NEW EMAILS (${emailCount}): -${emailData} +${body} `.trim(); } diff --git a/src/polling/service.test.ts b/src/polling/service.test.ts index 14287dd..6a114a9 100644 --- a/src/polling/service.test.ts +++ b/src/polling/service.test.ts @@ -1,5 +1,8 @@ -import { describe, it, expect } from 'vitest'; -import { parseGmailAccounts } from './service.js'; +import { describe, it, expect, afterEach } from 'vitest'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { parseGmailAccounts, resolveEmailPrompt } from './service.js'; describe('parseGmailAccounts', () => { it('returns empty array for undefined', () => { @@ -89,3 +92,80 @@ describe('parseGmailAccounts', () => { ]); }); }); + +describe('resolveEmailPrompt', () => { + let tmpDir: string; + + afterEach(() => { + if (tmpDir) rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('returns undefined when no prompts configured', () => { + expect(resolveEmailPrompt({ account: 'a@gmail.com' })).toBeUndefined(); + }); + + it('returns account-specific inline prompt', () => { + expect(resolveEmailPrompt( + { account: 'a@gmail.com', prompt: 'Check urgent' }, + 'global prompt', + )).toBe('Check urgent'); + }); + + it('reads account-specific promptFile', () => { + tmpDir = mkdtempSync(join(tmpdir(), 'poll-test-')); + writeFileSync(join(tmpDir, 'acct.txt'), ' File prompt content '); + expect(resolveEmailPrompt( + { account: 'a@gmail.com', promptFile: 'acct.txt' }, + undefined, undefined, tmpDir, + )).toBe('File prompt content'); + }); + + it('account inline prompt wins over account promptFile', () => { + tmpDir = mkdtempSync(join(tmpdir(), 'poll-test-')); + writeFileSync(join(tmpDir, 'acct.txt'), 'from file'); + expect(resolveEmailPrompt( + { account: 'a@gmail.com', prompt: 'inline wins', promptFile: 'acct.txt' }, + undefined, undefined, tmpDir, + )).toBe('inline wins'); + }); + + it('falls back to global prompt when account has none', () => { + expect(resolveEmailPrompt( + { account: 'a@gmail.com' }, + 'global prompt', + )).toBe('global prompt'); + }); + + it('falls back to global promptFile', () => { + tmpDir = mkdtempSync(join(tmpdir(), 'poll-test-')); + writeFileSync(join(tmpDir, 'global.txt'), 'global file content'); + expect(resolveEmailPrompt( + { account: 'a@gmail.com' }, + undefined, 'global.txt', tmpDir, + )).toBe('global file content'); + }); + + it('global inline prompt wins over global promptFile', () => { + tmpDir = mkdtempSync(join(tmpdir(), 'poll-test-')); + writeFileSync(join(tmpDir, 'global.txt'), 'from file'); + expect(resolveEmailPrompt( + { account: 'a@gmail.com' }, + 'global inline', 'global.txt', tmpDir, + )).toBe('global inline'); + }); + + it('falls through gracefully when account promptFile is missing', () => { + expect(resolveEmailPrompt( + { account: 'a@gmail.com', promptFile: 'nonexistent.txt' }, + 'fallback global', + undefined, '/tmp', + )).toBe('fallback global'); + }); + + it('falls through gracefully when global promptFile is missing', () => { + expect(resolveEmailPrompt( + { account: 'a@gmail.com' }, + undefined, 'nonexistent.txt', '/tmp', + )).toBeUndefined(); + }); +}); diff --git a/src/polling/service.ts b/src/polling/service.ts index 0e1711a..1fd3ef3 100644 --- a/src/polling/service.ts +++ b/src/polling/service.ts @@ -11,12 +11,57 @@ import { resolve, join } from 'node:path'; import type { AgentSession } from '../core/interfaces.js'; import type { TriggerContext } from '../core/types.js'; import type { GmailAccountConfig } from '../config/types.js'; -import { buildEmailPrompt, buildCustomEmailPrompt } from '../core/prompts.js'; +import { buildEmailPrompt } from '../core/prompts.js'; import { createLogger } from '../logger.js'; const log = createLogger('Polling'); +/** + * Resolve the custom prompt for a Gmail account. + * Pure function extracted for testability. + * + * Priority: account prompt > account promptFile > global prompt > global promptFile > undefined (built-in) + */ +export function resolveEmailPrompt( + accountConfig: GmailAccountConfig, + globalPrompt?: string, + globalPromptFile?: string, + workingDir?: string, +): string | undefined { + // Account-specific inline prompt + if (accountConfig.prompt) { + return accountConfig.prompt; + } + + // Account-specific promptFile + if (accountConfig.promptFile) { + try { + const path = workingDir ? resolve(workingDir, accountConfig.promptFile) : accountConfig.promptFile; + return readFileSync(path, 'utf-8').trim(); + } catch (err) { + log.warn(`Failed to read promptFile for ${accountConfig.account}: ${(err as Error).message}`); + } + } + + // Global inline prompt + if (globalPrompt) { + return globalPrompt; + } + + // Global promptFile + if (globalPromptFile) { + try { + const path = workingDir ? resolve(workingDir, globalPromptFile) : globalPromptFile; + return readFileSync(path, 'utf-8').trim(); + } catch (err) { + log.warn(`Failed to read global promptFile: ${(err as Error).message}`); + } + } + + return undefined; +} + /** * Parse Gmail accounts from various formats. * Handles: string (comma-separated), string array, or GmailAccountConfig array. @@ -200,41 +245,12 @@ export class PollingService { /** * Resolve custom prompt for an account. - * Priority: account-specific > global default > none (use built-in prompt) + * Delegates to the exported pure function. */ private resolvePrompt(accountConfig: GmailAccountConfig): string | undefined { const gmail = this.config.gmail; if (!gmail) return undefined; - - // Check for account-specific prompt first - if (accountConfig.prompt) { - return accountConfig.prompt; - } - - // Check for account-specific promptFile - if (accountConfig.promptFile) { - try { - return readFileSync(resolve(this.config.workingDir, accountConfig.promptFile), 'utf-8').trim(); - } catch (err) { - log.error(`Failed to read promptFile for ${accountConfig.account}:`, err); - } - } - - // Fall back to global prompt - if (gmail.prompt) { - return gmail.prompt; - } - - // Fall back to global promptFile - if (gmail.promptFile) { - try { - return readFileSync(resolve(this.config.workingDir, gmail.promptFile), 'utf-8').trim(); - } catch (err) { - log.error(`Failed to read global promptFile:`, err); - } - } - - return undefined; + return resolveEmailPrompt(accountConfig, gmail.prompt, gmail.promptFile, this.config.workingDir); } /** @@ -307,10 +323,8 @@ export class PollingService { const now = new Date(); const time = now.toLocaleString(); - // Build message using prompt builders - const message = customPrompt - ? buildCustomEmailPrompt(customPrompt, account, newEmails.length, newEmailsOutput, time) - : buildEmailPrompt(account, newEmails.length, newEmailsOutput, time); + // Build message using prompt builder + const message = buildEmailPrompt(account, newEmails.length, newEmailsOutput, time, customPrompt); // Build trigger context for silent mode const context: TriggerContext = {