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
212 changes: 212 additions & 0 deletions apps/web/__tests__/ai-choose-args.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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,
};
}
Comment on lines +1 to +212
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codebase verification

Add test coverage for error handling scenarios

The implementation includes error handling for InvalidToolArgumentsError in the withRetry function, but there are no corresponding test cases. Consider adding tests for:

  • Error handling when AI tool calls fail
  • Retry behavior with InvalidToolArgumentsError
  • Edge cases with malformed template variables
🔗 Analysis chain

Verify test coverage of the implementation.

Let's ensure all code paths in the implementation are covered by these tests.


Let's analyze the implementation file we found. I notice several code paths that need verification in the test coverage:

🏁 Scripts executed

The following scripts were executed for the analysis:

Script:

#!/bin/bash
# Search for the implementation file
fd -e ts "ai-choose-args.ts" -x cat {} \;

# Look for conditional statements and error handling that might need testing
rg -n "if|throw|catch" "ai-choose-args.ts"

Length of output: 12003


Script:

# Let's check if all the exported functions are tested
ast-grep --pattern 'export function $NAME' apps/web/utils/ai/choose-rule/ai-choose-args.ts

# Let's also check if error cases from withRetry are tested
rg -A 5 "withRetry" apps/web/utils/ai/choose-rule/ai-choose-args.ts

Length of output: 517


Script:

# Let's check all exported functions and their test coverage
ast-grep --pattern 'export function $NAME' utils/ai/choose-rule/ai-choose-args.ts

# Let's also check the test file for error cases
rg -n "test\(.*error|test\(.*fail|test\(.*invalid" apps/web/__tests__/ai-choose-args.test.ts

# Let's check for error handling in the implementation
rg -n "throw|catch|Error" utils/ai/choose-rule/ai-choose-args.ts

Length of output: 410


Script:

# Let's try with correct paths based on the earlier output
# Check exported functions
ast-grep --pattern 'export function $NAME' apps/web/utils/ai/choose-rule/ai-choose-args.ts

# Check error test cases
rg -n "test\(.*error|test\(.*fail|test\(.*invalid" apps/web/__tests__/ai-choose-args.test.ts

# Check error handling
rg -n "throw|catch|Error" apps/web/utils/ai/choose-rule/ai-choose-args.ts

Length of output: 398

20 changes: 16 additions & 4 deletions apps/web/__tests__/ai-choose-rule.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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",
},
],
});
Expand All @@ -120,6 +131,7 @@ function getRule(instructions: string, actions: Action[] = []) {
to: null,
type: RuleType.AI,
enabled: true,
categoryFilterType: null,
};
}

Expand All @@ -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,
Expand Down
94 changes: 60 additions & 34 deletions apps/web/app/(app)/automation/RuleForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 });
}
}
Expand Down Expand Up @@ -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 (
<div key={field.label}>
<div className="flex items-center justify-between">
<Label name={field.name} label={field.label} />
<div className="flex items-center space-x-2">
<TooltipExplanation text="Enable for AI-generated values unique to each email. Disable to use a fixed value you set here." />
<Toggle
name={`actions.${i}.${field.name}.ai`}
label="AI generated"
enabled={isAiGenerated || false}
onChange={(enabled) => {
setValue(
`actions.${i}.${field.name}`,
enabled
? { value: "", ai: true }
: { value: "", ai: false },
);
}}
/>
</div>
{field.name === "label" && (
<div className="flex items-center space-x-2">
<TooltipExplanation text="Enable for AI-generated values unique to each email. Put the prompt inside braces {{your prompt here}}. Disable to use a fixed value." />
<Toggle
name={`actions.${i}.${field.name}.ai`}
label="AI generated"
enabled={isAiGenerated || false}
onChange={(enabled) => {
setValue(
`actions.${i}.${field.name}`,
enabled
? { value: "", ai: true }
: { value: "", ai: false },
);
}}
/>
</div>
)}
</div>

{field.name === "label" ? (
{field.name === "label" && !isAiGenerated ? (
<div className="mt-2">
<LabelCombobox
userLabels={userLabels}
Expand All @@ -366,23 +371,44 @@ export function RuleForm({ rule }: { rule: CreateRuleBody & { id?: string } }) {
/>
</div>
) : field.textArea ? (
<textarea
className="mt-2 block w-full flex-1 whitespace-pre-wrap rounded-md border-gray-300 shadow-sm focus:border-black focus:ring-black sm:text-sm"
rows={3}
placeholder={
isAiGenerated ? "AI prompt (optional)" : ""
}
{...register(`actions.${i}.${field.name}.value`)}
/>
<div className="mt-2">
<textarea
className="block w-full flex-1 whitespace-pre-wrap rounded-md border-gray-300 shadow-sm focus:border-black focus:ring-black sm:text-sm"
rows={4}
placeholder="Add text or use {{AI prompts}}. e.g. Hi {{write greeting}}"
value={value || ""}
{...register(`actions.${i}.${field.name}.value`)}
/>
</div>
) : (
<input
className="mt-2 block w-full flex-1 rounded-md border-gray-300 shadow-sm focus:border-black focus:ring-black sm:text-sm"
type="text"
placeholder={
isAiGenerated ? "AI prompt (optional)" : ""
}
{...register(`actions.${i}.${field.name}.value`)}
/>
<div className="mt-2">
<input
className="block w-full flex-1 rounded-md border-gray-300 shadow-sm focus:border-black focus:ring-black sm:text-sm"
type="text"
placeholder="Add text or use {{AI prompts}}. e.g. Hi {{write greeting}}"
{...register(`actions.${i}.${field.name}.value`)}
/>
</div>
)}

{hasVariables(value) && (
<div className="mt-2 whitespace-pre-wrap rounded-md bg-gray-50 p-2 font-mono text-sm text-gray-900">
{(value || "")
.split(/(\{\{.*?\}\})/g)
.map((part, i) =>
part.startsWith("{{") ? (
<span
key={i}
className="rounded bg-blue-100 px-1 text-blue-500"
>
<sub className="font-sans">AI</sub>
{part}
</span>
) : (
<span key={i}>{part}</span>
),
)}
</div>
)}

{errors.actions?.[i]?.[field.name]?.message ? (
Expand Down
Loading