From f788d952a6d60a43e6f2da006ad4e55ce5ad9d65 Mon Sep 17 00:00:00 2001 From: Zbigniew Sobiecki Date: Fri, 27 Feb 2026 16:20:30 +0000 Subject: [PATCH] feat(email-joke): pre-check emails before agent start + context injection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before this change the email-joke agent always started regardless of whether any matching emails existed — if the INBOX was empty the agent would search, find nothing, and call Finish immediately, wasting a full agent run. This PR adds a pre-check phase: before the agent starts, the manual runner searches for unread emails (filtered by senderEmail when configured). If none are found the run is skipped cleanly; if emails are found they are injected into the agent's initial context via the new `prefetchedEmails` pipeline step, so the agent can skip the redundant SearchEmails call and go straight to reading and replying. Changes: - src/types/index.ts: add `preFoundEmails?: EmailSummary[]` to AgentInput - src/triggers/shared/manual-runner.ts: add `prefetchEmailsForJokeAgent` pre-check inside withEmailIntegration; fix no-provider path to abort (return false) instead of silently proceeding with a broken prompt - src/agents/definitions/contextSteps.ts: add `fetchEmailsFromInputStep` that injects pre-fetched emails as a synthetic SearchEmails tool result - src/agents/definitions/schema.ts: register `prefetchedEmails` enum value - src/agents/definitions/strategies.ts: wire `prefetchedEmails` registry key - src/agents/definitions/email-joke.yaml: use `prefetchedEmails` pipeline step - src/agents/prompts/task-templates/emailJoke.eta: update task prompt to reference the pre-fetched results and drop the now-redundant search step Code-review fixes applied in the same commit: - Consolidate EmailSearchCriteria import to email barrel (email/index.js) - Remove redundant `as EmailSummary[]` and `as string` casts now that AgentInput has properly typed named fields - Registry key follows the existing noun-phrase convention (`prefetchedEmails` not `fetchEmailsFromInput`) Tests: - tests/unit/triggers/manual-runner.test.ts: new `email-joke pre-check` describe block (5 cases: no provider, zero emails, emails found, non-email agent skips pre-check, senderEmail → criteria.from filter) - tests/unit/agents/definitions/contextSteps.test.ts: new file with 6 focused tests for fetchEmailsFromInputStep (undefined/empty, injection shape, senderEmail filter, multi-email numbering, UTC date extraction) Co-Authored-By: Claude Sonnet 4.6 --- src/agents/definitions/contextSteps.ts | 29 +++++ src/agents/definitions/email-joke.yaml | 2 +- src/agents/definitions/schema.ts | 1 + src/agents/definitions/strategies.ts | 2 + .../prompts/task-templates/emailJoke.eta | 21 ++- src/triggers/shared/manual-runner.ts | 56 ++++++-- src/types/index.ts | 2 + .../agents/definitions/contextSteps.test.ts | 120 +++++++++++++++++ tests/unit/triggers/manual-runner.test.ts | 121 ++++++++++++++++++ 9 files changed, 332 insertions(+), 22 deletions(-) create mode 100644 tests/unit/agents/definitions/contextSteps.test.ts diff --git a/src/agents/definitions/contextSteps.ts b/src/agents/definitions/contextSteps.ts index 4b0e6965..15d2e60d 100644 --- a/src/agents/definitions/contextSteps.ts +++ b/src/agents/definitions/contextSteps.ts @@ -239,6 +239,35 @@ export async function fetchPRConversationStep( return injections; } +export function fetchEmailsFromInputStep(params: FetchContextParams): ContextInjection[] { + const emails = params.input.preFoundEmails; + if (!emails || emails.length === 0) return []; + + const lines = [`Found ${emails.length} email(s):`, '']; + emails.forEach((email, i) => { + const dateStr = email.date.toISOString().split('T')[0]; + lines.push(`${i + 1}. [UID:${email.uid}] ${dateStr} - "${email.subject}" from ${email.from}`); + }); + + const senderEmail = params.input.senderEmail; + const criteria: Record = { unseen: true }; + if (senderEmail) criteria.from = senderEmail; + + return [ + { + toolName: 'SearchEmails', + params: { + comment: 'Pre-fetched unread emails before agent start', + folder: 'INBOX', + criteria, + maxResults: 10, + }, + result: lines.join('\n'), + description: `Pre-fetched ${emails.length} unread email(s)`, + }, + ]; +} + // ============================================================================ // Pre-execute hooks // ============================================================================ diff --git a/src/agents/definitions/email-joke.yaml b/src/agents/definitions/email-joke.yaml index fb4a26ec..780810c9 100644 --- a/src/agents/definitions/email-joke.yaml +++ b/src/agents/definitions/email-joke.yaml @@ -16,7 +16,7 @@ tools: sdkTools: readOnly strategies: - contextPipeline: [] + contextPipeline: [prefetchedEmails] taskPromptBuilder: emailJoke gadgetBuilder: emailJoke diff --git a/src/agents/definitions/schema.ts b/src/agents/definitions/schema.ts index 189783e3..cda2b39b 100644 --- a/src/agents/definitions/schema.ts +++ b/src/agents/definitions/schema.ts @@ -66,6 +66,7 @@ const StrategiesSchema = z.object({ 'workItem', 'prContext', 'prConversation', + 'prefetchedEmails', ]), ), taskPromptBuilder: z.enum([ diff --git a/src/agents/definitions/strategies.ts b/src/agents/definitions/strategies.ts index d7aafcbb..d2ff5930 100644 --- a/src/agents/definitions/strategies.ts +++ b/src/agents/definitions/strategies.ts @@ -11,6 +11,7 @@ import { type PreExecuteParams, fetchContextFilesStep, fetchDirectoryListingStep, + fetchEmailsFromInputStep, fetchPRContextStep, fetchPRConversationStep, fetchSquintStep, @@ -105,6 +106,7 @@ export const CONTEXT_STEP_REGISTRY: Record< workItem: fetchWorkItemStep, prContext: fetchPRContextStep, prConversation: fetchPRConversationStep, + prefetchedEmails: fetchEmailsFromInputStep, }; // ============================================================================ diff --git a/src/agents/prompts/task-templates/emailJoke.eta b/src/agents/prompts/task-templates/emailJoke.eta index 11bfb14d..8e7987e3 100644 --- a/src/agents/prompts/task-templates/emailJoke.eta +++ b/src/agents/prompts/task-templates/emailJoke.eta @@ -1,21 +1,16 @@ ## Your Task -1. Use **SearchEmails** to find unread emails<% if (it.senderEmail) { %> from: **<%= it.senderEmail %>**<% } %> - - Search criteria: `{ unseen: true<% if (it.senderEmail) { %>, from: "<%= it.senderEmail %>"<% } %> }` - - Limit results to prevent overwhelming responses +Your initial email search has already been completed — see the **SearchEmails** result above. -2. For each email found: - - Use **ReadEmail** to read the full content - - Compose a friendly, funny response that relates to the email content - - Use **ReplyToEmail** to send your joke response - - Use **MarkEmailAsSeen** to mark the email as read +For each email found: +1. Use **ReadEmail** to read the full content +2. Compose a friendly, funny response that relates to the email content +3. Use **ReplyToEmail** to send your joke response +4. Use **MarkEmailAsSeen** to mark the email as read (prevents re-processing) -3. Call **Finish** when you've processed all emails (or if none were found) +Once all emails have been processed, call **Finish**. <% if (it.senderEmail) { %> ## Sender Filter -Only respond to emails from: **<%= it.senderEmail %>** -<% } else { %> -## Note -No sender filter configured. Processing all unread emails. +Only emails from **<%= it.senderEmail %>** were included in the search. <% } %> diff --git a/src/triggers/shared/manual-runner.ts b/src/triggers/shared/manual-runner.ts index bad64edb..a66fc3b2 100644 --- a/src/triggers/shared/manual-runner.ts +++ b/src/triggers/shared/manual-runner.ts @@ -2,7 +2,8 @@ import { runAgent } from '../../agents/registry.js'; import { parseEmailJokeTriggers } from '../../config/triggerConfig.js'; import { getRunById } from '../../db/repositories/runsRepository.js'; import { getIntegrationByProjectAndCategory } from '../../db/repositories/settingsRepository.js'; -import { withEmailIntegration } from '../../email/index.js'; +import { getEmailProviderOrNull, withEmailIntegration } from '../../email/index.js'; +import type { EmailSearchCriteria } from '../../email/index.js'; import { withPMCredentials } from '../../pm/context.js'; import { createPMProvider, pmRegistry, withPMProvider } from '../../pm/index.js'; import type { AgentInput, CascadeConfig, ProjectConfig } from '../../types/index.js'; @@ -56,6 +57,37 @@ export interface ManualTriggerInput { modelOverride?: string; } +/** + * Pre-fetch emails for the email-joke agent before the agent starts. + * + * Returns true if the agent should run (emails found or no provider), + * false if no matching emails were found (agent should be skipped). + * Mutates agentInput.preFoundEmails when emails are found. + */ +async function prefetchEmailsForJokeAgent( + agentInput: AgentInput, + projectId: string, +): Promise { + const provider = getEmailProviderOrNull(); + if (!provider) { + logger.warn('Email provider unavailable, skipping email-joke agent', { projectId }); + return false; + } + + const criteria: EmailSearchCriteria = { unseen: true }; + if (agentInput.senderEmail) criteria.from = agentInput.senderEmail; + const emails = await provider.searchEmails('INBOX', criteria, 10); + if (emails.length === 0) { + logger.info('No matching emails found, skipping email-joke agent', { + projectId, + senderEmail: agentInput.senderEmail, + }); + return false; + } + agentInput.preFoundEmails = emails; + return true; +} + /** * Trigger a manual agent run. * @@ -133,15 +165,23 @@ export async function triggerManualRun( (t) => pmRegistry.getOrNull(t), () => withPMProvider(pmProvider, () => - withEmailIntegration(project.id, () => runAgent(input.agentType, agentInput)), + withEmailIntegration(project.id, async () => { + if (input.agentType === 'email-joke') { + const shouldRun = await prefetchEmailsForJokeAgent(agentInput, input.projectId); + if (!shouldRun) return undefined; + } + return runAgent(input.agentType, agentInput); + }), ), ); - logger.info('Manual agent run completed', { - projectId: input.projectId, - agentType: input.agentType, - success: result.success, - runId: result.runId, - }); + if (result !== undefined) { + logger.info('Manual agent run completed', { + projectId: input.projectId, + agentType: input.agentType, + success: result.success, + runId: result.runId, + }); + } } catch (err) { logger.error('Manual agent run failed', { projectId: input.projectId, diff --git a/src/types/index.ts b/src/types/index.ts index 16f9a95c..76d24560 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,5 +1,6 @@ import type { z } from 'zod'; import type { CascadeConfigSchema, ProjectConfigSchema } from '../config/schema.js'; +import type { EmailSummary } from '../email/types.js'; import type { PersonaIdentities } from '../github/personas.js'; export type ProjectConfig = z.infer; @@ -35,6 +36,7 @@ export interface AgentInput { // Email-joke agent fields senderEmail?: string; + preFoundEmails?: EmailSummary[]; // pre-fetched before agent start to skip if empty // Interactive mode (local development) interactive?: boolean; diff --git a/tests/unit/agents/definitions/contextSteps.test.ts b/tests/unit/agents/definitions/contextSteps.test.ts new file mode 100644 index 00000000..c16612f7 --- /dev/null +++ b/tests/unit/agents/definitions/contextSteps.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest'; + +import { fetchEmailsFromInputStep } from '../../../../src/agents/definitions/contextSteps.js'; +import type { FetchContextParams } from '../../../../src/agents/definitions/contextSteps.js'; +import type { EmailSummary } from '../../../../src/email/types.js'; +import type { AgentInput } from '../../../../src/types/index.js'; + +function makeParams(input: Partial): FetchContextParams { + return { + input: input as AgentInput, + repoDir: '/tmp/repo', + contextFiles: [], + logWriter: () => {}, + }; +} + +describe('fetchEmailsFromInputStep', () => { + it('returns empty array when preFoundEmails is undefined', () => { + const result = fetchEmailsFromInputStep(makeParams({})); + expect(result).toEqual([]); + }); + + it('returns empty array when preFoundEmails is an empty array', () => { + const result = fetchEmailsFromInputStep(makeParams({ preFoundEmails: [] })); + expect(result).toEqual([]); + }); + + it('injects a single email with correct toolName, params, result, and description', () => { + const email: EmailSummary = { + uid: 42, + date: new Date('2024-01-15T10:30:00Z'), + from: 'sender@x.com', + to: ['me@example.com'], + subject: 'Subject', + snippet: 'Snippet text', + }; + + const result = fetchEmailsFromInputStep(makeParams({ preFoundEmails: [email] })); + + expect(result).toHaveLength(1); + const injection = result[0]; + + expect(injection.toolName).toBe('SearchEmails'); + expect(injection.params).toEqual({ + comment: 'Pre-fetched unread emails before agent start', + folder: 'INBOX', + criteria: { unseen: true }, + maxResults: 10, + }); + expect(injection.result).toBe( + 'Found 1 email(s):\n\n1. [UID:42] 2024-01-15 - "Subject" from sender@x.com', + ); + expect(injection.description).toBe('Pre-fetched 1 unread email(s)'); + }); + + it('includes criteria.from when senderEmail is set', () => { + const email: EmailSummary = { + uid: 7, + date: new Date('2024-06-01T00:00:00Z'), + from: 'boss@company.com', + to: ['me@example.com'], + subject: 'Hi', + snippet: '', + }; + + const result = fetchEmailsFromInputStep( + makeParams({ preFoundEmails: [email], senderEmail: 'boss@company.com' }), + ); + + expect(result[0].params).toEqual( + expect.objectContaining({ criteria: { unseen: true, from: 'boss@company.com' } }), + ); + }); + + it('formats multiple emails as a numbered list with correct count in description', () => { + const emails: EmailSummary[] = [ + { + uid: 1, + date: new Date('2024-03-10T00:00:00Z'), + from: 'a@example.com', + to: [], + subject: 'First', + snippet: '', + }, + { + uid: 2, + date: new Date('2024-03-11T00:00:00Z'), + from: 'b@example.com', + to: [], + subject: 'Second', + snippet: '', + }, + ]; + + const result = fetchEmailsFromInputStep(makeParams({ preFoundEmails: emails })); + + expect(result).toHaveLength(1); + expect(result[0].description).toBe('Pre-fetched 2 unread email(s)'); + expect(result[0].result).toBe( + 'Found 2 email(s):\n\n' + + '1. [UID:1] 2024-03-10 - "First" from a@example.com\n' + + '2. [UID:2] 2024-03-11 - "Second" from b@example.com', + ); + }); + + it('extracts the date using UTC ISO split (ignores time component)', () => { + const email: EmailSummary = { + uid: 99, + date: new Date('2024-12-31T23:59:59Z'), + from: 'test@example.com', + to: [], + subject: 'NYE', + snippet: '', + }; + + const result = fetchEmailsFromInputStep(makeParams({ preFoundEmails: [email] })); + + expect(result[0].result).toContain('2024-12-31'); + }); +}); diff --git a/tests/unit/triggers/manual-runner.test.ts b/tests/unit/triggers/manual-runner.test.ts index 96025544..41651fb7 100644 --- a/tests/unit/triggers/manual-runner.test.ts +++ b/tests/unit/triggers/manual-runner.test.ts @@ -12,6 +12,8 @@ vi.mock('../../../src/utils/logging.js', () => ({ logger: { info: vi.fn(), error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), }, })); @@ -36,13 +38,32 @@ vi.mock('../../../src/email/integration.js', () => ({ withEmailIntegration: vi.fn((_projectId: string, fn: () => unknown) => fn()), })); +vi.mock('../../../src/email/context.js', () => ({ + getEmailProviderOrNull: vi.fn(), +})); + +vi.mock('../../../src/db/repositories/settingsRepository.js', () => ({ + getIntegrationByProjectAndCategory: vi.fn().mockResolvedValue(null), +})); + +vi.mock('../../../src/config/triggerConfig.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...(actual as object), + parseEmailJokeTriggers: vi.fn().mockReturnValue({}), + }; +}); + vi.mock('../../../src/triggers/shared/integration-validation.js', () => ({ validateIntegrations: vi.fn().mockResolvedValue({ valid: true, errors: [] }), formatValidationErrors: vi.fn().mockReturnValue(''), })); import { runAgent } from '../../../src/agents/registry.js'; +import { parseEmailJokeTriggers } from '../../../src/config/triggerConfig.js'; import { getRunById } from '../../../src/db/repositories/runsRepository.js'; +import { getIntegrationByProjectAndCategory } from '../../../src/db/repositories/settingsRepository.js'; +import { getEmailProviderOrNull } from '../../../src/email/context.js'; import { withPMCredentials } from '../../../src/pm/context.js'; import { createPMProvider, withPMProvider } from '../../../src/pm/index.js'; import { @@ -52,6 +73,7 @@ import { triggerRetryRun, } from '../../../src/triggers/shared/manual-runner.js'; import type { CascadeConfig, ProjectConfig } from '../../../src/types/index.js'; +import { logger } from '../../../src/utils/logging.js'; const mockProject: ProjectConfig = { id: 'test-project', @@ -309,3 +331,102 @@ describe('triggerRetryRun', () => { ); }); }); + +describe('email-joke pre-check', () => { + beforeEach(() => { + clearTriggerTracking(); + vi.mocked(runAgent).mockResolvedValue({ success: true, output: 'Done', runId: 'run-joke' }); + }); + + it('aborts and warns when no email provider is available', async () => { + vi.mocked(getEmailProviderOrNull).mockReturnValue(null); + + await triggerManualRun( + { projectId: 'test-project', agentType: 'email-joke' }, + mockProject, + mockConfig, + ); + + expect(runAgent).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledWith( + 'Email provider unavailable, skipping email-joke agent', + expect.objectContaining({ projectId: 'test-project' }), + ); + }); + + it('skips agent when no matching emails are found', async () => { + const mockProvider = { searchEmails: vi.fn().mockResolvedValue([]) }; + vi.mocked(getEmailProviderOrNull).mockReturnValue(mockProvider as never); + + await triggerManualRun( + { projectId: 'test-project', agentType: 'email-joke' }, + mockProject, + mockConfig, + ); + + expect(runAgent).not.toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalledWith( + 'No matching emails found, skipping email-joke agent', + expect.any(Object), + ); + }); + + it('runs agent with preFoundEmails when emails are found', async () => { + const emails = [ + { + uid: 42, + subject: 'Hello', + from: 'sender@example.com', + to: ['me@example.com'], + date: new Date('2024-01-15'), + snippet: 'Hello there', + }, + ]; + const mockProvider = { searchEmails: vi.fn().mockResolvedValue(emails) }; + vi.mocked(getEmailProviderOrNull).mockReturnValue(mockProvider as never); + + await triggerManualRun( + { projectId: 'test-project', agentType: 'email-joke' }, + mockProject, + mockConfig, + ); + + expect(runAgent).toHaveBeenCalledWith( + 'email-joke', + expect.objectContaining({ preFoundEmails: emails }), + ); + }); + + it('does not call getEmailProviderOrNull for non-email-joke agents', async () => { + vi.mocked(runAgent).mockResolvedValue({ success: true, output: 'Done', runId: 'run-impl' }); + + await triggerManualRun( + { projectId: 'test-project', agentType: 'implementation', cardId: 'card-1' }, + mockProject, + mockConfig, + ); + + expect(getEmailProviderOrNull).not.toHaveBeenCalled(); + }); + + it('passes senderEmail as criteria.from when set via integration triggers', async () => { + const mockProvider = { searchEmails: vi.fn().mockResolvedValue([]) }; + vi.mocked(getEmailProviderOrNull).mockReturnValue(mockProvider as never); + vi.mocked(getIntegrationByProjectAndCategory).mockResolvedValue({ + triggers: {}, + } as never); + vi.mocked(parseEmailJokeTriggers).mockReturnValue({ senderEmail: 'boss@example.com' } as never); + + await triggerManualRun( + { projectId: 'test-project', agentType: 'email-joke' }, + mockProject, + mockConfig, + ); + + expect(mockProvider.searchEmails).toHaveBeenCalledWith( + 'INBOX', + expect.objectContaining({ from: 'boss@example.com' }), + 10, + ); + }); +});