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
81 changes: 80 additions & 1 deletion tests/unit/web/triggerAgentMapping.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { describe, expect, it } from 'vitest';
import { getTriggersForAgent } from '../../../web/src/lib/trigger-agent-mapping.js';
import {
AGENT_LABELS,
ALL_AGENT_TYPES,
EMAIL_TRIGGER_AGENTS,
getTriggersForAgent,
} from '../../../web/src/lib/trigger-agent-mapping.js';

describe('getTriggersForAgent', () => {
it('returns all triggers when no opts given (backward compatibility)', () => {
Expand Down Expand Up @@ -89,3 +94,77 @@ describe('getTriggersForAgent — review trigger dot-notation keys and defaults'
expect(defaults.prOpened).toBe(false);
});
});

describe('ALL_AGENT_TYPES', () => {
it('includes email-joke', () => {
expect(ALL_AGENT_TYPES).toContain('email-joke');
});

it('contains all expected agent types in order', () => {
expect(ALL_AGENT_TYPES).toEqual([
'splitting',
'planning',
'implementation',
'review',
'respond-to-review',
'respond-to-ci',
'respond-to-pr-comment',
'respond-to-planning-comment',
'email-joke',
]);
});
});

describe('AGENT_LABELS', () => {
it('has a label for every entry in ALL_AGENT_TYPES', () => {
for (const type of ALL_AGENT_TYPES) {
expect(AGENT_LABELS).toHaveProperty(type);
expect(typeof AGENT_LABELS[type]).toBe('string');
expect(AGENT_LABELS[type].length).toBeGreaterThan(0);
}
});

it('maps email-joke to a friendly label', () => {
expect(AGENT_LABELS['email-joke']).toBe('Email Joke');
});

it('has no entries beyond ALL_AGENT_TYPES', () => {
const knownTypes = new Set<string>(ALL_AGENT_TYPES);
for (const key of Object.keys(AGENT_LABELS)) {
expect(knownTypes).toContain(key);
}
});
});

describe('EMAIL_TRIGGER_AGENTS', () => {
it('contains email-joke', () => {
expect(EMAIL_TRIGGER_AGENTS.has('email-joke')).toBe(true);
});

it('does not contain non-email agents', () => {
expect(EMAIL_TRIGGER_AGENTS.has('implementation')).toBe(false);
expect(EMAIL_TRIGGER_AGENTS.has('review')).toBe(false);
expect(EMAIL_TRIGGER_AGENTS.has('splitting')).toBe(false);
});

it('every entry is a known agent type', () => {
const knownTypes = new Set<string>(ALL_AGENT_TYPES);
for (const agentType of EMAIL_TRIGGER_AGENTS) {
expect(knownTypes).toContain(agentType);
}
});
});

describe('getTriggersForAgent — email-joke', () => {
it('returns empty array for email-joke (triggers are handled by a custom widget, not toggles)', () => {
expect(getTriggersForAgent('email-joke')).toHaveLength(0);
});

it('returns empty array for email-joke with category: pm', () => {
expect(getTriggersForAgent('email-joke', { category: 'pm' })).toHaveLength(0);
});

it('returns empty array for email-joke with category: scm', () => {
expect(getTriggersForAgent('email-joke', { category: 'scm' })).toHaveLength(0);
});
});
1 change: 1 addition & 0 deletions web/src/components/projects/email-wizard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -532,6 +532,7 @@ export function EmailWizard({
// Initialize from existing integration.
// For Gmail: wait until orgCredentials has loaded so we can resolve the email
// address from the credential name and pre-confirm the verify step.
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: multi-provider initialization requires coordinating several async conditions and credential lookups in a single effect
useEffect(() => {
if (initDoneRef.current || !initialProvider || !initialCredentials) return;
if (initialProvider === 'gmail' && orgCredentials.length === 0) return;
Expand Down
15 changes: 6 additions & 9 deletions web/src/components/projects/integration-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { trpc, trpcClient } from '@/lib/trpc.js';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { CheckCircle, Loader2, XCircle } from 'lucide-react';
import { useEffect, useState } from 'react';
import { EmailJokeConfig, EmailWizard } from './email-wizard.js';
import { EmailWizard } from './email-wizard.js';
import { PMWizard } from './pm-wizard.js';

type IntegrationCategory = 'pm' | 'scm' | 'email';
Expand Down Expand Up @@ -425,14 +425,11 @@ export function IntegrationForm({ projectId }: { projectId: string }) {
)}

{activeTab === 'email' && (
<div className="space-y-6">
<EmailWizard
projectId={projectId}
initialProvider={emailProvider}
initialCredentials={emailCredMap}
/>
<EmailJokeConfig projectId={projectId} />
</div>
<EmailWizard
projectId={projectId}
initialProvider={emailProvider}
initialCredentials={emailCredMap}
/>
)}
</div>
);
Expand Down
58 changes: 29 additions & 29 deletions web/src/components/projects/project-agent-configs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,20 @@ import {
SelectValue,
} from '@/components/ui/select.js';
import {
AGENT_LABELS,
ALL_AGENT_TYPES,
EMAIL_TRIGGER_AGENTS,
type KnownAgentType,
LIFECYCLE_TRIGGERS,
SHARED_PM_TRIGGERS,
getTriggersForAgent,
} from '@/lib/trigger-agent-mapping.js';
import { trpc, trpcClient } from '@/lib/trpc.js';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import { ChevronDown, ChevronRight, Pencil, Plus, Trash2 } from 'lucide-react';
import { ChevronDown, ChevronRight, Pencil, Trash2 } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { EmailJokeConfig } from './email-wizard.js';

interface AgentConfig {
id: number;
Expand All @@ -31,18 +35,6 @@ interface AgentConfig {
prompt: string | null;
}

/** Friendly labels for known agent types */
const AGENT_LABELS: Record<string, string> = {
splitting: 'Splitting',
planning: 'Planning',
implementation: 'Implementation',
review: 'Review',
'respond-to-review': 'Respond to Review',
'respond-to-ci': 'Respond to CI',
'respond-to-pr-comment': 'Respond to PR Comment',
'respond-to-planning-comment': 'Respond to Planning Comment',
};

function AgentConfigBadge({ config }: { config: AgentConfig | null }) {
if (!config) {
return <span className="text-xs text-muted-foreground">Using defaults</span>;
Expand Down Expand Up @@ -86,6 +78,7 @@ function extractRelevantTriggers(

function AgentSection({
agentType,
projectId,
config,
pmTriggers,
scmTriggers,
Expand All @@ -95,6 +88,8 @@ function AgentSection({
onSaveTriggers,
}: {
agentType: string;
/** Only required for agents in EMAIL_TRIGGER_AGENTS */
projectId?: string;
config: AgentConfig | null;
pmTriggers: Record<string, unknown>;
scmTriggers: Record<string, unknown>;
Expand All @@ -117,8 +112,9 @@ function AgentSection({

const agentPmTriggers = getTriggersForAgent(agentType, { pmProvider, category: 'pm' });
const agentScmTriggers = getTriggersForAgent(agentType, { category: 'scm' });
const hasEmailTriggers = EMAIL_TRIGGER_AGENTS.has(agentType as KnownAgentType);

const hasTriggers = agentPmTriggers.length > 0 || agentScmTriggers.length > 0;
const hasTriggers = agentPmTriggers.length > 0 || agentScmTriggers.length > 0 || hasEmailTriggers;

// Sync local state when props change (e.g., after another agent section saves shared triggers)
useEffect(() => {
Expand Down Expand Up @@ -167,7 +163,9 @@ function AgentSection({
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
<span className="font-medium text-sm">{AGENT_LABELS[agentType] ?? agentType}</span>
<span className="font-medium text-sm">
{(AGENT_LABELS as Record<string, string | undefined>)[agentType] ?? agentType}
</span>
<AgentConfigBadge config={config} />
</div>
<div className="flex items-center gap-1">
Expand Down Expand Up @@ -252,6 +250,16 @@ function AgentSection({
</div>
)}

{/* Email Triggers */}
{hasEmailTriggers && projectId && (
<div className="space-y-3">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Email Triggers
</p>
<EmailJokeConfig projectId={projectId} />
</div>
)}

{!hasTriggers && (
<p className="text-xs text-muted-foreground">
No trigger configuration for this agent.
Expand Down Expand Up @@ -286,7 +294,7 @@ export function ProjectAgentConfigs({ projectId }: { projectId: string }) {
const configsQueryKey = trpc.agentConfigs.list.queryOptions({ projectId }).queryKey;
const integrationsQueryKey = trpc.projects.integrations.list.queryOptions({ projectId }).queryKey;

function openCreate(defaultAgentType = '') {
function openCreate(defaultAgentType: string) {
setEditing(null);
setAgentType(defaultAgentType);
setModel('');
Expand Down Expand Up @@ -457,6 +465,7 @@ export function ProjectAgentConfigs({ projectId }: { projectId: string }) {
<AgentSection
key={type}
agentType={type}
projectId={projectId}
config={configByAgent.get(type) ?? null}
pmTriggers={pmTriggers}
scmTriggers={scmTriggers}
Expand All @@ -474,6 +483,7 @@ export function ProjectAgentConfigs({ projectId }: { projectId: string }) {
<AgentSection
key={c.agentType}
agentType={c.agentType}
projectId={projectId}
config={c}
pmTriggers={pmTriggers}
scmTriggers={scmTriggers}
Expand All @@ -485,15 +495,6 @@ export function ProjectAgentConfigs({ projectId }: { projectId: string }) {
))}
</div>

{/* Add config for custom agent type */}
<button
type="button"
onClick={() => openCreate()}
className="inline-flex h-8 items-center gap-1 rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
<Plus className="h-4 w-4" /> Add Custom Agent Config
</button>

{/* Shared PM triggers section */}
{filteredSharedPmTriggers.length > 0 && (
<div className="rounded-lg border border-border p-4 space-y-3">
Expand Down Expand Up @@ -569,10 +570,9 @@ export function ProjectAgentConfigs({ projectId }: { projectId: string }) {
<Label htmlFor="ac-agentType">Agent Type</Label>
<Input
id="ac-agentType"
value={agentType}
onChange={(e) => setAgentType(e.target.value)}
placeholder="e.g. implementation, review"
required
value={(AGENT_LABELS as Record<string, string | undefined>)[agentType] ?? agentType}
readOnly
className="bg-muted"
/>
</div>
<div className="grid grid-cols-2 gap-4">
Expand Down
18 changes: 18 additions & 0 deletions web/src/lib/trigger-agent-mapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,7 @@ export const AGENT_TRIGGER_MAP: Record<string, TriggerDef[]> = {
},
],
'respond-to-planning-comment': [],
'email-joke': [],
};

/**
Expand Down Expand Up @@ -299,6 +300,23 @@ export const ALL_AGENT_TYPES = [
'respond-to-ci',
'respond-to-pr-comment',
'respond-to-planning-comment',
'email-joke',
] as const;

export type KnownAgentType = (typeof ALL_AGENT_TYPES)[number];

/** Friendly display labels for all known agent types */
export const AGENT_LABELS: Record<KnownAgentType, string> = {
splitting: 'Splitting',
planning: 'Planning',
implementation: 'Implementation',
review: 'Review',
'respond-to-review': 'Respond to Review',
'respond-to-ci': 'Respond to CI',
'respond-to-pr-comment': 'Respond to PR Comment',
'respond-to-planning-comment': 'Respond to Planning Comment',
'email-joke': 'Email Joke',
};

/** Agent types that use email-based trigger configuration (custom widget, not toggle-based) */
export const EMAIL_TRIGGER_AGENTS = new Set<KnownAgentType>(['email-joke']);