diff --git a/apps/web/__tests__/ai-choose-args.test.ts b/apps/web/__tests__/ai-choose-args.test.ts new file mode 100644 index 0000000000..0afce1c620 --- /dev/null +++ b/apps/web/__tests__/ai-choose-args.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, test, vi } from "vitest"; +import { getActionItemsWithAiArgs } from "@/utils/ai/choose-rule/ai-choose-args"; +import { type Action, ActionType, RuleType } from "@prisma/client"; + +vi.mock("server-only", () => ({})); + +describe("getActionItemsWithAiArgs", () => { + test("should return actions unchanged when no AI args needed", async () => { + const actions = [getAction({})]; + const rule = getRule("Test rule", actions); + + const result = await getActionItemsWithAiArgs({ + email: getEmail(), + user: getUser(), + selectedRule: rule, + }); + + expect(result).toEqual(actions); + }); + + test("should return actions unchanged when no variables to fill", async () => { + const actions = [ + getAction({ + type: ActionType.REPLY, + content: "You can set a meeting with me here: https://cal.com/alice", + }), + ]; + const rule = getRule("Choose this rule for meeting requests", actions); + + const result = await getActionItemsWithAiArgs({ + email: getEmail({ + subject: "Quick question", + content: "When is the meeting tomorrow?", + }), + user: getUser(), + selectedRule: rule, + }); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject(actions[0]); + }); + + test("should generate AI content for actions that need it", async () => { + const actions = [ + getAction({ + type: ActionType.REPLY, + content: + "The price of pears is: {{the price with the dollar sign - pears are $1.99, apples are $2.99}}", + }), + ]; + const rule = getRule( + "Choose this when the price of an items is asked for", + actions, + ); + + const result = await getActionItemsWithAiArgs({ + email: getEmail({ + subject: "Quick question", + content: "How much are pears?", + }), + user: getUser(), + selectedRule: rule, + }); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + ...actions[0], + content: "The price of pears is: $1.99", + }); + console.debug("Generated content:\n", result[0].content); + }); + + test("should handle multiple actions with mixed AI needs", async () => { + const actions = [ + getAction({ + content: "Write a professional response", + }), + getAction({}), + ]; + const rule = getRule("Test rule", actions); + + const result = await getActionItemsWithAiArgs({ + email: getEmail({ + subject: "Project status", + content: "Can you update me on the project status?", + }), + user: getUser(), + selectedRule: rule, + }); + + expect(result).toHaveLength(2); + expect(result[0].content).toBeTruthy(); + expect(result[1]).toEqual(actions[1]); + }); + + test("should handle multiple variables with specific formatting", async () => { + const actions = [ + getAction({ + type: ActionType.LABEL, + label: "{{fruit}}", + }), + getAction({ + type: ActionType.REPLY, + content: `Hey {{name}}, + +{{$10 for apples, $20 for pears}} + +Best, +Matt`, + }), + ]; + const rule = getRule( + "Use this when someone asks about the price of fruits", + actions, + ); + + const result = await getActionItemsWithAiArgs({ + email: getEmail({ + from: "jill@example.com", + subject: "fruits", + content: "how much do apples cost?", + }), + user: getUser(), + selectedRule: rule, + }); + + expect(result).toHaveLength(2); + + // Check label action + expect(result[0].label).toBeTruthy(); + expect(result[0].label).not.toContain("{{"); + expect(result[0].label).toMatch(/apple(s)?/i); + + // Check reply action + expect(result[1].content).toMatch(/^Hey [Jj]ill,/); // Match "Hey Jill," or "Hey jill," + expect(result[1].content).toContain("$10"); + expect(result[1].content).toContain("Best,\nMatt"); + expect(result[1].content).not.toContain("{{"); + expect(result[1].content).not.toContain("}}"); + + console.debug("Generated label:\n", result[0].label); + console.debug("Generated content:\n", result[1].content); + }); +}); + +// helpers +function getAction(action: Partial = {}): Action { + return { + id: "a123", + createdAt: new Date(), + updatedAt: new Date(), + type: ActionType.REPLY, + ruleId: "ruleId", + label: null, + subject: null, + content: null, + to: null, + cc: null, + bcc: null, + labelPrompt: null, + subjectPrompt: null, + contentPrompt: null, + toPrompt: null, + ccPrompt: null, + bccPrompt: null, + ...action, + }; +} + +function getRule(instructions: string, actions: Action[] = []) { + return { + instructions, + name: "Test Rule", + actions, + id: "r123", + userId: "userId", + createdAt: new Date(), + updatedAt: new Date(), + automate: false, + runOnThreads: false, + groupId: null, + from: null, + subject: null, + body: null, + to: null, + type: RuleType.AI, + enabled: true, + categoryFilterType: null, + }; +} + +function getEmail({ + from = "from@test.com", + subject = "subject", + content = "content", +}: { from?: string; subject?: string; content?: string } = {}) { + return { + from, + subject, + content, + }; +} + +function getUser() { + return { + aiModel: null, + aiProvider: null, + email: "user@test.com", + aiApiKey: null, + about: null, + }; +} diff --git a/apps/web/__tests__/ai-choose-rule.test.ts b/apps/web/__tests__/ai-choose-rule.test.ts index 288899e46f..c5c62cc106 100644 --- a/apps/web/__tests__/ai-choose-rule.test.ts +++ b/apps/web/__tests__/ai-choose-rule.test.ts @@ -62,14 +62,14 @@ test("Should generate action arguments", async () => { ruleId: "ruleId", label: null, subject: null, - content: null, + content: "{{Write a joke}}", to: null, cc: null, bcc: null, labelPrompt: null, subjectPrompt: null, - contentPrompt: "Write a joke", + contentPrompt: null, toPrompt: null, ccPrompt: null, bccPrompt: null, @@ -96,6 +96,17 @@ test("Should generate action arguments", async () => { subject: null, to: null, type: "REPLY", + + labelPrompt: null, + subjectPrompt: null, + contentPrompt: null, + toPrompt: null, + ccPrompt: null, + bccPrompt: null, + createdAt: expect.any(Date), + updatedAt: expect.any(Date), + id: "id", + ruleId: "ruleId", }, ], }); @@ -120,6 +131,7 @@ function getRule(instructions: string, actions: Action[] = []) { to: null, type: RuleType.AI, enabled: true, + categoryFilterType: null, }; } @@ -137,8 +149,8 @@ function getEmail({ function getUser() { return { - aiModel: "gpt-4o-mini", - aiProvider: "openai", + aiModel: null, + aiProvider: null, email: "user@test.com", aiApiKey: null, about: null, diff --git a/apps/web/app/(app)/automation/RuleForm.tsx b/apps/web/app/(app)/automation/RuleForm.tsx index 861e91e8af..948966dea3 100644 --- a/apps/web/app/(app)/automation/RuleForm.tsx +++ b/apps/web/app/(app)/automation/RuleForm.tsx @@ -54,6 +54,7 @@ import type { LabelsResponse } from "@/app/api/google/labels/route"; import { MultiSelectFilter } from "@/components/MultiSelectFilter"; import { useCategories } from "@/hooks/useCategories"; import { useSmartCategoriesEnabled } from "@/hooks/useFeatureFlags"; +import { hasVariables } from "@/utils/template"; export function RuleForm({ rule }: { rule: CreateRuleBody & { id?: string } }) { const { @@ -90,7 +91,7 @@ export function RuleForm({ rule }: { rule: CreateRuleBody & { id?: string } }) { const hasLabel = gmailLabelsData?.labels?.some( (label) => label.name === action.label, ); - if (!hasLabel && action.label?.value) { + if (!hasLabel && action.label?.value && !action.label?.ai) { await createLabelAction({ name: action.label.value }); } } @@ -328,29 +329,33 @@ export function RuleForm({ rule }: { rule: CreateRuleBody & { id?: string } }) { {actionInputs[action.type].fields.map((field) => { const isAiGenerated = action[field.name]?.ai; + const value = watch(`actions.${i}.${field.name}.value`); + return (
- {field.name === "label" ? ( + {field.name === "label" && !isAiGenerated ? (
) : field.textArea ? ( -