Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions src/agents/definitions/contextSteps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = { 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
// ============================================================================
Expand Down
2 changes: 1 addition & 1 deletion src/agents/definitions/email-joke.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ tools:
sdkTools: readOnly

strategies:
contextPipeline: []
contextPipeline: [prefetchedEmails]
taskPromptBuilder: emailJoke
gadgetBuilder: emailJoke

Expand Down
1 change: 1 addition & 0 deletions src/agents/definitions/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ const StrategiesSchema = z.object({
'workItem',
'prContext',
'prConversation',
'prefetchedEmails',
]),
),
taskPromptBuilder: z.enum([
Expand Down
2 changes: 2 additions & 0 deletions src/agents/definitions/strategies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
type PreExecuteParams,
fetchContextFilesStep,
fetchDirectoryListingStep,
fetchEmailsFromInputStep,
fetchPRContextStep,
fetchPRConversationStep,
fetchSquintStep,
Expand Down Expand Up @@ -105,6 +106,7 @@ export const CONTEXT_STEP_REGISTRY: Record<
workItem: fetchWorkItemStep,
prContext: fetchPRContextStep,
prConversation: fetchPRConversationStep,
prefetchedEmails: fetchEmailsFromInputStep,
};

// ============================================================================
Expand Down
21 changes: 8 additions & 13 deletions src/agents/prompts/task-templates/emailJoke.eta
Original file line number Diff line number Diff line change
@@ -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.
<% } %>
56 changes: 48 additions & 8 deletions src/triggers/shared/manual-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<boolean> {
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.
*
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
@@ -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<typeof ProjectConfigSchema>;
Expand Down Expand Up @@ -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;
Expand Down
120 changes: 120 additions & 0 deletions tests/unit/agents/definitions/contextSteps.test.ts
Original file line number Diff line number Diff line change
@@ -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<AgentInput>): 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');
});
});
Loading