fix: add resolveEmailPrompt tests and consolidate prompt builders (#474)

This commit is contained in:
Cameron
2026-03-03 11:59:59 -08:00
committed by GitHub
parent e7f6e85880
commit ef13c3d8ab
3 changed files with 139 additions and 71 deletions

View File

@@ -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();
}

View File

@@ -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();
});
});

View File

@@ -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 = {