From 7ceee974a9f2b5f0c1687be081faeb823f2c2bed Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 19 Oct 2025 12:33:58 +0300 Subject: [PATCH 01/40] wip: move to multi rule system --- .../assistant/FixWithChat.tsx | 33 +- .../assistant/ProcessResultDisplay.tsx | 15 +- .../assistant/ProcessRules.tsx | 32 +- .../assistant/TestCustomEmailForm.tsx | 6 +- .../api/user/executed-rules/batch/route.ts | 7 +- apps/web/prisma/schema.prisma | 11 +- apps/web/utils/actions/ai-rule.ts | 34 +- .../utils/ai/choose-rule/ai-choose-rule.ts | 297 +++++++++++---- apps/web/utils/ai/choose-rule/match-rules.ts | 168 +++++---- apps/web/utils/ai/choose-rule/run-rules.ts | 339 ++++++------------ .../assistant/process-assistant-email.ts | 14 +- .../web/utils/webhook/process-history-item.ts | 20 +- 12 files changed, 531 insertions(+), 445 deletions(-) diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/FixWithChat.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/FixWithChat.tsx index e863bd0ffd..04749db116 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/FixWithChat.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/FixWithChat.tsx @@ -27,11 +27,11 @@ import { Badge } from "@/components/ui/badge"; export function FixWithChat({ setInput, message, - result, + results, }: { setInput: (input: string) => void; message: ParsedMessage; - result: RunRulesResult | null; + results: RunRulesResult[]; }) { const { data, isLoading, error } = useRules(); const { isModalOpen, setIsModalOpen } = useModal(); @@ -53,7 +53,7 @@ export function FixWithChat({ if (selectedRuleId === NEW_RULE_ID) { input = getFixMessage({ message, - result, + results, expectedRuleName: NEW_RULE_ID, explanation, }); @@ -62,7 +62,7 @@ export function FixWithChat({ input = getFixMessage({ message, - result, + results, expectedRuleName: expectedRule?.name ?? null, explanation, }); @@ -105,7 +105,7 @@ export function FixWithChat({ {data && !showExplanation ? ( @@ -165,14 +165,15 @@ export function FixWithChat({ ); } +// TODO: tag rule like in Cursor so we don't need to show the email contents function getFixMessage({ message, - result, + results, expectedRuleName, explanation, }: { message: ParsedMessage; - result: RunRulesResult | null; + results: RunRulesResult[]; expectedRuleName: string | null; explanation?: string; }) { @@ -191,10 +192,14 @@ Email details: *Subject*: ${message.headers.subject} *Content*: ${getMessageContent()} -Current rule applied: ${result?.rule?.name || "No rule"} +Current rules applied and the reasons they were chosen: -Reason the rule was chosen: -${result?.reason || "-"} +${results + .map( + (r) => `- Rule: ${r.rule?.name || "No rule"} +- Reason: ${r.reason}`, + ) + .join("\n")} ${ expectedRuleName === NEW_RULE_ID @@ -206,11 +211,11 @@ ${ } function RuleMismatch({ - result, + results, rules, onSelectExpectedRuleId, }: { - result: RunRulesResult | null; + results: RunRulesResult[]; rules: RulesResponse; onSelectExpectedRuleId: (ruleId: string | null) => void; }) { @@ -218,8 +223,8 @@ function RuleMismatch({
))} diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/Rules.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/Rules.tsx index b5e39ecf0f..31d1d88c31 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/Rules.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/Rules.tsx @@ -110,7 +110,6 @@ export function Rules({ runOnThreads: false, automate: true, actions: getDefaultActions(systemType, provider), - categoryFilters: [], group: null, emailAccountId: emailAccountId, createdAt: new Date(), diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/constants.ts b/apps/web/app/(app)/[emailAccountId]/assistant/constants.ts index 8b7936c573..703c494690 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/constants.ts +++ b/apps/web/app/(app)/[emailAccountId]/assistant/constants.ts @@ -8,7 +8,6 @@ import { MailOpenIcon, ShieldCheckIcon, WebhookIcon, - EyeIcon, FileTextIcon, FolderInputIcon, } from "lucide-react"; diff --git a/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeSection.tsx b/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeSection.tsx index e5f4dd6e95..d98745fb50 100644 --- a/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeSection.tsx +++ b/apps/web/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeSection.tsx @@ -35,7 +35,6 @@ import { BulkUnsubscribeRowDesktop, } from "@/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkUnsubscribeDesktop"; import { Card } from "@/components/ui/card"; -import { ShortcutTooltip } from "@/app/(app)/[emailAccountId]/bulk-unsubscribe/ShortcutTooltip"; import { SearchBar } from "@/app/(app)/[emailAccountId]/bulk-unsubscribe/SearchBar"; import { useToggleSelect } from "@/hooks/useToggleSelect"; import { BulkActions } from "@/app/(app)/[emailAccountId]/bulk-unsubscribe/BulkActions"; diff --git a/apps/web/app/(app)/[emailAccountId]/debug/rule-history/[ruleId]/page.tsx b/apps/web/app/(app)/[emailAccountId]/debug/rule-history/[ruleId]/page.tsx index 3757fb9394..e0505937ba 100644 --- a/apps/web/app/(app)/[emailAccountId]/debug/rule-history/[ruleId]/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/debug/rule-history/[ruleId]/page.tsx @@ -160,21 +160,6 @@ export default async function RuleHistoryPage(props: { )} - {history.categoryFilterType && ( -
-

Category Filters

-

- Type: {history.categoryFilterType} - {history.categoryFilters && ( - - ({(history.categoryFilters as any[]).length}{" "} - categories) - - )} -

-
- )} - {history.systemType && (

System Type

diff --git a/apps/web/app/api/google/webhook/types.ts b/apps/web/app/api/google/webhook/types.ts index 6fd1903d71..4ff63b9f61 100644 --- a/apps/web/app/api/google/webhook/types.ts +++ b/apps/web/app/api/google/webhook/types.ts @@ -1,5 +1,5 @@ import type { gmail_v1 } from "@googleapis/gmail"; -import type { RuleWithActionsAndCategories } from "@/utils/types"; +import type { RuleWithActions } from "@/utils/types"; import type { EmailAccountWithAI } from "@/utils/llms/types"; import type { EmailAccount } from "@prisma/client"; @@ -16,7 +16,7 @@ export type ProcessHistoryOptions = { history: gmail_v1.Schema$History[]; gmail: gmail_v1.Gmail; accessToken: string; - rules: RuleWithActionsAndCategories[]; + rules: RuleWithActions[]; hasAutomationRules: boolean; hasAiAccess: boolean; emailAccount: Pick & diff --git a/apps/web/app/api/outlook/webhook/process-history-item.ts b/apps/web/app/api/outlook/webhook/process-history-item.ts index bedb5fc0da..d851d56844 100644 --- a/apps/web/app/api/outlook/webhook/process-history-item.ts +++ b/apps/web/app/api/outlook/webhook/process-history-item.ts @@ -1,14 +1,14 @@ import type { OutlookResourceData } from "@/app/api/outlook/webhook/types"; import { logger as globalLogger } from "@/app/api/outlook/webhook/logger"; import { processHistoryItem as processHistoryItemShared } from "@/utils/webhook/process-history-item"; -import type { RuleWithActionsAndCategories } from "@/utils/types"; +import type { RuleWithActions } from "@/utils/types"; import type { EmailAccountWithAI } from "@/utils/llms/types"; import type { EmailAccount } from "@prisma/client"; import type { EmailProvider } from "@/utils/email/types"; type ProcessHistoryOptions = { provider: EmailProvider; - rules: RuleWithActionsAndCategories[]; + rules: RuleWithActions[]; hasAutomationRules: boolean; hasAiAccess: boolean; emailAccount: Pick & diff --git a/apps/web/app/api/user/planned/get-executed-rules.ts b/apps/web/app/api/user/planned/get-executed-rules.ts index e4d5bbd75c..4aacb34f31 100644 --- a/apps/web/app/api/user/planned/get-executed-rules.ts +++ b/apps/web/app/api/user/planned/get-executed-rules.ts @@ -42,7 +42,6 @@ export async function getExecutedRules({ rule: { include: { group: { select: { name: true } }, - categoryFilters: true, }, }, actionItems: true, diff --git a/apps/web/app/api/user/rules/[id]/example/controller.ts b/apps/web/app/api/user/rules/[id]/example/controller.ts index d387fadb7c..5cb529d9f3 100644 --- a/apps/web/app/api/user/rules/[id]/example/controller.ts +++ b/apps/web/app/api/user/rules/[id]/example/controller.ts @@ -7,12 +7,7 @@ import { splitEmailPatterns, } from "@/utils/ai/choose-rule/match-rules"; import { fetchPaginatedMessages } from "@/app/api/user/group/[groupId]/messages/controller"; -import { - isGroupRule, - isAIRule, - isStaticRule, - isCategoryRule, -} from "@/utils/condition"; +import { isGroupRule, isAIRule, isStaticRule } from "@/utils/condition"; import { LogicalOperator } from "@prisma/client"; import type { EmailProvider } from "@/utils/email/types"; @@ -23,13 +18,12 @@ export async function fetchExampleMessages( const isStatic = isStaticRule(rule); const isGroup = isGroupRule(rule); const isAI = isAIRule(rule); - const isCategory = isCategoryRule(rule); - if (isAI || isCategory) return []; + if (isAI) return []; // if AND and more than 1 condition, return [] // TODO: handle multiple conditions properly and return real examples - const conditions = [isStatic, isGroup, isAI, isCategory]; + const conditions = [isStatic, isGroup, isAI]; const trueConditionsCount = conditions.filter(Boolean).length; if ( diff --git a/apps/web/app/api/user/rules/[id]/route.ts b/apps/web/app/api/user/rules/[id]/route.ts index 3952dcd2b9..4b336ba1a6 100644 --- a/apps/web/app/api/user/rules/[id]/route.ts +++ b/apps/web/app/api/user/rules/[id]/route.ts @@ -18,7 +18,6 @@ async function getRule({ where: { id: ruleId, emailAccount: { id: emailAccountId } }, include: { actions: true, - categoryFilters: true, }, }); @@ -42,7 +41,6 @@ async function getRule({ folderName: { value: action.folderName }, folderId: { value: action.folderId }, })), - categoryFilters: rule.categoryFilters.map((category) => category.id), conditions: getConditions(rule), }; diff --git a/apps/web/app/api/user/rules/route.ts b/apps/web/app/api/user/rules/route.ts index 7d2bae0e9a..49c18fb37c 100644 --- a/apps/web/app/api/user/rules/route.ts +++ b/apps/web/app/api/user/rules/route.ts @@ -10,7 +10,6 @@ async function getRules({ emailAccountId }: { emailAccountId: string }) { include: { actions: true, group: { select: { name: true } }, - categoryFilters: { select: { id: true, name: true } }, }, orderBy: { createdAt: "asc" }, }); diff --git a/apps/web/components/GroupedTable.tsx b/apps/web/components/GroupedTable.tsx index 169ffd8811..2d0274eca3 100644 --- a/apps/web/components/GroupedTable.tsx +++ b/apps/web/components/GroupedTable.tsx @@ -16,12 +16,9 @@ import { ChevronRight, MoreVerticalIcon, PencilIcon, - FileCogIcon, - PlusIcon, BookmarkXIcon, } from "lucide-react"; import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table"; -import { ConditionType } from "@/utils/config"; import { EmailCell } from "@/components/EmailCell"; import { useThreads } from "@/hooks/useThreads"; import { Skeleton } from "@/components/ui/skeleton"; @@ -58,7 +55,6 @@ import type { CategoryWithRules } from "@/utils/category.server"; import { ViewEmailButton } from "@/components/ViewEmailButton"; import { CategorySelect } from "@/components/CategorySelect"; import { useAccount } from "@/providers/EmailAccountProvider"; -import { prefixPath } from "@/utils/path"; const COLUMNS = 4; @@ -247,7 +243,6 @@ export function GroupedTable({ return ( - {category.rules.length ? ( -
- {category.rules.map((rule) => ( - - ))} -
- ) : ( - - )}
- {!!(thread.category?.category || thread.plan) && ( + {!!thread.plan && (
- {thread.category?.category ? ( - - ) : null}
)} diff --git a/apps/web/components/email-list/types.ts b/apps/web/components/email-list/types.ts index 20b4a254f3..e34af05c3b 100644 --- a/apps/web/components/email-list/types.ts +++ b/apps/web/components/email-list/types.ts @@ -7,7 +7,6 @@ export type Thread = { messages: FullThread["messages"]; snippet: FullThread["snippet"]; plan: FullThread["plan"]; - category: FullThread["category"]; }; export type Executing = Record; diff --git a/apps/web/utils/ai/assistant/process-user-request.ts b/apps/web/utils/ai/assistant/process-user-request.ts index 7b2907b17c..9c594c8823 100644 --- a/apps/web/utils/ai/assistant/process-user-request.ts +++ b/apps/web/utils/ai/assistant/process-user-request.ts @@ -2,12 +2,7 @@ import { stepCountIs, tool } from "ai"; import { z } from "zod"; import { createGenerateText } from "@/utils/llms"; import { createScopedLogger } from "@/utils/logger"; -import { - type Category, - GroupItemType, - LogicalOperator, - type Rule, -} from "@prisma/client"; +import { GroupItemType, LogicalOperator, type Rule } from "@prisma/client"; import type { EmailAccountWithAI } from "@/utils/llms/types"; import type { RuleWithRelations } from "@/utils/rule/types"; import type { ParsedMessage } from "@/utils/types"; @@ -27,16 +22,12 @@ export async function processUserRequest({ originalEmail, messages, matchedRule, - categories, - senderCategory, }: { emailAccount: EmailAccountWithAI; rules: RuleWithRelations[]; originalEmail: ParsedMessage | null; messages: { role: "assistant" | "user"; content: string }[]; matchedRule: RuleWithRelations | null; - categories: Pick[] | null; - senderCategory: string | null; }) { const logger = createScopedLogger("ai-fix-rules").with({ emailAccountId: emailAccount.id, @@ -63,7 +54,6 @@ You can fix rules using these specific operations: - Change conditional operator (AND/OR logic) - Modify AI instructions - Update static conditions (from, to, subject, body) -- Add or remove categories 2. Create New Rules: - Create new rules when asked or when existing ones cannot be modified to fit the need @@ -85,7 +75,7 @@ When fixing rules: Rule matching logic: - All static conditions (from, to, subject, body) use AND logic - meaning all static conditions must match -- Top level conditions (AI instructions, static, category) can use either AND or OR logic, controlled by the conditionalOperator setting +- Top level conditions (AI instructions, static) can use either AND or OR logic, controlled by the conditionalOperator setting Best practices: - For static conditions, use email patterns (e.g., '@company.com') when matching multiple addresses @@ -117,14 +107,6 @@ ${ ${stringifyEmailSimple(getEmailForLLM(originalEmail))} ` : "" -} - -${ - originalEmail && categories?.length - ? ` -${senderCategory || "No category"} -` - : "" }`; const allMessages = [ diff --git a/apps/web/utils/assistant/process-assistant-email.ts b/apps/web/utils/assistant/process-assistant-email.ts index 017dda466c..fae0c6280c 100644 --- a/apps/web/utils/assistant/process-assistant-email.ts +++ b/apps/web/utils/assistant/process-assistant-email.ts @@ -96,7 +96,7 @@ async function processAssistantEmailInternal({ const originalMessageId = firstMessageToAssistant.headers["in-reply-to"]; const originalMessage = await provider.getOriginalMessage(originalMessageId); - const [emailAccount, executedRules, senderCategory] = await Promise.all([ + const [emailAccount, executedRules] = await Promise.all([ prisma.emailAccount.findUnique({ where: { email: userEmail }, select: { @@ -129,7 +129,6 @@ async function processAssistantEmailInternal({ }, }, }, - categories: true, account: { select: { provider: true } }, }, }), @@ -150,19 +149,6 @@ async function processAssistantEmailInternal({ }, }) : null, - originalMessage - ? prisma.newsletter.findUnique({ - where: { - email_emailAccountId: { - email: extractEmailAddress(originalMessage.headers.from), - emailAccountId, - }, - }, - select: { - category: { select: { name: true } }, - }, - }) - : null, ]); if (!emailAccount) { @@ -214,8 +200,6 @@ async function processAssistantEmailInternal({ originalEmail: originalMessage, messages, matchedRule: executedRules?.length ? executedRules[0].rule : null, // TODO: support multiple rule matching - categories: emailAccount.categories.length ? emailAccount.categories : null, - senderCategory: senderCategory?.category?.name ?? null, }); const toolCalls = result.steps.flatMap((step) => step.toolCalls); diff --git a/apps/web/utils/condition.ts b/apps/web/utils/condition.ts index e5daa1c08b..b8b9401e57 100644 --- a/apps/web/utils/condition.ts +++ b/apps/web/utils/condition.ts @@ -1,4 +1,4 @@ -import { LogicalOperator, type Category, type Rule } from "@prisma/client"; +import { LogicalOperator, type Rule } from "@prisma/client"; import { ConditionType, type CoreConditionType } from "@/utils/config"; import type { CreateRuleBody, From a9b335c2caadcee272ed9b227a219da08743e89d Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Mon, 20 Oct 2025 07:20:19 +0300 Subject: [PATCH 17/40] fixes --- apps/web/utils/ai/choose-rule/match-rules.ts | 39 +++++++++++++++++--- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/apps/web/utils/ai/choose-rule/match-rules.ts b/apps/web/utils/ai/choose-rule/match-rules.ts index e8191e0ffa..27ae639138 100644 --- a/apps/web/utils/ai/choose-rule/match-rules.ts +++ b/apps/web/utils/ai/choose-rule/match-rules.ts @@ -144,6 +144,7 @@ async function findPotentialMatchingRules({ { type: ConditionType.PRESET, systemType: SystemType.CALENDAR }, ], }); + continue; } // TODO: still run this, if a previous email in the thread matched the rule @@ -327,12 +328,40 @@ async function findMatchingRulesWithReasons( modelType, }); - return { - matches: result.rules.map((rule) => ({ - rule, - matchReasons: [{ type: ConditionType.AI }], + // Build combined matches: start with existing static/learned matches, then append AI-selected matches + const combinedMatches = [ + // Map existing matches to the same output shape + ...matches.map((match) => ({ + rule: match.rule, + matchReasons: match.matchReasons || [], })), - reasoning: result.reason, + // Append AI-selected matches, deduplicating by rule id + ...result.rules + .filter( + (aiRule) => + !matches.some( + (existingMatch) => existingMatch.rule.id === aiRule.id, + ), + ) + .map((rule) => ({ + rule, + matchReasons: [{ type: ConditionType.AI }], + })), + ]; + + // Combine reasoning: existing reasoning plus AI reasoning + const existingReasoning = matches + .map((m) => getMatchReason(m.matchReasons)) + .filter((r): r is string => !!r) + .join(", "); + + const combinedReasoning = existingReasoning + ? `${existingReasoning}; ${result.reason}` + : result.reason; + + return { + matches: combinedMatches, + reasoning: combinedReasoning, }; } else { return { From 28b82f0770e3f056c7334f13a98763ea1c3256d3 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Mon, 20 Oct 2025 08:18:03 +0300 Subject: [PATCH 18/40] use isprimary to only choose one system rule --- apps/web/__tests__/ai-choose-rule.test.ts | 101 ++++++++---------- .../utils/ai/choose-rule/ai-choose-rule.ts | 32 +----- apps/web/utils/ai/choose-rule/match-rules.ts | 27 ++++- 3 files changed, 75 insertions(+), 85 deletions(-) diff --git a/apps/web/__tests__/ai-choose-rule.test.ts b/apps/web/__tests__/ai-choose-rule.test.ts index 3a7dc4a1d5..02c82b7e95 100644 --- a/apps/web/__tests__/ai-choose-rule.test.ts +++ b/apps/web/__tests__/ai-choose-rule.test.ts @@ -17,7 +17,7 @@ describe.runIf(isAiTest)("aiChooseRule", () => { emailAccount: getEmailAccount(), }); - expect(result).toEqual({ reason: "No rules" }); + expect(result).toEqual({ rules: [], reason: "" }); }); test("Should return correct rule when only one rule passed", async () => { @@ -31,10 +31,10 @@ describe.runIf(isAiTest)("aiChooseRule", () => { emailAccount: getEmailAccount(), }); - expect(result).toEqual({ - rule, - reason: expect.any(String), - }); + expect(result.rules).toHaveLength(1); + expect(result.rules[0].rule).toEqual(rule); + expect(result.rules[0].isPrimary).toBe(true); + expect(result.reason).toBeTruthy(); }); test("Should return correct rule when multiple rules passed", async () => { @@ -51,10 +51,9 @@ describe.runIf(isAiTest)("aiChooseRule", () => { emailAccount: getEmailAccount(), }); - expect(result).toEqual({ - rule: rule2, - reason: expect.any(String), - }); + expect(result.rules).toHaveLength(1); + expect(result.rules[0].rule).toEqual(rule2); + expect(result.reason).toBeTruthy(); }); test("Should generate action arguments", async () => { @@ -91,10 +90,9 @@ describe.runIf(isAiTest)("aiChooseRule", () => { emailAccount: getEmailAccount(), }); - expect(result).toEqual({ - rule: rule2, - reason: expect.any(String), - }); + expect(result.rules).toHaveLength(1); + expect(result.rules[0].rule).toEqual(rule2); + expect(result.reason).toBeTruthy(); }); describe("Complex real-world rule scenarios", () => { @@ -159,10 +157,9 @@ describe.runIf(isAiTest)("aiChooseRule", () => { emailAccount: getEmailAccount(), }); - expect(result).toEqual({ - rule: requiresResponse, - reason: expect.any(String), - }); + expect(result.rules).toHaveLength(1); + expect(result.rules[0].rule).toEqual(requiresResponse); + expect(result.reason).toBeTruthy(); }); test("Should match technical issues", async () => { @@ -176,10 +173,9 @@ describe.runIf(isAiTest)("aiChooseRule", () => { emailAccount: getEmailAccount(), }); - expect(result).toEqual({ - rule: technicalIssues, - reason: expect.any(String), - }); + expect(result.rules).toHaveLength(1); + expect(result.rules[0].rule).toEqual(technicalIssues); + expect(result.reason).toBeTruthy(); }); test("Should match financial emails", async () => { @@ -192,10 +188,9 @@ describe.runIf(isAiTest)("aiChooseRule", () => { emailAccount: getEmailAccount(), }); - expect(result).toEqual({ - rule: financial, - reason: expect.any(String), - }); + expect(result.rules).toHaveLength(1); + expect(result.rules[0].rule).toEqual(financial); + expect(result.reason).toBeTruthy(); }); test("Should match recruiter emails", async () => { @@ -209,10 +204,9 @@ describe.runIf(isAiTest)("aiChooseRule", () => { emailAccount: getEmailAccount(), }); - expect(result).toEqual({ - rule: recruiters, - reason: expect.any(String), - }); + expect(result.rules).toHaveLength(1); + expect(result.rules[0].rule).toEqual(recruiters); + expect(result.reason).toBeTruthy(); }); test("Should match legal documents", async () => { @@ -225,10 +219,9 @@ describe.runIf(isAiTest)("aiChooseRule", () => { emailAccount: getEmailAccount(), }); - expect(result).toEqual({ - rule: legal, - reason: expect.any(String), - }); + expect(result.rules).toHaveLength(1); + expect(result.rules[0].rule).toEqual(legal); + expect(result.reason).toBeTruthy(); }); test("Should match emails requiring response", async () => { @@ -241,10 +234,9 @@ describe.runIf(isAiTest)("aiChooseRule", () => { emailAccount: getEmailAccount(), }); - expect(result).toEqual({ - rule: requiresResponse, - reason: expect.any(String), - }); + expect(result.rules).toHaveLength(1); + expect(result.rules[0].rule).toEqual(requiresResponse); + expect(result.reason).toBeTruthy(); }); test("Should match product updates", async () => { @@ -257,10 +249,9 @@ describe.runIf(isAiTest)("aiChooseRule", () => { emailAccount: getEmailAccount(), }); - expect(result).toEqual({ - rule: productUpdates, - reason: expect.any(String), - }); + expect(result.rules).toHaveLength(1); + expect(result.rules[0].rule).toEqual(productUpdates); + expect(result.reason).toBeTruthy(); }); test("Should match marketing emails", async () => { @@ -273,10 +264,9 @@ describe.runIf(isAiTest)("aiChooseRule", () => { emailAccount: getEmailAccount(), }); - expect(result).toEqual({ - rule: marketing, - reason: expect.any(String), - }); + expect(result.rules).toHaveLength(1); + expect(result.rules[0].rule).toEqual(marketing); + expect(result.reason).toBeTruthy(); }); test("Should match team updates", async () => { @@ -289,10 +279,9 @@ describe.runIf(isAiTest)("aiChooseRule", () => { emailAccount: getEmailAccount(), }); - expect(result).toEqual({ - rule: teamUpdates, - reason: expect.any(String), - }); + expect(result.rules).toHaveLength(1); + expect(result.rules[0].rule).toEqual(teamUpdates); + expect(result.reason).toBeTruthy(); }); test("Should match customer feedback", async () => { @@ -305,10 +294,9 @@ describe.runIf(isAiTest)("aiChooseRule", () => { emailAccount: getEmailAccount(), }); - expect(result).toEqual({ - rule: customerFeedback, - reason: expect.any(String), - }); + expect(result.rules).toHaveLength(1); + expect(result.rules[0].rule).toEqual(customerFeedback); + expect(result.reason).toBeTruthy(); }); test("Should match event invitations", async () => { @@ -321,10 +309,9 @@ describe.runIf(isAiTest)("aiChooseRule", () => { emailAccount: getEmailAccount(), }); - expect(result).toEqual({ - rule: events, - reason: expect.any(String), - }); + expect(result.rules).toHaveLength(1); + expect(result.rules[0].rule).toEqual(events); + expect(result.reason).toBeTruthy(); }); }); }); diff --git a/apps/web/utils/ai/choose-rule/ai-choose-rule.ts b/apps/web/utils/ai/choose-rule/ai-choose-rule.ts index 7161bf214a..cab3aa8353 100644 --- a/apps/web/utils/ai/choose-rule/ai-choose-rule.ts +++ b/apps/web/utils/ai/choose-rule/ai-choose-rule.ts @@ -26,7 +26,7 @@ export async function aiChooseRule< emailAccount: EmailAccountWithAI; modelType?: ModelType; }): Promise<{ - rules: T[]; + rules: { rule: T; isPrimary?: boolean }[]; reason: string; }> { if (!rules.length) return { rules: [], reason: "" }; @@ -40,24 +40,24 @@ export async function aiChooseRule< if (aiResponse.noMatchFound) return { rules: [], reason: "" }; - const selectedRules = aiResponse.matchedRules + const rulesWithMetadata = aiResponse.matchedRules .map((match) => { const rule = rules.find( (r) => r.name.toLowerCase() === match.ruleName.toLowerCase(), ); - return rule; + return rule ? { rule, isPrimary: match.isPrimary } : undefined; }) .filter(isDefined); return { - rules: selectedRules, + rules: rulesWithMetadata, reason: aiResponse.reasoning, }; } async function getAiResponse(options: GetAiResponseOptions): Promise<{ result: { - matchedRules: { ruleName: string }[]; + matchedRules: { ruleName: string; isPrimary?: boolean }[]; reasoning: string; noMatchFound: boolean; }; @@ -283,25 +283,3 @@ ${stringifyEmail(email, 500)} reasoning: aiResponse.object?.reasoning ?? "", }; } - -/** - * Filter system rules: if multiple system rules were matched, only keep the primary one. - * Always keep all conversation rules (non-system rules). - */ -function filterMultipleSystemRules< - T extends { name: string; instructions: string; systemType?: string | null }, ->(selectedRules: { rule: T; isPrimary?: boolean }[]): T[] { - const systemRules = selectedRules.filter((r) => r.rule.systemType); - const conversationRules = selectedRules.filter((r) => !r.rule.systemType); - - let filteredSystemRules = systemRules; - if (systemRules.length > 1) { - // Only keep the primary system rule - const primarySystemRule = systemRules.find((r) => r.isPrimary); - filteredSystemRules = primarySystemRule - ? [primarySystemRule] - : [systemRules[0]]; - } - - return [...filteredSystemRules, ...conversationRules].map((r) => r.rule); -} diff --git a/apps/web/utils/ai/choose-rule/match-rules.ts b/apps/web/utils/ai/choose-rule/match-rules.ts index 27ae639138..05615d42a0 100644 --- a/apps/web/utils/ai/choose-rule/match-rules.ts +++ b/apps/web/utils/ai/choose-rule/match-rules.ts @@ -321,13 +321,18 @@ async function findMatchingRulesWithReasons( }); if (potentialAiMatches.length) { - const result = await aiChooseRule({ + const fullResult = await aiChooseRule({ email: getEmailForLLM(message), rules: potentialAiMatches, emailAccount, modelType, }); + const result = { + rules: filterMultipleSystemRules(fullResult.rules), + reason: fullResult.reason, + }; + // Build combined matches: start with existing static/learned matches, then append AI-selected matches const combinedMatches = [ // Map existing matches to the same output shape @@ -532,3 +537,23 @@ export async function filterConversationStatusRules< return potentialMatches; } + +/** + * Filter system rules: if multiple system rules were matched, only keep the primary one. + * Always keep all conversation rules (non-system rules). + */ +export function filterMultipleSystemRules< + T extends { name: string; instructions: string; systemType?: string | null }, +>(selectedRules: { rule: T; isPrimary?: boolean }[]): T[] { + const systemRules = selectedRules.filter((r) => r.rule.systemType); + const conversationRules = selectedRules.filter((r) => !r.rule.systemType); + + let filteredSystemRules = systemRules; + if (systemRules.length > 1) { + // Only keep the primary system rule + const primarySystemRule = systemRules.find((r) => r.isPrimary); + filteredSystemRules = primarySystemRule ? [primarySystemRule] : systemRules; + } + + return [...filteredSystemRules, ...conversationRules].map((r) => r.rule); +} From 98b009dcacaaa3e6456141a4abaf2f8cb5a96be7 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Mon, 20 Oct 2025 08:26:16 +0300 Subject: [PATCH 19/40] ensure conversation tracking continues --- apps/web/utils/ai/choose-rule/run-rules.ts | 126 ++++++++++++++++++++- 1 file changed, 121 insertions(+), 5 deletions(-) diff --git a/apps/web/utils/ai/choose-rule/run-rules.ts b/apps/web/utils/ai/choose-rule/run-rules.ts index 94ad64d4a1..b98a974f0a 100644 --- a/apps/web/utils/ai/choose-rule/run-rules.ts +++ b/apps/web/utils/ai/choose-rule/run-rules.ts @@ -20,7 +20,10 @@ import { import groupBy from "lodash/groupBy"; import type { EmailProvider } from "@/utils/email/types"; import type { ModelType } from "@/utils/llms/model"; -import { isConversationStatusType } from "@/utils/reply-tracker/conversation-status-config"; +import { + CONVERSATION_STATUS_TYPES, + isConversationStatusType, +} from "@/utils/reply-tracker/conversation-status-config"; import { determineConversationStatus, updateThreadTrackers, @@ -66,11 +69,21 @@ export async function runRules({ modelType, }); + // Auto-reapply conversation tracking for thread continuity + const finalMatches = await ensureConversationRuleContinuity({ + emailAccountId: emailAccount.id, + threadId: message.threadId, + messageId: message.id, + conversationRules, + regularRules, + matches: results.matches, + }); + logger.trace("Matching rule", () => ({ - results: results.matches.map(filterNullProperties), + results: finalMatches.map(filterNullProperties), })); - if (!results.matches.length) { + if (!finalMatches.length) { const reason = results.reasoning || "No rules matched"; await prisma.executedRule.create({ data: { @@ -88,11 +101,11 @@ export async function runRules({ const executedRules: RunRulesResult[] = []; - for (const result of results.matches) { + for (const result of finalMatches) { let ruleToExecute = result.rule; let reasonToUse = results.reasoning; - if (result.rule.id === CONVERSATION_TRACKING_META_RULE_ID) { + if (isConversationRule(result.rule.id)) { // Determine which specific sub-rule applies const { specificRule, reason: statusReason } = await determineConversationStatus({ @@ -354,3 +367,106 @@ function shouldAnalyzeSenderPattern({ return true; } + +/** + * Checks if a conversation status rule was previously applied to any email in this thread. + */ +async function checkPreviousConversationRuleInThread({ + emailAccountId, + threadId, + messageId, +}: { + emailAccountId: string; + threadId: string; + messageId: string; +}): Promise { + const previousConversationRule = await prisma.executedRule.findFirst({ + where: { + emailAccountId, + threadId, + messageId: { not: messageId }, + status: ExecutedRuleStatus.APPLIED, + rule: { systemType: { in: CONVERSATION_STATUS_TYPES } }, + }, + select: { id: true }, + }); + + return !!previousConversationRule; +} + +/** + * Ensures conversation tracking continues throughout a thread. + * If a conversation meta rule was previously applied to any email in this thread, + * we automatically add it to matches even if the AI didn't select it. + * This ensures conversation tracking continues consistently throughout the thread. + * + * Note: The meta rule is still passed to the AI (in regularRules), which may + * influence it to select the rule naturally, but we enforce it regardless. + * + * Returns a new array of matches (does not mutate the input). + */ +async function ensureConversationRuleContinuity({ + emailAccountId, + threadId, + messageId, + conversationRules, + regularRules, + matches, +}: { + emailAccountId: string; + threadId: string; + messageId: string; + conversationRules: RuleWithActions[]; + regularRules: RuleWithActions[]; + matches: { rule: RuleWithActions; matchReasons?: MatchReason[] }[]; +}): Promise<{ rule: RuleWithActions; matchReasons?: MatchReason[] }[]> { + if (conversationRules.length === 0) { + return matches; + } + + const hadConversationRuleInThread = + await checkPreviousConversationRuleInThread({ + emailAccountId, + threadId, + messageId, + }); + + if (!hadConversationRuleInThread) { + return matches; + } + + const hasConversationMetaRuleInMatches = matches.some((match) => + isConversationRule(match.rule.id), + ); + + if (hasConversationMetaRuleInMatches) { + return matches; + } + + logger.info( + "Automatically adding conversation meta rule due to previous application in thread", + ); + + // Find the meta rule in regularRules + const metaRule = regularRules.find((r) => isConversationRule(r.id)); + + if (!metaRule) { + return matches; + } + + return [ + ...matches, + { + rule: metaRule, + matchReasons: [ + { + type: ConditionType.STATIC, + }, + ], + }, + ]; +} + +function isConversationRule(ruleId: string): boolean { + return ruleId === CONVERSATION_TRACKING_META_RULE_ID; +} From db35d0045168f01c86d0a472c2c0ad48b1f5572f Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Mon, 20 Oct 2025 08:38:59 +0300 Subject: [PATCH 20/40] tests --- .../utils/ai/choose-rule/match-rules.test.ts | 5 +- apps/web/utils/ai/choose-rule/match-rules.ts | 6 +- .../utils/ai/choose-rule/run-rules.test.ts | 215 ++++++++++++++++++ apps/web/utils/ai/choose-rule/run-rules.ts | 8 +- 4 files changed, 227 insertions(+), 7 deletions(-) create mode 100644 apps/web/utils/ai/choose-rule/run-rules.test.ts diff --git a/apps/web/utils/ai/choose-rule/match-rules.test.ts b/apps/web/utils/ai/choose-rule/match-rules.test.ts index 7fffcc4441..f6fedc4d6c 100644 --- a/apps/web/utils/ai/choose-rule/match-rules.test.ts +++ b/apps/web/utils/ai/choose-rule/match-rules.test.ts @@ -1522,6 +1522,7 @@ function getRule(overrides: Partial = {}): RuleWithActions { runOnThreads: true, conditionalOperator: LogicalOperator.AND, type: null, + systemType: null, ...overrides, } as RuleWithActions; } @@ -1748,7 +1749,7 @@ describe("findMatchingRules - Integration Tests", () => { }); vi.mocked(aiChooseRule).mockResolvedValue({ - rules: [aiRule as any], + rules: [{ rule: aiRule as any }], reason: "This is a promotional email", }); @@ -1884,7 +1885,7 @@ describe("findMatchingRules - Integration Tests", () => { }); vi.mocked(aiChooseRule).mockResolvedValue({ - rules: [mixedRule as any], + rules: [{ rule: mixedRule as any }], reason: "Email is promotional", }); diff --git a/apps/web/utils/ai/choose-rule/match-rules.ts b/apps/web/utils/ai/choose-rule/match-rules.ts index 05615d42a0..cb4fa172b3 100644 --- a/apps/web/utils/ai/choose-rule/match-rules.ts +++ b/apps/web/utils/ai/choose-rule/match-rules.ts @@ -545,8 +545,10 @@ export async function filterConversationStatusRules< export function filterMultipleSystemRules< T extends { name: string; instructions: string; systemType?: string | null }, >(selectedRules: { rule: T; isPrimary?: boolean }[]): T[] { - const systemRules = selectedRules.filter((r) => r.rule.systemType); - const conversationRules = selectedRules.filter((r) => !r.rule.systemType); + const systemRules = selectedRules.filter((r) => r.rule?.systemType); + const conversationRules = selectedRules.filter( + (r) => r.rule && !r.rule?.systemType, + ); let filteredSystemRules = systemRules; if (systemRules.length > 1) { diff --git a/apps/web/utils/ai/choose-rule/run-rules.test.ts b/apps/web/utils/ai/choose-rule/run-rules.test.ts new file mode 100644 index 0000000000..032db580e7 --- /dev/null +++ b/apps/web/utils/ai/choose-rule/run-rules.test.ts @@ -0,0 +1,215 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + ensureConversationRuleContinuity, + CONVERSATION_TRACKING_META_RULE_ID, +} from "./run-rules"; +import { ExecutedRuleStatus, SystemType } from "@prisma/client"; +import { ConditionType } from "@/utils/config"; +import prisma from "@/utils/__mocks__/prisma"; +import type { RuleWithActions } from "@/utils/types"; + +vi.mock("@/utils/prisma"); +vi.mock("server-only", () => ({})); + +describe("ensureConversationRuleContinuity", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const emailAccountId = "account-1"; + const threadId = "thread-1"; + const messageId = "msg-1"; + + const createRule = ( + id: string, + systemType: SystemType | null = null, + ): RuleWithActions => ({ + id, + name: `Rule ${id}`, + instructions: `Instructions for ${id}`, + enabled: true, + emailAccountId, + createdAt: new Date(), + updatedAt: new Date(), + actions: [], + runOnThreads: false, + from: null, + to: null, + subject: null, + body: null, + groupId: null, + conditionalOperator: "AND" as const, + systemType, + automate: true, + promptText: null, + }); + + const conversationMetaRule = createRule(CONVERSATION_TRACKING_META_RULE_ID); + const toReplyRule = createRule("to-reply-rule", SystemType.TO_REPLY); + const regularRule = createRule("regular-rule"); + + it("returns matches unchanged when there are no conversation rules", async () => { + const matches = [{ rule: regularRule }]; + + const result = await ensureConversationRuleContinuity({ + emailAccountId, + threadId, + messageId, + conversationRules: [], + regularRules: [regularRule], + matches, + }); + + expect(result).toEqual(matches); + expect(prisma.executedRule.findFirst).not.toHaveBeenCalled(); + }); + + it("returns matches unchanged when no previous conversation rule was applied in thread", async () => { + prisma.executedRule.findFirst.mockResolvedValue(null); + + const matches = [{ rule: regularRule }]; + + const result = await ensureConversationRuleContinuity({ + emailAccountId, + threadId, + messageId, + conversationRules: [toReplyRule], + regularRules: [regularRule, conversationMetaRule], + matches, + }); + + expect(result).toEqual(matches); + expect(prisma.executedRule.findFirst).toHaveBeenCalledWith({ + where: { + emailAccountId, + threadId, + messageId: { not: messageId }, + status: ExecutedRuleStatus.APPLIED, + rule: { + systemType: { + in: expect.arrayContaining([ + SystemType.TO_REPLY, + SystemType.AWAITING_REPLY, + SystemType.FYI, + SystemType.ACTIONED, + ]), + }, + }, + }, + select: { id: true }, + }); + }); + + it("returns matches unchanged when conversation meta rule is already in matches", async () => { + prisma.executedRule.findFirst.mockResolvedValue({ + id: "executed-rule-1", + } as any); + + const matches = [{ rule: conversationMetaRule }, { rule: regularRule }]; + + const result = await ensureConversationRuleContinuity({ + emailAccountId, + threadId, + messageId, + conversationRules: [toReplyRule], + regularRules: [regularRule, conversationMetaRule], + matches, + }); + + expect(result).toEqual(matches); + }); + + it("adds conversation meta rule when previous conversation rule was applied and meta rule not in matches", async () => { + prisma.executedRule.findFirst.mockResolvedValue({ + id: "executed-rule-1", + } as any); + + const matches = [{ rule: regularRule }]; + + const result = await ensureConversationRuleContinuity({ + emailAccountId, + threadId, + messageId, + conversationRules: [toReplyRule], + regularRules: [regularRule, conversationMetaRule], + matches, + }); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ rule: regularRule }); + expect(result[1]).toEqual({ + rule: conversationMetaRule, + matchReasons: [{ type: ConditionType.STATIC }], + }); + }); + + it("returns original matches when conversation meta rule cannot be found in regularRules", async () => { + prisma.executedRule.findFirst.mockResolvedValue({ + id: "executed-rule-1", + } as any); + + const matches = [{ rule: regularRule }]; + + const result = await ensureConversationRuleContinuity({ + emailAccountId, + threadId, + messageId, + conversationRules: [toReplyRule], + regularRules: [regularRule], // No meta rule + matches, + }); + + expect(result).toEqual(matches); + }); + + it("does not mutate the original matches array", async () => { + prisma.executedRule.findFirst.mockResolvedValue({ + id: "executed-rule-1", + } as any); + + const matches = [{ rule: regularRule }]; + const originalMatches = [...matches]; + + const result = await ensureConversationRuleContinuity({ + emailAccountId, + threadId, + messageId, + conversationRules: [toReplyRule], + regularRules: [regularRule, conversationMetaRule], + matches, + }); + + expect(matches).toEqual(originalMatches); + expect(result).not.toBe(matches); + }); + + it("queries database with correct parameters", async () => { + prisma.executedRule.findFirst.mockResolvedValue(null); + + const matches = [{ rule: regularRule }]; + + await ensureConversationRuleContinuity({ + emailAccountId, + threadId, + messageId, + conversationRules: [toReplyRule], + regularRules: [regularRule, conversationMetaRule], + matches, + }); + + expect(prisma.executedRule.findFirst).toHaveBeenCalledWith({ + where: { + emailAccountId, + threadId, + messageId: { not: messageId }, + status: ExecutedRuleStatus.APPLIED, + rule: { + systemType: { + in: expect.any(Array), + }, + }, + }, + select: { id: true }, + }); + }); +}); diff --git a/apps/web/utils/ai/choose-rule/run-rules.ts b/apps/web/utils/ai/choose-rule/run-rules.ts index b98a974f0a..e34bbb5f02 100644 --- a/apps/web/utils/ai/choose-rule/run-rules.ts +++ b/apps/web/utils/ai/choose-rule/run-rules.ts @@ -42,7 +42,7 @@ export type RunRulesResult = { existing?: boolean; }; -const CONVERSATION_TRACKING_META_RULE_ID = "conversation-tracking-meta"; +export const CONVERSATION_TRACKING_META_RULE_ID = "conversation-tracking-meta"; export async function runRules({ provider, @@ -105,7 +105,7 @@ export async function runRules({ let ruleToExecute = result.rule; let reasonToUse = results.reasoning; - if (isConversationRule(result.rule.id)) { + if (result.rule && isConversationRule(result.rule.id)) { // Determine which specific sub-rule applies const { specificRule, reason: statusReason } = await determineConversationStatus({ @@ -404,8 +404,10 @@ async function checkPreviousConversationRuleInThread({ * influence it to select the rule naturally, but we enforce it regardless. * * Returns a new array of matches (does not mutate the input). + * + * @internal Exported for testing */ -async function ensureConversationRuleContinuity({ +export async function ensureConversationRuleContinuity({ emailAccountId, threadId, messageId, From 7a332dc82d5d638e471a39673d4f4ea1f06074d7 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Mon, 20 Oct 2025 13:33:34 +0300 Subject: [PATCH 21/40] allow applying previously applied rules on threads --- .../utils/ai/choose-rule/match-rules.test.ts | 3 + apps/web/utils/ai/choose-rule/match-rules.ts | 83 ++++++++++++++++++- .../utils/ai/choose-rule/run-rules.test.ts | 10 +-- apps/web/utils/ai/choose-rule/run-rules.ts | 7 -- 4 files changed, 83 insertions(+), 20 deletions(-) diff --git a/apps/web/utils/ai/choose-rule/match-rules.test.ts b/apps/web/utils/ai/choose-rule/match-rules.test.ts index f6fedc4d6c..21b0c4aad9 100644 --- a/apps/web/utils/ai/choose-rule/match-rules.test.ts +++ b/apps/web/utils/ai/choose-rule/match-rules.test.ts @@ -1843,6 +1843,9 @@ describe("findMatchingRules - Integration Tests", () => { isReplyInThread: vi.fn().mockReturnValue(true), } as unknown as EmailProvider; + // Mock no previously executed rules in thread + prisma.executedRule.findMany.mockResolvedValue([]); + const rules = [threadRule]; const message = getMessage({ headers: getHeaders({ from: "test@example.com" }), diff --git a/apps/web/utils/ai/choose-rule/match-rules.ts b/apps/web/utils/ai/choose-rule/match-rules.ts index cb4fa172b3..131a717d5a 100644 --- a/apps/web/utils/ai/choose-rule/match-rules.ts +++ b/apps/web/utils/ai/choose-rule/match-rules.ts @@ -4,7 +4,11 @@ import { getGroupsWithRules, } from "@/utils/group/find-matching-group"; import type { ParsedMessage, RuleWithActions } from "@/utils/types"; -import { LogicalOperator, SystemType } from "@prisma/client"; +import { + ExecutedRuleStatus, + LogicalOperator, + SystemType, +} from "@prisma/client"; import { ConditionType } from "@/utils/config"; import prisma from "@/utils/prisma"; import { aiChooseRule } from "@/utils/ai/choose-rule/ai-choose-rule"; @@ -117,11 +121,13 @@ async function findPotentialMatchingRules({ message, isThread, provider, + emailAccountId, }: { rules: RuleWithActions[]; message: ParsedMessage; isThread: boolean; provider: EmailProvider; + emailAccountId: string; }): Promise { const matches: { rule: RuleWithActions; @@ -130,6 +136,10 @@ async function findPotentialMatchingRules({ const potentialAiMatches: (RuleWithActions & { instructions: string })[] = []; const learnedPatternsLoader = new LearnedPatternsLoader(); + const previousRulesLoader = new PreviousThreadRulesLoader({ + emailAccountId, + threadId: message.threadId, + }); // Go through all rules and collect matches and potential AI matches for (const rule of rules) { @@ -147,9 +157,6 @@ async function findPotentialMatchingRules({ continue; } - // TODO: still run this, if a previous email in the thread matched the rule - if (isThread && !rule.runOnThreads) continue; - // Learned patterns (groups) // Note: Groups are independent of the AND/OR operator (which only applies to AI/Static conditions) if (rule.groupId) { @@ -181,6 +188,17 @@ async function findPotentialMatchingRules({ } } + // Skip rules with runOnThreads=false, unless this rule was previously applied in the thread + // This ensures thread continuity (e.g., notifications continue to be labeled as notifications) + if (isThread && !rule.runOnThreads) { + const previousRuleIds = await previousRulesLoader.getRuleIds(); + const wasPreviouslyApplied = previousRuleIds.has(rule.id); + + if (!wasPreviouslyApplied) { + continue; + } + } + // AI + Static conditions const { matched, potentialAiMatch, matchReasons } = evaluateRuleConditions({ rule, @@ -287,6 +305,34 @@ class LearnedPatternsLoader { } } +// Lazy load previously executed rules in thread when needed +class PreviousThreadRulesLoader { + private ruleIds?: Set; + private readonly emailAccountId: string; + private readonly threadId: string; + + constructor({ + emailAccountId, + threadId, + }: { + emailAccountId: string; + threadId: string; + }) { + this.emailAccountId = emailAccountId; + this.threadId = threadId; + } + + async getRuleIds(): Promise> { + if (this.ruleIds === undefined) { + this.ruleIds = await getPreviouslyExecutedRuleIds({ + emailAccountId: this.emailAccountId, + threadId: this.threadId, + }); + } + return this.ruleIds; + } +} + function getMatchReason(matchReasons?: MatchReason[]): string | undefined { if (!matchReasons || matchReasons.length === 0) return; @@ -318,6 +364,7 @@ async function findMatchingRulesWithReasons( message, isThread, provider, + emailAccountId: emailAccount.id, }); if (potentialAiMatches.length) { @@ -559,3 +606,31 @@ export function filterMultipleSystemRules< return [...filteredSystemRules, ...conversationRules].map((r) => r.rule); } + +/** + * Gets the IDs of rules that were previously executed in this thread. + * This allows us to continue applying the same rules to a thread for consistency, + * even if `runOnThreads` is false. + */ +async function getPreviouslyExecutedRuleIds({ + emailAccountId, + threadId, +}: { + emailAccountId: string; + threadId: string; +}): Promise> { + const previousRules = await prisma.executedRule.findMany({ + where: { + emailAccountId, + threadId, + status: ExecutedRuleStatus.APPLIED, + ruleId: { not: null }, + }, + select: { ruleId: true }, + distinct: ["ruleId"], + }); + + return new Set( + previousRules.map((r) => r.ruleId).filter((id): id is string => !!id), + ); +} diff --git a/apps/web/utils/ai/choose-rule/run-rules.test.ts b/apps/web/utils/ai/choose-rule/run-rules.test.ts index 032db580e7..1b9534cf4c 100644 --- a/apps/web/utils/ai/choose-rule/run-rules.test.ts +++ b/apps/web/utils/ai/choose-rule/run-rules.test.ts @@ -42,6 +42,7 @@ describe("ensureConversationRuleContinuity", () => { systemType, automate: true, promptText: null, + categoryFilterType: null, }); const conversationMetaRule = createRule(CONVERSATION_TRACKING_META_RULE_ID); @@ -54,7 +55,6 @@ describe("ensureConversationRuleContinuity", () => { const result = await ensureConversationRuleContinuity({ emailAccountId, threadId, - messageId, conversationRules: [], regularRules: [regularRule], matches, @@ -72,7 +72,6 @@ describe("ensureConversationRuleContinuity", () => { const result = await ensureConversationRuleContinuity({ emailAccountId, threadId, - messageId, conversationRules: [toReplyRule], regularRules: [regularRule, conversationMetaRule], matches, @@ -83,7 +82,6 @@ describe("ensureConversationRuleContinuity", () => { where: { emailAccountId, threadId, - messageId: { not: messageId }, status: ExecutedRuleStatus.APPLIED, rule: { systemType: { @@ -110,7 +108,6 @@ describe("ensureConversationRuleContinuity", () => { const result = await ensureConversationRuleContinuity({ emailAccountId, threadId, - messageId, conversationRules: [toReplyRule], regularRules: [regularRule, conversationMetaRule], matches, @@ -129,7 +126,6 @@ describe("ensureConversationRuleContinuity", () => { const result = await ensureConversationRuleContinuity({ emailAccountId, threadId, - messageId, conversationRules: [toReplyRule], regularRules: [regularRule, conversationMetaRule], matches, @@ -153,7 +149,6 @@ describe("ensureConversationRuleContinuity", () => { const result = await ensureConversationRuleContinuity({ emailAccountId, threadId, - messageId, conversationRules: [toReplyRule], regularRules: [regularRule], // No meta rule matches, @@ -173,7 +168,6 @@ describe("ensureConversationRuleContinuity", () => { const result = await ensureConversationRuleContinuity({ emailAccountId, threadId, - messageId, conversationRules: [toReplyRule], regularRules: [regularRule, conversationMetaRule], matches, @@ -191,7 +185,6 @@ describe("ensureConversationRuleContinuity", () => { await ensureConversationRuleContinuity({ emailAccountId, threadId, - messageId, conversationRules: [toReplyRule], regularRules: [regularRule, conversationMetaRule], matches, @@ -201,7 +194,6 @@ describe("ensureConversationRuleContinuity", () => { where: { emailAccountId, threadId, - messageId: { not: messageId }, status: ExecutedRuleStatus.APPLIED, rule: { systemType: { diff --git a/apps/web/utils/ai/choose-rule/run-rules.ts b/apps/web/utils/ai/choose-rule/run-rules.ts index e34bbb5f02..d6dd10dd2d 100644 --- a/apps/web/utils/ai/choose-rule/run-rules.ts +++ b/apps/web/utils/ai/choose-rule/run-rules.ts @@ -73,7 +73,6 @@ export async function runRules({ const finalMatches = await ensureConversationRuleContinuity({ emailAccountId: emailAccount.id, threadId: message.threadId, - messageId: message.id, conversationRules, regularRules, matches: results.matches, @@ -374,17 +373,14 @@ function shouldAnalyzeSenderPattern({ async function checkPreviousConversationRuleInThread({ emailAccountId, threadId, - messageId, }: { emailAccountId: string; threadId: string; - messageId: string; }): Promise { const previousConversationRule = await prisma.executedRule.findFirst({ where: { emailAccountId, threadId, - messageId: { not: messageId }, status: ExecutedRuleStatus.APPLIED, rule: { systemType: { in: CONVERSATION_STATUS_TYPES } }, }, @@ -410,14 +406,12 @@ async function checkPreviousConversationRuleInThread({ export async function ensureConversationRuleContinuity({ emailAccountId, threadId, - messageId, conversationRules, regularRules, matches, }: { emailAccountId: string; threadId: string; - messageId: string; conversationRules: RuleWithActions[]; regularRules: RuleWithActions[]; matches: { rule: RuleWithActions; matchReasons?: MatchReason[] }[]; @@ -430,7 +424,6 @@ export async function ensureConversationRuleContinuity({ await checkPreviousConversationRuleInThread({ emailAccountId, threadId, - messageId, }); if (!hadConversationRuleInThread) { From fdfe9e6884b58b5f246f346c9bff27e8fec22c57 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Mon, 20 Oct 2025 14:13:17 +0300 Subject: [PATCH 22/40] add more tests for match rules --- .../utils/ai/choose-rule/match-rules.test.ts | 385 ++++++++++++++++++ .../utils/ai/choose-rule/run-rules.test.ts | 1 - 2 files changed, 385 insertions(+), 1 deletion(-) diff --git a/apps/web/utils/ai/choose-rule/match-rules.test.ts b/apps/web/utils/ai/choose-rule/match-rules.test.ts index 21b0c4aad9..ad17baf76c 100644 --- a/apps/web/utils/ai/choose-rule/match-rules.test.ts +++ b/apps/web/utils/ai/choose-rule/match-rules.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +import { filterMultipleSystemRules } from "./match-rules"; import { findMatchingRules, matchesStaticRule, @@ -1864,6 +1865,261 @@ describe("findMatchingRules - Integration Tests", () => { expect(result.matches).toHaveLength(0); }); + describe("filterMultipleSystemRules branches", () => { + it("returns all system rules when none marked primary (plus conversation rules)", () => { + const sysA: { + name: string; + instructions: string; + systemType: string | null; + } = { + name: "Sys A", + instructions: "", + systemType: "TO_REPLY", + }; + const sysB: { + name: string; + instructions: string; + systemType: string | null; + } = { + name: "Sys B", + instructions: "", + systemType: "AWAITING_REPLY", + }; + const conv: { + name: string; + instructions: string; + systemType: string | null; + } = { + name: "Conv", + instructions: "", + systemType: null, + }; + + const result = filterMultipleSystemRules([ + { rule: sysA, isPrimary: false }, + { rule: sysB }, + { rule: conv }, + ]); + + expect(result).toEqual([sysA, sysB, conv]); + }); + + it("keeps only the primary system rule when multiple system rules present", () => { + const sysA: { + name: string; + instructions: string; + systemType: string | null; + } = { + name: "Sys A", + instructions: "", + systemType: "TO_REPLY", + }; + const sysB: { + name: string; + instructions: string; + systemType: string | null; + } = { + name: "Sys B", + instructions: "", + systemType: "AWAITING_REPLY", + }; + const conv: { + name: string; + instructions: string; + systemType: string | null; + } = { + name: "Conv", + instructions: "", + systemType: null, + }; + + const result = filterMultipleSystemRules([ + { rule: sysA, isPrimary: false }, + { rule: sysB, isPrimary: true }, + { rule: conv }, + ]); + + expect(result).toEqual([sysB, conv]); + }); + }); + + describe("Group rules fallthrough when no groups exist", () => { + it("falls through to static/AI evaluation when getGroupsWithRules returns empty", async () => { + const groupRule = getRule({ + id: "group-rule-1", + from: "group@example.com", + groupId: "g1", + }); + + // Ensure provider treats this as non-thread + const providerNoThread = { + isReplyInThread: vi.fn().mockReturnValue(false), + } as unknown as EmailProvider; + + // Mock groups to be empty so the code path skips learned pattern branch + const groupModule = await import("@/utils/group/find-matching-group"); + vi.spyOn(groupModule, "getGroupsWithRules").mockResolvedValue([] as any); + + const rules = [groupRule]; + const message = getMessage({ + headers: getHeaders({ from: "group@example.com" }), + }); + const emailAccount = getEmailAccount(); + + const result = await findMatchingRules({ + rules, + message, + emailAccount, + provider: providerNoThread, + modelType: "default", + }); + + // Should match via static evaluation since groups are empty + expect(result.matches).toHaveLength(1); + expect(result.matches[0]?.rule.id).toBe("group-rule-1"); + }); + }); + describe("Thread continuity - runOnThreads=false rules", () => { + it("should continue applying rule in a thread when it was previously applied", async () => { + const notifRule = getRule({ + id: "notif-rule", + from: "notif@example.com", + runOnThreads: false, + }); + + // Mock provider to indicate this is a thread + const threadProvider = { + isReplyInThread: vi.fn().mockReturnValue(true), + } as unknown as EmailProvider; + + // Mock DB to return previously executed rule id + prisma.executedRule.findMany.mockResolvedValue([ + { ruleId: "notif-rule" }, + ] as any); + + const rules = [notifRule]; + const message = getMessage({ + headers: getHeaders({ from: "notif@example.com" }), + }); + const emailAccount = getEmailAccount(); + + const result = await findMatchingRules({ + rules, + message, + emailAccount, + provider: threadProvider, + modelType: "default", + }); + + expect(prisma.executedRule.findMany).toHaveBeenCalledTimes(1); + expect(result.matches).toHaveLength(1); + expect(result.matches[0]?.rule.id).toBe("notif-rule"); + }); + + it("should lazy-load previous rules only once for multiple runOnThreads=false rules", async () => { + const ruleA = getRule({ + id: "rule-a", + from: "multi@example.com", + runOnThreads: false, + }); + const ruleB = getRule({ + id: "rule-b", + from: "multi@example.com", + runOnThreads: false, + }); + + const threadProvider = { + isReplyInThread: vi.fn().mockReturnValue(true), + } as unknown as EmailProvider; + + prisma.executedRule.findMany.mockResolvedValue([ + { ruleId: "rule-a" }, + { ruleId: "rule-b" }, + ] as any); + + const rules = [ruleA, ruleB]; + const message = getMessage({ + headers: getHeaders({ from: "multi@example.com" }), + }); + const emailAccount = getEmailAccount(); + + const result = await findMatchingRules({ + rules, + message, + emailAccount, + provider: threadProvider, + modelType: "default", + }); + + expect(prisma.executedRule.findMany).toHaveBeenCalledTimes(1); + expect(result.matches.map((m) => m.rule.id).sort()).toEqual([ + "rule-a", + "rule-b", + ]); + }); + + it("should not query DB when message is not a thread", async () => { + const notifRule = getRule({ + id: "not-thread", + from: "no-thread@example.com", + runOnThreads: false, + }); + + const providerNotThread = { + isReplyInThread: vi.fn().mockReturnValue(false), + } as unknown as EmailProvider; + + const rules = [notifRule]; + const message = getMessage({ + headers: getHeaders({ from: "no-thread@example.com" }), + }); + const emailAccount = getEmailAccount(); + + const result = await findMatchingRules({ + rules, + message, + emailAccount, + provider: providerNotThread, + modelType: "default", + }); + + expect(prisma.executedRule.findMany).not.toHaveBeenCalled(); + // Not a thread, so normal matching applies (matches by static from) + expect(result.matches).toHaveLength(1); + expect(result.matches[0]?.rule.id).toBe("not-thread"); + }); + + it("should not query DB when rule has runOnThreads=true (even in a thread)", async () => { + const threadRule = getRule({ + id: "thread-ok", + from: "yes-thread@example.com", + runOnThreads: true, + }); + + const threadProvider = { + isReplyInThread: vi.fn().mockReturnValue(true), + } as unknown as EmailProvider; + + const rules = [threadRule]; + const message = getMessage({ + headers: getHeaders({ from: "yes-thread@example.com" }), + }); + const emailAccount = getEmailAccount(); + + const result = await findMatchingRules({ + rules, + message, + emailAccount, + provider: threadProvider, + modelType: "default", + }); + + expect(prisma.executedRule.findMany).not.toHaveBeenCalled(); + expect(result.matches).toHaveLength(1); + expect(result.matches[0]?.rule.id).toBe("thread-ok"); + }); + }); + it("should handle invalid regex patterns gracefully", () => { const rule = getRule({ from: "[invalid(regex", @@ -1910,6 +2166,135 @@ describe("findMatchingRules - Integration Tests", () => { expect(aiChooseRule).toHaveBeenCalled(); expect(result.matches[0]?.rule.id).toBe("mixed-rule"); }); + + it("merges static match with AI rule and combines reasoning text", async () => { + const staticRule = getRule({ + id: "static-rule-1", + from: "reason@example.com", + }); + const aiOnlyRule = getRule({ id: "ai-rule-2", instructions: "Do X" }); + + // Ensure potentialAiMatches includes aiOnlyRule + vi.mocked(aiChooseRule).mockResolvedValue({ + rules: [aiOnlyRule as any], + reason: "AI reasoning here", + }); + + const rules = [staticRule, aiOnlyRule]; + const message = getMessage({ + headers: getHeaders({ from: "reason@example.com" }), + }); + const emailAccount = getEmailAccount(); + + const result = await findMatchingRules({ + rules, + message, + emailAccount, + provider, + modelType: "default", + }); + + // Reasoning should combine existing matchReasons text + AI reason + // existing part comes from getMatchReason => "Matched static conditions" + expect(result.reasoning).toBe( + "Matched static conditions; AI reasoning here", + ); + }); + + it("matchesStaticRule: catches RegExp construction error and returns false", () => { + const rule = getRule({ from: "trigger-error" }); + const message = getMessage({ + headers: getHeaders({ from: "any@example.com" }), + }); + + const OriginalRegExp = RegExp; + // Monkeypatch RegExp to throw for our specific pattern + // Only for this test; restore afterwards + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any).RegExp = ((pattern: string) => { + if (pattern.includes("trigger-error")) { + throw new Error("synthetic error"); + } + // Delegate to original + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return new (OriginalRegExp as any)(pattern); + }) as unknown as RegExpConstructor; + + try { + const matched = matchesStaticRule(rule as any, message as any); + expect(matched).toBe(false); + } finally { + // restore + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any).RegExp = + OriginalRegExp as unknown as RegExpConstructor; + } + }); + + it("AI path: returns only AI reasoning when no static matches and AI returns no rules", async () => { + const aiOnlyRule = getRule({ id: "ai-only-1", instructions: "Do Y" }); + + vi.mocked(aiChooseRule).mockResolvedValue({ + rules: [], + reason: "AI had reasoning but selected nothing", + }); + + const rules = [aiOnlyRule]; + const message = getMessage({ + // No static matchers + headers: getHeaders({ from: "nobody@example.com" }), + }); + const emailAccount = getEmailAccount(); + + const result = await findMatchingRules({ + rules, + message, + emailAccount, + provider, + modelType: "default", + }); + + expect(result.matches.map((m) => m.rule.id)).toEqual([]); + expect(result.reasoning).toBe("AI had reasoning but selected nothing"); + }); + + it("AI path: dedups AI-selected rule when it duplicates a static match", async () => { + const dupRule = getRule({ + id: "dup-rule", + from: "dup@example.com", + instructions: "Use AI too", + runOnThreads: true, + }); + + vi.mocked(aiChooseRule).mockResolvedValue({ + rules: [{ rule: dupRule as any }], + reason: "AI selects dup-rule", + }); + + const rules = [dupRule]; + const message = getMessage({ + headers: getHeaders({ from: "dup@example.com" }), + }); + const emailAccount = getEmailAccount(); + + const spy = vi.spyOn(provider, "isReplyInThread").mockReturnValue(false); + try { + const result = await findMatchingRules({ + rules, + message, + emailAccount, + provider, + modelType: "default", + }); + + // Only one occurrence of dup-rule should remain + const ids = result.matches.map((m) => m.rule.id); + expect(ids).toEqual(["dup-rule"]); + expect(result.reasoning).toContain("AI selects dup-rule"); + } finally { + spy.mockRestore(); + } + }); }); describe("evaluateRuleConditions", () => { diff --git a/apps/web/utils/ai/choose-rule/run-rules.test.ts b/apps/web/utils/ai/choose-rule/run-rules.test.ts index 1b9534cf4c..9e7b93ff63 100644 --- a/apps/web/utils/ai/choose-rule/run-rules.test.ts +++ b/apps/web/utils/ai/choose-rule/run-rules.test.ts @@ -18,7 +18,6 @@ describe("ensureConversationRuleContinuity", () => { const emailAccountId = "account-1"; const threadId = "thread-1"; - const messageId = "msg-1"; const createRule = ( id: string, From 121d762240a5d0a229fe25774409c17688d7ed6e Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Mon, 20 Oct 2025 14:47:20 +0300 Subject: [PATCH 23/40] update ui components --- apps/web/components/ExpandableText.tsx | 2 +- apps/web/components/ProgressPanel.tsx | 2 +- apps/web/components/TabSelect.tsx | 2 +- apps/web/components/ai-elements/actions.tsx | 4 +- .../web/components/ai-elements/code-block.tsx | 2 +- .../components/ai-elements/conversation.tsx | 37 +++++- apps/web/components/ai-elements/loader.tsx | 2 +- apps/web/components/ai-elements/message.tsx | 2 +- apps/web/components/ai-elements/reasoning.tsx | 9 +- apps/web/components/ai-elements/response.tsx | 2 +- apps/web/components/ai-elements/shimmer.tsx | 64 ++++++++++ .../web/components/ai-elements/suggestion.tsx | 2 +- apps/web/components/ai-elements/tool.tsx | 25 +++- apps/web/components/ui/alert-dialog.tsx | 2 +- apps/web/components/ui/form.tsx | 2 +- apps/web/components/ui/input.tsx | 2 +- apps/web/components/ui/progress.tsx | 2 +- apps/web/components/ui/scroll-area.tsx | 4 +- apps/web/components/ui/select.tsx | 51 +++++++- apps/web/package.json | 4 +- pnpm-lock.yaml | 109 +++++++++++++++++- 21 files changed, 292 insertions(+), 39 deletions(-) create mode 100644 apps/web/components/ai-elements/shimmer.tsx diff --git a/apps/web/components/ExpandableText.tsx b/apps/web/components/ExpandableText.tsx index 28c9b34123..b1d98c9db9 100644 --- a/apps/web/components/ExpandableText.tsx +++ b/apps/web/components/ExpandableText.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"; -import { motion } from "framer-motion"; +import { motion } from "motion/react"; import { cn } from "@/utils"; export function ExpandableText({ diff --git a/apps/web/components/ProgressPanel.tsx b/apps/web/components/ProgressPanel.tsx index e4485aeb95..75fc327d97 100644 --- a/apps/web/components/ProgressPanel.tsx +++ b/apps/web/components/ProgressPanel.tsx @@ -1,6 +1,6 @@ "use client"; -import { AnimatePresence, motion } from "framer-motion"; +import { AnimatePresence, motion } from "motion/react"; import { ProgressBar } from "@tremor/react"; import { cn } from "@/utils"; import { LoadingMiniSpinner } from "@/components/Loading"; diff --git a/apps/web/components/TabSelect.tsx b/apps/web/components/TabSelect.tsx index f6a315b219..fc0f9d040e 100644 --- a/apps/web/components/TabSelect.tsx +++ b/apps/web/components/TabSelect.tsx @@ -10,7 +10,7 @@ */ import { cn } from "@/utils"; import { cva, type VariantProps } from "class-variance-authority"; -import { LayoutGroup, motion } from "framer-motion"; +import { LayoutGroup, motion } from "motion/react"; import Link from "next/link"; import { type Dispatch, type SetStateAction, useId } from "react"; import { ArrowUpRight } from "lucide-react"; diff --git a/apps/web/components/ai-elements/actions.tsx b/apps/web/components/ai-elements/actions.tsx index 76fbfeeee5..1d189e8a89 100644 --- a/apps/web/components/ai-elements/actions.tsx +++ b/apps/web/components/ai-elements/actions.tsx @@ -7,7 +7,7 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; -import { cn } from "@/utils"; +import { cn } from "@/utils/index"; import type { ComponentProps } from "react"; export type ActionsProps = ComponentProps<"div">; @@ -35,7 +35,7 @@ export const Action = ({ const button = ( + + + ) : null} { e.preventDefault(); - if (input.trim() && status === "ready") { + if ((input.trim() || nextContext) && status === "ready") { handleSubmit(); setLocalStorageInput(""); } @@ -105,7 +130,7 @@ export function Chat() { ? "submitted" : "ready" } - disabled={!input.trim() || status !== "ready"} + disabled={(!input.trim() && !nextContext) || status !== "ready"} className="absolute bottom-1 right-1" onClick={(e) => { if (status === "streaming") { diff --git a/apps/web/providers/ChatProvider.tsx b/apps/web/providers/ChatProvider.tsx index 86854135dd..3b2b7b9dde 100644 --- a/apps/web/providers/ChatProvider.tsx +++ b/apps/web/providers/ChatProvider.tsx @@ -17,6 +17,7 @@ import type { ChatMessage } from "@/components/assistant-chat/types"; import { useChatMessages } from "@/hooks/useChatMessages"; import { useAccount } from "@/providers/EmailAccountProvider"; import { EMAIL_ACCOUNT_HEADER } from "@/utils/config"; +import type { MessageContext } from "@/app/api/chat/validation"; export type Chat = ReturnType>; @@ -28,6 +29,8 @@ type ChatContextType = { setChatId: (chatId: string | null) => void; setNewChat: () => void; handleSubmit: () => void; + context: MessageContext | null; + setContext: (context: MessageContext | null) => void; }; const ChatContext = createContext(undefined); @@ -38,6 +41,7 @@ export function ChatProvider({ children }: { children: React.ReactNode }) { const [input, setInput] = useState(""); const [chatId, setChatId] = useQueryState("chatId", parseAsString); + const [nextContext, setNextContext] = useState(null); const { data } = useChatMessages(chatId); @@ -57,6 +61,7 @@ export function ChatProvider({ children }: { children: React.ReactNode }) { body: { id, message: messages.at(-1), + context: nextContext ?? undefined, ...body, }, }; @@ -88,13 +93,13 @@ export function ChatProvider({ children }: { children: React.ReactNode }) { parts: [ { type: "text", - text: input, + text: input.trim(), }, ], }); setInput(""); - }, [chat.sendMessage, input]); + }, [chat.sendMessage, input, nextContext]); return ( {children} diff --git a/apps/web/utils/ai/assistant/chat.ts b/apps/web/utils/ai/assistant/chat.ts index 4d183dbc7f..ac82f36e46 100644 --- a/apps/web/utils/ai/assistant/chat.ts +++ b/apps/web/utils/ai/assistant/chat.ts @@ -17,6 +17,10 @@ import { chatCompletionStream } from "@/utils/llms"; import { filterNullProperties } from "@/utils"; import { delayInMinutesSchema } from "@/utils/actions/rule.validation"; import { isMicrosoftProvider } from "@/utils/email/provider-types"; +import type { MessageContext } from "@/app/api/chat/validation"; +import { stringifyEmail } from "@/utils/stringify-email"; +import { getEmailForLLM } from "@/utils/get-email-from-message"; +import type { ParsedMessage } from "@/utils/types"; const logger = createScopedLogger("ai/assistant/chat"); @@ -662,10 +666,12 @@ export async function aiProcessAssistantChat({ messages, emailAccountId, user, + context, }: { messages: ModelMessage[]; emailAccountId: string; user: EmailAccountWithAI; + context?: MessageContext; }) { const system = `You are an assistant that helps create and update rules to manage a user's inbox. Our platform is called Inbox Zero. @@ -919,6 +925,33 @@ Examples: provider: user.account.provider, }; + const hiddenContextMessage = + context && context.type === "fix-rule" + ? [ + { + role: "system" as const, + content: + "Hidden context for the user's request (do not repeat this to the user):\n\n" + + `\n${stringifyEmail( + getEmailForLLM(context.message as ParsedMessage, { + maxLength: 3000, + }), + 3000, + )}\n\n\n` + + `Rules that were applied:\n${context.results + .map((r) => `- ${r.ruleName ?? "None"}: ${r.reason}`) + .join("\n")}\n\n` + + `Expected outcome: ${ + context.expected === "new" + ? "Create a new rule" + : context.expected === "none" + ? "No rule should be applied" + : `Should match the "${context.expected.name}" rule` + }`, + }, + ] + : []; + const result = chatCompletionStream({ userAi: user.user, userEmail: user.email, @@ -929,6 +962,7 @@ Examples: role: "system", content: system, }, + ...hiddenContextMessage, ...messages, ], onStepFinish: async ({ text, toolCalls }) => { From ebc920264f1bab4f0e902cc141134a557a8461f7 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Tue, 21 Oct 2025 13:37:07 +0300 Subject: [PATCH 33/40] fix ai tests --- apps/web/__tests__/ai-choose-rule.test.ts | 185 ++++++++++++++++++---- apps/web/__tests__/helpers.ts | 8 +- 2 files changed, 158 insertions(+), 35 deletions(-) diff --git a/apps/web/__tests__/ai-choose-rule.test.ts b/apps/web/__tests__/ai-choose-rule.test.ts index bb1a03a934..a846598abc 100644 --- a/apps/web/__tests__/ai-choose-rule.test.ts +++ b/apps/web/__tests__/ai-choose-rule.test.ts @@ -40,9 +40,13 @@ describe.runIf(isAiTest)("aiChooseRule", () => { test("Should return correct rule when multiple rules passed", async () => { const rule1 = getRule( "Match emails that have the word 'test' in the subject line", + [], + "Test emails", ); const rule2 = getRule( "Match emails that have the word 'remember' in the subject line", + [], + "Remember emails", ); const result = await aiChooseRule({ @@ -59,27 +63,33 @@ describe.runIf(isAiTest)("aiChooseRule", () => { test("Should select the correct rule and provide a reason", async () => { const rule1 = getRule( "Match emails that have the word 'question' in the subject line", + [], + "Question emails", + ); + const rule2 = getRule( + "Match emails asking for a joke", + [ + { + id: "id", + createdAt: new Date(), + updatedAt: new Date(), + type: ActionType.REPLY, + ruleId: "ruleId", + label: null, + labelId: null, + subject: null, + content: "{{Write a joke}}", + to: null, + cc: null, + bcc: null, + url: null, + folderName: null, + delayInMinutes: null, + folderId: null, + }, + ], + "Joke requests", ); - const rule2 = getRule("Match emails asking for a joke", [ - { - id: "id", - createdAt: new Date(), - updatedAt: new Date(), - type: ActionType.REPLY, - ruleId: "ruleId", - label: null, - labelId: null, - subject: null, - content: "{{Write a joke}}", - to: null, - cc: null, - bcc: null, - url: null, - folderName: null, - delayInMinutes: null, - folderId: null, - }, - ]); const result = await aiChooseRule({ rules: [rule1, rule2], @@ -98,37 +108,69 @@ describe.runIf(isAiTest)("aiChooseRule", () => { describe("Complex real-world rule scenarios", () => { const recruiters = getRule( "Match emails from recruiters or about job opportunities", + [], + "Recruiters", ); const legal = getRule( "Match emails containing legal documents or contracts", + [], + "Legal", + ); + const requiresResponse = getRule( + "Match emails requiring a response", + [], + "Requires Response", ); - const requiresResponse = getRule("Match emails requiring a response"); const productUpdates = getRule( "Match emails about product updates or feature announcements", + [], + "Product Updates", ); const financial = getRule( "Match emails containing financial information or invoices", + [], + "Financial", ); const technicalIssues = getRule( "Match emails about technical issues like server downtime or bug reports", + [], + "Technical Issues", ); const marketing = getRule( "Match emails containing marketing or promotional content", + [], + "Marketing", ); const teamUpdates = getRule( "Match emails about team updates or internal communications", + [], + "Team Updates", ); const customerFeedback = getRule( "Match emails about customer feedback or support requests", + [], + "Customer Feedback", ); const events = getRule( "Match emails containing event invitations or RSVPs", + [], + "Events", ); const projectDeadlines = getRule( "Match emails about project deadlines or milestones", + [], + "Project Deadlines", + ); + const urgent = getRule( + "Match urgent emails requiring immediate attention", + [], + "Urgent", + ); + const catchAll = getRule( + "Match emails that don't fit any other category", + [], + "Catch All", ); - const urgent = getRule("Match urgent emails requiring immediate attention"); - const catchAll = getRule("Match emails that don't fit any other category"); const rules = [ recruiters, @@ -173,8 +215,23 @@ describe.runIf(isAiTest)("aiChooseRule", () => { emailAccount: getEmailAccount(), }); - expect(result.rules).toHaveLength(1); - expect(result.rules[0].rule).toEqual(technicalIssues); + // Log if multiple rules were matched + if (result.rules.length > 1) { + console.log("⚠️ Technical Issues test matched multiple rules:"); + console.log( + result.rules.map((r) => ({ + name: r.rule.name, + isPrimary: r.isPrimary, + })), + ); + console.log("Reasoning:", result.reason); + } + + // AI may match multiple rules (e.g., Technical Issues + Urgent) + // Verify the primary match is Technical Issues + const primaryRule = result.rules.find((r) => r.isPrimary); + expect(primaryRule).toBeDefined(); + expect(primaryRule?.rule).toEqual(technicalIssues); expect(result.reason).toBeTruthy(); }); @@ -219,8 +276,23 @@ describe.runIf(isAiTest)("aiChooseRule", () => { emailAccount: getEmailAccount(), }); - expect(result.rules).toHaveLength(1); - expect(result.rules[0].rule).toEqual(legal); + // Log if multiple rules were matched + if (result.rules.length > 1) { + console.log("⚠️ Legal Documents test matched multiple rules:"); + console.log( + result.rules.map((r) => ({ + name: r.rule.name, + isPrimary: r.isPrimary, + })), + ); + console.log("Reasoning:", result.reason); + } + + // AI may match multiple rules (e.g., Legal + Requires Response) + // Verify the primary match is Legal + const primaryRule = result.rules.find((r) => r.isPrimary); + expect(primaryRule).toBeDefined(); + expect(primaryRule?.rule).toEqual(legal); expect(result.reason).toBeTruthy(); }); @@ -234,8 +306,25 @@ describe.runIf(isAiTest)("aiChooseRule", () => { emailAccount: getEmailAccount(), }); - expect(result.rules).toHaveLength(1); - expect(result.rules[0].rule).toEqual(requiresResponse); + // Log if multiple rules were matched + if (result.rules.length > 1) { + console.log( + "⚠️ Emails Requiring Response test matched multiple rules:", + ); + console.log( + result.rules.map((r) => ({ + name: r.rule.name, + isPrimary: r.isPrimary, + })), + ); + console.log("Reasoning:", result.reason); + } + + // AI may match multiple rules (e.g., Requires Response + Team Updates) + // Verify the primary match is Requires Response + const primaryRule = result.rules.find((r) => r.isPrimary); + expect(primaryRule).toBeDefined(); + expect(primaryRule?.rule).toEqual(requiresResponse); expect(result.reason).toBeTruthy(); }); @@ -294,8 +383,23 @@ describe.runIf(isAiTest)("aiChooseRule", () => { emailAccount: getEmailAccount(), }); - expect(result.rules).toHaveLength(1); - expect(result.rules[0].rule).toEqual(customerFeedback); + // Log if multiple rules were matched + if (result.rules.length > 1) { + console.log("⚠️ Customer Feedback test matched multiple rules:"); + console.log( + result.rules.map((r) => ({ + name: r.rule.name, + isPrimary: r.isPrimary, + })), + ); + console.log("Reasoning:", result.reason); + } + + // AI may match multiple rules (e.g., Customer Feedback + Technical Issues + Requires Response) + // Verify the primary match is Customer Feedback + const primaryRule = result.rules.find((r) => r.isPrimary); + expect(primaryRule).toBeDefined(); + expect(primaryRule?.rule).toEqual(customerFeedback); expect(result.reason).toBeTruthy(); }); @@ -309,8 +413,23 @@ describe.runIf(isAiTest)("aiChooseRule", () => { emailAccount: getEmailAccount(), }); - expect(result.rules).toHaveLength(1); - expect(result.rules[0].rule).toEqual(events); + // Log if multiple rules were matched + if (result.rules.length > 1) { + console.log("⚠️ Event Invitations test matched multiple rules:"); + console.log( + result.rules.map((r) => ({ + name: r.rule.name, + isPrimary: r.isPrimary, + })), + ); + console.log("Reasoning:", result.reason); + } + + // AI may match multiple rules (e.g., Events + Requires Response) + // Verify the primary match is Events + const primaryRule = result.rules.find((r) => r.isPrimary); + expect(primaryRule).toBeDefined(); + expect(primaryRule?.rule).toEqual(events); expect(result.reason).toBeTruthy(); }); }); diff --git a/apps/web/__tests__/helpers.ts b/apps/web/__tests__/helpers.ts index 3207be1440..9c418220fd 100644 --- a/apps/web/__tests__/helpers.ts +++ b/apps/web/__tests__/helpers.ts @@ -41,10 +41,14 @@ export function getEmail({ }; } -export function getRule(instructions: string, actions: Action[] = []) { +export function getRule( + instructions: string, + actions: Action[] = [], + name?: string, +) { return { instructions, - name: "Joke requests", + name: name || "Joke requests", actions, id: "id", userId: "userId", From cf18bdec9c0175cc2f1199d61f68869ec6600856 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Tue, 21 Oct 2025 13:41:21 +0300 Subject: [PATCH 34/40] update sort and tests --- apps/web/__tests__/ai-choose-rule.test.ts | 31 +++++++++++++++++++ .../assistant/ProcessResultDisplay.tsx | 9 ++++-- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/apps/web/__tests__/ai-choose-rule.test.ts b/apps/web/__tests__/ai-choose-rule.test.ts index a846598abc..687c82da5b 100644 --- a/apps/web/__tests__/ai-choose-rule.test.ts +++ b/apps/web/__tests__/ai-choose-rule.test.ts @@ -432,5 +432,36 @@ describe.runIf(isAiTest)("aiChooseRule", () => { expect(primaryRule?.rule).toEqual(events); expect(result.reason).toBeTruthy(); }); + + test("Should return no match when email doesn't fit any rule", async () => { + // Use a subset of rules WITHOUT the catch-all rule to test true no-match scenario + const rulesWithoutCatchAll = [ + recruiters, + legal, + productUpdates, + financial, + technicalIssues, + marketing, + teamUpdates, + customerFeedback, + events, + projectDeadlines, + ]; + + const result = await aiChooseRule({ + rules: rulesWithoutCatchAll, + email: getEmail({ + subject: "Weather Update: Sunny skies ahead", + content: + "Today's forecast: Clear skies with temperatures reaching 75°F. Perfect day for outdoor activities!\n\nUV Index: Moderate\nWind: 5-10 mph", + }), + emailAccount: getEmailAccount(), + }); + + // This is a weather notification that doesn't match any of our business rules + // Should return empty array with no reason + expect(result.rules).toEqual([]); + expect(result.reason).toBe(""); + }); }); }); diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/ProcessResultDisplay.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/ProcessResultDisplay.tsx index 97cdb463e4..8e1b0549fc 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/ProcessResultDisplay.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/ProcessResultDisplay.tsx @@ -58,9 +58,12 @@ export function ProcessResultDisplay({ return result.createdAt.toString(); }); - const sortedBatches = sortBy(Object.entries(groupedResults), ([date]) => { - return -new Date(date).getTime(); // Negative for descending order - }); + const sortedBatches = sortBy( + Object.entries(groupedResults), + ([, batchResults]) => { + return -batchResults[0]?.createdAt.getTime(); // Negative for descending order + }, + ); return (
From d6fe48aa496f5aabab19aaaa9ac835d8a82756e3 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Tue, 21 Oct 2025 15:13:41 +0300 Subject: [PATCH 35/40] fix history executed at --- .../assistant/ExecutedRulesTable.tsx | 11 ++- .../[emailAccountId]/assistant/History.tsx | 36 ++++---- .../web/app/api/user/planned/history/route.ts | 89 +++++++++++++++++-- apps/web/hooks/useExecutedRules.tsx | 14 +++ 4 files changed, 122 insertions(+), 28 deletions(-) create mode 100644 apps/web/hooks/useExecutedRules.tsx diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/ExecutedRulesTable.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/ExecutedRulesTable.tsx index cc6b401b42..946cf23e91 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/ExecutedRulesTable.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/ExecutedRulesTable.tsx @@ -1,6 +1,6 @@ import Link from "next/link"; import { ExternalLinkIcon, EyeIcon } from "lucide-react"; -import type { PlanHistoryResponse } from "@/app/api/user/planned/history/route"; +import type { GetExecutedRulesResponse } from "@/app/api/user/planned/history/route"; import { decodeSnippet } from "@/utils/gmail/decode"; import { ActionBadgeExpanded } from "@/components/PlanBadge"; import { Tooltip } from "@/components/Tooltip"; @@ -18,6 +18,7 @@ import { FixWithChat } from "@/app/(app)/[emailAccountId]/assistant/FixWithChat" import { useAccount } from "@/providers/EmailAccountProvider"; import { isGoogleProvider } from "@/utils/email/provider-types"; import { useRuleDialog } from "@/app/(app)/[emailAccountId]/assistant/RuleDialog"; +import { RunRulesResult } from "@/utils/ai/choose-rule/run-rules"; export function EmailCell({ from, @@ -63,12 +64,14 @@ export function EmailCell({ export function RuleCell({ rule, + executedAt, status, reason, message, setInput, }: { - rule: PlanHistoryResponse["executedRules"][number]["rule"]; + rule: GetExecutedRulesResponse["executedRules"][number]["rule"]; + executedAt: Date; status: ExecutedRuleStatus; reason?: string | null; message: ParsedMessage; @@ -136,7 +139,7 @@ export function RuleCell({
@@ -147,7 +150,7 @@ export function ActionItemsCell({ actionItems, provider, }: { - actionItems: PlanHistoryResponse["executedRules"][number]["actionItems"]; + actionItems: GetExecutedRulesResponse["executedRules"][number]["actionItems"]; provider: string; }) { return ( diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/History.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/History.tsx index f3124576ee..6e5d8d1560 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/History.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/History.tsx @@ -3,7 +3,7 @@ import useSWR from "swr"; import { useQueryState, parseAsInteger, parseAsString } from "nuqs"; import { LoadingContent } from "@/components/LoadingContent"; -import type { PlanHistoryResponse } from "@/app/api/user/planned/history/route"; +import type { GetExecutedRulesResponse } from "@/app/api/user/planned/history/route"; import { AlertBasic } from "@/components/Alert"; import { Card } from "@/components/ui/card"; import { @@ -23,14 +23,13 @@ import { Badge } from "@/components/Badge"; import { RulesSelect } from "@/app/(app)/[emailAccountId]/assistant/RulesSelect"; import { useAccount } from "@/providers/EmailAccountProvider"; import { useChat } from "@/providers/ChatProvider"; +import { useExecutedRules } from "@/hooks/useExecutedRules"; export function History() { const [page] = useQueryState("page", parseAsInteger.withDefault(1)); const [ruleId] = useQueryState("ruleId", parseAsString.withDefault("all")); - const { data, isLoading, error } = useSWR( - `/api/user/planned/history?page=${page}&ruleId=${ruleId}`, - ); + const { data, isLoading, error } = useExecutedRules({ page, ruleId }); return ( <> @@ -62,7 +61,7 @@ function HistoryTable({ data, totalPages, }: { - data: PlanHistoryResponse["executedRules"]; + data: GetExecutedRulesResponse["executedRules"]; totalPages: number; }) { const { userEmail } = useAccount(); @@ -78,19 +77,19 @@ function HistoryTable({ - {data.map((p) => ( - + {data.map((er) => ( + - {!p.automated && ( + {!er.automated && ( Applied manually @@ -98,10 +97,11 @@ function HistoryTable({ {/* */} diff --git a/apps/web/app/api/user/planned/history/route.ts b/apps/web/app/api/user/planned/history/route.ts index aaa2889f9a..9e7f91189c 100644 --- a/apps/web/app/api/user/planned/history/route.ts +++ b/apps/web/app/api/user/planned/history/route.ts @@ -1,11 +1,18 @@ import { NextResponse } from "next/server"; import { withEmailProvider } from "@/utils/middleware"; -import { ExecutedRuleStatus } from "@prisma/client"; -import { getExecutedRules } from "@/app/api/user/planned/get-executed-rules"; +import { isDefined } from "@/utils/types"; +import prisma from "@/utils/prisma"; +import { ExecutedRuleStatus, type Prisma } from "@prisma/client"; +import { createScopedLogger } from "@/utils/logger"; +import type { EmailProvider } from "@/utils/email/types"; + +const LIMIT = 50; export const dynamic = "force-dynamic"; -export type PlanHistoryResponse = Awaited>; +export type GetExecutedRulesResponse = Awaited< + ReturnType +>; export const GET = withEmailProvider(async (request) => { const emailAccountId = request.auth.emailAccountId; @@ -14,13 +21,83 @@ export const GET = withEmailProvider(async (request) => { const page = Number.parseInt(url.searchParams.get("page") || "1"); const ruleId = url.searchParams.get("ruleId") || "all"; - const messages = await getExecutedRules({ - status: ExecutedRuleStatus.APPLIED, + const result = await getExecutedRules({ page, ruleId, emailAccountId, emailProvider: request.emailProvider, }); - return NextResponse.json(messages); + return NextResponse.json(result); }); + +async function getExecutedRules({ + page, + ruleId, + emailAccountId, + emailProvider, +}: { + page: number; + ruleId?: string; + emailAccountId: string; + emailProvider: EmailProvider; +}) { + const logger = createScopedLogger("api/user/planned/history").with({ + emailAccountId, + ruleId, + }); + + const where: Prisma.ExecutedRuleWhereInput = { + emailAccountId, + status: ExecutedRuleStatus.APPLIED, + rule: ruleId === "skipped" ? undefined : { isNot: null }, + ruleId: ruleId === "all" || ruleId === "skipped" ? undefined : ruleId, + }; + + const [executedRules, total] = await Promise.all([ + prisma.executedRule.findMany({ + where, + take: LIMIT, + skip: (page - 1) * LIMIT, + orderBy: { createdAt: "desc" }, + select: { + id: true, + messageId: true, + threadId: true, + rule: { + include: { + group: { select: { name: true } }, + }, + }, + actionItems: true, + status: true, + reason: true, + automated: true, + createdAt: true, + }, + }), + prisma.executedRule.count({ where }), + ]); + + const executedRulesWithMessages = await Promise.all( + executedRules.map(async (p) => { + try { + return { + ...p, + message: await emailProvider.getMessage(p.messageId), + }; + } catch (error) { + logger.error("Error getting message", { + error, + messageId: p.messageId, + threadId: p.threadId, + }); + } + }), + ); + + return { + executedRules: executedRulesWithMessages.filter(isDefined), + totalPages: Math.ceil(total / LIMIT), + }; +} diff --git a/apps/web/hooks/useExecutedRules.tsx b/apps/web/hooks/useExecutedRules.tsx new file mode 100644 index 0000000000..51834d652a --- /dev/null +++ b/apps/web/hooks/useExecutedRules.tsx @@ -0,0 +1,14 @@ +import useSWR from "swr"; +import type { GetExecutedRulesResponse } from "@/app/api/user/planned/history/route"; + +export function useExecutedRules({ + page, + ruleId, +}: { + page: number; + ruleId: string; +}) { + return useSWR( + `/api/user/planned/history?page=${page}&ruleId=${ruleId}`, + ); +} From bb89ecccd252d745c699a555f231c69cf753c3bc Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Tue, 21 Oct 2025 15:20:58 +0300 Subject: [PATCH 36/40] fixes --- .../(app)/[emailAccountId]/assistant/ProcessResultDisplay.tsx | 2 +- apps/web/components/assistant-chat/chat.tsx | 2 +- version.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/ProcessResultDisplay.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/ProcessResultDisplay.tsx index 8e1b0549fc..f6ee562dec 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/ProcessResultDisplay.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/ProcessResultDisplay.tsx @@ -85,7 +85,7 @@ export function ProcessResultDisplay({ } > - {prefix ? prefix : ""} + {prefix ?? ""} {result.rule?.name} diff --git a/apps/web/components/assistant-chat/chat.tsx b/apps/web/components/assistant-chat/chat.tsx index 1e9a2446f6..bf526e9077 100644 --- a/apps/web/components/assistant-chat/chat.tsx +++ b/apps/web/components/assistant-chat/chat.tsx @@ -109,7 +109,7 @@ export function Chat() { { e.preventDefault(); - if ((input.trim() || nextContext) && status === "ready") { + if (input.trim() && status === "ready") { handleSubmit(); setLocalStorageInput(""); } diff --git a/version.txt b/version.txt index 7728c4ba0a..c0436dea50 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -v2.16.11 +v2.17.0 From 9c9a8480648f4ebc126c3818fdec9c1a1d05006a Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Tue, 21 Oct 2025 15:25:18 +0300 Subject: [PATCH 37/40] rename files --- .../assistant/ExecutedRulesTable.tsx | 2 +- .../[emailAccountId]/assistant/History.tsx | 3 +- .../history/route.ts | 2 +- .../api/user/planned/get-executed-rules.ts | 80 ------------------- apps/web/hooks/useExecutedRules.tsx | 4 +- 5 files changed, 5 insertions(+), 86 deletions(-) rename apps/web/app/api/user/{planned => executed-rules}/history/route.ts (97%) delete mode 100644 apps/web/app/api/user/planned/get-executed-rules.ts diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/ExecutedRulesTable.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/ExecutedRulesTable.tsx index 946cf23e91..9bbd2e686f 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/ExecutedRulesTable.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/ExecutedRulesTable.tsx @@ -1,6 +1,6 @@ import Link from "next/link"; import { ExternalLinkIcon, EyeIcon } from "lucide-react"; -import type { GetExecutedRulesResponse } from "@/app/api/user/planned/history/route"; +import type { GetExecutedRulesResponse } from "@/app/api/user/executed-rules/history/route"; import { decodeSnippet } from "@/utils/gmail/decode"; import { ActionBadgeExpanded } from "@/components/PlanBadge"; import { Tooltip } from "@/components/Tooltip"; diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/History.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/History.tsx index 6e5d8d1560..3342290c8d 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/History.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/History.tsx @@ -1,9 +1,8 @@ "use client"; -import useSWR from "swr"; import { useQueryState, parseAsInteger, parseAsString } from "nuqs"; import { LoadingContent } from "@/components/LoadingContent"; -import type { GetExecutedRulesResponse } from "@/app/api/user/planned/history/route"; +import type { GetExecutedRulesResponse } from "@/app/api/user/executed-rules/history/route"; import { AlertBasic } from "@/components/Alert"; import { Card } from "@/components/ui/card"; import { diff --git a/apps/web/app/api/user/planned/history/route.ts b/apps/web/app/api/user/executed-rules/history/route.ts similarity index 97% rename from apps/web/app/api/user/planned/history/route.ts rename to apps/web/app/api/user/executed-rules/history/route.ts index 9e7f91189c..99f5f29a23 100644 --- a/apps/web/app/api/user/planned/history/route.ts +++ b/apps/web/app/api/user/executed-rules/history/route.ts @@ -42,7 +42,7 @@ async function getExecutedRules({ emailAccountId: string; emailProvider: EmailProvider; }) { - const logger = createScopedLogger("api/user/planned/history").with({ + const logger = createScopedLogger("api/user/executed-rules/history").with({ emailAccountId, ruleId, }); diff --git a/apps/web/app/api/user/planned/get-executed-rules.ts b/apps/web/app/api/user/planned/get-executed-rules.ts deleted file mode 100644 index 4aacb34f31..0000000000 --- a/apps/web/app/api/user/planned/get-executed-rules.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { isDefined } from "@/utils/types"; -import prisma from "@/utils/prisma"; -import { ExecutedRuleStatus } from "@prisma/client"; -import { createScopedLogger } from "@/utils/logger"; -import type { EmailProvider } from "@/utils/email/types"; - -const logger = createScopedLogger("api/user/planned/get-executed-rules"); - -const LIMIT = 50; - -export async function getExecutedRules({ - status, - page, - ruleId, - emailAccountId, - emailProvider, -}: { - status: ExecutedRuleStatus; - page: number; - ruleId?: string; - emailAccountId: string; - emailProvider: EmailProvider; -}) { - const where = { - emailAccountId, - status: ruleId === "skipped" ? ExecutedRuleStatus.SKIPPED : status, - rule: ruleId === "skipped" ? undefined : { isNot: null }, - ruleId: ruleId === "all" || ruleId === "skipped" ? undefined : ruleId, - }; - logger.info("getExecutedRules query", { where }); - - const [executedRules, total] = await Promise.all([ - prisma.executedRule.findMany({ - where, - take: LIMIT, - skip: (page - 1) * LIMIT, - orderBy: { createdAt: "desc" }, - select: { - id: true, - messageId: true, - threadId: true, - rule: { - include: { - group: { select: { name: true } }, - }, - }, - actionItems: true, - status: true, - reason: true, - automated: true, - createdAt: true, - }, - }), - prisma.executedRule.count({ where }), - ]); - - const executedRulesWithMessages = await Promise.all( - executedRules.map(async (p) => { - try { - return { - ...p, - message: await emailProvider.getMessage(p.messageId), - }; - } catch (error) { - logger.error("Error getting message", { - error, - messageId: p.messageId, - threadId: p.threadId, - emailAccountId, - ruleId, - }); - } - }), - ); - - return { - executedRules: executedRulesWithMessages.filter(isDefined), - totalPages: Math.ceil(total / LIMIT), - }; -} diff --git a/apps/web/hooks/useExecutedRules.tsx b/apps/web/hooks/useExecutedRules.tsx index 51834d652a..fd575c0e95 100644 --- a/apps/web/hooks/useExecutedRules.tsx +++ b/apps/web/hooks/useExecutedRules.tsx @@ -1,5 +1,5 @@ import useSWR from "swr"; -import type { GetExecutedRulesResponse } from "@/app/api/user/planned/history/route"; +import type { GetExecutedRulesResponse } from "@/app/api/user/executed-rules/history/route"; export function useExecutedRules({ page, @@ -9,6 +9,6 @@ export function useExecutedRules({ ruleId: string; }) { return useSWR( - `/api/user/planned/history?page=${page}&ruleId=${ruleId}`, + `/api/user/executed-rules/history?page=${page}&ruleId=${ruleId}`, ); } From 229ab4b889dcfd9c697367264d3acca537f0594e Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Tue, 21 Oct 2025 15:27:51 +0300 Subject: [PATCH 38/40] rename var --- .../assistant/ExecutedRulesTable.tsx | 1 - .../[emailAccountId]/assistant/FixWithChat.tsx | 4 ++-- apps/web/components/assistant-chat/chat.tsx | 14 +++++++------- apps/web/providers/ChatProvider.tsx | 10 +++++----- 4 files changed, 14 insertions(+), 15 deletions(-) diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/ExecutedRulesTable.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/ExecutedRulesTable.tsx index 9bbd2e686f..e0fc88f0cd 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/ExecutedRulesTable.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/ExecutedRulesTable.tsx @@ -18,7 +18,6 @@ import { FixWithChat } from "@/app/(app)/[emailAccountId]/assistant/FixWithChat" import { useAccount } from "@/providers/EmailAccountProvider"; import { isGoogleProvider } from "@/utils/email/provider-types"; import { useRuleDialog } from "@/app/(app)/[emailAccountId]/assistant/RuleDialog"; -import { RunRulesResult } from "@/utils/ai/choose-rule/run-rules"; export function EmailCell({ from, diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/FixWithChat.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/FixWithChat.tsx index 6e7d655092..9534436d06 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/FixWithChat.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/FixWithChat.tsx @@ -45,7 +45,7 @@ export function FixWithChat({ const [showExplanation, setShowExplanation] = useState(false); const { setOpen } = useSidebar(); - const { setContext: setNextContext } = useChat(); + const { setContext } = useChat(); const selectedRuleName = useMemo(() => { if (!data) return null; @@ -110,7 +110,7 @@ export function FixWithChat({ ? "none" : { name: selectedRuleName || "Unknown" }, }; - setNextContext(context); + setContext(context); setInput(input); setOpen((arr) => [...arr, "chat-sidebar"]); diff --git a/apps/web/components/assistant-chat/chat.tsx b/apps/web/components/assistant-chat/chat.tsx index bf526e9077..1c9545e159 100644 --- a/apps/web/components/assistant-chat/chat.tsx +++ b/apps/web/components/assistant-chat/chat.tsx @@ -33,8 +33,8 @@ export function Chat() { setInput, handleSubmit, setNewChat, - context: nextContext, - setContext: setNextContext, + context, + setContext, } = useChat(); const { messages, status, stop, regenerate, setMessages } = chat; const [localStorageInput, setLocalStorageInput] = useLocalStorage( @@ -90,16 +90,16 @@ export function Chat() { />
- {nextContext ? ( + {context ? (
- Fix: {nextContext.message.headers.subject.slice(0, 60)} - {nextContext.message.headers.subject.length > 60 ? "..." : ""} + Fix: {context.message.headers.subject.slice(0, 60)} + {context.message.headers.subject.length > 60 ? "..." : ""} @@ -130,7 +130,7 @@ export function Chat() { ? "submitted" : "ready" } - disabled={(!input.trim() && !nextContext) || status !== "ready"} + disabled={(!input.trim() && !context) || status !== "ready"} className="absolute bottom-1 right-1" onClick={(e) => { if (status === "streaming") { diff --git a/apps/web/providers/ChatProvider.tsx b/apps/web/providers/ChatProvider.tsx index 3b2b7b9dde..fc4825cb32 100644 --- a/apps/web/providers/ChatProvider.tsx +++ b/apps/web/providers/ChatProvider.tsx @@ -41,7 +41,7 @@ export function ChatProvider({ children }: { children: React.ReactNode }) { const [input, setInput] = useState(""); const [chatId, setChatId] = useQueryState("chatId", parseAsString); - const [nextContext, setNextContext] = useState(null); + const [context, setContext] = useState(null); const { data } = useChatMessages(chatId); @@ -61,7 +61,7 @@ export function ChatProvider({ children }: { children: React.ReactNode }) { body: { id, message: messages.at(-1), - context: nextContext ?? undefined, + context: context ?? undefined, ...body, }, }; @@ -99,7 +99,7 @@ export function ChatProvider({ children }: { children: React.ReactNode }) { }); setInput(""); - }, [chat.sendMessage, input, nextContext]); + }, [chat.sendMessage, input, context]); return ( {children} From 2048c4c492171d642c4bccd4d7da05d0e1e48bf0 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Tue, 21 Oct 2025 15:39:09 +0300 Subject: [PATCH 39/40] Add migration --- apps/web/package.json | 2 - .../migration.sql | 8 ++++ apps/web/providers/ChatProvider.tsx | 9 ++-- pnpm-lock.yaml | 43 ------------------- 4 files changed, 12 insertions(+), 50 deletions(-) create mode 100644 apps/web/prisma/migrations/20251021123040_drop_executed_rule_unique/migration.sql diff --git a/apps/web/package.json b/apps/web/package.json index 88a97b66cd..20025439af 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -122,7 +122,6 @@ "lodash": "4.17.21", "lucide-react": "0.542.0", "motion": "12.23.24", - "nanoid": "5.1.5", "next": "15.5.2", "next-axiom": "1.9.2", "next-safe-action": "7.10.8", @@ -161,7 +160,6 @@ "tailwind-merge": "2.6.0", "tailwindcss-animate": "1.0.7", "tiptap-markdown": "0.8.10", - "tokenlens": "1.3.1", "typescript": "5.9.2", "use-stick-to-bottom": "1.1.1", "usehooks-ts": "3.1.1", diff --git a/apps/web/prisma/migrations/20251021123040_drop_executed_rule_unique/migration.sql b/apps/web/prisma/migrations/20251021123040_drop_executed_rule_unique/migration.sql new file mode 100644 index 0000000000..1710a659fb --- /dev/null +++ b/apps/web/prisma/migrations/20251021123040_drop_executed_rule_unique/migration.sql @@ -0,0 +1,8 @@ +-- DropIndex +DROP INDEX "ExecutedRule_emailAccountId_threadId_messageId_key"; + +-- CreateIndex +CREATE INDEX "ExecutedRule_emailAccountId_threadId_messageId_ruleId_idx" ON "ExecutedRule"("emailAccountId", "threadId", "messageId", "ruleId"); + +-- CreateIndex +CREATE INDEX "ExecutedRule_emailAccountId_messageId_idx" ON "ExecutedRule"("emailAccountId", "messageId"); diff --git a/apps/web/providers/ChatProvider.tsx b/apps/web/providers/ChatProvider.tsx index fc4825cb32..54e2d23f21 100644 --- a/apps/web/providers/ChatProvider.tsx +++ b/apps/web/providers/ChatProvider.tsx @@ -67,8 +67,7 @@ export function ChatProvider({ children }: { children: React.ReactNode }) { }; }, }), - // TODO: couldn't get this to work - // messages: initialMessages, + // messages: initialMessages, // NOTE: couldn't get this to work experimental_throttle: 100, generateId: generateUUID, onFinish: () => { @@ -99,7 +98,7 @@ export function ChatProvider({ children }: { children: React.ReactNode }) { }); setInput(""); - }, [chat.sendMessage, input, context]); + }, [chat.sendMessage, input]); return ( {children} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f189de136b..a22eaacce8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -417,9 +417,6 @@ importers: motion: specifier: 12.23.24 version: 12.23.24(@emotion/is-prop-valid@1.2.2)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) - nanoid: - specifier: 5.1.5 - version: 5.1.5 next: specifier: 15.5.2 version: 15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) @@ -534,9 +531,6 @@ importers: tiptap-markdown: specifier: 0.8.10 version: 0.8.10(@tiptap/core@2.26.1(@tiptap/pm@2.26.1)) - tokenlens: - specifier: 1.3.1 - version: 1.3.1 typescript: specifier: 5.9.2 version: 5.9.2 @@ -4504,18 +4498,6 @@ packages: '@tiptap/core': ^2.7.0 '@tiptap/pm': ^2.7.0 - '@tokenlens/core@1.3.0': - resolution: {integrity: sha512-d8YNHNC+q10bVpi95fELJwJyPVf1HfvBEI18eFQxRSZTdByXrP+f/ZtlhSzkx0Jl0aEmYVeBA5tPeeYRioLViQ==} - - '@tokenlens/fetch@1.3.0': - resolution: {integrity: sha512-RONDRmETYly9xO8XMKblmrZjKSwCva4s5ebJwQNfNlChZoA5kplPoCgnWceHnn1J1iRjLVlrCNB43ichfmGBKQ==} - - '@tokenlens/helpers@1.3.1': - resolution: {integrity: sha512-t6yL8N6ES8337E6eVSeH4hCKnPdWkZRFpupy9w5E66Q9IeqQ9IO7XQ6gh12JKjvWiRHuyyJ8MBP5I549Cr41EQ==} - - '@tokenlens/models@1.3.0': - resolution: {integrity: sha512-9mx7ZGeewW4ndXAiD7AT1bbCk4OpJeortbjHHyNkgap+pMPPn1chY6R5zqe1ggXIUzZ2l8VOAKfPqOvpcrisJw==} - '@tootallnate/quickjs-emscripten@0.23.0': resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} @@ -10587,9 +10569,6 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} - tokenlens@1.3.1: - resolution: {integrity: sha512-7oxmsS5PNCX3z+b+z07hL5vCzlgHKkCGrEQjQmWl5l+v5cUrtL7S1cuST4XThaL1XyjbTX8J5hfP0cjDJRkaLA==} - totalist@3.0.1: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} @@ -15891,21 +15870,6 @@ snapshots: '@tiptap/core': 2.26.1(@tiptap/pm@2.26.1) '@tiptap/pm': 2.26.1 - '@tokenlens/core@1.3.0': {} - - '@tokenlens/fetch@1.3.0': - dependencies: - '@tokenlens/core': 1.3.0 - - '@tokenlens/helpers@1.3.1': - dependencies: - '@tokenlens/core': 1.3.0 - '@tokenlens/fetch': 1.3.0 - - '@tokenlens/models@1.3.0': - dependencies: - '@tokenlens/core': 1.3.0 - '@tootallnate/quickjs-emscripten@0.23.0': {} '@tremor/react@3.18.7(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': @@ -23181,13 +23145,6 @@ snapshots: toidentifier@1.0.1: {} - tokenlens@1.3.1: - dependencies: - '@tokenlens/core': 1.3.0 - '@tokenlens/fetch': 1.3.0 - '@tokenlens/helpers': 1.3.1 - '@tokenlens/models': 1.3.0 - totalist@3.0.1: {} tough-cookie@4.1.4: From 8a648e67752714200646d380c739961c2b673b7d Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Tue, 21 Oct 2025 15:41:56 +0300 Subject: [PATCH 40/40] fix skipped --- apps/web/app/api/user/executed-rules/history/route.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/web/app/api/user/executed-rules/history/route.ts b/apps/web/app/api/user/executed-rules/history/route.ts index 99f5f29a23..ee5ecba4f4 100644 --- a/apps/web/app/api/user/executed-rules/history/route.ts +++ b/apps/web/app/api/user/executed-rules/history/route.ts @@ -49,7 +49,10 @@ async function getExecutedRules({ const where: Prisma.ExecutedRuleWhereInput = { emailAccountId, - status: ExecutedRuleStatus.APPLIED, + status: + ruleId === "skipped" + ? ExecutedRuleStatus.SKIPPED + : ExecutedRuleStatus.APPLIED, rule: ruleId === "skipped" ? undefined : { isNot: null }, ruleId: ruleId === "all" || ruleId === "skipped" ? undefined : ruleId, };