fix: add resolveEmailPrompt tests and consolidate prompt builders (#474)
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user